diff --git a/R/Host.R b/R/Host.R index b2bda7e..9057645 100644 --- a/R/Host.R +++ b/R/Host.R @@ -82,6 +82,35 @@ Host <- R6Class( private$pathogens[[length(private$pathogens)+1]] <- new.pathogen }, + # --- fixed-size lineage pool (for recombination parent sampling) --- + # Fixes exponential lineage growth under recombination: instead of + # always creating a brand-new ancestral lineage, recombination should + # sample a parent from a FIXED pool of n (= population size) possible + # lineages, only "activating" a previously-inactive one when chosen. + # Total pool size never changes; only how many slots are active does. + init.pool = function(n) { + if (is.null(private$pool.size)) { + private$pool.size <- n + private$lineage.pool <- vector("list", n) + } + }, + get.pool.size = function() { private$pool.size }, + is.pool.initialized = function() { !is.null(private$pool.size) }, + is.slot.active = function(slot.id) { !is.null(private$lineage.pool[[slot.id]]) }, + get.slot.occupant = function(slot.id) { private$lineage.pool[[slot.id]] }, + activate.slot = function(slot.id, pathogen) { + private$lineage.pool[[slot.id]] <- pathogen + }, + deactivate.slot = function(slot.id) { + private$lineage.pool[slot.id] <- list(NULL) # preserves pool length + }, + get.active.slots = function() { + which(!sapply(private$lineage.pool, is.null)) + }, + get.inactive.slots = function() { + which(sapply(private$lineage.pool, is.null)) + }, + remove.pathogen = function(idx) { path <- private$pathogens[[idx]] private$pathogens[[idx]] <- NULL @@ -136,7 +165,9 @@ Host <- R6Class( sampling.time = NULL, sampling.comp = NULL, unsampled = NULL, - pathogens = NULL + pathogens = NULL, + lineage.pool = NULL, + pool.size = NULL ) ) diff --git a/R/Model.R b/R/Model.R index 7ef2b1d..a73646b 100644 --- a/R/Model.R +++ b/R/Model.R @@ -328,6 +328,19 @@ Model <- R6Class( } private$check.expression(params$pop.size, env) private$pop.sizes[[src]] <- params$pop.size + } else if (!is.null(params$coalescent.rate) && + params$coalescent.rate != "Inf") { + # coalescence is enabled for this compartment (finite rate) but + # pop.size was never explicitly set, so it's silently using the + # unconfigured default (100) baked into this package -- this + # default has no connection to the compartment's own `size:` + # field or any other yaml value, and coalescent.rate expressions + # that reference population size (or were chosen assuming a + # particular population size) may be silently mismatched with it. + warning(src, ": coalescent.rate is set but pop.size is not -- ", + "using unconfigured default pop.size=100. If ", + "coalescent.rate was chosen with a particular population ", + "size in mind, set pop.size explicitly in the yaml.") } } # end loop diff --git a/R/Pathogen.R b/R/Pathogen.R index b81f3b7..67ebcde 100644 --- a/R/Pathogen.R +++ b/R/Pathogen.R @@ -41,6 +41,8 @@ Pathogen <- R6Class( # immutable attributes get.name = function() { private$name }, get.end.time = function() { private$end.time }, + get.slot.id = function() { private$slot.id }, + set.slot.id = function(id) { private$slot.id <- id }, # mutables get.start.time = function() { private$start.time }, @@ -78,6 +80,7 @@ Pathogen <- R6Class( end.time = NULL, parents = NULL, children = NULL, - breakpoint = NULL + breakpoint = NULL, + slot.id = NA ) ) diff --git a/R/simARG.R b/R/simARG.R index 251df9e..b7feb4a 100644 --- a/R/simARG.R +++ b/R/simARG.R @@ -63,8 +63,11 @@ sim.arg <- function(outer, rho = 1e-4, seq.length = 9000L) { if (ev$type == "coalescent") { .do.coalescent(ev$host, inner, event.time, envir = env) } else { + host.obj <- active$get.host.by.name(ev$host) + p.size.expr <- mod$get.pop.size(host.obj$get.compartment()) + p.size.val <- eval(parse(text = p.size.expr), envir = env) bp <- .do.recombination(ev$host, ev$pathogen, inner, event.time, - seq.length = seq.length) + seq.length = seq.length, p.size = p.size.val) breakpoints[[bp$child]] <- bp$position } } @@ -133,6 +136,7 @@ sim.arg <- function(outer, rho = 1e-4, seq.length = 9000L) { # coalescence rate for this host (requires 2+ lineages) if (k >= 2) { + expr <- mod$get.coalescent.rate(comp) rate <- eval(parse(text = expr), envir = envir) if (rate > 0) { @@ -190,20 +194,58 @@ sim.arg <- function(outer, rho = 1e-4, seq.length = 9000L) { #' @keywords internal #' @noRd .do.recombination <- function(host.name, pathogen, inner, time, - seq.length = 9000L) { + seq.length = 9000L, p.size = NULL) { active <- inner$get.active() host <- active$get.host.by.name(host.name) + # initialize this host's FIXED lineage pool (sample + # recombination parents from a fixed pool of p.size lineages, only + # activating a previously-inactive one when chosen, instead of always + # creating a brand-new lineage de novo. Total pool size never changes.) + if (!host$is.pool.initialized()) { + if (is.null(p.size)) { + stop(".do.recombination: p.size must be provided to initialize ", + "host's lineage pool on first use") + } + host$init.pool(p.size) + } + + # ensure the recombining pathogen already occupies a pool slot -- if + # this is the first pool-tracked event involving it, assign one now + if (is.na(pathogen$get.slot.id())) { + free <- host$get.inactive.slots() + slot <- if (length(free) > 0) free[1] else 1 + pathogen$set.slot.id(slot) + host$activate.slot(slot, pathogen) + } + own.slot <- pathogen$get.slot.id() + # sample breakpoint uniformly across genome breakpoint <- sample.int(seq.length - 1L, 1L) pathogen$set.breakpoint(breakpoint) - - # end the current lineage at this recombination event pathogen$set.start.time(time) - # create two parental lineages — left and right of breakpoint - parent.left <- inner$new.pathogen(time) - parent.right <- inner$new.pathogen(time) + # LEFT parent: continues in the SAME slot as the child + parent.left <- inner$new.pathogen(time) + parent.left$set.slot.id(own.slot) + host$activate.slot(own.slot, parent.left) + + # RIGHT parent: sample a slot from the fixed pool (excluding own slot) + pool.size <- host$get.pool.size() + other.slots <- setdiff(seq_len(pool.size), own.slot) + + if (length(other.slots) == 0) { + parent.right <- parent.left + } else { + sampled.slot <- if (length(other.slots) == 1) other.slots else sample(other.slots, 1) + if (host$is.slot.active(sampled.slot)) { + parent.right <- host$get.slot.occupant(sampled.slot) + } else { + parent.right <- inner$new.pathogen(time) + parent.right$set.slot.id(sampled.slot) + host$activate.slot(sampled.slot, parent.right) + } + } # record parent-child relationships (recombination has two parents) parent.left$add.child(pathogen) @@ -216,9 +258,12 @@ sim.arg <- function(outer, rho = 1e-4, seq.length = 9000L) { idx <- which(sapply(paths, function(p) p$get.name()) == pathogen$get.name()) if (length(idx) == 1) host$remove.pathogen(idx) host$add.pathogen(parent.left) - host$add.pathogen(parent.right) + already.present <- any(sapply(host$get.pathogens(), function(p) { + p$get.name() == parent.right$get.name() + })) + if (!already.present) host$add.pathogen(parent.right) - # log the recombination event (breakpoint not stored in log — fixed schema) + # log the recombination event (breakpoint not stored in log -- fixed schema) event <- list( time = time, event = "recombination", from.comp = host$get.compartment(), to.comp = NA, diff --git a/R/simInnerTree.R b/R/simInnerTree.R index e5c9cd7..b8a574d 100644 --- a/R/simInnerTree.R +++ b/R/simInnerTree.R @@ -202,6 +202,29 @@ sim.inner.tree <- function(outer) { expr <- inner$get.model()$get.pop.size(e$to.comp) p.size <- eval(parse(text=expr), envir=envir) + if (count > p.size) { + # count (n.active.lineages) is tracked ancestral segment/lineage + # objects, not necessarily distinct physical genomes -- once + # recombination is active, lineage count can legitimately exceed + # the population's census size, and the correct relationship + # between the two is not yet resolved (see issue tracker: rhyper() + # here implicitly assumes count occupies count distinct slots + # among p.size exchangeable individuals, which breaks once one + # genome can carry several ancestral segments). Fail loudly with + # a clear diagnostic rather than silently capping or crashing on + # an opaque NA, until the bottleneck/occupancy model is revisited. + stop(sprintf( + "Tracked lineage count (%d) exceeds pathogen population size (%d) ", + count, p.size), + "in host ", recipient$get.name(), " at time ", e$time, ". ", + "This means more ancestral lineages/segments are being tracked ", + "than the model's nominal population size anticipates (likely ", + "from recombination). The bottleneck sampling here assumes ", + "count occupies count distinct slots among p.size individuals, ", + "which is not valid once lineage count and physical individual ", + "count can diverge -- needs a proper occupancy/carrier model, ", + "not a silent cap.") + } n.transfer <- rhyper(1, count, p.size-count, b.size) if (n.transfer > 0) { for (i in 1:n.transfer) { @@ -321,6 +344,27 @@ sim.inner.tree <- function(outer) { p2$set.start.time(time) anc <- inner$new.pathogen(time) # sets end.time + + # reconcile the lineage pool: the ancestor represents the same + # physical individual as whichever of p1/p2 already occupied a pool + # slot (from a prior recombination event). If both occupied slots, + # keep one for the ancestor and free the other -- two active + # lineages coalescing means one fewer active individual going + # forward. If neither occupied a slot, this coalescence never + # touched the pool, so the ancestor stays unassigned too. + p1.slot <- p1$get.slot.id() + p2.slot <- p2$get.slot.id() + if (!is.na(p1.slot)) { + anc$set.slot.id(p1.slot) + host$activate.slot(p1.slot, anc) + if (!is.na(p2.slot) && p2.slot != p1.slot) { + host$deactivate.slot(p2.slot) + } + } else if (!is.na(p2.slot)) { + anc$set.slot.id(p2.slot) + host$activate.slot(p2.slot, anc) + } + host$add.pathogen(anc) # assign ancestral/descendant relations diff --git a/tests/testthat/test_Superinfection.yaml b/tests/testthat/test_Superinfection.yaml index fc96ce3..1f2db63 100644 --- a/tests/testthat/test_Superinfection.yaml +++ b/tests/testthat/test_Superinfection.yaml @@ -24,6 +24,7 @@ Compartments: size: 2 bottleneck.size: 1 coalescent.rate: 0.01 + pop.size: 100 # was silently defaulting to this I_samp: infected: true size: 0 diff --git a/tests/testthat/test_simARG.R b/tests/testthat/test_simARG.R index c049d5b..d4d9ab4 100644 --- a/tests/testthat/test_simARG.R +++ b/tests/testthat/test_simARG.R @@ -207,3 +207,82 @@ test_that("resolve.arg produces genuinely divergent topology (hand-built positiv expect_true(is.monophyletic(phy2, c("A","C"))) expect_false(is.monophyletic(phy2, c("A","B"))) }) +test_that("Host lineage pool: basic activate/deactivate/query", { + h <- Host$new(name="H1", compartment="I") + expect_false(h$is.pool.initialized()) + h$init.pool(5) + expect_true(h$is.pool.initialized()) + expect_equal(h$get.pool.size(), 5) + expect_equal(length(h$get.inactive.slots()), 5) + expect_equal(length(h$get.active.slots()), 0) + + p <- Pathogen$new(name="P1", end.time=1) + h$activate.slot(2, p) + expect_true(h$is.slot.active(2)) + expect_equal(h$get.slot.occupant(2)$get.name(), "P1") + expect_equal(length(h$get.active.slots()), 1) + expect_equal(length(h$get.inactive.slots()), 4) + + h$deactivate.slot(2) + expect_false(h$is.slot.active(2)) + expect_equal(length(h$get.active.slots()), 0) + # pool size must not shrink after deactivation + expect_equal(h$get.pool.size(), 5) +}) + +test_that("Host lineage pool: init.pool is idempotent", { + h <- Host$new(name="H1", compartment="I") + h$init.pool(10) + h$init.pool(999) # should be ignored, pool already initialized + expect_equal(h$get.pool.size(), 10) +}) + +test_that("sim.arg does not exceed pop.size at high rho (fixed lineage pool)", { + settings <- read_yaml("test_Superinfection.yaml") + settings$Parameters$sigma <- 0.05 + mod <- Model$new(settings) + set.seed(33) + dyn <- tryCatch(sim.dynamics(mod, max.attempts=10), error=function(e) NULL) + if (is.null(dyn)) skip("could not build dynamics for this seed") + outer <- tryCatch( + withCallingHandlers(sim.outer.tree(dyn), warning=function(w) invokeRestart("muffleWarning")), + error=function(e) NULL) + if (is.null(outer)) skip("could not build outer tree for this seed") + + # rho=5,7,10 previously exceeded pop.size and crashed/errored before + # the fixed lineage pool (Art's design: sample recombination parents + # from a fixed pool of p.size lineages instead of always creating a + # new lineage de novo). Now they should all succeed. + for (rho in c(5, 7, 10)) { + arg <- tryCatch(sim.arg(outer, rho=rho, seq.length=9000), error=function(e) e) + expect_false(inherits(arg, "error"), + info=paste("rho =", rho, "should not error with fixed lineage pool")) + } +}) + +test_that("resolve.arg produces valid trees on top of the fixed lineage pool", { + settings <- read_yaml("test_Superinfection.yaml") + settings$Parameters$sigma <- 0.05 + mod <- Model$new(settings) + set.seed(33) + dyn <- tryCatch(sim.dynamics(mod, max.attempts=10), error=function(e) NULL) + if (is.null(dyn)) skip("could not build dynamics for this seed") + outer <- tryCatch( + withCallingHandlers(sim.outer.tree(dyn), warning=function(w) invokeRestart("muffleWarning")), + error=function(e) NULL) + if (is.null(outer)) skip("could not build outer tree for this seed") + + n.sampled <- outer$get.sampled()$count.type() + arg <- sim.arg(outer, rho=2, seq.length=9000) + res <- resolve.arg(arg, seq.length=9000) + + valid <- sapply(res$local.trees, function(lt) { + phy <- lt$phylo + inherits(phy, "phylo") && + length(phy$tip.label) == n.sampled && + sum(duplicated(phy$tip.label)) == 0 && + !any(is.na(phy$edge.length)) && + !any(phy$edge.length < 0, na.rm=TRUE) + }) + expect_true(all(valid)) +}) diff --git a/tests/testthat/test_simInnerTree.R b/tests/testthat/test_simInnerTree.R index e6c147e..22e6880 100644 --- a/tests/testthat/test_simInnerTree.R +++ b/tests/testthat/test_simInnerTree.R @@ -3,6 +3,7 @@ require(twt) # generate test fixtures settings <- yaml.load_file("test_SIR.yaml") settings$Compartments$I$coalescent.rate <- 1.0 +settings$Compartments$I$pop.size <- 100 # was silently defaulting to this mod <- Model$new(settings) set.seed(276) dynamics <- sim.dynamics(mod) diff --git a/tests/testthat/test_superinfection.R b/tests/testthat/test_superinfection.R index 4d79ceb..6662525 100644 --- a/tests/testthat/test_superinfection.R +++ b/tests/testthat/test_superinfection.R @@ -393,7 +393,7 @@ test_that("model: compartment I flagged infected=TRUE", { expect_true(mod$get.infected("I")) }) -test_that("model YAML: compartment I has pop.size=2 and bottleneck.size=1", { +test_that("model YAML: compartment I has size=2 and bottleneck.size=1", { try_model(SUPERINF_INNER_PATH) cfg <- read_yaml(SUPERINF_INNER_PATH) expect_equal(cfg$Compartments$I$size, 2)