Skip to content

engine: <leak_integers/> is silently ignored on an exponential-leak conveyor #942

Description

@bpowers

ConveyorState::leak_step (src/simlin-engine/src/conveyor.rs, ~line 721) branches on exponential_leak. The exponential arm (§5.2) computes each flow's continuous shed, subtracts it, and return (leak_vols, slat_vols); (~line 777) before reaching the integer-leakage (§5.4) requantization block that the linear (§5.1) path falls through to.

Consequence: a conveyor with exponential_leak="true" whose leak flow carries <leak_integers/> leaks continuously. leak_carry[k] is never accumulated, floor(leak_carry[k]) whole units are never taken, and the reported leak rate is the raw continuous rate. The integers flag is simply ignored on that path. The model simulates and produces plausible-looking fractional leak rates, so the failure is silent.

Note the leak_step rustdoc already promises otherwise: "slat_vols[k] sums to leak_vols[k] (conservation) after any integer-leak requantization".

Why this looks like a bug rather than intent

docs/design/conveyors.md §5.4 ("Integer leakage") does not restrict integer leakage to the linear model:

With <leak_integers/>, flow k accumulates its computed real leak into leak_carry[k] each DT; it actually removes floor(leak_carry[k]) whole units (distributed from the in-zone slats, exit-most first) and retains the fractional remainder in leak_carry[k]. Reported rate = whole units removed / DT. Linear leak_window is consumed by in-zone travel (§5.1) independent of the quantization, so the carry redistributes timing without changing a cohort's schedule.

Only the trailing sentence about leak_window consumption is scoped to the linear model; the quantization rule itself is stated unconditionally. The conveyor-level exponential_leak flag is documented (§5, line 447) as selecting the leak model for all leak flows, and <leak_integers/> is a per-flow marker (§ table, line 199) that is orthogonal to it. Spec and implementation disagree.

Why it matters

  • Correctness, silently: the modeler asked for whole-unit leakage and got fractional. Nothing errors, nothing warns.
  • Integer leakage exists precisely for discrete-entity belts (people, vehicles, batches). Fractional leak on such a belt is a wrong answer, not a rounding nit.

Components affected

  • src/simlin-engine/src/conveyor.rs -- ConveyorState::leak_step (VM/interpreter path, the parity oracle)
  • src/simlin-engine/src/wasmgen/belt.rs -- wasm backend, which deliberately mirrors the VM here: its shed_words slat-stride helper is gated on !exponential_leak, and this is documented in belt.rs and in the belt.rs bullet of src/simlin-engine/CLAUDE.md
  • src/simlin-engine/src/conveyor_compile.rs -- expand_conveyors, if the resolution is a validation error
  • docs/design/conveyors.md §5.4 -- if the resolution is to scope integer leakage to linear

Possible resolutions (decide as part of this issue)

  1. Define and implement exponential integer semantics. Accumulate leak_carry[k] from the exponential arm's computed continuous shed, remove floor(carry) whole units distributed exit-most-first from the in-zone slats, and report whole_units / DT. This is the reading the spec text implies. Open sub-questions: interaction with the proportional overdrain scale-down (quantize before or after scaling?), and how whole-unit removal interacts with the "all flows see the same start-of-step content" rate-additivity rule of §5.2.
  2. Reject the combination loudly. Make <leak_integers/> + exponential_leak="true" a validation ERROR at expansion time in conveyor_compile::expand_conveyors, and amend docs/design/conveyors.md §5.4 to scope integer leakage to the linear model. Smaller and safer than defining new semantics, and turns a silent wrong answer into a loud one.

Option 2 is likely the better first move unless Stella is known to support the combination -- worth checking what Stella does before committing (per the house rule that Stella behavior wins over the OASIS spec).

Whichever resolution is chosen, both backends must change together, plus a VM/wasm parity test. The wasm backend's current gate is not an oversight -- it mirrors the VM on purpose, because the VM is the parity oracle and silently "fixing" one backend would have made the two disagree. If option 2 is chosen, the wasm gate and its comment stay but the reject moves upstream to expansion, and the belt.rs / CLAUDE.md notes should be updated to cite the validation error rather than "mirrors the VM's silent drop".

Discovery context

Found while lowering conveyor leaks to wasm (GH #922 step 2, src/simlin-engine/src/wasmgen/belt.rs).

Not covered by #938 (mid-pass ConveyorTransitTooLong state), #939 (Vm::into_results row counts), #940 (sub-model conveyor rejection), #941 (sir_social_distancing_mixnot.stmx fixture), or #865 (GF leak fraction / PREVIOUS(driven_flow) / JSON serialization).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions