Reaction networks and differential/algebraic equations
Reaction networks and differential/algebraic equations
These blocks bind reusable definitions, not trajectories. Assignment is the
canonical spelling; REACTIONS name … END and EQUATIONS name … END are equivalent
alternatives. Reading a definition returns its schema and model-local name without
running integration. Solve calls are ordinary reactive formulas and never write
back to the declared species or states.
Begin either block form with a species, state, or parameter declaration.
input transfer_rate = 0.1
conversion = REACTIONS
SPECIES A INITIAL 10
SPECIES B INITIAL 0
PARAMETER k = transfer_rate
REACTION transfer: A -> B AT k*A
END
trajectory = REACTION_SOLVE(conversion, 0, 20, {samples: 101})
columns = REACTION_SPECIES(conversion)The trajectory has time in column 1, followed by species in declaration order.
columns is [["A", "B"]]. A changed transfer_rate invalidates the trajectory;
parameters and initial values are captured once for each solve. Parameters can
use earlier parameters and previously declared numeric model bindings. Initial
values can use earlier parameters or model bindings. Rates can use species,
parameters, TIME, arithmetic and the supported pure numeric builtin subset.
Capture outer values with PARAMETER; do not refer to them directly in rates.
A rate is the complete authored flux. 2 A -> B AT k*A means
A' = -2*k*A, B' = k*A; it does not silently square A. Repeated species terms
are combined with exact integer stoichiometry. Use 0 -> A for a source,
A -> 0 for a sink. Each reaction
rate is evaluated once per right-hand-side evaluation, in source order, and
reused across species. Signed rates are allowed. There is no concentration
clamp or automatic conservation correction.
For a reversible reaction, give two complete fluxes, forward then reverse:
REACTION binding: enzyme + substrate <-> complex AT k_on*enzyme*substrate, k_off*complexAT MASS_ACTION(k) explicitly multiplies k by each reactant raised to its
stoichiometric coefficient. 2 A -> B AT MASS_ACTION(k) means k*A^2;
A + A -> B has the same exponent. With <->, each direction uses its own
reactant side: A <-> B AT MASS_ACTION(k_on), MASS_ACTION(k_off) means
k_on*A forward and k_off*B reverse. A source with no reactants uses k
alone. This is deterministic concentration/amount kinetics, with no stochastic
combinatorial factor. MASS_ACTION is contextual to the complete AT rate;
it is not an ordinary callable or an implicit default for an authored flux.
Use block-local LET for a shared rate, force, or other intermediate:
LET saturation = maximum_rate * substrate / (affinity + substrate)
REACTION conversion: substrate -> product AT saturationDeclare LET values before all reactions/equations. They can read states,
parameters, TIME, and earlier LET values. Forward references between LET
values, cycles, and shadowing another local are rejected. Each is evaluated and checked for a
finite numeric result once per solver callback, even when unused. PARAMETER
values instead remain fixed throughout one solve. These are private model
intermediates; output columns still contain declared species/states only.
charging = EQUATIONS
PARAMETER capacitance = 0.1
STATE voltage INITIAL 0
capacitance * DERIVATIVE(voltage) = 5 - voltage
END
trajectory = EQUATION_SOLVE(charging, 0, 1, {samples: 101})
columns = EQUATION_STATES(charging)Grid isolates diagonal derivative coefficients that are constant during a solve and calls ODE_SOLVE.
For a constant mass matrix and algebraic constraints it calls DAE_SOLVE:
circuit = EQUATIONS
STATE voltage INITIAL 0
ALGEBRAIC current INITIAL 0
0.1 * DERIVATIVE(voltage) = current
0 = current - (5 - voltage)
END
trajectory = EQUATION_SOLVE(circuit, 0, 1, {samples: 101})Write exactly one equation per variable, in declaration order. A STATE row
has derivatives; an ALGEBRAIC row has none. Algebraic variables cannot appear
inside DERIVATIVE. Their INITIAL values are guesses: the native DAE solver
projects them to a consistent initial state. The differential mass block must
be nonsingular. The supported DAE profile is semi-explicit, constant-mass,
index 1; state-dependent mass, nonlinear derivatives, higher-index constraints,
index reduction and purely algebraic systems are unsupported. Coefficients of
derivatives may use finite constants and captured parameters, such as mass,
2*mass, or 1/capacitance. Their arithmetic and finite checks run once before
integration. They cannot depend on states, algebraic variables, TIME, or
per-callback LET values. A parameter update that makes a differential row zero
or its mass block singular fails; it never reclassifies a STATE as ALGEBRAIC.
The solve, final, and report functions accept (definition, start, end[, options]). Use a direct
same-module definition name or an immutable ordinary alias. Arbitrary records,
strings, computed selections and imported definition references are unsupported
in this initial profile. Definitions cannot be reassigned or written by rules.
Assignment decorators and cell/qualified targets are unsupported for blocks.
Solve calls may appear in same-module callables and local expressions. Local
bindings must not shadow the selected definition or its captured dependencies;
ambiguous captures are rejected at compilation.
The literal options record can also contain named scenario overrides:
baseline = REACTION_FINAL(conversion, 0, 20)
faster = REACTION_FINAL(conversion, 0, 20, {parameters: {k: 0.2}, initial: {A: 5}})parameters overrides declared PARAMETER values; initial overrides declared
species/state/algebraic initial values. Keys match local names without case
sensitivity. Unknown or duplicate names and nonliteral override records are
rejected. Values use pure numeric expressions in the caller's scope, so a
local callable can pass a scenario value. Overrides do not bind the model's
local names in caller scope. Default parameters/initials that reference earlier
parameters see their overridden values. Each solve is independent and does
not modify the definition, another solve, or the reactive inputs. The same
finite and reaction-initial nonnegativity checks apply to overrides.
REACTION_FINAL and EQUATION_FINAL return the terminal scalar or a single
state row. REACTION_REPORT and EQUATION_REPORT return the selected native
solver's diagnostics. ODE reports support {format: "record"} with named work
counters and terminal state; DAE reports retain their numeric row with initial
projection and algebraic-residual diagnostics. Native report layouts differ.
Each explicit
solve/final/report call requests its own result; requesting three outputs does
not promise one shared integration. Ordinary formula caching still applies.
Remaining options use the existing native solver's literal record, defaults, validation,
budgets and cancellation. ODE methods are auto, rk45, or stiff; DAEs use the
native implicit method. Unknown keys, invalid samples/tolerances and exhausted
budgets return native errors, never successful partial trajectories. No solver
runs merely because a block was declared. Each definition reference has schema
grid.reactions.reference.v1 or grid.equations.reference.v1 and a name field.
Model quantities are finite numeric values. Reaction initial amounts must also
be nonnegative. Unannotated models use the author's consistent numeric
coordinates; annotated models use the physical-unit rules below. Currency tags
and effectful/volatile calls are not admitted in this profile. Reusable pure
numeric functions are described below. The builtin subset is ABS, MIN, MAX, SQRT,
EXP, LN, LOG, LOG10, SIN, COS, TAN, ASIN, ACOS, ATAN, SINH,
COSH, TANH, POWER (POW), SIGN, FLOOR, CEILING, ROUND, and IF.
Limits are 256 variables, 1,024 directed reaction channels (a reversible declaration counts as two), 1,024 terms per reaction side, 2,048 clauses, 64 expression/dependency levels, and 50,000 expression work units and generated definition nodes. Existing compiler, callable and solver budgets also apply. The owning AST retains every clause and expression; source-free rendering uses assignment spelling, while source formatting preserves the original header and comments. Renaming dynamics-local declarations is refused; state labels and scenario keys need an explicit public rename contract.
Named reusable solutions and observations
REACTION_SOLUTION and EQUATION_SOLUTION take the same definition, time span,
and literal scenario/solver options as SOLVE. They request one integration and
return a portable grid.dynamics.solution.v1 record. Reading several fields of
that bound solution reuses the result through ordinary reactive dependencies; it does
not integrate separately for each field. Native solve caching may also reuse an
identical request. Declaring or reading the model definition still does no work.
conversion = REACTIONS
PARAMETER rate = 0.1
SPECIES substrate INITIAL 10
SPECIES product INITIAL 0
LET flux = rate * substrate
REACTION transfer: substrate -> product AT flux
OBSERVE total = substrate + product
OBSERVE activity = flux
END
run = REACTION_SOLUTION(conversion, 0, 20, {samples: 101})
produced = run.final.product
activity = run.series.activity
work = run.report.workOBSERVE name = expression exposes a finite numeric derived output. Expressions
can read states/species, parameters, TIME, and block-local LET values. Output
names share the model namespace; duplicates, shadowing and references to other
observations are rejected. Observations are evaluated at returned sample times,
not at every solver step, and do not enter the error-control norm. DAE samples
use the projected consistent initial state. An invalid/nonfinite observation
fails the whole named solution. SOLVE/FINAL/REPORT do not evaluate observations.
The record fields are:
| Field | Value |
|---|---|
time |
Sample times as an N × 1 numeric column. |
series |
Record of N × 1 columns keyed by authored state and observation names. |
final |
Record of terminal numeric values under the same names. |
trajectory |
Numeric table: time, declaration-order states, declaration-order observations. |
columns |
One row naming all trajectory columns, beginning with time. |
state_names, observation_names |
One row of names for each category. |
report |
Diagnostics from the same integration, excluding observation evaluations. |
ODE reports use grid.ode.report.v1. DAE reports use grid.dae.report.v1, with
named work counters, terminal state, accepted-step range, and
initial_consistency (initial/projected scaled residuals, projection iterations,
algebraic-variable count and projected state). Report state vectors contain
integrated/algebraic variables only. Names preserve authored spelling; record
field access uses those names. The outer schema is common to both solver kinds.
At most 256 observations and 2,000,000 numeric trajectory entries are admitted.
The solution always returns a record; omit format on these two authoring calls.
Both scenario records and all native solver controls remain supported. Solver
and output failures never publish a successful partial solution. Backward and
zero-length spans retain the native sampling contract.
The native lowering uses ODE_SOLUTION(..., {format: "record"}) or
DAE_SOLVE(..., {format: "record"}), then DYNAMICS.SOLUTION to assemble labels
and observations through ordinary compiled callbacks. The native record contains
trajectory and report. Existing native default numeric results are unchanged.
Existing trajectory-query functions can consume run.trajectory; queries
interpolate stored samples, including observation columns, rather than invoking
the original integrator or re-evaluating observation expressions.
Shared pure numeric LET callbacks now compile their local values once per callback invocation. The compiler retains eager binding order, finite guards, transitive state dependencies, and exact Jacobian/JVP chain rules. Unsupported expressions and unused locals whose errors may be ignored by general LET retain the existing compiled fallback. Dynamics guards deliberately check every declared LET and reaction flux, including unused or zero-net values.
Runnable examples
- Enzyme binding and catalysis: reversible mass action, shared turnover, enzyme/substrate conservation, and an alternative initial enzyme concentration. This retains the full elementary mechanism, without imposing the Michaelis–Menten quasi-steady approximation. Model background: MIT Systems Biology notes.
- Closed population compartments: named incidence and recovery fluxes, conserved population, and changed transmission. Parameters are illustrative, with homogeneous mixing, no births or deaths, and permanent removal from susceptibility. Model background: Duke's SIR model.
- Damped oscillator: adjustable inertia, named spring/total forces, and initial-displacement scenarios. The linear model can be checked against its underdamped analytic solution. Model background: OpenStax damped oscillations.
- RC circuit: parameterized capacitance
with an algebraic current constraint and native initial projection. The voltage
is
supply*(1-EXP(-TIME/(resistance*capacitance)))for the zero initial voltage used here.
These four examples use the author's consistent numeric units.
Conservation/analytic checks assess the authored models and
numerics separately from successful solver completion. Piecewise forcing via
IF is possible, but discontinuities should be split into explicit intervals;
these blocks do not promise event-triggered state resets or hybrid scheduling.
Imported model definitions/computed definition selection, indexed state families,
stochastic kinetics, state-dependent mass, and higher-index DAE reduction remain
outside this profile. Existing native trajectory-query functions can analyze
sampled results; named threshold events are described below.
Named threshold events
Declare a zero-crossing condition with a name, then request its first crossing:
conversion = REACTIONS
PARAMETER speed = 0.1
SPECIES substrate INITIAL 10
SPECIES product INITIAL 0
LET flux = speed * substrate
REACTION transfer: substrate -> product AT flux
EVENT half_converted WHEN substrate = 5 FALLING
OBSERVE total = substrate + product
END
hit = REACTION_EVENT(conversion, "half_converted", 0, 20)
half_life = hit.time
produced = hit.state.product
material = hit.observations.totalEQUATION_EVENT has the same arguments and result. The event condition must be
left = right; the solver locates a zero of left - right. RISING, FALLING,
or optional ANY filters the residual's direction with respect to increasing
time, including backward integration. An exactly zero initial residual counts
as a hit. Event names share the state/parameter/observation namespace; there are
at most 256 events per definition. Event expressions can use parameters, states,
TIME, and shared LET values. They cannot depend on observations or other events.
The result has schema grid.dynamics.event.v1, event, numeric time, named
state, and named observations. It is an ordinary reusable value. Observations
are evaluated once at the located state; invalid observations fail the whole
result. No crossing returns #N/A; invalid event evaluations and exhausted
solver budgets remain errors. SOLVE, FINAL, REPORT and SOLUTION do not evaluate
event conditions or change their behavior because events were declared.
The optional literal record supports parameter/initial overrides and native
integration controls. Omit samples, format, and event_direction: an event
returns one hit and uses its declared direction. Reduce max_step when a model
may cross more than once in a step. Detection brackets roots at accepted-step
endpoints; tangencies and multiple crossings inside a step are not guaranteed.
Events do not reset state or continue a hybrid simulation.
For constant-mass index-1 DAEs, initial algebraic projection precedes the first
event evaluation. DAE_EVENT uses the existing adaptive implicit integrator and
locates a bracketed root with bounded constrained substeps, preserving algebraic
equations at the returned state. It does not interpolate algebraic variables
between output samples. Its native form is
DAE_EVENT(rhs, mass, initial, start, end, event[, options]); both callbacks take
(time, state), and its result is one [time, state...] row.
Physical units
Annotate a model's quantities with the existing IS unit:... spelling. A clock
declaration specifies the numerical time coordinate:
motion = EQUATIONS
TIME IS unit:min
PARAMETER speed IS unit:m/s = 2
STATE distance IS unit:cm INITIAL 1m
DERIVATIVE(distance) = speed
EVENT arrived WHEN distance = 5m RISING
OBSERVE meters IS unit:m = distance
END
run = EQUATION_SOLUTION(motion, 0, 1min, {max_step: 5s, atol: 0.1mm})
distance_in_cm = run.final.distance
distance_in_m = run.final.metersThe solver uses the declared coordinates: centimetres and minutes here. Dimensional checking and conversion factors are compiled into ordinary numeric expressions. The example's derivative is therefore 12,000 centimetres per minute. There is no unit parsing inside numerical callbacks.
IS unit:... is available on STATE, SPECIES, ALGEBRAIC, PARAMETER, LET,
and OBSERVE; IS number declares a dimensionless quantity. Any annotation or
TIME IS selects dimensional admission for the block. Its default clock is
seconds. Quantities without annotations infer units from their expressions;
bare numbers infer dimensionless coordinates.
Bare numbers at declaration and call boundaries use the target coordinate:
INITIAL 10 means ten centimetres for a state declared in centimetres.
Explicit quantities must be compatible. Inside governing expressions, units
must balance: distance = 5m is valid, while distance = 5 is refused. Bare
zero can appear in any dimension, as in an algebraic residual. Arithmetic,
comparisons, and conditional branches convert compatible quantities before
combining them. Transcendental functions require dimensionless arguments.
Reaction rates remain complete fluxes, with units of amount per model time.
MASS_ACTION derives the required dimensions from its reactants. Mixed
compatible species coordinates, such as millimoles and moles, receive the
appropriate stoichiometric conversion factors. Amount and concentration
cannot be mixed implicitly.
Captured inputs and scenario overrides preserve their declared unit metadata.
Start/end times, first_step, and max_step accept compatible time quantities.
Fixed duration literals such as 1s, 5min, and 2h are supported; calendar
months/years are refused. A time-valued reactive input must be numeric and
tagged, for example input elapsed IS unit:s = 10; a duration-valued string
cell is not a numeric solver coordinate.
An absolute tolerance may be a quantity compatible with every state or an
array with one compatible quantity per state. A bare scalar atol uses that
same number in each state's declared coordinate. Relative tolerance, sample
count, step count and Jacobian bandwidth are dimensionless.
Named solutions and event results retain numeric values in those coordinates
and add units.time, units.state.<name>, and units.observations.<name>.
Each descriptor contains symbol in normalized Grid type-tag spelling,
canonical dimension, and base-unit scale. These are nested records, so
both Grid field access and exported JSON retain their named fields.
Existing SOLVE, FINAL and REPORT result shapes remain unchanged.
Physical captures unwrap numeric type tags before coordinate conversion and integration. Plain text, booleans, blanks, nonnumeric tagged payloads, and nonfinite values fail the numeric capture check; a tag or unit conversion does not coerce its payload into a number. External model values are captured once per solve; scenario, time, and numeric solver-option inputs are checked at their operation boundary.
Model coordinates must have zero offset: use Kelvin for a temperature state.
Celsius/Fahrenheit literals and explicitly tagged inputs are converted once
at capture. Custom UNIT declarations use Grid's existing registry.
The physical dynamics example combines chemistry in millimoles/minutes, dimensionless population growth with a physical rate, and an electrical DAE in millivolts/milliseconds.
Reusable numeric functions
Use ordinary Grid function definitions for rate laws, forces, observations and event expressions:
saturating_flux(amount, maximum, half_saturation) = maximum * amount / (half_saturation + amount)
conversion = REACTIONS
PARAMETER maximum = 0.2
PARAMETER half_saturation = 0.5
SPECIES substrate INITIAL 1
SPECIES product INITIAL 0
REACTION transfer: substrate -> product AT saturating_flux(substrate, maximum, half_saturation)
OBSERVE total = substrate + product
END
run = REACTION_SOLUTION(conversion, 0, 10)Declare helpers before the model. Helpers can call other admitted helpers and
use numeric LET locals. Each argument is evaluated once using the caller's
scope. Free names in a helper retain its defining module scope; a model
parameter with the same spelling does not replace that capture. Captured inputs
remain reactive and receive the model's finite numeric checks once per selected
operation, including captures used only in a conditional branch. Imported helpers retain their defining callable identities,
including private helper calls, through the existing module resolver.
Admission specializes the existing function body to ordinary numeric LET expressions. Units are inferred from each call's argument quantities and checked through the helper body. No second evaluator or function language is introduced. The existing compiled fallback preserves general LET behavior, including ignored errors in unused locals; finite checks still apply to model quantities and returned fluxes or observations.
Effects, unresolved references, recursion, arrays and higher-order callbacks are refused. A helper takes at most 64 arguments, calls nest at most 32 levels, and the existing expression and generated-node budgets apply. A helper used as a derivative coefficient must depend only on solve-constant parameters.
The reusable dynamics examples cover saturating kinetics, a population rate with a lexical capture, and shared mechanical forces.
Coupled model components
COMPONENT includes a preceding model's states and governing equations in the
parent's single solve. Qualified names connect components:
vessel = REACTIONS
PARAMETER leakage = 0.1
SPECIES material INITIAL 1
REACTION loss: material -> 0 AT leakage * material
END
connected = REACTIONS
PARAMETER exchange = 0.5
COMPONENT donor = vessel WITH {parameters: {leakage: 0}}
COMPONENT receiver = vessel WITH {parameters: {leakage: 0}, initial: {material: 0}}
REACTION transfer: donor.material -> receiver.material AT exchange * donor.material
OBSERVE total = donor.material + receiver.material
END
run = REACTION_SOLUTION(connected, 0, 2)
remaining = run.final["donor.material"]Equation components expose changing forces, inflows, or signals with INPUT.
Unlike a PARAMETER, an input formula is evaluated at the current model time
and state on each callback. Give it a default, then replace that default with
an explicit component binding:
body = EQUATIONS
PARAMETER inertia = 1
INPUT force = 0
STATE position INITIAL 0
STATE velocity INITIAL 0
DERIVATIVE(position) = velocity
inertia * DERIVATIVE(velocity) = force
END
coupled = EQUATIONS
COMPONENT first = body WITH {initial: {position: 1}, inputs: {force: second.position - first.position}}
COMPONENT second = body WITH {inputs: {force: first.position - second.position}}
END
motion = EQUATION_SOLUTION(coupled, 0, 1)Place INPUT and LET before governing clauses. Inputs can use parameters,
states, time, and earlier numeric locals. They cannot serve as constant mass
coefficients, even if their default happens to be a constant. Component input
bindings can use parent states and numeric locals; state feedback is a coupled
differential system, not a cycle of computed input definitions.
Components retain declaration order, lexical captures, inherited observations,
and named events. Their public labels are qualified strings such as
"first.position" or "donor.half". Nested scenario keys use those same strings:
{parameters: {"nested.exchange": 2}}. Reading a definition remains passive.
An explicit operation integrates the entire composed state vector once.
For physical models, annotate both parent and components. A parent without an
explicit TIME uses seconds when physical components select dimensional
admission. Child states retain their declared coordinates, child clocks are
converted to parent time, and input bindings must have compatible dimensions.
Constant mass matrices combine into the same existing ODE/DAE problem.
Composition is bounded by the aggregate 256-state, 256-observation, 256-event, 1,024-reaction-channel and expression limits, with at most 16 component levels. Components must use the parent's notation kind. Model selection uses preceding module definitions or immutable aliases; imported model definitions and computed model selection remain unsupported.
The composed dynamics example combines connected vessels, coupled mechanical bodies, and electrical DAEs with different clock units.
Indexed states, species, and reactions
Use fixed domains to describe repeated compartments or populations. Admission expands each family into ordinary scalar solver coordinates:
compartments = [1, 2, 3]
links = [1, 2]
chain = REACTIONS
PARAMETER transfer_rate = 0.5
SPECIES amount[i IN compartments] INITIAL 1
REACTION transfer[i IN links]:
amount[i] -> amount[i + 1] AT transfer_rate * amount[i]
OBSERVE total = SUM(i IN compartments, amount[i])
END
run = REACTION_SOLUTION(chain, 0, 2)
first_amount = run.final["amount[1]"]STATE, ALGEBRAIC, and named equation rows use the same binders:
regions = ["north", "south"]
colonies = EQUATIONS
PARAMETER growth_rate = 0.1
STATE population[region IN regions, cohort IN [1, 2]] INITIAL 10 * cohort
EQUATION growth[region IN regions, cohort IN [1, 2]]:
DERIVATIVE(population[region, cohort]) = growth_rate * population[region, cohort]
OBSERVE total = SUM(region IN regions, SUM(cohort IN [1, 2], population[region, cohort]))
ENDDomains are literal row/column arrays or preceding immutable constants. Each
axis contains unique integer, text, or symbol keys of one representation.
Text and symbol keys are case-sensitive. Binders are lexical; integer index
arithmetic must produce an exact key in the target family. Bare family reads,
missing keys, mismatched subscripts, input-driven shapes, and later domain
redefinitions are refused. This bounded SUM spelling operates inside indexed
dynamics expressions.
Declaration order and domain order determine output columns; the last axis
varies fastest. Families can have up to eight axes, with at most 1,024 keys
per axis, while the whole model still permits at most 256 expanded states and
1,024 directed reaction channels. The Cartesian product is checked before
expansion. Components qualify generated labels, and scenario records use those
exact strings, such as "vessel.amount[1]" or
"population[\"north\",2]".
The indexed dynamics examples cover a reaction chain, population cohorts, and parallel electrical DAE branches.
Model invariants
An invariant states a condition that every admitted numerical model state must satisfy within an explicit absolute tolerance:
conversion = REACTIONS
PARAMETER rate = 0.5
SPECIES substrate INITIAL 1
SPECIES product INITIAL 0
REACTION transfer: substrate -> product AT rate * substrate
INVARIANT material: substrate + product = 1 TOLERANCE 1e-8
INVARIANT nonnegative: substrate >= 0 TOLERANCE 1e-10
END
run = REACTION_SOLUTION(conversion, 0, 10)Relations support =, <=, and >=. Equality checks the absolute difference;
inequalities allow at most the stated excess in the forbidden direction.
Tolerance must be finite, nonnegative, and constant during the solve. Use a
literal or captured PARAMETER; state, time, and dynamic INPUT values cannot
set a changing tolerance. A physical tolerance must match the relation's
dimension. Bare numbers use the relation's numeric coordinate.
Invariants can use indexed binders, shared numeric helpers, and model locals. Components inherit checks under qualified names. At most 256 expanded checks are admitted across a composed model.
Every solve, final, report, solution, and event operation applies the checks. They run on the initial state after any DAE projection, accepted integration states, requested output samples, and a returned event state. They do not run on rejected trial stages or Newton guesses. Event operations stop at the first requested crossing and do not check unused states beyond it. Reading a model definition still performs no integration or invariant evaluation.
A failed or nonfinite check fails the entire operation with its authored name and time. No successful partial solution is returned. Invariants neither clamp states nor adjust tolerances, and sampled numerical checks do not establish a continuous-time proof between accepted states. Ordinary models without invariants retain the solver's static no-check specialization.
The invariant examples add material conservation, population nonnegativity, and Kirchhoff constraints to the indexed chemistry, biology, and engineering models.