Problem
ConveyorState::leak_step (src/simlin-engine/src/conveyor.rs) correctly skips a leak flow k whose destination belt is arrested:
- the linear arm
continues on arrested(k):
if !self.in_zone(i, l, k) || arrested(k) {
continue;
}
- the exponential arm's
if self.in_zone(i, l, k) && !arrested(k) leaves sheds[k] = 0.
So the continuous shed for that flow is 0, as intended.
But ConveyorState::quantize_integer_leaks -- which runs after the linear arm, over the <leak_integers/> flows -- does not consult leak_dest_arrested. Its signature carries no arrest information at all (n, shed_by, leak_vols, slat_vols). It adds the (zero) continuous shed into the flow's persistent leak_carry[k] and then takes floor(carry) whole units off the belt:
self.leak_carry[k] += leak_vols[k]; // += 0.0 for an arrested destination
let whole = self.leak_carry[k].floor();
self.leak_carry[k] -= whole;
leak_carry[k] is not necessarily below 1. quantize_integer_leaks returns UNDELIVERED whole units to the carry when the belt cannot supply them (there is less material in-zone than the whole-unit count demanded):
let delivered = whole - remaining;
self.leak_carry[k] += remaining; // carry can now be >= 1
leak_vols[k] = delivered;
so the carry can hold >= 1 across a step boundary.
Consequence
While the destination belt is arrested -- which is supposed to stop material entering it entirely -- an integer leak flow can still remove whole units from the upstream belt and publish a nonzero driven rate into the arrested belt's inflow slot. The arrested belt's phase_b then early-returns without inserting:
if inp.phase_a.arrested {
return PhaseBResult { admitted: 0.0, in_vols: vec![0.0; n_inflows], cleared: vec![0.0; n_inflows] };
}
The material is not destroyed -- it desynchronizes the destination belt's flat stock from its slat ring, permanently. expand_conveyors drops the conveyor marker and leaves the belt as an ordinary INTEG (conveyor_compile.rs: "the expanded stock is an ordinary INTEG whose Δ = admitted - out - leak"), so nothing retracts the conveyor stock slot from ordinary stock integration. The leak's nonzero driven rate sits in the arrested belt's inflow slot, and the Stocks phase adds rate * dt to its flat stock -- while the early-return above keeps that volume off the slat ring.
Since the ring never receives the material, no future exit can ever discharge it. The flat stock over-reports by the leaked volume for the remainder of the run, and the §4.3 identity flat stock == Σ belt contents -- which the whole design rests on and which every other code path maintains -- is broken from that step onward. The upstream belt, meanwhile, correctly loses the units from both its ring and its flat stock.
So there are two observable symptoms, and a regression test should pin both:
- the upstream belt loses whole units while its leak's destination is arrested (it should shed nothing), and
- the arrested belt's flat stock diverges from
Σ its slat contents and never re-converges.
Symptom 2 is the more dangerous one: it survives the arrest window, so a model that arrests a belt briefly reports a permanently inflated stock. A test that only checks total conservation across all stocks would MISS this bug entirely -- the total is conserved; it is the belt's internal representation that splits.
This is a wrong-answer bug, not a performance issue, and it is reachable: <leak_integers/> plus a leak flow into a belt with <arrest> is legal XMILE.
Why it matters
Correctness. A conveyor system silently loses mass whenever an integer leak flow's destination belt is arrested while the flow's carry holds a whole unit. Nothing diagnoses it; the numbers are simply wrong.
Components affected
src/simlin-engine/src/conveyor.rs -- ConveyorState::leak_step, ConveyorState::quantize_integer_leaks
src/simlin-engine/src/wasmgen/belt.rs -- emit_leak
Fix both backends in one commit
This is the non-obvious part. The wasm backend deliberately MIRRORS this quirk rather than correcting it. From emit_leak's rustdoc in src/simlin-engine/src/wasmgen/belt.rs:
A VM quirk this mirrors rather than corrects: quantize_integer_leaks does NOT consult leak_dest_arrested, so an integer leak flow whose destination is arrested still drains any whole units its never-resetting carry has accumulated (its continuous shed was 0, so the undo loop adds back 0, but floor(carry) can still be >= 1 when an earlier step's undelivered units were returned to it). Emitting the quantizer unconditionally reproduces that; skipping it would silently diverge.
The mirroring exists because the bytecode VM is the parity oracle for GH #922, and diverging would fail the parity tests silently in the other direction.
So fixing this means:
- fixing
conveyor.rs and wasmgen/belt.rs together, in one commit, and
- removing the "A VM quirk this mirrors rather than corrects" paragraph from
emit_leak's rustdoc.
Suggested fix
Gate quantize_integer_leaks' per-flow work on !arrested(k), leaving the carry untouched for that flow that step -- matching how an arrested BELT leaves its own carry untouched. This requires threading leak_dest_arrested (or leak_step's arrested closure) into quantize_integer_leaks, which today receives neither.
Discovery
Identified during review of the conveyor/queue engine work on branch conveyor-engine.
Problem
ConveyorState::leak_step(src/simlin-engine/src/conveyor.rs) correctly skips a leak flowkwhose destination belt is arrested:continues onarrested(k):if self.in_zone(i, l, k) && !arrested(k)leavessheds[k] = 0.So the continuous shed for that flow is 0, as intended.
But
ConveyorState::quantize_integer_leaks-- which runs after the linear arm, over the<leak_integers/>flows -- does not consultleak_dest_arrested. Its signature carries no arrest information at all (n,shed_by,leak_vols,slat_vols). It adds the (zero) continuous shed into the flow's persistentleak_carry[k]and then takesfloor(carry)whole units off the belt:leak_carry[k]is not necessarily below 1.quantize_integer_leaksreturns UNDELIVERED whole units to the carry when the belt cannot supply them (there is less material in-zone than the whole-unit count demanded):so the carry can hold
>= 1across a step boundary.Consequence
While the destination belt is arrested -- which is supposed to stop material entering it entirely -- an integer leak flow can still remove whole units from the upstream belt and publish a nonzero driven rate into the arrested belt's inflow slot. The arrested belt's
phase_bthen early-returns without inserting:The material is not destroyed -- it desynchronizes the destination belt's flat stock from its slat ring, permanently.
expand_conveyorsdrops the conveyor marker and leaves the belt as an ordinaryINTEG(conveyor_compile.rs: "the expanded stock is an ordinary INTEG whose Δ = admitted - out - leak"), so nothing retracts the conveyor stock slot from ordinary stock integration. The leak's nonzero driven rate sits in the arrested belt's inflow slot, and the Stocks phase addsrate * dtto its flat stock -- while the early-return above keeps that volume off the slat ring.Since the ring never receives the material, no future exit can ever discharge it. The flat stock over-reports by the leaked volume for the remainder of the run, and the §4.3 identity
flat stock == Σ belt contents-- which the whole design rests on and which every other code path maintains -- is broken from that step onward. The upstream belt, meanwhile, correctly loses the units from both its ring and its flat stock.So there are two observable symptoms, and a regression test should pin both:
Σits slat contents and never re-converges.Symptom 2 is the more dangerous one: it survives the arrest window, so a model that arrests a belt briefly reports a permanently inflated stock. A test that only checks total conservation across all stocks would MISS this bug entirely -- the total is conserved; it is the belt's internal representation that splits.
This is a wrong-answer bug, not a performance issue, and it is reachable:
<leak_integers/>plus a leak flow into a belt with<arrest>is legal XMILE.Why it matters
Correctness. A conveyor system silently loses mass whenever an integer leak flow's destination belt is arrested while the flow's carry holds a whole unit. Nothing diagnoses it; the numbers are simply wrong.
Components affected
src/simlin-engine/src/conveyor.rs--ConveyorState::leak_step,ConveyorState::quantize_integer_leakssrc/simlin-engine/src/wasmgen/belt.rs--emit_leakFix both backends in one commit
This is the non-obvious part. The wasm backend deliberately MIRRORS this quirk rather than correcting it. From
emit_leak's rustdoc insrc/simlin-engine/src/wasmgen/belt.rs:The mirroring exists because the bytecode VM is the parity oracle for GH #922, and diverging would fail the parity tests silently in the other direction.
So fixing this means:
conveyor.rsandwasmgen/belt.rstogether, in one commit, andemit_leak's rustdoc.Suggested fix
Gate
quantize_integer_leaks' per-flow work on!arrested(k), leaving the carry untouched for that flow that step -- matching how an arrested BELT leaves its own carry untouched. This requires threadingleak_dest_arrested(orleak_step'sarrestedclosure) intoquantize_integer_leaks, which today receives neither.Discovery
Identified during review of the conveyor/queue engine work on branch
conveyor-engine.