Summary
Variables (notably stocks, also flows) can carry a non_negative flag. It is parsed from every input format and threaded all the way through the data model and salsa IR, but it is never read or applied anywhere in the compiler or VM. The simulation computes values as if the flag were absent, so a stock that should be clamped at 0 can go negative.
This is an engine-wide correctness / feature gap in src/simlin-engine, not specific to any one subsystem. It was discovered during the LTM review (see "How it was discovered" below), but the root issue and the fix both live in the core compile/simulate path.
Verification (current main)
The flag is stored along this path:
- Parsed from XMILE (
src/simlin-engine/src/xmile/variables.rs -- <non_negative/> tag on stocks and flows), MDL, JSON, and protobuf into datamodel::Compat.non_negative (src/simlin-engine/src/datamodel.rs:267).
- Carried into the salsa
SourceVariable and reconstructed back into the datamodel (src/simlin-engine/src/db.rs:619, :2564, :2672).
- Landed onto the compiler-facing IR:
variable::Variable::Stock { non_negative, .. } and Variable::Var { non_negative, .. } (src/simlin-engine/src/variable.rs:563, :601).
But it is never enforced:
grep -rn non_negative src/simlin-engine/src/compiler/ returns only non_negative: false struct-literal initializers (test fixtures / default constructions) -- zero reads of the field.
grep -n non_negative src/simlin-engine/src/vm.rs returns only one unrelated comment (rem_euclid always returns a non-negative result).
- The VM stock-integration path (the Euler / RK update logic in
vm.rs) has no clamp, no .max(0.0), and no constraint check keyed on the flag.
- After the value reaches the
variable.rs IR field, nothing reads it again. The db.rs occurrences only move the flag between SourceVariable, datamodel::Compat, and serialization; none feed compiler/ or vm.rs.
Net effect: the non_negative flag is inert. A model with a non_negative stock simulates identically to one without it, and the stock can take negative values.
Why it matters
- Correctness:
non_negative is a documented XMILE feature and a common modeling need (inventories, populations, capacities that physically cannot go below zero). Simlin silently produces wrong trajectories for any model that relies on it -- the model author gets no error and no clamping.
- Interop: Models imported from Vensim/Stella that use this constraint will diverge from their source-tool results, undermining the cross-tool fidelity the
test/ integration suite is meant to guarantee.
- Silent failure: There is no diagnostic. The flag round-trips through save/load intact, so it looks supported.
Components affected
src/simlin-engine/src/compiler/** -- needs to emit the constraint (or the data it needs) during bytecode generation.
src/simlin-engine/src/vm.rs -- needs to apply the clamp in the stock-update path.
src/simlin-engine/src/variable.rs / datamodel.rs -- the flag is already plumbed here; no schema change needed.
Possible approaches
Decide the exact XMILE-conformant semantics first (when the constraint is applied -- after each integration step, the flow-level vs stock-level behavior, and whether non_negative on a flow means something distinct from on a stock), then:
- Have the compiler mark non-negative stock offsets (analogous to how
stock_offsets are already collected in vm.rs for RK integration) and have the VM clamp those offsets to >= 0 after the stock-update phase of each step; or
- Emit an explicit clamp opcode / post-update pass in the generated stock bytecode.
Either way, this should be test-driven: add an integration fixture under test/ with a non_negative stock that would otherwise go negative, plus unit coverage in the engine.
LTM consequence (how it was discovered)
This surfaced during the LTM deep review. The LTM reference doc docs/reference/ltm--loops-that-matter.md (section ~11.5) describes LTM surfacing non-negative-constraint feedback loops -- the constraint itself acts as a balancing loop in the causal structure. Simlin's LTM cannot surface that loop, because the underlying simulation ignores the constraint entirely: there is no constraint-induced edge in the causal graph to detect. Fixing the engine-wide enforcement gap is a prerequisite for LTM ever representing those loops.
Filed as one of the LTM-review findings; see epic #488 for the LTM-discovery context. The issue itself is the engine-wide compiler/VM gap described above, not an LTM bug.
Summary
Variables (notably stocks, also flows) can carry a
non_negativeflag. It is parsed from every input format and threaded all the way through the data model and salsa IR, but it is never read or applied anywhere in the compiler or VM. The simulation computes values as if the flag were absent, so a stock that should be clamped at 0 can go negative.This is an engine-wide correctness / feature gap in
src/simlin-engine, not specific to any one subsystem. It was discovered during the LTM review (see "How it was discovered" below), but the root issue and the fix both live in the core compile/simulate path.Verification (current
main)The flag is stored along this path:
src/simlin-engine/src/xmile/variables.rs--<non_negative/>tag on stocks and flows), MDL, JSON, and protobuf intodatamodel::Compat.non_negative(src/simlin-engine/src/datamodel.rs:267).SourceVariableand reconstructed back into the datamodel (src/simlin-engine/src/db.rs:619,:2564,:2672).variable::Variable::Stock { non_negative, .. }andVariable::Var { non_negative, .. }(src/simlin-engine/src/variable.rs:563,:601).But it is never enforced:
grep -rn non_negative src/simlin-engine/src/compiler/returns onlynon_negative: falsestruct-literal initializers (test fixtures / default constructions) -- zero reads of the field.grep -n non_negative src/simlin-engine/src/vm.rsreturns only one unrelated comment (rem_euclid always returns a non-negative result).vm.rs) has noclamp, no.max(0.0), and no constraint check keyed on the flag.variable.rsIR field, nothing reads it again. Thedb.rsoccurrences only move the flag betweenSourceVariable,datamodel::Compat, and serialization; none feedcompiler/orvm.rs.Net effect: the
non_negativeflag is inert. A model with anon_negativestock simulates identically to one without it, and the stock can take negative values.Why it matters
non_negativeis a documented XMILE feature and a common modeling need (inventories, populations, capacities that physically cannot go below zero). Simlin silently produces wrong trajectories for any model that relies on it -- the model author gets no error and no clamping.test/integration suite is meant to guarantee.Components affected
src/simlin-engine/src/compiler/**-- needs to emit the constraint (or the data it needs) during bytecode generation.src/simlin-engine/src/vm.rs-- needs to apply the clamp in the stock-update path.src/simlin-engine/src/variable.rs/datamodel.rs-- the flag is already plumbed here; no schema change needed.Possible approaches
Decide the exact XMILE-conformant semantics first (when the constraint is applied -- after each integration step, the flow-level vs stock-level behavior, and whether
non_negativeon a flow means something distinct from on a stock), then:stock_offsetsare already collected invm.rsfor RK integration) and have the VM clamp those offsets to>= 0after the stock-update phase of each step; orEither way, this should be test-driven: add an integration fixture under
test/with anon_negativestock that would otherwise go negative, plus unit coverage in the engine.LTM consequence (how it was discovered)
This surfaced during the LTM deep review. The LTM reference doc
docs/reference/ltm--loops-that-matter.md(section ~11.5) describes LTM surfacing non-negative-constraint feedback loops -- the constraint itself acts as a balancing loop in the causal structure. Simlin's LTM cannot surface that loop, because the underlying simulation ignores the constraint entirely: there is no constraint-induced edge in the causal graph to detect. Fixing the engine-wide enforcement gap is a prerequisite for LTM ever representing those loops.Filed as one of the LTM-review findings; see epic #488 for the LTM-discovery context. The issue itself is the engine-wide compiler/VM gap described above, not an LTM bug.