Skip to content

conveyor-engine: an integer leak flow into an ARRESTED destination belt can still deliver whole units #947

Description

@bpowers

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:

  1. the upstream belt loses whole units while its leak's destination is arrested (it should shed nothing), and
  2. 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:

  1. fixing conveyor.rs and wasmgen/belt.rs together, in one commit, and
  2. 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.

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