From 62ee462d1cbb454a871771f886b4de883d390d2d Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 14:11:37 +0200 Subject: [PATCH 1/7] Added two-mode support to the last three `generate_*()` functions (closes #150) --- DESCRIPTION | 2 +- NEWS.md | 11 ++ R/make_generate.R | 164 ++++++++++++++++++++++++++-- man/make_stochastic.Rd | 30 ++++- tests/testthat/test-make_generate.R | 30 +++++ 5 files changed, 224 insertions(+), 13 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2d7ed6cc..017b0907 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: manynet Title: Many Ways to Make, Manipulate, and Modify Myriad Networks -Version: 2.3.3 +Version: 2.3.4 Description: Many tools for making, manipulating, and modifying many different types of networks. All functions operate with matrices, edge lists, and 'igraph', 'network', and 'tidygraph' objects, on directed, multiplex, multimodal, signed, and other networks. diff --git a/NEWS.md b/NEWS.md index 3b66cf5f..5dd513de 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,14 @@ +# manynet 2.3.4 + +## Making + +- Fixed `generate_islands()` deriving `p` from a two-mode network with a one-mode dyad count +- Added two-mode support to the last three `generate_*()` functions (closes #150) + - `generate_fire()` burns along two-paths, closing four-cycles instead of triangles + - `generate_islands()` builds a bipartite blockmodel with a planted diagonal + - `generate_citations()` keeps its recency mechanism, but the target crosses the mode divide, + and both modes grow so that the concentration turns over + # manynet 2.3.3 ## Package diff --git a/R/make_generate.R b/R/make_generate.R index 95a33356..7f699d20 100644 --- a/R/make_generate.R +++ b/R/make_generate.R @@ -378,10 +378,24 @@ generate_scalefree <- function(n, p = 1, directed = FALSE) { #' nodes in the network. #' By default 1. #' See `igraph::sample_forestfire()`. +#' @details +#' In a one-mode network, each burn step is a single hop, so each tie the +#' fire creates closes a triangle. +#' A tie in a two-mode network crosses modes, so the shortest closure there +#' is the four-cycle rather than the triangle. +#' In a two-mode network each burn step is therefore a two-path hop across +#' the other mode, and each tie the fire creates closes a four-cycle. +#' Both modes grow over the course of the simulation. #' @param their_out Probability of tieing to a contact's outgoing ties. #' By default 0. +#' In a two-mode network, this is instead the probability of burning across +#' each two-path, and so of closing a four-cycle. #' @param their_in Probability of tieing to a contact's incoming ties. #' By default 1. +#' This is a factor on `their_out` rather than a probability in its own +#' right, so `their_out = 0` gives a tree whatever `their_in` is set to. +#' In a two-mode network, `their_out * their_in` is instead the probability +#' that a newly burned node re-ignites and spreads the fire further. #' @importFrom igraph sample_forestfire #' @references #' ## On the forest-fire model @@ -391,12 +405,13 @@ generate_scalefree <- function(n, p = 1, directed = FALSE) { #' \doi{10.1145/1217299.1217301} #' @examples #' generate_fire(10) +#' generate_fire(c(10, 6)) #' @export generate_fire <- function(n, contacts = 1, their_out = 0, their_in = 1, directed = FALSE){ directed <- infer_directed(n, directed) n <- infer_n(n) if(length(n)==2){ - snet_abort("There is currently no forest fire model implemented for two-mode networks.") + out <- .fire_twomode(n, contacts, their_out, their_in) } else { out <- igraph::sample_forestfire(n, fw.prob = their_out, bw.factor = their_in, @@ -409,26 +424,38 @@ generate_fire <- function(n, contacts = 1, their_out = 0, their_in = 1, directed #' @param islands Number of islands or communities to create. #' By default 2. #' See `igraph::sample_islands()` for more. +#' In a two-mode network, each mode is cut into this many blocks, +#' and a node of each mode that share a block are tied with probability `p`. #' @param bridges Number of bridges between islands/communities. #' By default 1. #' @importFrom igraph sample_islands #' @examples #' generate_islands(10) +#' generate_islands(c(10, 6)) #' @export generate_islands <- function(n, islands = 2, p = 0.5, bridges = 1, directed = FALSE){ directed <- infer_directed(n, directed) if(is_manynet(n)){ - m <- net_nodes(n) extra_ties <- ifelse(islands > 2, islands * bridges, bridges) aimed_ties <- net_ties(n) - extra_ties - m <- mean(c(table(cut(seq.int(m), islands, labels = FALSE)))) - p <- (aimed_ties/islands) / ifelse(directed, m*(m-1), (m*(m-1))/2) + if(is_twomode(n)){ + # a two-mode island has m1 * m2 possible ties, not m * (m-1) / 2 + dims <- infer_dims(n) + m1 <- mean(c(table(cut(seq.int(dims[1]), islands, labels = FALSE)))) + m2 <- mean(c(table(cut(seq.int(dims[2]), islands, labels = FALSE)))) + p <- (aimed_ties/islands) / (m1*m2) + } else { + m <- net_nodes(n) + m <- mean(c(table(cut(seq.int(m), islands, labels = FALSE)))) + p <- (aimed_ties/islands) / ifelse(directed, m*(m-1), (m*(m-1))/2) + } if(p > 1) p <- 1 + if(p < 0) p <- 0 } n <- infer_n(n) if(length(n)==2){ - snet_abort("There is currently no island model implemented for two-mode networks.") + out <- .islands_twomode(n, islands, p, bridges) } else { out <- igraph::sample_islands(islands.n = islands, islands.size = ceiling(n/islands), @@ -487,6 +514,8 @@ generate_islands <- function(n, islands = 2, p = 0.5, bridges = 1, #' @rdname make_stochastic #' @param ties Number of ties to add per new node. #' By default a uniform random sample from 1 to 4 new ties. +#' In a two-mode network, each new node of the first mode ties to this many +#' nodes of the second mode, chosen by how recently each was last tied to. #' @param agebins Number of aging bins. #' By default either \eqn{\frac{n}{10}} or 1, #' whichever is the larger. @@ -494,15 +523,134 @@ generate_islands <- function(n, islands = 2, p = 0.5, bridges = 1, #' @importFrom igraph sample_last_cit #' @examples #' generate_citations(10) +#' generate_citations(c(10, 6)) #' @export generate_citations <- function(n, ties = sample(1:4,1), agebins = max(1, n/10), directed = FALSE){ directed <- infer_directed(n, directed) n <- infer_n(n) + stopifnot(is.scalar(ties)) if(length(n)>1){ - snet_abort("There is currently no citation model implemented for two-mode networks.") + out <- .citations_twomode(n, ties, agebins) + } else { + out <- igraph::sample_last_cit(n, edges = ties, agebins = agebins, + directed = directed) } - stopifnot(is.scalar(ties)) - out <- igraph::sample_last_cit(n, edges = ties, agebins = agebins, directed = directed) as_tidygraph(out) } + +# Two-mode helpers #### + +# Returns the arrival order of nodes as a vector of mode indices, +# with exactly `n[1]` entries of 1 and `n[2]` entries of 2, +# interleaved in the ratio n[1]:n[2] so that both modes grow together. +.interleave_modes <- function(n) { + t1 <- (seq_len(n[1]) - 0.5) / n[1] + t2 <- (seq_len(n[2]) - 0.5) / n[2] + c(rep(1L, n[1]), rep(2L, n[2]))[order(c(t1, t2))] +} + +# A two-mode forest fire. +# In one mode a burn step is a single hop, so each new tie closes a triangle. +# A tie in a two-mode network crosses modes, so the shortest closure is the +# four-cycle. One burn step is therefore a 2-path hop across the other mode: +# from a burned node `e`, to a partner `a` of `e`, to another node `f` of `a`. +# Tieing the new node `v` to `f` closes the four-cycle v-e-a-f-v. +# As in one mode, `their_out` is the burn probability and `their_in` is a +# factor on it, so the defaults give the same minimal fire in both cases. +# `their_out` is the probability of burning across each two-path, +# and so of closing a four-cycle. +# `their_out * their_in` is the probability that a newly burned node +# re-ignites, which spreads the fire beyond the immediate closure. +.fire_twomode <- function(n, contacts, their_out, their_in) { + g <- matrix(0, n[1], n[2]) + arrivals <- .interleave_modes(n) + act <- c(0, 0) + for (k in seq_along(arrivals)) { + m <- arrivals[k] + act[m] <- act[m] + 1 + v <- act[m] + if (act[1] == 0 || act[2] == 0) next + sub <- g[seq_len(act[1]), seq_len(act[2]), drop = FALSE] + if (sum(sub) == 0) { # seed the first tie + if (m == 1) g[v, 1] <- 1 else g[1, v] <- 1 + next + } + # ambassadors are drawn from the opposite mode, among those already tied + cand <- if (m == 1) which(colSums(sub) > 0) else which(rowSums(sub) > 0) + burnt <- cand[sample.int(length(cand), min(contacts, length(cand)))] + queue <- burnt + while (length(queue) > 0) { + e <- queue[1] + queue <- queue[-1] + # the partners of `e`, which are in the same mode as `v` + mem <- if (m == 1) which(sub[, e] > 0) else which(sub[e, ] > 0) + if (length(mem) == 0) next + # the nodes those partners reach, which are in the opposite mode to `v` + reach <- if (m == 1) which(colSums(sub[mem, , drop = FALSE]) > 0) else + which(rowSums(sub[, mem, drop = FALSE]) > 0) + reach <- setdiff(reach, burnt) + if (length(reach) == 0) next + lit <- reach[stats::runif(length(reach)) < their_out] + if (length(lit) == 0) next + burnt <- c(burnt, lit) + queue <- c(queue, lit[stats::runif(length(lit)) < their_out * their_in]) + } + if (m == 1) g[v, burnt] <- 1 else g[burnt, v] <- 1 + } + as_igraph(g, twomode = TRUE) +} + +# A two-mode islands model, that is, a bipartite blockmodel with a planted +# diagonal. Each mode is cut into `islands` blocks. A node of the first mode +# and a node of the second mode that share a block are tied with probability +# `p`. Each pair of blocks is then joined by `bridges` further ties. +.islands_twomode <- function(n, islands, p, bridges) { + b1 <- cut(seq_len(n[1]), islands, labels = FALSE) + b2 <- cut(seq_len(n[2]), islands, labels = FALSE) + g <- matrix(0, n[1], n[2]) + same <- outer(b1, b2, "==") + g[same] <- stats::rbinom(sum(same), 1, p) + if (bridges > 0 && islands > 1) { + for (i in seq_len(islands - 1)) for (j in seq(i + 1, islands)) { + cells <- which(outer(b1 == i, b2 == j, "&") | + outer(b1 == j, b2 == i, "&")) + if (length(cells) == 0) next + g[cells[sample.int(length(cells), min(bridges, length(cells)))]] <- 1 + } + } + as_igraph(g, twomode = TRUE) +} + +# A two-mode citation model. +# `igraph::sample_last_cit()` is a recency model: a new node cites old nodes +# with a probability that depends on how long ago each was last cited. +# Here the mechanism is kept but the target crosses the mode divide. +# A new node of the first mode ties to `ties` nodes of the second mode, +# each chosen by how recently that node was last tied to. +# Because both modes grow, new second-mode nodes keep entering in the freshest +# bin, so the concentration of ties turns over instead of locking in. +.citations_twomode <- function(n, ties, agebins) { + agebins <- max(1, round(agebins)) + pref <- seq_len(agebins + 1)^-3 + g <- matrix(0, n[1], n[2]) + arrivals <- .interleave_modes(n) + last_used <- rep(NA_integer_, n[2]) + act <- c(0, 0) + for (k in seq_along(arrivals)) { + m <- arrivals[k] + act[m] <- act[m] + 1 + if (m == 2) { + last_used[act[2]] <- k # a new node enters in the freshest bin + next + } + if (act[2] == 0) next + cols <- seq_len(act[2]) + prob <- pref[pmin(k - last_used[cols], agebins) + 1] + chosen <- cols[sample.int(length(cols), min(ties, length(cols)), + prob = prob)] + g[act[1], chosen] <- 1 + last_used[chosen] <- k + } + as_igraph(g, twomode = TRUE) +} diff --git a/man/make_stochastic.Rd b/man/make_stochastic.Rd index 5cac63a3..f957a8e0 100644 --- a/man/make_stochastic.Rd +++ b/man/make_stochastic.Rd @@ -48,20 +48,30 @@ By default 1. See \code{igraph::sample_forestfire()}.} \item{their_out}{Probability of tieing to a contact's outgoing ties. -By default 0.} +By default 0. +In a two-mode network, this is instead the probability of burning across +each two-path, and so of closing a four-cycle.} \item{their_in}{Probability of tieing to a contact's incoming ties. -By default 1.} +By default 1. +This is a factor on \code{their_out} rather than a probability in its own +right, so \code{their_out = 0} gives a tree whatever \code{their_in} is set to. +In a two-mode network, \code{their_out * their_in} is instead the probability +that a newly burned node re-ignites and spreads the fire further.} \item{islands}{Number of islands or communities to create. By default 2. -See \code{igraph::sample_islands()} for more.} +See \code{igraph::sample_islands()} for more. +In a two-mode network, each mode is cut into this many blocks, +and a node of each mode that share a block are tied with probability \code{p}.} \item{bridges}{Number of bridges between islands/communities. By default 1.} \item{ties}{Number of ties to add per new node. -By default a uniform random sample from 1 to 4 new ties.} +By default a uniform random sample from 1 to 4 new ties. +In a two-mode network, each new node of the first mode ties to this many +nodes of the second mode, chosen by how recently each was last tied to.} \item{agebins}{Number of aging bins. By default either \eqn{\frac{n}{10}} or 1, @@ -103,14 +113,26 @@ and the second integer indicates the number of nodes in the second mode. As an alternative, an existing network can be provided to \code{n} and the number of modes, nodes, and directedness will be inferred. } +\details{ +In a one-mode network, each burn step is a single hop, so each tie the +fire creates closes a triangle. +A tie in a two-mode network crosses modes, so the shortest closure there +is the four-cycle rather than the triangle. +In a two-mode network each burn step is therefore a two-path hop across +the other mode, and each tie the fire creates closes a four-cycle. +Both modes grow over the course of the simulation. +} \examples{ generate_smallworld(12, 0.025) generate_smallworld(12, 0.25) generate_scalefree(12, 0.25) generate_scalefree(12, 1.25) generate_fire(10) +generate_fire(c(10, 6)) generate_islands(10) +generate_islands(c(10, 6)) generate_citations(10) +generate_citations(c(10, 6)) } \references{ \subsection{On small-world networks}{ diff --git a/tests/testthat/test-make_generate.R b/tests/testthat/test-make_generate.R index 9b379a79..623c490d 100644 --- a/tests/testthat/test-make_generate.R +++ b/tests/testthat/test-make_generate.R @@ -45,14 +45,44 @@ test_that("generate_man works without a dyad census", { test_that("generate_fire works", { expect_s3_class(generate_fire(ison_adolescents), "igraph") + fire <- generate_fire(c(20, 10)) + expect_true(is_twomode(fire)) + expect_equal(as.numeric(net_nodes(fire)), 30) + expect_true(is_twomode(generate_fire(ison_southern_women))) + # `their_out` is the burn probability, so raising it spreads the fire + expect_gt(mean(replicate(10, net_ties(generate_fire(c(20, 10), + their_out = 0.5)))), + mean(replicate(10, net_ties(generate_fire(c(20, 10)))))) }) test_that("generate_islands works", { expect_s3_class(generate_islands(ison_adolescents), "igraph") + isles <- generate_islands(c(40, 20), islands = 4) + expect_true(is_twomode(isles)) + expect_equal(as.numeric(net_nodes(isles)), 60) + # the diagonal blocks must be much denser than the off-diagonal blocks + mat <- as_matrix(isles) + same <- outer(cut(seq_len(40), 4, labels = FALSE), + cut(seq_len(20), 4, labels = FALSE), "==") + expect_gt(mean(mat[same]), mean(mat[!same]) + 0.2) + expect_true(is_twomode(generate_islands(ison_southern_women))) }) test_that("generate_citations works", { expect_s3_class(generate_citations(ison_adolescents), "igraph") + cites <- generate_citations(c(20, 10)) + expect_true(is_twomode(cites)) + expect_equal(as.numeric(net_nodes(cites)), 30) + expect_true(is_twomode(generate_citations(ison_southern_women))) + # recency concentrates ties on some second-mode nodes more than chance does + gini <- function(x) { + x <- sort(x) + sum((2 * seq_along(x) - length(x) - 1) * x) / (length(x) * sum(x)) + } + expect_gt(mean(replicate(10, gini(colSums( + as_matrix(generate_citations(c(200, 40), ties = 2)))))), + mean(replicate(10, gini(colSums( + matrix(stats::rbinom(200 * 40, 1, 2/40), 200, 40)))))) }) test_that("generate_configuration reads the modes of a stocnet", { From 74ec66754010223df6bbd19778e748c5362674f8 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 14:45:51 +0200 Subject: [PATCH 2/7] Added `to_positive()` to shortcut keeping just the positive ties of a signed network (closes #176) --- NAMESPACE | 1 + NEWS.md | 8 ++++++ R/mark_format.R | 8 ++++-- R/modif_weight.R | 36 +++++++++++++++++------ man/modif_weight.Rd | 9 ++++++ tests/testthat/test-functional_to.R | 1 + tests/testthat/test-manip_format.R | 44 +++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 1182301e..b443dc33 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1031,6 +1031,7 @@ export(to_normalised) export(to_normalized) export(to_onemode) export(to_permuted) +export(to_positive) export(to_proximity) export(to_reciprocated) export(to_redirected) diff --git a/NEWS.md b/NEWS.md index 5dd513de..fc0d5a12 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,14 @@ - `generate_citations()` keeps its recency mechanism, but the target crosses the mode divide, and both modes grow so that the concentration turns over +## Modifying + +- Added `to_positive()` to shortcut keeping just the positive ties of a signed network (closes #176) + +## Marking + +- Fixed `is_signed()` to check for a 'sign' column or a negative 'weight' column + # manynet 2.3.3 ## Package diff --git a/R/mark_format.R b/R/mark_format.R index 35395cd1..03648c7d 100644 --- a/R/mark_format.R +++ b/R/mark_format.R @@ -433,8 +433,12 @@ is_signed.default <- function(.data) { #' @export is_signed.data.frame <- function(.data) { - if(ncol(.data) <= 2) FALSE else - any(.data[,3] < 0) + # signs are held either in a 'sign' column or as negative weights, which is + # what `to_unsigned.data.frame()` reads. A third column is not a sign just + # because it sits in the third position. + # a tibble warns where `$` names a column it does not have, so `[[` is used + if(!is.null(.data[["sign"]])) return(TRUE) + !is.null(.data[["weight"]]) && any(.data[["weight"]] < 0, na.rm = TRUE) } #' @export diff --git a/R/modif_weight.R b/R/modif_weight.R index 59171c9c..57e50718 100644 --- a/R/modif_weight.R +++ b/R/modif_weight.R @@ -8,6 +8,11 @@ #' - `to_unsigned()` reformats signed network data to unsigned network data, #' keeping just the "positive" or the "negative" ties, or "both", #' which keeps every tie but replaces its sign with its magnitude. +#' - `to_positive()` keeps just the positive ties of a signed network, +#' and returns an unsigned network unaltered. +#' It is a guard for the functions that cannot read a negative tie, +#' such as the path-based measures, for which a negative tie is hostility +#' rather than a channel. #' - `to_normalised()` rescales tie weights relative to the other ties of the #' same node, so that a value reads as a share rather than a count. #' @@ -93,13 +98,9 @@ to_unsigned.data.frame <- function(.data, to_unsigned.tbl_graph <- function(.data, keep = c("positive", "negative", "both")){ keep <- match.arg(keep) - out <- to_unsigned(as_igraph(.data), keep = keep) - dropped <- switch(keep, positive = "negative ties", - negative = "positive ties", both = "no ties") - # 'both' excludes no tie, so this records nothing. Taking the magnitude of a - # weight is not an exclusion, and none of the transformation items names it, - # so it goes unrecorded until one does. - as_tidygraph(out) |> .record_exclusion(.data, dropped, "ties") + # the igraph method records the exclusion, and the record survives coercion, + # so nothing is recorded a second time here + as_tidygraph(to_unsigned(as_igraph(.data), keep = keep)) } #' @export @@ -148,7 +149,15 @@ to_unsigned.igraph <- function(.data, igraph::delete_edge_attr(out, "weight") else igraph::set_edge_attr(out, "weight", value = wts) } - out + dropped <- switch(keep, positive = "negative ties", + negative = "positive ties", both = "no ties") + # 'both' excludes no tie, so this records nothing. Taking the magnitude of a + # weight is not an exclusion, and none of the transformation items names it, + # so it goes unrecorded until one does. + out <- .record_exclusion(out, .data, dropped, "ties") + # `add_info()` returns a tbl_graph, so the class is restored here. + # These functions return the class they are given. + as_igraph(out) } else .data } @@ -159,6 +168,17 @@ to_unsigned.network <- function(.data, as_network(to_unsigned(as_igraph(.data), keep = keep)) } +#' @rdname modif_weight +#' @examples +#' to_positive(marvel) +#' @export +to_positive <- function(.data){ + # A guard rather than a new operation, so it neither warns nor reports. + # `to_unsigned()` records the exclusion, which is where a user reads off + # which ties a measure ran over. + if(is_signed(.data)) to_unsigned(.data, keep = "positive") else .data +} + #' @rdname modif_weight #' @importFrom dplyr filter select #' @export diff --git a/man/modif_weight.Rd b/man/modif_weight.Rd index 27fe601e..5297684f 100644 --- a/man/modif_weight.Rd +++ b/man/modif_weight.Rd @@ -3,6 +3,7 @@ \name{modif_weight} \alias{modif_weight} \alias{to_unsigned} +\alias{to_positive} \alias{to_unweighted} \alias{to_signed} \alias{to_weighted} @@ -12,6 +13,8 @@ \usage{ to_unsigned(.data, keep = c("positive", "negative", "both")) +to_positive(.data) + to_unweighted(.data, threshold = 1) to_signed(.data, mark = NULL) @@ -98,6 +101,11 @@ data, with all tie weights removed. \item \code{to_unsigned()} reformats signed network data to unsigned network data, keeping just the "positive" or the "negative" ties, or "both", which keeps every tie but replaces its sign with its magnitude. +\item \code{to_positive()} keeps just the positive ties of a signed network, +and returns an unsigned network unaltered. +It is a guard for the functions that cannot read a negative tie, +such as the path-based measures, for which a negative tie is hostility +rather than a channel. \item \code{to_normalised()} rescales tie weights relative to the other ties of the same node, so that a value reads as a share rather than a count. } @@ -136,6 +144,7 @@ split into two, and the network is returned directed. marvel <- to_uniplex(fict_marvel, "relationship") to_unsigned(marvel, "positive") to_unsigned(marvel, "both") +to_positive(marvel) to_normalised(ison_networkers, rule = "sum", across = "rows") } \seealso{ diff --git a/tests/testthat/test-functional_to.R b/tests/testthat/test-functional_to.R index 0f06d8a5..573ad1aa 100644 --- a/tests/testthat/test-functional_to.R +++ b/tests/testthat/test-functional_to.R @@ -28,6 +28,7 @@ to_invariants <- list( to_weighted = function(o) is_weighted(o), to_signed = function(o) is_signed(o), to_unsigned = function(o) !is_signed(o), + to_positive = function(o) !is_signed(o), to_named = function(o) is_labelled(o), to_labelled = function(o) is_labelled(o), to_unnamed = function(o) !is_labelled(o), diff --git a/tests/testthat/test-manip_format.R b/tests/testthat/test-manip_format.R index c782df7e..f9987a8f 100644 --- a/tests/testthat/test-manip_format.R +++ b/tests/testthat/test-manip_format.R @@ -132,6 +132,50 @@ test_that("to_unsigned.network keeps the sign it is asked for", { expect_equal(as.numeric(net_ties(to_unsigned(n, "negative"))), 4) }) +test_that("to_unsigned records the exclusion for every class that holds info", { + ring <- to_signed(create_ring(8), mark = rep(c(TRUE, FALSE), 4)) + for(x in list(ring, as_igraph(ring), as_tidygraph(ring), as_network(ring))){ + entry <- as_infolist(to_unsigned(x, "positive"))$transformations$exclusion + expect_true(any(grepl("negative ties", entry))) + # the record is written once, not once for each class it passes through + expect_equal(sum(grepl("negative ties", entry)), 1) + } + # 'both' excludes no tie, so it records nothing + expect_false(any(grepl("ties", + as_infolist(to_unsigned(ring, "both"))$transformations$exclusion))) +}) + +test_that("to_positive keeps the positive ties and leaves the rest alone", { + ring <- to_signed(create_ring(8), mark = rep(c(TRUE, FALSE), 4)) + for(x in list(ring, as_igraph(ring), as_tidygraph(ring), as_network(ring))){ + out <- to_positive(x) + expect_false(is_signed(out)) + expect_equal(class(out), class(x)) + expect_equal(as.numeric(net_ties(out)), 4) + expect_true(any(grepl("negative ties", + as_infolist(out)$transformations$exclusion))) + } + # a guard is a no-op on an unsigned network + unsigned <- to_unsigned(ring, "positive") + expect_identical(to_positive(unsigned), unsigned) + expect_identical(to_positive(ison_southern_women), ison_southern_women) + # an edgelist keeps its rows, not its sign column + el <- data.frame(from = c("a","b","c"), to = c("b","c","a"), + sign = c(1,-1,1)) + expect_equal(nrow(to_positive(el)), 2) + expect_false("sign" %in% names(to_positive(el))) +}) + +test_that("is_signed.data.frame reads a sign column or a negative weight", { + expect_true(is_signed(data.frame(from = 1:2, to = 2:3, sign = c(1,-1)))) + expect_true(is_signed(data.frame(from = 1:2, to = 2:3, weight = c(1,-1)))) + expect_false(is_signed(data.frame(from = 1:2, to = 2:3, weight = c(1,2)))) + # a third column is not a sign just because it sits in the third position + expect_false(is_signed(data.frame(from = 1:2, to = 2:3, + year = c(-1990, 2000)))) + expect_false(is_signed(data.frame(from = 1:2, to = 2:3))) +}) + test_that("to_named relabels an unlabelled network, or with names given", { expect_true(is_labelled(to_named(to_unnamed(ison_southern_women)))) expect_true(is_labelled(to_named(ison_southern_women, From 877892f5cc437ab076cc3793a112ca48e40725b3 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 15:21:37 +0200 Subject: [PATCH 3/7] Fixed generate_islands to grow bridges as `choose(islands, 2)` and not as `islands` --- NEWS.md | 4 +++- R/make_generate.R | 5 ++++- tests/testthat/test-make_generate.R | 13 +++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index fc0d5a12..f98cf747 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,7 +2,9 @@ ## Making -- Fixed `generate_islands()` deriving `p` from a two-mode network with a one-mode dyad count +- Fixed a couple of `generate_islands()` errors + - Fixed to grow bridges as `choose(islands, 2)` and not as `islands` + - Fixed deriving `p` from a two-mode network with a one-mode dyad count - Added two-mode support to the last three `generate_*()` functions (closes #150) - `generate_fire()` burns along two-paths, closing four-cycles instead of triangles - `generate_islands()` builds a bipartite blockmodel with a planted diagonal diff --git a/R/make_generate.R b/R/make_generate.R index 7f699d20..9ceb9b6b 100644 --- a/R/make_generate.R +++ b/R/make_generate.R @@ -437,7 +437,10 @@ generate_islands <- function(n, islands = 2, p = 0.5, bridges = 1, directed = FALSE){ directed <- infer_directed(n, directed) if(is_manynet(n)){ - extra_ties <- ifelse(islands > 2, islands * bridges, bridges) + # both `igraph::sample_islands()` and `.islands_twomode()` add `bridges` + # ties for each pair of islands, so the count of the pairs is what the + # aimed tie count subtracts + extra_ties <- choose(islands, 2) * bridges aimed_ties <- net_ties(n) - extra_ties if(is_twomode(n)){ # a two-mode island has m1 * m2 possible ties, not m * (m-1) / 2 diff --git a/tests/testthat/test-make_generate.R b/tests/testthat/test-make_generate.R index 623c490d..a5283534 100644 --- a/tests/testthat/test-make_generate.R +++ b/tests/testthat/test-make_generate.R @@ -68,6 +68,19 @@ test_that("generate_islands works", { expect_true(is_twomode(generate_islands(ison_southern_women))) }) +test_that("generate_islands adds a bridge for each pair of islands", { + # both branches tie each pair of islands, so the count of bridge ties grows + # as `choose(islands, 2)`, not as `islands`. The `p` inference subtracts it. + for(k in c(2, 3, 4, 6)){ + onemode <- igraph::sample_islands(islands.n = k, islands.size = 10, + islands.pin = 0, n.inter = 1) + expect_equal(igraph::ecount(onemode), choose(k, 2)) + twomode <- generate_islands(c(10 * k, 10 * k), islands = k, p = 0, + bridges = 1) + expect_equal(as.numeric(net_ties(twomode)), choose(k, 2)) + } +}) + test_that("generate_citations works", { expect_s3_class(generate_citations(ison_adolescents), "igraph") cites <- generate_citations(c(20, 10)) From 3863445b8153c4348037af1048abaabd672edfb2 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 15:49:25 +0200 Subject: [PATCH 4/7] Improved `to_time()` to scope globals as it already scoped ties, changes, and missings --- NEWS.md | 4 ++++ R/modif_scope.R | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index f98cf747..89b451d7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,6 +11,10 @@ - `generate_citations()` keeps its recency mechanism, but the target crosses the mode divide, and both modes grow so that the concentration turns over +## Manipulating + +- Improved `to_time()` to scope globals as it already scoped ties, changes, and missings + ## Modifying - Added `to_positive()` to shortcut keeping just the positive ties of a signed network (closes #176) diff --git a/R/modif_scope.R b/R/modif_scope.R index c81cb405..aa45dfe9 100644 --- a/R/modif_scope.R +++ b/R/modif_scope.R @@ -200,7 +200,9 @@ to_wave <- to_time snet_abort(paste("Please supply a {.arg time} to scope to,", "or use {.fn to_times} for one network per moment.")) rule <- .time_rule(.data) - if(rule == "none") return(.data) + # The globals are scoped whatever the ties do, since a global variable + # records its own moments and does not read them off the ties. + if(rule == "none") return(.scope_globals(.data, time)) out <- .apply_changes_at(.data, time) at <- .clamp_time(.data, time, rule) out <- switch(rule, @@ -209,11 +211,31 @@ to_wave <- to_time replace = .stamped_at(out, at)) # the nodes and the ties are dropped by two different criteria, so each # is recorded on its own rather than summed into one figure + out <- .scope_globals(out, time) out |> .record_exclusion(.data, paste("not present at time", time), "nodes") |> .record_exclusion(.data, paste("not tied at time", time), "ties") } +# The globals may record a value for each moment, the same way the ties record +# the moments they were observed at, so a network scoped to one moment carries +# that moment's globals alone. A globals table with no 'time' column holds a +# constant, which every moment shares, so it is left as it is. +.scope_globals <- function(out, time){ + globals <- as_globallist(out) + if(is.null(globals) || !"time" %in% names(globals)) return(out) + globals <- globals[globals$time == time, , drop = FALSE] + # A component that holds nothing is NULL rather than an empty table, as it + # is everywhere else a network's components are built. + globals <- if(!nrow(globals)) NULL else { + globals$time <- NULL + globals + } + if(inherits(out, "stocnet")) out$globals <- globals else + igraph::graph_attr(out, "globals") <- globals + out +} + # The nodes as they stood at a moment: the changes recorded up to then applied, # the changelist dropped, and the nodes that are not in the network at that # moment taken out with the 'active' column that said so. From 1d50595f1960530bcfca37b8c5ddb69700757625 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 15:50:03 +0200 Subject: [PATCH 5/7] Fixed `bind_changes.igraph()` replacing the changelog instead of appending --- NEWS.md | 1 + R/manip_changes.R | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 89b451d7..a9dadb7e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,6 +13,7 @@ ## Manipulating +- Fixed `bind_changes.igraph()` replacing the changelog instead of appending - Improved `to_time()` to scope globals as it already scoped ties, changes, and missings ## Modifying diff --git a/R/manip_changes.R b/R/manip_changes.R index 10499f3a..3ce1377a 100644 --- a/R/manip_changes.R +++ b/R/manip_changes.R @@ -99,7 +99,10 @@ bind_changes.tbl_graph <- function(.data, changes, var, ...){ #' @export bind_changes.igraph <- function(.data, changes, var, ...){ out <- .data - if(length(names(changes)) == 4){ + # Only a changelog of the standard four columns is bound onto an existing + # changelog. The composition branch below builds its own from scratch. + append <- length(names(changes)) == 4 + if(append){ if("active" %in% changes[,3] && !("active" %in% net_node_attributes(.data))){ out <- .infer_active(out, changes) @@ -144,6 +147,16 @@ bind_changes.igraph <- function(.data, changes, var, ...){ dplyr::filter(wave != 1) } + # Bound onto any existing changelog, as `bind_changes.stocnet()` does, so + # that a second call adds to the changelog rather than replacing it. + # `.align_change_values()` reconciles a 'value' column of a differing type. + old <- igraph::graph_attr(out)$changes + if(append && !is.null(old) && setequal(names(old), names(changes))){ + binding <- .align_change_values(dplyr::as_tibble(old), + dplyr::as_tibble(changes)) + changes <- dplyr::bind_rows(binding$old, binding$new) |> + dplyr::arrange(time, node) + } igraph::graph_attr(out)$changes <- changes out } From 4fb2f9d0a125bc499da704d7c9937d69003ceb7d Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 15:50:17 +0200 Subject: [PATCH 6/7] Added remaining row-wise verbs for the globals component (closes #149) --- NAMESPACE | 16 ++++ NEWS.md | 6 ++ R/manip_globals.R | 117 +++++++++++++++++++++++- man/manip_globals.Rd | 31 ++++++- tests/testthat/test-functional_manips.R | 71 ++++++++++++++ 5 files changed, 239 insertions(+), 2 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index b443dc33..d9cf12a0 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -25,6 +25,9 @@ S3method(add_ties,tbl_graph) S3method(arrange_changes,default) S3method(arrange_changes,igraph) S3method(arrange_changes,stocnet) +S3method(arrange_globals,data.frame) +S3method(arrange_globals,default) +S3method(arrange_globals,stocnet) S3method(arrange_nodes,default) S3method(arrange_nodes,stocnet) S3method(arrange_nodes,tbl_graph) @@ -133,6 +136,9 @@ S3method(bind_changes,default) S3method(bind_changes,igraph) S3method(bind_changes,stocnet) S3method(bind_changes,tbl_graph) +S3method(bind_globals,data.frame) +S3method(bind_globals,default) +S3method(bind_globals,stocnet) S3method(bind_nodes,default) S3method(bind_nodes,stocnet) S3method(bind_ties,default) @@ -141,6 +147,9 @@ S3method(bind_ties,tbl_graph) S3method(delete_changes,default) S3method(delete_changes,igraph) S3method(delete_changes,stocnet) +S3method(delete_globals,default) +S3method(delete_globals,igraph) +S3method(delete_globals,stocnet) S3method(delete_incomplete,default) S3method(delete_incomplete,stocnet) S3method(delete_incomplete,tbl_graph) @@ -171,6 +180,9 @@ S3method(delete_ties,tbl_graph) S3method(filter_changes,default) S3method(filter_changes,igraph) S3method(filter_changes,stocnet) +S3method(filter_globals,data.frame) +S3method(filter_globals,default) +S3method(filter_globals,stocnet) S3method(filter_nodes,default) S3method(filter_nodes,stocnet) S3method(filter_nodes,tbl_graph) @@ -654,6 +666,7 @@ export(add_tie_attribute) export(add_ties) export(apply_changes) export(arrange_changes) +export(arrange_globals) export(arrange_nodes) export(arrange_ties) export(as_changelist) @@ -672,6 +685,7 @@ export(as_siena) export(as_stocnet) export(as_tidygraph) export(bind_changes) +export(bind_globals) export(bind_node_attributes) export(bind_nodes) export(bind_ties) @@ -695,6 +709,7 @@ export(create_tree) export(create_wheel) export(create_windmill) export(delete_changes) +export(delete_globals) export(delete_incomplete) export(delete_isolates) export(delete_node_attribute) @@ -709,6 +724,7 @@ export(describe_transformations) export(expect_nodes) export(expect_ties) export(filter_changes) +export(filter_globals) export(filter_nodes) export(filter_ties) export(from_egos) diff --git a/NEWS.md b/NEWS.md index a9dadb7e..ff3d83a8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,12 @@ ## Manipulating - Fixed `bind_changes.igraph()` replacing the changelog instead of appending +- Added remaining row-wise verbs for the globals component (closes #149) + - Added `bind_globals()` + - Added `filter_globals()` + - Added `arrange_globals()` + - Added `delete_globals()` + - Added `mutate_globals()` to alter table columns - Improved `to_time()` to scope globals as it already scoped ties, changes, and missings ## Modifying diff --git a/R/manip_globals.R b/R/manip_globals.R index 7cabcda6..f8faa794 100644 --- a/R/manip_globals.R +++ b/R/manip_globals.R @@ -5,9 +5,16 @@ #' or variables that are not tied to a particular node or tie. #' They include: #' -#' - `mutate_globals()` adds a table of global variables to the network. +#' - `bind_globals()` adds rows of global variables to the network. +#' - `mutate_globals()` changes the columns of the global variables table. +#' Where the network holds no global variables yet, it creates the table. +#' Where it holds one already, it changes that table's columns rather than +#' adding to it, so use `bind_globals()` to add another global variable. #' - `rename_globals()` renames columns in the global variables table. #' - `select_globals()` selects columns in the global variables table. +#' - `filter_globals()` subsets rows of the global variables table. +#' - `arrange_globals()` orders rows of the global variables table. +#' - `delete_globals()` drops the global variables table from the network. #' #' It expects three columns for #' the variable to which the change applies, which should be called 'var', @@ -118,3 +125,111 @@ select_globals.stocnet <- function(.data, ...){ out } + +#' @rdname manip_globals +#' @param globals A data frame of global variables to bind on. +#' It should hold a column `var` naming the variable, +#' a column `value` holding its value, +#' and, where the variable changes over time, a column `time`. +#' @examples +#' as_stocnet(ison_algebra) |> +#' bind_globals(data.frame(time = 1:2, var = "budget", value = c(10, 20))) +#' @export +bind_globals <- function(.data, globals) UseMethod("bind_globals") + +#' @export +bind_globals.default <- function(.data, globals){ + as_input(.data, bind_globals, globals = globals) +} + +#' @export +bind_globals.data.frame <- function(.data, globals){ + # The column names are brought to the stocnet conventions first, so that a + # table that names its time column 'wave' binds onto one that names it 'time'. + globals <- rename_globals.data.frame(dplyr::as_tibble(globals)) + if(is.null(.data) || nrow(.data) == 0) return(globals) + out <- dplyr::bind_rows(dplyr::as_tibble(.data), globals) + if("time" %in% names(out)) out <- dplyr::arrange(out, time, var) else + out <- dplyr::arrange(out, var) + out +} + +#' @export +bind_globals.stocnet <- function(.data, globals){ + out <- .data + out$globals <- bind_globals.data.frame(out$globals, globals) + validate_stocnet(out) +} + +#' @rdname manip_globals +#' @template param_dots +#' @examples +#' as_stocnet(ison_algebra) |> +#' bind_globals(data.frame(time = 1:2, var = "budget", value = c(10, 20))) |> +#' filter_globals(time == 1) +#' @export +filter_globals <- function(.data, ...) UseMethod("filter_globals") + +#' @export +filter_globals.default <- function(.data, ...){ + as_input(.data, filter_globals, ...) +} + +#' @export +filter_globals.data.frame <- function(.data, ...){ + # Globals hold no 'node' column, so no label masking is needed here, + # which is what `filter_changes()` has to do. + if(is.null(.data)) return(.data) + dplyr::filter(.data, ...) +} + +#' @export +filter_globals.stocnet <- function(.data, ...){ + out <- .data + out$globals <- filter_globals.data.frame(out$globals, ...) + out +} + +#' @rdname manip_globals +#' @export +arrange_globals <- function(.data, ...) UseMethod("arrange_globals") + +#' @export +arrange_globals.default <- function(.data, ...){ + as_input(.data, arrange_globals, ...) +} + +#' @export +arrange_globals.data.frame <- function(.data, ...){ + if(is.null(.data)) return(.data) + dplyr::arrange(.data, ...) +} + +#' @export +arrange_globals.stocnet <- function(.data, ...){ + out <- .data + out$globals <- arrange_globals.data.frame(out$globals, ...) + out +} + +#' @rdname manip_globals +#' @export +delete_globals <- function(.data) UseMethod("delete_globals") + +#' @export +delete_globals.default <- function(.data){ + as_input(.data, delete_globals) +} + +#' @export +delete_globals.igraph <- function(.data){ + if(!"globals" %in% igraph::graph_attr_names(.data)) return(.data) + igraph::delete_graph_attr(.data, "globals") +} + +#' @export +delete_globals.stocnet <- function(.data){ + out <- .data + out$globals <- NULL + out +} diff --git a/man/manip_globals.Rd b/man/manip_globals.Rd index 75ae5bcf..c4ed7e55 100644 --- a/man/manip_globals.Rd +++ b/man/manip_globals.Rd @@ -5,6 +5,10 @@ \alias{mutate_globals} \alias{rename_globals} \alias{select_globals} +\alias{bind_globals} +\alias{filter_globals} +\alias{arrange_globals} +\alias{delete_globals} \title{Manipulating global attributes} \usage{ mutate_globals(.data, ...) @@ -12,6 +16,14 @@ mutate_globals(.data, ...) rename_globals(.data, ...) select_globals(.data, ...) + +bind_globals(.data, globals) + +filter_globals(.data, ...) + +arrange_globals(.data, ...) + +delete_globals(.data) } \arguments{ \item{.data}{An object of a \code{{manynet}}-consistent class: @@ -25,6 +37,11 @@ select_globals(.data, ...) }} \item{...}{Additional parameters and arguments passed on internally.} + +\item{globals}{A data frame of global variables to bind on. +It should hold a column \code{var} naming the variable, +a column \code{value} holding its value, +and, where the variable changes over time, a column \code{time}.} } \value{ A data object of the same class as the function was given. @@ -34,9 +51,16 @@ These functions offer ways to manipulate network-level data constants or variables that are not tied to a particular node or tie. They include: \itemize{ -\item \code{mutate_globals()} adds a table of global variables to the network. +\item \code{bind_globals()} adds rows of global variables to the network. +\item \code{mutate_globals()} changes the columns of the global variables table. +Where the network holds no global variables yet, it creates the table. +Where it holds one already, it changes that table's columns rather than +adding to it, so use \code{bind_globals()} to add another global variable. \item \code{rename_globals()} renames columns in the global variables table. \item \code{select_globals()} selects columns in the global variables table. +\item \code{filter_globals()} subsets rows of the global variables table. +\item \code{arrange_globals()} orders rows of the global variables table. +\item \code{delete_globals()} drops the global variables table from the network. } It expects three columns for @@ -62,6 +86,11 @@ If no method is available for any class, an error will be thrown. \examples{ as_stocnet(ison_algebra) |> mutate_globals(time = 2, var = "active", value = FALSE) +as_stocnet(ison_algebra) |> + bind_globals(data.frame(time = 1:2, var = "budget", value = c(10, 20))) +as_stocnet(ison_algebra) |> + bind_globals(data.frame(time = 1:2, var = "budget", value = c(10, 20))) |> + filter_globals(time == 1) } \seealso{ \code{\link[=to_time]{to_time()}} diff --git a/tests/testthat/test-functional_manips.R b/tests/testthat/test-functional_manips.R index 52a9a38f..dec579a8 100644 --- a/tests/testthat/test-functional_manips.R +++ b/tests/testthat/test-functional_manips.R @@ -156,6 +156,27 @@ test_that("rename_changes() renames changelog columns", { expect_true(is_acceptable_output(out)) }) +test_that("bind_changes() adds to the changelog rather than replacing it", { + mk <- function() create_filled(4) |> + mutate_nodes(name = LETTERS[1:4], status = c(TRUE, FALSE, FALSE, FALSE)) + c1 <- data.frame(time = 2, node = "B", var = "status", value = TRUE) + c2 <- data.frame(time = 3, node = "C", var = "status", value = TRUE) + for(x in list(as_igraph(mk()), as_tidygraph(mk()), as_stocnet(mk()))){ + out <- bind_changes(bind_changes(x, c1), c2) + expect_equal(nrow(as_changelist(out)), 2) + } + # `.align_change_values()` reconciles a 'value' column of a differing type + c3 <- data.frame(time = 4, node = "D", var = "status", value = "maybe") + out <- bind_changes(bind_changes(as_igraph(mk()), c1), c3) + expect_equal(nrow(as_changelist(out)), 2) + expect_type(as_changelist(out)$value, "character") + # a composition table builds its own changelog, so that branch still replaces + comp <- data.frame(node = 1:4, begin = c(1, 1, 2, 3), end = c(4, 4, 4, 4)) + y <- bind_changes(as_igraph(create_filled(4)), comp) + expect_true("active" %in% net_node_attributes(y)) + expect_equal(nrow(igraph::graph_attr(y)$changes), 6) +}) + # Verbs manipulating global variables and network info ------------------------ test_that("mutate_globals(), rename_globals() and select_globals() work", { @@ -171,6 +192,56 @@ test_that("mutate_globals(), rename_globals() and select_globals() work", { expect_true(all(names(sn3$globals) %in% c("var", "time", "value"))) }) +test_that("bind_globals() adds rows where mutate_globals() changes columns", { + gl <- data.frame(time = 1:2, var = "budget", value = c(10, 20)) + sn <- run_or_skip(bind_globals(as_stocnet(ison_algebra), gl), + "bind_globals", "stocnet") + expect_equal(nrow(sn$globals), 2) + # a second bind adds to the table, where a second mutate changes its columns + expect_equal(nrow(bind_globals(sn, data.frame(time = 3, var = "staff", + value = 4))$globals), 3) + m <- mutate_globals(as_stocnet(ison_algebra), time = 1, var = "a", + value = TRUE) + expect_equal(nrow(mutate_globals(m, time = 2, var = "b", + value = FALSE)$globals), 1) + # the column names are brought to the stocnet conventions on the way in + expect_setequal(names(bind_globals(as_stocnet(ison_algebra), + data.frame(wave = 1, variable = "a", + weight = 0))$globals), + c("time", "var", "value")) +}) + +test_that("filter_globals(), arrange_globals() and delete_globals() work", { + gl <- data.frame(time = 1:2, var = "budget", value = c(10, 20)) + sn <- bind_globals(as_stocnet(ison_algebra), gl) + out <- run_or_skip(filter_globals(sn, time == 1), "filter_globals", "stocnet") + expect_equal(nrow(out$globals), 1) + expect_equal(out$globals$value, 10) + out <- run_or_skip(arrange_globals(sn, dplyr::desc(time)), + "arrange_globals", "stocnet") + expect_equal(out$globals$time, c(2L, 1L)) + out <- run_or_skip(delete_globals(sn), "delete_globals", "stocnet") + expect_null(out$globals) + expect_s3_class(validate_stocnet(out), "stocnet") +}) + +test_that("to_time() scopes the globals to the moment asked for", { + gl <- data.frame(time = 1:2, var = "budget", value = c(10, 20)) + sn <- bind_globals(as_stocnet(ison_algebra), gl) + out <- to_time(sn, 1) + expect_equal(nrow(out$globals), 1) + expect_equal(out$globals$value, 10) + # the time column goes, as it does for the ties and the missings + expect_false("time" %in% names(out$globals)) + # a component that holds nothing is NULL, not an empty table + expect_null(to_time(sn, 9)$globals) + # a globals table with no time column holds a constant, shared by every + # moment, so it is left alone + const <- bind_globals(as_stocnet(ison_algebra), + data.frame(var = "k", value = 1)) + expect_equal(nrow(to_time(const, 1)$globals), 1) +}) + test_that("rename_globals() renames aliases to stocnet conventions", { df <- data.frame(wave = 1, variable = "active", weight = 0) out <- rename_globals.data.frame(df) From cf5de224e59b2e2ee9da11068f03e64cfa3c5abc Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 7 Sep 2026 16:15:40 +0200 Subject: [PATCH 7/7] Updated CRAN comments --- cran-comments.md | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index adaa52bd..f11cdd8d 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -1,27 +1,10 @@ ## Test environments -* local R installation, aarch64-apple-darwin23, R 4.6.0 -* macOS 15.7.7 (on Github), R 4.6.0 -* Microsoft Windows Server 2025 10.0.26100 (on Github), R 4.6.0 -* Ubuntu 24.04.4 (on Github), R 4.6.0 +* local R installation, aarch64-apple-darwin23, R 4.6.1 +* macOS 26.6.2 (on Github), R 4.6.1 +* Microsoft Windows Server 2025 10.0.26100 (on Github), R 4.6.1 +* Ubuntu 24.04.4 (on Github), R 4.6.1 ## R CMD check results 0 errors | 0 warnings | 0 notes - -## Reverse dependencies - -The auto-check of 2.3.0 reported new failures in `autograph` and `netrics`. -I maintain both packages. - -This version fixes the three causes that were bugs in `manynet`: - -* `tie_attribute()` and `node_attribute()` aborted on a stocnet object where - the caller named no attribute, which the `{igraph}` method allows. -* A mark inside `filter_ties()` read the outer network, not the filtered one. -* `is_longitudinal()` marked a network whose ties carry no moment. - -The remaining failures are calls in the released `autograph` 1.1.2 to -`to_no_isolates()`, which 2.3.0 deprecates, and to arguments that `autograph` -itself deprecates. `autograph` 1.2.0 removes them and passes against this -version with 0 failures. It is submitted separately.