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 the realized 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.
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 dosing. An infusion already in flight runs to its scheduled end. |
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.
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.
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. 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.
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 by the slow-gated tests/adaptive_platelet_anchor.rs (nightly + on push to main).
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 by the slow-gated tests/adaptive_vanco_anchor.rs (nightly + on push to main).
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. - Dose-free base subjects — the regimen is fully controller-driven; a subject that already carries doses is rejected.
- Diagonal assay noise — each
Dvmonitor draws independently; correlated cross-endpoint (block_sigma) assay noise is a follow-up. - Between-subject variability only — η is drawn per subject/replicate; parameter-uncertainty propagation is a later layer.
- 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.