fix(dde): optExpression and past() durations for delay differential equations - #1194
Conversation
rxOptExpr() could not optimize a past(state, tau) whose delay duration was an expression: ..rxOptLhs() recursed into the duration with the left-hand-side renderer, which only knows names and the dosing-property heads, so past(G, exp(lT)) hit its stop() (after printing the duration into the middle of the progress bar). Only the whole-model path was affected -- .rxDisguiseCmt() hex-encodes the whole left-hand side for the chunked path -- so the same model failed at 10 lines and optimized at 50, and optExpression=TRUE was unusable for a DDE. Render the duration with .rxOptExpr() instead: it parses, and it picks up the same rx_expr_ temporary the matching delay() calls do, which is what .rxValidatePast() needs to keep matching them. The chunked path cannot do that (the left-hand side is hidden from the optimizer and restored byte-exactly), so .rxRealignPastTau() re-points a restored past() duration at the one its delay() calls ended up with. Text only -- the duration is never evaluated -- and only when the state's delay() calls agree on exactly one duration; otherwise the line is left for the validator. Separately, the duration was stored as verbatim source text and emitted unresolved by all four past() emitters, while every delay() had its duration inlined by symengine. A duration written as an intermediate (T <- exp(lT)) was therefore emitted into a generated model that intermediate had been dead-code eliminated from, so rxode2(..., calcJac=TRUE), calcSens= and the nlmixr2 estimation models produced a past() naming a duration no delay() used any more and rxSolve() rejected them. .rxPastFromEnv()/.rxTauFromEnv() resolve the duration the way the history already was, by evaluating a surrogate delay(state, tau) in the env so the rendering is identical to the augmented d/dt() by construction (this also covers a constant-folded duration, stored as a plain numeric rather than a Basic). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the review of the previous commit. .rxRealignPastTau() decided
which past() lines to touch by splitting each line at its first "=" and found
the delay() durations by parsing the whole reassembled model as R. Three ways
that fell short:
- a duration holding an "=" (past(G, h(x)==1)) split in the wrong place, so
the line was skipped and left unmatched;
- the reassembled text is rxode2 syntax and not guaranteed to be valid R, so
a parse failure silently skipped every past() line in the model;
- a state whose delay() calls used more than one duration was skipped
outright, which a two-delay/two-past() model does legitimately.
Delimit the past() left-hand side with the parenthesis matching past(, and scan
for delay(state, tau) by matching parentheses the way .rxDisguiseDelayChunks()
already does, so neither depends on the text parsing as R. Optimizing never
reorders statements, so a state's delay() durations keep their order of first
appearance: .rxOptExprChunked() now passes the pre-optimization text in and each
past() line follows the delay() it came from. A single duration remains
unambiguous without it; anything else is still left for .rxValidatePast().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- drop the explicit return() on the past() branch of ..rxOptLhs() (the if/else chain is the function's last expression) - call the internal .rxValidatePast()/.rxRealignPastTau()/..rxOptLhs() unqualified in the new tests, as the rest of the suite does, instead of going through rxode2:::
Three defects found by an independent review pass over the branch. R/dde.R -- the stored past() pieces are rxode2 source text, where THETA[1] is a subscript; in the symengine env it is the symbol THETA_1_. Evaluating the text directly therefore failed on every mu-referenced model, so .rxTauFromEnv() fell back to the verbatim duration while the matching delay() had its own inlined -- exactly the #1193 mismatch the branch set out to fix, still reachable from rxode2(mod, calcJac=TRUE) with a THETA[] duration. The same eval resolved the history, and an unresolved history is not a Basic, so .rxDelaySensAugment() also emitted no per-parameter sensitivity pre-history at all. Both now go through .rxSeEvalTxt(), which translates with .rxToSE() first. R/rxOptExpr.R -- .rxRealignPastTau() re-pointed a duration that had matched no delay() BEFORE optimizing, so the chunked path silently accepted a duration the whole-model path rejects (past(G,typo) became past(G,rx_expr_0)). It now rewrites only a duration that did match one, and takes the single-duration fallback only when a single past() line claims it, so two histories cannot be collapsed onto one duration with one silently shadowing the other.
…ld read Second independent review pass. .rxSplitStateTau() required a state name to start with a letter, so a model using a leading-dot state (d/dt(.y.1) parses) had its past() line skipped by .rxRealignPastTau() and left unmatched to its optimized delay(). The "did it match a delay() before optimizing" guard now applies only when the pre-optimization scan actually read that state's delay() durations. The scan reads a line at a time, so a delay() split over lines is not read at all, and the guard would have turned its absence into a refusal to re-point.
…e model Third independent review pass. .rxDelayDurs() reads a line at a time, so a delay() split over lines is not read at all -- and both guards then drew a conclusion the scan had not earned: a past() duration missing from the pre-optimization text was taken as already wrong (it may be the dropped call's), and the durations that were read were matched by position against the optimized ones, which are not the same calls in the same order. Either way a history could be re-pointed at another delay() term's duration, and a genuinely wrong duration could be re-pointed into a valid one on the chunked path. The scan now reports that it could not read a call, and .rxRealignPastTau() leaves every line alone when either text is incomplete -- which is what happened before this pass existed, so the validator says whatever it would have said.
Fourth independent review pass. .rxDelayDurs() matched delay( anywhere on a line, comments included, and the pre-optimization text keeps its comments. A comment naming a delay() the model does not have gave the state a duration no delay() call carries, and .rxRealignPastTau() then refused the rewrite the past() line needed, so the optimized model was rejected by .rxValidatePast(). The scan now takes the code part of a line, tracking string literals so a "#" inside one is not a comment; an unbalanced parenthesis in a comment no longer stops it either.
…duration Fifth review pass. .rxRealignPastTau()'s `orig` defaulted to NULL, and on that branch the "did it match a delay() before optimizing" guard was skipped and the single-duration fallback re-pointed anyway -- so the helper could still turn a duration .rxValidatePast() rejects into one it accepts. Without that text there is nothing to tell a duration that stopped matching from one that never did, and the only caller always has it, so it is now required. Also covered: an operator duration (`a*b`), where the emitted past() line carries deparse1()'s spacing and the augmented d/dt() the rxFromSE() form -- it matches and solves identically, which the comment now states accurately.
…ad code only Sixth review pass, one wrong-solution bug and one wrongly-rejected model. Optimizing can DROP a delay() duration -- 0*delay(G,10) folds to 0 -- and the single-duration fallback then re-pointed past(G,10) at the surviving delay(G,20), so a model that .rxValidatePast() should reject was accepted and solved with one delay() term carrying another's history. A state's durations are now only matched up when the two texts agree on how many it has; nothing else is guessed at, and what does not line up is left for the validator to report. The scan also read inside string literals, so a line as harmless as s="delay(G," marked the model unreadable and stopped the realign for all of it, leaving a model the validator rejects. It now takes the code part of a line: the comment dropped and every string's contents blanked, columns preserved.
Seventh review pass, a regression from the sixth. Masking string literals kept the scan from reading a delay() written inside one, but it also rewrote a duration that legitimately holds a string -- rxode2 takes a string comparison in a model expression, so delay(G, 1+(OCC=="first")) was read as 1+(OCC==" ") and the past() line naming the real duration no longer matched it, leaving a valid model rejected. A call is now LOCATED on the code-only form of a line and READ off the line itself; the top-level comma is found in the code-only form too, so a comma or a parenthesis inside a string no longer splits the call. The two forms agree column for column, which is what .rxCodeOnly() blanking rather than removing is for.
Follow-up: CodeFactor, and seven rounds of independent reviewCodeFactor is green (0 issues): the explicit The branch was then reviewed by two outside models (Gemini via Antigravity, GPT via Wrong answers
Models wrongly accepted
Models wrongly rejected
Considered, deliberately unchanged
Tests
Generated with Claude Code |
Closes #1192
Closes #1193
Two defects in how the delay duration of a
past(state, tau) <- exprline iscarried through the model machinery. Together they are why the nlmixr2 DDE
vignette has to pass
optExpression = FALSEto every fit.#1192 --
rxOptExpr()could not optimize an expression duration..rxOptLhs()recursed into the duration with the left-hand-side renderer,which only knows names and the dosing-property heads (
/and(work only byaccident -- they are there for
d/dt(...)). Sopast(G, exp(lT)),past(G, tau*2)andpast(G, exp(THETA[1]+ETA[1]))all hitstop("unsupported lhs in optimize expression"), after a strayprint()dumpedthe duration into the middle of the progress bar.
Only the whole-model path was affected --
.rxDisguiseCmt()hex-encodes thewhole left-hand side for the chunked path -- so the same model failed at 10
lines and optimized at 50. End to end:
The duration is now rendered by
.rxOptExpr(), the right-hand-side optimizer.It parses, and it picks up the same
rx_expr_temporary the matchingdelay()calls do -- which is what.rxValidatePast()needs to keep matchingthem. A bare name, a number and
(x)still render byte-identically.The chunked path cannot do that (the left-hand side is hidden from the optimizer
and restored byte-exactly), so
.rxRealignPastTau()re-points a restoredpast()duration at the one itsdelay()calls ended up with. It is text only-- the duration is never evaluated, it exists so a history can be matched to its
delay()(src/parseCmtProperties.h) -- and it never guesses: optimizing doesnot reorder statements, so the pre-optimization text says which optimized
duration each
past()line meant; a single duration is unambiguous without it;anything else is left for the validator to report.
#1193 -- the duration was never resolved in generated models
rx__pastTau_<state>__is stored as verbatim source text. All fourpast()emitters resolved the history right-hand side through the symengine env but
passed the duration straight through, while every
delay()had its durationinlined. A duration written as an intermediate was therefore emitted into a
generated model that intermediate had been dead-code eliminated from:
rxSolve()then rejected rxode2's own generated model withpast(G, T): duration 'T' does not match any delay(G, ...). Reachable fromrxode2 alone via
rxode2(mod, calcJac = TRUE)/calcSens=, not only throughnlmixr2.
.rxPastFromEnv()/.rxTauFromEnv()now resolve the duration the way thehistory already was, by evaluating a surrogate
delay(state, tau)in the env sothe rendering is identical to the augmented
d/dt()by construction (this alsocovers a constant-folded duration, stored as a plain numeric rather than a
Basic). Used at all four sites, including the exported.rxPastBaseLinesFromEnv()-- its signature is unchanged, so released reversedependencies simply get a corrected string.
No runtime behaviour changed. The duration reaches only
sbt(thenormalized-text buffer), never
sb/sbDt, so it is absent from the generated Centirely;
_rxDelay()calls_rxPast()for any pre-history time regardless.With
.rxValidatePast()stubbed out, a matched and a mismatched duration givebit-identical solutions. Fits were never silently wrong -- the generated models
just could not be round-tripped through
rxSolve().Verification
exp(lT),lT*2,2^lT,2*3,exp(THETA[1]+ETA[1])all optimize (all errored before);lT,12.8,(lT)unchanged.
past(G,rx_expr_0)+delay(G, rx_expr_0);chunked
rx_expr_c2_1), and the optimized model solves identically.calcJac=TRUE/calcSens=emitpast(y,exp(lT)), pass validation, and solvematching the plain model.
optExpressionboth
TRUEandFALSE, andrxSolve()now accepts the generated FOCEi innermodel in both cases (it errored in both before), identical
D[20].both paths.
test-opt-expr.R81 -> 112 assertions,test-dde-past.R26 -> 49, both greenlocally, as were
test-dde.R,test-dde-sens.Rand the adjoint suite (3230assertions passing before the second commit's rewrite; the individual DDE and
optimizer files re-ran green after it). Leaving the rest to CI.
The second commit is the result of an independent review pass, which found three
gaps in the first: a duration containing
=split the line in the wrong place;the delay scan parsed the reassembled model as R, which is rxode2 syntax and not
guaranteed to be valid R; and a state with several distinct delay durations was
skipped outright. Both scans now match parentheses instead, the way
.rxDisguiseDelayChunks()already does.Follow-up (not in this PR)
~/src/nlmixr2/vignettes/delays.Rmdno longer needsoptExpression = FALSEonce this ships.
🤖 Generated with Claude Code