PharmFlux 0.1.0 documentation

Fixed administration requests

Status: implemented M1 event subset, not the complete event contract.

A text-model worker accepts exactly one protocol (the existing numeric form) or regimen. A regimen contains unit-bearing end, samples, and administrations, plus rtol and exactly one absolute-tolerance mode. The WASM CompiledSimulation.simulate_regimen entry point uses the same native expansion and simulation code.

Example administration:

{
  "target": "central",
  "time": {"value": 0, "unit": "h"},
  "amount": {"value": 6000, "unit": "ug"},
  "delivery": {"kind": "bolus"},
  "repeat": {"interval": {"value": 120, "unit": "min"}, "additional": 2},
  "lag": {"value": 30, "unit": "min"},
  "bioavailability": 0.5
}

The target must be a named state with a declared dosing mapping. Times convert to the model time unit; amounts convert to the mapping's amount unit. The example delivers three doses at 0.5, 2.5 and 4.5 h. Each contains 3 mg before the model's amount-to-state conversion.

Expansion rules

The expanded count is checked before allocating expanded events. Counting uses u64 on native and wasm32; the default worker limit is 10,000 administrations. Limit failures return work_budget, including a request for u32::MAX additional doses. Per-administration validation errors carry administrations[index]. A failure produces no partial scientific result. Unknown targets, incompatible units, invalid fractions, invalid intervals and unrepresentable times fail.

Remaining scope

This is a unit-bearing administration/time request, not the final full run schema. Named administration channels beyond the current state mappings and steady state are not implemented. Explicit observation-side selection is described below. Dynamic event expressions are not accepted by this schema. Solver requirements, complete result identity and governed product integration remain separate work.

State resets

A regimen may now contain resets (omitted means empty):

{
  "target": "central",
  "time": {"value": 120, "unit": "min"},
  "value": {"value": 3000, "unit": "ug"},
  "order": 0,
  "active_inputs": "stop_target"
}

The target names a state; it does not need a dosing mapping. The value converts to the state's unit and must be finite. Resets share the run's event budget with expanded administrations. The raw protocol has a corresponding kind: reset variant with target index, numeric time/value and the same required policy/order. Event::amount() now returns an option: resets do not have a dose amount.

At a boundary the driver records the pre-state, treats ending infusions as inactive, applies resets in ascending order, applies boluses, starts new infusions, and records the post-state. Multiple resets at the same exact time must have distinct order values, including when they name different states. Duplicate orders fail before integration. Reordering input records does not change the explicitly ordered reset result.

Active input policies are explicit and required:

Stopped infusion records remain stopped for the rest of that run; a later continue reset does not restart them. Infusions are tracked by their stable record index within the immutable protocol. Original scheduled end times may remain in the boundary/output timeline even when a reset stopped an infusion early. They do not deliver additional drug. The solver is reinitialized at reset boundaries, as at the existing dosing discontinuities.

No occasion label causes an implicit reset. No model-wide nonnegative reset constraint is inferred: the implemented reset check enforces finite values and units. General model invariants and reset sensitivity jump qualification remain separate outstanding work.

Explicit observation times and sides

A regimen can select deterministic outputs using a nonempty observations list. Each point requires a unit-bearing time and a side:

{
  "samples": [],
  "observations": [
    {"time": {"value": 120, "unit": "min"}, "side": "pre"},
    {"time": {"value": 2, "unit": "h"}, "side": "post"}
  ]
}

These are evaluation-time requests, not measured observation values or a statistical observation model. At a declared event, pre selects the state before the boundary operations and post selects the state after them. At a continuous, non-event instant, both sides use the same state. The returned side still matches the requested label. An unavailable side at a declared event is an error, not a fallback to the other side.

The result contains exactly the requested points, in request order, including duplicates. Times are converted to the model time unit. Consumers must not assume that this selected result is sorted by time. The full trajectory mode remains available by omitting/nulling observations and using samples.

Combining nonempty trajectory samples with an observation plan is rejected. An explicitly empty observation plan is also rejected. Missing/unknown side labels, incompatible units, out-of-window times and output-budget violations fail before returning a scientific result.

