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):

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.
Importantobserve 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.

¹ 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 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.

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, &params, 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-subject AdaptiveSubjectMetrics row per realized run: cumulative dose, dose-increase / -decrease / hold / discontinuation counts, time-to-discontinuation, the observed-signal summary (min / max / mean), pct_time_in_window when the block declares a target_window, and auc_target_attainment when it declares an auc_target. The point fields are derived from ledger + decisions alone, so they are auditable against those rows (no re-integration). Increases / decreases are counted by realized dose change, not by which rule fired — a decrease clamped 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_attainment is the one integrated-exposure field: the fraction of inter-decision windows whose area under the monitored signal falls in the auc_target band, 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 (a Stop) 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 keeps auc_target_attainment on the same realized basis as pct_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.

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 its LOCF 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).

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 levels ladder, 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_times never 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 as predict() / simulate(), and they appear in the controller’s dose history; 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. The auc_target exposure 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, so auc_target_attainment is not biased low on a base-regimen run (constant-covariate; the time-varying / IOV / reset auc_target caveats 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 controller Stop still 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 bioavailability F is 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, whose F is 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-RATE base 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 the TIME built-in is covered the same way. Caveat: the auc_target_attainment metric is not yet computed per-event, so declaring auc_target on 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 as predict() / 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), matching predict(). Base × reset under a time-varying covariate or IOV stays a typed error (a #932 follow-up). Caveat: declaring auc_target on 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 kappa is 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’s F resolves 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/). kappa is drawn on a dedicated per-(subject, replicate) substream (disjoint from the η and assay streams), so a non-IOV run is unchanged and enabling a Dv monitor never shifts the draws. Caveat: as with time-varying covariates, auc_target on 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 Dv monitor 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.