Skip to content

Commit 2c01ac0

Browse files
simonbeyer1claude
andcommitted
Key warm-start caches per condition in Pequil/Pimpl
Replace the single warm-start cache in Pequil/Pimpl with a per-condition registry. A condition-less transformation composed across conditions now warm-starts each condition from its own previous root instead of from whichever condition was solved last, which could cross basins of attraction in multistable models. parfn forwards the active condition to the warm-starting p2p closure. Flush warm-start caches at the start of every mstrust fit so each multistart fit rediscovers its own steady-state roots rather than inheriting a stale basin. This covers both the sequential path, where all fits share one objfun closure, and forked workers, which inherit the parent cache via copy-on-write. Make lpSolve an unconditional dependency since the sink-cluster LP now always runs; move MASS, pander, and lpSolve from Suggests to Imports. Update tests for the registry structure and add a per-condition warm-start test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6762ddf commit 2c01ac0

6 files changed

Lines changed: 124 additions & 30 deletions

File tree

DESCRIPTION

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ Imports:
3434
cowplot,
3535
digest,
3636
nleqslv,
37-
scales
37+
scales,
38+
MASS,
39+
pander,
40+
lpSolve
3841
Remotes:
3942
simonbeyer1/CppODE,
4043
dkaschek/cOde
4144
Suggests:
42-
MASS,
43-
pander,
4445
knitr,
4546
rmarkdown,
4647
testthat (>= 3.0.0),
@@ -49,8 +50,7 @@ Suggests:
4950
deSolve,
5051
JuliaCall,
5152
openxlsx,
52-
Polychrome,
53-
lpSolve
53+
Polychrome
5454
VignetteBuilder: knitr
5555
Encoding: UTF-8
5656
Roxygen: list(markdown = TRUE)