Output expressions are evaluated only at selected points in observation mode. For example, a requested post-dose log(amount / amount_ref) can be valid even if its unrequested pre-dose value is undefined. Requesting that pre-dose value returns its domain error and no partial selected result. The ODE itself still must have valid domains throughout integration. Trajectory mode continues to check outputs at every returned trajectory point.

Memory checks cover retained state rows plus selected output values. Event and solver-work budgets remain in effect. The current observation result has all declared deterministic output columns; per-endpoint selection, measured data, censoring and likelihood evaluation remain outside this implemented slice.

Covariate steps

A regimen accepts a covariates object mapping names to baseline quantities and covariate_changes, each with name, quantity time and quantity value. Changes require a declared step covariate. They must be finite, inside the run, and unique per covariate at each exact time. Different covariates changing at the same instant are rebound together. Constant covariates reject changes.

Order at a boundary: capture pre-event state and binding, apply covariate changes, apply ordered state resets, apply boluses, capture post-event state and binding. Initial conditions use the baseline binding before time-zero changes. A covariate step does not reset the evolving state or re-evaluate its initial condition. Explicit state resets are required for instantaneous state changes. The changed binding recomputes derived parameters and dose conversions. Ongoing infusion amount rates use the current amount-to-state conversion after a step; this alone does not rescale the existing state.

The entire left interval, including solver evaluations at its ending time, uses the left binding. Integration restarts at the exact change time. Output projection uses the binding appropriate to each time and requested pre/post side, including out-of-order observations and changes at zero or run end.

Changes share the event budget with administrations and resets. Numeric storage for bound contexts is charged against the available value budget before allocating the schedule; remaining storage is reserved for states and outputs. One callback budget covers the whole solve, including all interval restarts. Failures produce no partial trajectory and do not alter the baseline binding.

Use BoundDocument.simulate_regimen for these requests. The low-level regimen_protocol converter rejects covariate-bearing requests because a raw dosing protocol has no representation for binding changes. The WASM worker uses the full regimen path.

State tolerances

Each request supplies exactly one of:

The compiler converts quantities to state units and orders the vector by state declaration before passing it to Diffsol. Missing/extra names, incompatible units, zero/negative/nonfinite values, and conflicting or missing modes fail before solving. rtol remains dimensionless. The same validated vector is used across all dose, reset and covariate intervals. These are solver error-control settings, not a guarantee that global trajectory errors equal their values.

The full regimen API handles per-state tolerances. The raw-protocol converter rejects this mode because its legacy scalar protocol cannot represent it.

Zero dose fractions

A dose-to-state scale must be finite and nonnegative. Zero is a valid parameterized fraction and delivers no state increment or infusion input. The administration still consumes the ordinary event budget and preserves its boundaries and observation sides. Rebinding recomputes the scale; a later positive binding restores delivery. Negative and nonfinite scales remain errors. This applies to both typed regimen execution and raw bolus/infusion protocols.

Declared fixed switches

Models may declare up to 1,024 switches, each with a unique name and a time expression. The expression must have time dimensions and may reference only declared parameters. States, inputs, covariates, other switches and time itself are excluded. Rebinding parameters recomputes the schedule; gradients through moving switch times remain unsupported.

Each switch supplies a dimensionless numeric binding: zero before its event, one afterward. Use it arithmetically or in a condition such as active > 0. For example, the text language accepts:

switches
  active = start_time
  stopped = start_time + duration

start_time and duration must be declared parameters with time units. JSON uses "switches": [{"name": "active", "time": {"kind": "symbol", "name": "start_time"}}]. Unknown fields are rejected. Switch names share the model binding namespace; callers cannot override them through covariate values or covariate changes.

Switches lower to owned step bindings. Interior boundaries retain pre values, then update all simultaneous switches and covariates, then apply resets and doses, and retain post values. The solver restarts at each boundary. A switch at or before the run start is already active when initial expressions are evaluated; this initializes the requested run and does not replay history. Switches after the run end remain zero. A switch at the run end still has pre and post observations. Every in-window switch change consumes an event budget entry before simultaneous times are merged, alongside other declared events.

Use execute or simulate_regimen. Raw protocol simulation of a bound switch model fails explicitly because it cannot carry the owned schedule. Existing models with no switch declarations retain their serialized model identity.

