Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion R/Host.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -136,7 +165,9 @@ Host <- R6Class(
sampling.time = NULL,
sampling.comp = NULL,
unsampled = NULL,
pathogens = NULL
pathogens = NULL,
lineage.pool = NULL,
pool.size = NULL
)
)

Expand Down
13 changes: 13 additions & 0 deletions R/Model.R
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion R/Pathogen.R
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -78,6 +80,7 @@ Pathogen <- R6Class(
end.time = NULL,
parents = NULL,
children = NULL,
breakpoint = NULL
breakpoint = NULL,
slot.id = NA
)
)
63 changes: 54 additions & 9 deletions R/simARG.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions R/simInnerTree.R
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/testthat/test_Superinfection.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions tests/testthat/test_simARG.R
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
1 change: 1 addition & 0 deletions tests/testthat/test_simInnerTree.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/testthat/test_superinfection.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down