R/classes.R

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ parfn <- function(p2p, parameters = NULL, condition = NULL) {
296296
mappings[[1]] <- p2p
297297
names(mappings) <- condition
298298

299+
# p2p of warm-starting transformations (Pequil / Pimpl) accepts a `condition`
300+
# argument so it can keep one warm-start cache per condition. Forward it only
301+
# when present; Pexpl and other p2p's keep their original signature untouched.
302+
p2p_has_cond <- "condition" %in% names(formals(p2p))
303+
299304
outfn <- function(..., fixed = NULL, deriv = TRUE, deriv2 = FALSE, conditions = condition, env = NULL) {
300305

301306

@@ -310,8 +315,18 @@ parfn <- function(p2p, parameters = NULL, condition = NULL) {
310315

311316
if (is.null(overlap)) conditions <- union(condition, conditions)
312317

318+
# Per-condition warm-start key: prefer this parfn's own condition, else the
319+
# single condition it is currently being evaluated under (set by prodfn when
320+
# a condition-less parfn is composed with a condition-specifying one).
321+
cond_key <- if (!is.null(condition)) condition[[1]]
322+
else if (length(conditions) == 1L) conditions
323+
else NULL
324+
313325
if (is.null(overlap) | length(overlap) > 0)
314-
result <- p2p(pars = pars, fixed = fixed, deriv = deriv, deriv2 = deriv2)
326+
result <- if (p2p_has_cond)
327+
p2p(pars = pars, fixed = fixed, deriv = deriv, deriv2 = deriv2, condition = cond_key)
328+
else
329+
p2p(pars = pars, fixed = fixed, deriv = deriv, deriv2 = deriv2)
315330
else
316331
result <- NULL
317332

R/parameters.R

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,37 @@ P <- function(trafo = NULL, parameters = NULL, condition = NULL,
6363
}
6464

6565

66+
## Per-condition warm-start registry shared by Pimpl / Pequil / .Pequil_totals.
67+
## Each of those builds ONE p2p closure even when condition-less, and the common
68+
## usage pattern is a condition-less `Pequil` (or `Pimpl`) composed via `*` with a
69+
## condition-specifying `Pexpl`. The prodfn loop then calls that single p2p once
70+
## per condition. With a single cache env this means every condition warm-starts
71+
## from whichever condition was solved last (order-dependent, can cross basins of
72+
## attraction). The registry keeps one cache env PER condition key instead, so a
73+
## condition is always warm-started from its OWN previous root. `parfn()` forwards
74+
## the active condition to p2p (see the `condition` argument there); a NULL/empty
75+
## key (parfn called outside any condition context) falls back to a shared slot.
76+
.warmstart_registry <- function() {
77+
caches <- new.env(parent = emptyenv())
78+
get_cache <- function(key) {
79+
k <- if (is.null(key) || !nzchar(key)) "__default__" else key
80+
cc <- get0(k, envir = caches, inherits = FALSE)
81+
if (is.null(cc)) { cc <- new.env(parent = emptyenv()); assign(k, cc, envir = caches) }
82+
cc
83+
}
84+
reset <- function() {
85+
nms <- ls(caches, all.names = TRUE)
86+
for (k in nms) {
87+
cc <- get(k, envir = caches, inherits = FALSE)
88+
inner <- ls(cc, all.names = TRUE)
89+
if (length(inner)) rm(list = inner, envir = cc)
90+
}
91+
invisible(nms)
92+
}
93+
list(get = get_cache, reset = reset, caches = caches)
94+
}
95+
96+
6697
#' Parameter transformation (explicit, algebraic)
6798
#'
6899
#' Builds `p_inner = f(p_outer)` from symbolic expressions via
@@ -338,7 +369,7 @@ Pexpl <- function(trafo, parameters = NULL, attach.input = FALSE, condition = NU
338369
#' vanishes at SS; if a feeder's rate involves exactly one state, that
339370
#' state must be zero.
340371
#' \item *Sink cluster (LP)*: subsets whose combined mass leaks
341-
#' monotonically (mass-balance LP via `lpSolve::lp`, only when installed).
372+
#' monotonically (mass-balance LP via `lpSolve::lp`).
342373
#' }
343374
#' For each zero-state the column is dropped, the state symbol is substituted
344375
#' by `"0"` in remaining rates, structurally-zero reactions are removed, and
@@ -403,7 +434,6 @@ Pexpl <- function(trafo, parameters = NULL, attach.input = FALSE, condition = NU
403434
}
404435

405436
.sink_cluster <- function(M, eps = 1e-8, Mbig = 1e4) {
406-
if (!requireNamespace("lpSolve", quietly = TRUE)) return(integer(0))
407437
nF <- nrow(M); nS <- ncol(M)
408438
if (nF == 0L || nS == 0L) return(integer(0))
409439
c_obj <- colSums(M)
@@ -805,8 +835,7 @@ Pimpl <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
805835
array(c(H4), dim(H4)[2:4], dimnames = dimnames(H4)[2:4])
806836
}
807837

808-
cache <- new.env(parent = emptyenv())
809-
cache$guess <- NULL
838+
reg <- .warmstart_registry()
810839

811840
## Biological SS magnitudes span ~10 orders when rate constants span 2-3:
812841
## default log-uniform sampling over [1e-5, 1e5] when positive = TRUE.
@@ -962,12 +991,13 @@ Pimpl <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
962991
}
963992

964993

965-
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE) {
994+
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE, condition = NULL) {
966995
if (deriv2 && !emit_d2)
967996
stop("Pimpl was built with deriv2 = FALSE; rebuild with deriv2 = TRUE.", call. = FALSE)
968997
if (!emit_d1) deriv <- FALSE
969998
if (deriv2 && !deriv) deriv <- TRUE
970999

1000+
cache <- reg$get(condition)
9711001
p <- pars
9721002
dP <- attr(p, "deriv")
9731003
dP2 <- if (deriv2) attr(p, "deriv2") else NULL
@@ -1086,9 +1116,9 @@ Pimpl <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
10861116
attr(p2p, "modelname") <- modelname
10871117
attr(p2p, "compileInfo") <- collectCompileInfo(PEval$func, PEval$jac, PEval$hess)
10881118
attr(p2p, "resetWarmStart") <- local({
1089-
cache_ref <- cache; mn <- modelname; cond <- condition
1119+
reg_ref <- reg; mn <- modelname; cond <- condition
10901120
function() {
1091-
cache_ref$guess <- NULL
1121+
reg_ref$reset()
10921122
paste0("Pimpl(", mn, if (!is.null(cond)) paste0(":", cond) else "", ")")
10931123
}
10941124
})
@@ -1198,21 +1228,21 @@ Pimpl <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
11981228
controls <- c(list(keep.root = keep.root, attach.input = attach.input,
11991229
start.time = start.time, end.time = end.time), ode_ctrl)
12001230

1201-
cache <- new.env(parent = emptyenv())
1202-
cache$yini <- cache$last_hash <- cache$last_result <- NULL
1231+
reg <- .warmstart_registry()
12031232

12041233
default_sens <- matrix(0, n_dep, length(all_sens), dimnames = list(dependent, all_sens))
12051234
if (length(pivots)) default_sens[cbind(pivots, pivots)] <- 1
12061235
default_sens2 <- if (emit_d2)
12071236
array(0, c(n_dep, length(all_sens), length(all_sens)),
12081237
dimnames = list(dependent, all_sens, all_sens)) else NULL
12091238

1210-
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE) {
1239+
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE, condition = NULL) {
12111240
if (deriv2 && !emit_d2)
12121241
stop("Pequil(deriv2 = TRUE) requires the model to be built with deriv2 = TRUE.",
12131242
call. = FALSE)
12141243
if (!emit_d1) deriv <- FALSE
12151244
if (deriv2 && !deriv) deriv <- TRUE
1245+
cache <- reg$get(condition)
12161246
p <- pars
12171247
dP <- attr(p, "deriv")
12181248
dP2 <- if (deriv2) attr(p, "deriv2") else NULL
@@ -1352,9 +1382,9 @@ Pimpl <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
13521382
attr(p2p, "modelname") <- modelname
13531383
attr(p2p, "compileInfo") <- collectCompileInfo(model, model_s, model_s2)
13541384
attr(p2p, "resetWarmStart") <- local({
1355-
cache_ref <- cache; mn <- modelname; cond <- condition
1385+
reg_ref <- reg; mn <- modelname; cond <- condition
13561386
function() {
1357-
cache_ref$yini <- cache_ref$last_hash <- cache_ref$last_result <- NULL
1387+
reg_ref$reset()
13581388
paste0("Pequil(", mn, if (!is.null(cond)) paste0(":", cond) else "", ")")
13591389
}
13601390
})
@@ -1465,9 +1495,7 @@ Pequil <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
14651495
maxprogress = 100L, hini = 0, roottol = 1e-6, maxroot = 1L),
14661496
controlsODE)
14671497

1468-
cache <- new.env(parent = emptyenv())
1469-
cache$yini <- cache$sensini <- cache$sens2ini <-
1470-
cache$last_hash <- cache$last_result <- NULL
1498+
reg <- .warmstart_registry()
14711499

14721500
default_sens <- matrix(0, n_dep, length(all_sens),
14731501
dimnames = list(dependent, all_sens))
@@ -1480,13 +1508,14 @@ Pequil <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
14801508
controls <- c(list(keep.root = keep.root, attach.input = attach.input,
14811509
start.time = start.time, end.time = end.time), ode_ctrl)
14821510

1483-
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE) {
1511+
p2p <- function(pars, fixed = NULL, deriv = TRUE, deriv2 = FALSE, condition = NULL) {
14841512
if (deriv2 && !emit_d2)
14851513
stop("Pequil(deriv2 = TRUE) requires the model to be built with deriv2 = TRUE.",
14861514
call. = FALSE)
14871515
if (!emit_d1) deriv <- FALSE
14881516
if (deriv2 && !deriv) deriv <- TRUE
14891517

1518+
cache <- reg$get(condition)
14901519
p <- pars
14911520
dP <- attr(p, "deriv")
14921521
dP2 <- if (deriv2) attr(p, "deriv2") else NULL
@@ -1658,10 +1687,9 @@ Pequil <- function(trafo, parameters = NULL, forcings = NULL, condition = NULL,
16581687
attr(p2p, "modelname") <- modelname
16591688
attr(p2p, "compileInfo") <- collectCompileInfo(model, model_s, model_s2)
16601689
attr(p2p, "resetWarmStart") <- local({
1661-
cache_ref <- cache; mn <- modelname; cond <- condition
1690+
reg_ref <- reg; mn <- modelname; cond <- condition
16621691
function() {
1663-
cache_ref$yini <- cache_ref$sensini <- cache_ref$sens2ini <-
1664-
cache_ref$last_hash <- cache_ref$last_result <- NULL
1692+
reg_ref$reset()
16651693
paste0("Pequil(", mn, if (!is.null(cond)) paste0(":", cond) else "", ")")
16661694
}
16671695
})

R/statistics.R

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,19 @@ mstrust <- function(objfun, center, studyname, rinit = .1, rmax = 10, fits = 20,
954954
argstrust[["traceFile"]] <- file.path(resultFolder, paste0(formatC(i, digits = digits, flag = "0"), "_", argslist[["traceFile"]]))
955955
}
956956

957+
# Each fit must rediscover its own condition-specific steady-state roots.
958+
# A stale Pequil/Pimpl warm-start root would seed this fit's equilibration
959+
# and short-circuit the parfn-internal multistart, silently pinning the
960+
# wrong basin in multistable models. This body runs once per fit in BOTH
961+
# backends, so flushing here covers both leak paths:
962+
# - cores = 1 (%do%): all fits share one objfun closure; clear the root
963+
# left by the previous fit.
964+
# - cores > 1 (%dopar%): the body runs inside the fork, so this clears the
965+
# fork's copy-on-write inheritance of whatever the parent cache held at
966+
# fork time (non-empty e.g. if objfun was evaluated before mstrust),
967+
# which would otherwise be the SAME stale root in every fork.
968+
try(resetWarmStarts(objfun, verbose = FALSE), silent = TRUE)
969+
957970
# Retry loop: a try-error or fit$error triggers re-sampling parinit and
958971
# re-running optmethod, up to nTries times. parframe-supplied centers
959972
# are skipped (rows are taken as given). Warm-start caches in

tests/testthat/test-Pequil-Pimpl.R

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,9 @@ test_that("resetWarmStarts clears Pequil's cache by name", {
454454
pars <- c(k_in = 1.5, k_out = 0.3, A = 0.1)
455455
pf(pars)
456456

457-
reset_env <- environment(attr(pf, "resetWarmStart"))$cache_ref
457+
# Warm-start caches are now kept per condition in a registry; a condition-less
458+
# call lands in the "__default__" slot.
459+
reset_env <- environment(attr(pf, "resetWarmStart"))$reg_ref$caches[["__default__"]]
458460
expect_false(is.null(reset_env$yini))
459461

460462
labels <- resetWarmStarts(pf, verbose = FALSE)
@@ -475,7 +477,7 @@ test_that("resetWarmStarts clears Pimpl's cache by name", {
475477
compile = TRUE, verbose = FALSE)
476478
pf(c(a = 1, x = 0.5))
477479

478-
reset_env <- environment(attr(pf, "resetWarmStart"))$cache_ref
480+
reset_env <- environment(attr(pf, "resetWarmStart"))$reg_ref$caches[["__default__"]]
479481
expect_false(is.null(reset_env$guess))
480482

481483
labels <- resetWarmStarts(pf, verbose = FALSE)
@@ -501,8 +503,8 @@ test_that("resetWarmStarts walks into composed functions", {
501503
p1(c(k1 = 1, kdg1 = 0.5, A = 0.1))
502504
p2(c(k2 = 2, kdg2 = 0.4, B = 0.2))
503505

504-
ref1 <- environment(attr(p1, "resetWarmStart"))$cache_ref
505-
ref2 <- environment(attr(p2, "resetWarmStart"))$cache_ref
506+
ref1 <- environment(attr(p1, "resetWarmStart"))$reg_ref$caches[["__default__"]]
507+
ref2 <- environment(attr(p2, "resetWarmStart"))$reg_ref$caches[["__default__"]]
506508
expect_false(is.null(ref1$yini))
507509
expect_false(is.null(ref2$yini))
508510

@@ -515,6 +517,40 @@ test_that("resetWarmStarts walks into composed functions", {
515517
})
516518

517519

520+
test_that("a condition-less Pequil keeps an independent warm start per condition", {
521+
skip_if_no_compile()
522+
oldwd <- setwd(.dmod_fx_workdir()); on.exit(setwd(oldwd), add = TRUE)
523+
524+
stamp <- as.integer(Sys.time())
525+
526+
# Condition-less equilibration: SS A* = k_in / k_out.
527+
g_eq <- Pequil(c(A = "k_in - k_out * A"),
528+
parameters = c("k_in", "k_out"),
529+
modelname = paste0("test_percond_eq_", stamp),
530+
compile = TRUE, verbose = FALSE, attach.input = TRUE)
531+
532+
# Two conditions with different k_in -> different steady states (3 and 6).
533+
trafos <- list(
534+
C1 = c(k_in = "s", k_out = "1", A = "1"),
535+
C2 = c(k_in = "2 * s", k_out = "1", A = "1"))
536+
px <- P(trafos, modelname = paste0("test_percond_px_", stamp),
537+
compile = TRUE, verbose = FALSE)
538+
539+
pf <- g_eq * px
540+
out <- pf(c(s = 3), deriv = FALSE)
541+
542+
expect_equal(as.numeric(out$C1["A"]), 3, tolerance = 1e-4)
543+
expect_equal(as.numeric(out$C2["A"]), 6, tolerance = 1e-4)
544+
545+
# The single Pequil closure must now hold one warm-start slot per condition,
546+
# each cached at its own root -- not a single shared/overwritten cache.
547+
caches <- environment(attr(g_eq, "resetWarmStart"))$reg_ref$caches
548+
expect_setequal(ls(caches), c("C1", "C2"))
549+
expect_equal(as.numeric(caches[["C1"]]$yini["A"]), 3, tolerance = 1e-4)
550+
expect_equal(as.numeric(caches[["C2"]]$yini["A"]), 6, tolerance = 1e-4)
551+
})
552+
553+
518554
test_that("resetWarmStarts is idempotent and silent when nothing to reset", {
519555
f <- function(x) x + 1
520556
labels <- resetWarmStarts(f, verbose = FALSE)

tests/testthat/test-normL2.R

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,10 @@ test_that("normL2 rejects unknown opt.BLOQ values", {
350350
bench <- fx_decay_compiled()
351351
data <- fx_decay_data_bloq(sigma = 0.05, lloq = 0.1,
352352
times = seq(0, 10, by = 1))
353+
# match.arg() rejects the unknown value; assert on the listed choices rather
354+
# than the "should be one of" prefix, which match.arg translates per locale.
353355
expect_error(normL2(data, bench$prd_id, opt.BLOQ = "M2"),
354-
"should be one of")
356+
"M4BEAL")
355357
})
356358

357359

0 commit comments

Comments
 (0)