Native analytic tests cover integration across switches, observation sides, start-time initialization, parameter/unit rebinding, simultaneous switch/reset/ dose ordering, independent repeated execution, name and time restrictions, forbidden overrides, event budgets and text round trips. Browser and Python package qualification for this feature is pending. Plain phase declarations retain the side-based semantics above. Explicit time predicates use the additional pointwise-readout contract below.

Strict and inclusive time predicates

A switch can additionally declare comparison as lt, le, gt or ge. Its binding then represents the numeric truth value of time comparison threshold. For example:

switches
  active = time > start_time
  before_end = time <= end_time

JSON stores the threshold in time and the operator in comparison. The threshold still depends only on parameters and must have time dimensions. Equality and inequality (eq, ne) predicates are not qualified.

Integration uses the constant truth value on each open interval, with a restart at every threshold. Outputs evaluate the predicate at the exact sample time, preserving the difference between strict and inclusive comparisons. Both pre- and post-event readouts use that pointwise predicate value; their state and ordinary covariate values still follow the requested side. Initial expressions use the exact predicate at the run start, then integration enters the right-hand interval. No epsilon shift or smoothed approximation is used.

Predicate-dependent dose scales, including dependencies through definitions or individual parameters, are rejected until their event-time semantics qualify. Plain phase switch dose scales retain the earlier step-binding contract. Parameter sensitivities through event times remain unsupported.

Native tests cover all four predicates, analytic trajectories, exact pre/post readouts, initial values at a threshold, text round trips, and unsupported predicate uses. The legacy adapter explicitly lowers comparisons between t and parameter-only thresholds (including reversed comparisons), retains their source trees and deduplicates identical predicates. Unsupported time expressions and state-dependent thresholds remain compiler errors.

Model-bound dosing lag

A state dosing declaration may include lag, a time-valued expression. It may reference model parameters, constant covariates and algebraic definitions using those inputs. The expression is evaluated for each bound run, so changing parameters changes the schedule without recompiling the model. State, time, step-covariate and individual parameter dependencies are currently rejected by the lag compiler. Constant covariates use the effective bound/request values and their declared units; missing required values fail without producing a trajectory.

The effective lag is the model lag plus the administration's explicit lag. Both must be finite and nonnegative. The existing event expansion applies this sum to each repeated administration and to infusion start/stop times. Model lag does not alter bioavailability or infusion duration. Requests and model hashes retain their original expressions and input quantities.

Use execute or bound simulate_regimen. Raw event/protocol APIs reject models with lag because they cannot bind its expression. Text authoring uses lag(central) = delay in the dosing block; formatting preserves the expression. Duplicate declarations and targets that do not accept dosing are rejected. Browser/Python package qualification for this new feature is pending; previous packaged builds do not include it.

Native tests cover unit conversion, additive administration lag, repeated boluses, parameter rebinding, exact boundary sides, analytic trajectories, negative lag, state dependencies and dimension errors.

Administration metadata for history consumers

pharmflux_core::regimen::expand_administrations_in_window returns one validated ExpandedAdministration per occurrence. Each record preserves the original administration index, zero-based repetition index, scheduled time before lag, administered amount before bioavailability, and the ordinary delivered event. The event time includes lag. Amounts use the target input unit and times use the run time unit. Model-specific amount-to-state scaling occurs later.

The existing expand_in_window delegates to the same expansion and returns only its events. Both APIs share validation, budgets, stable simultaneous-event ordering and infusion-duration calculations. In particular, F = 0 does not erase the original amount from the metadata record.

The model bindings below use these records. Reset history and nullable observation representations still require separate contracts.

Immutable history lookup

dose_history::DoseHistory owns normalized expanded administration records and indexes them by target and delivery time. at(target, time, side) returns the selected raw amount, delivery and scheduled times, elapsed time, and original administration/repetition indices. Query order has no effect, including a backwards query after an adaptive solver trial. No prior dose returns None. Non-finite times, elapsed-time overflow and malformed records return errors.

At an exact boundary, Pre excludes all doses at that time and Post includes the selected dose. SimultaneousDose::First and Last make tie selection explicit; input order within equal times is stable. Owned native rxode2 bolus probes select First, including when the two original records are reversed. Times one representable value apart remain distinct.

