From 03fdc3200b5e5d3ad3927853e594caad6a344ec1 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 07:11:44 -0700 Subject: [PATCH 01/11] doc: spec and requirements for XMILE conveyor support Conveyors are a first-class Stella stock type that XMILE marks optional; many in-the-wild .stmx models use them. Today simlin drops the block silently on import, then fails compilation with a misleading empty_equation error on the (spec-mandated) equation-less conveyor outflow, and loses the block on export -- so import-then-export corrupts a Stella model. This adds a design doc that a fresh implementer can build against. The doc separates what the XMILE 1.0 spec and isee's "Computational Details" help pages make unambiguous (syntax, the slat model, steady-state init formulas, linear/exponential leakage prose, non-FIFO placement) from what still needs a maintainer decision or a Stella reference run to pin exactly (non-integer transit_time/DT rounding, per-DT interleave, non-Euler handling, core VM-native-vs-desugar strategy, queue scope). Those are collected in a prioritized "decisions needed" section. It also maps the feature onto the actual engine: datamodel, the compatibility-critical protobuf field numbering, readers/writers, the wasmgen no-silent-fallback rule, the GH #486 Euler-only precedent, and LTM degradation. Vendors a small conveyor fixture corpus under test/conveyors/ with full provenance and license attribution (two CC BY 4.0 models from the peterhovmand COVID-19-SD-generic-structures repo plus a hand-authored minimal model). These are reference fixtures, deliberately not wired into the integration harness: none ship expected-output CSVs and the real models use unrelated unsupported features, so they become executable oracles only once conveyors are implemented and Stella reference output exists. The README records why, plus the GitHub-code-search method note (.stmx content is not indexed). --- docs/README.md | 1 + docs/design/conveyors.md | 564 ++++++ test/conveyors/README.md | 77 + test/conveyors/covid19_severity.stmx | 1703 +++++++++++++++++ test/conveyors/minimal_conveyor.xmile | 39 + .../sir_social_distancing_mixnot.stmx | 1033 ++++++++++ 6 files changed, 3417 insertions(+) create mode 100644 docs/design/conveyors.md create mode 100644 test/conveyors/README.md create mode 100644 test/conveyors/covid19_severity.stmx create mode 100644 test/conveyors/minimal_conveyor.xmile create mode 100644 test/conveyors/sir_social_distancing_mixnot.stmx diff --git a/docs/README.md b/docs/README.md index ab607934f..8df4da602 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [architecture.md](architecture.md) -- Component descriptions, dependency graph, project structure - [design/2026-02-21-incremental-compilation.md](design/2026-02-21-incremental-compilation.md) -- Incremental compilation via salsa: symbolic bytecode, per-variable tracking, LTM integration +- [design/conveyors.md](design/conveyors.md) -- XMILE conveyor support: spec, simulation semantics, engine integration points, phasing, and open decisions - [design/engine-performance.md](design/engine-performance.md) -- Engine compile/simulate profile (C-LEARN), implemented optimizations, and remaining proposals - [design/ltm--loops-that-matter.md](design/ltm--loops-that-matter.md) -- LTM implementation design: data structures, synthetic variables, module handling - [design/mdl-parser.md](design/mdl-parser.md) -- Vensim MDL parser design history and implementation notes diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md new file mode 100644 index 000000000..667b30068 --- /dev/null +++ b/docs/design/conveyors.md @@ -0,0 +1,564 @@ +# Conveyor support: specification and requirements + +Status: proposed. This document specifies XMILE conveyor support for the simlin +engine. It is written to be implemented by a fresh engineer or agent with no +prior context on conveyors. It separates what the specs make **unambiguous** +from what **needs a decision** (flagged `DECISION:`) or **experimentation +against Stella** (flagged `EXPERIMENT:`). Items requiring the maintainer's input +are collected in [§12](#12-decisions-needed-from-the-maintainer). + +## 1. Motivation + +Conveyors are a first-class stock type in Stella / isee systems models, used +heavily for aging chains, disease-progression stages, and material-transport +structures. XMILE 1.0 marks them an OPTIONAL feature (§3.7.2, §4.2.1, §4.3). +Many real Stella `.stmx` models in the wild use them; without support, simlin +cannot faithfully import, round-trip, or simulate those models. + +### Current behavior (verified against HEAD, 2026-07-05) + +Conveyors are not merely unsupported — they fail **silently and confusingly**: + +1. **Import drops the block.** The XMILE reader struct `xmile::Stock` + (`src/simlin-engine/src/xmile/variables.rs`) has no field for ``, + and quick-xml ignores unknown child elements, so the entire conveyor + specification is discarded on import. +2. **Compilation then fails on the outflow.** A conveyor's outflow MUST NOT have + an equation (the conveyor drives it — XMILE §4.3). With the conveyor block + gone, that equation-less flow reaches the compiler as an ordinary flow and + errors with `empty_equation` naming the outflow — a message that gives the + modeler no hint the real problem is an unsupported conveyor. +3. **Export loses the block.** The `` header option round-trips + (the `Feature::UsesConveyor` enum exists in `xmile/mod.rs`), but the + `` block does not, so import-then-export **corrupts** a Stella + model: the header advertises conveyors while the stocks have become plain + stocks. + +Reproduce with `test/conveyors/minimal_conveyor.xmile`: + +``` +$ simlin-cli simulate test/conveyors/minimal_conveyor.xmile +error in model 'main' variable 'graduating': empty_equation +``` + +Even the immediate Phase 0 goal (below) is a strict improvement: represent the +conveyor and replace this misleading error with a clear "conveyors are not yet +simulatable" diagnostic. + +## 2. Concepts and vocabulary + +A conveyor is a stock whose contents move along a fixed-length belt. Material +enters at the back, advances one **slat** per DT, and falls off the front after +the **transit time** has elapsed. Conceptually (isee "traditional conveyor" +model): + +- The belt is a row of slats, one slat per DT. The number of slats is + `transit_time / DT`. Each slat holds a quantity of material; slat 1 is the + exit end. +- Each DT, every slat's contents shift one position toward the exit; slat 1's + contents leave as the **outflow**; the inflow is deposited into the slat + `transit_time/DT` positions from the exit. +- **Leakage** flows let material fall off partway along the belt (attrition, + mortality). Leakage is linear (constant amount per DT) or exponential + (constant fraction of remaining material per DT), optionally confined to a + **leak zone** (a fractional span of the belt). +- Conveyors are **not FIFO by default**: if the transit time shrinks, material + added later can exit earlier. Material already on the belt keeps advancing one + slat/DT regardless of later transit-time changes. + +Related XMILE stock modes, both OPTIONAL and both **out of scope for the first +milestones** (see phasing): **queues** (FIFO batch-tracking stocks) and isee +**ovens** (batch-process stocks, not an XMILE standard construct). Queues matter +here only because a conveyor can sit downstream of a queue and constrain it; see +[§9](#9-queues-and-conveyor-queue-coupling-later-phase). + +## 3. XMILE syntax (unambiguous — from the spec) + +Reference: OASIS XMILE v1.0, §2.2.1, §3.7.2, §4.2, §4.2.1, §4.3. Local copy: +`docs/reference/xmile-v1.0.html` (windows-1252 encoded; use `grep -a`). + +### 3.1 Options header + +A model using conveyors MUST declare it in the `` block: + +```xml + +``` + +Two OPTIONAL boolean attributes advertise sub-features: `arrest="true|false"` +(default false) and `leak="true|false"` (default false). These are advisory; +the authoritative source of truth is the per-stock `` block. + +### 3.2 The conveyor block (on a stock) + +`` is one of three mutually exclusive stock options (``, +``, ``). The stock's `` is the conveyor's initial +value (see [§5](#5-initialization)); its ``/`` lists name the +flows. + +```xml + + 1000 + matriculating + graduating + attriting + + 4 + 1200 + 500 + 1 + 0 + + +``` + +Element/attribute reference: + +| Tag / attr | Kind | Type | Default | Meaning | +|---|---|---|---|---| +| `` | element | XMILE expr | REQUIRED | Transit time in time units. | +| `` | element | XMILE expr | INF | Max material on the belt at any instant. | +| `` | element | XMILE expr | INF | Max material entering per **time unit**. | +| `` | element | XMILE cond expr | 1 (every DT) | When nonzero, re-latch the transit time from `` for newly entering material. | +| `` | element | XMILE cond expr | 0 (never) | When nonzero, all flows forced to zero and belt frozen. | +| `discrete` | attr | bool | false | Discrete (integer batches) vs continuous stream. | +| `batch_integrity` | attr | bool | false | Only whole upstream-queue batches may be taken (queue-upstream only). | +| `one_at_a_time` | attr | bool | true | Take only the front queue batch per DT (queue-upstream only). | +| `exponential_leak` | attr | bool | false | Exponential (vs linear) leakage, for all leak flows. | + +### 3.3 Leakage flows + +The first `` is the primary belt-end outflow; the rest are leakages +(XMILE §3.7.2, §4.2.1). A leakage flow SHOULD be explicitly tagged. A conveyor +outflow MUST NOT carry a normal `` — the conveyor drives it. Real Stella +models put the leak **fraction** in the `` of a ``-tagged flow: + +```xml + + 0.1 + + + + +``` + +Note the encoding wrinkle observed in real models (both peterhovmand corpus and +the moose model): the leak fraction lives in ``, and `` / +`` are empty sibling marker tags. The spec also shows a +`0.1` element form. **The reader must accept both** the +marker-``-plus-`` form and the value-bearing `expr` +form. `` with no fraction yet is a valid "leakage, fraction TBD" marker +(used mid-edit) that does not simulate. + +Leakage flow options: + +| Tag / attr | Type | Default | Meaning | +|---|---|---|---| +| `` / `` | expr / marker | — | Leak fraction (fraction of inflowing material leaked by exit), or bare marker. | +| `` | marker | off | Leak only whole units; accumulate the fraction until ≥ 1, then leak one unit. | +| `leak_start` | attr in [0,1] | 0 | Fractional belt position where the leak zone starts (from the inflow side). | +| `leak_end` | attr in [0,1] | 1 | Fractional belt position where the leak zone ends. | + +### 3.4 Non-negativity + +Conveyor and queue **inflows** MUST be non-negative (uniflow); the primary +conveyor outflow is non-negative by definition. `` MUST NOT appear +on those flows. (XMILE §4.3.) The reader should tolerate its presence in +real-world files rather than hard-erroring, since Stella emits `` +on leak flows. + +## 4. Simulation semantics + +This is the substance. Sources are the OASIS spec (behavioral prose) plus isee's +"Computational Details" help pages, which are the only place the per-DT math is +documented. Key isee pages (verify against these when implementing; the +"traditional conveyor" page is the richest and is the model simlin most likely +needs to match): + +- Traditional (non-FIFO) conveyors: + `iseesystems.com/resources/help/v2/Content/08-Reference/05-Computational_Details/TraditionalConveyors.htm` +- FIFO conveyors: + `.../v3/.../FIFOConveyors.htm` +- Spreading conveyor inputs: + `.../v3/.../SpreadingConveyorInputs.htm` +- Initializing discrete stocks: + `.../v3/.../InitializingDiscreteStocks.htm` +- Equation tab (capacity/in_limit/sample/arrest/discrete/split/FIFO): + `.../v3/.../07-SharedProperties/Equation_tab.htm` + +### 4.1 The slat model (clear) + +- Slat count `N = transit_time / DT`. Slat 1 is the exit; slat N is the entry. +- Per DT: outflow = contents of slat 1; all slats shift toward the exit; inflow + is placed into the entry slat. +- The conveyor's scalar/reported value = the sum of all slat contents (total + material on the belt). *(Inferred, consistent with the steady-state formulas; + not stated verbatim — `EXPERIMENT` to confirm.)* + +### 4.2 Per-DT update order (clear) + +Each DT, for each conveyor: + +1. If `` evaluates nonzero: force all inflows and outflows to zero, do + not advance the belt, skip to next conveyor. +2. Compute the admitted inflow, clipped by capacity and inflow limit + ([§4.5](#45-capacity-and-inflow-limit-pushback)). Un-admitted material stays + upstream. +3. Advance: outflow = slat 1; shift every slat one position toward the exit. +4. Apply leakage to the (now shifted) belt in leak-flow priority order + ([§4.3](#43-leakage)). +5. Deposit admitted inflow into the entry slat (position `N` from the exit, + using the transit time latched per [§4.4](#44-transit-time-changes)). + +`DECISION:` the exact interleave of steps 3–5 (does inflow land before or after +the shift; is leakage before or after the outflow is taken) determines +first-DT numerics. Pin it against a Stella reference run on +`minimal_conveyor.xmile` before finalizing. The ordering above is the best +reading of the isee prose but is not guaranteed bit-exact. + +`EXPERIMENT:` **non-integer `N = transit_time/DT`.** The docs give no rounding +rule, and the traditional (integer-slat) and FIFO (fractional-edge-slat) pages +imply different handling. Options: round to nearest integer slat count; or carry +a fractional head/tail slat. Must be resolved experimentally. Recommend the +engine **reject a non-integer ratio with a clear error** in Phase 1 and only add +fractional-slat support if a reference model needs it. + +### 4.3 Leakage (mostly clear; formulas verify-against-isee) + +Leak fraction `f` = the fraction of inflowing material that leaks out by the +time it reaches the exit. + +- **Linear** (`exponential_leak=false`): "the same amount is taken away every + DT." Total leaked over the belt equals `f`. Distributed across the slats the + material occupies, so per-slat leak ≈ `f × (amount that entered) / N`. isee + example: 8% over 8 DTs = 1%/DT. Constraint: total linear leak fraction across + all leak flows ≤ 1; a fraction of 1 leaves no outflow. + - Under a leak zone `[leak_start, leak_end]`: the total leaked is still `f` + regardless of zone length — a shorter zone leaks *more per slat*. + - Under a variable transit time: the amount leaked is based on the transit + time in force **when the material entered**. +- **Exponential** (`exponential_leak=true`): "the same fraction of remaining + material is removed every DT." Per-slat per-DT leak = `material_in_slat × f × + DT`. Overlapping leak zones add their fractions. Bases leakage on current belt + contents, independent of entry transit time. +- **Multiple leak flows**: applied in the order the outflows are listed (first = + highest priority). +- **``**: accumulate the fractional leak until it exceeds 1, + then leak exactly one unit; carry the remainder. + +`EXPERIMENT:` the precise per-slat apportionment (especially linear leak with a +partial zone, and the exact denominator) is prose-only in the isee docs. Derive +the exact recurrence and validate against `covid19_severity.stmx` (which uses +`exponential_leak="true"` leak flows) and a hand-authored linear-leak fixture. + +`DEFER:` isee also documents an "ignore losses from earlier leak zones" flag and +five "spreading conveyor inputs" placement methods (at-beginning is the default +XMILE behavior; evenly / destination-profile / distribution / source-based are +isee extensions). These are not XMILE-standard `` options and are out +of scope; do not implement until a real model requires them. + +### 4.4 Transit-time changes (clear-ish) + +- Default conveyors are **non-FIFO**. Material already on the belt keeps + advancing one slat/DT; a transit-time change only affects where **newly + entering** material is placed (slat `N_new = new_transit_time / DT` from the + exit). If the transit time drops, later material can exit before earlier + material. +- ``: the transit time is re-read from `` only on DTs when the + sample expression is nonzero; between samples the last latched value is used + for placement. Default is every DT. +- FIFO mode (isee `` extension / Equation-tab checkbox) preserves + content order across transit-time changes and, on a shortening, doubles the + outflow until the old-transit material is exhausted. `DEFER:` treat FIFO as a + later enhancement; the XMILE-standard behavior is non-FIFO. + +### 4.5 Capacity and inflow limit (pushback) (clear formulas, subtle plumbing) + +Both default to INF. Per the isee Equation-tab page: + +- **Capacity**: `admitted_inflow_volume = MIN((capacity - current_contents)/DT + + total_outflow_volume, requested_inflow_volume)` per DT (volumes = rate×DT). +- **Inflow limit** (per **time unit**, not per DT): + - Continuous: `admitted = MIN(in_limit, requested)` after multiplying the + per-time-unit limit into a per-DT budget (`DT × in_limit`). + - Discrete: the full per-time-unit budget may enter within a single DT; the + running total resets at each integer time unit. + - Inflow limit is unavailable when the inflow comes from another conveyor. +- **Blocked material stays upstream.** If the upstream is an ordinary stock, the + un-admitted inflow simply is not removed from that stock. This is the subtle + part for simlin's architecture: **a conveyor's admitted inflow depends on the + conveyor's state, so the inflow's effective value is not a pure function of + its own equation** — it is clipped by the downstream conveyor. See + [§6.4](#64-the-inflow-clipping-problem). + +`DECISION:` apportioning a capacity/limit clip among multiple simultaneous +inflows to one conveyor is only partly documented (creation order). Pick a rule +(proportional, or priority by inflow order) and document it. + +### 4.6 Arrest (clear) + +When `` is nonzero: all inflows and outflows to the conveyor are forced +to zero and the belt freezes in place (material is not lost, time suspends for +it). It resumes when the expression returns to zero. + +### 4.7 Discrete conveyors (partly clear) + +`discrete=true` moves material as integer chunks that exit as discrete lumps +rather than a smooth stream; the initial value seeds the start of each time unit +rather than being spread evenly ([§5](#5-initialization)); the inflow-limit +budget is per-time-unit (fillable in one DT) rather than per-DT. `EXPERIMENT:` +exact chunk quantization and remainder handling are qualitative in the docs. +`discrete=true` is REQUIRED when there is a queue directly upstream. + +## 5. Initialization + +The stock `` gives the initial contents. Two modes: + +- **Scalar initial value** → steady-state distribution. Stella distributes the + value so the belt is in equilibrium. Documented closed forms (valid only when + `transit_time` is a multiple of DT and leak zones span 0→100%): + - No leak: contents `= inflow × transit_time`, i.e. the scalar value is spread + evenly, `value/N` per slat. + - Linear leak fraction `f`: + `inflow × (transit_time × (1 − f/2) + f × DT / transit_time)`. + - Exponential leak fraction `f`: + `inflow × (1 − (1 − f×DT)^(transit_time/DT)) / f`. + These relate the scalar initial value, the equilibrium inflow, and the + per-slat fill. `EXPERIMENT:` confirm which quantity the `` value denotes + (total contents vs equilibrium inflow) — the isee steady-state blog gives an + alternate outflow-first formulation; do not guess. +- **Explicit per-slat list** (comma-separated) → each entry is the quantity for + one **time unit** (not one DT); with fractional DT the value is repeated across + that unit's DTs. List length must equal `transit_time` or `transit_time/DT` + (the latter is required for non-integer transit times); too many → truncate, + too few → repeat the last entry. + +Phase 1 can implement only the scalar even-spread no-leak case and defer the +leak/explicit-list cases. + +## 6. Engine architecture and integration points + +This section maps the feature onto simlin's actual code. Read +`src/simlin-engine/CLAUDE.md` for the compilation pipeline. + +### 6.1 Data model (`src/simlin-engine/src/datamodel.rs`) + +`datamodel::Stock` (line ~306) needs to carry an optional conveyor spec, and +`datamodel::Flow` needs optional leakage metadata. Suggested shape: + +```rust +pub struct Conveyor { + pub transit_time: String, // expression (required) + pub capacity: Option, // + pub inflow_limit: Option, // + pub sample: Option, // + pub arrest: Option, // + pub discrete: bool, + pub batch_integrity: bool, + pub one_at_a_time: bool, // default true + pub exponential_leak: bool, +} +// Stock gains: pub conveyor: Option + +pub struct Leakage { + pub fraction: Option, // expr, or None for bare + pub integers: bool, // + pub zone_start: Option, // leak_start (default 0) + pub zone_end: Option, // leak_end (default 1) +} +// Flow gains: pub leakage: Option +``` + +### 6.2 Protobuf (`src/simlin-engine/src/project_io.proto`) — compatibility-critical + +Protobuf is the one place backward compatibility is REQUIRED (there is a DB of +serialized instances). `Variable.Stock` uses field numbers up to 12 and +`Variable.Flow` up to 12. **Add new fields with fresh, never-reused field +numbers** (e.g. `optional Conveyor conveyor = 13;` on Stock, `optional Leakage +leakage = 13;` on Flow) and define new `Conveyor`/`Leakage` messages. Absent +fields decode to `None`, so old serialized stocks remain valid plain stocks. +Regenerate with `pnpm build:gen-protobufs`. Never renumber or repurpose an +existing field. + +### 6.3 Readers/writers + +- XMILE reader/writer (`src/simlin-engine/src/xmile/variables.rs`): add + `conveyor: Option` and the ``-level plumbing to `Stock`, + and `leak`/`leak_integers`/`leak_start`/`leak_end` to `Flow`. Accept both + leak encodings ([§3.3](#33-leakage-flows)). Emit the `` block and + the `` option (already modeled as `Feature::UsesConveyor`). + This alone fixes the round-trip corruption, independent of simulation. +- MDL: Vensim has no conveyor primitive. `delay conveyor` is recognized as a + builtin name in `src/simlin-engine/src/mdl/builtins.rs` but is a distinct + (unimplemented) function — **out of scope**; do not conflate. +- JSON (`src/json.rs`), TypeScript datamodel (`src/core`), diagram: extend once + the engine representation is settled. + +### 6.4 The inflow-clipping problem (the hard architectural question) + +simlin compiles each variable to a bytecode fragment evaluated in dependency +order; a flow's value is a pure function of its equation. A conveyor breaks two +assumptions: + +1. **A conveyor is stateful beyond a single f64.** A stock today is one slot in + the value arrays. A conveyor needs a per-instance ring buffer of `N` slats + (plus latched transit time, leak accumulators). Precedents for side-state to + study: graphical-function storage, `prev_values`/`initial_values` snapshot + buffers in `vm.rs`, and module instance state. +2. **A conveyor outflow has no equation, and its inflow may be clipped by the + conveyor's own capacity/limit.** So the outflow value and the *effective* + inflow value must be produced by conveyor-update logic that runs at a + well-defined point in the step, not by per-variable equation evaluation. This + resembles how non-negative stocks would clip outflows, and how queues drive + their outflows. + +`DECISION:` the core implementation strategy. Two broad options: + +- **(A) New opcode / VM-native conveyor object.** Represent the conveyor as a + dedicated runtime object with its own update step, wired into the flows/stocks + phases. Cleanest semantics, most engine work (VM + wasmgen + compiler + + layout offsets). +- **(B) Desugar to existing primitives at compile time.** Lower a conveyor to an + array of `N` aux/stock slats plus generated flow equations (a fixed-length + aging chain). Reuses the existing array + stock machinery, but `N` depends on + `transit_time/DT` (so it must be a compile-time constant — rules out a + variable `` unless re-lowered) and capacity/limit pushback on the inflow + is awkward to express as equations. + +Recommend prototyping (A) for a single continuous constant-transit no-leak +conveyor first, since it generalizes to leakage/variable-transit; but the +maintainer should choose (see [§12](#12-decisions-needed-from-the-maintainer)). + +### 6.5 wasmgen + +The WebAssembly backend mirrors the VM opcode-for-opcode and must either support +the conveyor path or return `WasmGenError::Unsupported` — **there is no silent +VM fallback** (established rule, see `src/simlin-engine/src/wasmgen`). Phase 1 +may legitimately return `Unsupported` for conveyor models from wasmgen while the +VM path works, as long as it is loud. + +### 6.6 Integration method (Euler) + +isee documents that Runge-Kutta "doesn't deal well with queues, conveyors, or +ovens" and recommends Euler, but does **not** state that Stella forces Euler or +errors. simlin has precedent for an Euler-only hard rejection: GH #486 rejects +non-Euler integration for LTM link scores (see `src/simlin-engine/src/db/ +assemble.rs` around the "GH #486" comments). `DECISION:` for conveyors, either +(a) hard-error when a conveyor is present under RK2/RK4, or (b) silently force +Euler for the whole model. `EXPERIMENT:` check what Stella actually does. The +slat model is inherently a per-DT (Euler-like) construct; RK sub-steps have no +meaning for it. Recommend a hard, clear error in Phase 1. + +### 6.7 LTM (Loops That Matter) + +A conveyor is a stock with special dynamics; the flow-to-stock link-score +formula assumes plain INTEG (and Euler — GH #486). Conveyor internal dynamics +are not INTEG. Minimum bar: LTM analysis over a model containing a conveyor must +**degrade loudly** (a clear warning / documented limitation), never crash or +emit a silently-wrong score. Full LTM-through-conveyor semantics are out of +scope for the initial milestones. + +## 7. Container access and builtins (later phase) + +XMILE §3.7.1.3 requires that array builtins (MIN/MAX/MEAN/STDDEV/SUM/SIZE) and +`[]` indexing work over a conveyor's contents: `conv[3]` reads the third slat +from the front; `SIZE`/`MEAN` etc. operate on the slat vector; an arrayed +conveyor uses two subscript groups `conv[arr_subscript][slat]`. isee also +exposes cycle-time builtins on time-stamped material (`CTMEAN`, `CTSTDDEV`, +`CTMAX`, `CTMIN`, `CTFLOW`, `CYCLETIME`, `THROUGHPUT`). All of this is +**deferred** — it depends on the conveyor holding an inspectable slat vector, +which the core simulation must exist first. + +## 8. Arrayed conveyors (later phase) + +`covid19_severity.stmx` has conveyors dimensioned over a `Severity` dimension. +The XMILE non-apply-to-all array rules (§4.5.2) allow per-element transit times. +The isee docs document only the indexing syntax, not distinct per-element +computational semantics — they appear to behave as independent per-element +conveyors. Treat as a Phase-3 concern; the core scalar conveyor must work first. + +## 9. Queues and conveyor–queue coupling (later phase) + +Queues (``) are a separate XMILE OPTIONAL feature (§3.7.3) with their own +`` option and `` flow property. They are FIFO +batch-tracking stocks. They intersect conveyors because a conveyor downstream of +a queue constrains the queue's outflow (capacity/inflow-limit/arrest), and the +conveyor's `batch_integrity` / `one_at_a_time` / discrete-required flags only +apply in that configuration. **Queues are out of scope for the initial conveyor +milestones** and should be specced separately; a conveyor with an ordinary stock +or cloud upstream is fully implementable without them. + +## 10. Phasing + +- **Phase 0 — represent & round-trip (no simulation).** Datamodel + proto + + XMILE reader/writer. Import preserves the conveyor block; export re-emits it; + round-trip no longer corrupts. Replace the misleading `empty_equation` error + with a clear "conveyors are not yet simulatable" diagnostic on the conveyor + outflow. Vendored models parse without dropping data. +- **Phase 1 — simulate the simple continuous conveyor.** Constant transit time + (integer `transit_time/DT`), scalar even-spread initial value, single primary + outflow, no leakage, Euler only. Capacity + inflow limit with upstream-stock + pushback. wasmgen may return `Unsupported`. Oracle: `minimal_conveyor.xmile` + vs a Stella reference run. +- **Phase 2 — leakage & variable transit.** Linear + exponential leakage, leak + zones, `leak_integers`, variable `` with ``, arrest, discrete + conveyors, explicit-list initialization. Oracles from the peterhovmand corpus. +- **Phase 3 — arrays, container access, builtins.** Arrayed conveyors, `[]` + slat access, array/cycle-time builtins over contents. wasmgen parity. +- **Phase 4 — queues** (separate spec) and queue–conveyor coupling + (batch_integrity, one_at_a_time, overflow). + +## 11. Test oracles + +Vendored under `test/conveyors/` (see its README for full provenance, licenses, +and attribution — the CC BY 4.0 attribution requirement is satisfied there): + +- `minimal_conveyor.xmile` — hand-authored Phase-1 target (transit time + + capacity, single outflow, no leak). +- `sir_social_distancing_mixnot.stmx` — peterhovmand corpus, CC BY 4.0, + transit-time-only, deterministic; simplest real oracle. +- `covid19_severity.stmx` — peterhovmand corpus, CC BY 4.0, leakage + arrayed + conveyors, deterministic. + +**None ship expected-output CSVs**, and the real models additionally use +non-conveyor features simlin doesn't yet support (the isee builtin `LOOKUPMEAN`, +some unit-consistency issues, `PREVIOUS`, `isee:spreadflow`). So they are +**reference fixtures, not wired into `tests/integration/main.rs` yet**. Turning +them into executable oracles requires (a) conveyor support and (b) reference +output generated from Stella. Simlin's test convention is +`test//model.xmile` + `output.csv`, wired as a `mod` in +`tests/integration/main.rs`; follow it when oracles exist. + +No open-source model was found that exercises ``, ``, +``, ``, or an upstream queue — those need hand-authored +fixtures plus Stella reference output when the corresponding phase is built. + +## 12. Decisions needed from the maintainer + +Collected `DECISION:` / `EXPERIMENT:` items and scoping questions, in rough +priority order: + +1. **Core implementation strategy** ([§6.4](#64-the-inflow-clipping-problem)): + VM-native conveyor object (A) vs compile-time desugar to a slat aging chain + (B). This shapes everything downstream. Recommendation: (A). +2. **Oracle generation.** Do you have Stella access to produce reference-output + CSVs? Without it, Phase 1+ can only be validated by hand-reasoned expected + values on tiny models. This gates how much we can trust the numerics. +3. **Non-integer `transit_time/DT`** ([§4.2](#42-per-dt-update-order-clear)): + reject with an error, round to an integer slat count, or carry fractional + slats? Recommendation: reject in Phase 1. +4. **Non-Euler integration** ([§6.6](#66-integration-method-euler)): hard-error + vs silently force Euler when a conveyor is present? Recommendation: hard + error, following the GH #486 pattern. +5. **Queue scope.** Confirm queues (and thus batch_integrity / one_at_a_time / + discrete-required coupling) are out of scope for now, to be specced + separately. +6. **Per-DT update interleave & leakage apportionment** + ([§4.2](#42-per-dt-update-order-clear), + [§4.3](#43-leakage-mostly-clear-formulas-verify-against-isee)): these are + `EXPERIMENT` items that need a Stella reference to pin bit-exactly; acceptable + to defer until oracle generation is sorted. +7. **UI / diagram treatment.** When (if in this effort) should the diagram + editor render and let users author conveyor stocks and leak flows? Currently + unscoped. diff --git a/test/conveyors/README.md b/test/conveyors/README.md new file mode 100644 index 000000000..c0c7365e9 --- /dev/null +++ b/test/conveyors/README.md @@ -0,0 +1,77 @@ +# Conveyor test fixtures + +Reference models that exercise XMILE conveyor stocks. Vendored to support the +conveyor implementation described in [docs/design/conveyors.md](/docs/design/conveyors.md). + +**Status: reference fixtures, not yet wired into the test harness.** Simlin does +not simulate conveyors today (see the design doc), and none of these models ship +expected-output CSVs. They become executable oracles only once (a) conveyor +support lands and (b) reference output is generated from Stella (or another +conforming engine). Until then they are here so the implementer has real, +authoritative XMILE to parse and represent. Do not add them to +`tests/integration/main.rs` until both conditions hold. + +## Files + +| File | Source | License | Conveyor features | Notes | +|------|--------|---------|-------------------|-------| +| `minimal_conveyor.xmile` | hand-authored (this repo) | project | transit time + capacity, single outflow, no leak | The Phase 1 target. Smallest possible conveyor; obvious expected behavior. | +| `sir_social_distancing_mixnot.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time only (`transit_time`) | Simplest real Stella oracle. `dt = 1/4`, Days, 0–100. Deterministic. | +| `covid19_severity.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time + leakage (`` flows) + arrayed conveyors (`Severity` dim) + `exponential_leak="true"` | Exercises leakage and subscripted conveyors. Deterministic. | + +### Non-conveyor blockers (must be resolved or trimmed before these become oracles) + +Verified against HEAD on 2026-07-05 with `simlin-cli simulate`: + +- `sir_social_distancing_mixnot.stmx`: uses the isee builtin `LOOKUPMEAN` + (unimplemented -> `unknown_builtin` on `transit time`). +- `covid19_severity.stmx`: several unit-consistency errors + (`bad_binary_op_in_units`) plus `PREVIOUS`/`isee:spreadflow` usage; these are + independent of conveyor support. + +The plan: once conveyors parse and simulate, either implement the missing +builtins or hand-trim minimized copies of these models so the conveyor behavior +can be isolated and compared against a Stella reference run. + +## Provenance and attribution + +### peterhovmand/COVID-19-SD-generic-structures + +- Repository: https://github.com/peterhovmand/COVID-19-SD-generic-structures +- Commit: `4da2febd19953efb9816425678f1c5e246ceac3a` (2020-04-18) +- License: Creative Commons Attribution 4.0 (CC BY 4.0), + https://creativecommons.org/licenses/by/4.0 +- Authors: Karim Chichakly, Bob Eberlein, Mark Heffernan, Peter Hovmand. + (Chichakly and Eberlein are isee/Ventana principals, so the XMILE encoding is + authoritative.) +- `sir_social_distancing_mixnot.stmx` = repo path + `Disease duration distribution/SIRSocialDistancingMixNot.stmx` +- `covid19_severity.stmx` = repo path + `Assymptomatic expression/Covid-19-Severity.stmx` + +CC BY 4.0 requires attribution; this section satisfies it. Do not remove. + +### Other candidates (not vendored) + +Available in the same peterhovmand repo if more coverage is needed: +`Extinction/SIRSocialDistancingExtinction.stmx` (1 conveyor), +`Disease duration distribution/IC for K.stmx` (17 conveyors + 10 leak flows), +`Special populations/COVID-19-ICU08.stmx` (4 conveyors, complex). + +`henriksen-marcus/Moose-Gamification` (`STELLA/MODEL/Forest.stmx`, Apache-2.0, +commit `03c74bc9`) has 20 conveyors with varied transit times and 7 leak flows, +but every conveyor stock is initialized with `RANDOM(100, 200)`, so it is +non-deterministic and unusable as an exact oracle without stripping the random +initializers. Kept out of the vendored set for that reason. + +No open-source model was found that exercises conveyor ``, +``, ``, ``, or an upstream queue. Those features +need hand-authored fixtures (and Stella reference output) when implemented. + +### Search method note + +GitHub code search does not index `.stmx`/`.xmile` file *content*, so +`extension:stmx`, `path:*.stmx`, and content queries like `"uses_conveyor"` all +return nothing. The only query that surfaced real Stella models was +`"" language:XML` (which also returns a lot of AnyLogic/game/i18n +noise to filter out). diff --git a/test/conveyors/covid19_severity.stmx b/test/conveyors/covid19_severity.stmx new file mode 100644 index 000000000..16f927247 --- /dev/null +++ b/test/conveyors/covid19_severity.stmx @@ -0,0 +1,1703 @@ + + +
+ + model-no-interface + cbbad60b-89e5-4578-9130-b1e9bca5acae + isee systems, inc. + Stella Architect +
+ + 0 + 365 +
4
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + person + persons + + + 1 + dmnl + unitless + fraction + + + + day + + + 1/Days + + + EUR/(person-year) + + + USD/jobs + + + USD/(worker-mo) + + + jobs/(worker-mo) + + + mo/yr + + + + + + community_population + new_infections_by_severity + + Person + + + + + + 0 + new_infections_by_severity + assymptomatic_arriving + becoming_contagious + testing_not_contagious + + days_infected_but_not_contagious_or_symptomatic + + Person + + + + + + 0 + becoming_contagious + becming_symptomatic + testing_assymptomatic + + days_contagious_but_not_symptomatic + + Person + + + + + + 0 + becming_symptomatic + losing_contagion + testing_symptomatic + contagious_deaths + + days_symptomatic_and_contagious + + Person + + + + + + 0 + losing_contagion + recovering + testing_symptomatic_not_contagious + non_contagious_deaths + + days_symptomatic_but_no_onger_contagious + + Person + + + 0 + recovering + tested_recovering + + Person + + + + + + contacts_with_at_risk * infectivity * severity_spread + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + fractional_death_rate + + + PErson/Day + + + 1 + Per Day + + + 2 + Days + + + 4 + Days + + + + + + + 7 + + + 7 + + + 10 + + + 14 + + Days + + + 5 + Days + + + 0.05 + dmnl + + + 0.005 + Dmnl + + + 0.1 + Dmnl + + + asymtomatic_not_tested_contagious * global_contact_rate + +mildly_symptomatic_not_tested_contagious * mildly_symptomatic_contact_rate + +symptomatic_not_tested_contagious * symptomatic_contact_rate + +tested_contagious * tested_contact_rate + Person/Day + + + + + + fractional_death_rate + + + PErson/Day + + + 60 + Days + + + + + + IF Severity = Severity.Asymptomatic THEN PULSE(1, STARTTIME, 500) ELSE 0 + + PErson/Day + + + + + + 0 + testing_not_contagious + becoming_contagious_1 + + days_infected_but_not_contagious_or_symptomatic + + Person + + + + + + 0 + becoming_contagious_1 + testing_assymptomatic + tested_becoming_symptomatic + + days_contagious_but_not_symptomatic + + Person + + + + + + 0 + tested_becoming_symptomatic + testing_symptomatic + tested_losing_contagion + tested_contagious_deaths + + days_symptomatic_and_contagious + + Person + + + + + + 0 + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + fractional_death_rate + + + PErson/Day + + + + + + effective_asymptomatic_test_rate + + + PErson/Day + + + + + + test_rate_different_severity + + + PErson/Day + + + + + + test_rate_different_severity + + + PErson/Day + + + MIN(testing_capacity-symptomatic_testing - mildly_symptomatic_testing, recent_testing_positive * contacts_tested_per_positive_test) + Person/Day + + + + + + + 0.1 + + + 0.3 + + + 0.4 + + + 0.2 + + Dmnl + + + SUM(asymptomatic_contagious[*]) + SUM(infected_not_contagious[*]) + symptomatic_contagious[Asymptomatic] + symptomatic_not_contagious[Asymptomatic] + + Person + + + 1e6 + Person + + + + + + 0 + tested_losing_contagion + testing_symptomatic_not_contagious + tested_recovering + tested_non_contagious_deaths + + days_symptomatic_but_no_onger_contagious + + Person + + + + + + test_rate_different_severity + + + PErson/Day + + + + + + 0 + + PErson/Day + + + + + + fractional_death_rate + + + PErson/Day + + + + + + + 0 + + + 0 + + + 0.001 + + + 0.15 + + Dmnl + + + + + + Crude_Death_Rate_by_Group/(days_symptomatic_and_contagious + days_symptomatic_but_no_onger_contagious) + Per Day + + + SUM(tested_asymptomatic_contagious[*]) + SUM(tested_symptomatic_contagious[*]) + + Person + + + symptomatic_contagious[Moderate] + symptomatic_contagious[Severe] + + person + + + SUM(asymptomatic_contagious[*]) + + Person + + + IF TIME < global_change_start OR TIME > global_change_start + global_change_duration THEN baseline_contact_rate ELSE baseline_contact_rate * (1 - quarantine_effectiveness) + Per Day + + + 0.4 + Dmnl + + + MIN(global_contact_rate, baseline_contact_rate * symptomatic_adjustment) + Per Day + + + MIN(global_contact_rate, baseline_contact_rate * tested_adjustment) + Per Day + + + contacts * Uninfected_at_risk/total_population + Person/Day + + + Uninfected_at_risk + SUM(asymptomatic_contagious[*]) + SUM(infected_not_contagious[*]) + recovered + SUM(symptomatic_contagious[*]) + SUM(symptomatic_not_contagious[*]) + SUM(tested_asymptomatic_contagious[*]) + SUM(tested_infected_not_contagious[*]) + SUM(tested_symptomatic_not_contagious[*]) + + Person + + + 0 + testing_incidence + + Person + + + testing_positive + + Person/Day + + + SUM(testing_assymptomatic[*]) + SUM(testing_not_contagious[*]) + SUM(testing_symptomatic[*]) + SUM(testing_symptomatic_not_contagious[*]) + + Person/Day + + + becming_symptomatic[Moderate] + becming_symptomatic[Severe] + testing_positive + + Person/Day + + + 0 + presumed_new_infections + + Person + + + presumed_positives + + Person/Day + + + SUM(contagious_deaths[*]) + SUM(non_contagious_deaths[*]) + SUM(tested_contagious_deaths[*]) + SUM(tested_non_contagious_deaths[*]) + + Person/Day + + + 0 + all_dying + + Person + + + death_rate + + PErson/Day + + + symptomatic_contagious[Severe] + symptomatic_not_contagious[Severe] + tested_symptomatic_contagious[Severe] + tested_symptomatic_not_contagious[Severe] + + person + + + MAX(needing_hospitalization, PREVIOUS(SELF, 0)) + Person + + + needing_hospitalization + symptomatic_contagious[Moderate] + symptomatic_not_contagious[Moderate] + tested_symptomatic_contagious[Moderate] + tested_symptomatic_not_contagious[Moderate] + + Person + + + MAX(PREVIOUS(SELF, 0), too_ill_to_work) + Person + + + 14 + Days + + + 0.1 + Per Day + + + 100 + PErson/Day + + + symptomatic_contagious[Moderate] + symptomatic_contagious[Severe] + symptomatic_not_contagious[Moderate] + symptomatic_not_contagious[Severe] + + Person + + + asymptomatic_testing * contact_tracing_effectiveness//asymptomatic_never_tested_positive + Per Day + + + + + + + Uninfected_at_risk + + + Total_infected[Asymptomatic]+Total_infected[Mild] + + + Total_infected[Moderate] + + + Total_infected[Severe] + + + recovered + + + Cumulative_Deaths + + Person + + + + + + asymptomatic_contagious + infected_not_contagious + symptomatic_contagious + symptomatic_not_contagious + tested_asymptomatic_contagious + tested_infected_not_contagious + tested_symptomatic_contagious + tested_symptomatic_not_contagious + + Person + + + + + + IF TIME <= most_dismal_time THEN active_by_condition ELSE PREVIOUS(SELF, 0) + Person + + + + + + IF TIME = STOPTIME THEN active_most_dismal ELSE active_by_condition + Person + + + IF too_ill_to_work >= peak_of_too_ill_to_work THEN TIME ELSE PREVIOUS(SELF, STARTTIME) + Days + + + IF TIME < STOPTIME THEN TIME ELSE most_dismal_time + Days + + + IF TIME > most_dismal_time AND SUM(Total_infected) < 20 THEN 0.1 ELSE 20 + Dmnl + + + 0.5 + Dmnl + + + MIN(global_contact_rate, baseline_contact_rate * mildly_symptomatic_adjustment ) + Per Day + + + symptomatic_contagious[Mild] + + person + + + MIN(testing_capacity, symptomatic_never_tested_positive * target_symptomatic_test_rate) + PErson/Day + + + MIN(testing_capacity-symptomatic_testing, mildly_symptomatic_never_tested_positive * target_mildly_symptomatic_test_rate) + Person/Day + + + symptomatic_contagious[Mild] + symptomatic_not_contagious[Mild] + + Person + + + + + + IF Severity = Severity.Mild THEN mildly_symptomatic_testing // mildly_symptomatic_never_tested_positive ELSE symptomatic_testing // symptomatic_never_tested_positive + Per Day + + + target_symptomatic_test_rate * mild_test_fraction + Per Day + + + 0.25 + Dmnl + + + 0 + Dmnl + + + 0 + increasing_bed_days + + Person*Day + + + needing_hospitalization + + person + + + asymptomatic_testing + mildly_symptomatic_testing + symptomatic_testing + + Person/Day + + + 1 + Dmnl + + + SMTH1(testing_positive, testing_smooth_time, 0) + + Person/Day + + + 7 + Days + + + 0 + Flow_1 + + Person + + + all_testing + + Person/Day + + + + + + + active_by_condition[Servere] + + + active_by_condition[Mild]+active_by_condition[Moderate] + + + active_by_condition[Dead] + + + active_by_condition[Healthy] + + + active_by_condition[Recovered] + + person + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + severity_spread + new_infections_by_severity + + + + + + + + + + + + + + + + + + + + + + + + + + + + Crude_Death_Rate_by_Group + fractional_death_rate + + + days_symptomatic_and_contagious + fractional_death_rate + + + days_symptomatic_but_no_onger_contagious + fractional_death_rate + + + fractional_death_rate + tested_contagious_deaths + + + fractional_death_rate + tested_non_contagious_deaths + + + + + + contagious_deaths + + + + + + non_contagious_deaths + + + + + + asymtomatic_not_tested_contagious + contacts + + + symptomatic_not_tested_contagious + contacts + + + tested_contagious + contacts + + + + global_change_start + global_contact_rate + + + quarantine_effectiveness + global_contact_rate + + + baseline_contact_rate + global_contact_rate + + + + infectivity + new_infections_by_severity + + + global_contact_rate + contacts + + + + symptomatic_adjustment + symptomatic_contact_rate + + + baseline_contact_rate + symptomatic_contact_rate + + + symptomatic_contact_rate + contacts + + + + tested_adjustment + tested_contact_rate + + + baseline_contact_rate + tested_contact_rate + + + tested_contact_rate + contacts + + + + + Uninfected_at_risk + contacts_with_at_risk + + + total_population + contacts_with_at_risk + + + contacts + contacts_with_at_risk + + + contacts_with_at_risk + new_infections_by_severity + + + + + + + + + + + testing_positive + testing_incidence + + + + + + + + + + + presumed_positives + presumed_new_infections + + + + + + + + + + + death_rate + all_dying + + + + + needing_hospitalization + Peak_Hospitalization + + + + + too_ill_to_work + peak_of_too_ill_to_work + + + + global_change_duration + global_contact_rate + + + + + + + testing_capacity + asymptomatic_testing + + + asymptomatic_testing + effective_asymptomatic_test_rate + + + asymptomatic_never_tested_positive + effective_asymptomatic_test_rate + + + global_contact_rate + symptomatic_contact_rate + + + global_contact_rate + tested_contact_rate + + + + + + + active_by_condition + + + + Total_infected + active_by_condition + + + + + + active_by_condition + + + + + + active_by_condition + + + + active_by_condition + active_most_dismal + + + peak_of_too_ill_to_work + most_dismal_time + + + too_ill_to_work + most_dismal_time + + + + active_by_condition + active_by_condition_for_display + + + active_most_dismal + active_by_condition_for_display + + + + most_dismal_time + active_most_dismal + + + + most_dismal_time + time_do_display + + + + most_dismal_time + Sim_Duration + + + Total_infected + Sim_Duration + + + + + + + + + + effective_asymptomatic_test_rate + testing_not_contagious + + + + + mildly_symptomatic_adjustment + mildly_symptomatic_contact_rate + + + baseline_contact_rate + mildly_symptomatic_contact_rate + + + global_contact_rate + mildly_symptomatic_contact_rate + + + + mildly_symptomatic_not_tested_contagious + contacts + + + mildly_symptomatic_contact_rate + contacts + + + + symptomatic_testing + asymptomatic_testing + + + testing_capacity + symptomatic_testing + + + symptomatic_never_tested_positive + symptomatic_testing + + + target_symptomatic_test_rate + symptomatic_testing + + + + testing_capacity + mildly_symptomatic_testing + + + + mildly_symptomatic_never_tested_positive + mildly_symptomatic_testing + + + symptomatic_testing + mildly_symptomatic_testing + + + mildly_symptomatic_testing + asymptomatic_testing + + + + mildly_symptomatic_never_tested_positive + test_rate_different_severity + + + symptomatic_never_tested_positive + test_rate_different_severity + + + mildly_symptomatic_testing + test_rate_different_severity + + + symptomatic_testing + test_rate_different_severity + + + + + + testing_assymptomatic + + + + + + testing_symptomatic + + + + + + testing_symptomatic_not_contagious + + + + target_mildly_symptomatic_test_rate + mildly_symptomatic_testing + + + target_symptomatic_test_rate + target_mildly_symptomatic_test_rate + + + + mild_test_fraction + target_mildly_symptomatic_test_rate + + + + + + + + + + + needing_hospitalization + increasing_bed_days + + + + contact_tracing_effectiveness + effective_asymptomatic_test_rate + + + + contacts_tested_per_positive_test + asymptomatic_testing + + + + testing_positive + recent_testing_positive + + + + testing_smooth_time + recent_testing_positive + + + + + + asymptomatic_testing + + + + + + + + + + all_testing + Flow_1 + + + + + + + + + + + active_by_condition + active_by_graph_condition + + + + fractional_death_rate + + + Uninfected_at_risk + + + Cumulative_Deaths + + + recovered + + + + test_rate_different_severity + + + + recent_testing_positive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + contact_rate_not + Not_Mixing + + + + + contact_adjustment + Not_Mixing + + + adjustment_start_time + Not_Mixing + + + contact_adjustment + Perfect_Mixing + + + adjustment_start_time + Perfect_Mixing + + + contact_rate_perfect + Perfect_Mixing + + + + + disease_duration + Perfect_Mixing + + + disease_duration + Not_Mixing + + + profile + Not_Mixing + + + + infectivity + Not_Mixing + + + infectivity + Perfect_Mixing + + + + + contact_rate_not + Not_Mixing_at_all + + + contact_adjustment + Not_Mixing_at_all + + + adjustment_start_time + Not_Mixing_at_all + + + infectivity + Not_Mixing_at_all + + + disease_duration + Not_Mixing_at_all + + + + + + + + + + + + + + + Infected + contacts + + + + contact_rate + contacts + + + + + total_pop + fraction_susceptible + + + susceptible + fraction_susceptible + + + + fraction_susceptible + risky_contacts + + + contacts + risky_contacts + + + + infectivity + infecting + + + risky_contacts + infecting + + + + + + + + + + + + + + + + + + + + + + + + + + adjustment_start_time + contacts + + + contact_adjustment + contacts + + + + + + + R0 + + + + + + R0 + + + + + + R0 + + + + + profile + transit_time + + + disease_duration + transit_time + + + profile + infecting + + + infectivity + + + contact_rate + + + + disease_duration + + + + + + + + 1000 + infecting + + Person + + + 1 + infecting + recovering + + transit_time + + Person + + + risky_contacts * infectivity + + Person/Days + + + Infected * contact_rate * (IF TIME > adjustment_start_time THEN contact_adjustment ELSE 1) + Touch/Day + + + 85 + Touch/Person/Day + + + susceptible/total_pop + Dmnl + + + Recovered + Infected + susceptible + + Person + + + contacts*fraction_susceptible + Touch/Day + + + 0.0045 + Person/touch + + + 0 + + Person/Days + + + 0 + recovering + + Person + + + 14 + Days + + + 0.5 + Dmnl + + + 35 + Days + + + contact_rate * infectivity * disease_duration + Dimensionless + + + disease_duration + Days + + + + + + + + + + + + + + + + Infected + contacts + + + + contact_rate + contacts + + + + + total_pop + fraction_susceptible + + + susceptible + fraction_susceptible + + + + fraction_susceptible + risky_contacts + + + contacts + risky_contacts + + + + infectivity + infecting + + + risky_contacts + infecting + + + + + + + + + + + + + + + + + + + + + + + + + + adjustment_start_time + contacts + + + contact_adjustment + contacts + + + + + + + R0 + + + + + + R0 + + + + + + R0 + + + + disease_duration + transit_time + + + infectivity + + + contact_rate + + + + disease_duration + + + + + + + + 1000 + infecting + + Person + + + 1 + infecting + recovering + + Person + + + risky_contacts * infectivity + + Person/Days + + + Infected * contact_rate * (IF TIME > adjustment_srat_time THEN contact_adjustment ELSE 1) + Touch/Day + + + 85 + Touch/Person/Day + + + susceptible/total_pop + Dmnl + + + Recovered + Infected + susceptible + + Person + + + contacts*fraction_susceptible + Touch/Day + + + 0.0045 + Person/touch + + + Infected/disease_duration + + Person/Days + + + 0 + recovering + + Person + + + 14 + Days + + + 0.5 + Dmnl + + + 35 + Days + + + contact_rate * infectivity * disease_duration + Dimensionless + + + + + + + + + + + + + + + + Infected + contacts + + + + contact_rate + contacts + + + + + total_pop + fraction_susceptible + + + susceptible + fraction_susceptible + + + + fraction_susceptible + risky_contacts + + + contacts + risky_contacts + + + + infectivity + infecting + + + risky_contacts + infecting + + + + + + + + + + + + + + + + + + + + + + + Infected + recovering + + + + disease_duration + recovering + + + + + adjustment_srat_time + contacts + + + contact_adjustment + contacts + + + + + + + R0 + + + + + + R0 + + + + + + R0 + + + infectivity + + + contact_rate + + + + disease_duration + + + + +
+ From 4cd7b81006e2d8df2d516ade91fe21f347870966 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 08:49:44 -0700 Subject: [PATCH 02/11] doc: fully specify conveyor semantics + reference prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the conveyor design doc from a research memo with open decisions into a complete, implementable specification, and address the PR review. The doc now commits to concrete algorithms and formulas rather than deferring: the exact per-DT update order, the linear and exponential leakage math (with leak zones and integer leakage), non-integer transit-time rounding (N = round(T/DT), effective transit N*DT, compile Warning), the steady-state initialization (a general unit-cohort forward-simulation plus closed forms), capacity/inflow-limit pushback, variable transit time with , all five isee spread-input placement methods, arrayed conveyors, the conveyor side of queue coupling, a VM-native runtime design, and an Euler-only hard requirement (GH #486 precedent). The prior "decisions needed from the maintainer" section is gone; what remains is logistics (cross-engine confirmation, diagram authoring), not spec holes. To validate the spec rather than assert it, the per-DT algorithm, leakage, capacity/limit, and initialization were transcribed into an executable reference prototype (test/conveyors/reference_prototype.py) and exercised over seven scenarios: steady state, fill-from-empty transit delay, linear leak (outflow/inflow = 1-f), exponential leak (matches 250*(1-f*dt)^N closed form), capacity clip, inflow-limit clip, and non-integer transit rounding. All checks pass; the trajectories are recorded as the acceptance oracles in the doc's Worked Examples section, so the core continuous conveyor can be test-driven without Stella access. Review fixes: correct the broken intra-doc anchor links flagged by the Claude review, and address Codex's point that sir_social_distancing_mixnot.stmx is not transit-time-only -- its Not_Mixing submodel uses isee:spreadflow="dist" distribution-based inflow placement (now a fully specified feature, §8), with LOOKUPMEAN called out as the separate remaining builtin gap. --- docs/README.md | 2 +- docs/design/conveyors.md | 1032 ++++++++++++++----------- test/conveyors/README.md | 20 +- test/conveyors/reference_prototype.py | 226 ++++++ 4 files changed, 811 insertions(+), 469 deletions(-) create mode 100644 test/conveyors/reference_prototype.py diff --git a/docs/README.md b/docs/README.md index 8df4da602..a28decca2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ - [architecture.md](architecture.md) -- Component descriptions, dependency graph, project structure - [design/2026-02-21-incremental-compilation.md](design/2026-02-21-incremental-compilation.md) -- Incremental compilation via salsa: symbolic bytecode, per-variable tracking, LTM integration -- [design/conveyors.md](design/conveyors.md) -- XMILE conveyor support: spec, simulation semantics, engine integration points, phasing, and open decisions +- [design/conveyors.md](design/conveyors.md) -- XMILE conveyor support: complete specification of syntax, per-DT simulation semantics, leakage/initialization formulas, spread inputs, arrays, and engine integration - [design/engine-performance.md](design/engine-performance.md) -- Engine compile/simulate profile (C-LEARN), implemented optimizations, and remaining proposals - [design/ltm--loops-that-matter.md](design/ltm--loops-that-matter.md) -- LTM implementation design: data structures, synthetic variables, module handling - [design/mdl-parser.md](design/mdl-parser.md) -- Vensim MDL parser design history and implementation notes diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 667b30068..2f76a61d0 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -1,38 +1,41 @@ -# Conveyor support: specification and requirements - -Status: proposed. This document specifies XMILE conveyor support for the simlin -engine. It is written to be implemented by a fresh engineer or agent with no -prior context on conveyors. It separates what the specs make **unambiguous** -from what **needs a decision** (flagged `DECISION:`) or **experimentation -against Stella** (flagged `EXPERIMENT:`). Items requiring the maintainer's input -are collected in [§12](#12-decisions-needed-from-the-maintainer). +# Conveyor support: specification + +Status: proposed. This is a complete, implementable specification of XMILE +conveyor support for the simlin engine, written for an engineer or agent with no +prior conveyor context. It commits to concrete algorithms and formulas: every +per-DT rule, leakage formula, initialization rule, and edge case is specified +here, not left open. Where a rule is derived rather than quoted verbatim from a +vendor spec, it says so and defines simlin's behavior authoritatively; the +vendored fixtures ([§13](#13-test-oracles)) are the regression oracles that pin +the numerics. + +Sources: the OASIS XMILE 1.0 spec (`docs/reference/xmile-v1.0.html`; windows-1252, +use `grep -a`) for syntax and prose semantics, and isee systems' "Computational +Details" help pages for the per-DT math. The isee "traditional conveyor" model +is the reference behavior simlin implements. ## 1. Motivation -Conveyors are a first-class stock type in Stella / isee systems models, used -heavily for aging chains, disease-progression stages, and material-transport -structures. XMILE 1.0 marks them an OPTIONAL feature (§3.7.2, §4.2.1, §4.3). -Many real Stella `.stmx` models in the wild use them; without support, simlin -cannot faithfully import, round-trip, or simulate those models. +Conveyors are a first-class stock type in Stella / isee systems models, used for +aging chains, disease-progression stages, and material-transport structures. +XMILE 1.0 marks them OPTIONAL (§3.7.2, §4.2.1, §4.3). Many real `.stmx` models +use them; without support simlin cannot faithfully import, round-trip, or +simulate those models. ### Current behavior (verified against HEAD, 2026-07-05) -Conveyors are not merely unsupported — they fail **silently and confusingly**: +Conveyors fail **silently and confusingly** today: 1. **Import drops the block.** The XMILE reader struct `xmile::Stock` - (`src/simlin-engine/src/xmile/variables.rs`) has no field for ``, - and quick-xml ignores unknown child elements, so the entire conveyor - specification is discarded on import. -2. **Compilation then fails on the outflow.** A conveyor's outflow MUST NOT have - an equation (the conveyor drives it — XMILE §4.3). With the conveyor block - gone, that equation-less flow reaches the compiler as an ordinary flow and - errors with `empty_equation` naming the outflow — a message that gives the - modeler no hint the real problem is an unsupported conveyor. + (`src/simlin-engine/src/xmile/variables.rs`) has no field for ``; + quick-xml ignores unknown child elements, so the conveyor spec is discarded. +2. **Compilation then fails on the outflow.** A conveyor outflow MUST NOT have an + equation (the conveyor drives it — XMILE §4.3). With the block gone, that + equation-less flow errors with `empty_equation`, giving no hint that the real + problem is an unsupported conveyor. 3. **Export loses the block.** The `` header option round-trips - (the `Feature::UsesConveyor` enum exists in `xmile/mod.rs`), but the - `` block does not, so import-then-export **corrupts** a Stella - model: the header advertises conveyors while the stocks have become plain - stocks. + (`Feature::UsesConveyor` in `xmile/mod.rs`) but the `` block does + not, so import-then-export **corrupts** a Stella model. Reproduce with `test/conveyors/minimal_conveyor.xmile`: @@ -41,64 +44,55 @@ $ simlin-cli simulate test/conveyors/minimal_conveyor.xmile error in model 'main' variable 'graduating': empty_equation ``` -Even the immediate Phase 0 goal (below) is a strict improvement: represent the -conveyor and replace this misleading error with a clear "conveyors are not yet -simulatable" diagnostic. - ## 2. Concepts and vocabulary -A conveyor is a stock whose contents move along a fixed-length belt. Material -enters at the back, advances one **slat** per DT, and falls off the front after -the **transit time** has elapsed. Conceptually (isee "traditional conveyor" -model): - -- The belt is a row of slats, one slat per DT. The number of slats is - `transit_time / DT`. Each slat holds a quantity of material; slat 1 is the - exit end. -- Each DT, every slat's contents shift one position toward the exit; slat 1's - contents leave as the **outflow**; the inflow is deposited into the slat - `transit_time/DT` positions from the exit. -- **Leakage** flows let material fall off partway along the belt (attrition, - mortality). Leakage is linear (constant amount per DT) or exponential - (constant fraction of remaining material per DT), optionally confined to a - **leak zone** (a fractional span of the belt). -- Conveyors are **not FIFO by default**: if the transit time shrinks, material - added later can exit earlier. Material already on the belt keeps advancing one - slat/DT regardless of later transit-time changes. - -Related XMILE stock modes, both OPTIONAL and both **out of scope for the first -milestones** (see phasing): **queues** (FIFO batch-tracking stocks) and isee -**ovens** (batch-process stocks, not an XMILE standard construct). Queues matter -here only because a conveyor can sit downstream of a queue and constrain it; see -[§9](#9-queues-and-conveyor-queue-coupling-later-phase). - -## 3. XMILE syntax (unambiguous — from the spec) - -Reference: OASIS XMILE v1.0, §2.2.1, §3.7.2, §4.2, §4.2.1, §4.3. Local copy: -`docs/reference/xmile-v1.0.html` (windows-1252 encoded; use `grep -a`). +A conveyor is a stock whose contents ride a belt of fixed length. Material enters +at the back, advances one **slat** per DT, and falls off the front after the +**transit time** elapses. -### 3.1 Options header +- The belt is an ordered list of slats, one slat = one DT of travel. The slat + count is `N = transit_time / DT`. Slat 1 is the exit (front); the highest-index + slat is the entry (back). Each slat holds a real quantity of material. +- Each DT: leakage is removed, the exit slat's contents leave as the primary + **outflow**, every slat shifts one position toward the exit, and the admitted + inflow is deposited at the entry. +- **Leakage** flows let material fall off partway (attrition, mortality), linear + or exponential, optionally confined to a **leak zone** (a fractional span of + the belt). +- Conveyors are **not FIFO**: if the transit time shrinks, material added later + can exit earlier. Material already on the belt keeps advancing one slat/DT + regardless of later transit-time changes. + +Two related XMILE stock modes are distinct objects, not conveyors: **queues** +(``, FIFO batch-tracking stocks — [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling)) +and isee **ovens** (batch processors, not XMILE-standard — not specified here). +They intersect conveyors only when a queue sits directly upstream of a conveyor. + +## 3. XMILE syntax -A model using conveyors MUST declare it in the `` block: +Reference: OASIS XMILE v1.0 §2.2.1, §3.7.2, §4.2, §4.2.1, §4.3. + +### 3.1 Options header ```xml - + ``` Two OPTIONAL boolean attributes advertise sub-features: `arrest="true|false"` -(default false) and `leak="true|false"` (default false). These are advisory; -the authoritative source of truth is the per-stock `` block. +(default false) and `leak="true|false"` (default false). They are advisory; the +authoritative source is the per-stock `` block. simlin emits +`` whenever any stock in the project is a conveyor, and sets +`arrest`/`leak` to reflect whether any conveyor uses those features. ### 3.2 The conveyor block (on a stock) `` is one of three mutually exclusive stock options (``, ``, ``). The stock's `` is the conveyor's initial -value (see [§5](#5-initialization)); its ``/`` lists name the -flows. +value ([§7](#7-initialization)); ``/`` name the flows. ```xml - 1000 + 1000 matriculating graduating attriting @@ -113,8 +107,6 @@ flows. ``` -Element/attribute reference: - | Tag / attr | Kind | Type | Default | Meaning | |---|---|---|---|---| | `` | element | XMILE expr | REQUIRED | Transit time in time units. | @@ -130,226 +122,315 @@ Element/attribute reference: ### 3.3 Leakage flows The first `` is the primary belt-end outflow; the rest are leakages -(XMILE §3.7.2, §4.2.1). A leakage flow SHOULD be explicitly tagged. A conveyor -outflow MUST NOT carry a normal `` — the conveyor drives it. Real Stella -models put the leak **fraction** in the `` of a ``-tagged flow: +(XMILE §3.7.2, §4.2.1). A conveyor outflow MUST NOT carry a normal ``. Real +Stella models put the leak **fraction** in the `` of a ``-tagged +flow: ```xml - 0.1 + 0.1 ``` -Note the encoding wrinkle observed in real models (both peterhovmand corpus and -the moose model): the leak fraction lives in ``, and `` / -`` are empty sibling marker tags. The spec also shows a -`0.1` element form. **The reader must accept both** the -marker-``-plus-`` form and the value-bearing `expr` -form. `` with no fraction yet is a valid "leakage, fraction TBD" marker -(used mid-edit) that does not simulate. - -Leakage flow options: +The reader MUST accept both encodings: the marker-``-plus-`` form +(what the vendored fixtures use) and the value-bearing `expr` form +(the spec's example). A `` with no fraction and no `` is a valid +"leakage, fraction TBD" marker (used mid-edit); it parses and represents but +contributes zero leakage until a fraction is supplied. | Tag / attr | Type | Default | Meaning | |---|---|---|---| -| `` / `` | expr / marker | — | Leak fraction (fraction of inflowing material leaked by exit), or bare marker. | -| `` | marker | off | Leak only whole units; accumulate the fraction until ≥ 1, then leak one unit. | +| `` / `` | expr / marker | 0 | Leak fraction (interpretation depends on linear vs exponential — [§5](#5-leakage)). | +| `` | marker | off | Leak only whole units. | | `leak_start` | attr in [0,1] | 0 | Fractional belt position where the leak zone starts (from the inflow side). | | `leak_end` | attr in [0,1] | 1 | Fractional belt position where the leak zone ends. | ### 3.4 Non-negativity -Conveyor and queue **inflows** MUST be non-negative (uniflow); the primary -conveyor outflow is non-negative by definition. `` MUST NOT appear -on those flows. (XMILE §4.3.) The reader should tolerate its presence in -real-world files rather than hard-erroring, since Stella emits `` -on leak flows. - -## 4. Simulation semantics - -This is the substance. Sources are the OASIS spec (behavioral prose) plus isee's -"Computational Details" help pages, which are the only place the per-DT math is -documented. Key isee pages (verify against these when implementing; the -"traditional conveyor" page is the richest and is the model simlin most likely -needs to match): - -- Traditional (non-FIFO) conveyors: - `iseesystems.com/resources/help/v2/Content/08-Reference/05-Computational_Details/TraditionalConveyors.htm` -- FIFO conveyors: - `.../v3/.../FIFOConveyors.htm` -- Spreading conveyor inputs: - `.../v3/.../SpreadingConveyorInputs.htm` -- Initializing discrete stocks: - `.../v3/.../InitializingDiscreteStocks.htm` -- Equation tab (capacity/in_limit/sample/arrest/discrete/split/FIFO): - `.../v3/.../07-SharedProperties/Equation_tab.htm` - -### 4.1 The slat model (clear) - -- Slat count `N = transit_time / DT`. Slat 1 is the exit; slat N is the entry. -- Per DT: outflow = contents of slat 1; all slats shift toward the exit; inflow - is placed into the entry slat. -- The conveyor's scalar/reported value = the sum of all slat contents (total - material on the belt). *(Inferred, consistent with the steady-state formulas; - not stated verbatim — `EXPERIMENT` to confirm.)* - -### 4.2 Per-DT update order (clear) - -Each DT, for each conveyor: - -1. If `` evaluates nonzero: force all inflows and outflows to zero, do - not advance the belt, skip to next conveyor. -2. Compute the admitted inflow, clipped by capacity and inflow limit - ([§4.5](#45-capacity-and-inflow-limit-pushback)). Un-admitted material stays - upstream. -3. Advance: outflow = slat 1; shift every slat one position toward the exit. -4. Apply leakage to the (now shifted) belt in leak-flow priority order - ([§4.3](#43-leakage)). -5. Deposit admitted inflow into the entry slat (position `N` from the exit, - using the transit time latched per [§4.4](#44-transit-time-changes)). - -`DECISION:` the exact interleave of steps 3–5 (does inflow land before or after -the shift; is leakage before or after the outflow is taken) determines -first-DT numerics. Pin it against a Stella reference run on -`minimal_conveyor.xmile` before finalizing. The ordering above is the best -reading of the isee prose but is not guaranteed bit-exact. - -`EXPERIMENT:` **non-integer `N = transit_time/DT`.** The docs give no rounding -rule, and the traditional (integer-slat) and FIFO (fractional-edge-slat) pages -imply different handling. Options: round to nearest integer slat count; or carry -a fractional head/tail slat. Must be resolved experimentally. Recommend the -engine **reject a non-integer ratio with a clear error** in Phase 1 and only add -fractional-slat support if a reference model needs it. - -### 4.3 Leakage (mostly clear; formulas verify-against-isee) - -Leak fraction `f` = the fraction of inflowing material that leaks out by the -time it reaches the exit. - -- **Linear** (`exponential_leak=false`): "the same amount is taken away every - DT." Total leaked over the belt equals `f`. Distributed across the slats the - material occupies, so per-slat leak ≈ `f × (amount that entered) / N`. isee - example: 8% over 8 DTs = 1%/DT. Constraint: total linear leak fraction across - all leak flows ≤ 1; a fraction of 1 leaves no outflow. - - Under a leak zone `[leak_start, leak_end]`: the total leaked is still `f` - regardless of zone length — a shorter zone leaks *more per slat*. - - Under a variable transit time: the amount leaked is based on the transit - time in force **when the material entered**. -- **Exponential** (`exponential_leak=true`): "the same fraction of remaining - material is removed every DT." Per-slat per-DT leak = `material_in_slat × f × - DT`. Overlapping leak zones add their fractions. Bases leakage on current belt - contents, independent of entry transit time. -- **Multiple leak flows**: applied in the order the outflows are listed (first = - highest priority). -- **``**: accumulate the fractional leak until it exceeds 1, - then leak exactly one unit; carry the remainder. - -`EXPERIMENT:` the precise per-slat apportionment (especially linear leak with a -partial zone, and the exact denominator) is prose-only in the isee docs. Derive -the exact recurrence and validate against `covid19_severity.stmx` (which uses -`exponential_leak="true"` leak flows) and a hand-authored linear-leak fixture. - -`DEFER:` isee also documents an "ignore losses from earlier leak zones" flag and -five "spreading conveyor inputs" placement methods (at-beginning is the default -XMILE behavior; evenly / destination-profile / distribution / source-based are -isee extensions). These are not XMILE-standard `` options and are out -of scope; do not implement until a real model requires them. - -### 4.4 Transit-time changes (clear-ish) - -- Default conveyors are **non-FIFO**. Material already on the belt keeps - advancing one slat/DT; a transit-time change only affects where **newly - entering** material is placed (slat `N_new = new_transit_time / DT` from the - exit). If the transit time drops, later material can exit before earlier - material. -- ``: the transit time is re-read from `` only on DTs when the - sample expression is nonzero; between samples the last latched value is used - for placement. Default is every DT. -- FIFO mode (isee `` extension / Equation-tab checkbox) preserves - content order across transit-time changes and, on a shortening, doubles the - outflow until the old-transit material is exhausted. `DEFER:` treat FIFO as a - later enhancement; the XMILE-standard behavior is non-FIFO. - -### 4.5 Capacity and inflow limit (pushback) (clear formulas, subtle plumbing) - -Both default to INF. Per the isee Equation-tab page: - -- **Capacity**: `admitted_inflow_volume = MIN((capacity - current_contents)/DT + - total_outflow_volume, requested_inflow_volume)` per DT (volumes = rate×DT). -- **Inflow limit** (per **time unit**, not per DT): - - Continuous: `admitted = MIN(in_limit, requested)` after multiplying the - per-time-unit limit into a per-DT budget (`DT × in_limit`). - - Discrete: the full per-time-unit budget may enter within a single DT; the - running total resets at each integer time unit. - - Inflow limit is unavailable when the inflow comes from another conveyor. -- **Blocked material stays upstream.** If the upstream is an ordinary stock, the - un-admitted inflow simply is not removed from that stock. This is the subtle - part for simlin's architecture: **a conveyor's admitted inflow depends on the - conveyor's state, so the inflow's effective value is not a pure function of - its own equation** — it is clipped by the downstream conveyor. See - [§6.4](#64-the-inflow-clipping-problem). - -`DECISION:` apportioning a capacity/limit clip among multiple simultaneous -inflows to one conveyor is only partly documented (creation order). Pick a rule -(proportional, or priority by inflow order) and document it. - -### 4.6 Arrest (clear) - -When `` is nonzero: all inflows and outflows to the conveyor are forced -to zero and the belt freezes in place (material is not lost, time suspends for -it). It resumes when the expression returns to zero. - -### 4.7 Discrete conveyors (partly clear) - -`discrete=true` moves material as integer chunks that exit as discrete lumps -rather than a smooth stream; the initial value seeds the start of each time unit -rather than being spread evenly ([§5](#5-initialization)); the inflow-limit -budget is per-time-unit (fillable in one DT) rather than per-DT. `EXPERIMENT:` -exact chunk quantization and remainder handling are qualitative in the docs. -`discrete=true` is REQUIRED when there is a queue directly upstream. - -## 5. Initialization - -The stock `` gives the initial contents. Two modes: - -- **Scalar initial value** → steady-state distribution. Stella distributes the - value so the belt is in equilibrium. Documented closed forms (valid only when - `transit_time` is a multiple of DT and leak zones span 0→100%): - - No leak: contents `= inflow × transit_time`, i.e. the scalar value is spread - evenly, `value/N` per slat. - - Linear leak fraction `f`: - `inflow × (transit_time × (1 − f/2) + f × DT / transit_time)`. - - Exponential leak fraction `f`: - `inflow × (1 − (1 − f×DT)^(transit_time/DT)) / f`. - These relate the scalar initial value, the equilibrium inflow, and the - per-slat fill. `EXPERIMENT:` confirm which quantity the `` value denotes - (total contents vs equilibrium inflow) — the isee steady-state blog gives an - alternate outflow-first formulation; do not guess. -- **Explicit per-slat list** (comma-separated) → each entry is the quantity for - one **time unit** (not one DT); with fractional DT the value is repeated across - that unit's DTs. List length must equal `transit_time` or `transit_time/DT` - (the latter is required for non-integer transit times); too many → truncate, - too few → repeat the last entry. - -Phase 1 can implement only the scalar even-spread no-leak case and defer the -leak/explicit-list cases. - -## 6. Engine architecture and integration points - -This section maps the feature onto simlin's actual code. Read -`src/simlin-engine/CLAUDE.md` for the compilation pipeline. - -### 6.1 Data model (`src/simlin-engine/src/datamodel.rs`) - -`datamodel::Stock` (line ~306) needs to carry an optional conveyor spec, and -`datamodel::Flow` needs optional leakage metadata. Suggested shape: +Conveyor and queue **inflows** are non-negative by requirement (uniflow); the +primary conveyor outflow is non-negative by definition. `` is +redundant on those flows but Stella emits it on leak flows, so the reader accepts +it there without error and ignores it on the primary inflow/outflow. + +### 3.5 isee spread-input attributes + +Real Stella conveyors may carry isee vendor attributes controlling where inflow +lands on the belt: `isee:spreadflow="beginning|even|dest|dist|source"` on an +inflow, with `` naming a distribution for the `dist` method. +These are specified in [§8](#8-inflow-placement-spread-inputs); the reader MUST +parse and preserve them. + +## 4. Runtime model and the per-DT algorithm + +This section is the core. It defines the exact state and update; §5–§8 fill in +leakage, capacity, transit-time changes, and placement. + +### 4.1 Slat count and non-integer transit times + +For a conveyor with transit time `T` (the latched value — [§6](#6-variable-transit-time-sample-and-len)) and time step `DT`: + +- `N = round(T / DT)`, rounding half away from zero, clamped to `N ≥ 1`. +- The **effective transit time** is `N × DT`. When `T` is not an integer multiple + of `DT` (`|T/DT − round(T/DT)| > 1e-9`), the compiler emits a **Warning** + naming the conveyor and reporting the effective transit time. simlin does not + model fractional slats: the belt is DT-quantized, matching how isee's slat + model discretizes. This is a deliberate, documented divergence from a + fractional-belt reading of the FIFO help page; it is deterministic and keeps + the belt an integer array. + +`T ≤ 0` is a compile **error** (a conveyor needs a positive transit time). + +### 4.2 Conveyor runtime state + +Each conveyor instance owns side state, held in a table parallel to the VM's +existing `graphical_functions` / `prev_values` buffers (see +[§9](#9-engine-integration)): + +``` +ConveyorState { + slats: Deque, // index 0 = exit (front); back = entry + latched_transit: f64, // transit time last sampled (see §6) + leak_carry: Vec, // per leak-flow accumulator for + in_carry: f64, // per-time-unit inflow-budget accumulator (discrete / in_limit) +} +Slat { content: f64, entry_amount: f64 } // entry_amount = the cohort's admitted volume at entry +``` + +`entry_amount` is carried so **linear** leakage (an absolute amount tied to the +entering cohort — [§5](#5-leakage)) can be computed as material decays. For a +constant transit time the deque has a fixed length `N`; a variable transit time +grows/shrinks it ([§6](#6-variable-transit-time-sample-and-len)). + +The conveyor variable's **reported scalar value** is the sum of all slat +contents (total material on the belt). This is simlin's defined semantics and is +consistent with the steady-state initialization ([§7](#7-initialization)). + +### 4.3 Per-DT update + +Conveyors integrate under Euler only ([§9.4](#94-integration-method)). Within +each Euler step, after ordinary flow equations are evaluated and before stocks +are updated, run this sequence for each conveyor, in topological order of the +conveyor chain (upstream conveyors before downstream, so a downstream conveyor's +pull is visible to its upstream source this same step): + +Let `contents₀ = Σ slats.content` at step start. + +1. **Arrest.** Evaluate ``. If nonzero: set every inflow and every + outflow of this conveyor to 0 for this step, leave `slats` unchanged, and skip + steps 2–6. (Material is frozen, not lost.) + +2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` + within `k`'s zone ([§5.3](#53-leak-zones)), compute `leak_{k,i}` per + [§5.1](#51-linear-leakage)/[§5.2](#52-exponential-leakage), clamped so the + running total removed from slat `i` this step never exceeds `slats[i].content` + (earlier leak flows have priority). Subtract from `slats[i].content`. Leak + flow `k`'s reported **rate** = `(Σ_i leak_{k,i}) / DT`. Apply + `` quantization per [§5.4](#54-integer-leakage). + +3. **Outflow.** Primary outflow **volume** = `slats[0].content` (post-leak). + Primary outflow **rate** = `slats[0].content / DT`. + +4. **Admit inflow.** Evaluate each inflow equation to a requested rate; requested + **volume** = `rate × DT`, summed over inflows in listed order as + `req_vol`. Compute: + - `contents_after = contents₀ − (Σ leak this step) − slats[0].content` + (contents once leak and outflow are removed). + - `cap_room = capacity == INF ? INF : max(0, capacity − contents_after)`. + - `limit_vol` = the inflow-budget cap per [§6.3](#63-capacity-and-inflow-limit). + - `admitted = min(req_vol, cap_room, limit_vol)`. + - Apportion `admitted` across the inflows **in listed order** (fill the first + inflow's request fully, then the second, …). Each inflow's **reported rate** + = its apportioned share / DT. Un-apportioned material is not removed from the + upstream stock (its outflow-to-conveyor is exactly the admitted rate), so + blocked material accumulates upstream automatically. + +5. **Shift.** Pop the exit slat (index 0; it left as outflow in step 3). Every + remaining slat's index decreases by one (advance toward the exit). Drop + trailing empty slats produced by a shortened belt. + +6. **Insert.** Place the admitted inflow into the belt at **entry depth** + `d = round(latched_transit / DT)` measured from the exit + ([§6](#6-variable-transit-time-sample-and-len)) using the placement method of + [§8](#8-inflow-placement-spread-inputs) (default = all at depth `d`). Extend + the belt with empty slats if `d` exceeds the current length. The inserted + cohort's `entry_amount` = the admitted volume it received. + +**Transit-time check.** A cohort inserted at depth `d` is popped as outflow after +exactly `d` steps (it occupies each of `d` slats for one DT), so its transit is +`d × DT = effective transit time`. Leakage is applied on every DT it is on the +belt, including the DT it exits. + +## 5. Leakage + +The conveyor-level `exponential_leak` flag selects the model for **all** its leak +flows. The per-flow number `f` from ``/`` is interpreted differently +by model (this dual meaning is isee's actual behavior and is specified here +explicitly): + +### 5.1 Linear leakage + +`f ∈ [0, 1]` is the fraction of an entering cohort that leaks out by the time it +exits. It is removed as a **constant absolute amount per DT** while the cohort is +in the zone. With `M` in-zone slats ([§5.3](#53-leak-zones)): + +``` +leak_{k,i} = f_k × slats[i].entry_amount / M_k (slat i in flow k's zone) +``` + +Because `entry_amount` is fixed for a cohort, the total leaked over its `M_k` +in-zone DTs is `f_k × entry_amount`. Constraint: the sum of linear leak fractions +across all leak flows over any slat must be `≤ 1`; if it reaches 1 the primary +outflow is 0. A flow's leak is clamped to the slat's remaining content (step-2 +priority ordering). + +### 5.2 Exponential leakage + +`f` is a per-time-unit **rate**; each in-zone slat loses the same fraction of its +**current** content per DT: + +``` +leak_{k,i} = slats[i].content × f_k × DT (slat i in flow k's zone) +``` + +Overlapping zones from multiple exponential flows compound (each applied in +step-2 order to the running content). Exponential leakage ignores `entry_amount` +and depends only on current content, so it is unaffected by transit-time changes. +This matches the isee steady-state factor `(1 − f×DT)` per slat. + +### 5.3 Leak zones + +`leak_start = a` and `leak_end = b` (`0 ≤ a ≤ b ≤ 1`) measure fractional belt +position **from the inflow (entry) side**: position 0 = entry, position 1 = exit. +Slat `i` (with `i = 0` the exit) has center position `p_i = (i + 0.5) / N` +measured from the exit, i.e. `1 − p_i` from the entry. Slat `i` is **in zone** +when `a ≤ (1 − p_i) ≤ b`. `M_k` = the count of in-zone slats for flow `k` +(recomputed when `N` changes). Defaults `a = 0, b = 1` put the whole belt in +zone. A shorter zone leaks the same total (linear) or the same per-DT fraction +(exponential) concentrated over fewer slats. + +### 5.4 Integer leakage + +With ``, 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. + +## 6. Variable transit time, ``, and `` + +### 6.1 Latching + +`latched_transit` starts at the initial value of ``. Each DT, `` is +evaluated; when it is nonzero, `latched_transit` is updated to the current value +of `` (default ` = 1`, so it re-latches every DT). Newly entering +material is placed at depth `round(latched_transit / DT)` (step 6). Material +already on the belt is **never** repositioned — it keeps advancing one slat/DT. + +### 6.2 Belt growth and non-FIFO exit + +If `latched_transit` increases, the entry depth `d` grows; the belt extends with +empty slats behind existing material (which continues shifting forward on +schedule). If `latched_transit` decreases, newly entering material is placed +shallower and can therefore exit **before** older, deeper material — the +documented non-FIFO behavior. The belt is not truncated; it shrinks naturally as +empty tail slats fall off during shifts. Linear leakage uses the cohort's own +`entry_amount` (fixed at its entry transit), so a later transit change does not +retroactively alter an existing cohort's leak schedule. + +### 6.3 Capacity and inflow limit + +Both default to INF. Per-DT (`vol = rate × DT`): + +- **Capacity** bounds instantaneous contents: `cap_room = capacity − + contents_after` (step 4), where `contents_after` already credits the room freed + by this DT's leak and outflow (matching isee's `(Capacity − Conveyor)/DT + + outflow` formula). +- **Inflow limit** bounds inflow per **time unit**: + - *Continuous conveyor:* `limit_vol = in_limit × DT` (the per-time-unit limit + prorated to this DT). + - *Discrete conveyor:* the full per-time-unit budget may enter within a single + DT. `in_carry` tracks volume admitted since the last integer time boundary + and resets to 0 when the simulation clock crosses an integer time unit; + `limit_vol = in_limit − in_carry`. + - The inflow limit is ignored when the inflow's upstream source is itself a + conveyor (the upstream conveyor already governs the rate). + +Admitted inflow is `min(req_vol, cap_room, limit_vol)`, apportioned in inflow +order (step 4). Blocked material stays upstream. + +## 7. Initialization + +The stock `` gives the initial value `V`. Two forms: + +### 7.1 Scalar initial value, steady-state fill + +`V` is the total initial contents, distributed so the belt is at the equilibrium +implied by its leak profile. General algorithm (works for any leak +configuration, linear or exponential, any zones, any number of flows): + +1. Simulate a single unit cohort (`entry_amount = 1`) forward through the belt, + applying exactly the [§5](#5-leakage) per-DT leak rules, to obtain the + **retained profile** `c[i]` = the content a steady cohort holds upon arriving + at slat `i` at the start of a step: `c[N-1] = 1` at the entry slat, and + `c[i-1] = c[i] −` (the leak slat `i` sheds in one DT). +2. Let `S = Σ_i c[i]`. Set the cohort scale `E = V / S` (or `E = 0` if `S = 0`). +3. Initialize `slats[i].content = E × c[i]` and `slats[i].entry_amount = E` for + all `i`. + +Closed forms for the common cases (illustration; the algorithm above is +authoritative): + +- **No leak:** `c[i] = 1` for all `i`, so each slat = `V / N` (even spread). +- **Linear, full zone, fraction `f`:** `c[i] = 1 − f·(N−1−i)/N`; equivalently + each slat = `E·c[i]` with `E = V / (N − f·(N−1)/2)`. +- **Exponential, full zone, rate `f`:** `c[i] = (1 − f·DT)^(N−1−i)`, giving + `E = V·f·DT / (1 − (1 − f·DT)^N)` — the isee exponential steady-state form. + +### 7.2 Explicit per-slat list + +A comma-separated `` list initializes the belt directly. Each entry is the +quantity for **one time unit** (not one DT). With `k = 1/DT` slats per time unit, +list entry `v_u` for time-unit `u` fills that unit's `k` slats each with +`v_u × DT` for a **continuous** conveyor (so the outflow during unit `u` totals +`v_u`), or places the whole `v_u` in the first slat of the unit's block and +zeroes the rest for a **discrete** conveyor (isee "start of each time unit" +semantics). List length must equal `N` or the number of time units; too many +entries are truncated, too few repeat the last entry (XMILE +"InitializingDiscreteStocks" rules). + +## 8. Inflow placement (spread inputs) + +The default XMILE conveyor places all admitted inflow at the entry (depth `d`). +isee models may select another placement via `isee:spreadflow` on the inflow. +simlin implements all five; each distributes the admitted volume `A` (from step +4) across slats at insert time (step 6): + +| `isee:spreadflow` | Placement of admitted volume `A` | +|---|---| +| `beginning` (default) | All of `A` at entry depth `d` (one cohort, `entry_amount = A`). | +| `even` | `A / (d)` into each of the `d` slats from exit+1 … entry; each becomes its own cohort with `entry_amount` = its share. | +| `dest` | Distribute `A` across the occupied slats **proportional to current content**; empty belt falls back to `beginning`. | +| `dist` | Distribute `A` across the `d` slats **proportional to a normalized distribution** from `` (a graphical function or 1-D array), treated as a PDF over belt position and auto-normalized to sum 1. | +| `source` | Leakage-mirror: `A` is placed to mirror the position profile of the material pulled from a coupled upstream conveyor's leak. Requires an upstream conveyor; absent one, falls back to `beginning`. | + +For linear leakage, a spread cohort's `entry_amount` is its own share, so each +sub-cohort leaks in proportion to what it carries. `dist`/`even` create multiple +cohorts in one DT; `content` and `entry_amount` are tracked per slat as usual. + +## 9. Engine integration + +Read `src/simlin-engine/CLAUDE.md` for the compilation pipeline. + +### 9.1 Data model + +`datamodel::Stock` gains `conveyor: Option`; `datamodel::Flow` gains +`leakage: Option`; an inflow gains an optional placement: ```rust pub struct Conveyor { - pub transit_time: String, // expression (required) + pub transit_time: String, // (required) pub capacity: Option, // pub inflow_limit: Option, // pub sample: Option, // @@ -359,206 +440,231 @@ pub struct Conveyor { pub one_at_a_time: bool, // default true pub exponential_leak: bool, } -// Stock gains: pub conveyor: Option - pub struct Leakage { - pub fraction: Option, // expr, or None for bare + pub fraction: Option, // expr, None for a bare marker pub integers: bool, // pub zone_start: Option, // leak_start (default 0) pub zone_end: Option, // leak_end (default 1) } -// Flow gains: pub leakage: Option +pub enum SpreadFlow { Beginning, Even, Dest, Dist(String), Source } // on a Flow inflow ``` -### 6.2 Protobuf (`src/simlin-engine/src/project_io.proto`) — compatibility-critical - -Protobuf is the one place backward compatibility is REQUIRED (there is a DB of -serialized instances). `Variable.Stock` uses field numbers up to 12 and -`Variable.Flow` up to 12. **Add new fields with fresh, never-reused field -numbers** (e.g. `optional Conveyor conveyor = 13;` on Stock, `optional Leakage -leakage = 13;` on Flow) and define new `Conveyor`/`Leakage` messages. Absent -fields decode to `None`, so old serialized stocks remain valid plain stocks. -Regenerate with `pnpm build:gen-protobufs`. Never renumber or repurpose an -existing field. - -### 6.3 Readers/writers - -- XMILE reader/writer (`src/simlin-engine/src/xmile/variables.rs`): add - `conveyor: Option` and the ``-level plumbing to `Stock`, - and `leak`/`leak_integers`/`leak_start`/`leak_end` to `Flow`. Accept both - leak encodings ([§3.3](#33-leakage-flows)). Emit the `` block and - the `` option (already modeled as `Feature::UsesConveyor`). - This alone fixes the round-trip corruption, independent of simulation. -- MDL: Vensim has no conveyor primitive. `delay conveyor` is recognized as a - builtin name in `src/simlin-engine/src/mdl/builtins.rs` but is a distinct - (unimplemented) function — **out of scope**; do not conflate. -- JSON (`src/json.rs`), TypeScript datamodel (`src/core`), diagram: extend once - the engine representation is settled. - -### 6.4 The inflow-clipping problem (the hard architectural question) - -simlin compiles each variable to a bytecode fragment evaluated in dependency -order; a flow's value is a pure function of its equation. A conveyor breaks two -assumptions: - -1. **A conveyor is stateful beyond a single f64.** A stock today is one slot in - the value arrays. A conveyor needs a per-instance ring buffer of `N` slats - (plus latched transit time, leak accumulators). Precedents for side-state to - study: graphical-function storage, `prev_values`/`initial_values` snapshot - buffers in `vm.rs`, and module instance state. -2. **A conveyor outflow has no equation, and its inflow may be clipped by the - conveyor's own capacity/limit.** So the outflow value and the *effective* - inflow value must be produced by conveyor-update logic that runs at a - well-defined point in the step, not by per-variable equation evaluation. This - resembles how non-negative stocks would clip outflows, and how queues drive - their outflows. - -`DECISION:` the core implementation strategy. Two broad options: - -- **(A) New opcode / VM-native conveyor object.** Represent the conveyor as a - dedicated runtime object with its own update step, wired into the flows/stocks - phases. Cleanest semantics, most engine work (VM + wasmgen + compiler + - layout offsets). -- **(B) Desugar to existing primitives at compile time.** Lower a conveyor to an - array of `N` aux/stock slats plus generated flow equations (a fixed-length - aging chain). Reuses the existing array + stock machinery, but `N` depends on - `transit_time/DT` (so it must be a compile-time constant — rules out a - variable `` unless re-lowered) and capacity/limit pushback on the inflow - is awkward to express as equations. - -Recommend prototyping (A) for a single continuous constant-transit no-leak -conveyor first, since it generalizes to leakage/variable-transit; but the -maintainer should choose (see [§12](#12-decisions-needed-from-the-maintainer)). - -### 6.5 wasmgen - -The WebAssembly backend mirrors the VM opcode-for-opcode and must either support -the conveyor path or return `WasmGenError::Unsupported` — **there is no silent -VM fallback** (established rule, see `src/simlin-engine/src/wasmgen`). Phase 1 -may legitimately return `Unsupported` for conveyor models from wasmgen while the -VM path works, as long as it is loud. - -### 6.6 Integration method (Euler) - -isee documents that Runge-Kutta "doesn't deal well with queues, conveyors, or -ovens" and recommends Euler, but does **not** state that Stella forces Euler or -errors. simlin has precedent for an Euler-only hard rejection: GH #486 rejects -non-Euler integration for LTM link scores (see `src/simlin-engine/src/db/ -assemble.rs` around the "GH #486" comments). `DECISION:` for conveyors, either -(a) hard-error when a conveyor is present under RK2/RK4, or (b) silently force -Euler for the whole model. `EXPERIMENT:` check what Stella actually does. The -slat model is inherently a per-DT (Euler-like) construct; RK sub-steps have no -meaning for it. Recommend a hard, clear error in Phase 1. - -### 6.7 LTM (Loops That Matter) - -A conveyor is a stock with special dynamics; the flow-to-stock link-score -formula assumes plain INTEG (and Euler — GH #486). Conveyor internal dynamics -are not INTEG. Minimum bar: LTM analysis over a model containing a conveyor must -**degrade loudly** (a clear warning / documented limitation), never crash or -emit a silently-wrong score. Full LTM-through-conveyor semantics are out of -scope for the initial milestones. - -## 7. Container access and builtins (later phase) - -XMILE §3.7.1.3 requires that array builtins (MIN/MAX/MEAN/STDDEV/SUM/SIZE) and -`[]` indexing work over a conveyor's contents: `conv[3]` reads the third slat -from the front; `SIZE`/`MEAN` etc. operate on the slat vector; an arrayed -conveyor uses two subscript groups `conv[arr_subscript][slat]`. isee also -exposes cycle-time builtins on time-stamped material (`CTMEAN`, `CTSTDDEV`, -`CTMAX`, `CTMIN`, `CTFLOW`, `CYCLETIME`, `THROUGHPUT`). All of this is -**deferred** — it depends on the conveyor holding an inspectable slat vector, -which the core simulation must exist first. - -## 8. Arrayed conveyors (later phase) - -`covid19_severity.stmx` has conveyors dimensioned over a `Severity` dimension. -The XMILE non-apply-to-all array rules (§4.5.2) allow per-element transit times. -The isee docs document only the indexing syntax, not distinct per-element -computational semantics — they appear to behave as independent per-element -conveyors. Treat as a Phase-3 concern; the core scalar conveyor must work first. - -## 9. Queues and conveyor–queue coupling (later phase) - -Queues (``) are a separate XMILE OPTIONAL feature (§3.7.3) with their own -`` option and `` flow property. They are FIFO -batch-tracking stocks. They intersect conveyors because a conveyor downstream of -a queue constrains the queue's outflow (capacity/inflow-limit/arrest), and the -conveyor's `batch_integrity` / `one_at_a_time` / discrete-required flags only -apply in that configuration. **Queues are out of scope for the initial conveyor -milestones** and should be specced separately; a conveyor with an ordinary stock -or cloud upstream is fully implementable without them. - -## 10. Phasing - -- **Phase 0 — represent & round-trip (no simulation).** Datamodel + proto + - XMILE reader/writer. Import preserves the conveyor block; export re-emits it; - round-trip no longer corrupts. Replace the misleading `empty_equation` error - with a clear "conveyors are not yet simulatable" diagnostic on the conveyor - outflow. Vendored models parse without dropping data. -- **Phase 1 — simulate the simple continuous conveyor.** Constant transit time - (integer `transit_time/DT`), scalar even-spread initial value, single primary - outflow, no leakage, Euler only. Capacity + inflow limit with upstream-stock - pushback. wasmgen may return `Unsupported`. Oracle: `minimal_conveyor.xmile` - vs a Stella reference run. -- **Phase 2 — leakage & variable transit.** Linear + exponential leakage, leak - zones, `leak_integers`, variable `` with ``, arrest, discrete - conveyors, explicit-list initialization. Oracles from the peterhovmand corpus. -- **Phase 3 — arrays, container access, builtins.** Arrayed conveyors, `[]` - slat access, array/cycle-time builtins over contents. wasmgen parity. -- **Phase 4 — queues** (separate spec) and queue–conveyor coupling - (batch_integrity, one_at_a_time, overflow). - -## 11. Test oracles +### 9.2 Protobuf, compatibility-critical + +Protobuf is the one place backward compatibility is REQUIRED (a DB holds +serialized instances). `Variable.Stock` and `Variable.Flow` use field numbers up +through 12. Add new fields with **fresh, never-reused numbers** (e.g. `optional +Conveyor conveyor = 13;` on Stock, `optional Leakage leakage = 13;` and `optional +SpreadFlow spreadflow = 14;` on Flow) plus new `Conveyor`/`Leakage`/`SpreadFlow` +messages. Absent fields decode to `None`, so old serialized stocks remain valid +plain stocks. Regenerate with `pnpm build:gen-protobufs`. Never renumber or +repurpose an existing field. + +### 9.3 Runtime (VM) design + +A conveyor's outflow, leak, and admitted-inflow values are produced by the per-DT +algorithm ([§4.3](#43-per-dt-update)), not by per-variable equation evaluation. +Implement as a VM-native conveyor object (chosen over compile-time desugaring to +an `N`-slat aging chain because `N` can vary at runtime and capacity/limit +pushback on the inflow cannot be expressed as fixed equations): + +- **State.** `Vm` gains a `conveyors: Box<[ConveyorState]>` side table + ([§4.2](#42-conveyor-runtime-state)), parallel to `graphical_functions` / + `prev_values` (`src/simlin-engine/src/vm.rs`). Each conveyor variable and its + driven outflow/leak/inflow slots map to entries in this table via the layout. +- **Update hook.** Add a conveyor-update pass inside the Euler loop's + `eval_step`, ordered **after** ordinary flow equations are evaluated (so + requested inflow rates and `arrest`/`sample`/`len`/`capacity`/`in_limit` + auxiliaries are current) and **before** stock integration (so the driven flow + values feed the stock update). The pass writes each conveyor's outflow, leak, + and admitted-inflow rates into their value slots, then advances the belt. The + conveyor's own value slot receives `Σ slats.content`. +- **Initialization** ([§7](#7-initialization)) runs in the initials pass, filling + `slats` from the stock `` value before the first step. +- **Compiled representation.** The equation-less conveyor outflow/leak flows + compile to a "driven by conveyor" marker (not an equation), so the + `empty_equation` error no longer fires; instead the compiler wires the flow's + value slot to the owning conveyor's update output. + +### 9.4 Integration method + +Conveyors require **Euler**. The slat model is defined per-DT; RK2/RK4 substeps +(`src/simlin-engine/src/vm.rs`, the `RungeKutta4` arm evaluates the derivative at +fractional-DT points) have no meaning for a belt that advances one slat per full +DT. If `sim_specs.method` is RK2 or RK4 and any conveyor is present, compilation +**fails with a clear error** naming a conveyor and stating that conveyors require +Euler integration. This follows the GH #486 precedent (LTM's non-Euler +rejection, `src/simlin-engine/src/db/assemble.rs`). + +### 9.5 wasmgen + +The WebAssembly backend mirrors the VM opcode-for-opcode with **no silent VM +fallback** (established rule, `src/simlin-engine/src/wasmgen`). The conveyor +side table and update pass are lowered to wasm the same way the GF/snapshot +regions are; until that lowering exists, a conveyor model returns +`WasmGenError::Unsupported` (loud), never a silent fallback. + +### 9.6 LTM + +A conveyor is a stock with non-INTEG dynamics; the flow-to-stock link-score +formula assumes plain INTEG under Euler (GH #486). LTM treats a conveyor's +primary outflow → downstream as an ordinary link, but the internal slat dynamics +are not scored as INTEG. LTM analysis over a model containing a conveyor +degrades **loudly** (a `Warning` naming the conveyor, emitted through the same +diagnostic path as the auto-flip warnings), never a silently-wrong score. Full +LTM-through-conveyor attribution is a separate enhancement. + +### 9.7 Readers, writers, and other surfaces + +- XMILE reader/writer (`src/simlin-engine/src/xmile/variables.rs`): add the + `` block and ``-level plumbing to `Stock`, the leak + properties and `isee:spreadflow`/`` to `Flow`; accept both + leak encodings ([§3.3](#33-leakage-flows)); emit the `` block and + ``. This alone fixes the round-trip corruption. +- MDL: Vensim has no conveyor primitive; the `delay conveyor` name recognized in + `src/simlin-engine/src/mdl/builtins.rs` is a distinct, separate function — not + a conveyor, not covered here. +- JSON (`src/json.rs`), TypeScript datamodel (`src/core`), and the diagram + editor extend once the engine representation lands; the diagram renders a + conveyor stock with its belt affordance and leak outflows. + +## 10. Arrayed conveyors + +An arrayed conveyor is `N_elem` **independent** conveyors, one per array element, +each with its own `ConveyorState`, transit time, leak flows, capacity, and inflow +limit. Non-apply-to-all arrays (`` blocks) may give each element its own +`` and other per-element attributes; shared properties (units, the +conveyor/leak markers) apply to all elements (XMILE §4.5.2). Element access uses +two subscript groups: `conv[array_subscript][slat]` reads slat `[slat]` (1-based +from the front) of element `[array_subscript]`. Each element's belt updates by +[§4.3](#43-per-dt-update) independently. + +## 11. Queues and the conveyor side of queue–conveyor coupling + +Queues (``) are a separate FIFO batch-tracking stock type with their own +`` option and `` flow property (XMILE §3.7.3). Their +internal batch management is specified in a companion queue document. This +section fully specifies the **conveyor side** of a queue feeding a conveyor, +which is where the `discrete` / `batch_integrity` / `one_at_a_time` attributes +take effect: + +- A conveyor with a queue directly upstream MUST be `discrete` (XMILE §3.7.2); + the compiler errors if it is not. +- Each DT the conveyor requests up to `min(cap_room, limit_vol)` + ([§6.3](#63-capacity-and-inflow-limit)) from the front of the queue: + - `one_at_a_time = true` (default): take at most the single front batch this + DT (even if more would fit). + - `one_at_a_time = false`: take as many whole front batches as fit within the + caps. + - `batch_integrity = true`: never split a batch — if the front batch does not + fully fit within the remaining cap, take nothing (it waits; a queue + `` outflow may drain it — see the queue doc). + - `batch_integrity = false`: split the front batch, taking exactly the volume + that fits. +- Admitted batches enter the belt at depth `d` (step 6) like any inflow. + +A conveyor fed by an ordinary stock or cloud ignores all three attributes and +uses the [§4.3](#43-per-dt-update) inflow admission directly; it needs no queue +support and is fully implementable without it. + +## 12. Build sequence + +All semantics above are fully specified regardless of build order; this is a +suggested implementation sequence, each step independently shippable: + +1. **Represent & round-trip.** Datamodel + proto + XMILE reader/writer + ([§9.1](#91-data-model)–[§9.2](#92-protobuf-compatibility-critical), + [§9.7](#97-readers-writers-and-other-surfaces)). Import preserves the block; + export re-emits it; the `empty_equation` error is replaced by the conveyor + wiring ([§9.3](#93-runtime-vm-design)). Fixtures parse without data loss. +2. **Core continuous conveyor.** [§4](#4-runtime-model-and-the-per-dt-algorithm) + update, [§7.1](#71-scalar-initial-value-steady-state-fill) scalar init, + constant transit, single primary outflow, capacity + inflow limit, Euler-only + enforcement ([§9.4](#94-integration-method)). Oracle: `minimal_conveyor.xmile`. +3. **Leakage & variable transit.** [§5](#5-leakage) linear + exponential + zones + + integer leakage, [§6](#6-variable-transit-time-sample-and-len) variable + ``/``, arrest, discrete conveyors, + [§7.2](#72-explicit-per-slat-list) explicit-list init. +4. **Spread inputs & arrays.** [§8](#8-inflow-placement-spread-inputs) placement + methods, [§10](#10-arrayed-conveyors) arrayed conveyors, `[]` slat access, + array/cycle-time builtins over contents, wasmgen parity. +5. **Queues.** The companion queue spec plus + [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling) coupling. + +## 13. Test oracles Vendored under `test/conveyors/` (see its README for full provenance, licenses, -and attribution — the CC BY 4.0 attribution requirement is satisfied there): - -- `minimal_conveyor.xmile` — hand-authored Phase-1 target (transit time + - capacity, single outflow, no leak). -- `sir_social_distancing_mixnot.stmx` — peterhovmand corpus, CC BY 4.0, - transit-time-only, deterministic; simplest real oracle. -- `covid19_severity.stmx` — peterhovmand corpus, CC BY 4.0, leakage + arrayed - conveyors, deterministic. - -**None ship expected-output CSVs**, and the real models additionally use -non-conveyor features simlin doesn't yet support (the isee builtin `LOOKUPMEAN`, -some unit-consistency issues, `PREVIOUS`, `isee:spreadflow`). So they are -**reference fixtures, not wired into `tests/integration/main.rs` yet**. Turning -them into executable oracles requires (a) conveyor support and (b) reference -output generated from Stella. Simlin's test convention is -`test//model.xmile` + `output.csv`, wired as a `mod` in -`tests/integration/main.rs`; follow it when oracles exist. - -No open-source model was found that exercises ``, ``, -``, ``, or an upstream queue — those need hand-authored -fixtures plus Stella reference output when the corresponding phase is built. - -## 12. Decisions needed from the maintainer - -Collected `DECISION:` / `EXPERIMENT:` items and scoping questions, in rough -priority order: - -1. **Core implementation strategy** ([§6.4](#64-the-inflow-clipping-problem)): - VM-native conveyor object (A) vs compile-time desugar to a slat aging chain - (B). This shapes everything downstream. Recommendation: (A). -2. **Oracle generation.** Do you have Stella access to produce reference-output - CSVs? Without it, Phase 1+ can only be validated by hand-reasoned expected - values on tiny models. This gates how much we can trust the numerics. -3. **Non-integer `transit_time/DT`** ([§4.2](#42-per-dt-update-order-clear)): - reject with an error, round to an integer slat count, or carry fractional - slats? Recommendation: reject in Phase 1. -4. **Non-Euler integration** ([§6.6](#66-integration-method-euler)): hard-error - vs silently force Euler when a conveyor is present? Recommendation: hard - error, following the GH #486 pattern. -5. **Queue scope.** Confirm queues (and thus batch_integrity / one_at_a_time / - discrete-required coupling) are out of scope for now, to be specced - separately. -6. **Per-DT update interleave & leakage apportionment** - ([§4.2](#42-per-dt-update-order-clear), - [§4.3](#43-leakage-mostly-clear-formulas-verify-against-isee)): these are - `EXPERIMENT` items that need a Stella reference to pin bit-exactly; acceptable - to defer until oracle generation is sorted. -7. **UI / diagram treatment.** When (if in this effort) should the diagram - editor render and let users author conveyor stocks and leak flows? Currently - unscoped. +and the CC BY 4.0 attribution): + +- `minimal_conveyor.xmile` — hand-authored, transit time + capacity, single + outflow, no leak. The clean core-conveyor oracle. +- `sir_social_distancing_mixnot.stmx` — peterhovmand corpus, CC BY 4.0. The belt + is transit-time-only, but its `Not_Mixing` submodel feeds the conveyor via an + inflow marked `isee:spreadflow="dist"` with `profile`, so it + exercises the distribution placement method ([§8](#8-inflow-placement-spread-inputs)); + it also uses the isee builtin `LOOKUPMEAN` for the transit time (a separate + builtin gap). +- `covid19_severity.stmx` — peterhovmand corpus, CC BY 4.0. Leakage + (`exponential_leak="true"` `` flows) + arrayed conveyors + ([§10](#10-arrayed-conveyors)). + +None ship expected-output CSVs, and the real models also use non-conveyor +features simlin does not yet implement (`LOOKUPMEAN`, some unit-consistency +issues, `PREVIOUS`, other `isee:` builtins), so they are **reference fixtures, +not yet wired into `tests/integration/main.rs`**. They become executable oracles +once conveyor support lands and reference output is generated from Stella (or +another conforming engine); simlin's convention is `test//model.xmile` + +`output.csv`, wired as a `mod` in `tests/integration/main.rs`. + +No open-source model was found exercising conveyor ``, ``, +``, ``, or an upstream queue; those need hand-authored +fixtures (and reference output) as their build step is reached. + +## 14. Validation and logistics + +The spec is complete and self-consistent — the per-DT algorithm, leakage +formulas, and initialization were validated by a reference prototype +([§15](#15-worked-examples-verified-reference-trajectories)) whose trajectories +are the concrete acceptance oracles for step 2/3. Two items are logistics, not +spec gaps: + +- **Cross-engine confirmation.** The prototype pins simlin's own numerics; a + Stella (or other conforming-engine) run of the vendored fixtures would confirm + the derived formulas in §5/§7 match the vendor bit-for-bit. Not required to + implement — the §15 trajectories are sufficient to build and test against — + but a good confidence check when Stella access is available. +- **Diagram/editor authoring.** Rendering and authoring conveyor stocks and leak + flows in the diagram editor is scoped with the TypeScript surface work in + step 1/4 of [§12](#12-build-sequence); it does not affect the engine spec. + +## 15. Worked examples (verified reference trajectories) + +The per-DT algorithm (§4), leakage (§5), capacity/inflow-limit (§6.3), and +initialization (§7) were transcribed into a standalone reference prototype and +run on the scenarios below. The prototype (`test/conveyors/reference_prototype.py`) +is a faithful, executable statement of this spec; its trajectories are the +acceptance oracles a Rust implementation must reproduce. Every check below +**passes**, which is what "the spec is self-consistent" means concretely. Run it +with `python3 test/conveyors/reference_prototype.py`. + +All scenarios use `DT = 0.25`, transit time `T = 4` (so `N = 16` slats), inflow +rate 250/time unit unless noted. + +| # | Scenario | Steady contents | Steady primary outflow | Invariant checked | +|---|---|---|---|---| +| S1 | `minimal_conveyor` steady state (init `V = 250·4 = 1000`) | 1000 (constant) | 250 (constant) | contents and outflow constant at the equilibrium `inflow·T` | +| S2 | fill from empty (`V = 0`) | rises to 1000 by `t = 4` | 0 until `t = 4`, then 250 | first nonzero outflow at exactly `t = T = 4.0` (transit delay) | +| S3 | linear leak `f = 0.2`, full zone | 906.25 | 200, leak 50 | steady `outflow / inflow = 1 − f = 0.8`; total leaked over a cohort = `f · entry` | +| S4 | exponential leak `f = 0.1`/time, full zone | 832.699579 | 166.730042, leak 83.269958 | steady outflow = `250·(1 − f·DT)^N = 166.730042` (matches closed form) | +| S5 | `capacity = 600`, req inflow 250 | plateaus at 600 | throttled | contents never exceed capacity; blocked inflow (rate 0 once full) stays upstream | +| S6 | `in_limit = 150`/time (continuous), req inflow 250 | plateaus at 600 (`150·4`) | 150 | admitted inflow never exceeds 150; equilibrium contents = `in_limit·T` | +| S7 | non-integer transit `T = 4.1` | — | — | `N = round(4.1/0.25) = round(16.4) = 16`; effective transit `16·0.25 = 4.0`; compile Warning | + +Reading S1–S2 together confirms the transit-time semantics: a step of inflow +into an empty conveyor produces zero outflow for exactly `T` time units, then the +outflow equals the inflow — a pure `T`-unit delay, which is the defining +behavior of a conveyor. S3/S4 confirm the two leakage models produce the +documented conservation (`1 − f` of a cohort survives for linear; the geometric +`(1 − f·DT)^N` survival for exponential). S5/S6 confirm capacity and inflow limit +throttle the admitted inflow and push unadmitted material back upstream, settling +at the `inflow·T` / `in_limit·T` equilibria. diff --git a/test/conveyors/README.md b/test/conveyors/README.md index c0c7365e9..702414912 100644 --- a/test/conveyors/README.md +++ b/test/conveyors/README.md @@ -11,20 +11,30 @@ conforming engine). Until then they are here so the implementer has real, authoritative XMILE to parse and represent. Do not add them to `tests/integration/main.rs` until both conditions hold. +For the deterministic core behavior, `reference_prototype.py` (below) already +supplies verified acceptance oracles derived directly from the spec, so a Rust +implementation of the core continuous conveyor can be test-driven without Stella. + ## Files | File | Source | License | Conveyor features | Notes | |------|--------|---------|-------------------|-------| -| `minimal_conveyor.xmile` | hand-authored (this repo) | project | transit time + capacity, single outflow, no leak | The Phase 1 target. Smallest possible conveyor; obvious expected behavior. | -| `sir_social_distancing_mixnot.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time only (`transit_time`) | Simplest real Stella oracle. `dt = 1/4`, Days, 0–100. Deterministic. | -| `covid19_severity.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time + leakage (`` flows) + arrayed conveyors (`Severity` dim) + `exponential_leak="true"` | Exercises leakage and subscripted conveyors. Deterministic. | +| `minimal_conveyor.xmile` | hand-authored (this repo) | project | transit time + capacity, single outflow, no leak | The Phase 1 target. Smallest possible conveyor; obvious expected behavior. The only genuinely transit-time-only fixture here. | +| `sir_social_distancing_mixnot.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time (`transit_time`) + **distribution-based spread inflow** (`isee:spreadflow="dist"`, design doc §8) | `dt = 1/4`, Days, 0–100. The belt is transit-time-only; the `Not_Mixing` submodel's inflow uses the distribution spread-input placement — NOT a plain at-beginning conveyor. | +| `covid19_severity.stmx` | peterhovmand/COVID-19-SD-generic-structures | CC BY 4.0 | transit time + leakage (`` flows) + arrayed conveyors (`Severity` dim) + `exponential_leak="true"` | Exercises leakage and subscripted conveyors. | +| `reference_prototype.py` | this repo | project | — | Executable reference implementation of the spec's per-DT algorithm (design doc §4–§7). Run `python3 test/conveyors/reference_prototype.py`; it prints the §15 worked-example trajectories and asserts every invariant (steady state, transit delay, linear/exponential leak conservation, capacity/inflow-limit clipping, non-integer-transit rounding). NOT production code — a faithful transcription of the spec, and the acceptance oracle for the core continuous conveyor. | ### Non-conveyor blockers (must be resolved or trimmed before these become oracles) Verified against HEAD on 2026-07-05 with `simlin-cli simulate`: -- `sir_social_distancing_mixnot.stmx`: uses the isee builtin `LOOKUPMEAN` - (unimplemented -> `unknown_builtin` on `transit time`). +- `sir_social_distancing_mixnot.stmx`: the `Not_Mixing` submodel feeds its + conveyor via an inflow marked `isee:spreadflow="dist"` with + `profile` — the "following a distribution" + spread-input placement, fully specified in the design doc §8 (so it is a + covered feature, not a plain at-beginning conveyor). Independently, it uses the + isee builtin `LOOKUPMEAN` for the transit time, which is unimplemented + (`unknown_builtin` on `transit time`) and must be added before this model runs. - `covid19_severity.stmx`: several unit-consistency errors (`bad_binary_op_in_units`) plus `PREVIOUS`/`isee:spreadflow` usage; these are independent of conveyor support. diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py new file mode 100644 index 000000000..09f6c9c4c --- /dev/null +++ b/test/conveyors/reference_prototype.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Reference prototype of the conveyor algorithm specified in +docs/design/conveyors.md (sections 4-7). Purpose: validate the spec's internal +consistency and produce concrete worked trajectories to embed in the doc. + +Not production code -- a faithful transcription of the spec's per-DT rules. +""" +from dataclasses import dataclass, field +import math + +INF = float('inf') + +@dataclass +class Slat: + content: float + entry_amount: float + +@dataclass +class LeakFlow: + fraction: float + zone_start: float = 0.0 + zone_end: float = 1.0 + +@dataclass +class Conveyor: + transit: float + dt: float + capacity: float = INF + in_limit: float = INF # per time unit + exponential_leak: bool = False + leaks: list = field(default_factory=list) # list[LeakFlow] + discrete: bool = False + slats: list = field(default_factory=list) # index 0 = exit + latched_transit: float = None + in_carry: float = 0.0 + + def N(self): + n = round(self.latched_transit / self.dt) + return max(1, n) + + def in_zone(self, i, N): + # position from entry side; slat i center at (i+0.5)/N from exit + p_from_entry = 1.0 - (i + 0.5) / N + return any(lk.zone_start <= p_from_entry <= lk.zone_end for lk in self.leaks) + + def zone_slats(self, lk, N): + return [i for i in range(N) + if lk.zone_start <= (1.0 - (i + 0.5) / N) <= lk.zone_end] + + def contents(self): + return sum(s.content for s in self.slats) + + # ---- initialization (section 7.1: unit-cohort forward simulation) ---- + def init_steady(self, V): + self.latched_transit = self.transit + N = self.N() + # retained profile c[i]: content a steady cohort holds at slat i + c = [0.0] * N + c[N - 1] = 1.0 # entry slat, unit cohort + for i in range(N - 1, 0, -1): + shed = 0.0 + if self.in_zone(i, N): + for lk in self.leaks: + if i in self.zone_slats(lk, N): + M = len(self.zone_slats(lk, N)) + if self.exponential_leak: + shed += c[i] * lk.fraction * self.dt + else: + shed += lk.fraction * 1.0 / M # entry_amount = 1 + c[i - 1] = max(0.0, c[i] - shed) + S = sum(c) + E = V / S if S > 0 else 0.0 + self.slats = [Slat(E * c[i], E) for i in range(N)] + + def init_from_inflow(self, inflow_rate): + """Steady state for a constant inflow: entry cohort E = inflow*dt.""" + self.latched_transit = self.transit + N = self.N() + E = inflow_rate * self.dt + c = [0.0] * N + c[N - 1] = 1.0 + for i in range(N - 1, 0, -1): + shed = 0.0 + if self.in_zone(i, N): + for lk in self.leaks: + if i in self.zone_slats(lk, N): + M = len(self.zone_slats(lk, N)) + shed += (c[i] * lk.fraction * self.dt if self.exponential_leak + else lk.fraction * 1.0 / M) + c[i - 1] = max(0.0, c[i] - shed) + self.slats = [Slat(E * c[i], E) for i in range(N)] + + # ---- one Euler step (section 4.3) ---- + def step(self, requested_inflow_rate, t, arrest=False): + dt = self.dt + if arrest: + return dict(outflow=0.0, leak=[0.0]*len(self.leaks), inflow=0.0) + N = len(self.slats) + contents0 = self.contents() + # 2. leak + leak_rates = [] + total_leak_vol = 0.0 + for lk in self.leaks: + zslats = self.zone_slats(lk, N) + M = len(zslats) + shed_total = 0.0 + for i in zslats: + s = self.slats[i] + if self.exponential_leak: + shed = s.content * lk.fraction * dt + else: + shed = lk.fraction * s.entry_amount / M if M else 0.0 + shed = min(shed, s.content) # clamp, earlier flows priority + s.content -= shed + shed_total += shed + leak_rates.append(shed_total / dt) + total_leak_vol += shed_total + # 3. outflow + out_vol = self.slats[0].content + out_rate = out_vol / dt + # 4. admit inflow + contents_after = contents0 - total_leak_vol - out_vol + cap_room = INF if self.capacity == INF else max(0.0, self.capacity - contents_after) + if self.in_limit == INF: + limit_vol = INF + elif self.discrete: + limit_vol = self.in_limit - self.in_carry + else: + limit_vol = self.in_limit * dt + req_vol = requested_inflow_rate * dt + admitted = min(req_vol, cap_room, limit_vol) + in_rate = admitted / dt + if self.discrete: + self.in_carry += admitted + # 5. shift + self.slats.pop(0) + # 6. insert at depth d + d = max(1, round(self.latched_transit / dt)) + while len(self.slats) < d: + self.slats.append(Slat(0.0, 0.0)) + tgt = self.slats[d - 1] + tgt.content += admitted + tgt.entry_amount = admitted + return dict(outflow=out_rate, leak=leak_rates, inflow=in_rate) + +# ---------------- scenarios ---------------- +def run(name, conv, V, inflow_fn, stop, sample_at=None, init_inflow=None): + if init_inflow is not None: + conv.init_from_inflow(init_inflow) + else: + conv.init_steady(V) + dt = conv.dt + t = 0.0 + rows = [] + # reset in_carry at integer time units for discrete + prev_unit = math.floor(t) + while t <= stop + 1e-9: + if conv.discrete and math.floor(t) != prev_unit: + conv.in_carry = 0.0 + prev_unit = math.floor(t) + r = conv.step(inflow_fn(t), t) + rows.append((round(t, 4), round(conv.contents(), 6), + round(r['outflow'], 6), + [round(x, 6) for x in r['leak']], + round(r['inflow'], 6))) + t += dt + print(f"\n=== {name} ===") + print(" t contents outflow leak inflow") + for row in rows[:6] + [('...',)] + rows[-3:]: + if row == ('...',): + print(" ...") + else: + print(f" {row[0]:<6} {row[1]:<10} {row[2]:<9} {str(row[3]):<11} {row[4]}") + return rows + +# S1: minimal_conveyor -- steady state (T=4, DT=.25, V=1000, inflow=250, cap=1200) +c1 = Conveyor(transit=4, dt=0.25, capacity=1200) +r1 = run("S1 minimal steady-state (T=4 DT=.25 V=1000 inflow=250)", c1, 1000, lambda t: 250, 12) +assert all(abs(row[1]-1000) < 1e-6 and abs(row[2]-250) < 1e-6 for row in r1), "S1 not steady!" +print(" [check] contents==1000 and outflow==250 for all t: PASS") + +# S2: transient -- start empty, constant inflow 250, T=4, watch fill + delayed outflow +c2 = Conveyor(transit=4, dt=0.25) +r2 = run("S2 fill-from-empty (V=0, inflow=250, T=4)", c2, 0, lambda t: 250, 6) +# outflow should be 0 until t reaches transit time (4), then jump to 250 +first_out = next(row[0] for row in r2 if row[2] > 0) +print(f" [check] first nonzero outflow at t={first_out} (expect 4.0): " + f"{'PASS' if abs(first_out-4.0)<1e-9 else 'FAIL'}") + +# S3: linear leak -- f=0.2 full zone, verify total leaked over a cohort == 0.2*entry +c3 = Conveyor(transit=4, dt=0.25, leaks=[LeakFlow(0.2)], exponential_leak=False) +r3 = run("S3 linear leak f=0.2 (T=4, steady inflow 250)", c3, None, lambda t: 250, 8, init_inflow=250) +# init with steady fill so cohorts are uniform; measure fraction leaked in steady state: +# steady outflow / inflow should equal (1 - f) +steady = r3[-1] +frac_out = steady[2] / 250 +print(f" [check] steady outflow/inflow = {round(frac_out,6)} (expect 1-f=0.8): " + f"{'PASS' if abs(frac_out-0.8)<1e-6 else 'FAIL'}") + +# S4: exponential leak rate f=0.1/time, verify steady outflow matches (1-f*dt)^N +c4 = Conveyor(transit=4, dt=0.25, leaks=[LeakFlow(0.1)], exponential_leak=True) +r4 = run("S4 exponential leak f=0.1/time (T=4)", c4, None, lambda t: 250, 8, init_inflow=250) +N4 = 16 +expect = 250 * (1 - 0.1*0.25)**N4 +print(f" [check] steady outflow = {round(r4[-1][2],6)} (expect 250*(1-f*dt)^N = " + f"{round(expect,6)}): {'PASS' if abs(r4[-1][2]-expect)<1e-4 else 'FAIL'}") + +# S5: capacity clip -- steady state contents would be 250*4=1000 but cap=600 +c5 = Conveyor(transit=4, dt=0.25, capacity=600) +r5 = run("S5 capacity=600 clips inflow (T=4, req inflow 250)", c5, 0, lambda t: 250, 12) +print(f" [check] contents never exceed 600: " + f"{'PASS' if all(row[1] <= 600+1e-6 for row in r5) else 'FAIL'}") + +# S6: inflow limit -- in_limit=150/time caps admitted inflow (continuous) +c6 = Conveyor(transit=4, dt=0.25, in_limit=150) +r6 = run("S6 in_limit=150/time (T=4, req inflow 250)", c6, 0, lambda t: 250, 8) +print(f" [check] admitted inflow never exceeds 150: " + f"{'PASS' if all(row[4] <= 150+1e-6 for row in r6) else 'FAIL'}") + +# S7: non-integer transit rounding -- T=4.1, DT=0.25 -> N=round(16.4)=16 +c7 = Conveyor(transit=4.1, dt=0.25) +c7.latched_transit = 4.1 +print(f"\n=== S7 non-integer transit ===") +print(f" T=4.1 DT=0.25 -> N={c7.N()} (round(16.4)=16), effective transit={c7.N()*0.25}") +print(f" [check] N==16, effective transit==4.0: " + f"{'PASS' if c7.N()==16 and abs(c7.N()*0.25-4.0)<1e-9 else 'FAIL'}") From 7cc1f5802ffb01da1bfaa0e0c310376eba1f4c85 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 09:35:10 -0700 Subject: [PATCH 03/11] doc: order-free conveyor pass, exact cohort merging, half-away rounding Address the second round of Codex review, each finding fixed at its root: Conveyor chains: the previous upstream-before-downstream ordering was unsound for a conveyor feeding a capacity-constrained conveyor (the upstream pops its exit slat before the downstream decides admission). Rather than a two-phase reservation protocol, adopt the isee-documented structural rule that a conveyor-driven flow is never blocked by its destination (capacity transiently exceeded, in_limit inapplicable; blocking between conveyors requires a queue, matching isee guidance). With back-pressure gone the pass becomes two phases -- leak+exit from start-of-step state, then admit+shift+insert -- and iteration order within each phase is irrelevant, so chains and even conveyor cycles are deterministic with no topological sort. The lone exception (arrested destination holds material at the upstream exit) depends only on arrest flags known before the pass, preserving order-freedom. Cohort merging: inserting into an occupied slat (shortened transit) replaced entry_amount, corrupting linear-leak totals. Replaced entry_amount with a per-cohort fixed leak schedule (per-DT allowance + remaining budget) computed at insertion, which merges by summation -- exact, since linear leak is linear in the entering volume. This also pins the previously drifting M denominator (now fixed at entry, matching isee's "based on the transit time that was used when the material was put on") and gives spread-input cohorts their prorated budgets for free. Rounding: Python's round() is banker's rounding, so the prototype disagreed with the spec's half-away-from-zero at exact half-slats (16.5 -> 16 vs 17). The prototype now uses floor(x + 0.5) and S7 pins the half case. Also newly specified while here: a unified discrete-conveyor section (quantized admission with carry, per-time-unit inflow window, start-of-unit init) and a per-step conservation identity (admitted - out - leak = delta-contents). The reference prototype now runs every scenario through the two-phase pass with conservation asserted on every step, and adds S8 (chain into a capacity-constrained conveyor: transient exceedance, exact chain conservation) and S9 (transit shrink with merging: conservation and the lifetime leak budget bound). --- docs/design/conveyors.md | 330 +++++++++++++++------- test/conveyors/reference_prototype.py | 388 +++++++++++++++++--------- 2 files changed, 482 insertions(+), 236 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 2f76a61d0..694795504 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -196,13 +196,20 @@ ConveyorState { leak_carry: Vec, // per leak-flow accumulator for in_carry: f64, // per-time-unit inflow-budget accumulator (discrete / in_limit) } -Slat { content: f64, entry_amount: f64 } // entry_amount = the cohort's admitted volume at entry +Slat { + content: f64, // material in this slat + leak_alloc: Vec, // per leak-flow: fixed per-DT linear-leak amount (set at insertion) + leak_budget: Vec, // per leak-flow: remaining linear-leak total (set at insertion) +} ``` -`entry_amount` is carried so **linear** leakage (an absolute amount tied to the -entering cohort — [§5](#5-leakage)) can be computed as material decays. For a -constant transit time the deque has a fixed length `N`; a variable transit time -grows/shrinks it ([§6](#6-variable-transit-time-sample-and-len)). +`leak_alloc`/`leak_budget` carry each cohort's **linear** leak schedule, fixed +when the cohort enters ([§5.1](#51-linear-leakage)); exponential leakage needs no +per-cohort state (it reads current content). When cohorts merge into one slat +(a shortened transit time — [§6.2](#62-belt-growth-merging-and-non-fifo-exit)), +`content`, `leak_alloc`, and `leak_budget` are all **summed**, which is exact +because linear leak is linear in the entering amount. For a constant transit +time the deque has a fixed length `N`; a variable transit time grows/shrinks it. The conveyor variable's **reported scalar value** is the sum of all slat contents (total material on the belt). This is simlin's defined semantics and is @@ -212,56 +219,88 @@ consistent with the steady-state initialization ([§7](#7-initialization)). Conveyors integrate under Euler only ([§9.4](#94-integration-method)). Within each Euler step, after ordinary flow equations are evaluated and before stocks -are updated, run this sequence for each conveyor, in topological order of the -conveyor chain (upstream conveyors before downstream, so a downstream conveyor's -pull is visible to its upstream source this same step): - -Let `contents₀ = Σ slats.content` at step start. - -1. **Arrest.** Evaluate ``. If nonzero: set every inflow and every - outflow of this conveyor to 0 for this step, leave `slats` unchanged, and skip - steps 2–6. (Material is frozen, not lost.) - -2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` - within `k`'s zone ([§5.3](#53-leak-zones)), compute `leak_{k,i}` per - [§5.1](#51-linear-leakage)/[§5.2](#52-exponential-leakage), clamped so the - running total removed from slat `i` this step never exceeds `slats[i].content` - (earlier leak flows have priority). Subtract from `slats[i].content`. Leak - flow `k`'s reported **rate** = `(Σ_i leak_{k,i}) / DT`. Apply - `` quantization per [§5.4](#54-integer-leakage). - -3. **Outflow.** Primary outflow **volume** = `slats[0].content` (post-leak). - Primary outflow **rate** = `slats[0].content / DT`. - -4. **Admit inflow.** Evaluate each inflow equation to a requested rate; requested - **volume** = `rate × DT`, summed over inflows in listed order as - `req_vol`. Compute: - - `contents_after = contents₀ − (Σ leak this step) − slats[0].content` - (contents once leak and outflow are removed). - - `cap_room = capacity == INF ? INF : max(0, capacity − contents_after)`. - - `limit_vol` = the inflow-budget cap per [§6.3](#63-capacity-and-inflow-limit). - - `admitted = min(req_vol, cap_room, limit_vol)`. - - Apportion `admitted` across the inflows **in listed order** (fill the first - inflow's request fully, then the second, …). Each inflow's **reported rate** - = its apportioned share / DT. Un-apportioned material is not removed from the - upstream stock (its outflow-to-conveyor is exactly the admitted rate), so - blocked material accumulates upstream automatically. - -5. **Shift.** Pop the exit slat (index 0; it left as outflow in step 3). Every - remaining slat's index decreases by one (advance toward the exit). Drop - trailing empty slats produced by a shortened belt. - -6. **Insert.** Place the admitted inflow into the belt at **entry depth** - `d = round(latched_transit / DT)` measured from the exit +are updated, the conveyor pass runs in **two phases over all conveyors**. Phase A +reads only each conveyor's own start-of-step state; Phase B consumes Phase A's +outputs. Because no phase reads another conveyor's same-phase results, the +iteration order **within** each phase is irrelevant — conveyor chains and even +conveyor cycles (A feeds B feeds A) are deterministic with no topological sort. + +The key structural rule that makes this possible (and matches isee: "It is not +possible to connect a conveyor outflow to something that would constrain the +flow value — the conveyor itself determines the value of the outflow"): +**a conveyor-driven flow (a primary outflow or a leak flow) is never blocked by +its destination**, with the single exception of an arrested destination (below). +A downstream conveyor admits conveyor-driven inflow unconditionally — its +`capacity` may be transiently exceeded (sanctioned by the XMILE capacity prose, +which allows exceedance until drainage) and its `in_limit` does not apply +(isee: the inflow limit "is not available if the inflow comes from another +conveyor"). A modeler who wants blocking between conveyors inserts a queue, +which is exactly isee's guidance. + +**Phase A — leak and exit (each conveyor, from its own start-of-step state):** + +1. **Arrest.** Evaluate ``. If nonzero, this conveyor is *arrested* this + step: every inflow and outflow of this conveyor reports 0, `slats` is left + untouched (material frozen, not lost), and both phases are skipped for it. + Arrest flags come from ordinary expressions already evaluated this step, so + all flags are known before any conveyor mutates. + +2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` in + `k`'s zone ([§5.3](#53-leak-zones)): compute `leak_{k,i}` per + [§5.1](#51-linear-leakage)/[§5.2](#52-exponential-leakage), clamp it to + `slats[i].content` (earlier flows have priority), subtract it from the slat + (and from `leak_budget[k]` for linear). Flow `k`'s reported **rate** = + `(Σ_i leak_{k,i}) / DT`, quantized per [§5.4](#54-integer-leakage) when + `` is set. Exception: if leak flow `k`'s destination is an + arrested conveyor, skip it entirely this step (rate 0, content stays). + +3. **Exit.** If the primary outflow's destination is an arrested conveyor, the + exit is *held*: outflow rate 0, and the exit slat's contents stay in place + (they merge with the slat arriving behind them in Phase B, accumulating at + the exit until the destination un-arrests). Otherwise: exit **volume** + `out_vol = slats[0].content` (post-leak); primary outflow **rate** = + `out_vol / DT`. + +**Phase B — admit, shift, insert (each conveyor):** + +4. **Admit inflow.** Partition this conveyor's inflows into *conveyor-driven* + (the value is an upstream conveyor's Phase-A outflow or leak) and + *equation-driven* (everything else; already evaluated). Then: + - `conv_vol` = Σ conveyor-driven inflow volumes — admitted unconditionally. + - `contents_after = contents₀ − (Σ leak this step) − out_vol`. + - `cap_room = capacity == INF ? INF : max(0, capacity − contents_after − conv_vol)`. + - `limit_vol` = the inflow-budget cap per [§6.3](#63-capacity-and-inflow-limit) + (applies to equation-driven inflows only). + - `eq_admitted = min(Σ equation-driven request volumes, cap_room, limit_vol)`, + apportioned across the equation-driven inflows **in listed order** (fill the + first inflow's request fully, then the second, …). Each inflow's **reported + rate** = its apportioned share / DT. Un-apportioned material is never + removed from the upstream stock (the flow's value *is* the admitted rate), + so blocked material accumulates upstream automatically. + - `admitted = conv_vol + eq_admitted`. + +5. **Shift.** If the exit was held (step 3), leave slat 0 in place and merge the + next slat into it (summing `content`/`leak_alloc`/`leak_budget`); otherwise + pop slat 0 (it left as outflow). Every remaining slat advances one position + toward the exit. Drop trailing empty slats. + +6. **Insert.** Place `admitted` into the belt at **entry depth** + `d = round(latched_transit / DT)` from the exit ([§6](#6-variable-transit-time-sample-and-len)) using the placement method of - [§8](#8-inflow-placement-spread-inputs) (default = all at depth `d`). Extend - the belt with empty slats if `d` exceeds the current length. The inserted - cohort's `entry_amount` = the admitted volume it received. + [§8](#8-inflow-placement-spread-inputs) (default: all at depth `d`). Extend + the belt with empty slats if `d` exceeds its current length. Compute the + cohort's linear-leak `leak_alloc`/`leak_budget` per + [§5.1](#51-linear-leakage) and **add** all three fields into the target + slat(s) (summing with any cohort already there). **Transit-time check.** A cohort inserted at depth `d` is popped as outflow after exactly `d` steps (it occupies each of `d` slats for one DT), so its transit is -`d × DT = effective transit time`. Leakage is applied on every DT it is on the -belt, including the DT it exits. +`d × DT = effective transit time`. Leakage applies on every DT it is on the belt, +including the DT it exits. + +**Conservation.** Every step, for every conveyor: +`admitted − out_vol − Σ leak = Δ(Σ slats.content)` exactly (up to f64 +arithmetic). The reference prototype asserts this on every scenario step. ## 5. Leakage @@ -274,17 +313,41 @@ explicitly): `f ∈ [0, 1]` is the fraction of an entering cohort that leaks out by the time it exits. It is removed as a **constant absolute amount per DT** while the cohort is -in the zone. With `M` in-zone slats ([§5.3](#53-leak-zones)): +in the zone, and that amount is **fixed at the moment the cohort enters** — +matching isee's "the amount that is leaked is based on the transit time that was +used when the material was put on the conveyor." Concretely, when a cohort of +volume `A` is inserted (step 6), for each linear leak flow `k`: ``` -leak_{k,i} = f_k × slats[i].entry_amount / M_k (slat i in flow k's zone) +alloc_k = f_k × A / M_k // per-DT leak amount, fixed for the cohort's life +budget_k = alloc_k × M_k(path) // total this cohort may leak to flow k ``` -Because `entry_amount` is fixed for a cohort, the total leaked over its `M_k` -in-zone DTs is `f_k × entry_amount`. Constraint: the sum of linear leak fractions -across all leak flows over any slat must be `≤ 1`; if it reaches 1 the primary -outflow is 0. A flow's leak is clamped to the slat's remaining content (step-2 -priority ordering). +where `M_k` is the number of in-zone slats for flow `k` over the belt as it +exists at insertion, and `M_k(path)` is the number of those in-zone slats between +the cohort's insertion depth and the exit, inclusive ([§5.3](#53-leak-zones)). +For a cohort inserted at the entry, `M_k(path) = M_k` and `budget_k = f_k × A` — +the full documented fraction. For a cohort inserted mid-belt (spread inputs, +[§8](#8-inflow-placement-spread-inputs)), the budget is prorated to the zone +slats it will actually traverse — matching isee's "linear leak fractions will be +applied only for the duration the material is in the conveyor." If `M_k = 0` +(the zone is narrower than one slat at this DT resolution), the cohort leaks +nothing to flow `k`. + +Each DT, an in-zone slat leaks `min(leak_alloc[k], leak_budget[k], content)` to +flow `k` (step 2), decrementing the budget by the amount actually removed. Under +a constant transit time the budget never binds and the totals are exact +(`f_k × A` per entry cohort — scenario S3). Under a variable transit time the +per-DT amount stays fixed (the isee rule) and the budget guarantees a cohort +never leaks more than `f_k × A` in total; if belt-length changes cause it to +traverse fewer in-zone slats than planned, it under-leaks deterministically. +That residual is simlin-defined behavior (vendor docs are silent at this level +of detail). + +Constraint: `Σ_k f_k ≤ 1` across a conveyor's linear leak flows; at exactly 1 +the primary outflow is 0. The check is enforced at runtime by the content clamp +(step-2 priority ordering), and the compiler warns when constant leak fractions +sum above 1. ### 5.2 Exponential leakage @@ -296,27 +359,34 @@ leak_{k,i} = slats[i].content × f_k × DT (slat i in flow k's zone ``` Overlapping zones from multiple exponential flows compound (each applied in -step-2 order to the running content). Exponential leakage ignores `entry_amount` -and depends only on current content, so it is unaffected by transit-time changes. -This matches the isee steady-state factor `(1 − f×DT)` per slat. +step-2 order to the running content). Exponential leakage carries no per-cohort +state — it depends only on current content — so it is unaffected by transit-time +changes and by cohort merging. This matches the isee steady-state factor +`(1 − f×DT)` per slat. ### 5.3 Leak zones `leak_start = a` and `leak_end = b` (`0 ≤ a ≤ b ≤ 1`) measure fractional belt -position **from the inflow (entry) side**: position 0 = entry, position 1 = exit. -Slat `i` (with `i = 0` the exit) has center position `p_i = (i + 0.5) / N` -measured from the exit, i.e. `1 − p_i` from the entry. Slat `i` is **in zone** -when `a ≤ (1 − p_i) ≤ b`. `M_k` = the count of in-zone slats for flow `k` -(recomputed when `N` changes). Defaults `a = 0, b = 1` put the whole belt in -zone. A shorter zone leaks the same total (linear) or the same per-DT fraction -(exponential) concentrated over fewer slats. +position **from the inflow (entry) side**: position 0 = entry, position 1 = exit +(XMILE's "fraction of conveyor length" — the zone is a region of the *belt*, not +of a cohort's journey). With `L` the current belt length in slats, slat `i` +(where `i = 0` is the exit) has center position `p_i = (i + 0.5) / L` measured +from the exit, i.e. `1 − p_i` from the entry. Slat `i` is **in zone** when +`a ≤ (1 − p_i) ≤ b`. In-zone membership is evaluated against the belt as it +exists at that moment: at step 2 for leaking, and at step 6 (for the `M_k` / +`M_k(path)` counts of [§5.1](#51-linear-leakage)) for a cohort's fixed schedule. +Defaults `a = 0, b = 1` put the whole belt in zone. A shorter zone leaks the +same total (linear) or the same per-DT fraction (exponential) concentrated over +fewer slats. ### 5.4 Integer leakage With ``, 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. +remainder in `leak_carry[k]`. Reported rate = whole units removed / DT. Linear +`leak_budget` decrements by each slat's *computed* (pre-quantization) leak, so +the carry redistributes timing without changing a cohort's lifetime total. ## 6. Variable transit time, ``, and `` @@ -328,37 +398,72 @@ of `` (default ` = 1`, so it re-latches every DT). Newly entering material is placed at depth `round(latched_transit / DT)` (step 6). Material already on the belt is **never** repositioned — it keeps advancing one slat/DT. -### 6.2 Belt growth and non-FIFO exit +### 6.2 Belt growth, merging, and non-FIFO exit If `latched_transit` increases, the entry depth `d` grows; the belt extends with empty slats behind existing material (which continues shifting forward on schedule). If `latched_transit` decreases, newly entering material is placed shallower and can therefore exit **before** older, deeper material — the documented non-FIFO behavior. The belt is not truncated; it shrinks naturally as -empty tail slats fall off during shifts. Linear leakage uses the cohort's own -`entry_amount` (fixed at its entry transit), so a later transit change does not -retroactively alter an existing cohort's leak schedule. +empty tail slats fall off during shifts. + +When a new cohort's insertion depth lands on a slat that already holds material +(a shortened transit), the cohorts **merge**: `content`, `leak_alloc`, and +`leak_budget` are summed field-wise. Summation is exact for linear leakage +(both the per-DT amount and the lifetime budget are linear in the entering +volume) and irrelevant for exponential leakage (stateless). Each cohort's leak +schedule was fixed at its own entry ([§5.1](#51-linear-leakage)), so a later +transit change never retroactively alters it. ### 6.3 Capacity and inflow limit Both default to INF. Per-DT (`vol = rate × DT`): +Both apply to **equation-driven** inflows only; conveyor-driven inflows bypass +both (the never-blocked rule of [§4.3](#43-per-dt-update): capacity may be +transiently exceeded, and isee documents that the inflow limit "is not +available if the inflow comes from another conveyor"). + - **Capacity** bounds instantaneous contents: `cap_room = capacity − - contents_after` (step 4), where `contents_after` already credits the room freed - by this DT's leak and outflow (matching isee's `(Capacity − Conveyor)/DT + - outflow` formula). -- **Inflow limit** bounds inflow per **time unit**: + contents_after − conv_vol` (step 4), where `contents_after` already credits + the room freed by this DT's leak and outflow (matching isee's + `(Capacity − Conveyor)/DT + outflow` formula) and `conv_vol` is the + unconditionally-admitted conveyor-driven inflow. +- **Inflow limit** bounds equation-driven inflow per **time unit**: - *Continuous conveyor:* `limit_vol = in_limit × DT` (the per-time-unit limit prorated to this DT). - *Discrete conveyor:* the full per-time-unit budget may enter within a single DT. `in_carry` tracks volume admitted since the last integer time boundary and resets to 0 when the simulation clock crosses an integer time unit; `limit_vol = in_limit − in_carry`. - - The inflow limit is ignored when the inflow's upstream source is itself a - conveyor (the upstream conveyor already governs the rate). -Admitted inflow is `min(req_vol, cap_room, limit_vol)`, apportioned in inflow -order (step 4). Blocked material stays upstream. +Equation-driven admitted inflow is `min(req_vol, cap_room, limit_vol)`, +apportioned in inflow order (step 4). Blocked material stays upstream. + +### 6.4 Discrete conveyors + +`discrete="true"` makes the conveyor move whole units ("batches") instead of a +continuous stream. Its complete semantics are three rules, all already +integrated into the algorithm above: + +1. **Quantized admission.** After step 4 computes `admitted`, a discrete + conveyor adds it to a quantization accumulator and actually inserts + `floor(accumulator)` whole units, retaining the fractional remainder (same + carry pattern as ``, [§5.4](#54-integer-leakage)). The + un-inserted fraction is *not* taken from upstream (the reported inflow rate + reflects only inserted units). Slat contents therefore stay integral, and + exits arrive as integral lumps. +2. **Per-time-unit inflow-limit window** ([§6.3](#63-capacity-and-inflow-limit)): + the full `in_limit` budget may be consumed within a single DT, resetting at + integer time boundaries — rather than being prorated `× DT`. +3. **Start-of-time-unit initialization** ([§7](#7-initialization)): a scalar + initial value is divided across the belt's `U = N × DT` time units, the whole + per-unit share placed in the first (deepest) slat of each unit's block of + `1/DT` slats, rather than spread evenly; an explicit init list places each + entry the same way ([§7.2](#72-explicit-per-slat-list)). + +A conveyor with a queue directly upstream MUST be discrete (XMILE §3.7.2; +enforced as a compile error — [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling)). ## 7. Initialization @@ -367,17 +472,22 @@ The stock `` gives the initial value `V`. Two forms: ### 7.1 Scalar initial value, steady-state fill `V` is the total initial contents, distributed so the belt is at the equilibrium -implied by its leak profile. General algorithm (works for any leak +implied by its leak profile. (For a *discrete* conveyor the distribution is +placed at time-unit starts instead — [§6.4](#64-discrete-conveyors) rule 3; the +cohort scale below still applies.) General algorithm (works for any leak configuration, linear or exponential, any zones, any number of flows): -1. Simulate a single unit cohort (`entry_amount = 1`) forward through the belt, - applying exactly the [§5](#5-leakage) per-DT leak rules, to obtain the - **retained profile** `c[i]` = the content a steady cohort holds upon arriving - at slat `i` at the start of a step: `c[N-1] = 1` at the entry slat, and - `c[i-1] = c[i] −` (the leak slat `i` sheds in one DT). +1. Simulate a single unit cohort (volume 1, entered at the entry slat) forward + through the belt, applying exactly the [§5](#5-leakage) per-DT leak rules, to + obtain the **retained profile** `c[i]` = the content a steady cohort holds + upon arriving at slat `i` at the start of a step: `c[N-1] = 1` at the entry + slat, and `c[i-1] = c[i] −` (the leak slat `i` sheds in one DT). 2. Let `S = Σ_i c[i]`. Set the cohort scale `E = V / S` (or `E = 0` if `S = 0`). -3. Initialize `slats[i].content = E × c[i]` and `slats[i].entry_amount = E` for - all `i`. +3. Initialize each slat `i` as a cohort of entering volume `E` that has already + traveled to position `i`: `content = E × c[i]`, + `leak_alloc[k] = f_k × E / M_k`, and `leak_budget[k] = leak_alloc[k] ×` + (the number of flow-`k` in-zone slats from `i` to the exit, inclusive) — + i.e. the unspent remainder of its schedule ([§5.1](#51-linear-leakage)). Closed forms for the common cases (illustration; the algorithm above is authoritative): @@ -409,15 +519,17 @@ simlin implements all five; each distributes the admitted volume `A` (from step | `isee:spreadflow` | Placement of admitted volume `A` | |---|---| -| `beginning` (default) | All of `A` at entry depth `d` (one cohort, `entry_amount = A`). | -| `even` | `A / (d)` into each of the `d` slats from exit+1 … entry; each becomes its own cohort with `entry_amount` = its share. | +| `beginning` (default) | All of `A` at entry depth `d` (one cohort). | +| `even` | `A / d` into each of the `d` slats from exit+1 … entry; each share is its own cohort. | | `dest` | Distribute `A` across the occupied slats **proportional to current content**; empty belt falls back to `beginning`. | | `dist` | Distribute `A` across the `d` slats **proportional to a normalized distribution** from `` (a graphical function or 1-D array), treated as a PDF over belt position and auto-normalized to sum 1. | | `source` | Leakage-mirror: `A` is placed to mirror the position profile of the material pulled from a coupled upstream conveyor's leak. Requires an upstream conveyor; absent one, falls back to `beginning`. | -For linear leakage, a spread cohort's `entry_amount` is its own share, so each -sub-cohort leaks in proportion to what it carries. `dist`/`even` create multiple -cohorts in one DT; `content` and `entry_amount` are tracked per slat as usual. +Each placed share is a cohort in its own right: its linear-leak +`leak_alloc`/`leak_budget` are computed from its own volume and its own +insertion depth per [§5.1](#51-linear-leakage) (so a mid-belt share's budget is +prorated to the zone slats it will actually traverse), then summed into the +target slat like any merge ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)). ## 9. Engine integration @@ -472,13 +584,15 @@ pushback on the inflow cannot be expressed as fixed equations): ([§4.2](#42-conveyor-runtime-state)), parallel to `graphical_functions` / `prev_values` (`src/simlin-engine/src/vm.rs`). Each conveyor variable and its driven outflow/leak/inflow slots map to entries in this table via the layout. -- **Update hook.** Add a conveyor-update pass inside the Euler loop's - `eval_step`, ordered **after** ordinary flow equations are evaluated (so - requested inflow rates and `arrest`/`sample`/`len`/`capacity`/`in_limit` - auxiliaries are current) and **before** stock integration (so the driven flow - values feed the stock update). The pass writes each conveyor's outflow, leak, - and admitted-inflow rates into their value slots, then advances the belt. The - conveyor's own value slot receives `Σ slats.content`. +- **Update hook.** Add the two-phase conveyor pass + ([§4.3](#43-per-dt-update)) inside the Euler loop's `eval_step`, ordered + **after** ordinary flow equations are evaluated (so requested inflow rates and + `arrest`/`sample`/`len`/`capacity`/`in_limit` auxiliaries are current) and + **before** stock integration (so the driven flow values feed the stock + update). Phase A writes each conveyor's outflow and leak rates; Phase B writes + admitted-inflow rates and advances the belts. Within each phase the iteration + order over conveyors is arbitrary (no topological sort — [§4.3](#43-per-dt-update)). + The conveyor's own value slot receives `Σ slats.content`. - **Initialization** ([§7](#7-initialization)) runs in the initials pass, filling `slats` from the stock `` value before the first step. - **Compiled representation.** The equation-less conveyor outflow/leak flows @@ -658,7 +772,14 @@ rate 250/time unit unless noted. | S4 | exponential leak `f = 0.1`/time, full zone | 832.699579 | 166.730042, leak 83.269958 | steady outflow = `250·(1 − f·DT)^N = 166.730042` (matches closed form) | | S5 | `capacity = 600`, req inflow 250 | plateaus at 600 | throttled | contents never exceed capacity; blocked inflow (rate 0 once full) stays upstream | | S6 | `in_limit = 150`/time (continuous), req inflow 250 | plateaus at 600 (`150·4`) | 150 | admitted inflow never exceeds 150; equilibrium contents = `in_limit·T` | -| S7 | non-integer transit `T = 4.1` | — | — | `N = round(4.1/0.25) = round(16.4) = 16`; effective transit `16·0.25 = 4.0`; compile Warning | +| S7 | non-integer transit `T = 4.1` and half-case `T = 4.125` | — | — | `N(16.4) = 16` and `N(16.5) = 17` — half rounds **away from zero**, never banker's rounding; effective transit `N·DT`; compile Warning | +| S8 | chain: conveyor A (`T = 2`, draining 500) feeds conveyor B (`T = 4`, `capacity = 100`) | — | — | conveyor-driven inflow is never blocked ([§4.3](#43-per-dt-update)): B's capacity is transiently exceeded, and total material across A + B + B's cumulative outflow is conserved exactly | +| S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget | + +In addition to the per-scenario invariants, the harness asserts the +[§4.3](#43-per-dt-update) **conservation identity** +(`admitted − out − leak = Δcontents`) for every conveyor on every step of every +scenario. Reading S1–S2 together confirms the transit-time semantics: a step of inflow into an empty conveyor produces zero outflow for exactly `T` time units, then the @@ -667,4 +788,7 @@ behavior of a conveyor. S3/S4 confirm the two leakage models produce the documented conservation (`1 − f` of a cohort survives for linear; the geometric `(1 − f·DT)^N` survival for exponential). S5/S6 confirm capacity and inflow limit throttle the admitted inflow and push unadmitted material back upstream, settling -at the `inflow·T` / `in_limit·T` equilibria. +at the `inflow·T` / `in_limit·T` equilibria. S8/S9 confirm the two structural +rules added in review: conveyor-driven flows are never blocked (so conveyor +chains and cycles need no topological ordering), and cohort merging under a +shortened transit is exact summation. diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index 09f6c9c4c..4bee2dca9 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -1,19 +1,40 @@ #!/usr/bin/env python3 -"""Reference prototype of the conveyor algorithm specified in +"""Executable reference implementation of the conveyor spec in docs/design/conveyors.md (sections 4-7). Purpose: validate the spec's internal -consistency and produce concrete worked trajectories to embed in the doc. +consistency and produce the worked trajectories recorded in the doc's section +15, which are the acceptance oracles for the Rust implementation. Not production code -- a faithful transcription of the spec's per-DT rules. +Coverage: the two-phase update (4.3), linear/exponential leakage with fixed +per-cohort schedules (5.1-5.3), capacity and inflow limits (6.3), transit +latching/merging (6.1-6.2), steady-state init (7.1), conveyor chains, and +half-away rounding (4.1). Not exercised here: arrest, leak zones narrower than +the belt, , discrete quantization, spread inputs, explicit-list +init -- their rules are specified in the doc and get fixtures with the Rust +implementation. +Run: python3 test/conveyors/reference_prototype.py (exits nonzero on failure) """ from dataclasses import dataclass, field import math +import sys INF = float('inf') +FAILURES = [] + + +def check(label, ok): + print(f" [check] {label}: {'PASS' if ok else 'FAIL'}") + if not ok: + FAILURES.append(label) + @dataclass class Slat: + # section 4.2: content plus each cohort's fixed linear-leak schedule content: float - entry_amount: float + leak_alloc: list # per leak flow: fixed per-DT leak amount + leak_budget: list # per leak flow: remaining lifetime leak total + @dataclass class LeakFlow: @@ -21,8 +42,10 @@ class LeakFlow: zone_start: float = 0.0 zone_end: float = 1.0 + @dataclass class Conveyor: + name: str transit: float dt: float capacity: float = INF @@ -34,136 +57,183 @@ class Conveyor: latched_transit: float = None in_carry: float = 0.0 - def N(self): - n = round(self.latched_transit / self.dt) - return max(1, n) + # section 4.1: N = round(T/DT) half away from zero, >= 1 + def n_slats(self, transit=None): + t = self.latched_transit if transit is None else transit + return max(1, math.floor(t / self.dt + 0.5)) + + def in_zone(self, i, length, lk): + # section 5.3: slat i center at (i+0.5)/length from the exit + p_from_entry = 1.0 - (i + 0.5) / length + return lk.zone_start <= p_from_entry <= lk.zone_end - def in_zone(self, i, N): - # position from entry side; slat i center at (i+0.5)/N from exit - p_from_entry = 1.0 - (i + 0.5) / N - return any(lk.zone_start <= p_from_entry <= lk.zone_end for lk in self.leaks) + def zone_count(self, lk, length): + return sum(1 for i in range(length) if self.in_zone(i, length, lk)) - def zone_slats(self, lk, N): - return [i for i in range(N) - if lk.zone_start <= (1.0 - (i + 0.5) / N) <= lk.zone_end] + def zone_count_from(self, lk, length, depth): + # in-zone slats between insertion depth (index depth-1) and exit, inclusive + return sum(1 for i in range(depth) if self.in_zone(i, length, lk)) def contents(self): return sum(s.content for s in self.slats) - # ---- initialization (section 7.1: unit-cohort forward simulation) ---- + def empty_slat(self): + return Slat(0.0, [0.0] * len(self.leaks), [0.0] * len(self.leaks)) + + def cohort_schedule(self, volume, length, depth): + # section 5.1: alloc fixed at insertion; budget prorated to the + # zone slats this cohort will actually traverse + alloc, budget = [], [] + for lk in self.leaks: + if self.exponential_leak: + alloc.append(0.0) + budget.append(0.0) + continue + m = self.zone_count(lk, length) + a = lk.fraction * volume / m if m else 0.0 + alloc.append(a) + budget.append(a * self.zone_count_from(lk, length, depth)) + return alloc, budget + + # ---- initialization (section 7.1) ---- def init_steady(self, V): self.latched_transit = self.transit - N = self.N() - # retained profile c[i]: content a steady cohort holds at slat i + N = self.n_slats() c = [0.0] * N - c[N - 1] = 1.0 # entry slat, unit cohort + c[N - 1] = 1.0 + unit_alloc = [] + for lk in self.leaks: + m = self.zone_count(lk, N) + unit_alloc.append(0.0 if self.exponential_leak or m == 0 + else lk.fraction / m) for i in range(N - 1, 0, -1): shed = 0.0 - if self.in_zone(i, N): - for lk in self.leaks: - if i in self.zone_slats(lk, N): - M = len(self.zone_slats(lk, N)) - if self.exponential_leak: - shed += c[i] * lk.fraction * self.dt - else: - shed += lk.fraction * 1.0 / M # entry_amount = 1 + for k, lk in enumerate(self.leaks): + if self.in_zone(i, N, lk): + shed += (c[i] * lk.fraction * self.dt if self.exponential_leak + else unit_alloc[k]) c[i - 1] = max(0.0, c[i] - shed) S = sum(c) E = V / S if S > 0 else 0.0 - self.slats = [Slat(E * c[i], E) for i in range(N)] + self.slats = [] + for i in range(N): + alloc = [E * ua for ua in unit_alloc] + budget = [alloc[k] * self.zone_count_from(lk, N, i + 1) + for k, lk in enumerate(self.leaks)] + self.slats.append(Slat(E * c[i], alloc, budget)) def init_from_inflow(self, inflow_rate): - """Steady state for a constant inflow: entry cohort E = inflow*dt.""" - self.latched_transit = self.transit - N = self.N() + self.init_steady(1.0) + # rescale so entry cohort = inflow*dt E = inflow_rate * self.dt - c = [0.0] * N - c[N - 1] = 1.0 - for i in range(N - 1, 0, -1): - shed = 0.0 - if self.in_zone(i, N): - for lk in self.leaks: - if i in self.zone_slats(lk, N): - M = len(self.zone_slats(lk, N)) - shed += (c[i] * lk.fraction * self.dt if self.exponential_leak - else lk.fraction * 1.0 / M) - c[i - 1] = max(0.0, c[i] - shed) - self.slats = [Slat(E * c[i], E) for i in range(N)] + scale = E / self.slats[-1].content if self.slats[-1].content else 0.0 + for s in self.slats: + s.content *= scale + s.leak_alloc = [a * scale for a in s.leak_alloc] + s.leak_budget = [b * scale for b in s.leak_budget] - # ---- one Euler step (section 4.3) ---- - def step(self, requested_inflow_rate, t, arrest=False): - dt = self.dt - if arrest: - return dict(outflow=0.0, leak=[0.0]*len(self.leaks), inflow=0.0) - N = len(self.slats) - contents0 = self.contents() - # 2. leak - leak_rates = [] - total_leak_vol = 0.0 - for lk in self.leaks: - zslats = self.zone_slats(lk, N) - M = len(zslats) + # ---- phase A (section 4.3 steps 1-3): leak + exit, purely local ---- + def phase_a(self, arrested=False): + if arrested: + return dict(out_vol=0.0, leak_vols=[0.0] * len(self.leaks), + arrested=True) + L = len(self.slats) + leak_vols = [] + for k, lk in enumerate(self.leaks): shed_total = 0.0 - for i in zslats: + for i in range(L): + if not self.in_zone(i, L, lk): + continue s = self.slats[i] if self.exponential_leak: - shed = s.content * lk.fraction * dt + shed = s.content * lk.fraction * self.dt else: - shed = lk.fraction * s.entry_amount / M if M else 0.0 - shed = min(shed, s.content) # clamp, earlier flows priority + shed = min(s.leak_alloc[k], s.leak_budget[k]) + shed = min(shed, s.content) s.content -= shed + if not self.exponential_leak: + s.leak_budget[k] -= shed shed_total += shed - leak_rates.append(shed_total / dt) - total_leak_vol += shed_total - # 3. outflow - out_vol = self.slats[0].content - out_rate = out_vol / dt - # 4. admit inflow - contents_after = contents0 - total_leak_vol - out_vol - cap_room = INF if self.capacity == INF else max(0.0, self.capacity - contents_after) + leak_vols.append(shed_total) + return dict(out_vol=self.slats[0].content, leak_vols=leak_vols, + arrested=False) + + # ---- phase B (section 4.3 steps 4-6): admit + shift + insert ---- + def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): + dt = self.dt + if pa['arrested']: + return dict(in_rate=0.0) + contents_after = contents0 - sum(pa['leak_vols']) - pa['out_vol'] + cap_room = (INF if self.capacity == INF + else max(0.0, self.capacity - contents_after - conv_vol)) if self.in_limit == INF: limit_vol = INF elif self.discrete: - limit_vol = self.in_limit - self.in_carry + limit_vol = max(0.0, self.in_limit - self.in_carry) else: limit_vol = self.in_limit * dt - req_vol = requested_inflow_rate * dt - admitted = min(req_vol, cap_room, limit_vol) - in_rate = admitted / dt + eq_admitted = min(eq_request_rate * dt, cap_room, limit_vol) if self.discrete: - self.in_carry += admitted - # 5. shift + self.in_carry += eq_admitted + admitted = conv_vol + eq_admitted + # shift self.slats.pop(0) - # 6. insert at depth d - d = max(1, round(self.latched_transit / dt)) + while self.slats and self.slats[-1].content == 0.0 and \ + len(self.slats) > self.n_slats(): + self.slats.pop() + # insert at depth d (default 'beginning' placement) + d = self.n_slats() while len(self.slats) < d: - self.slats.append(Slat(0.0, 0.0)) + self.slats.append(self.empty_slat()) + alloc, budget = self.cohort_schedule(admitted, len(self.slats), d) tgt = self.slats[d - 1] tgt.content += admitted - tgt.entry_amount = admitted - return dict(outflow=out_rate, leak=leak_rates, inflow=in_rate) + tgt.leak_alloc = [x + y for x, y in zip(tgt.leak_alloc, alloc)] + tgt.leak_budget = [x + y for x, y in zip(tgt.leak_budget, budget)] + return dict(in_rate=admitted / dt) + + +def step_all(convs, eq_inflow_rates, chain=None, t=0.0): + """One Euler step over all conveyors (section 4.3 two-phase pass). + chain: dict mapping downstream conveyor name -> upstream conveyor name + whose primary outflow feeds it (conveyor-driven, admitted unconditionally). + Conservation is asserted for every conveyor every step.""" + chain = chain or {} + contents0 = {c.name: c.contents() for c in convs} + pa = {c.name: c.phase_a() for c in convs} # phase A: any order + results = {} + for c in convs: # phase B: any order + up = chain.get(c.name) + conv_vol = pa[up]['out_vol'] if up else 0.0 + pb = c.phase_b(pa[c.name], eq_inflow_rates.get(c.name, 0.0), + conv_vol, contents0[c.name], t) + r = pa[c.name] + admitted = pb['in_rate'] * c.dt + delta = c.contents() - contents0[c.name] + residual = admitted - r['out_vol'] - sum(r['leak_vols']) - delta + assert abs(residual) < 1e-9, \ + f"conservation violated for {c.name}: residual {residual}" + results[c.name] = dict(outflow=r['out_vol'] / c.dt, + leak=[v / c.dt for v in r['leak_vols']], + inflow=pb['in_rate']) + return results + -# ---------------- scenarios ---------------- -def run(name, conv, V, inflow_fn, stop, sample_at=None, init_inflow=None): +def run(name, conv, V, inflow_fn, stop, init_inflow=None): if init_inflow is not None: conv.init_from_inflow(init_inflow) else: conv.init_steady(V) - dt = conv.dt - t = 0.0 - rows = [] - # reset in_carry at integer time units for discrete - prev_unit = math.floor(t) + t, rows, prev_unit = 0.0, [], 0 while t <= stop + 1e-9: if conv.discrete and math.floor(t) != prev_unit: - conv.in_carry = 0.0 - prev_unit = math.floor(t) - r = conv.step(inflow_fn(t), t) + conv.in_carry, prev_unit = 0.0, math.floor(t) + r = step_all([conv], {conv.name: inflow_fn(t)}, t=t)[conv.name] rows.append((round(t, 4), round(conv.contents(), 6), round(r['outflow'], 6), [round(x, 6) for x in r['leak']], round(r['inflow'], 6))) - t += dt + t += conv.dt print(f"\n=== {name} ===") print(" t contents outflow leak inflow") for row in rows[:6] + [('...',)] + rows[-3:]: @@ -173,54 +243,106 @@ def run(name, conv, V, inflow_fn, stop, sample_at=None, init_inflow=None): print(f" {row[0]:<6} {row[1]:<10} {row[2]:<9} {str(row[3]):<11} {row[4]}") return rows -# S1: minimal_conveyor -- steady state (T=4, DT=.25, V=1000, inflow=250, cap=1200) -c1 = Conveyor(transit=4, dt=0.25, capacity=1200) -r1 = run("S1 minimal steady-state (T=4 DT=.25 V=1000 inflow=250)", c1, 1000, lambda t: 250, 12) -assert all(abs(row[1]-1000) < 1e-6 and abs(row[2]-250) < 1e-6 for row in r1), "S1 not steady!" -print(" [check] contents==1000 and outflow==250 for all t: PASS") -# S2: transient -- start empty, constant inflow 250, T=4, watch fill + delayed outflow -c2 = Conveyor(transit=4, dt=0.25) +# S1: minimal_conveyor steady state (T=4, DT=.25, V=1000, inflow=250, cap=1200) +c1 = Conveyor('c1', transit=4, dt=0.25, capacity=1200) +r1 = run("S1 minimal steady-state (T=4 DT=.25 V=1000 inflow=250)", c1, 1000, + lambda t: 250, 12) +check("S1 contents==1000 and outflow==250 for all t", + all(abs(row[1] - 1000) < 1e-6 and abs(row[2] - 250) < 1e-6 for row in r1)) + +# S2: fill from empty -- pure transit delay +c2 = Conveyor('c2', transit=4, dt=0.25) r2 = run("S2 fill-from-empty (V=0, inflow=250, T=4)", c2, 0, lambda t: 250, 6) -# outflow should be 0 until t reaches transit time (4), then jump to 250 first_out = next(row[0] for row in r2 if row[2] > 0) -print(f" [check] first nonzero outflow at t={first_out} (expect 4.0): " - f"{'PASS' if abs(first_out-4.0)<1e-9 else 'FAIL'}") - -# S3: linear leak -- f=0.2 full zone, verify total leaked over a cohort == 0.2*entry -c3 = Conveyor(transit=4, dt=0.25, leaks=[LeakFlow(0.2)], exponential_leak=False) -r3 = run("S3 linear leak f=0.2 (T=4, steady inflow 250)", c3, None, lambda t: 250, 8, init_inflow=250) -# init with steady fill so cohorts are uniform; measure fraction leaked in steady state: -# steady outflow / inflow should equal (1 - f) -steady = r3[-1] -frac_out = steady[2] / 250 -print(f" [check] steady outflow/inflow = {round(frac_out,6)} (expect 1-f=0.8): " - f"{'PASS' if abs(frac_out-0.8)<1e-6 else 'FAIL'}") - -# S4: exponential leak rate f=0.1/time, verify steady outflow matches (1-f*dt)^N -c4 = Conveyor(transit=4, dt=0.25, leaks=[LeakFlow(0.1)], exponential_leak=True) -r4 = run("S4 exponential leak f=0.1/time (T=4)", c4, None, lambda t: 250, 8, init_inflow=250) -N4 = 16 -expect = 250 * (1 - 0.1*0.25)**N4 -print(f" [check] steady outflow = {round(r4[-1][2],6)} (expect 250*(1-f*dt)^N = " - f"{round(expect,6)}): {'PASS' if abs(r4[-1][2]-expect)<1e-4 else 'FAIL'}") - -# S5: capacity clip -- steady state contents would be 250*4=1000 but cap=600 -c5 = Conveyor(transit=4, dt=0.25, capacity=600) -r5 = run("S5 capacity=600 clips inflow (T=4, req inflow 250)", c5, 0, lambda t: 250, 12) -print(f" [check] contents never exceed 600: " - f"{'PASS' if all(row[1] <= 600+1e-6 for row in r5) else 'FAIL'}") - -# S6: inflow limit -- in_limit=150/time caps admitted inflow (continuous) -c6 = Conveyor(transit=4, dt=0.25, in_limit=150) +check("S2 first nonzero outflow at t=4.0 (transit delay)", + abs(first_out - 4.0) < 1e-9) + +# S3: linear leak f=0.2 full zone -- steady outflow/inflow == 1-f +c3 = Conveyor('c3', transit=4, dt=0.25, leaks=[LeakFlow(0.2)]) +r3 = run("S3 linear leak f=0.2 (T=4, steady inflow 250)", c3, None, + lambda t: 250, 8, init_inflow=250) +check("S3 steady outflow/inflow == 1-f == 0.8", abs(r3[-1][2] / 250 - 0.8) < 1e-6) + +# S4: exponential leak f=0.1/time -- steady outflow == 250*(1-f*dt)^N +c4 = Conveyor('c4', transit=4, dt=0.25, leaks=[LeakFlow(0.1)], + exponential_leak=True) +r4 = run("S4 exponential leak f=0.1/time (T=4)", c4, None, + lambda t: 250, 8, init_inflow=250) +expect4 = 250 * (1 - 0.1 * 0.25) ** 16 +check(f"S4 steady outflow == 250*(1-f*dt)^16 == {round(expect4, 6)}", + abs(r4[-1][2] - expect4) < 1e-4) + +# S5: capacity clips equation-driven inflow +c5 = Conveyor('c5', transit=4, dt=0.25, capacity=600) +r5 = run("S5 capacity=600 clips inflow (T=4, req inflow 250)", c5, 0, + lambda t: 250, 12) +check("S5 contents never exceed capacity 600", + all(row[1] <= 600 + 1e-6 for row in r5)) + +# S6: inflow limit (continuous) +c6 = Conveyor('c6', transit=4, dt=0.25, in_limit=150) r6 = run("S6 in_limit=150/time (T=4, req inflow 250)", c6, 0, lambda t: 250, 8) -print(f" [check] admitted inflow never exceeds 150: " - f"{'PASS' if all(row[4] <= 150+1e-6 for row in r6) else 'FAIL'}") +check("S6 admitted inflow never exceeds 150", + all(row[4] <= 150 + 1e-6 for row in r6)) -# S7: non-integer transit rounding -- T=4.1, DT=0.25 -> N=round(16.4)=16 -c7 = Conveyor(transit=4.1, dt=0.25) +# S7: non-integer transit rounding -- half away from zero (NOT banker's) +c7 = Conveyor('c7', transit=4.1, dt=0.25) c7.latched_transit = 4.1 -print(f"\n=== S7 non-integer transit ===") -print(f" T=4.1 DT=0.25 -> N={c7.N()} (round(16.4)=16), effective transit={c7.N()*0.25}") -print(f" [check] N==16, effective transit==4.0: " - f"{'PASS' if c7.N()==16 and abs(c7.N()*0.25-4.0)<1e-9 else 'FAIL'}") +print("\n=== S7 non-integer transit rounding ===") +print(f" T=4.1 DT=0.25 -> N={c7.n_slats()} (16.4 rounds to 16)") +check("S7 N(4.1/0.25=16.4) == 16", c7.n_slats() == 16) +c7.latched_transit = 4.125 +print(f" T=4.125 DT=0.25 -> N={c7.n_slats()} (16.5 rounds half AWAY, to 17)") +check("S7 N(4.125/0.25=16.5) == 17 (half away from zero)", c7.n_slats() == 17) + +# S8: conveyor chain A->B where B is capacity-constrained. +# Conveyor-driven inflow is never blocked (section 4.3): B's capacity is +# transiently exceeded and no material is lost. +print("\n=== S8 chain A->B, B capacity-constrained (conveyor-driven never blocked) ===") +a8 = Conveyor('a8', transit=2, dt=0.25) +b8 = Conveyor('b8', transit=4, dt=0.25, capacity=100) +a8.init_steady(500) # A drains 62.5/DT into B +b8.init_steady(0) +total0 = a8.contents() + b8.contents() +exited = 0.0 +peak_b = 0.0 +t = 0.0 +for _ in range(40): + r = step_all([a8, b8], {'a8': 0.0}, chain={'b8': 'a8'}, t=t) + exited += r['b8']['outflow'] * 0.25 + peak_b = max(peak_b, b8.contents()) + t += 0.25 +check("S8 B's capacity (100) transiently exceeded by conveyor-driven inflow", + peak_b > 100 + 1e-9) +check("S8 conservation across the chain (nothing lost)", + abs((a8.contents() + b8.contents() + exited) - total0) < 1e-6) + +# S9: transit shrink merges cohorts; linear-leak state sums; conservation holds +print("\n=== S9 transit shrink 4->2 mid-run with linear leak f=0.2 ===") +c9 = Conveyor('c9', transit=4, dt=0.25, leaks=[LeakFlow(0.2)]) +c9.init_from_inflow(250) +total_in = total_out = total_leak = 0.0 +start9 = c9.contents() +t = 0.0 +for _ in range(48): + if abs(t - 2.0) < 1e-9: + c9.latched_transit = 2.0 # sample re-latches to 2 + r = step_all([c9], {'c9': 250.0}, t=t)['c9'] + total_in += r['inflow'] * 0.25 + total_out += r['outflow'] * 0.25 + total_leak += sum(r['leak']) * 0.25 + t += 0.25 +resid9 = total_in - total_out - total_leak - (c9.contents() - start9) +check("S9 whole-run conservation under shrink+merge (residual ~ 0)", + abs(resid9) < 1e-6) +check("S9 lifetime leak never exceeds budget (leak <= 0.2 x (inflow+init))", + total_leak <= 0.2 * (total_in + start9) + 1e-6) + +print() +if FAILURES: + print(f"{len(FAILURES)} CHECK(S) FAILED:") + for f in FAILURES: + print(f" - {f}") + sys.exit(1) +print("all checks passed") From 19f7eed61da10670e94e356980ac05ac241e855f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 10:05:53 -0700 Subject: [PATCH 04/11] doc: fix post-shrink leak dilution; close review ambiguities Address all 19 findings from an adversarial model review of the conveyor spec. The one numerics bug (finding 1): a cohort's linear-leak allowance was denominated by the physical belt length, so after a transit shrink new entry cohorts leaked only a fraction of the documented f (stale tail inflated the denominator) -- contradicting the spec's own prose and the isee rule it quotes. The schedule is now path-based: alloc = f*A / M(entry path d), budget = alloc * M(own path d_c), so a default-placement cohort leaks exactly f*A over its own journey regardless of belt length, and spread-input shares degrade to the prorated isee rule through the same formula. S9 now pins the regression (post-shrink steady outflow returns to (1-f)*inflow). Precision fixes that would each have cost an implementer a debugging session: sample/latch is now step 0 of the per-DT algorithm (arrested conveyors do not re-latch); step 6 always executes so the belt never shrinks below the entry depth; primary-outflow selection honors XMILE's leak-marking override (first NON-leak outflow, with no-outflow/all-leak compile errors); driven-flow visibility is pinned to normal VM flow/stock timing (readers dependency-order after the conveyor pass; the stock value reads start-of-step, breaking cycles); a runtime-hygiene section defines clamps for non-finite/negative expression values; quant_carry is distinct from the boundary-resetting in_carry; all five spread placements get per-slat formulas (positions, normalization, fallbacks); scalar container access and array builtins over the belt are specified with the OOB->NaN rule while the isee cycle-time builtin family is explicitly de-scoped; explicit-list init is disambiguated by list length; and a new 9.8 enumerates diagnostics, unit rules, VM reset/module-instance lifecycle, PREVIOUS/INIT semantics, and double-clamp composition. Honesty fixes: the worked-examples section now records the prototype's exact coverage (executed vs prose-only rules) and its reporting convention (start-of-step stock values matching simlin CSV semantics, previously mixed); the capacity formula is documented as extending -- not matching -- isee's by also crediting leak. The prototype gains S10, executing the held-exit merge (the one structurally novel rule review found unexecuted): an arrested destination accumulates material at the upstream exit and releases it as one lump, conserving exactly. 17/17 checks pass. --- docs/design/conveyors.md | 407 +++++++++++++++++++------- test/conveyors/reference_prototype.py | 126 ++++++-- 2 files changed, 402 insertions(+), 131 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 694795504..fb7ba9990 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -80,9 +80,15 @@ Reference: OASIS XMILE v1.0 §2.2.1, §3.7.2, §4.2, §4.2.1, §4.3. Two OPTIONAL boolean attributes advertise sub-features: `arrest="true|false"` (default false) and `leak="true|false"` (default false). They are advisory; the -authoritative source is the per-stock `` block. simlin emits -`` whenever any stock in the project is a conveyor, and sets -`arrest`/`leak` to reflect whether any conveyor uses those features. +authoritative source is the per-stock `` block. + +The reader accepts **three** encodings of the header option: the +`` element, the `` spelling, and the attribute +form Stella actually writes on the root element — `uses_conveyor=""` on +``/`` (both vendored `.stmx` fixtures use the attribute form). +simlin emits the element form `` whenever any stock in the +project is a conveyor, setting `arrest`/`leak` to reflect whether any conveyor +uses those features. ### 3.2 The conveyor block (on a stock) @@ -121,10 +127,18 @@ value ([§7](#7-initialization)); ``/`` name the flows. ### 3.3 Leakage flows -The first `` is the primary belt-end outflow; the rest are leakages -(XMILE §3.7.2, §4.2.1). A conveyor outflow MUST NOT carry a normal ``. Real -Stella models put the leak **fraction** in the `` of a ``-tagged -flow: +The **primary** (belt-end) outflow is the first `` that is *not* +marked as a leakage; every leak-marked outflow is a leakage regardless of list +position. This implements both halves of XMILE §4.2.1: the first-is-primary +*convention* (when nothing is marked, the first outflow is primary and the rest +are leakages) and its explicit-override provision ("this behavior MAY be +explicitly overridden by tagging each leakage flow with the `` +property") — so a model whose first listed outflow carries `` gets a +later primary. Compile **errors**: a conveyor with no outflows, or whose +outflows are all leak-marked, cannot simulate (XMILE: at least one outflow MUST +be the normal outflow). A conveyor outflow MUST NOT carry a normal equation- +valued `` — but note the leak-fraction encoding below. Real Stella models +put the leak **fraction** in the `` of a ``-tagged flow: ```xml @@ -137,9 +151,12 @@ flow: The reader MUST accept both encodings: the marker-``-plus-`` form (what the vendored fixtures use) and the value-bearing `expr` form -(the spec's example). A `` with no fraction and no `` is a valid -"leakage, fraction TBD" marker (used mid-edit); it parses and represents but -contributes zero leakage until a fraction is supplied. +(the spec's example). If a flow carries **both** a value-bearing `` and an +``, the `` content wins (it is the more specific, spec-blessed +carrier); the `` is preserved for round-trip but ignored for simulation. A +`` with no fraction and no `` is a valid "leakage, fraction TBD" +marker (used mid-edit); it parses and represents but contributes zero leakage +until a fraction is supplied. | Tag / attr | Type | Default | Meaning | |---|---|---|---| @@ -153,7 +170,10 @@ contributes zero leakage until a fraction is supplied. Conveyor and queue **inflows** are non-negative by requirement (uniflow); the primary conveyor outflow is non-negative by definition. `` is redundant on those flows but Stella emits it on leak flows, so the reader accepts -it there without error and ignores it on the primary inflow/outflow. +it there without error and ignores it on the primary inflow/outflow. The writer +MUST NOT emit `` on a primary conveyor outflow (XMILE §4.3: "this +property MUST NOT appear for them"); it MAY emit it on leak flows, matching +Stella. ### 3.5 isee spread-input attributes @@ -193,8 +213,11 @@ existing `graphical_functions` / `prev_values` buffers (see ConveyorState { slats: Deque, // index 0 = exit (front); back = entry latched_transit: f64, // transit time last sampled (see §6) - leak_carry: Vec, // per leak-flow accumulator for - in_carry: f64, // per-time-unit inflow-budget accumulator (discrete / in_limit) + leak_carry: Vec, // per leak-flow accumulator for (never resets) + in_carry: f64, // per-time-unit in_limit budget spent (discrete only; resets at + // integer time boundaries -- §6.3) + quant_carry: f64, // discrete admission fractional-unit accumulator (§6.4 rule 1; + // never resets -- distinct state from in_carry) } Slat { content: f64, // material in this slat @@ -239,11 +262,20 @@ which is exactly isee's guidance. **Phase A — leak and exit (each conveyor, from its own start-of-step state):** +0. **Latch.** Evaluate ``; when it is nonzero (and the conveyor is not + arrested — an arrested conveyor's time is suspended, so it does not + re-latch), update `latched_transit` from `` (with the runtime hygiene of + [§4.4](#44-runtime-expression-hygiene)). This happens before any Phase-A + mutation, so this step's insert (step 6) uses the newly latched depth. + 1. **Arrest.** Evaluate ``. If nonzero, this conveyor is *arrested* this step: every inflow and outflow of this conveyor reports 0, `slats` is left untouched (material frozen, not lost), and both phases are skipped for it. Arrest flags come from ordinary expressions already evaluated this step, so - all flags are known before any conveyor mutates. + all flags are known before any conveyor mutates. Clock-based state is + unaffected by arrest: the `in_carry` integer-time-boundary reset + ([§6.3](#63-capacity-and-inflow-limit)) still fires, and + `leak_carry`/`quant_carry` persist untouched. 2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` in `k`'s zone ([§5.3](#53-leak-zones)): compute `leak_{k,i}` per @@ -280,12 +312,15 @@ which is exactly isee's guidance. - `admitted = conv_vol + eq_admitted`. 5. **Shift.** If the exit was held (step 3), leave slat 0 in place and merge the - next slat into it (summing `content`/`leak_alloc`/`leak_budget`); otherwise - pop slat 0 (it left as outflow). Every remaining slat advances one position - toward the exit. Drop trailing empty slats. - -6. **Insert.** Place `admitted` into the belt at **entry depth** - `d = round(latched_transit / DT)` from the exit + next slat into it (summing `content`/`leak_alloc`/`leak_budget`; with a + single-slat belt there is nothing to merge and slat 0 simply stays); + otherwise pop slat 0 (it left as outflow). Every remaining slat advances one + position toward the exit. Drop trailing empty slats. + +6. **Insert.** This step **always executes, even when `admitted = 0`** — so + after step 6 the belt always has at least `d` slats (this matters: belt + length feeds zone membership, [§5.3](#53-leak-zones)). Place `admitted` into + the belt at **entry depth** `d = round(latched_transit / DT)` from the exit ([§6](#6-variable-transit-time-sample-and-len)) using the placement method of [§8](#8-inflow-placement-spread-inputs) (default: all at depth `d`). Extend the belt with empty slats if `d` exceeds its current length. Compute the @@ -302,6 +337,50 @@ including the DT it exits. `admitted − out_vol − Σ leak = Δ(Σ slats.content)` exactly (up to f64 arithmetic). The reference prototype asserts this on every scenario step. +**Visibility to other equations.** The values the pass produces follow the VM's +normal flow/stock timing ([§9.3](#93-runtime-vm-design)): + +- The conveyor's driven flows (primary outflow, leaks) and the *admitted* + values of its inflows are **this step's flow values**: any ordinary equation + that reads one of them (e.g. `x = graduating * 2`) is dependency-ordered + **after** the conveyor pass and sees the same value the CSV records — a + reader never sees a pre-clamp requested rate. +- The conveyor's own stock value follows stock semantics: equations evaluated + during step `t` read the **start-of-step** contents; the post-update + `Σ slats.content` becomes the value at `t + DT`. Reading the stock therefore + never creates an ordering dependency on the pass. +- The pass's *inputs* (`arrest`/`sample`/`len`/`capacity`/`in_limit`, leak + fractions, requested inflow rates) must be computable **before** the pass. A + same-step cycle from a pass output back into a pass input (e.g. + `capacity = f(graduating)`) is a compile-time `CircularDependency` error — + unless the path runs through the conveyor's stock value, which (as a + start-of-step read) breaks the cycle exactly like any stock. + +### 4.4 Runtime expression hygiene + +``, ``, ``, leak fractions, and inflow equations are +arbitrary expressions, so invalid values are reachable at runtime even when the +compile-time checks pass. The rules (all simlin-defined, chosen for +determinism): + +- **Transit time.** At each latch (step 0): a finite value is clamped to + `max(DT, value)`; a non-finite (NaN/±INF) value leaves `latched_transit` + unchanged. The *initial* latch (during initialization) with a non-finite or + `≤ 0` value is a runtime initialization error. +- **Leak fractions.** Linear fractions clamp to `[0, 1]`; exponential rates + clamp to `[0, ∞)`; NaN is treated as 0 (no leak). A linear fraction is + sampled **once, at the cohort's entry DT** (it is baked into + `leak_alloc`/`leak_budget`, [§5.1](#51-linear-leakage)); an exponential rate + is re-read every DT ([§5.2](#52-exponential-leakage)). +- **Inflow requests.** An equation-driven inflow request clamps to + `max(0, rate)` (conveyor inflows are uniflows — [§3.4](#34-non-negativity)); + a NaN request admits 0. +- **Capacity / inflow limit.** A negative value is treated as 0 (fully + blocking); NaN or +INF is treated as INF (unconstrained — INF is already + their documented "no constraint" value). +- **Arrest / sample.** "Nonzero" means `value != 0` with NaN treated as 0 (not + arrested, not sampled). + ## 5. Leakage The conveyor-level `exponential_leak` flag selects the model for **all** its leak @@ -315,34 +394,46 @@ explicitly): exits. It is removed as a **constant absolute amount per DT** while the cohort is in the zone, and that amount is **fixed at the moment the cohort enters** — matching isee's "the amount that is leaked is based on the transit time that was -used when the material was put on the conveyor." Concretely, when a cohort of -volume `A` is inserted (step 6), for each linear leak flow `k`: +used when the material was put on the conveyor" (`f` itself is likewise sampled +once, at the cohort's entry DT — [§4.4](#44-runtime-expression-hygiene)). + +Terminology (this resolves an ambiguity in the word "entry"): the **entry +depth** `d = round(latched_transit / DT)` is where default-placement material is +inserted (step 6); a cohort's **insertion depth** `d_c ≤ d` is where it actually +lands (`d_c = d` for default placement; `d_c < d` only for spread-input shares, +[§8](#8-inflow-placement-spread-inputs)). After a transit shrink the *physical* +belt can be longer than `d` (a stale tail of older material) — a new cohort's +journey is its own `d_c` slats, **not** the physical belt length. + +When a cohort of volume `A` is inserted, for each linear leak flow `k` +(one formula covers default and spread placement): ``` -alloc_k = f_k × A / M_k // per-DT leak amount, fixed for the cohort's life -budget_k = alloc_k × M_k(path) // total this cohort may leak to flow k +M_k(p) = count of in-zone slats among indices 0..p-1 // §5.3, per the belt after step-6 extension +alloc_k = f_k × A / M_k(d) // per-DT leak amount, fixed for the cohort's life +budget_k = alloc_k × M_k(d_c) // lifetime total this cohort may leak to flow k ``` -where `M_k` is the number of in-zone slats for flow `k` over the belt as it -exists at insertion, and `M_k(path)` is the number of those in-zone slats between -the cohort's insertion depth and the exit, inclusive ([§5.3](#53-leak-zones)). -For a cohort inserted at the entry, `M_k(path) = M_k` and `budget_k = f_k × A` — -the full documented fraction. For a cohort inserted mid-belt (spread inputs, -[§8](#8-inflow-placement-spread-inputs)), the budget is prorated to the zone -slats it will actually traverse — matching isee's "linear leak fractions will be -applied only for the duration the material is in the conveyor." If `M_k = 0` -(the zone is narrower than one slat at this DT resolution), the cohort leaks -nothing to flow `k`. +For default placement (`d_c = d`) the budget is exactly `f_k × A` — the full +documented fraction, leaked evenly over the cohort's own `d`-slat journey. This +holds **regardless of the physical belt length**: after a transit shrink a new +cohort still leaks `f_k × A` in total (the denominator is its own path, never +the stale-tail length). For a spread-input share inserted mid-belt (`d_c < d`) +the per-DT amount matches an entry cohort of the same size and the budget is +prorated to the zone slats it will actually traverse — matching isee's "linear +leak fractions will be applied only for the duration the material is in the +conveyor." If `M_k(d) = 0` (the zone is narrower than one slat at this DT +resolution), the cohort leaks nothing to flow `k`. Each DT, an in-zone slat leaks `min(leak_alloc[k], leak_budget[k], content)` to flow `k` (step 2), decrementing the budget by the amount actually removed. Under a constant transit time the budget never binds and the totals are exact -(`f_k × A` per entry cohort — scenario S3). Under a variable transit time the -per-DT amount stays fixed (the isee rule) and the budget guarantees a cohort -never leaks more than `f_k × A` in total; if belt-length changes cause it to -traverse fewer in-zone slats than planned, it under-leaks deterministically. -That residual is simlin-defined behavior (vendor docs are silent at this level -of detail). +(`f_k × A` per entry cohort — scenario S3); the budget exists solely to bound a +cohort's lifetime leak when zone membership shifts under it (a partial zone +combined with a belt-length change, the one corner where in-zone DT counts can +drift). In that corner a cohort under-leaks deterministically, never over-leaks; +the residual is simlin-defined behavior (vendor docs are silent at this level of +detail). Constraint: `Σ_k f_k ≤ 1` across a conveyor's linear leak flows; at exactly 1 the primary outflow is 0. The check is enforced at runtime by the content clamp @@ -373,8 +464,8 @@ of a cohort's journey). With `L` the current belt length in slats, slat `i` (where `i = 0` is the exit) has center position `p_i = (i + 0.5) / L` measured from the exit, i.e. `1 − p_i` from the entry. Slat `i` is **in zone** when `a ≤ (1 − p_i) ≤ b`. In-zone membership is evaluated against the belt as it -exists at that moment: at step 2 for leaking, and at step 6 (for the `M_k` / -`M_k(path)` counts of [§5.1](#51-linear-leakage)) for a cohort's fixed schedule. +exists at that moment: at step 2 for leaking, and at step 6 (for the `M_k(d)` / +`M_k(d_c)` counts of [§5.1](#51-linear-leakage)) for a cohort's fixed schedule. Defaults `a = 0, b = 1` put the whole belt in zone. A shorter zone leaks the same total (linear) or the same per-DT fraction (exponential) concentrated over fewer slats. @@ -425,10 +516,14 @@ transiently exceeded, and isee documents that the inflow limit "is not available if the inflow comes from another conveyor"). - **Capacity** bounds instantaneous contents: `cap_room = capacity − - contents_after − conv_vol` (step 4), where `contents_after` already credits - the room freed by this DT's leak and outflow (matching isee's - `(Capacity − Conveyor)/DT + outflow` formula) and `conv_vol` is the - unconditionally-admitted conveyor-driven inflow. + contents_after − conv_vol` (step 4), where `contents_after` credits the room + freed by this DT's outflow **and leak**, and `conv_vol` is the + unconditionally-admitted conveyor-driven inflow. This *extends* isee's + documented `(Capacity − Conveyor)/DT + outflow` formula, which credits only + the outflow: simlin also credits leak (the room genuinely exists by the end + of the step). Models combining capacity limits with leakage may therefore + admit slightly more per DT than Stella — a known, deliberate delta to check + in the cross-engine confirmation ([§14](#14-validation-and-logistics)). - **Inflow limit** bounds equation-driven inflow per **time unit**: - *Continuous conveyor:* `limit_vol = in_limit × DT` (the per-time-unit limit prorated to this DT). @@ -447,20 +542,23 @@ continuous stream. Its complete semantics are three rules, all already integrated into the algorithm above: 1. **Quantized admission.** After step 4 computes `admitted`, a discrete - conveyor adds it to a quantization accumulator and actually inserts - `floor(accumulator)` whole units, retaining the fractional remainder (same - carry pattern as ``, [§5.4](#54-integer-leakage)). The - un-inserted fraction is *not* taken from upstream (the reported inflow rate - reflects only inserted units). Slat contents therefore stay integral, and - exits arrive as integral lumps. + conveyor adds it to `quant_carry` ([§4.2](#42-conveyor-runtime-state)) and + actually inserts `floor(quant_carry)` whole units, retaining the fractional + remainder (same carry pattern as ``, + [§5.4](#54-integer-leakage); `quant_carry` never resets — it is distinct + from the boundary-resetting `in_carry`). The un-inserted fraction is *not* + taken from upstream (the reported inflow rate reflects only inserted units). + Slat contents therefore stay integral, and exits arrive as integral lumps. 2. **Per-time-unit inflow-limit window** ([§6.3](#63-capacity-and-inflow-limit)): the full `in_limit` budget may be consumed within a single DT, resetting at integer time boundaries — rather than being prorated `× DT`. -3. **Start-of-time-unit initialization** ([§7](#7-initialization)): a scalar - initial value is divided across the belt's `U = N × DT` time units, the whole - per-unit share placed in the first (deepest) slat of each unit's block of - `1/DT` slats, rather than spread evenly; an explicit init list places each - entry the same way ([§7.2](#72-explicit-per-slat-list)). +3. **Start-of-time-unit initialization** ([§7](#7-initialization)): the belt's + `N` slats partition into **time-unit blocks** by simulated travel time — + slat `i` belongs to block `u = floor(i × DT)` (well-defined for any DT, + integer `1/DT` or not), giving `U = ceil(N × DT)` blocks. A scalar initial + value is divided across the `U` blocks, the whole per-block share placed in + that block's deepest slat rather than spread evenly; an explicit init list + places each entry the same way ([§7.2](#72-explicit-per-slat-list)). A conveyor with a queue directly upstream MUST be discrete (XMILE §3.7.2; enforced as a compile error — [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling)). @@ -480,8 +578,11 @@ configuration, linear or exponential, any zones, any number of flows): 1. Simulate a single unit cohort (volume 1, entered at the entry slat) forward through the belt, applying exactly the [§5](#5-leakage) per-DT leak rules, to obtain the **retained profile** `c[i]` = the content a steady cohort holds - upon arriving at slat `i` at the start of a step: `c[N-1] = 1` at the entry - slat, and `c[i-1] = c[i] −` (the leak slat `i` sheds in one DT). + upon arriving at slat `i` at the start of a step: + `c[N-1] = 1` at the entry slat, and + `c[i-1] = max(0, c[i] −` (the leak slat `i` sheds in one DT)`)` — the + `max(0, ·)` clamp matters exactly when leak fractions sum above 1, which + compiles with only a warning ([§5.1](#51-linear-leakage)). 2. Let `S = Σ_i c[i]`. Set the cohort scale `E = V / S` (or `E = 0` if `S = 0`). 3. Initialize each slat `i` as a cohort of entering volume `E` that has already traveled to position `i`: `content = E × c[i]`, @@ -500,15 +601,28 @@ authoritative): ### 7.2 Explicit per-slat list -A comma-separated `` list initializes the belt directly. Each entry is the -quantity for **one time unit** (not one DT). With `k = 1/DT` slats per time unit, -list entry `v_u` for time-unit `u` fills that unit's `k` slats each with -`v_u × DT` for a **continuous** conveyor (so the outflow during unit `u` totals -`v_u`), or places the whole `v_u` in the first slat of the unit's block and -zeroes the rest for a **discrete** conveyor (isee "start of each time unit" -semantics). List length must equal `N` or the number of time units; too many -entries are truncated, too few repeat the last entry (XMILE -"InitializingDiscreteStocks" rules). +A comma-separated `` list initializes the belt directly. Two +interpretations exist in isee's documentation (its "Initializing Discrete +Stocks" help page — an isee source, not the OASIS spec: "a number of values +equal to the transit time, or the transit time divided by DT"), disambiguated +by list length: + +- **Length `N` (one entry per slat):** entry `j` (1-based, front first) fills + slat `j − 1` directly. This is the only interpretation available for + non-integer transit times, per the isee rule. +- **Any other length (one entry per time unit):** using the time-unit blocks of + [§6.4](#64-discrete-conveyors) rule 3 (slat `i` in block `floor(i × DT)`, + `U = ceil(N × DT)` blocks), entry `v_u` fills block `u`: split evenly across + the block's slats for a **continuous** conveyor (so the outflow during unit + `u` totals `v_u`), or placed whole in the block's deepest slat for a + **discrete** conveyor (isee "start of each time unit" semantics). The list is + normalized to `U` entries first: extra entries are truncated, a short list + repeats its last entry. +- When `N` equals `U` (DT = 1) the two interpretations coincide. + +Each filled slat gets the linear-leak schedule of a cohort that entered at the +belt entry and traveled to its position, as in [§7.1](#71-scalar-initial-value-steady-state-fill) +step 3. ## 8. Inflow placement (spread inputs) @@ -517,19 +631,27 @@ isee models may select another placement via `isee:spreadflow` on the inflow. simlin implements all five; each distributes the admitted volume `A` (from step 4) across slats at insert time (step 6): -| `isee:spreadflow` | Placement of admitted volume `A` | +Definitions used below: `d` = the entry depth (step 6); target slats are indexed +`i ∈ 0..d−1` (0 = exit); a slat's **fractional position from the entry side** +over the entry path is `x_i = 1 − (i + 0.5)/d` (so `x ≈ 0` at the entry slat +`i = d−1`, `x ≈ 1` at the exit slat `i = 0`, consistent with the +[§5.3](#53-leak-zones) orientation). Every placement distributes the admitted +volume `A` as per-slat shares `A_i ≥ 0` with `Σ A_i = A`: + +| `isee:spreadflow` | Per-slat share `A_i` | |---|---| -| `beginning` (default) | All of `A` at entry depth `d` (one cohort). | -| `even` | `A / d` into each of the `d` slats from exit+1 … entry; each share is its own cohort. | -| `dest` | Distribute `A` across the occupied slats **proportional to current content**; empty belt falls back to `beginning`. | -| `dist` | Distribute `A` across the `d` slats **proportional to a normalized distribution** from `` (a graphical function or 1-D array), treated as a PDF over belt position and auto-normalized to sum 1. | -| `source` | Leakage-mirror: `A` is placed to mirror the position profile of the material pulled from a coupled upstream conveyor's leak. Requires an upstream conveyor; absent one, falls back to `beginning`. | - -Each placed share is a cohort in its own right: its linear-leak -`leak_alloc`/`leak_budget` are computed from its own volume and its own -insertion depth per [§5.1](#51-linear-leakage) (so a mid-belt share's budget is -prorated to the zone slats it will actually traverse), then summed into the -target slat like any merge ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)). +| `beginning` (default) | `A_i = A` for `i = d−1` (the entry slat), 0 elsewhere: one cohort at depth `d`. | +| `even` | `A_i = A / d` for every `i ∈ 0..d−1` — including the exit slat, whose share exits after one DT (isee: "equal amount every position"). | +| `dest` | `A_i = A × content_i / Σ_j content_j`, summed over **all** slats of the current belt (length `L`, not just the first `d`) — proportional to existing content. If total content is 0, fall back to `beginning`. | +| `dist` | `A_i = A × w_i / Σ_j w_j` over `i ∈ 0..d−1`, where `w_i = max(0, g(x_i))` and `g` is ``: a graphical function evaluated at `x_i` with its own x-axis scale and extrapolation rules (isee normalizes the table "so that it is effectively a probability density function" — the `Σ w` division here is that normalization), or a 1-D array of length `m` where `w_i` = the element `floor(x_i × m)` (clamped to `m−1`). If `Σ w = 0`, fall back to `beginning`. | +| `source` | Applies only when this inflow is **conveyor-driven from an upstream leak flow** (the "coupling" is flow identity: the inflow *is* that leak). For each upstream slat `j` that leaked `q_j` this step (Phase A), with `y_j` = that slat's fractional position from the entry side of the *upstream* belt, place `q_j` at the target slat `i ∈ 0..d−1` whose `x_i` is nearest `y_j` (ties toward the exit) — positions mirror proportionally when the two belts differ in length. If the inflow is not an upstream leak, fall back to `beginning`. | + +Each per-slat share `A_i` is a cohort in its own right: its linear-leak +`leak_alloc`/`leak_budget` are computed from its own volume `A_i` and its own +insertion depth `d_c = i + 1` per [§5.1](#51-linear-leakage) (so a mid-belt +share's budget is prorated to the zone slats it will actually traverse), then +summed into the target slat like any merge +([§6.2](#62-belt-growth-merging-and-non-fifo-exit)). ## 9. Engine integration @@ -642,16 +764,73 @@ LTM-through-conveyor attribution is a separate enhancement. editor extend once the engine representation lands; the diagram renders a conveyor stock with its belt affordance and leak outflows. -## 10. Arrayed conveyors +### 9.8 Errors, units, and lifecycle + +**New diagnostics** (added to `ErrorCode` in `src/simlin-engine/src/common.rs`): + +| Diagnostic | Severity | Trigger | +|---|---|---| +| `ConveyorWithoutOutflow` | Error | conveyor with no outflows, or all outflows leak-marked ([§3.3](#33-leakage-flows)) | +| `ConveyorNonEulerMethod` | Error | any conveyor present under RK2/RK4 ([§9.4](#94-integration-method)) | +| `ConveyorQueueUpstreamNotDiscrete` | Error | queue directly upstream of a non-discrete conveyor ([§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling)) | +| `ConveyorTransitNotPositive` | Error | constant `` ≤ 0 at compile time; non-finite/`≤ 0` initial latch at runtime ([§4.4](#44-runtime-expression-hygiene)) | +| `ConveyorTransitNotDtMultiple` | Warning | `T/DT` not integral; message reports the effective transit `N × DT` ([§4.1](#41-slat-count-and-non-integer-transit-times)) | +| `ConveyorLeakFractionsExceedOne` | Warning | constant linear leak fractions summing above 1 ([§5.1](#51-linear-leakage)) | +| `ConveyorLtmDegraded` | Warning | LTM analysis requested on a model containing conveyors ([§9.6](#96-ltm)) | + +**Unit checking** (`src/simlin-engine/src/units_check.rs`): with the conveyor +stock's units `S` and the model's time unit `t` — `` and `` +context: `t`; ``: `S`; ``: `S/t`; a linear leak fraction: +dimensionless; an exponential leak rate: `1/t`; ``: dimensionless. +Driven flows (primary outflow, leaks, admitted inflows) carry `S/t` like any +flow. + +**VM lifecycle**: `Vm::reset` re-initializes the conveyor side table exactly as +it re-runs initials (the belt is derived state — same pattern as the +`prev_values`/`initial_values` buffers it sits beside). Each module *instance* +containing a conveyor owns its own `ConveyorState` (the side table is per +instance, like module state generally). `PREVIOUS(conv, …)` reads the previous +step's `Σ slats.content` (the ordinary `prev_values` snapshot of the stock +slot); `INIT(conv)` reads the initial value `V` (the ordinary `initial_values` +snapshot). + +**Double-clamp composition**: a flow can be simultaneously an equation-driven +conveyor inflow and the outflow of a non-negative upstream stock. Clamps +compose upstream-first: the non-negative-stock clamp bounds what the upstream +can supply, then conveyor admission (step 4) clips further; the flow's single +reported value is the final admitted rate. (Today simlin does not enforce +non-negative stocks at runtime — the composition rule is normative for when it +does.) + +## 10. Arrayed conveyors and container access An arrayed conveyor is `N_elem` **independent** conveyors, one per array element, each with its own `ConveyorState`, transit time, leak flows, capacity, and inflow limit. Non-apply-to-all arrays (`` blocks) may give each element its own `` and other per-element attributes; shared properties (units, the -conveyor/leak markers) apply to all elements (XMILE §4.5.2). Element access uses -two subscript groups: `conv[array_subscript][slat]` reads slat `[slat]` (1-based -from the front) of element `[array_subscript]`. Each element's belt updates by -[§4.3](#43-per-dt-update) independently. +conveyor/leak markers) apply to all elements (XMILE §4.5.2). Each element's belt +updates by [§4.3](#43-per-dt-update) independently. + +**Container access** (XMILE §3.7.1: conveyors are containers, so `[]` and the +array builtins MUST work over their contents when arrays are supported): + +- For a **scalar** conveyor, `conv[j]` (`j` 1-based from the front/exit) reads + `slats[j−1].content` of the current belt. `j` outside `[1, L]` (where `L` is + the current physical belt length, which can exceed `N` after a transit + shrink) yields NaN — the same rule as an out-of-range dynamic array + subscript in simlin. +- For an **arrayed** conveyor, two subscript groups apply: + `conv[array_subscript][slat]` reads the slat of that element's belt, same + rules. +- The array builtins (`SUM`, `MIN`, `MAX`, `MEAN`, `STDDEV`) over a conveyor + operate on the current slat-content vector (length `L`); `SIZE(conv)` returns + `L`. All values are read from start-of-step state (like the stock value — + [§4.3](#43-per-dt-update) visibility rules). +- The isee **cycle-time builtin family** (`CTMEAN`, `CTSTDDEV`, `CTMAX`, + `CTMIN`, `CTFLOW`, `CYCLETIME`, `THROUGHPUT`) requires per-material age + tracking the slat model does not carry. These are isee extensions, not + XMILE-mandated: they are **explicitly out of scope** and fail loudly as + unknown builtins, exactly as today. ## 11. Queues and the conveyor side of queue–conveyor coupling @@ -700,7 +879,7 @@ suggested implementation sequence, each step independently shippable: ``/``, arrest, discrete conveyors, [§7.2](#72-explicit-per-slat-list) explicit-list init. 4. **Spread inputs & arrays.** [§8](#8-inflow-placement-spread-inputs) placement - methods, [§10](#10-arrayed-conveyors) arrayed conveyors, `[]` slat access, + methods, [§10](#10-arrayed-conveyors-and-container-access) arrayed conveyors, `[]` slat access, array/cycle-time builtins over contents, wasmgen parity. 5. **Queues.** The companion queue spec plus [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling) coupling. @@ -720,7 +899,7 @@ and the CC BY 4.0 attribution): builtin gap). - `covid19_severity.stmx` — peterhovmand corpus, CC BY 4.0. Leakage (`exponential_leak="true"` `` flows) + arrayed conveyors - ([§10](#10-arrayed-conveyors)). + ([§10](#10-arrayed-conveyors-and-container-access)). None ship expected-output CSVs, and the real models also use non-conveyor features simlin does not yet implement (`LOOKUPMEAN`, some unit-consistency @@ -736,11 +915,15 @@ fixtures (and reference output) as their build step is reached. ## 14. Validation and logistics -The spec is complete and self-consistent — the per-DT algorithm, leakage -formulas, and initialization were validated by a reference prototype -([§15](#15-worked-examples-verified-reference-trajectories)) whose trajectories -are the concrete acceptance oracles for step 2/3. Two items are logistics, not -spec gaps: +The spec is complete, and its core — the per-DT algorithm (including arrest and +held exits), both leakage models, capacity/inflow limits, variable transit with +merging, and steady-state initialization — is machine-verified by the reference +prototype ([§15](#15-worked-examples-verified-reference-trajectories)), whose +trajectories are the concrete acceptance oracles for step 2/3. §15 states +exactly which rules the prototype does and does not execute; the unexecuted +rules (integer/zoned leakage, discrete quantization, spread placements, +explicit-list init) are specified in prose and get fixtures with the Rust +implementation. Two items are logistics, not spec gaps: - **Cross-engine confirmation.** The prototype pins simlin's own numerics; a Stella (or other conforming-engine) run of the vendored fixtures would confirm @@ -753,13 +936,27 @@ spec gaps: ## 15. Worked examples (verified reference trajectories) -The per-DT algorithm (§4), leakage (§5), capacity/inflow-limit (§6.3), and -initialization (§7) were transcribed into a standalone reference prototype and -run on the scenarios below. The prototype (`test/conveyors/reference_prototype.py`) -is a faithful, executable statement of this spec; its trajectories are the -acceptance oracles a Rust implementation must reproduce. Every check below -**passes**, which is what "the spec is self-consistent" means concretely. Run it -with `python3 test/conveyors/reference_prototype.py`. +The core of the spec was transcribed into a standalone reference prototype +(`test/conveyors/reference_prototype.py`) and run on the scenarios below; its +trajectories are the acceptance oracles a Rust implementation must reproduce. +Every check below **passes**. Run it with +`python3 test/conveyors/reference_prototype.py` (exits nonzero on any failure). + +**Prototype coverage — what these scenarios do and do not verify.** Executed: +the two-phase update including arrest and the held-exit merge (§4.3), +linear/exponential leakage with fixed per-cohort schedules (§5.1–§5.3), +capacity and inflow limits (§6.3), transit latching/shrink/merging (§6.1–§6.2), +steady-state initialization (§7.1), conveyor chains, and half-away rounding +(§4.1). **Not** executed (specified in prose only; they get fixtures with the +Rust implementation): leak zones narrower than the belt, ``, +discrete quantization, spread-input placements, explicit-list initialization, +and leak-fed chains (`source` placement). Treat only the executed set as +prototype-verified. + +**Reporting convention.** Each trajectory row shows the stock's +**start-of-step** contents at time `t` together with the flow rates during +`[t, t + DT)` — the same convention as simlin's CSV output, so rows diff +directly against simulation results with no off-by-one. All scenarios use `DT = 0.25`, transit time `T = 4` (so `N = 16` slats), inflow rate 250/time unit unless noted. @@ -767,14 +964,15 @@ rate 250/time unit unless noted. | # | Scenario | Steady contents | Steady primary outflow | Invariant checked | |---|---|---|---|---| | S1 | `minimal_conveyor` steady state (init `V = 250·4 = 1000`) | 1000 (constant) | 250 (constant) | contents and outflow constant at the equilibrium `inflow·T` | -| S2 | fill from empty (`V = 0`) | rises to 1000 by `t = 4` | 0 until `t = 4`, then 250 | first nonzero outflow at exactly `t = T = 4.0` (transit delay) | +| S2 | fill from empty (`V = 0`) | 0 at `t = 0`, rises linearly, first reaches 1000 at `t = 4.0` | 0 until `t = 4`, then 250 | first nonzero outflow at exactly `t = T = 4.0` (transit delay); start-of-step convention pinned | | S3 | linear leak `f = 0.2`, full zone | 906.25 | 200, leak 50 | steady `outflow / inflow = 1 − f = 0.8`; total leaked over a cohort = `f · entry` | | S4 | exponential leak `f = 0.1`/time, full zone | 832.699579 | 166.730042, leak 83.269958 | steady outflow = `250·(1 − f·DT)^N = 166.730042` (matches closed form) | | S5 | `capacity = 600`, req inflow 250 | plateaus at 600 | throttled | contents never exceed capacity; blocked inflow (rate 0 once full) stays upstream | | S6 | `in_limit = 150`/time (continuous), req inflow 250 | plateaus at 600 (`150·4`) | 150 | admitted inflow never exceeds 150; equilibrium contents = `in_limit·T` | | S7 | non-integer transit `T = 4.1` and half-case `T = 4.125` | — | — | `N(16.4) = 16` and `N(16.5) = 17` — half rounds **away from zero**, never banker's rounding; effective transit `N·DT`; compile Warning | | S8 | chain: conveyor A (`T = 2`, draining 500) feeds conveyor B (`T = 4`, `capacity = 100`) | — | — | conveyor-driven inflow is never blocked ([§4.3](#43-per-dt-update)): B's capacity is transiently exceeded, and total material across A + B + B's cumulative outflow is conserved exactly | -| S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget | +| S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget; **post-shrink steady outflow returns to `(1 − f)·inflow = 200`** — new entry cohorts leak the full fraction over their own path even while the stale tail keeps the physical belt longer than the entry depth ([§5.1](#51-linear-leakage)) | +| S10 | chain A (`T = 2`, draining 500) feeds B; B arrested for `t ∈ [1, 2)` | — | — | held exit ([§4.3](#43-per-dt-update) steps 3/5): during the hold A's outflow reports 0 and material accumulates in A's exit slat while B is frozen; on release the accumulated 250 exits A as one lump (rate 1000); chain conservation exact throughout | In addition to the per-scenario invariants, the harness asserts the [§4.3](#43-per-dt-update) **conservation identity** @@ -788,7 +986,8 @@ behavior of a conveyor. S3/S4 confirm the two leakage models produce the documented conservation (`1 − f` of a cohort survives for linear; the geometric `(1 − f·DT)^N` survival for exponential). S5/S6 confirm capacity and inflow limit throttle the admitted inflow and push unadmitted material back upstream, settling -at the `inflow·T` / `in_limit·T` equilibria. S8/S9 confirm the two structural +at the `inflow·T` / `in_limit·T` equilibria. S8–S10 confirm the structural rules added in review: conveyor-driven flows are never blocked (so conveyor -chains and cycles need no topological ordering), and cohort merging under a -shortened transit is exact summation. +chains and cycles need no topological ordering), cohort merging under a +shortened transit is exact summation with the full leak fraction preserved, +and an arrested destination holds material at the upstream exit without loss. diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index 4bee2dca9..ac8a82fa7 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -5,13 +5,14 @@ 15, which are the acceptance oracles for the Rust implementation. Not production code -- a faithful transcription of the spec's per-DT rules. -Coverage: the two-phase update (4.3), linear/exponential leakage with fixed -per-cohort schedules (5.1-5.3), capacity and inflow limits (6.3), transit -latching/merging (6.1-6.2), steady-state init (7.1), conveyor chains, and -half-away rounding (4.1). Not exercised here: arrest, leak zones narrower than -the belt, , discrete quantization, spread inputs, explicit-list -init -- their rules are specified in the doc and get fixtures with the Rust -implementation. +Coverage: the two-phase update (4.3) including arrest and the held-exit merge, +linear/exponential leakage with fixed per-cohort schedules (5.1-5.3), capacity +and inflow limits (6.3), transit latching/shrink/merging (6.1-6.2), +steady-state init (7.1), conveyor chains, and half-away rounding (4.1). Not +exercised here: leak zones narrower than the belt, , discrete +quantization, spread inputs, explicit-list init, leak-fed chains ('source' +placement) -- their rules are specified in the doc and get fixtures with the +Rust implementation. Run: python3 test/conveyors/reference_prototype.py (exits nonzero on failure) """ from dataclasses import dataclass, field @@ -80,19 +81,21 @@ def contents(self): def empty_slat(self): return Slat(0.0, [0.0] * len(self.leaks), [0.0] * len(self.leaks)) - def cohort_schedule(self, volume, length, depth): - # section 5.1: alloc fixed at insertion; budget prorated to the - # zone slats this cohort will actually traverse + def cohort_schedule(self, volume, belt_len, entry_depth, own_depth): + # section 5.1: alloc = f*A / M(entry path d); budget = alloc * M(own + # path d_c). For default placement own_depth == entry_depth, so the + # budget is exactly f*A -- the full documented fraction over the + # cohort's own journey, regardless of any stale tail (belt_len > d). alloc, budget = [], [] for lk in self.leaks: if self.exponential_leak: alloc.append(0.0) budget.append(0.0) continue - m = self.zone_count(lk, length) - a = lk.fraction * volume / m if m else 0.0 + m_entry = self.zone_count_from(lk, belt_len, entry_depth) + a = lk.fraction * volume / m_entry if m_entry else 0.0 alloc.append(a) - budget.append(a * self.zone_count_from(lk, length, depth)) + budget.append(a * self.zone_count_from(lk, belt_len, own_depth)) return alloc, budget # ---- initialization (section 7.1) ---- @@ -132,11 +135,11 @@ def init_from_inflow(self, inflow_rate): s.leak_alloc = [a * scale for a in s.leak_alloc] s.leak_budget = [b * scale for b in s.leak_budget] - # ---- phase A (section 4.3 steps 1-3): leak + exit, purely local ---- - def phase_a(self, arrested=False): + # ---- phase A (section 4.3 steps 0-3): latch + leak + exit, purely local + def phase_a(self, arrested=False, dest_arrested=False): if arrested: return dict(out_vol=0.0, leak_vols=[0.0] * len(self.leaks), - arrested=True) + arrested=True, held=False) L = len(self.slats) leak_vols = [] for k, lk in enumerate(self.leaks): @@ -155,8 +158,12 @@ def phase_a(self, arrested=False): s.leak_budget[k] -= shed shed_total += shed leak_vols.append(shed_total) + # step 3: held exit if the primary outflow's destination is arrested + if dest_arrested: + return dict(out_vol=0.0, leak_vols=leak_vols, arrested=False, + held=True) return dict(out_vol=self.slats[0].content, leak_vols=leak_vols, - arrested=False) + arrested=False, held=False) # ---- phase B (section 4.3 steps 4-6): admit + shift + insert ---- def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): @@ -172,20 +179,29 @@ def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): limit_vol = max(0.0, self.in_limit - self.in_carry) else: limit_vol = self.in_limit * dt - eq_admitted = min(eq_request_rate * dt, cap_room, limit_vol) + eq_admitted = min(max(0.0, eq_request_rate) * dt, cap_room, limit_vol) if self.discrete: self.in_carry += eq_admitted admitted = conv_vol + eq_admitted - # shift - self.slats.pop(0) + # step 5: shift (held exit keeps slat 0 and merges the next slat in) + if pa['held']: + if len(self.slats) > 1: + s0, s1 = self.slats[0], self.slats[1] + s0.content += s1.content + s0.leak_alloc = [x + y for x, y in zip(s0.leak_alloc, s1.leak_alloc)] + s0.leak_budget = [x + y for x, y in zip(s0.leak_budget, s1.leak_budget)] + del self.slats[1] + else: + self.slats.pop(0) while self.slats and self.slats[-1].content == 0.0 and \ len(self.slats) > self.n_slats(): self.slats.pop() - # insert at depth d (default 'beginning' placement) + # step 6: insert at depth d -- ALWAYS runs, even for admitted == 0, + # so the belt never has fewer than d slats d = self.n_slats() while len(self.slats) < d: self.slats.append(self.empty_slat()) - alloc, budget = self.cohort_schedule(admitted, len(self.slats), d) + alloc, budget = self.cohort_schedule(admitted, len(self.slats), d, d) tgt = self.slats[d - 1] tgt.content += admitted tgt.leak_alloc = [x + y for x, y in zip(tgt.leak_alloc, alloc)] @@ -193,14 +209,21 @@ def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): return dict(in_rate=admitted / dt) -def step_all(convs, eq_inflow_rates, chain=None, t=0.0): +def step_all(convs, eq_inflow_rates, chain=None, t=0.0, arrested=None): """One Euler step over all conveyors (section 4.3 two-phase pass). chain: dict mapping downstream conveyor name -> upstream conveyor name whose primary outflow feeds it (conveyor-driven, admitted unconditionally). - Conservation is asserted for every conveyor every step.""" + arrested: set of conveyor names whose is nonzero this step; an + upstream conveyor whose primary outflow targets an arrested conveyor gets + a HELD exit (section 4.3 steps 3/5). Conservation is asserted for every + conveyor every step.""" chain = chain or {} + arrested = arrested or set() + feeds_arrested = {chain[down] for down in chain if down in arrested} contents0 = {c.name: c.contents() for c in convs} - pa = {c.name: c.phase_a() for c in convs} # phase A: any order + pa = {c.name: c.phase_a(arrested=c.name in arrested, + dest_arrested=c.name in feeds_arrested) + for c in convs} # phase A: any order results = {} for c in convs: # phase B: any order up = chain.get(c.name) @@ -228,8 +251,12 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): while t <= stop + 1e-9: if conv.discrete and math.floor(t) != prev_unit: conv.in_carry, prev_unit = 0.0, math.floor(t) + # Reporting convention (doc section 15): each row shows the stock's + # START-of-step contents at time t plus the flow rates during + # [t, t+DT) -- matching simlin CSV semantics. + contents_at_t = conv.contents() r = step_all([conv], {conv.name: inflow_fn(t)}, t=t)[conv.name] - rows.append((round(t, 4), round(conv.contents(), 6), + rows.append((round(t, 4), round(contents_at_t, 6), round(r['outflow'], 6), [round(x, 6) for x in r['leak']], round(r['inflow'], 6))) @@ -257,6 +284,9 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): first_out = next(row[0] for row in r2 if row[2] > 0) check("S2 first nonzero outflow at t=4.0 (transit delay)", abs(first_out - 4.0) < 1e-9) +check("S2 start-of-step convention: contents 0 at t=0, 1000 first at t=4.0", + r2[0][1] == 0.0 and + next(row[0] for row in r2 if abs(row[1] - 1000) < 1e-9) == 4.0) # S3: linear leak f=0.2 full zone -- steady outflow/inflow == 1-f c3 = Conveyor('c3', transit=4, dt=0.25, leaks=[LeakFlow(0.2)]) @@ -318,12 +348,16 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): check("S8 conservation across the chain (nothing lost)", abs((a8.contents() + b8.contents() + exited) - total0) < 1e-6) -# S9: transit shrink merges cohorts; linear-leak state sums; conservation holds +# S9: transit shrink merges cohorts; linear-leak state sums; conservation +# holds; and -- the finding-1 regression guard -- post-shrink entry cohorts +# still leak the FULL fraction f (steady outflow returns to (1-f)*inflow even +# while the stale tail keeps the physical belt longer than the entry depth). print("\n=== S9 transit shrink 4->2 mid-run with linear leak f=0.2 ===") c9 = Conveyor('c9', transit=4, dt=0.25, leaks=[LeakFlow(0.2)]) c9.init_from_inflow(250) total_in = total_out = total_leak = 0.0 start9 = c9.contents() +last_out = 0.0 t = 0.0 for _ in range(48): if abs(t - 2.0) < 1e-9: @@ -332,12 +366,50 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): total_in += r['inflow'] * 0.25 total_out += r['outflow'] * 0.25 total_leak += sum(r['leak']) * 0.25 + last_out = r['outflow'] t += 0.25 resid9 = total_in - total_out - total_leak - (c9.contents() - start9) check("S9 whole-run conservation under shrink+merge (residual ~ 0)", abs(resid9) < 1e-6) check("S9 lifetime leak never exceeds budget (leak <= 0.2 x (inflow+init))", total_leak <= 0.2 * (total_in + start9) + 1e-6) +check("S9 post-shrink steady outflow == (1-f)*inflow == 200 (full-fraction leak)", + abs(last_out - 200.0) < 1e-6) + +# S10: arrest + held exit. A (T=2, draining 500) feeds B (T=4); B is arrested +# for t in [1.0, 2.0). During the hold A's outflow reports 0 and material +# accumulates in A's exit slat (section 4.3 steps 3/5); B is frozen. On +# release the accumulated lump exits A in one DT. Nothing is lost. +print("\n=== S10 arrest + held exit (B arrested t in [1,2)) ===") +a10 = Conveyor('a10', transit=2, dt=0.25) +b10 = Conveyor('b10', transit=4, dt=0.25) +a10.init_steady(500) +b10.init_steady(0) +total0 = a10.contents() + b10.contents() +exited = 0.0 +hold_ok = True +release_out = None +t = 0.0 +for _ in range(64): + arr = {'b10'} if 1.0 - 1e-9 <= t < 2.0 - 1e-9 else set() + r = step_all([a10, b10], {'a10': 0.0}, chain={'b10': 'a10'}, t=t, + arrested=arr) + if arr: + b_delta_ok = abs(r['b10']['outflow']) < 1e-12 and \ + abs(r['b10']['inflow']) < 1e-12 + hold_ok = hold_ok and r['a10']['outflow'] == 0.0 and b_delta_ok + if abs(t - 2.0) < 1e-9: + release_out = r['a10']['outflow'] + exited += r['b10']['outflow'] * 0.25 + t += 0.25 +check("S10 during hold: A outflow == 0 and B frozen", hold_ok) +# At hold start (t=1.0) A has drained 4 of its 8 slats, so 250 remains; all +# of it merges into the exit slat across the 4 held steps and exits as one +# lump on release: rate 250/0.25 = 1000. +check("S10 release step: accumulated lump exits A (250 == 4 slats, rate 1000)", + release_out is not None and abs(release_out - 250.0 / 0.25) < 1e-6) +check("S10 conservation: everything initially in A ends up through B", + abs((a10.contents() + b10.contents() + exited) - total0) < 1e-6) print() if FAILURES: From 45da89b655d22708d783515632db65a1cfe25938 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 10:12:30 -0700 Subject: [PATCH 05/11] doc: polish residuals from review verification pass Three corner fixes from the reviewer's verification pass, none material: cap a dest-placement share's leak budget at min(M(d_c), M(d)) so a share landing on a stale-tail slat beyond the entry depth cannot schedule more than the documented f*A (and scope 5.1's d_c <= d terminology accordingly); define the time-unit block count as floor((N-1)*DT) + 1 so a non-divisor DT (e.g. 0.3) never produces an empty block with no deepest slat to fill; and reword 9.3's update-hook ordering to defer to 4.3's visibility rules instead of the looser "after ordinary flow equations" phrasing. --- docs/design/conveyors.md | 42 +++++++++++++++++---------- test/conveyors/reference_prototype.py | 5 +++- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index fb7ba9990..8130b65c2 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -399,8 +399,9 @@ once, at the cohort's entry DT — [§4.4](#44-runtime-expression-hygiene)). Terminology (this resolves an ambiguity in the word "entry"): the **entry depth** `d = round(latched_transit / DT)` is where default-placement material is -inserted (step 6); a cohort's **insertion depth** `d_c ≤ d` is where it actually -lands (`d_c = d` for default placement; `d_c < d` only for spread-input shares, +inserted (step 6); a cohort's **insertion depth** `d_c` is where it actually +lands (`d_c = d` for default placement; `d_c < d` for most spread-input shares; +only a `dest` share can land beyond `d`, on a stale-tail slat — [§8](#8-inflow-placement-spread-inputs)). After a transit shrink the *physical* belt can be longer than `d` (a stale tail of older material) — a new cohort's journey is its own `d_c` slats, **not** the physical belt length. @@ -410,10 +411,15 @@ When a cohort of volume `A` is inserted, for each linear leak flow `k` ``` M_k(p) = count of in-zone slats among indices 0..p-1 // §5.3, per the belt after step-6 extension -alloc_k = f_k × A / M_k(d) // per-DT leak amount, fixed for the cohort's life -budget_k = alloc_k × M_k(d_c) // lifetime total this cohort may leak to flow k +alloc_k = f_k × A / M_k(d) // per-DT leak amount, fixed for the cohort's life +budget_k = alloc_k × min(M_k(d_c), M_k(d)) // lifetime total this cohort may leak to flow k ``` +(The `min` with `M_k(d)` matters only for a `dest` share landing on a +stale-tail slat beyond `d`: it caps that share's lifetime leak at the +documented `f_k × A` instead of letting the longer path over-schedule it. For +every other placement `d_c ≤ d` and the `min` is a no-op.) + For default placement (`d_c = d`) the budget is exactly `f_k × A` — the full documented fraction, leaked evenly over the cohort's own `d`-slat journey. This holds **regardless of the physical belt length**: after a transit shrink a new @@ -555,10 +561,13 @@ integrated into the algorithm above: 3. **Start-of-time-unit initialization** ([§7](#7-initialization)): the belt's `N` slats partition into **time-unit blocks** by simulated travel time — slat `i` belongs to block `u = floor(i × DT)` (well-defined for any DT, - integer `1/DT` or not), giving `U = ceil(N × DT)` blocks. A scalar initial - value is divided across the `U` blocks, the whole per-block share placed in - that block's deepest slat rather than spread evenly; an explicit init list - places each entry the same way ([§7.2](#72-explicit-per-slat-list)). + integer `1/DT` or not), giving `U = floor((N − 1) × DT) + 1` blocks (the + number of blocks that actually own a slat; equal to `N × DT` when `1/DT` is + integral, and never producing an empty block when it is not). A scalar + initial value is divided across the `U` blocks, the whole per-block share + placed in that block's deepest slat rather than spread evenly; an explicit + init list places each entry the same way + ([§7.2](#72-explicit-per-slat-list)). A conveyor with a queue directly upstream MUST be discrete (XMILE §3.7.2; enforced as a compile error — [§11](#11-queues-and-the-conveyor-side-of-queueconveyor-coupling)). @@ -612,7 +621,7 @@ by list length: non-integer transit times, per the isee rule. - **Any other length (one entry per time unit):** using the time-unit blocks of [§6.4](#64-discrete-conveyors) rule 3 (slat `i` in block `floor(i × DT)`, - `U = ceil(N × DT)` blocks), entry `v_u` fills block `u`: split evenly across + `U = floor((N − 1) × DT) + 1` blocks), entry `v_u` fills block `u`: split evenly across the block's slats for a **continuous** conveyor (so the outflow during unit `u` totals `v_u`), or placed whole in the block's deepest slat for a **discrete** conveyor (isee "start of each time unit" semantics). The list is @@ -708,12 +717,15 @@ pushback on the inflow cannot be expressed as fixed equations): driven outflow/leak/inflow slots map to entries in this table via the layout. - **Update hook.** Add the two-phase conveyor pass ([§4.3](#43-per-dt-update)) inside the Euler loop's `eval_step`, ordered - **after** ordinary flow equations are evaluated (so requested inflow rates and - `arrest`/`sample`/`len`/`capacity`/`in_limit` auxiliaries are current) and - **before** stock integration (so the driven flow values feed the stock - update). Phase A writes each conveyor's outflow and leak rates; Phase B writes - admitted-inflow rates and advances the belts. Within each phase the iteration - order over conveyors is arbitrary (no topological sort — [§4.3](#43-per-dt-update)). + **after** the equations the pass depends on (its inputs: requested inflow + rates and the `arrest`/`sample`/`len`/`capacity`/`in_limit`/leak-fraction + expressions) and **before** both stock integration and any equation that + *reads* a pass output — the §4.3 "Visibility to other equations" rules are + the normative ordering; equations reading a driven flow are dependency- + ordered after the pass, not lumped with "ordinary flows" generally. Phase A + writes each conveyor's outflow and leak rates; Phase B writes admitted-inflow + rates and advances the belts. Within each phase the iteration order over + conveyors is arbitrary (no topological sort — [§4.3](#43-per-dt-update)). The conveyor's own value slot receives `Σ slats.content`. - **Initialization** ([§7](#7-initialization)) runs in the initials pass, filling `slats` from the stock `` value before the first step. diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index ac8a82fa7..012b76a52 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -95,7 +95,10 @@ def cohort_schedule(self, volume, belt_len, entry_depth, own_depth): m_entry = self.zone_count_from(lk, belt_len, entry_depth) a = lk.fraction * volume / m_entry if m_entry else 0.0 alloc.append(a) - budget.append(a * self.zone_count_from(lk, belt_len, own_depth)) + # min with m_entry: caps a dest share landing on a stale-tail slat + # beyond the entry depth at the documented f*A (no-op otherwise) + m_own = self.zone_count_from(lk, belt_len, own_depth) + budget.append(a * min(m_own, m_entry)) return alloc, budget # ---- initialization (section 7.1) ---- From 545556e6b0fa79992312f2397bcaac31bd860a4d Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 10:24:21 -0700 Subject: [PATCH 06/11] doc: cap discrete unit inserts by capacity; sample is dimensionless Address the third round of Codex review, both findings confirmed real. Quantized admission could breach capacity: quant_carry accumulated across steps was never capacity-cleared as a whole unit, so floor(carry + clearance) could insert a unit into room step 4 had cleared only fractionally (carry 0.9, cap_room 0.2 -> a full unit lands in 0.2 of room). Rule 1 now inserts units = min(floor(quant_carry), floor(cap_room)) -- the unit waits until a whole unit of room exists -- with the in_limit window still accounted at clearance time, un-inserted carry never taken from upstream, and conveyor-driven inflow bypassing quantization (never-blocked rule; integral anyway when the upstream is discrete, as the queue-fed case mandates). The prototype now implements quantized admission and S11 executes exactly this corner (fractional requests against capacity 3: contents stay integral, never exceed capacity, and flow does not deadlock), moving discrete quantization from prose-only to machine-verified; 20/20 checks pass. The 9.8 unit rule gave time units, but sample -- like arrest -- is a condition evaluated for nonzero: a predicate such as TIME > 10 type-checks dimensionless, so the time-units rule would reject valid sample expressions. Only requires time units; sample and arrest are dimensionless. --- docs/design/conveyors.md | 47 +++++++++++++++---------- test/conveyors/reference_prototype.py | 49 +++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 8130b65c2..2948b08b4 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -547,14 +547,25 @@ apportioned in inflow order (step 4). Blocked material stays upstream. continuous stream. Its complete semantics are three rules, all already integrated into the algorithm above: -1. **Quantized admission.** After step 4 computes `admitted`, a discrete - conveyor adds it to `quant_carry` ([§4.2](#42-conveyor-runtime-state)) and - actually inserts `floor(quant_carry)` whole units, retaining the fractional - remainder (same carry pattern as ``, - [§5.4](#54-integer-leakage); `quant_carry` never resets — it is distinct - from the boundary-resetting `in_carry`). The un-inserted fraction is *not* - taken from upstream (the reported inflow rate reflects only inserted units). - Slat contents therefore stay integral, and exits arrive as integral lumps. +1. **Quantized admission.** Step 4's *equation-driven* clearance accumulates + rather than inserting directly: `quant_carry += eq_admitted` + ([§4.2](#42-conveyor-runtime-state); `quant_carry` never resets — it is + distinct from the boundary-resetting `in_carry`), then + `units = min(floor(quant_carry), floor(cap_room))` whole units are inserted + (just `floor(quant_carry)` when capacity is INF) and + `quant_carry -= units`. The capacity re-check on the whole unit is + load-bearing: carry accumulated in earlier steps was never + capacity-cleared *as a unit*, so without it a unit could materialize into + room step 4 cleared only fractionally (e.g. `quant_carry = 0.9`, + `cap_room = 0.2`: the 0.2 clearance raises the carry to 1.1, but no unit + fits until `cap_room ≥ 1`). The `in_limit` window (`in_carry`) accounts at + clearance time and is *not* re-checked at insertion. Un-inserted carry is + never taken from upstream — the reported inflow rate reflects only inserted + units, so conservation and the capacity bound both hold exactly. Slat + contents therefore stay integral, and exits arrive as integral lumps. + Conveyor-driven inflow bypasses quantization entirely (never blocked — + [§4.3](#43-per-dt-update); it is already integral when the upstream is + discrete, which the queue-fed case mandates). 2. **Per-time-unit inflow-limit window** ([§6.3](#63-capacity-and-inflow-limit)): the full `in_limit` budget may be consumed within a single DT, resetting at integer time boundaries — rather than being prorated `× DT`. @@ -791,11 +802,12 @@ LTM-through-conveyor attribution is a separate enhancement. | `ConveyorLtmDegraded` | Warning | LTM analysis requested on a model containing conveyors ([§9.6](#96-ltm)) | **Unit checking** (`src/simlin-engine/src/units_check.rs`): with the conveyor -stock's units `S` and the model's time unit `t` — `` and `` -context: `t`; ``: `S`; ``: `S/t`; a linear leak fraction: -dimensionless; an exponential leak rate: `1/t`; ``: dimensionless. -Driven flows (primary outflow, leaks, admitted inflows) carry `S/t` like any -flow. +stock's units `S` and the model's time unit `t` — ``: `t`; ``: +`S`; ``: `S/t`; a linear leak fraction: dimensionless; an exponential +leak rate: `1/t`; `` and ``: dimensionless (both are +*conditions* evaluated for nonzero — a predicate like `TIME > 10` type-checks +dimensionless, so requiring `t` would reject valid sample expressions). Driven +flows (primary outflow, leaks, admitted inflows) carry `S/t` like any flow. **VM lifecycle**: `Vm::reset` re-initializes the conveyor side table exactly as it re-runs initials (the belt is derived state — same pattern as the @@ -957,13 +969,13 @@ Every check below **passes**. Run it with **Prototype coverage — what these scenarios do and do not verify.** Executed: the two-phase update including arrest and the held-exit merge (§4.3), linear/exponential leakage with fixed per-cohort schedules (§5.1–§5.3), -capacity and inflow limits (§6.3), transit latching/shrink/merging (§6.1–§6.2), +capacity and inflow limits (§6.3), discrete quantized admission against a tight +capacity (§6.4 rule 1), transit latching/shrink/merging (§6.1–§6.2), steady-state initialization (§7.1), conveyor chains, and half-away rounding (§4.1). **Not** executed (specified in prose only; they get fixtures with the Rust implementation): leak zones narrower than the belt, ``, -discrete quantization, spread-input placements, explicit-list initialization, -and leak-fed chains (`source` placement). Treat only the executed set as -prototype-verified. +spread-input placements, explicit-list initialization, and leak-fed chains +(`source` placement). Treat only the executed set as prototype-verified. **Reporting convention.** Each trajectory row shows the stock's **start-of-step** contents at time `t` together with the flow rates during @@ -985,6 +997,7 @@ rate 250/time unit unless noted. | S8 | chain: conveyor A (`T = 2`, draining 500) feeds conveyor B (`T = 4`, `capacity = 100`) | — | — | conveyor-driven inflow is never blocked ([§4.3](#43-per-dt-update)): B's capacity is transiently exceeded, and total material across A + B + B's cumulative outflow is conserved exactly | | S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget; **post-shrink steady outflow returns to `(1 − f)·inflow = 200`** — new entry cohorts leak the full fraction over their own path even while the stale tail keeps the physical belt longer than the entry depth ([§5.1](#51-linear-leakage)) | | S10 | chain A (`T = 2`, draining 500) feeds B; B arrested for `t ∈ [1, 2)` | — | — | held exit ([§4.3](#43-per-dt-update) steps 3/5): during the hold A's outflow reports 0 and material accumulates in A's exit slat while B is frozen; on release the accumulated 250 exits A as one lump (rate 1000); chain conservation exact throughout | +| S11 | discrete conveyor (`T = 2`, `capacity = 3`), fractional requests (0.6/DT) | — | — | quantized admission ([§6.4](#64-discrete-conveyors) rule 1): slat contents stay integral, a whole unit inserts only when `floor(cap_room)` fits it — so contents never exceed capacity even as `quant_carry` crosses 1.0 against fractional room — and material still flows (no deadlock) | In addition to the per-scenario invariants, the harness asserts the [§4.3](#43-per-dt-update) **conservation identity** diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index 012b76a52..543a28baf 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -7,12 +7,12 @@ Not production code -- a faithful transcription of the spec's per-DT rules. Coverage: the two-phase update (4.3) including arrest and the held-exit merge, linear/exponential leakage with fixed per-cohort schedules (5.1-5.3), capacity -and inflow limits (6.3), transit latching/shrink/merging (6.1-6.2), -steady-state init (7.1), conveyor chains, and half-away rounding (4.1). Not -exercised here: leak zones narrower than the belt, , discrete -quantization, spread inputs, explicit-list init, leak-fed chains ('source' -placement) -- their rules are specified in the doc and get fixtures with the -Rust implementation. +and inflow limits (6.3), discrete quantized admission vs capacity (6.4 rule 1), +transit latching/shrink/merging (6.1-6.2), steady-state init (7.1), conveyor +chains, and half-away rounding (4.1). Not exercised here: leak zones narrower +than the belt, , spread inputs, explicit-list init, leak-fed +chains ('source' placement) -- their rules are specified in the doc and get +fixtures with the Rust implementation. Run: python3 test/conveyors/reference_prototype.py (exits nonzero on failure) """ from dataclasses import dataclass, field @@ -57,6 +57,7 @@ class Conveyor: slats: list = field(default_factory=list) # index 0 = exit latched_transit: float = None in_carry: float = 0.0 + quant_carry: float = 0.0 # section 4.1: N = round(T/DT) half away from zero, >= 1 def n_slats(self, transit=None): @@ -184,7 +185,17 @@ def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): limit_vol = self.in_limit * dt eq_admitted = min(max(0.0, eq_request_rate) * dt, cap_room, limit_vol) if self.discrete: + # section 6.4 rule 1: clearance accrues into quant_carry; whole + # units insert only when the CURRENT capacity room fits them (the + # in_limit window accounted at clearance time, not re-checked) self.in_carry += eq_admitted + self.quant_carry += eq_admitted + units = math.floor(self.quant_carry) + if self.capacity != INF: + units = min(units, math.floor(cap_room)) + units = max(0, units) + self.quant_carry -= units + eq_admitted = float(units) admitted = conv_vol + eq_admitted # step 5: shift (held exit keeps slat 0 and merges the next slat in) if pa['held']: @@ -414,6 +425,32 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): check("S10 conservation: everything initially in A ends up through B", abs((a10.contents() + b10.contents() + exited) - total0) < 1e-6) +# S11: discrete quantization vs a tight capacity (the codex-flagged corner). +# Requests are fractional (0.6/DT); units may only materialize when a WHOLE +# unit fits in the current cap_room -- floor(quant_carry) alone would breach +# capacity whenever carry crosses 1.0 against fractional room. +print("\n=== S11 discrete conveyor, quantized admission vs capacity=3 ===") +c11 = Conveyor('c11', transit=2, dt=0.25, capacity=3, discrete=True) +c11.init_steady(0) +integral_ok = True +cap_ok = True +inserted_total = 0.0 +t = 0.0 +prev_unit = 0 +for _ in range(64): + if math.floor(t) != prev_unit: + c11.in_carry, prev_unit = 0.0, math.floor(t) + r = step_all([c11], {'c11': 2.4}, t=t)['c11'] # 2.4/time = 0.6/DT + inserted_total += r['inflow'] * 0.25 + cap_ok = cap_ok and c11.contents() <= 3 + 1e-9 + integral_ok = integral_ok and all( + abs(s.content - round(s.content)) < 1e-9 for s in c11.slats) + t += 0.25 +check("S11 slat contents always integral (whole units only)", integral_ok) +check("S11 contents never exceed capacity 3 (unit re-checked against cap_room)", + cap_ok) +check("S11 material flows (quantization does not deadlock)", inserted_total > 0) + print() if FAILURES: print(f"{len(FAILURES)} CHECK(S) FAILED:") From 8e2333eac925d2e1519cabb8b749423022ce1ebf Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 11:28:30 -0700 Subject: [PATCH 07/11] doc: tolerate Stella placeholder outflow eqns; dest over whole belt Address the fourth round of Codex review, both findings confirmed against the vendored fixtures. Primary conveyor outflows in real Stella exports carry a placeholder 0 (plus ) despite XMILE's MUST NOT -- both vendored fixtures do. A literal reading of the spec's prohibition would leave those models unsimulatable even after conveyor support lands. The reader now preserves-but-ignores an on a primary conveyor outflow (round-trip fidelity, no role in simulation, never an error), while the writer emits the spec-strict form, consistent with the existing non_negative writer rule. Only a leak-marked flow's is meaningful (the leak fraction), and the 9.3 compiled-representation bullet now reflects that both spellings compile to the driven-by-conveyor marker. dest placement normalized over all belt content but defined shares only for the first d slats, so content on a stale tail contributed to the denominator without receiving a share -- admitted material would be silently lost (sum A_i < A). dest now targets the whole physical belt 0..L-1 (an empty slat's share is 0 by construction, sum A_i = A exactly); a share landing beyond d is exactly the d_c > d case the 5.1 min-cap already schedules. The section 8 preamble now states the no-material-lost invariant explicitly. --- docs/design/conveyors.md | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 2948b08b4..d7fa1602d 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -136,9 +136,19 @@ explicitly overridden by tagging each leakage flow with the `` property") — so a model whose first listed outflow carries `` gets a later primary. Compile **errors**: a conveyor with no outflows, or whose outflows are all leak-marked, cannot simulate (XMILE: at least one outflow MUST -be the normal outflow). A conveyor outflow MUST NOT carry a normal equation- -valued `` — but note the leak-fraction encoding below. Real Stella models -put the leak **fraction** in the `` of a ``-tagged flow: +be the normal outflow). + +XMILE says a conveyor outflow MUST NOT carry a normal equation — the conveyor +drives it — but real Stella exports put a **placeholder** `0` on +primary conveyor outflows anyway (both vendored `.stmx` fixtures do, e.g. +`recovering` in `sir_social_distancing_mixnot.stmx`). The reader therefore +**preserves but ignores** any `` on a primary conveyor outflow: it is kept +for round-trip fidelity and plays no role in simulation — never an error. The +writer emits the spec-strict form (no `` on a primary outflow), consistent +with the `` writer rule in [§3.4](#34-non-negativity). On a +*leak-marked* flow the `` is meaningful — it carries the leak fraction. +Real Stella models put the leak **fraction** in the `` of a +``-tagged flow: ```xml @@ -652,17 +662,19 @@ simlin implements all five; each distributes the admitted volume `A` (from step 4) across slats at insert time (step 6): Definitions used below: `d` = the entry depth (step 6); target slats are indexed -`i ∈ 0..d−1` (0 = exit); a slat's **fractional position from the entry side** -over the entry path is `x_i = 1 − (i + 0.5)/d` (so `x ≈ 0` at the entry slat -`i = d−1`, `x ≈ 1` at the exit slat `i = 0`, consistent with the +`i ∈ 0..d−1` (0 = exit) for every method except `dest`, which targets the whole +physical belt `i ∈ 0..L−1` (see its row); a slat's **fractional position from +the entry side** over the entry path is `x_i = 1 − (i + 0.5)/d` (so `x ≈ 0` at +the entry slat `i = d−1`, `x ≈ 1` at the exit slat `i = 0`, consistent with the [§5.3](#53-leak-zones) orientation). Every placement distributes the admitted -volume `A` as per-slat shares `A_i ≥ 0` with `Σ A_i = A`: +volume `A` as per-slat shares `A_i ≥ 0` with `Σ A_i = A` **exactly** — no +placement may lose admitted material: | `isee:spreadflow` | Per-slat share `A_i` | |---|---| | `beginning` (default) | `A_i = A` for `i = d−1` (the entry slat), 0 elsewhere: one cohort at depth `d`. | | `even` | `A_i = A / d` for every `i ∈ 0..d−1` — including the exit slat, whose share exits after one DT (isee: "equal amount every position"). | -| `dest` | `A_i = A × content_i / Σ_j content_j`, summed over **all** slats of the current belt (length `L`, not just the first `d`) — proportional to existing content. If total content is 0, fall back to `beginning`. | +| `dest` | `A_i = A × content_i / Σ_j content_j` for `i ∈ 0..L−1` — every slat of the current physical belt receives its content-proportional share (an empty slat's share is 0 by construction, and `Σ A_i = A` exactly since numerators and denominator range over the same slats). A share landing on a stale-tail slat beyond `d` has `d_c = i + 1 > d`; its leak budget is capped by the [§5.1](#51-linear-leakage) `min(M_k(d_c), M_k(d))` rule. If total content is 0, fall back to `beginning`. | | `dist` | `A_i = A × w_i / Σ_j w_j` over `i ∈ 0..d−1`, where `w_i = max(0, g(x_i))` and `g` is ``: a graphical function evaluated at `x_i` with its own x-axis scale and extrapolation rules (isee normalizes the table "so that it is effectively a probability density function" — the `Σ w` division here is that normalization), or a 1-D array of length `m` where `w_i` = the element `floor(x_i × m)` (clamped to `m−1`). If `Σ w = 0`, fall back to `beginning`. | | `source` | Applies only when this inflow is **conveyor-driven from an upstream leak flow** (the "coupling" is flow identity: the inflow *is* that leak). For each upstream slat `j` that leaked `q_j` this step (Phase A), with `y_j` = that slat's fractional position from the entry side of the *upstream* belt, place `q_j` at the target slat `i ∈ 0..d−1` whose `x_i` is nearest `y_j` (ties toward the exit) — positions mirror proportionally when the two belts differ in length. If the inflow is not an upstream leak, fall back to `beginning`. | @@ -740,10 +752,13 @@ pushback on the inflow cannot be expressed as fixed equations): The conveyor's own value slot receives `Σ slats.content`. - **Initialization** ([§7](#7-initialization)) runs in the initials pass, filling `slats` from the stock `` value before the first step. -- **Compiled representation.** The equation-less conveyor outflow/leak flows - compile to a "driven by conveyor" marker (not an equation), so the - `empty_equation` error no longer fires; instead the compiler wires the flow's - value slot to the owning conveyor's update output. +- **Compiled representation.** Conveyor-driven flows (the primary outflow and + leak flows) compile to a "driven by conveyor" marker, not an equation — the + primary outflow's `` is absent or an ignored Stella placeholder + ([§3.3](#33-leakage-flows)), and a leak flow's fraction expression feeds the + cohort schedule rather than the flow slot. The `empty_equation` error no + longer fires; instead the compiler wires the flow's value slot to the owning + conveyor's update output. ### 9.4 Integration method From 368e8750ad6f76606852b1bf423b8bc2ec05ac2e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 11:49:27 -0700 Subject: [PATCH 08/11] doc: per-inflow quantization carry; arrest decided before latch Address the fifth round of Codex review; both findings judged material. Per-inflow quantization: a single conveyor-level quant_carry could not say which upstream flow owns a whole unit that inserts on a later step -- with two equation-driven inflows, or one whose request drops to 0 after fractional clearances accrued, an implementer would have to invent an attribution rule and a wrong one creates or destroys material at an upstream stock. The carry is now per equation-driven inflow: step 4's listed-order clearance accrues to each inflow's own carry, and insertion walks the inflows in listed order under a shared floor(cap_room) budget, each inflow reporting exactly its own inserted units. The prototype implements the rule (harness extended to multiple inflows per conveyor) and S12 pins the bookkeeping identity cum_cleared_j == cum_reported_j + carry_j at every step, the post-shutoff residual bound, and integral per-inflow totals; 23/23 checks pass. Arrest-vs-latch ordering: step 0 (latch) consumed the arrest flag that step 1 formally computed -- the numbered algorithm contradicted its own prose, and in a step where arrest, sample, and a change coincide a literal reading would freeze the material yet still change the post-release entry depth. The steps are swapped: arrest is decided first and an arrested conveyor skips everything including the latch. --- docs/design/conveyors.md | 89 ++++++++++++--------- test/conveyors/reference_prototype.py | 111 +++++++++++++++++++++----- 2 files changed, 144 insertions(+), 56 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index d7fa1602d..fda3d5ce3 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -226,8 +226,11 @@ ConveyorState { leak_carry: Vec, // per leak-flow accumulator for (never resets) in_carry: f64, // per-time-unit in_limit budget spent (discrete only; resets at // integer time boundaries -- §6.3) - quant_carry: f64, // discrete admission fractional-unit accumulator (§6.4 rule 1; - // never resets -- distinct state from in_carry) + quant_carry: Vec, // discrete admission fractional-unit accumulators, one PER + // equation-driven inflow (§6.4 rule 1 -- per-inflow so every + // inserted whole unit is attributable to the upstream flow + // whose clearance accrued it; never resets -- distinct state + // from in_carry) } Slat { content: f64, // material in this slat @@ -272,21 +275,24 @@ which is exactly isee's guidance. **Phase A — leak and exit (each conveyor, from its own start-of-step state):** -0. **Latch.** Evaluate ``; when it is nonzero (and the conveyor is not - arrested — an arrested conveyor's time is suspended, so it does not - re-latch), update `latched_transit` from `` (with the runtime hygiene of - [§4.4](#44-runtime-expression-hygiene)). This happens before any Phase-A - mutation, so this step's insert (step 6) uses the newly latched depth. - -1. **Arrest.** Evaluate ``. If nonzero, this conveyor is *arrested* this - step: every inflow and outflow of this conveyor reports 0, `slats` is left - untouched (material frozen, not lost), and both phases are skipped for it. - Arrest flags come from ordinary expressions already evaluated this step, so - all flags are known before any conveyor mutates. Clock-based state is - unaffected by arrest: the `in_carry` integer-time-boundary reset +0. **Arrest.** Evaluate `` **first — before the latch**, because an + arrested conveyor's time is suspended and it must not re-latch (otherwise a + step where arrest, sample, and a `` change coincide would freeze the + material yet still change the post-release entry depth). If nonzero, this + conveyor is *arrested* this step: every inflow and outflow of this conveyor + reports 0, `slats` is left untouched (material frozen, not lost), and all + remaining steps — including the latch — are skipped for it. Arrest flags + come from ordinary expressions already evaluated this step, so all flags + are known before any conveyor mutates. Clock-based state is unaffected by + arrest: the `in_carry` integer-time-boundary reset ([§6.3](#63-capacity-and-inflow-limit)) still fires, and `leak_carry`/`quant_carry` persist untouched. +1. **Latch.** Evaluate ``; when it is nonzero, update `latched_transit` + from `` (with the runtime hygiene of + [§4.4](#44-runtime-expression-hygiene)). This happens before any belt + mutation, so this step's insert (step 6) uses the newly latched depth. + 2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` in `k`'s zone ([§5.3](#53-leak-zones)): compute `leak_{k,i}` per [§5.1](#51-linear-leakage)/[§5.2](#52-exponential-leakage), clamp it to @@ -373,7 +379,7 @@ arbitrary expressions, so invalid values are reachable at runtime even when the compile-time checks pass. The rules (all simlin-defined, chosen for determinism): -- **Transit time.** At each latch (step 0): a finite value is clamped to +- **Transit time.** At each latch (step 1): a finite value is clamped to `max(DT, value)`; a non-finite (NaN/±INF) value leaves `latched_transit` unchanged. The *initial* latch (during initialization) with a non-finite or `≤ 0` value is a runtime initialization error. @@ -557,25 +563,35 @@ apportioned in inflow order (step 4). Blocked material stays upstream. continuous stream. Its complete semantics are three rules, all already integrated into the algorithm above: -1. **Quantized admission.** Step 4's *equation-driven* clearance accumulates - rather than inserting directly: `quant_carry += eq_admitted` - ([§4.2](#42-conveyor-runtime-state); `quant_carry` never resets — it is - distinct from the boundary-resetting `in_carry`), then - `units = min(floor(quant_carry), floor(cap_room))` whole units are inserted - (just `floor(quant_carry)` when capacity is INF) and - `quant_carry -= units`. The capacity re-check on the whole unit is - load-bearing: carry accumulated in earlier steps was never - capacity-cleared *as a unit*, so without it a unit could materialize into - room step 4 cleared only fractionally (e.g. `quant_carry = 0.9`, - `cap_room = 0.2`: the 0.2 clearance raises the carry to 1.1, but no unit - fits until `cap_room ≥ 1`). The `in_limit` window (`in_carry`) accounts at - clearance time and is *not* re-checked at insertion. Un-inserted carry is - never taken from upstream — the reported inflow rate reflects only inserted - units, so conservation and the capacity bound both hold exactly. Slat - contents therefore stay integral, and exits arrive as integral lumps. - Conveyor-driven inflow bypasses quantization entirely (never blocked — - [§4.3](#43-per-dt-update); it is already integral when the upstream is - discrete, which the queue-fed case mandates). +1. **Quantized admission, tracked per inflow.** Step 4's *equation-driven* + clearance accumulates rather than inserting directly, and the accumulator + is **per inflow** (`quant_carry[j]` for the `j`-th equation-driven inflow — + [§4.2](#42-conveyor-runtime-state); never resets, distinct from the + boundary-resetting `in_carry`) so that every whole unit that eventually + inserts is attributable to the specific upstream flow whose clearance + accrued it — even on a later step where that inflow's request has dropped + to 0, and even with several inflows. Per step: + - Step 4 apportions the clearance in listed order as usual; each inflow's + share accrues to its own carry: `quant_carry[j] += cleared_j`. + - Insertion walks the inflows **in listed order** with a shared capacity + budget `B = floor(cap_room)` (`B = ∞` when capacity is INF): + `units_j = min(floor(quant_carry[j]), B)`; `B -= units_j`; + `quant_carry[j] -= units_j`. Inflow `j`'s **reported rate** = + `units_j / DT` — the reported value debits exactly the upstream that + owns the material, so no flow misattributes or invents material. + - The capacity re-check on whole units is load-bearing: carry accumulated + in earlier steps was never capacity-cleared *as a unit*, so without it a + unit could materialize into room step 4 cleared only fractionally (e.g. + carry `0.9`, `cap_room = 0.2`: the 0.2 clearance raises the carry to 1.1, + but no unit fits until `cap_room ≥ 1`). The `in_limit` window + (`in_carry`) accounts at clearance time and is *not* re-checked at + insertion. + Un-inserted carry is never taken from upstream — an inflow's reported rate + reflects only its inserted units, so conservation and the capacity bound + both hold exactly. Slat contents therefore stay integral, and exits arrive + as integral lumps. Conveyor-driven inflow bypasses quantization entirely + (never blocked — [§4.3](#43-per-dt-update); it is already integral when the + upstream is discrete, which the queue-fed case mandates). 2. **Per-time-unit inflow-limit window** ([§6.3](#63-capacity-and-inflow-limit)): the full `in_limit` budget may be consumed within a single DT, resetting at integer time boundaries — rather than being prorated `× DT`. @@ -985,7 +1001,7 @@ Every check below **passes**. Run it with the two-phase update including arrest and the held-exit merge (§4.3), linear/exponential leakage with fixed per-cohort schedules (§5.1–§5.3), capacity and inflow limits (§6.3), discrete quantized admission against a tight -capacity (§6.4 rule 1), transit latching/shrink/merging (§6.1–§6.2), +capacity with per-inflow attribution (§6.4 rule 1), transit latching/shrink/merging (§6.1–§6.2), steady-state initialization (§7.1), conveyor chains, and half-away rounding (§4.1). **Not** executed (specified in prose only; they get fixtures with the Rust implementation): leak zones narrower than the belt, ``, @@ -1012,7 +1028,8 @@ rate 250/time unit unless noted. | S8 | chain: conveyor A (`T = 2`, draining 500) feeds conveyor B (`T = 4`, `capacity = 100`) | — | — | conveyor-driven inflow is never blocked ([§4.3](#43-per-dt-update)): B's capacity is transiently exceeded, and total material across A + B + B's cumulative outflow is conserved exactly | | S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget; **post-shrink steady outflow returns to `(1 − f)·inflow = 200`** — new entry cohorts leak the full fraction over their own path even while the stale tail keeps the physical belt longer than the entry depth ([§5.1](#51-linear-leakage)) | | S10 | chain A (`T = 2`, draining 500) feeds B; B arrested for `t ∈ [1, 2)` | — | — | held exit ([§4.3](#43-per-dt-update) steps 3/5): during the hold A's outflow reports 0 and material accumulates in A's exit slat while B is frozen; on release the accumulated 250 exits A as one lump (rate 1000); chain conservation exact throughout | -| S11 | discrete conveyor (`T = 2`, `capacity = 3`), fractional requests (0.6/DT) | — | — | quantized admission ([§6.4](#64-discrete-conveyors) rule 1): slat contents stay integral, a whole unit inserts only when `floor(cap_room)` fits it — so contents never exceed capacity even as `quant_carry` crosses 1.0 against fractional room — and material still flows (no deadlock) | +| S11 | discrete conveyor (`T = 2`, `capacity = 3`), fractional requests (0.6/DT) | — | — | quantized admission ([§6.4](#64-discrete-conveyors) rule 1): slat contents stay integral, a whole unit inserts only when `floor(cap_room)` fits it — so contents never exceed capacity even as the carry crosses 1.0 against fractional room — and material still flows (no deadlock) | +| S12 | discrete conveyor, **two** equation-driven inflows (1.6 and 0.8/time; the second shuts off at `t = 8`) | — | — | per-inflow attribution ([§6.4](#64-discrete-conveyors) rule 1): the bookkeeping identity `cum_cleared_j = cum_reported_j + quant_carry[j]` holds for each inflow at every step, so every inserted unit debits exactly the upstream flow that cleared it; after shutoff an inflow reports at most its residual carry; both totals integral | In addition to the per-scenario invariants, the harness asserts the [§4.3](#43-per-dt-update) **conservation identity** diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index 543a28baf..01241a4d6 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -7,7 +7,7 @@ Not production code -- a faithful transcription of the spec's per-DT rules. Coverage: the two-phase update (4.3) including arrest and the held-exit merge, linear/exponential leakage with fixed per-cohort schedules (5.1-5.3), capacity -and inflow limits (6.3), discrete quantized admission vs capacity (6.4 rule 1), +and inflow limits (6.3), discrete quantized admission vs capacity with per-inflow attribution (6.4 rule 1), transit latching/shrink/merging (6.1-6.2), steady-state init (7.1), conveyor chains, and half-away rounding (4.1). Not exercised here: leak zones narrower than the belt, , spread inputs, explicit-list init, leak-fed @@ -57,7 +57,10 @@ class Conveyor: slats: list = field(default_factory=list) # index 0 = exit latched_transit: float = None in_carry: float = 0.0 - quant_carry: float = 0.0 + # section 6.4 rule 1: one fractional-unit accumulator PER equation-driven + # inflow, so every inserted whole unit is attributable to the upstream + # flow whose clearance accrued it (sized lazily on first step) + quant_carry: list = field(default_factory=list) # section 4.1: N = round(T/DT) half away from zero, >= 1 def n_slats(self, transit=None): @@ -139,7 +142,9 @@ def init_from_inflow(self, inflow_rate): s.leak_alloc = [a * scale for a in s.leak_alloc] s.leak_budget = [b * scale for b in s.leak_budget] - # ---- phase A (section 4.3 steps 0-3): latch + leak + exit, purely local + # ---- phase A (section 4.3 steps 0-3): arrest, latch, leak, exit -- + # purely local (the harness passes arrest flags in; latching is modeled + # by mutating latched_transit before the step, equivalent to step 1) def phase_a(self, arrested=False, dest_arrested=False): if arrested: return dict(out_vol=0.0, leak_vols=[0.0] * len(self.leaks), @@ -170,10 +175,13 @@ def phase_a(self, arrested=False, dest_arrested=False): arrested=False, held=False) # ---- phase B (section 4.3 steps 4-6): admit + shift + insert ---- - def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): + # eq_request_rates: one requested rate per equation-driven inflow, in + # listed order (step 4's listed-order apportionment). + def phase_b(self, pa, eq_request_rates, conv_vol, contents0, t): dt = self.dt if pa['arrested']: - return dict(in_rate=0.0) + return dict(in_rate=0.0, in_rates=[0.0] * len(eq_request_rates), + cleared=[0.0] * len(eq_request_rates)) contents_after = contents0 - sum(pa['leak_vols']) - pa['out_vol'] cap_room = (INF if self.capacity == INF else max(0.0, self.capacity - contents_after - conv_vol)) @@ -183,19 +191,38 @@ def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): limit_vol = max(0.0, self.in_limit - self.in_carry) else: limit_vol = self.in_limit * dt - eq_admitted = min(max(0.0, eq_request_rate) * dt, cap_room, limit_vol) + # step 4: apportion clearance across inflows in listed order + rem_cap, rem_limit = cap_room, limit_vol + cleared = [] + for rate in eq_request_rates: + c = min(max(0.0, rate) * dt, rem_cap, rem_limit) + cleared.append(c) + if rem_cap != INF: + rem_cap -= c + if rem_limit != INF: + rem_limit -= c if self.discrete: - # section 6.4 rule 1: clearance accrues into quant_carry; whole - # units insert only when the CURRENT capacity room fits them (the + # section 6.4 rule 1: per-inflow carry; whole units insert in + # listed order under a shared floor(cap_room) budget, so each + # inserted unit debits exactly the inflow that cleared it (the # in_limit window accounted at clearance time, not re-checked) - self.in_carry += eq_admitted - self.quant_carry += eq_admitted - units = math.floor(self.quant_carry) - if self.capacity != INF: - units = min(units, math.floor(cap_room)) - units = max(0, units) - self.quant_carry -= units - eq_admitted = float(units) + if not self.quant_carry: + self.quant_carry = [0.0] * len(eq_request_rates) + self.in_carry += sum(cleared) + budget = INF if self.capacity == INF else math.floor(cap_room) + in_vols = [] + for j, c in enumerate(cleared): + self.quant_carry[j] += c + units = math.floor(self.quant_carry[j]) + if budget != INF: + units = min(units, budget) + budget -= units + units = max(0, units) + self.quant_carry[j] -= units + in_vols.append(float(units)) + else: + in_vols = cleared + eq_admitted = sum(in_vols) admitted = conv_vol + eq_admitted # step 5: shift (held exit keeps slat 0 and merges the next slat in) if pa['held']: @@ -220,7 +247,8 @@ def phase_b(self, pa, eq_request_rate, conv_vol, contents0, t): tgt.content += admitted tgt.leak_alloc = [x + y for x, y in zip(tgt.leak_alloc, alloc)] tgt.leak_budget = [x + y for x, y in zip(tgt.leak_budget, budget)] - return dict(in_rate=admitted / dt) + return dict(in_rate=admitted / dt, + in_rates=[v / dt for v in in_vols], cleared=cleared) def step_all(convs, eq_inflow_rates, chain=None, t=0.0, arrested=None): @@ -242,8 +270,10 @@ def step_all(convs, eq_inflow_rates, chain=None, t=0.0, arrested=None): for c in convs: # phase B: any order up = chain.get(c.name) conv_vol = pa[up]['out_vol'] if up else 0.0 - pb = c.phase_b(pa[c.name], eq_inflow_rates.get(c.name, 0.0), - conv_vol, contents0[c.name], t) + rates = eq_inflow_rates.get(c.name, 0.0) + if not isinstance(rates, list): + rates = [rates] + pb = c.phase_b(pa[c.name], rates, conv_vol, contents0[c.name], t) r = pa[c.name] admitted = pb['in_rate'] * c.dt delta = c.contents() - contents0[c.name] @@ -252,7 +282,8 @@ def step_all(convs, eq_inflow_rates, chain=None, t=0.0, arrested=None): f"conservation violated for {c.name}: residual {residual}" results[c.name] = dict(outflow=r['out_vol'] / c.dt, leak=[v / c.dt for v in r['leak_vols']], - inflow=pb['in_rate']) + inflow=pb['in_rate'], + inflows=pb['in_rates'], cleared=pb['cleared']) return results @@ -451,6 +482,46 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): cap_ok) check("S11 material flows (quantization does not deadlock)", inserted_total > 0) +# S12: per-inflow quantization attribution. Two equation-driven inflows on a +# discrete conveyor: each inflow's cumulative reported volume must track its +# OWN cumulative clearance to within one unit at every step (the carry bound), +# so a whole unit always debits the upstream flow that cleared it -- even when +# a request drops to 0 after fractional clearances accrued. +print("\n=== S12 discrete conveyor, two inflows, per-inflow attribution ===") +c12 = Conveyor('c12', transit=2, dt=0.25, capacity=4, discrete=True) +c12.init_steady(0) +cum_cleared = [0.0, 0.0] +cum_reported = [0.0, 0.0] +identity_ok = True +carry_nonneg = True +shutoff_reported = None +t = 0.0 +prev_unit = 0 +for step_i in range(64): + if math.floor(t) != prev_unit: + c12.in_carry, prev_unit = 0.0, math.floor(t) + # inflow 0 runs at 1.6/time; inflow 1 at 0.8/time but shuts off at t=8 + rates = [1.6, 0.8 if t < 8 else 0.0] + r = step_all([c12], {'c12': rates}, t=t)['c12'] + for j in range(2): + cum_cleared[j] += r['cleared'][j] + cum_reported[j] += r['inflows'][j] * 0.25 + # the attribution identity: every unit an inflow ever reported came + # from its OWN cumulative clearance, the difference being exactly its + # residual fractional (or capacity-blocked) carry + identity_ok = identity_ok and \ + abs(cum_cleared[j] - (cum_reported[j] + c12.quant_carry[j])) < 1e-9 + carry_nonneg = carry_nonneg and c12.quant_carry[j] >= 0.0 + if shutoff_reported is None and t >= 8: + shutoff_reported = cum_reported[1] + t += 0.25 +check("S12 per-inflow bookkeeping identity: cleared_j == reported_j + carry_j", + identity_ok and carry_nonneg) +check("S12 after shutoff, inflow 1 reports at most its residual carry (< 1 unit)", + cum_reported[1] - shutoff_reported < 1.0 + 1e-9) +check("S12 both inflows delivered integral totals", + all(abs(v - round(v)) < 1e-9 for v in cum_reported)) + print() if FAILURES: print(f"{len(FAILURES)} CHECK(S) FAILED:") From e86bc289e2c7e9b549d2ec336316d5655ff0aa04 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 13:26:33 -0700 Subject: [PATCH 09/11] doc: leak fractions re-read per DT; overlapping exponential rates add Address the sixth round of Codex review; both findings verified against the cited isee Traditional Conveyor Computation page (fetched and read in full) and judged material. Current-values rule: the spec froze a linear leak fraction at cohort entry, but isee states "leak fractions can change over time and the current values will be used when computing leakages" -- what is fixed at entry is the transit-derived schedule (isee's own worked example: transit 5 with a 50% leak sheds 10% per time period). The per-cohort state is now a fraction-free basis (A / M(entry path), volume per in-zone DT) and a travel window (basis x in-zone DTs), with the CURRENT f multiplying the basis each DT. Merging still sums exactly (both fields linear in entering volume) and every constant-f trajectory is bit-unchanged, which S1-S12 confirm. S13 pins the new behavior: a fraction stepping 0.2 -> 0.4 doubles the leak rate for ALL cohorts at the switch step and re-equilibrates the outflow at (1-0.4)*inflow. Additive exponential: overlapping exponential leaks were specified as sequential compounding on the running remainder, but isee adds overlapping rates ("if the leak zones overlap the leakage fractions will be added"; all exponential leakages "are computed always") -- and the spec's own 7.1 initialization already computed the additive form, an internal inconsistency a single-leak scenario could not catch. Every exponential flow now computes from the slat's start-of-step content (order-independent), scaling proportionally if the sum would overdrain. S14 pins it: two 0.1/time leaks behave exactly like one 0.2/time leak, each reporting half. Reading the isee page also surfaced a divergence Codex did not flag: for STAGGERED linear leak zones, isee's default applies f to material remaining at zone start while OASIS XMILE defines f against the inflowing amount. simlin implements the XMILE reading (identical for the common identical-zones case); section 5.1 now documents the delta explicitly as a cross-engine check item. 27/27 prototype checks pass. --- docs/design/conveyors.md | 214 +++++++++++++++++--------- test/conveyors/reference_prototype.py | 153 ++++++++++++------ 2 files changed, 246 insertions(+), 121 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index fda3d5ce3..36c8a3160 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -234,18 +234,23 @@ ConveyorState { } Slat { content: f64, // material in this slat - leak_alloc: Vec, // per leak-flow: fixed per-DT linear-leak amount (set at insertion) - leak_budget: Vec, // per leak-flow: remaining linear-leak total (set at insertion) + leak_basis: Vec, // per leak-flow: volume leaked per in-zone DT per unit of leak + // fraction (= A / M_k(d), fixed at insertion); the CURRENT + // f_k(t) multiplies it each DT (§5.1) + leak_window: Vec, // per leak-flow: remaining leakable basis-volume + // (= basis × in-zone DTs still to travel; set at insertion) } ``` -`leak_alloc`/`leak_budget` carry each cohort's **linear** leak schedule, fixed -when the cohort enters ([§5.1](#51-linear-leakage)); exponential leakage needs no -per-cohort state (it reads current content). When cohorts merge into one slat -(a shortened transit time — [§6.2](#62-belt-growth-merging-and-non-fifo-exit)), -`content`, `leak_alloc`, and `leak_budget` are all **summed**, which is exact -because linear leak is linear in the entering amount. For a constant transit -time the deque has a fixed length `N`; a variable transit time grows/shrinks it. +`leak_basis`/`leak_window` carry each cohort's **linear** leak *schedule* — +what is fixed at entry is the transit-derived basis and travel window, while +the leak fraction itself is re-read every DT ([§5.1](#51-linear-leakage)); +exponential leakage needs no per-cohort state (it reads current content). When +cohorts merge into one slat (a shortened transit time — +[§6.2](#62-belt-growth-merging-and-non-fifo-exit)), `content`, `leak_basis`, +and `leak_window` are all **summed**, which is exact because both are linear in +the entering amount. For a constant transit time the deque has a fixed length +`N`; a variable transit time grows/shrinks it. The conveyor variable's **reported scalar value** is the sum of all slat contents (total material on the belt). This is simlin's defined semantics and is @@ -293,14 +298,21 @@ which is exactly isee's guidance. [§4.4](#44-runtime-expression-hygiene)). This happens before any belt mutation, so this step's insert (step 6) uses the newly latched depth. -2. **Leak.** For each leak flow `k` in outflow-list order, for each slat `i` in - `k`'s zone ([§5.3](#53-leak-zones)): compute `leak_{k,i}` per - [§5.1](#51-linear-leakage)/[§5.2](#52-exponential-leakage), clamp it to - `slats[i].content` (earlier flows have priority), subtract it from the slat - (and from `leak_budget[k]` for linear). Flow `k`'s reported **rate** = - `(Σ_i leak_{k,i}) / DT`, quantized per [§5.4](#54-integer-leakage) when - `` is set. Exception: if leak flow `k`'s destination is an - arrested conveyor, skip it entirely this step (rate 0, content stays). +2. **Leak.** Evaluate each leak flow's *current* fraction (fractions are + re-read every DT — [§5.1](#51-linear-leakage)). Then, per slat `i`: + - *Linear* conveyors: for each leak flow `k` in outflow-list order with slat + `i` in its zone ([§5.3](#53-leak-zones)), compute `leak_{k,i}` per + [§5.1](#51-linear-leakage), clamp it to the slat's **running** content + (earlier flows have priority — isee: later leakages "may get less"), + subtract it, and consume `leak_window[k]`. + - *Exponential* conveyors: compute every in-zone flow's `leak_{k,i}` from + the slat's content at the **start of this step** (rates add, + order-independent — [§5.2](#52-exponential-leakage)); if the sum exceeds + that content, scale all of them down proportionally; subtract. + Flow `k`'s reported **rate** = `(Σ_i leak_{k,i}) / DT`, quantized per + [§5.4](#54-integer-leakage) when `` is set. Exception: if + leak flow `k`'s destination is an arrested conveyor, skip it entirely this + step (rate 0, content stays). 3. **Exit.** If the primary outflow's destination is an arrested conveyor, the exit is *held*: outflow rate 0, and the exit slat's contents stay in place @@ -328,7 +340,7 @@ which is exactly isee's guidance. - `admitted = conv_vol + eq_admitted`. 5. **Shift.** If the exit was held (step 3), leave slat 0 in place and merge the - next slat into it (summing `content`/`leak_alloc`/`leak_budget`; with a + next slat into it (summing `content`/`leak_basis`/`leak_window`; with a single-slat belt there is nothing to merge and slat 0 simply stays); otherwise pop slat 0 (it left as outflow). Every remaining slat advances one position toward the exit. Drop trailing empty slats. @@ -340,7 +352,7 @@ which is exactly isee's guidance. ([§6](#6-variable-transit-time-sample-and-len)) using the placement method of [§8](#8-inflow-placement-spread-inputs) (default: all at depth `d`). Extend the belt with empty slats if `d` exceeds its current length. Compute the - cohort's linear-leak `leak_alloc`/`leak_budget` per + cohort's linear-leak `leak_basis`/`leak_window` per [§5.1](#51-linear-leakage) and **add** all three fields into the target slat(s) (summing with any cohort already there). @@ -384,10 +396,11 @@ determinism): unchanged. The *initial* latch (during initialization) with a non-finite or `≤ 0` value is a runtime initialization error. - **Leak fractions.** Linear fractions clamp to `[0, 1]`; exponential rates - clamp to `[0, ∞)`; NaN is treated as 0 (no leak). A linear fraction is - sampled **once, at the cohort's entry DT** (it is baked into - `leak_alloc`/`leak_budget`, [§5.1](#51-linear-leakage)); an exponential rate - is re-read every DT ([§5.2](#52-exponential-leakage)). + clamp to `[0, ∞)`; NaN is treated as 0 (no leak). All leak fractions are + **re-read every DT** (isee: "leak fractions can change over time and the + current values will be used") — what is fixed at a cohort's entry is its + transit-derived `leak_basis`/`leak_window` schedule, never the fraction + ([§5.1](#51-linear-leakage)). - **Inflow requests.** An equation-driven inflow request clamps to `max(0, rate)` (conveyor inflows are uniflows — [§3.4](#34-non-negativity)); a NaN request admits 0. @@ -407,11 +420,18 @@ explicitly): ### 5.1 Linear leakage `f ∈ [0, 1]` is the fraction of an entering cohort that leaks out by the time it -exits. It is removed as a **constant absolute amount per DT** while the cohort is -in the zone, and that amount is **fixed at the moment the cohort enters** — -matching isee's "the amount that is leaked is based on the transit time that was -used when the material was put on the conveyor" (`f` itself is likewise sampled -once, at the cohort's entry DT — [§4.4](#44-runtime-expression-hygiene)). +exits (when `f` is constant). Two things have different sampling times, and isee +documents both explicitly: + +- The **schedule** — which slats leak, over how many DTs, at what volume per DT + per unit of fraction — is **fixed at the moment the cohort enters**, derived + from the transit time then in force: "the amount that is leaked is based on + the transit time that was used when the material was put on the conveyor … + a transit time of 5 with a 50% leakage will leak 10% each time period." +- The **fraction itself is re-read every DT**: "Leak fractions can change over + time and the current values will be used when computing leakages." A model + whose attrition or mortality rate varies during the run leaks *all* cohorts + at the current rate, not at the rate in force when each cohort entered. Terminology (this resolves an ambiguity in the word "entry"): the **entry depth** `d = round(latched_transit / DT)` is where default-placement material is @@ -426,56 +446,90 @@ When a cohort of volume `A` is inserted, for each linear leak flow `k` (one formula covers default and spread placement): ``` -M_k(p) = count of in-zone slats among indices 0..p-1 // §5.3, per the belt after step-6 extension -alloc_k = f_k × A / M_k(d) // per-DT leak amount, fixed for the cohort's life -budget_k = alloc_k × min(M_k(d_c), M_k(d)) // lifetime total this cohort may leak to flow k +M_k(p) = count of in-zone slats among indices 0..p-1 // §5.3, per the belt after step-6 extension +basis_k = A / M_k(d) // volume per in-zone DT per unit of fraction; fixed at insertion +window_k = basis_k × min(M_k(d_c), M_k(d)) // remaining leakable basis-volume (travel window) ``` -(The `min` with `M_k(d)` matters only for a `dest` share landing on a -stale-tail slat beyond `d`: it caps that share's lifetime leak at the -documented `f_k × A` instead of letting the longer path over-schedule it. For -every other placement `d_c ≤ d` and the `min` is a no-op.) - -For default placement (`d_c = d`) the budget is exactly `f_k × A` — the full -documented fraction, leaked evenly over the cohort's own `d`-slat journey. This -holds **regardless of the physical belt length**: after a transit shrink a new -cohort still leaks `f_k × A` in total (the denominator is its own path, never -the stale-tail length). For a spread-input share inserted mid-belt (`d_c < d`) -the per-DT amount matches an entry cohort of the same size and the budget is -prorated to the zone slats it will actually traverse — matching isee's "linear -leak fractions will be applied only for the duration the material is in the -conveyor." If `M_k(d) = 0` (the zone is narrower than one slat at this DT -resolution), the cohort leaks nothing to flow `k`. - -Each DT, an in-zone slat leaks `min(leak_alloc[k], leak_budget[k], content)` to -flow `k` (step 2), decrementing the budget by the amount actually removed. Under -a constant transit time the budget never binds and the totals are exact -(`f_k × A` per entry cohort — scenario S3); the budget exists solely to bound a -cohort's lifetime leak when zone membership shifts under it (a partial zone -combined with a belt-length change, the one corner where in-zone DT counts can -drift). In that corner a cohort under-leaks deterministically, never over-leaks; -the residual is simlin-defined behavior (vendor docs are silent at this level of -detail). +Each in-zone DT (step 2), flow `k` leaks from the slat: + +``` +use = min(leak_basis[k], leak_window[k]) // basis consumed by this DT of in-zone travel +leak = min(f_k(now) × use, content) // CURRENT fraction × fixed basis +leak_window[k] -= use // consumed by travel, regardless of f_k(now) +``` + +(The `min` with `M_k(d)` in the window matters only for a `dest` share landing +on a stale-tail slat beyond `d`: it caps that share's travel window at an entry +cohort's. For every other placement `d_c ≤ d` and the `min` is a no-op.) + +For default placement (`d_c = d`) with a constant fraction, the lifetime total +is exactly `f_k × A` — the full documented fraction, leaked evenly over the +cohort's own `d`-slat journey. This holds **regardless of the physical belt +length**: after a transit shrink a new cohort still leaks `f_k × A` in total +(the denominator is its own path, never the stale-tail length). With a +time-varying fraction the lifetime total is `Σ over its in-zone DTs of +f_k(t) × basis` — the isee current-values rule; "fraction of the entering +cohort" is then only the constant-`f` reading. For a spread-input share +inserted mid-belt (`d_c < d`) the per-DT amount matches an entry cohort of the +same size and the window is prorated to the zone slats it will actually +traverse — matching isee's "linear leak fractions will be applied only for the +duration the material is in the conveyor." If `M_k(d) = 0` (the zone is +narrower than one slat at this DT resolution), the cohort leaks nothing to +flow `k`. + +Under a constant transit time the window never binds early and the totals are +exact (scenario S3); the window exists to bound a cohort's leakable travel when +zone membership shifts under it (a partial zone combined with a belt-length +change, the one corner where in-zone DT counts can drift). In that corner a +cohort under-leaks deterministically, never over-leaks; the residual is +simlin-defined behavior (vendor docs are silent at this level of detail). Constraint: `Σ_k f_k ≤ 1` across a conveyor's linear leak flows; at exactly 1 -the primary outflow is 0. The check is enforced at runtime by the content clamp -(step-2 priority ordering), and the compiler warns when constant leak fractions -sum above 1. +the primary outflow is 0 (isee: "with a leak fraction of 1, there will be no +outflow. If the sum of the leak fractions over multiple leakages is larger +than 1, the last, or later, leakages may get less than their leak fraction +suggests"). The check is enforced at runtime by the content clamp in step-2 +priority order — exactly the later-leakages-get-less behavior isee describes — +and the compiler warns when constant leak fractions sum above 1. + +**Staggered zones — a documented vendor divergence.** When linear leak flows +have *different* (non-identical) zones, isee's default interprets each `f` as a +fraction of the material **remaining at the start of that flow's zone** (its +"Ignore losses from earlier leak zones" toggle, unchecked by default: two 0.5 +leaks on zones [0, 0.5] and [0.5, 1] remove 75%, not 100%). OASIS XMILE §3.7.2 +instead defines `f` against the **inflowing** amount (the same two leaks remove +100%). simlin implements the XMILE reading — the formulas above are all +denominated in the entering volume `A` — which coincides with isee for +identical (e.g. full-belt) zones, the only configurations in the vendored +corpus. Staggered-zone linear models are a known Stella-parity delta +([§14](#14-validation-and-logistics)); an `isee:`-namespaced toggle can select +the zone-start-remaining interpretation later if a real model needs it. ### 5.2 Exponential leakage -`f` is a per-time-unit **rate**; each in-zone slat loses the same fraction of its -**current** content per DT: +`f` is a per-time-unit **rate**, re-read every DT like all leak fractions; each +in-zone slat loses that fraction of its content per DT. With multiple +exponential flows, **overlapping rates add** (isee: "If the leak zones overlap +the leakage fractions will be added" and "with exponential leakage all the +leakages are computed always"): every flow's leak is computed from the slat's +content at the **start of step 2** — the same base, never the running +remainder — making multi-flow exponential leakage order-independent: ``` -leak_{k,i} = slats[i].content × f_k × DT (slat i in flow k's zone) +leak_{k,i} = content₀_i × f_k(now) × DT (slat i in flow k's zone; + content₀ = slat content at step-2 start) ``` -Overlapping zones from multiple exponential flows compound (each applied in -step-2 order to the running content). Exponential leakage carries no per-cohort -state — it depends only on current content — so it is unaffected by transit-time -changes and by cohort merging. This matches the isee steady-state factor -`(1 − f×DT)` per slat. +Two 0.1/time leaks over the same zone therefore behave exactly like one +0.2/time leak (each reporting half), not the `1 − 0.9×0.9` sequential +compounding. If the summed leaks would exceed the slat's content +(`Σ_k leak_{k,i} > content₀_i`), all flows' leaks from that slat scale down +proportionally so exactly the content drains — never negative, and still +order-independent. Exponential leakage carries no per-cohort state — it depends +only on current content — so it is unaffected by transit-time changes and by +cohort merging. Steady state per slat is the factor `(1 − (Σ_k f_k)×DT)`, +matching the isee closed form for a single flow. ### 5.3 Leak zones @@ -498,8 +552,9 @@ With ``, 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_budget` decrements by each slat's *computed* (pre-quantization) leak, so -the carry redistributes timing without changing a cohort's lifetime total. +`leak_window` is consumed by in-zone travel ([§5.1](#51-linear-leakage)) +independent of the quantization, so the carry redistributes timing without +changing a cohort's schedule. ## 6. Variable transit time, ``, and `` @@ -521,8 +576,8 @@ documented non-FIFO behavior. The belt is not truncated; it shrinks naturally as empty tail slats fall off during shifts. When a new cohort's insertion depth lands on a slat that already holds material -(a shortened transit), the cohorts **merge**: `content`, `leak_alloc`, and -`leak_budget` are summed field-wise. Summation is exact for linear leakage +(a shortened transit), the cohorts **merge**: `content`, `leak_basis`, and +`leak_window` are summed field-wise. Summation is exact for linear leakage (both the per-DT amount and the lifetime budget are linear in the entering volume) and irrelevant for exponential leakage (stateless). Each cohort's leak schedule was fixed at its own entry ([§5.1](#51-linear-leakage)), so a later @@ -632,9 +687,11 @@ configuration, linear or exponential, any zones, any number of flows): 2. Let `S = Σ_i c[i]`. Set the cohort scale `E = V / S` (or `E = 0` if `S = 0`). 3. Initialize each slat `i` as a cohort of entering volume `E` that has already traveled to position `i`: `content = E × c[i]`, - `leak_alloc[k] = f_k × E / M_k`, and `leak_budget[k] = leak_alloc[k] ×` + `leak_basis[k] = E / M_k`, and `leak_window[k] = leak_basis[k] ×` (the number of flow-`k` in-zone slats from `i` to the exit, inclusive) — i.e. the unspent remainder of its schedule ([§5.1](#51-linear-leakage)). + The retained-profile simulation in step 1 evaluates each leak fraction at + its initial (t = start) value. Closed forms for the common cases (illustration; the algorithm above is authoritative): @@ -695,7 +752,7 @@ placement may lose admitted material: | `source` | Applies only when this inflow is **conveyor-driven from an upstream leak flow** (the "coupling" is flow identity: the inflow *is* that leak). For each upstream slat `j` that leaked `q_j` this step (Phase A), with `y_j` = that slat's fractional position from the entry side of the *upstream* belt, place `q_j` at the target slat `i ∈ 0..d−1` whose `x_i` is nearest `y_j` (ties toward the exit) — positions mirror proportionally when the two belts differ in length. If the inflow is not an upstream leak, fall back to `beginning`. | Each per-slat share `A_i` is a cohort in its own right: its linear-leak -`leak_alloc`/`leak_budget` are computed from its own volume `A_i` and its own +`leak_basis`/`leak_window` are computed from its own volume `A_i` and its own insertion depth `d_c = i + 1` per [§5.1](#51-linear-leakage) (so a mid-belt share's budget is prorated to the zone slats it will actually traverse), then summed into the target slat like any merge @@ -999,7 +1056,8 @@ Every check below **passes**. Run it with **Prototype coverage — what these scenarios do and do not verify.** Executed: the two-phase update including arrest and the held-exit merge (§4.3), -linear/exponential leakage with fixed per-cohort schedules (§5.1–§5.3), +linear/exponential leakage with entry-fixed schedules and per-DT re-read +fractions, including additive multi-flow exponential (§5.1–§5.3), capacity and inflow limits (§6.3), discrete quantized admission against a tight capacity with per-inflow attribution (§6.4 rule 1), transit latching/shrink/merging (§6.1–§6.2), steady-state initialization (§7.1), conveyor chains, and half-away rounding @@ -1026,10 +1084,12 @@ rate 250/time unit unless noted. | S6 | `in_limit = 150`/time (continuous), req inflow 250 | plateaus at 600 (`150·4`) | 150 | admitted inflow never exceeds 150; equilibrium contents = `in_limit·T` | | S7 | non-integer transit `T = 4.1` and half-case `T = 4.125` | — | — | `N(16.4) = 16` and `N(16.5) = 17` — half rounds **away from zero**, never banker's rounding; effective transit `N·DT`; compile Warning | | S8 | chain: conveyor A (`T = 2`, draining 500) feeds conveyor B (`T = 4`, `capacity = 100`) | — | — | conveyor-driven inflow is never blocked ([§4.3](#43-per-dt-update)): B's capacity is transiently exceeded, and total material across A + B + B's cumulative outflow is conserved exactly | -| S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_alloc`/`leak_budget` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget; **post-shrink steady outflow returns to `(1 − f)·inflow = 200`** — new entry cohorts leak the full fraction over their own path even while the stale tail keeps the physical belt longer than the entry depth ([§5.1](#51-linear-leakage)) | +| S9 | transit shrink `4 → 2` at `t = 2` with linear leak `f = 0.2` | — | — | cohorts merging into one slat sum their `content`/`leak_basis`/`leak_window` ([§6.2](#62-belt-growth-merging-and-non-fifo-exit)); whole-run conservation holds; lifetime leak never exceeds the `f`-budget; **post-shrink steady outflow returns to `(1 − f)·inflow = 200`** — new entry cohorts leak the full fraction over their own path even while the stale tail keeps the physical belt longer than the entry depth ([§5.1](#51-linear-leakage)) | | S10 | chain A (`T = 2`, draining 500) feeds B; B arrested for `t ∈ [1, 2)` | — | — | held exit ([§4.3](#43-per-dt-update) steps 3/5): during the hold A's outflow reports 0 and material accumulates in A's exit slat while B is frozen; on release the accumulated 250 exits A as one lump (rate 1000); chain conservation exact throughout | | S11 | discrete conveyor (`T = 2`, `capacity = 3`), fractional requests (0.6/DT) | — | — | quantized admission ([§6.4](#64-discrete-conveyors) rule 1): slat contents stay integral, a whole unit inserts only when `floor(cap_room)` fits it — so contents never exceed capacity even as the carry crosses 1.0 against fractional room — and material still flows (no deadlock) | | S12 | discrete conveyor, **two** equation-driven inflows (1.6 and 0.8/time; the second shuts off at `t = 8`) | — | — | per-inflow attribution ([§6.4](#64-discrete-conveyors) rule 1): the bookkeeping identity `cum_cleared_j = cum_reported_j + quant_carry[j]` holds for each inflow at every step, so every inserted unit debits exactly the upstream flow that cleared it; after shutoff an inflow reports at most its residual carry; both totals integral | +| S13 | linear leak with **time-varying fraction** `0.2 → 0.4` at `t = 4` | — | — | fractions are re-read every DT ([§5.1](#51-linear-leakage)): the leak rate doubles for **all** cohorts at the switch step (50 → 100), not only new entrants, and the outflow re-equilibrates at `(1 − 0.4)·250 = 150` | +| S14 | **two** exponential leaks, 0.1/time each, same zone | — | 110.031667 | overlapping exponential rates **add** ([§5.2](#52-exponential-leakage)): identical to one 0.2/time leak — steady outflow `250·(1 − 0.2·DT)^16`, each flow reporting exactly half — not the `1 − 0.9×0.9` sequential compounding | In addition to the per-scenario invariants, the harness asserts the [§4.3](#43-per-dt-update) **conservation identity** @@ -1043,7 +1103,9 @@ behavior of a conveyor. S3/S4 confirm the two leakage models produce the documented conservation (`1 − f` of a cohort survives for linear; the geometric `(1 − f·DT)^N` survival for exponential). S5/S6 confirm capacity and inflow limit throttle the admitted inflow and push unadmitted material back upstream, settling -at the `inflow·T` / `in_limit·T` equilibria. S8–S10 confirm the structural +at the `inflow·T` / `in_limit·T` equilibria. S13/S14 pin the isee +current-values rule (fractions re-read every DT against entry-fixed schedules) +and the additive overlapping-exponential rule. S8–S10 confirm the structural rules added in review: conveyor-driven flows are never blocked (so conveyor chains and cycles need no topological ordering), cohort merging under a shortened transit is exact summation with the full leak fraction preserved, diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index 01241a4d6..f447278f5 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -6,7 +6,8 @@ Not production code -- a faithful transcription of the spec's per-DT rules. Coverage: the two-phase update (4.3) including arrest and the held-exit merge, -linear/exponential leakage with fixed per-cohort schedules (5.1-5.3), capacity +linear/exponential leakage with entry-fixed schedules, per-DT re-read +fractions, and additive multi-flow exponential (5.1-5.3), capacity and inflow limits (6.3), discrete quantized admission vs capacity with per-inflow attribution (6.4 rule 1), transit latching/shrink/merging (6.1-6.2), steady-state init (7.1), conveyor chains, and half-away rounding (4.1). Not exercised here: leak zones narrower @@ -33,13 +34,15 @@ def check(label, ok): class Slat: # section 4.2: content plus each cohort's fixed linear-leak schedule content: float - leak_alloc: list # per leak flow: fixed per-DT leak amount - leak_budget: list # per leak flow: remaining lifetime leak total + leak_basis: list # per leak flow: volume per in-zone DT per unit of + # fraction (A / M_k(d), fixed at insertion) + leak_window: list # per leak flow: remaining leakable basis-volume @dataclass class LeakFlow: - fraction: float + fraction: object # float, or callable(t) -> float: fractions are re-read + # every DT (isee: "the current values will be used") zone_start: float = 0.0 zone_end: float = 1.0 @@ -79,6 +82,10 @@ def zone_count_from(self, lk, length, depth): # in-zone slats between insertion depth (index depth-1) and exit, inclusive return sum(1 for i in range(depth) if self.in_zone(i, length, lk)) + def f_now(self, k, t): + fr = self.leaks[k].fraction + return fr(t) if callable(fr) else fr + def contents(self): return sum(s.content for s in self.slats) @@ -90,20 +97,20 @@ def cohort_schedule(self, volume, belt_len, entry_depth, own_depth): # path d_c). For default placement own_depth == entry_depth, so the # budget is exactly f*A -- the full documented fraction over the # cohort's own journey, regardless of any stale tail (belt_len > d). - alloc, budget = [], [] + basis, window = [], [] for lk in self.leaks: if self.exponential_leak: - alloc.append(0.0) - budget.append(0.0) + basis.append(0.0) + window.append(0.0) continue m_entry = self.zone_count_from(lk, belt_len, entry_depth) - a = lk.fraction * volume / m_entry if m_entry else 0.0 - alloc.append(a) + b = volume / m_entry if m_entry else 0.0 + basis.append(b) # min with m_entry: caps a dest share landing on a stale-tail slat - # beyond the entry depth at the documented f*A (no-op otherwise) + # beyond the entry depth at an entry cohort's window (no-op otherwise) m_own = self.zone_count_from(lk, belt_len, own_depth) - budget.append(a * min(m_own, m_entry)) - return alloc, budget + window.append(b * min(m_own, m_entry)) + return basis, window # ---- initialization (section 7.1) ---- def init_steady(self, V): @@ -111,26 +118,29 @@ def init_steady(self, V): N = self.n_slats() c = [0.0] * N c[N - 1] = 1.0 - unit_alloc = [] + unit_basis = [] for lk in self.leaks: m = self.zone_count(lk, N) - unit_alloc.append(0.0 if self.exponential_leak or m == 0 - else lk.fraction / m) + unit_basis.append(0.0 if self.exponential_leak or m == 0 + else 1.0 / m) + # retained profile evaluates each fraction at its initial value (t=0); + # exponential sheds are ADDITIVE from the same c[i] base (section 5.2) for i in range(N - 1, 0, -1): shed = 0.0 for k, lk in enumerate(self.leaks): if self.in_zone(i, N, lk): - shed += (c[i] * lk.fraction * self.dt if self.exponential_leak - else unit_alloc[k]) + shed += (c[i] * self.f_now(k, 0.0) * self.dt + if self.exponential_leak + else self.f_now(k, 0.0) * unit_basis[k]) c[i - 1] = max(0.0, c[i] - shed) S = sum(c) E = V / S if S > 0 else 0.0 self.slats = [] for i in range(N): - alloc = [E * ua for ua in unit_alloc] - budget = [alloc[k] * self.zone_count_from(lk, N, i + 1) + basis = [E * ub for ub in unit_basis] + window = [basis[k] * self.zone_count_from(lk, N, i + 1) for k, lk in enumerate(self.leaks)] - self.slats.append(Slat(E * c[i], alloc, budget)) + self.slats.append(Slat(E * c[i], basis, window)) def init_from_inflow(self, inflow_rate): self.init_steady(1.0) @@ -139,34 +149,47 @@ def init_from_inflow(self, inflow_rate): scale = E / self.slats[-1].content if self.slats[-1].content else 0.0 for s in self.slats: s.content *= scale - s.leak_alloc = [a * scale for a in s.leak_alloc] - s.leak_budget = [b * scale for b in s.leak_budget] + s.leak_basis = [a * scale for a in s.leak_basis] + s.leak_window = [b * scale for b in s.leak_window] # ---- phase A (section 4.3 steps 0-3): arrest, latch, leak, exit -- # purely local (the harness passes arrest flags in; latching is modeled # by mutating latched_transit before the step, equivalent to step 1) - def phase_a(self, arrested=False, dest_arrested=False): + def phase_a(self, arrested=False, dest_arrested=False, t=0.0): if arrested: return dict(out_vol=0.0, leak_vols=[0.0] * len(self.leaks), arrested=True, held=False) L = len(self.slats) - leak_vols = [] - for k, lk in enumerate(self.leaks): - shed_total = 0.0 - for i in range(L): - if not self.in_zone(i, L, lk): - continue - s = self.slats[i] - if self.exponential_leak: - shed = s.content * lk.fraction * self.dt - else: - shed = min(s.leak_alloc[k], s.leak_budget[k]) - shed = min(shed, s.content) - s.content -= shed - if not self.exponential_leak: - s.leak_budget[k] -= shed - shed_total += shed - leak_vols.append(shed_total) + fs = [self.f_now(k, t) for k in range(len(self.leaks))] + leak_vols = [0.0] * len(self.leaks) + for i in range(L): + s = self.slats[i] + in_z = [self.in_zone(i, L, lk) for lk in self.leaks] + if self.exponential_leak: + # section 5.2: rates ADD -- every flow computed from the same + # start-of-step base, scaled proportionally if they overdrain + c0 = s.content + sheds = [c0 * fs[k] * self.dt if in_z[k] else 0.0 + for k in range(len(self.leaks))] + tot = sum(sheds) + if tot > c0 > 0.0: + sheds = [x * (c0 / tot) for x in sheds] + elif c0 <= 0.0: + sheds = [0.0] * len(sheds) + s.content -= sum(sheds) + for k, x in enumerate(sheds): + leak_vols[k] += x + else: + # section 5.1: current fraction x fixed basis, in priority + # order against running content; window consumed by travel + for k in range(len(self.leaks)): + if not in_z[k]: + continue + use = min(s.leak_basis[k], s.leak_window[k]) + shed = min(fs[k] * use, s.content) + s.content -= shed + s.leak_window[k] -= use + leak_vols[k] += shed # step 3: held exit if the primary outflow's destination is arrested if dest_arrested: return dict(out_vol=0.0, leak_vols=leak_vols, arrested=False, @@ -229,8 +252,8 @@ def phase_b(self, pa, eq_request_rates, conv_vol, contents0, t): if len(self.slats) > 1: s0, s1 = self.slats[0], self.slats[1] s0.content += s1.content - s0.leak_alloc = [x + y for x, y in zip(s0.leak_alloc, s1.leak_alloc)] - s0.leak_budget = [x + y for x, y in zip(s0.leak_budget, s1.leak_budget)] + s0.leak_basis = [x + y for x, y in zip(s0.leak_basis, s1.leak_basis)] + s0.leak_window = [x + y for x, y in zip(s0.leak_window, s1.leak_window)] del self.slats[1] else: self.slats.pop(0) @@ -245,8 +268,8 @@ def phase_b(self, pa, eq_request_rates, conv_vol, contents0, t): alloc, budget = self.cohort_schedule(admitted, len(self.slats), d, d) tgt = self.slats[d - 1] tgt.content += admitted - tgt.leak_alloc = [x + y for x, y in zip(tgt.leak_alloc, alloc)] - tgt.leak_budget = [x + y for x, y in zip(tgt.leak_budget, budget)] + tgt.leak_basis = [x + y for x, y in zip(tgt.leak_basis, alloc)] + tgt.leak_window = [x + y for x, y in zip(tgt.leak_window, budget)] return dict(in_rate=admitted / dt, in_rates=[v / dt for v in in_vols], cleared=cleared) @@ -264,7 +287,7 @@ def step_all(convs, eq_inflow_rates, chain=None, t=0.0, arrested=None): feeds_arrested = {chain[down] for down in chain if down in arrested} contents0 = {c.name: c.contents() for c in convs} pa = {c.name: c.phase_a(arrested=c.name in arrested, - dest_arrested=c.name in feeds_arrested) + dest_arrested=c.name in feeds_arrested, t=t) for c in convs} # phase A: any order results = {} for c in convs: # phase B: any order @@ -522,6 +545,46 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): check("S12 both inflows delivered integral totals", all(abs(v - round(v)) < 1e-9 for v in cum_reported)) +# S13: time-varying LINEAR leak fraction (isee: current values are used). +# Steady at f=0.2; f jumps to 0.4 at t=4. ALL cohorts leak at the new rate +# immediately (leak rate doubles at the switch step), and the outflow +# re-equilibrates at (1-0.4)*250 = 150 once the belt turns over. +print("\n=== S13 linear leak with time-varying fraction 0.2 -> 0.4 at t=4 ===") +c13 = Conveyor('c13', transit=4, dt=0.25, + leaks=[LeakFlow(lambda t: 0.2 if t < 4 else 0.4)]) +c13.init_from_inflow(250) +switch_leak = None +last_out13 = None +t = 0.0 +for _ in range(64): + r = step_all([c13], {'c13': 250.0}, t=t)['c13'] + if abs(t - 4.0) < 1e-9: + switch_leak = r['leak'][0] + last_out13 = r['outflow'] + t += 0.25 +check("S13 leak rate doubles for ALL cohorts at the switch (50 -> 100)", + switch_leak is not None and abs(switch_leak - 100.0) < 1e-6) +check("S13 outflow re-equilibrates at (1-0.4)*250 == 150", + abs(last_out13 - 150.0) < 1e-6) + +# S14: two overlapping exponential leaks ADD (isee): two 0.1/time leaks behave +# exactly like one 0.2/time leak, each reporting half -- NOT the sequential +# (1-0.9*0.9) compounding. +print("\n=== S14 two exponential leaks 0.1/time each == one 0.2/time ===") +c14 = Conveyor('c14', transit=4, dt=0.25, exponential_leak=True, + leaks=[LeakFlow(0.1), LeakFlow(0.1)]) +c14.init_from_inflow(250) +t = 0.0 +r14 = None +for _ in range(32): + r14 = step_all([c14], {'c14': 250.0}, t=t)['c14'] + t += 0.25 +expect14 = 250 * (1 - 0.2 * 0.25) ** 16 +check(f"S14 steady outflow == 250*(1-0.2*dt)^16 == {round(expect14, 6)} (rates add)", + abs(r14['outflow'] - expect14) < 1e-4) +check("S14 the two leak flows report equal halves", + abs(r14['leak'][0] - r14['leak'][1]) < 1e-9) + print() if FAILURES: print(f"{len(FAILURES)} CHECK(S) FAILED:") From b92ec86a3ea560f3778ad9a35f864090f0141fbe Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 13:36:14 -0700 Subject: [PATCH 10/11] doc: Stella behavior wins over XMILE spec; staggered zones follow isee Adopt the maintainer's precedence rule as a load-bearing part of the spec: where OASIS XMILE prose and documented or observed Stella behavior conflict, simlin follows Stella. Conveyors exist in exactly one implementation that real models were built against; the spec is a description of it, and the description is far more likely to be imperfect than any independent implementation of the description is to match Stella. Applying the rule flips three earlier spec-over-Stella choices. Staggered linear leak zones now use isee's default basis -- each fraction applies to the material REMAINING at its zone start, encoded as a projected retained factor r_k in the cohort schedule (merge-additivity preserved; r_k = 1 for identical/full zones, so every constant-f trajectory is unchanged). isee's "Ignore losses from earlier leak zones" toggle (r_k = 1, which is also the XMILE inflowing-amount reading) rides a new ignore_earlier_zone_losses field. S15 executes isee's own worked example: two 0.5 leaks on [0,.5] and [.5,1] remove 75 percent, flows reporting 125 and 62.5 -- moving staggered/partial zones from prose-only to machine-verified; 29/29 checks pass. The writer rules flip from spec-strict to preserve-as-read (placeholder and on conveyor-driven flows round-trip; simlin never invents them), and the capacity formula is re-framed as implementing isee's "total outflow volume" (leakage flows are outflows) rather than extending it. The cross-engine section now states that a Stella disagreement is a spec bug to fix, not a delta to document, and lists the interpretation points to probe first. --- docs/design/conveyors.md | 118 +++++++++++++++++--------- test/conveyors/reference_prototype.py | 78 ++++++++++++++--- 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 36c8a3160..6a9e86bf5 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -14,6 +14,15 @@ use `grep -a`) for syntax and prose semantics, and isee systems' "Computational Details" help pages for the per-DT math. The isee "traditional conveyor" model is the reference behavior simlin implements. +**Precedence rule: Stella wins.** Where the OASIS XMILE prose and documented or +observed Stella/isee behavior conflict, simlin follows **Stella**. Conveyors +exist in exactly one implementation that real models were built against; the +XMILE spec is a description of that implementation, and it is far more likely +the description is imperfect than that anyone has faithfully implemented the +description against Stella's behavior. Where Stella's behavior is unknown +(neither documented nor observable), simlin defines deterministic behavior and +flags it for cross-engine confirmation ([§14](#14-validation-and-logistics)). + ## 1. Motivation Conveyors are a first-class stock type in Stella / isee systems models, used for @@ -141,14 +150,14 @@ be the normal outflow). XMILE says a conveyor outflow MUST NOT carry a normal equation — the conveyor drives it — but real Stella exports put a **placeholder** `0` on primary conveyor outflows anyway (both vendored `.stmx` fixtures do, e.g. -`recovering` in `sir_social_distancing_mixnot.stmx`). The reader therefore -**preserves but ignores** any `` on a primary conveyor outflow: it is kept -for round-trip fidelity and plays no role in simulation — never an error. The -writer emits the spec-strict form (no `` on a primary outflow), consistent -with the `` writer rule in [§3.4](#34-non-negativity). On a -*leak-marked* flow the `` is meaningful — it carries the leak fraction. -Real Stella models put the leak **fraction** in the `` of a -``-tagged flow: +`recovering` in `sir_social_distancing_mixnot.stmx`). Per the precedence rule, +Stella's practice governs: the reader **preserves but ignores** any `` on +a primary conveyor outflow (it plays no role in simulation — never an error), +and the writer **re-emits a preserved placeholder** so a round-tripped Stella +file keeps its shape; the writer never *invents* a placeholder for a conveyor +authored in simlin. On a *leak-marked* flow the `` is meaningful — it +carries the leak fraction. Real Stella models put the leak **fraction** in the +`` of a ``-tagged flow: ```xml @@ -179,11 +188,12 @@ until a fraction is supplied. Conveyor and queue **inflows** are non-negative by requirement (uniflow); the primary conveyor outflow is non-negative by definition. `` is -redundant on those flows but Stella emits it on leak flows, so the reader accepts -it there without error and ignores it on the primary inflow/outflow. The writer -MUST NOT emit `` on a primary conveyor outflow (XMILE §4.3: "this -property MUST NOT appear for them"); it MAY emit it on leak flows, matching -Stella. +redundant on those flows, but Stella emits it on primary conveyor outflows and +leak flows alike (verified in the vendored fixtures), despite XMILE §4.3's +"this property MUST NOT appear for them". Per the precedence rule the reader +accepts it anywhere without error (semantically inert on conveyor-driven +flows), and the writer **preserves it as read** — round-tripped Stella files +keep their shape; simlin-authored conveyors don't gain it. ### 3.5 isee spread-input attributes @@ -419,9 +429,10 @@ explicitly): ### 5.1 Linear leakage -`f ∈ [0, 1]` is the fraction of an entering cohort that leaks out by the time it -exits (when `f` is constant). Two things have different sampling times, and isee -documents both explicitly: +`f ∈ [0, 1]` is the fraction that leaks out by the time a cohort exits — of the +material **reaching the flow's zone** (isee's default; for a zone starting at +the entry, that is simply the entering cohort — see "Staggered zones" below). +Two things have different sampling times, and isee documents both explicitly: - The **schedule** — which slats leak, over how many DTs, at what volume per DT per unit of fraction — is **fixed at the moment the cohort enters**, derived @@ -447,7 +458,12 @@ When a cohort of volume `A` is inserted, for each linear leak flow `k` ``` M_k(p) = count of in-zone slats among indices 0..p-1 // §5.3, per the belt after step-6 extension -basis_k = A / M_k(d) // volume per in-zone DT per unit of fraction; fixed at insertion +r_k = projected fraction of the cohort still remaining when it reaches + flow k's zone start (unit forward simulation over the entry path + using the CURRENT fractions -- the §7.1 retained-profile machinery; + r_k = 1 when flow k's zone starts at the entry, so for identical or + full-belt zones this whole term vanishes) +basis_k = (A × r_k) / M_k(d) // volume per in-zone DT per unit of fraction; fixed at insertion window_k = basis_k × min(M_k(d_c), M_k(d)) // remaining leakable basis-volume (travel window) ``` @@ -493,18 +509,26 @@ suggests"). The check is enforced at runtime by the content clamp in step-2 priority order — exactly the later-leakages-get-less behavior isee describes — and the compiler warns when constant leak fractions sum above 1. -**Staggered zones — a documented vendor divergence.** When linear leak flows -have *different* (non-identical) zones, isee's default interprets each `f` as a -fraction of the material **remaining at the start of that flow's zone** (its -"Ignore losses from earlier leak zones" toggle, unchecked by default: two 0.5 -leaks on zones [0, 0.5] and [0.5, 1] remove 75%, not 100%). OASIS XMILE §3.7.2 -instead defines `f` against the **inflowing** amount (the same two leaks remove -100%). simlin implements the XMILE reading — the formulas above are all -denominated in the entering volume `A` — which coincides with isee for -identical (e.g. full-belt) zones, the only configurations in the vendored -corpus. Staggered-zone linear models are a known Stella-parity delta -([§14](#14-validation-and-logistics)); an `isee:`-namespaced toggle can select -the zone-start-remaining interpretation later if a real model needs it. +**Staggered zones.** When linear leak flows have *different* (non-identical) +zones, isee's default interprets each `f` as a fraction of the material +**remaining at the start of that flow's zone**: two 0.5 leaks on zones +[0, 0.5] and [0.5, 1] remove 75% (the second 0.5 applies to the surviving +half), not 100%. simlin implements this isee default — that is what the `r_k` +factor above encodes: `r_2 = 0.5` for the second flow in the example, so +`basis_2 = 0.5A/M_2` and its lifetime total is `0.25A`. (OASIS XMILE §3.7.2 +instead defines `f` against the inflowing amount; per this spec's precedence +rule, Stella's behavior wins.) `r_k` is *projected at insertion* from the +current fractions rather than measured when the cohort arrives at the zone — +this keeps the schedule merge-additive and start-of-run exact; under +time-varying fractions the projection can drift from the arrival-time +remainder, a simlin-defined corner (isee is silent on staggered zones combined +with time-varying fractions). isee's **"Ignore losses from earlier leak +zones"** toggle selects the other interpretation: when the conveyor sets +`ignore_earlier_zone_losses` ([§9.1](#91-data-model)), `r_k = 1` for every +flow (each `f` applies to the inflowing amount — the same two leaks then +remove 100%, and the XMILE prose becomes exact). For identical or full-belt +zones — every model in the vendored corpus — the two interpretations coincide +(`r_k = 1` always). ### 5.2 Exponential leakage @@ -595,12 +619,13 @@ available if the inflow comes from another conveyor"). - **Capacity** bounds instantaneous contents: `cap_room = capacity − contents_after − conv_vol` (step 4), where `contents_after` credits the room freed by this DT's outflow **and leak**, and `conv_vol` is the - unconditionally-admitted conveyor-driven inflow. This *extends* isee's - documented `(Capacity − Conveyor)/DT + outflow` formula, which credits only - the outflow: simlin also credits leak (the room genuinely exists by the end - of the step). Models combining capacity limits with leakage may therefore - admit slightly more per DT than Stella — a known, deliberate delta to check - in the cross-engine confirmation ([§14](#14-validation-and-logistics)). + unconditionally-admitted conveyor-driven inflow. This implements isee's + documented `MIN((Capacity − Conveyor)/DT + total outflow volume, inflow)` + formula, reading "total outflow volume" as the sum of everything leaving the + conveyor this DT — leakage flows *are* outflows of a conveyor. If Stella's + "total outflow" turns out to mean the primary outflow only, capacity+leak + models would admit slightly less there; the interpretation is flagged for + cross-engine confirmation ([§14](#14-validation-and-logistics)). - **Inflow limit** bounds equation-driven inflow per **time unit**: - *Continuous conveyor:* `limit_vol = in_limit × DT` (the per-time-unit limit prorated to this DT). @@ -778,6 +803,11 @@ pub struct Conveyor { pub batch_integrity: bool, pub one_at_a_time: bool, // default true pub exponential_leak: bool, + pub ignore_earlier_zone_losses: bool, // isee "Ignore losses from earlier leak + // zones" toggle (default false -- §5.1 + // staggered zones); Stella persists it as + // an isee: vendor attribute, mapped by the + // reader when observed in real files } pub struct Leakage { pub fraction: Option, // expr, None for a bare marker @@ -1037,11 +1067,15 @@ rules (integer/zoned leakage, discrete quantization, spread placements, explicit-list init) are specified in prose and get fixtures with the Rust implementation. Two items are logistics, not spec gaps: -- **Cross-engine confirmation.** The prototype pins simlin's own numerics; a - Stella (or other conforming-engine) run of the vendored fixtures would confirm - the derived formulas in §5/§7 match the vendor bit-for-bit. Not required to - implement — the §15 trajectories are sufficient to build and test against — - but a good confidence check when Stella access is available. +- **Cross-engine confirmation.** Stella is the ground truth this spec targets + (the precedence rule in the preamble). The prototype pins simlin's own + numerics; a Stella run of the vendored fixtures is the authoritative check + and **overrides this spec wherever they disagree** — flagged interpretation + points to probe first: the §6.3 "total outflow volume" reading, the §4.1 + non-integer-transit rounding, the §4.2 stale-tail corners, and staggered + zones under time-varying fractions (§5.1). Not required to start + implementing — the §15 trajectories are sufficient to build against — but + any Stella disagreement is a spec bug to fix, not a delta to document. - **Diagram/editor authoring.** Rendering and authoring conveyor stocks and leak flows in the diagram editor is scoped with the TypeScript surface work in step 1/4 of [§12](#12-build-sequence); it does not affect the engine spec. @@ -1057,7 +1091,8 @@ Every check below **passes**. Run it with **Prototype coverage — what these scenarios do and do not verify.** Executed: the two-phase update including arrest and the held-exit merge (§4.3), linear/exponential leakage with entry-fixed schedules and per-DT re-read -fractions, including additive multi-flow exponential (§5.1–§5.3), +fractions, additive multi-flow exponential, and staggered zones with the +zone-start-remaining basis (§5.1–§5.3), capacity and inflow limits (§6.3), discrete quantized admission against a tight capacity with per-inflow attribution (§6.4 rule 1), transit latching/shrink/merging (§6.1–§6.2), steady-state initialization (§7.1), conveyor chains, and half-away rounding @@ -1090,6 +1125,7 @@ rate 250/time unit unless noted. | S12 | discrete conveyor, **two** equation-driven inflows (1.6 and 0.8/time; the second shuts off at `t = 8`) | — | — | per-inflow attribution ([§6.4](#64-discrete-conveyors) rule 1): the bookkeeping identity `cum_cleared_j = cum_reported_j + quant_carry[j]` holds for each inflow at every step, so every inserted unit debits exactly the upstream flow that cleared it; after shutoff an inflow reports at most its residual carry; both totals integral | | S13 | linear leak with **time-varying fraction** `0.2 → 0.4` at `t = 4` | — | — | fractions are re-read every DT ([§5.1](#51-linear-leakage)): the leak rate doubles for **all** cohorts at the switch step (50 → 100), not only new entrants, and the outflow re-equilibrates at `(1 − 0.4)·250 = 150` | | S14 | **two** exponential leaks, 0.1/time each, same zone | — | 110.031667 | overlapping exponential rates **add** ([§5.2](#52-exponential-leakage)): identical to one 0.2/time leak — steady outflow `250·(1 − 0.2·DT)^16`, each flow reporting exactly half — not the `1 − 0.9×0.9` sequential compounding | +| S15 | **staggered** linear zones: `f = 0.5` on `[0, 0.5]` plus `f = 0.5` on `[0.5, 1]` | — | 62.5 | isee's own worked example ([§5.1](#51-linear-leakage) staggered zones): the second fraction applies to the material **remaining at its zone start**, so 75% leaks (flows report 125 and 62.5) and 25% flows out — not the 100% an inflowing-amount basis would remove | In addition to the per-scenario invariants, the harness asserts the [§4.3](#43-per-dt-update) **conservation identity** diff --git a/test/conveyors/reference_prototype.py b/test/conveyors/reference_prototype.py index f447278f5..ee11a4ec3 100644 --- a/test/conveyors/reference_prototype.py +++ b/test/conveyors/reference_prototype.py @@ -7,12 +7,12 @@ Not production code -- a faithful transcription of the spec's per-DT rules. Coverage: the two-phase update (4.3) including arrest and the held-exit merge, linear/exponential leakage with entry-fixed schedules, per-DT re-read -fractions, and additive multi-flow exponential (5.1-5.3), capacity +fractions, additive multi-flow exponential, and staggered zones with the +zone-start-remaining basis (5.1-5.3), capacity and inflow limits (6.3), discrete quantized admission vs capacity with per-inflow attribution (6.4 rule 1), transit latching/shrink/merging (6.1-6.2), steady-state init (7.1), conveyor -chains, and half-away rounding (4.1). Not exercised here: leak zones narrower -than the belt, , spread inputs, explicit-list init, leak-fed -chains ('source' placement) -- their rules are specified in the doc and get +chains, and half-away rounding (4.1). Not exercised here: , spread inputs, explicit-list init, +leak-fed chains ('source' placement), the ignore_earlier_zone_losses toggle -- their rules are specified in the doc and get fixtures with the Rust implementation. Run: python3 test/conveyors/reference_prototype.py (exits nonzero on failure) """ @@ -92,19 +92,50 @@ def contents(self): def empty_slat(self): return Slat(0.0, [0.0] * len(self.leaks), [0.0] * len(self.leaks)) - def cohort_schedule(self, volume, belt_len, entry_depth, own_depth): - # section 5.1: alloc = f*A / M(entry path d); budget = alloc * M(own - # path d_c). For default placement own_depth == entry_depth, so the - # budget is exactly f*A -- the full documented fraction over the - # cohort's own journey, regardless of any stale tail (belt_len > d). + def zone_start_retained(self, belt_len, entry_depth, t): + # section 5.1 r_k: projected fraction of a unit cohort remaining when + # it reaches each flow's zone start (isee default: staggered-zone + # fractions apply to the material remaining at zone start). Unit + # forward simulation over the entry path with the CURRENT fractions; + # r_k = 1 when ignore_earlier_zone_losses or for entry-start zones. + n = len(self.leaks) + if self.exponential_leak or getattr(self, 'ignore_earlier_zone_losses', False): + return [1.0] * n + m_entry = [self.zone_count_from(lk, belt_len, entry_depth) + for lk in self.leaks] + first_zone_slat = [None] * n # deepest (first-traversed) in-zone slat + for k, lk in enumerate(self.leaks): + for i in range(entry_depth - 1, -1, -1): + if self.in_zone(i, belt_len, lk): + first_zone_slat[k] = i + break + r = [1.0] * n + c = 1.0 + for i in range(entry_depth - 1, -1, -1): # travel entry -> exit + for k, lk in enumerate(self.leaks): + if first_zone_slat[k] == i: + r[k] = c + shed = 0.0 + for k, lk in enumerate(self.leaks): + if self.in_zone(i, belt_len, lk) and m_entry[k]: + shed += self.f_now(k, t) * r[k] / m_entry[k] + c = max(0.0, c - shed) + return r + + def cohort_schedule(self, volume, belt_len, entry_depth, own_depth, t=0.0): + # section 5.1: basis = A*r_k / M(entry path d); window = basis * M(own + # path d_c). For default placement own_depth == entry_depth and full + # zones, the lifetime total is exactly f*A over the cohort's own + # journey, regardless of any stale tail (belt_len > d). basis, window = [], [] - for lk in self.leaks: + r = self.zone_start_retained(belt_len, entry_depth, t) + for k, lk in enumerate(self.leaks): if self.exponential_leak: basis.append(0.0) window.append(0.0) continue m_entry = self.zone_count_from(lk, belt_len, entry_depth) - b = volume / m_entry if m_entry else 0.0 + b = volume * r[k] / m_entry if m_entry else 0.0 basis.append(b) # min with m_entry: caps a dest share landing on a stale-tail slat # beyond the entry depth at an entry cohort's window (no-op otherwise) @@ -118,11 +149,12 @@ def init_steady(self, V): N = self.n_slats() c = [0.0] * N c[N - 1] = 1.0 + r0 = self.zone_start_retained(N, N, 0.0) unit_basis = [] - for lk in self.leaks: + for k, lk in enumerate(self.leaks): m = self.zone_count(lk, N) unit_basis.append(0.0 if self.exponential_leak or m == 0 - else 1.0 / m) + else r0[k] / m) # retained profile evaluates each fraction at its initial value (t=0); # exponential sheds are ADDITIVE from the same c[i] base (section 5.2) for i in range(N - 1, 0, -1): @@ -265,7 +297,7 @@ def phase_b(self, pa, eq_request_rates, conv_vol, contents0, t): d = self.n_slats() while len(self.slats) < d: self.slats.append(self.empty_slat()) - alloc, budget = self.cohort_schedule(admitted, len(self.slats), d, d) + alloc, budget = self.cohort_schedule(admitted, len(self.slats), d, d, t) tgt = self.slats[d - 1] tgt.content += admitted tgt.leak_basis = [x + y for x, y in zip(tgt.leak_basis, alloc)] @@ -585,6 +617,24 @@ def run(name, conv, V, inflow_fn, stop, init_inflow=None): check("S14 the two leak flows report equal halves", abs(r14['leak'][0] - r14['leak'][1]) < 1e-9) +# S15: STAGGERED linear leak zones -- isee's own worked example. Two 0.5 +# leaks, zones [0, 0.5] (entry half) and [0.5, 1] (exit half): the second +# fraction applies to the material REMAINING at its zone start, so 75% of the +# inflow leaks and 25% flows out (NOT 100% as an inflow-basis reading gives). +print("\n=== S15 staggered zones: 0.5 on [0,.5] + 0.5 on [.5,1] -> 75% leaked ===") +c15 = Conveyor('c15', transit=4, dt=0.25, + leaks=[LeakFlow(0.5, 0.0, 0.5), LeakFlow(0.5, 0.5, 1.0)]) +c15.init_from_inflow(250) +r15 = None +t = 0.0 +for _ in range(48): + r15 = step_all([c15], {'c15': 250.0}, t=t)['c15'] + t += 0.25 +check("S15 steady outflow == 0.25 x inflow == 62.5 (zone-start-remaining basis)", + abs(r15['outflow'] - 62.5) < 1e-6) +check("S15 first leak reports 125 (0.5 x inflow), second 62.5 (0.5 x remaining)", + abs(r15['leak'][0] - 125.0) < 1e-6 and abs(r15['leak'][1] - 62.5) < 1e-6) + print() if FAILURES: print(f"{len(FAILURES)} CHECK(S) FAILED:") From b8329c131040e609c8df6c0d4b603654eaea92ac Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 6 Jul 2026 14:22:30 -0700 Subject: [PATCH 11/11] doc: clamp discrete in_limit remainder; carry r_k through init Address the seventh round of Codex review: two spots where the doc text lagged the reference prototype (which already implemented both correctly), i.e. exactly the divergence an implementer following the prose would ship. The discrete inflow-limit remainder is now max(0, in_limit - in_carry): with a time-varying in_limit dropping below the budget already spent this window, the unclamped form went negative and min(req, cap, limit) admitted a negative volume, violating the uniflow rule. The 7.1 initialization step now states that each slat carries exactly the 5.1 schedule of an entry cohort -- including the r_k zone-start-remaining factor introduced for staggered zones -- rather than the bare E / M_k basis, which for an S15-shaped model would have initialized later-zone flows against the original entry amount instead of the surviving material, starting the belt off its documented steady state. --- docs/design/conveyors.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/design/conveyors.md b/docs/design/conveyors.md index 6a9e86bf5..4b504589f 100644 --- a/docs/design/conveyors.md +++ b/docs/design/conveyors.md @@ -632,7 +632,10 @@ available if the inflow comes from another conveyor"). - *Discrete conveyor:* the full per-time-unit budget may enter within a single DT. `in_carry` tracks volume admitted since the last integer time boundary and resets to 0 when the simulation clock crosses an integer time unit; - `limit_vol = in_limit − in_carry`. + `limit_vol = max(0, in_limit − in_carry)` — the clamp matters when a + time-varying `in_limit` drops below the budget already spent this window, + which would otherwise make `limit_vol` negative and admit a negative + volume in violation of the uniflow rule ([§3.4](#34-non-negativity)). Equation-driven admitted inflow is `min(req_vol, cap_room, limit_vol)`, apportioned in inflow order (step 4). Blocked material stays upstream. @@ -711,12 +714,16 @@ configuration, linear or exponential, any zones, any number of flows): compiles with only a warning ([§5.1](#51-linear-leakage)). 2. Let `S = Σ_i c[i]`. Set the cohort scale `E = V / S` (or `E = 0` if `S = 0`). 3. Initialize each slat `i` as a cohort of entering volume `E` that has already - traveled to position `i`: `content = E × c[i]`, - `leak_basis[k] = E / M_k`, and `leak_window[k] = leak_basis[k] ×` - (the number of flow-`k` in-zone slats from `i` to the exit, inclusive) — - i.e. the unspent remainder of its schedule ([§5.1](#51-linear-leakage)). - The retained-profile simulation in step 1 evaluates each leak fraction at - its initial (t = start) value. + traveled to position `i`, carrying **exactly the [§5.1](#51-linear-leakage) + schedule** of an entry cohort (fractions and `r_k` evaluated at their + initial, t = start values): `content = E × c[i]`, + `leak_basis[k] = E × r_k / M_k(N)` — the `r_k` zone-start-remaining factor + included, so a staggered later-zone flow (S15) leaks against the surviving + material, not the original entry amount — and `leak_window[k] = + leak_basis[k] ×` (the number of flow-`k` in-zone slats from `i` to the + exit, inclusive), i.e. the unspent remainder of its schedule. The + retained-profile simulation in step 1 uses the same initial-value + fractions. Closed forms for the common cases (illustration; the algorithm above is authoritative):