Adaptive (Feedback) Dosing
Maturity: beta — see Feature Maturity for what this means.
Adaptive dosing simulates regimens where future doses depend on the simulated state — therapeutic-drug-monitoring (TDM) target attainment, oncology toxicity-driven dose reduction, biomarker titration, target-controlled infusion. A plain simulation is a pure forward pass: every dose is fixed before integration. Here a controller is consulted at a schedule of decision times, reads the current state, and chooses what to do next.
There are two ways to drive a controller, and they compile to the same reactive engine — the same dose ledger, decision log, RNG substreams, and frozen-replay verifier (epic #391):
- the declarative
[adaptive_dosing]model-file block, run withsimulate_adaptive_from_spec()— the file-driven path, documented next; - the programmatic
simulate_adaptive(), which takes a hand-written controller closure — the lower-level path, documented under The controller.
Multi-model composition (PK + PD + safety) and an imperative [dose_control] block remain on the roadmap.
The declarative [adaptive_dosing] block
Put the policy in the model file and run it with simulate_adaptive_from_spec(). The block is declarative — a fixed dosing policy (trough < target → increase, grade 4 → stop), not a script — that rides with the run, never with a fitted model’s parameters.
[adaptive_dosing]
observe = central / V # monitored signal: a derived expression
at = every 24 from 0 to 168 # decision schedule (or an explicit list)
start_dose = 100 # dose issued at the first decision
route = bolus(cmt=1) # or: infuse(cmt=1, over=2)
dose_bounds = [0, 400] # every emitted dose is clamped here
confirm = 2 # act only after 2 consecutive breaches
when signal < 10 : increase 25% # first matching rule wins
when signal > 20 : decrease 25%
Keys
| Key | Required | Meaning |
|---|---|---|
observe |
signal¹ | The latent monitored signal — a free-form expression over states + individual parameters (e.g. central / V for concentration), titrated on with no measurement noise. Provide this or with_assay_error, never both. The when rules compare the keyword signal to its value. |
at |
✓ | Decision schedule on the subject clock: an explicit list [0, 24, 48] or an arithmetic sequence every <Δ> from <t0> to <t1> (inclusive). |
start_dose |
✓ | The dose issued at the first decision; seeds the running dose. |
route |
✓ | bolus(cmt=N) or infuse(cmt=N, over=T) (zero-order over duration T). |
dose_bounds |
✓ | Inclusive [low, high] clamp applied to every emitted dose. |
confirm |
Debounce: act only after this many consecutive matches of the same rule (default 1 = act on the first breach). |
|
levels |
Discrete titration ladder [d1, d2, …] (oncology). With levels, bare increase/decrease step one rung; without it, use increase N% / decrease N%. |
|
target_window |
Therapeutic band [low, high] for the monitored signal, used only to report the pct_time_in_window outcome metric — it does not influence dosing (the when rules do). high may be inf for a one-sided “at or above low” target. Omit it to leave pct_time_in_window unreported, rather than guessing a band from the rule thresholds. |
|
auc_target |
Exposure band [low, high] for the area under the monitored signal over each inter-decision window (e.g. vancomycin AUC₂₄ when decisions are daily), used only to report the auc_target_attainment outcome metric — like target_window it does not influence dosing. low must be ≥ 0; high may be inf. Declaring it turns on a signal-AUC pass that re-integrates any pre-scheduled base regimen (#702) together with the realized controller doses on a dense grid; omit it to skip that pass and leave auc_target_attainment unreported. |
|
with_assay_error |
signal¹ | Titrate on the noised measurement of a model output (named by assay_cmt) instead of a latent expression (default false). The signal’s value comes from that output’s [scaling] readout and its σ from that output’s [error_model], so the two are always on the same scale — there is no re-typed observe expression to mis-scale. Mutually exclusive with observe. |
assay_cmt |
The 1-based compartment of the model output measured under with_assay_error — its [scaling] readout is the signal value and its [error_model] σ the noise. Required with with_assay_error. |
¹ The signal source is exactly one of observe (a latent expression, un-noised) or with_assay_error = true + assay_cmt (a noised model output). Setting both, or neither, is a parse error. Titrating on a measured quantity uses the model’s own output (so its value and σ can never disagree on scale); titrating on a derived latent quantity (effect-site, a ratio — anything with no error model) uses observe.
Writing an observe expression
observe must not read a dose attribute
observe is a prediction path, so the same rule applies as in [odes] and [scaling]: a parameter the engine already applies at the dose event — on an ODE model F, LAGTIME/ALAG, or an indexed F{n}/ALAG{n}/LAGTIME{n}; on an analytical model whatever the pk(..., f=…) / lagtime=… mapping binds — must not be read back here. Writing observe = central / (V * F) applies F once to the dose and once to the signal, so the value the when rules compare against is wrong by exactly F — and unlike a mis-scaled readout this biases the titration decision, and every dose the controller goes on to emit. Such a model is rejected at parse time with E_DOSE_ATTR_DOUBLE_USE (#993, #1004); see Reading a dose attribute is an error, not a warning.
D{n}/R{n} are the exception, as everywhere: the engine only consults them for a dose that codes RATE=-2/-1, so an R1 in observe is reported against the dataset rather than the model file, and only when such a dose lands on it.
observe compiles through the same expression compiler as a [scaling] Form C readout, so it shares that block’s name scope — including the TIME built-in (spelled TIME or T), which resolves to the decision’s own time. A protocol-time-dependent target such as observe = central / V - TARGET * TIME / (TIME + T50) therefore titrates against the intended ramp (#1028).
The shared name scope includes the T precedence rule: a T (or t) declared in [covariates] is the data column, in observe exactly as in [scaling], so one model can never read the column in one block and the clock in the other. When T does fold into the built-in, the warning is reported at parse time — observe itself is compiled at simulate time, where there is no warnings channel. See Scaling.
Rules — when signal <op> <value> : <action>
Rules are evaluated top to bottom and the first match wins, so order the most specific / most severe condition first. <op> is one of < <= > >= ==.
| Action | Meaning |
|---|---|
increase N% / decrease N% |
Scale the running dose by ±N % (continuous titration). |
increase / decrease |
Step one rung up / down a levels ladder. |
hold |
Skip this decision’s dose. |
stop |
Discontinue all future dosing. |
A dose change is clamped to dose_bounds; a level step saturates at the ends of the ladder. When no rule matches (or a matched rule has not yet confirm-ed), the running dose is re-issued unchanged — the regimen continues, an explicit no-op rather than a silent skip.
Running it
use ferx_core::{parse_full_model_file, simulate_adaptive_from_spec, AdaptiveSimulateOptions};
use std::path::Path;
let parsed = parse_full_model_file(Path::new("titration.ferx"))?;
let spec = parsed
.adaptive_dosing
.as_ref()
.ok_or("model has no [adaptive_dosing] block")?;
// The block owns the schedule and the monitor, so leave `decision_times` and
// `monitors` empty here (setting either is a typed error); `seed` / `verify` /
// `max_decisions` still apply.
let opts = AdaptiveSimulateOptions { seed: Some(42), ..Default::default() };
let result = simulate_adaptive_from_spec(
&parsed.model, &population, &parsed.model.default_params, 100, spec, &opts,
)?;The block compiles to exactly the controller, monitor, and engine described below, so every guarantee in Monitors and Verification applies unchanged, and result is the same bundled trajectories / ledger / decisions. The lower-level programmatic API follows.
The controller
A controller is a closure consulted at each decision time. It receives a read-only ControllerCtx — the decision time, the live ODE state, the subject’s covariates, the realized dose history, and the resolved value of every declared monitor — and returns a list of DoseActions:
| Action | Meaning |
|---|---|
Bolus { amt, cmt } |
Instantaneous dose into 1-based compartment cmt. |
Infuse { amt, cmt, rate } |
Zero-order infusion; duration is amt / rate. |
Hold |
Skip this decision — no dose now, the regimen continues. |
Stop |
Discontinue all future controller dosing. An infusion already in flight runs to its scheduled end, and a pre-scheduled base regimen (#702) keeps dosing — Stop ends the controller’s augmentation, not the patient’s standing prescription. |
A Stop must be the final action in a decision’s list; issuing work after a Stop is a typed error, not a silently dropped dose.
What a decision reads
At a decision at time t, one data record supplies everything the controller reads: ctx.covariates, the PK parameters behind every monitored signal, and the bioavailability F of any dose it issues there. That record is the latest data record at or before t: an observation, an EVID=2 row, a pre-scheduled dose row or an EVID=3/4 reset row. When several records share that time, the observation wins over an EVID=2 row, which wins over a dose row, which wins over a reset row.
Decision at t |
Reads |
|---|---|
| before the subject’s first record | the baseline covariates and the t = 0 parameters |
| after an observation, EVID=2 or dose row | that row’s covariates and parameters |
| after an EVID=3/4 reset, with no record in between | the reset row’s covariates and parameters |
| on an IOV model | the same record, under the kappa of the decision’s own window |
A subject whose covariates never change reads the same values at every decision.
The state a decision reads (ctx.state) follows the rule predict() uses: an interval is integrated under the record that ends it. A reset row is a record, so a decision between the last record and a later reset reads a state integrated under the reset row’s parameters. The reset then restarts the state, so this changes what the controller sees before the reset, not any prediction.
A reset row’s own $PK is anchored to NONMEM (reset_init_snapshot_J). NONMEM has no feedback dosing, so the decision rule itself has no NONMEM counterpart. It is tested against predict() on the realized doses and against closed forms.
One controller per subject
simulate_adaptive() takes a factory (Fn() -> FnMut(...)), not a single shared closure. A fresh controller is built for each (subject, replicate). Real controllers carry per-subject state — a debounce counter, a windowed AUC, the current rung of a titration ladder — and a single shared closure would leak that state from one subject to the next. The factory makes the isolation structural: a stateless rule is just a factory whose closure ignores its environment.
Monitors
A controller reads named signals declared as MonitorSpecs. Each monitor observes a 1-based compartment/endpoint under an ObserveMode:
Ipred— the latent individual prediction (no measurement noise).Dv— the realized, assay-noised measurement:IPRED + ε·√(residual variance), clamped at 0, with the residual variance taken from the endpoint’s own[error_model]. This is the realistic TDM / titration signal — a clinician titrates on a measured value, not the latent truth.
Mode is chosen per monitor, so a PK exposure target can run on Ipred while a safety marker (e.g. ANC → CTCAE grade) runs on Dv in the same simulation.
The engine — not the controller — resolves every monitor and draws any assay noise, on a dedicated controller-assay RNG substream keyed by (subject, replicate, decision, analyte). That identity keying makes the assay draws (under a fixed seed):
- deterministic — re-running reproduces them exactly;
- permutation-invariant — a subject’s draws do not depend on iteration order;
- non-perturbing — adding a monitor never shifts another monitor’s (or η’s) draws, so the frozen-replay verifier stays exact.
A Dv monitor on a compartment with no [error_model] is a typed error (never a fabricated σ), and a noised reading below zero is clamped to 0 — but only on the default bare-state readout, where non-negativity is a physical property of the quantity. Since #1039 a Form C [scaling] y = <expr> readout is exempt: the expression may be legitimately signed (a change from baseline, a z-score, a difference from comparator), so its negative samples reach the controller unchanged, exactly as they do under Ipred. See Scaling. With sigma → 0 a Dv monitor therefore reproduces the Ipred monitor sample for sample on a Form C readout, negative samples included; on the bare-state readout the floor still applies, so the two agree only where the latent value is non-negative.
Example
use ferx_core::{
simulate_adaptive, AdaptiveSimulateOptions, ControllerCtx, DoseAction, MonitorSpec, ObserveMode,
};
let opts = AdaptiveSimulateOptions {
seed: Some(42),
decision_times: vec![0.0, 24.0, 48.0, 72.0, 96.0],
// Titrate on the noisy assay (DV), as in real TDM. Use `ObserveMode::Ipred`
// to titrate on the latent prediction instead.
monitors: vec![MonitorSpec::new("CONC", 1, ObserveMode::Dv)],
..Default::default() // verify = true, max_decisions = 10_000
};
// A fresh controller per subject: titrate a trough toward the [10, 20] window.
let make_controller = || {
move |ctx: &ControllerCtx| match ctx.signal("CONC") {
Some(c) if c < 10.0 => vec![DoseAction::Bolus { amt: 150.0, cmt: 1 }],
Some(c) if c > 20.0 => vec![DoseAction::Hold],
_ => vec![DoseAction::Bolus { amt: 100.0, cmt: 1 }],
}
};
let result = simulate_adaptive(&model, &population, ¶ms, 100, make_controller, &opts)?;The result bundles four tidy, long-form artifacts — each tagged by (draw, sim, id) so they join cleanly:
trajectories— per-observation predictions (Vec<SimulationResult>).ledger— every realized dose, with provenance (the decision that produced it, observed signals, applied bioavailability).decisions— one row per decision including holds, so non-events are on the record rather than inferred from gaps in the ledger.metrics— one per-subjectAdaptiveSubjectMetricsrow per realized run: cumulative dose, dose-increase / -decrease / hold / discontinuation counts, time-to-discontinuation, the observed-signal summary (min / max / mean),pct_time_in_windowwhen the block declares atarget_window, andauc_target_attainmentwhen it declares anauc_target. The point fields are derived fromledger+decisionsalone, so they are auditable against those rows (no re-integration). Increases / decreases are counted by realized dose change, not by which rule fired — adecreaseclamped at the lower bound re-issues the same dose and is not counted. The signal summary is over the troughs/peaks the controller saw at the decision times (a decision-grid summary, not a dense-trajectory extremum).auc_target_attainmentis the one integrated-exposure field: the fraction of inter-decision windows whose area under the monitored signal falls in theauc_targetband, computed by a separate signal-AUC pass that re-integrates the realized doses on a dense grid (it never perturbs the reactive run). It is scored over the windows between realized decisions, so if a run discontinues (aStop) the later, dose-free scheduled windows are not counted — discontinuation is already its own metric, and folding it in here too would double-count it; this keepsauc_target_attainmenton the same realized basis aspct_time_in_window. Population summaries with uncertainty bands ride with the uncertainty slice, where bands carry meaning.
Verification (on by default)
Adaptive dosing is bookkeeping-heavy (event ordering, bioavailability/lag, segment carry-over). simulate_adaptive() runs a frozen-schedule replay verifier after every run (verify = true): it rebuilds a static subject from the realized dose ledger, re-integrates it through the trusted static engine, and checks the reactive trajectory against it. Because the reactive driver and the static engine are different code, agreement proves the driver applied every dose identically to the static engine. A divergence is a typed error that taints the result — never a silent wrong number.
The replay reproduces the reactive driver’s segment structure — the driver restarts the integrator at every decision time (holds included), so the replay feeds those same decision times back in as no-op breaks. It builds the rest of that structure — a dose’s lag-shifted arrival, an infusion’s bioavailability-scaled end, a per-route absorption onset, and a zero_order() window’s two edges — through the same break-time builder the driver folds its pre-scheduled base regimen in with, rather than a second copy of the rule (#1188). That matters because a zero_order() window’s constant rate is only applied to a segment the window fully contains: a builder that failed to break at an edge would drop the rate for every segment straddling it, and the replay would then report a dose-bookkeeping mismatch that was its own artifact. One builder, so the two sides of the oracle cannot segment the timeline differently. Both engines then walk the same segments through the same integrator, so the comparison sits at the solver’s true round-off floor (a small multiple of its error control) rather than a wide held-decision slack. A real bookkeeping bug — a dropped dose, wrong compartment, double-applied bioavailability — moves a prediction by orders of magnitude more, so the tight check still catches it without ever false-positiving on a legitimate run.
On the time-varying-covariate and IOV paths the driver and the replay share precomputed per-event / per-occasion PK snapshots, so replay agreement alone would only prove the two consumed them identically — not that the snapshots were built correctly. The verifier therefore also independently re-derives each snapshot (the per-occasion kappa, the per-decision PK at the decision’s covariate, the per-record PK) from the run’s primitives and bit-checks it against what the driver used, so a mis-resolved covariate or occasion fails loudly rather than being applied by both sides and slipping through unnoticed (#748). It finds the decision’s record with the driver’s own resolver, so it checks the plumbing, not which record a decision reads (What a decision reads is tested against predict() directly).
Validation
NONMEM has no native feedback dosing, so there is no equivalent NONMEM run to anchor against — the standard NONMEM comparison does not apply to this feature family. It is validated instead by:
- a degenerate oracle — a controller that re-emits a fixed regimen reproduces a plain simulation of that regimen, bit-for-bit;
- the frozen-schedule replay verifier above (an exact internal bookkeeping anchor);
- the three-witnesses check — a declarative
[adaptive_dosing]block and a hand-written controller closure with the identical ladder realize the identical ledger, decision log, and trajectory, byte-for-byte (so the block compiles to exactly the engine the programmatic API exercises); - a closed-loop check — a titration that starts below its target band drives the trough into the band and holds it there;
- for genuinely reactive behaviour, reproduction of external mrgsolve dynamic-dosing runs (the apples-to-apples external comparator, since mrgsolve does do feedback dosing) — one for each titration mode: the discrete
levelsladder, in The platelet-ladder anchor, and continuous (percentage) titration plus the AUC-exposure metric, in The vancomycin AUC-TDM anchor.
The platelet-ladder anchor
The levels ladder is cross-checked against mrgsolve 1.7.2 on an oncology dose-modification scenario — a Friberg semi-mechanistic myelosuppression model (Friberg et al., J Clin Oncol 2002) driven by a 1-compartment IV drug. Circulating platelets are read at the start of each weekly cycle; the dose steps down a discrete ladder (100 → 75 → 50 → 25 mg) on thrombocytopenia, with a severe floor that stops treatment. ferx (RK45) and mrgsolve (LSODA) integrate the identical ODE system and an R loop mirrors the controller semantics exactly, so the two engines must reach the same decisions. Under the top dose the platelets fall, the controller de-escalates two levels (100 → 75 → 50 mg), and platelets recover and hold:
| Cycle | Platelet (×10⁹/L) | Dose (mg) | Action |
|---|---|---|---|
| 0 | 250.0 | 100 | start |
| 1 | 169.6 | 100 | continue |
| 2 | 94.3 | 75 | decrease |
| 3 | 98.7 | 50 | decrease |
| 4 | 151.3 | 50 | continue |
| 5–9 | 158–179 | 50 | continue |
ferx reproduces every dose exactly and every platelet value to < 0.01 ×10⁹/L — a cross-solver difference ~3 × 10⁻⁵ relative, far below the ~25-unit margin from each decision to its rule threshold, so every dose decision is robust. The model is examples/adaptive_platelet_ladder.ferx; the mrgsolve model, R driver, and frozen output live in tests/reference/platelet_mrgsolve/, exercised on every PR by tests/adaptive_platelet_anchor.rs.
The vancomycin AUC-TDM anchor
Continuous (percentage) titration and the auc_target exposure metric are cross-checked against mrgsolve 1.7.2 on a vancomycin TDM scenario — a 1-compartment IV model dosed once daily by a 1-h infusion. At the start of each day the controller reads the pre-dose trough and titrates the dose ±25 % to hold the trough in [10, 15] mg/L (the control law), while the reported outcome is AUC₂₄ attainment against the guideline band [400, 600] mg·h/L — the real trough-vs-AUC vancomycin tension, where dosing follows a single timed level but efficacy is judged on the daily exposure. The empiric start is subtherapeutic, so the dose climbs (seven 25 % steps) and holds once the trough enters the band:
| Day | Trough (mg/L) | Dose (mg) | AUC₂₄ (mg·h/L) | On AUC target |
|---|---|---|---|---|
| 0 | 0.0 | 625 | 108 | no |
| 4 | 6.2 | 1526 | 350 | no |
| 5 | 7.8 | 1907 | 438 | yes |
| 6 | 9.7 | 2384 | 548 | yes |
| 7 | 12.1 | 2384 | 581 | yes |
| 8–12 | 12.9–13.2 | 2384 | 592–596 | yes |
ferx reproduces every dose exactly (the titration is exact f64 arithmetic shared with the R loop), every trough to a small cross-solver tolerance, and the AUC-target attainment fraction (8 of 13 daily windows) exactly. ferx integrates the daily exposure by a 128-panel trapezoid per window while mrgsolve uses an AUC compartment; the two agree to ~10⁻⁵ relative, far inside the margin from each AUC₂₄ to the band edges, so the in/out classification — hence attainment — is identical. (The exact AUC-pass accuracy is pinned separately by an analytic unit test against the closed form (D/k)(e^{-k a} − e^{-k b}).) The model is examples/adaptive_vanco_auc.ferx; the mrgsolve model, R driver, and frozen output live in tests/reference/vanco_mrgsolve/, exercised on every PR by tests/adaptive_vanco_anchor.rs.
Model time in a reactive run
Where a reactive run starts
A reactive run starts where predict() starts on the same realized schedule: at the subject’s first record (observation, PK-only row, base dose or reset) or at the controller’s first realized dose, whichever is earlier — NONMEM’s first-record convention. Before that origin the state is its init(...) value and does not evolve, so a decision placed earlier reads the init state. A dataset whose first record is at t = 6 is not integrated over a phantom [0, 6] window.
Dose clocks before the first dose
A [odes] RHS may read the dose clocks TAD (time since the last dose) and TAFD (time after the first dose) — a natural shape for a PD controller, where time since the last dose drives an effect compartment. Both are supported reactively and match predict() on the realized schedule.
They are anchored at a dose, which is the one thing a reactive run does not know in advance. Before the controller’s first dose — and with no pre-scheduled base regimen to anchor them — TAD and TAFD have no referent at all. Where the record does contain a dose, predict() fills that window by looking ahead to the first one, which is a peek a causal simulation has not earned: that dose has not been decided yet, and may never be. (On a record with no dose anywhere, predict() has no anchor either and returns NaN in that window too.)
So simulate_adaptive() refuses the window with a typed error rather than hand you an unanchored trajectory. The message names the segment, the spelling that is unanchored (TAD or TAFD), and the two fixes:
- give the subject a pre-scheduled base regimen — base dose times are known up front, so they anchor the window exactly as
predict()does; or - have the controller dose at a decision at or before the subject’s first record, so no segment is integrated before the first dose.
Neither fix helps a controller that never doses: such a run has no anchor at any point, and the message says so.
The refusal covers the whole pre-dose window, not only the observations inside it: what an unanchored clock does to the derivative is integrated into the state — as a NaN, or as the wrong branch — so a read taken later, even after the first dose has landed, would inherit it.
How the check decides
It is gated on what the clock does, not on the text of your model. After each segment is integrated, the check asks whether the right-hand side’s derivative over that window depends on the missing anchor. It evaluates the [odes] right-hand side at 17 evenly spaced times across the window — at the state the window started from and the one it ended at, each only when every compartment in it is finite — once as the window ran, and once with the clock anchored at the window’s end, and refuses only if some derivative differs. Nothing is re-solved. The anchor is the earliest one a schedule can give: no dose has landed by the window’s start, so the first dose comes at its end or later, and the clock predict() reads there is never positive. That covers both ways a clock gets in:
- through arithmetic, as in
(1 + KT * TAD): theNaNreaches the state and the message says the segment “integrated to a non-finite state”; - through a comparison —
if (TAD < 5),min(TAD, 24),min(TAD, 0): an ordering comparison against the unanchored clock is false, so the window silently takes one branch and the state stays finite, but the answer depends on which way the comparison fell. The message says the derivative changes when the clock is anchored.
A comparison that only switches for a positive clock — if (TAD > 5), max(TAD, 0) — is not refused, and needs no refusal: the unanchored clock takes the same branch that every clock a schedule can give the window takes, so the run already matches predict().
A TAD that appears in a branch the pre-dose window does not take, or only in a condition on an empty compartment (where every branch gives the same zero derivative), is left alone. So is a compartment that diverges for its own reasons on a model that merely mentions TAD somewhere — it is not refused, and not blamed on the clock. A RHS that reads only TIME / T is unaffected in every case, since that clock is the integration axis and is anchored everywhere.
Not being refused is not the same as being right, and on one of those shapes it is emphatically not: a model whose other compartment has run away is left alone here, but the run it then returns is wrong for a reason this page cannot fix. When any state goes non-finite the solver stops advancing every state, so the remaining compartments freeze at their last value and come back as ordinary finite predictions — from predict() as much as from the reactive driver, which is also why the replay verifier agrees with them. That is tracked as a separate engine defect. If a compartment of your model can diverge, neither this check nor the verifier will tell you.
What the check cannot see
It samples, so a dependence confined between two of its 17 points, or one that shows only at states in the middle of the window, can slip through. Because it pairs the window’s start and end states with every sample time, it can also refuse a combination the run never reaches — a condition on both a compartment and TIME — although the run would have matched predict(). And it anchors the clock at the window’s end, while a later first dose puts the clock further below zero: a condition that only switches there — if (TAD < -20) over 12-hour windows with the first dose at 36 — is not seen, and the run returns numbers that depend on it. Probing every decision a dose could land at instead is tracked separately. The frozen-schedule replay verifier, on by default, remains the net for these, with two limits of its own: it fires only when an effect exceeds its tolerance band — 8 × ode_reltol, and at least 1e-9, relative — and it compares two engines, so it cannot see a failure they share, which is exactly the frozen-state case above.
Current scope and limits
- ODE models only (the reactive driver runs on the ODE engine).
- A non-empty decision schedule is required — an empty
decision_timesnever consults the controller, so it is rejected rather than run as a silent dose-free simulation. - Pre-scheduled base regimens are supported (#702) — the base subject may carry a loading / maintenance regimen, including a steady-state (
SS=1) dose and doses into lagged or built-in input-rate (transit / zero-order absorption) compartments, which the driver integrates and the controller augments at the decision schedule (the real TDM / MIPD workflow: start on a fixed regimen, then titrate on measured levels). Base doses flow through the same dose-resolution, break-timeline, and steady-state machinery aspredict()/simulate(), and they appear in the controller’s dosehistory; the frozen-replay verifier rebuilds the base regimen alongside the realized ledger, so the pre-scheduled + reactive bookkeeping is checked bit-for-bit each run. Theauc_targetexposure metric likewise integrates the base regimen: each window’s AUC covers the base doses — a loading dose before the first window and a maintenance dose landing inside a window alike — not just the controller’s, soauc_target_attainmentis not biased low on a base-regimen run (constant-covariate; the time-varying / IOV / resetauc_targetcaveats below are unchanged). A base dose that shares a time with a decision is observed pre-dose (the trough), symmetric with the controller’s own doses — the controller reads the level before the scheduled dose lands, then that dose is applied (#933); for a steady-state base dose the pre-dose readout is the steady-state trough. A base dose scheduled past a controllerStopstill lands — the base regimen is the patient’s standing prescription, which the controller discontinues its own augmentation of but does not cancel. (Base doses into lagged / input-rate compartments are supported because they run the exact pre-resolved static machinery; controller-issued doses into those compartments are still rejected at injection, where the TAD-anchor bookkeeping for a dose discovered mid-run is a follow-up.) Scope: supported on constant-covariate models, on time-varying-covariate models (since #930), and on IOV models (since #931), for a plain bolus / infusion base dose, whose bioavailabilityFis resolved from its own snapshot — the covariate active at, and (under IOV) the occasion κ of, the dose’s administration time (symmetric with a controller dose, whoseFis fixed at injection) — and which is then integrated under the per-segment PK, matching independent mrgsolve loading-dose runs (renal-decline #930, per-occasion κ #931). A base regimen also composes with system resets (EVID=3/4) on the constant-covariate path (since #932): the reset zeros the state and turns off a base infusion opened before it, and an EVID=4 reset+dose row’s dose lands after its own reset — see System resets below. Still a typed error, never a silent mis-integration of a delivered dose: a base regimen combined with a system reset under a time-varying covariate or IOV (a #932 follow-up), and — under a time-varying covariate or IOV — a steady-state, lagged, built-in input-rate, or modeled-RATEbase dose (a #930 / #931 follow-up whose per-dose SS / lag / input-rate / rate-resolution bookkeeping the frozen-replay engine does not yet reproduce). - Time-varying covariates are supported — the driver recomputes PK per event/segment from the covariate active in that segment (the NONMEM end-of-interval convention that
predict()/simulate()use), so a covariate that changes over the horizon (e.g. declining renal function driving clearance) correctly drives CL/V/KA, the monitored signal, and every dose decision; the frozen-replay verifier validates the per-event bookkeeping. A model whose PK reads theTIMEbuilt-in is covered the same way. Caveat: theauc_target_attainmentmetric is not yet computed per-event, so declaringauc_targeton a time-varying-covariate subject is a typed error rather than a frozen-snapshot AUC. - System resets (EVID=3) are supported — the reactive driver zeros the compartments at each reset time (re-seeding any
init(state)=expr) and turns off controller-issued infusions opened before the reset, exactly aspredict()/simulate()do on the event-driven path; the frozen-replay verifier is reset-aware, so the reset is honored and checked each run. Since #932 a reset also composes with a pre-scheduled base regimen on the constant-covariate path: it zeros the base regimen’s state and turns off a base infusion opened before it (the reset floor), and an EVID=4 reset+dose row — which records both a reset and a dose — reaches the adaptive path as a base dose landing after its own reset (Reset < Dose), matchingpredict(). Base × reset under a time-varying covariate or IOV stays a typed error (a #932 follow-up). Caveat: declaringauc_targeton a reset subject is a typed error — the exposure metric’s uniform per-window trapezoid grid has no node at the reset instant, so the one window straddling the reset would integrate the state discontinuity inaccurately (every other output is reset-aware). - Inter-occasion variability (IOV) is supported — a fresh occasion
kappais drawn per decision window (occasion = decision index) and threaded through the per-event PK, so occasion-to-occasion shifts in CL/V drive the trajectory, the monitored signal, and every reactive dose; the frozen-replay verifier validates the per-occasion bookkeeping. A pre-scheduled base regimen (a loading dose) composes with IOV (#931): each base dose’sFresolves under the κ of the occasion active at its administration time — see Pre-scheduled base regimens above. It also composes with time-varying covariates — the composition (CL = TVCL·(CRCL/100)·exp(η + κ)under a declining covariate and a per-occasion κ) is cross-validated dose-for-dose against an independent mrgsolve run (tests/reference/vanco_renal_iov_mrgsolve/).kappais drawn on a dedicated per-(subject, replicate) substream (disjoint from the η and assay streams), so a non-IOV run is unchanged and enabling aDvmonitor never shifts the draws. Caveat: as with time-varying covariates,auc_targeton an IOV model is a typed error (the exposure metric is not yet computed per-occasion). Between-subject η is still drawn per subject/replicate; parameter-uncertainty propagation is a later layer. - Deterministic ODE only — a stochastic (
[diffusion]/ SDE) model is rejected (the reactive integrator carries no process noise). - Diagonal assay noise — each
Dvmonitor draws independently; correlated cross-endpoint (block_sigma) assay noise is a follow-up. - One monitored signal per block — the declarative block titrates on a single
observe; multiple named per-analyte monitors (already supported by the programmatic engine) are a declarative-surface follow-up. - Roadmap — population outcome summaries with uncertainty bands, the imperative
[dose_control]block, and PK + PD + safety model composition.