Infusion entries start history at delivery; stopping an infusion does not erase that history. This concerns history readouts only and does not establish source runtime equivalence for infusion delivery/F conventions. Reset entries are rejected pending an explicit history policy. Model-visible bindings are described below. Dose-relative switching and nullable result columns remain unfinished.

Model history bindings

A model may declare a dose_history array:

{"name":"last_dose", "target":"central", "quantity":"dose_amount", "simultaneous":"first"}

Every declaration requires a name, an existing dose target, a quantity and an explicit first or last simultaneous-dose policy. Quantities are dose_amount (in target input units, before F or state scaling), dose_time (delivered time), time_since_dose, and has_dose (dimensionless 0 or 1). Times use model units. The declared name is available to differential equations and readouts. Use a lazy conditional guarded by has_dose when numeric history may be missing. An evaluated numeric history read without a previous dose returns a domain error; it never fabricates a zero previous dose.

History follows model plus request lag and creates bounded step bindings at delivery. Pre/post observations select history on the same side as the state. Internal bindings cannot be overridden by requests or public bind methods. Model history cannot currently be used in initial conditions or dose scales; reset requests are rejected. Raw protocols do not encode these bindings.

Text models use an equivalent block with an explicit simultaneous policy:

dose_history
  last_dose = dose_amount(central) simultaneous first
  elapsed = time_since_dose(central) simultaneous first
  seen = has_dose(central) simultaneous first

All four quantities and both policies round-trip through the parser and formatter without changing the model content identity. Units come from the selected target and model time unit; this block does not redeclare them. Dose-relative predicates remain to be implemented. Infusion-history readouts do not establish matching source-runtime F/duration conventions.

Dose-relative predicate scheduling API

DoseHistory::predicate_schedule plans elapsed-dose comparisons (lt, le, gt, ge) against a fixed, nonnegative offset in normalized model time units. A new delivery replaces the prior timer; an old threshold after that delivery is not retained as a switch. Deliveries coinciding with a threshold supply one stop with distinct old-history pre and new-history post values. The API returns an initial boundary and ordered subsequent boundaries, each with exact pre/post readouts and a separate right-hand integration phase. Missing history remains None. Candidate stops are budgeted before coincident times are merged.

predicate_at preserves direct floating-point elapsed-time subtraction for point readouts. This matters at decimal boundaries: native R reports 1.1 - 1 > 0.1 as true, while an exactly representable elapsed value of 1 remains false under a strict > 1 comparison. Integration phases use the scheduled crossing and do not approximate one-sided behavior by nudging time. The distinction between point values and integration phases is explicit.

Non-finite offsets, negative offsets, collapsed/overflowed crossing times, equality/inequality predicates and invalid windows are rejected. The model declarations below use these planning APIs for execution.

Dose-relative model switches (JSON)

A dose_switches declaration names a dimensionless predicate for elapsed time since a delivery. For example:

{"name":"late", "target":"depot", "offset":{"kind":"symbol","name":"delay"},
 "comparison":"gt", "simultaneous":"first"}

The offset is a time-valued expression of model parameters. The target must accept dosing, the simultaneous policy is explicit, and comparisons support lt, le, gt, and ge. Named predicates evaluate to 0 or 1; querying one before any dose produces a domain error unless a lazy has_dose guard avoids that read. Negative, non-finite and unrepresentable offsets are rejected.

The compiler creates private step bindings at deliveries and elapsed-time crossings after model/request lag expansion. A newer dose restarts the timer. RHS evaluation uses the right-hand phase, while every output read recomputes the exact predicate from history and the requested pre/post side. Parameters may be rebound without changing the compiled model. Generated changes and storage consume existing event/output budgets; internal bindings cannot be supplied by requests. Reset requests and predicate-dependent initial conditions or dose scales are not qualified and fail explicitly.

Text models use a dedicated block with an explicit simultaneous-dose policy:

dose_switches
  late = time_since_dose(depot) > delay simultaneous first

The offset accepts the same parameter expressions as JSON, including arithmetic and parentheses. Supported operators are <, <=, >, and >=; the policy must be first or last. Formatting preserves declarations and comparisons. Native simulation tests qualify the implemented boundary behavior; browser, Python-wheel and source-runtime infusion equivalence are separate checks.