Stochastic variability and fault injection¶
Status: normative, implementation contract. This section is the single source of truth for how randomness works in twinflow. Every other subsystem spec references it rather than restating it. When a subsystem spec says "unload duration is variable", the family, the parameters, the config key, and the RNG stream that produces it are defined here and nowhere else.
Scope¶
The discrete-event twin, the simulated sensor fleet, the supply network, the upstream factory, the transport network, the workforce model, the business layers that consume them, and the fault injection machinery that produces labeled special-cause events for the statistical engine to be scored against.
Requirements covered in full¶
| Requirement | Where |
|---|---|
| C1 determinism: one seed governs every stochastic stream | A.1, A.4, A.7, E, F.4 |
| C2 sim time against wall clock, per-run mapping recorded | E.1, E.4 |
| C4 property-based invariant suite and per-tier runtime budgets | F.2, F.7 |
| C5 config validation with line-numbered errors | B.1, F.1 |
| C6 compatibility table stating which recorded runs a release loads | A.3, E.2 |
| E11 learning-based AMR dispatch, kept out of the tape | A.8 |
| E25 labeled synthetic data products with dataset cards | C.3, E.6 |
| E30 causal discovery scored against a declared graph | C.3, D.4 |
| E40 one correlated weather state driving several subsystems | B.12, C.5 |
| E43 model registry and drift monitors, bound through a port | A.8, E.4 |
| E44 device lifecycle faults: provisioning, firmware, certificates | C.4 |
| E46 RF read-zone physics replacing the constant read probability | B.3, C.4 |
| Field-realistic device failure modes named in component 2 | C.4 |
| Every sensor class carries drift, stuck-at, dropout, calibration | B.10, C.4 |
| Heinrich-pyramid incident generation from near-miss frequency | B.9 |
| Labeled synthetic incidents that a learned model must beat | C.7, D.4 |
Doctrine rulings applied here¶
docs/design/DOCTRINE.md is binding. Where it and an earlier draft of this section disagreed, the ruling won. Every place a ruling lands is marked in the text with its id.
| Ruling | What it fixes here | Where |
|---|---|---|
| D-01 | The manifest splits into a hashed core and an unhashed provenance sidecar | E.1 |
| D-02 | Wall-clock reads are legal in four named places and never steer a draw | E.4 |
| D-03 | No collection whose iteration order reaches an event, a hash, or a branch is a set |
A.2, E.4 |
| D-04 | A solver or learned model either draws deterministically or stays outside the tape | A.8 |
| D-05 | The determinism claim is two-tier, and the cross-platform tolerance is measured | E.5, G.1 |
| D-06 | The Rust agent derives its streams from the same name-addressed derivation | A.7 |
| D-07 | The canonical total order is (sim_ts, producer_id, seq), distinct from queue order |
A.2, E.6 |
| D-08 | Telemetry-layer faults inject at the Network port, analytics fan-out at EventBus |
C.1 |
| D-09 | Run identity and the kernel fault primitives are owned by the foundations section | C.1, E.1 |
| D-10 | The sampler core install stays minimal; the fitting stack is a test extra | B.1, F.3 |
| D-11 | Every statistic here names an external reference, a tolerance, and a falsifier | F.7 |
| D-12 | Every test states the observation that would fail it | F.2, F.7 |
| D-13 | Every test tier states a runtime budget and a budget test asserts the arithmetic | F.7 |
| D-14 | Conformance scoring runs on twinflow-procmine under Apache-2.0 |
D.4 |
Explicitly out of scope for this section¶
- Run identity, the event envelope, the
Rngport signature, and the kernel network fault primitives.docs/design/foundations.mdowns them (D-09). This section names them and never redeclares them. - The statistical methods that consume these streams.
docs/design/lss-engine.mdowns control charts, capability, measurement system analysis, hypothesis testing, and the VAL-GATE registry. - Per-sensor-type catalog entries as data.
docs/design/sensor-catalog.mdandcatalog/sensors/*.yamlown them. Section B.11 fixes the family choice per type so a catalog entry cannot invent its own.
Packages¶
| Package | Owns | Core dependencies |
|---|---|---|
twinflow-rng |
The stream registry, the name-addressed derivation, generator construction, draw-count accounting | numpy |
twinflow-dist |
The family grammar of B.1, the practitioner-to-canonical conversions, the samplers | numpy |
twinflow-faults |
The fault catalog, the schedule materialiser, the overlay objects, the ground-truth writer | numpy |
twinflow-scoring |
The detector matching rule of C.6 and the reported metrics | numpy |
Applying D-10, none of the four needs a fitting or plotting stack at run time. The goodness-of-fit suite of F.3 needs scipy and is declared under the test extra of twinflow-dist, so a reader who installs the sampler alone gets numpy and nothing else. A CI job installs each package alone in a clean environment and imports it.
Applying D-09, twinflow-rng is the only package allowed to construct a bit generator, and every other package receives generators through it. The Rng port that the kernel exposes is owned by the foundations section; twinflow-rng is the adapter behind it.
Normative rules¶
Other specs cite these by number.
- R1. Reproducible, not predictable. One run seed governs every stochastic stream through a splittable RNG with per-subsystem child seeds. Applying D-05, the guarantee has two tiers and this document never states the stronger one alone. On one pinned platform with one pinned dependency set, identical seed plus identical resolved config yields a byte-identical event log. Across platforms, the business events are identical and the continuous fields agree inside a tolerance derived from measured divergence. Neither tier constrains the shape of the variability, which is heavy and realistic.
- R2. Correct support, not clamping. A quantity's distribution family is chosen so its support is already the physical support. Durations use lognormal, gamma, or Weibull. Bounded ratios use scaled beta. Counts use Poisson or negative binomial. Inter-arrival times use exponential, or a non-homogeneous Poisson process where a time-of-day or seasonal profile applies.
- R3. No tail truncation and no sigma cap anywhere. Rare extreme values are real and are retained. With genuine heavy tails the control charts must distinguish a rare common-cause tail draw from a real special cause, which is the hard problem in statistical process control and the skill this repo exists to demonstrate.
- R4. Physical invariants are assertions, never clamps. They live in the property-based test suite. A violation means the distribution choice is wrong, and CI fails loudly rather than production rounding silently.
- R5. Common cause and special cause are generated separately and recorded separately. Common-cause variation comes from
config/distributions.yaml. Special-cause variation comes from a materialised fault schedule. Nothing is both at once except the two declared boundary cases in section D. - R6. Faults transform samples; they never consume subsystem draws. A fault changes a parameter or post-processes a value. It never adds or removes a draw from a subsystem stream, and it draws its own randomness from its own stream.
- R7. Ground truth is sideband. Fault labels are written to
runs/<run_id>/ground_truth/. They are never published to a UNS topic, never written to a historian table that a detector reads, and never present in any dataset exported for model training unless the export is explicitly labeled as supervised. - R8. Every stochastic quantity appears in this catalog with a config key. No numeric distribution parameter is written as a literal in application code. CI enforces this with a lint rule over the sampler and subsystem packages.
- R9. Every statistic this section asserts names an external published reference. Applying D-11, a gate over a value taken from a published source is never checked more tightly than that source printed it, a gate over a stochastic quantity states a measured noise floor and sets its tolerance above it, and every gate states the result that would falsify it. This repository is never a reference for itself. A statistic with no valid external reference is recorded as an open question in section G and never as a passing gate. Section F.7 holds the registry.
- R10. Every test tier states a runtime budget, and the budget is itself tested. Applying D-13, a scenario that grows past the job budget fails as a defect rather than as a timeout. Section F.7 holds the budget table and the arithmetic test.
- R11. A component whose output steers the simulation draws deterministically or leaves the tape. Applying D-04, a solver is bounded by a deterministic budget rather than by wall-clock seconds, and a learned model runs behind the
Inferenceport with a pinned artifact hash. Section A.8 states the contract.
A. RNG architecture¶
A.1 The mechanism¶
Chosen mechanism: numpy SeedSequence with content-addressed spawn keys, feeding PCG64DXSM bit generators.
A stream is identified by a dotted stream name, not by a position. The derivation is fixed byte for byte, because D-06 makes it a cross-language contract rather than a Python implementation detail:
digest = blake2b(stream_name.encode("utf-8"),
digest_size=16,
person=b"twinflow-rng") # 12 bytes, zero-padded to 16
spawn_key = (w0, w1, w2, w3) # four uint32, little-endian, from digest
entropy = uint32[4] = (base_seed & 0xFFFFFFFF,
base_seed >> 32,
replication_index & 0xFFFFFFFF,
replication_index >> 32)
seed_seq = numpy.random.SeedSequence(entropy=entropy, spawn_key=spawn_key)
generator = numpy.random.Generator(numpy.random.PCG64DXSM(seed_seq))
base_seed is a uint64 recorded in the hashed core of the run manifest, per E.1. replication_index is a uint64 that distinguishes replications of the same scenario; folding it into the root entropy rather than into the stream name gives each replication an independent tree while keeping stream names stable across replications.
Four details in that block are load-bearing, and each one is a place where a reasonable-looking implementation silently produces different numbers.
- The personalization string is padded, not truncated. The BLAKE2b parameter block fixes the personalization field at 16 bytes, and the Python standard library pads a shorter value with zeros, so the 12-byte string and the same string followed by four zero bytes are one value (B.13 ref 23). Any second implementation pads the same way or produces a different digest for every stream name.
- The entropy is a fixed-width uint32 array, never a tuple of Python integers. numpy converts a non-negative integer to as many uint32 words as its magnitude needs, so
(base_seed, replication_index)yields between two and four words depending on how large the seed happens to be, and the mixing path changes with it. A uint32 array is passed through unchanged, which pins the assembled entropy at exactly four words for every seed (numpy source,numpy/random/bit_generator.pyx,_coerce_to_uint32_arrayandget_assembled_entropy, v2.3.0, retrieved 2026-08-09, HTTP 200). - Four entropy words means no zero-fill. numpy pads the run entropy to
pool_sizewith zeros when a spawn key is present and the entropy is shorter than the pool. Fixing the entropy at four words, which isDEFAULT_POOL_SIZE, makes the padding branch unreachable and removes a version-sensitive behavior from the contract. - The pool size stays at the default 4. That is a 128-bit entropy pool, and
generate_statecycles the pool words through the output hash, so a different pool size changes every stream.
Why this and not the alternatives:
- Why not
SeedSequence.spawn(n).spawnis positional: it extends the parent's spawn key by the child's index and maintains ann_children_spawnedcounter, so the child a subsystem receives depends on how many subsystems spawned before it. Adding subsystem 40 to the registration sequence shifts subsystem 1's child if registration order changes, and every recorded run and golden file breaks. Content-addressed spawn keys remove the ordering dependency. The name is the address. - Why not
PCG64itself.PCG64is numpy's current default, and it was this section's first choice. numpy documents a statistical weakness in it: a pair ofPCG64streams whose lower 58 state bits agree fails PractRand when interleaved, a seeded increment adds no protection against that particular weakness, and the stated guidance is that thousands of streams are comfortable while millions are the point to considerPCG64DXSM(B.13 ref 15). This section's declared ceiling of 750,000 streams sits between those two ranges, near enough to the warning that it is a decision rather than an assumption. A.1a computes what each choice costs. The switch was taken on 2026-08-09, before the known-answer corpus of A.7 existed, because the price is zero until that corpus is frozen and rises the day a second language is tested against it. Open question G.11 holds the record. - Why not
PCG64DXSM.jumped(k). Jumped streams are provably disjoint subsequences, and numpy states that thePCG64.jumpedmethod "uses a carefully chosen number of steps to avoid creating these collisions" (B.13 ref 15), which is the low-bit collision A.1a records as bound 2. numpy makes that statement aboutPCG64.jumpedand does not restate it forPCG64DXSM.jumped, so it is quoted here for what it says aboutPCG64and is not extended to the variant this section uses. The argument againstjumpeddoes not rest on it either way:kis an integer index, and assigning indices to subsystems reintroduces the positional hazard being removed. A persisted index registry to avoid reuse is more fragile than hashing a name. - Why not Philox counter-based streams.
Philox(key=base_seed, counter=name_hash)is also content addressed and is a legitimate alternative.SeedSequenceplusPCG64DXSMis chosen because the derivation then rests on two published algorithms rather than on a project-specific counter layout, and becausePCG64DXSMis one line from numpy's default rather than a departure from it: numpy seeds both through the same routine, so the seeded state and increment of A.7 are bit-identical between them, and numpy states thatPCG64DXSM"will eventually become the new default BitGenerator implementation used by default_rng in future releases" (B.13 ref 15). An earlier draft argued the choice partly on familiarity, thatPCG64is the construction a reader of numpy code already knows. That argument is weaker now and is not the deciding one, becausePCG64DXSMis not today's default. A later draft then said the collision arithmetic of A.1a carries the choice instead. That is corrected here, because the switch took it away: underPCG64DXSMthe governing bound is the 128-bitSeedSequencepool collision, andPhiloxseeded through the sameSeedSequenceinherits exactly that bound, so A.1a's arithmetic does not discriminate between the two at all. What carries the choice is the pair of reasons above, the derivation resting on two published algorithms and the seeding path being shared with numpy's default. Philox is retained as the mechanism for any later vectorised path. No throughput claim is made for either choice here; if throughput becomes the deciding argument it gets a benchmark in the repository, not a sentence.
A.1a The collision bounds, and what sets the ceiling¶
A collision between two streams is a silent correlation bug, so the risk is stated as arithmetic rather than as reassurance. Three quantities are in play and they are not interchangeable. One is a property of the derivation above and has always applied. One was a documented property of PCG64 and is now history. One is a published relation from the PCG project itself, and it is the only quantitative parallel-stream figure that project publishes. Each is computed here, and each states what it does not cover.
Bound 1, the spawn key and the entropy pool. The spawn key carries 128 bits, and the SeedSequence entropy pool it mixes into carries 128 bits as well. Two distinct stream names that hash to the same key, or two distinct keys that mix to the same pool, produce the same generator. Each is a 128-bit collision, so for n names the birthday approximation gives n^2 / 2^129 in both cases, which at n = 10^5 is about 1.5e-29. The name half of that is not left to argument: test_stream_names_do_not_collide computes every declared and template-expanded name in the smoke and full scenarios and asserts all spawn keys are distinct. It fails if any two names share a key.
Bound 2, the PCG64 low-bit state collision, now history. This was the bound that governed while the bit generator was PCG64, and it was a documented property of that generator rather than of the derivation above. numpy documents that a pair of PCG64 streams whose lower 58 state bits agree will, when interleaved, fail the PractRand test battery after a few gigabytes of output, and that the birthday calculation for a 58-bit collision reaches high probability at about 2^29 streams (B.13 ref 15). The same page states that a seeded increment does not add protection against this particular weakness, and that use at thousands of streams is comfortable while use at millions is the point to consider PCG64DXSM, SFC64, or Philox.
The formula behind that figure is not on the documentation page, and an earlier draft of this section used it without a source. It is in the issue that produced the change, where numpy's random-module maintainer writes that "for n instances, the probability of any 2 sharing the same lower 58 bits is n**2 / 2**58, give or take a factor of 2 for double-counting" (B.13 ref 27). The first column below takes the double-counting correction, so it reads n^2 / 2^59.
The same comment names a second and looser hazard that this section did not previously record: a stream may draw across another stream's colliding states rather than start on one, which he estimates grows linearly in draw length rather than quadratically, and which he puts at roughly 2^15 streams each drawing 2^28 values. He marks that estimate as one he is still thinking through, so it is carried here as his hedge and not as a bound. It matters only to the historical column, where it says the PCG64-era basis for 750,000 was generous rather than tight.
Bound 3, sequence overlap, the one number the PCG project publishes. Bounds 1 and 2 both come from numpy. The PCG project publishes no per-stream collision figure for PCG64DXSM, and G.14 records where that was searched. It does publish one quantitative relation for randomly seeded parallel streams, in the PCG author's 2017 post on streams: "Given a generator with 128 bits of state and a period of 2^128, we could be consuming a whopping 2^56 (72 quintillion) outputs and we'd need to create 2^((128-56)/2) (60 billion) distinct sequences before we had to worry about any overlap" (B.13 ref 25). The exponent is the readable part: for a b-bit state and 2^L draws per stream, the stream count that stays clear of overlap is 2^((b-L)/2). The post's own worked case is 2^36, which is 6.9e10; the post rounds it to 60 billion.
Solving that relation for draws rather than for streams gives the third column. At n = 750,000, log2(n) is 19.52, so L = 128 - 2*19.52 = 88.97 and each stream may draw about 6.0e26 values before overlap is a concern. The longest run this section budgets is a rounding error against that. The inversion is this section's algebra on a published relation, not a figure the post states.
Two limits on bound 3 are stated rather than glossed. It is an overlap argument about a 128-bit state space, so it speaks to two streams landing inside each other's consumed span, not to the correlated-pair weakness that the DXSM output function was designed to remove. And it predates DXSM by two years, so it is a family-level statement about 128 bits of state rather than a DXSM result.
| Declared streams | Pairs at 58 shared state bits, PCG64, history |
Pairs in the 128-bit pool, governing now | Draws per stream before overlap, ref 25 | Reading |
|---|---|---|---|---|
| 1.0e3 | 1.7e-12 | 1.5e-33 | 3.4e32 | numpy's stated comfortable range |
| 1.0e5 | 1.7e-8 | 1.5e-29 | 3.4e28 | the working size of the current catalog |
| 7.5e5 | 9.8e-7 | 8.3e-28 | 6.0e26 | the ceiling this section sets |
| 1.0e6 | 1.7e-6 | 1.5e-27 | 3.4e26 | numpy's stated "consider PCG64DXSM" range |
What the switch buys, and what it does not. Neither numpy nor the PCG project publishes a birthday number for PCG64DXSM, and none is invented here. G.14 records that search and its result. What is published falls into three kinds, and the kinds are kept apart because they carry different weight.
The first is numpy's comparative claim, and it is what licenses the second column governing. The DXSM output function "adopts a xorshift-multiply construction used in strong integer hashes that has much better avalanche properties than the XSL-RR output function", and with it "collisions are possible, but both streams have to simultaneously be both close in the 128 bit state space and close in the 127-bit increment space, so that would be less likely than the negligible chance of colliding in the 128-bit internal SeedSequence pool" (B.13 ref 15). That is an ordering between two probabilities. It is not a value for either.
The second is the PCG author's design claim, made in numpy's issue tracker rather than on her own site. DXSM was written to "scramble the bits more than XSL RR", at PCG-family speed, and without letting any of the output function's work be undone (B.13 ref 26). A design intent is a reason to expect a property, not a measurement of one.
The third is empirical non-detection, which is evidence and not a bound. The author reports testing DXSM with PractRand and TestU01 including "a gang of four and a gang of 8192 out to 8 TB with no issues" (B.13 ref 26), and later reports 128 TB of output tested before a power failure ended a 67-day run (B.13 ref 27). numpy's maintainer reports a PCG64DXSM pair sharing an increment and the lower 64 state bits tested to 2 TiB with no failure, and a pair with differing increments and a lower-64-bit collision showing no anomalies out to 128 GiB, where the matching PCG64 pair fails PractRand at 4 GiB (B.13 ref 27). A test that does not fail bounds nothing. It moves the observed failure point past the tested range, and the tested ranges are quoted here so a reader can see exactly how far that is.
So the documented lower-58-bit weakness is removed, and the residual is bounded above by a 128-bit pool collision, which is bound 1 and is computed in the second column. That is the whole of the gain. The switch buys nothing at all against a repeated stream name, a repeated seed, or a draw-order change, which are the failure modes A.2 and A.3 exist for, and it buys no throughput claim, because none was measured.
What sets the ceiling. STREAM_COUNT_CEILING = 750000 is a constant in twinflow_rng.registry. test_declared_stream_count_is_under_ceiling expands every template against the largest shipped facility profile and fails when the total crosses it.
Its basis is stated, and it is not a bit-generator bound. No published figure sets it, because at this scale no published figure binds: the governing collision bound at 750,000 streams is 8.3e-28, and the ref 25 overlap relation still allows about 6.0e26 draws per stream there. A statistical argument would put the ceiling many orders of magnitude above any catalog this project will hold, so a statistical ceiling would be a ceiling that never fires. The ceiling is an operating limit on catalog size instead, and what fixes its value is a declared headroom rule: it is about twelve times the largest stream count any shipped scenario declares, which E.1 shows as 61402 in its worked manifest. That leaves room for the catalog to grow by a full order of magnitude without review, and still fails on a runaway template expansion, which is the defect the check exists to catch. Crossing it is a catalog-size review rather than a bit-generator decision, and the failure message says which.
Two things would change the number, and neither is true today. A shipped scenario whose expanded stream count passes about 75,000 would exhaust the headroom rule and the ceiling would be raised with it. A published bound for PCG64DXSM that bites below 10^6 streams would take the basis back off catalog size and onto the bit generator, which is the residual G.15 watches.
The numbers above bound a statistical artifact, not a seed reuse. Two colliding streams do not produce identical draws; they produce a pair whose interleaving is detectably non-random after gigabytes of output. That is why the historical ceiling was set by a probability rather than by an assertion of impossibility, and it is why the second column is stated as a bound rather than as a guarantee.
A.2 Stream naming¶
Names are dotted, lowercase, and stable. The grammar:
<domain>.<subsystem>.<quantity> fixed streams
<domain>.<subsystem>.<entity_id>.<quantity> per-entity streams
provision.<domain>.<entity_id>.<attribute> entity attributes drawn before the clock starts
fault.<fault_id>.<instance_id>.<quantity> fault-owned randomness
schedule.faults the one stream that materialises the fault schedule
Examples: twin.receiving.unload_duration, twin.amr.AMR-014.task_travel, sensor.vibration.CNV-02-VIB-01.noise, provision.workforce.W-0231.productivity_effect, fault.F-MECH-BEARING.0003.progression.
Rules:
- One stream per (subsystem, quantity) pair. Never one stream shared by every quantity in a subsystem. Fine granularity means a code change that alters the draw order for one quantity cannot shift any other quantity.
- Per-entity streams for anything whose population size varies. Devices, pallets, lots, orders, AMRs, workers, suppliers, lanes, SKUs. Names key off the stable entity id, so creating entity 501 does not perturb entity 47. Entity ids are themselves deterministic (
AMR-%03d,LOT-%s-%06d) and are assigned from a declared id policy, never from a UUID4. - Per-entity attributes are drawn during provisioning, before the sim clock starts, from
provision.*streams. A worker's baseline productivity, a device's manufacturing offset and drift rate, a supplier's true OTIF rate, and a lane's true transit median are all provisioned attributes. This keeps entity identity constant across scenario arms, which is what makes a paired what-if comparison meaningful. - No stream is shared across processes whose interleaving is not deterministic. SimPy's event queue is deterministic given a total order on events; twinflow fixes the scheduling order as
(sim_time, priority, monotonic_event_counter)with the counter assigned at scheduling time. No component may break ties on object identity, hash, or memory address. This scheduling order is a different thing from the canonical order of the event log, which D-07 fixes as(sim_ts, producer_id, seq)because the log merges several producers and the sequence number is dense only per producer. One process draws in scheduling order; a reader replays in canonical order. Confusing the two produces a reader that appears to work on a single-container run and reorders on a fleet run. - No collection whose iteration order can reach a draw is a
set. Applying D-03, this binds every collection that reaches an event, a hash, or a control decision, not only the collection aforstatement walks. Set iteration order in CPython depends on hash values and insertion history, so a set that is materialised into a list, sorted by a non-total key, or used to build a dict carries the same hazard. Use a sorted sequence, or sort at the iteration site with an explicit total key and say in a comment why the set is the right semantic type. Lint ruleTWF-RNG-002fails CI onfor ... in <set literal or set()>inside the sampler and subsystem packages. The lint catches the syntactic case; the standing behavioral check is CI running the determinism scenario twice under differentPYTHONHASHSEEDvalues and comparing hashes, which catches the cases the lint cannot see.
A.3 The ordering hazard, stated explicitly¶
There are two distinct ordering hazards and they need different mitigations.
Hazard 1: stream allocation order. Solved by construction. Because a stream's seed is derived from its name and not from a counter, subsystem 40 can be added, removed, or renamed without touching subsystem 1's draws. The stream-append test in section F.4 is the standing proof.
Hazard 2: draw order within a stream. Not solved by construction, and this is the one that bites. If a refactor changes how many values a subsystem pulls from its own stream, or the order in which it pulls them, every subsequent draw from that stream shifts and the event log changes. Mitigations:
- Fine-grained streams (rule 1 above) confine the blast radius to the quantity that changed.
- Never draw speculatively. Do not sample a value inside a branch that may be discarded. If a value is needed only sometimes, either draw it unconditionally (accepting the cost) or give the conditional path its own stream.
- Do not draw inside a comprehension or generator whose evaluation order a later refactor might change.
- When a draw-order change is genuinely required, the stream name gets an explicit version suffix:
twin.receiving.unload_duration@v2. The superseded name stays in the registry markedretired_at: <release>. Golden files are regenerated in the same commit, and the CHANGELOG compatibility table (C6) records which recorded runs no longer reproduce. This makes an unavoidable break loud, dated, and attributable instead of silent.
A.4 The stream registry¶
packages/twinflow-rng/src/twinflow_rng/registry.py declares every fixed stream name as a module constant and every per-entity stream as a template. It exports STREAM_CATALOG_VERSION (semver).
The registry is the only place allowed to construct generators. The public API is:
rng = streams.get("twin.receiving.unload_duration") # fixed
rng = streams.get("twin.amr.{amr_id}.task_travel", amr_id=id) # templated
SeedSequence.spawn( and direct PCG64DXSM( construction are forbidden outside twinflow_rng; lint rule TWF-RNG-001 fails CI on either. The rule names every numpy bit generator rather than the chosen one alone, so swapping PCG64DXSM for PCG64, Philox, SFC64, or MT19937 outside the package fails the same way. numpy.random.seed, random.seed, and module-level numpy.random.* calls are forbidden repository-wide (TWF-RNG-003), because they touch the legacy global state that nothing here may depend on.
CI test test_registry_is_append_only diffs the registry against the previous release tag and fails if any name was removed or altered without a retired_at marker and a major version bump.
STREAM_COUNT_CEILING lives in this module, and its basis is stated in A.1a rather than here so that the number and its reason stay in one place. It is an operating limit on catalog size set by a headroom rule over the largest shipped scenario, not a bit-generator bound, and its docstring says so in those words. A reader who finds the constant and looks for the statistics behind it is sent to A.1a, which explains why no published figure binds at this scale.
A.5 Common random numbers across scenario arms¶
Because streams are addressed by name, two runs with the same base_seed and replication_index give the same draws to every stream that exists in both. This is deliberate: it is the variance-reduction technique that raises the statistical power of the LSS engine's before-and-after hypothesis test above an independent-samples comparison of the same size.
The caveat is stated here because a naive implementation gets it wrong. Common random numbers only produce a genuinely paired comparison when the two arms draw the same number of values from each shared stream. Adding a scan portal creates new entities with new stream names and leaves existing streams aligned. Changing an arrival rate changes how many arrivals occur, so pallet number 400 in arm A is not pallet number 400 in arm B, and the pairing is broken even though the stream names match.
The runner so computes a crn_integrity record for every scenario comparison: for each stream present in both arms, the draw count in each arm. The hypothesis-test assumption checker in the LSS engine consumes it and applies a paired test only when every shared stream's draw counts are identical; otherwise it applies the independent-samples test. The chosen test and the reason are printed in the capability report. Config key: whatif.crn.enabled (default true), whatif.crn.require_exact_pairing (default true).
A.6 What is recorded¶
Run metadata carries the full RNG state description. See section E.1 for the complete manifest. The RNG-specific fields are base_seed, replication_index, rng.mechanism, rng.bit_generator, rng.stream_catalog_version, rng.spawn_key_algorithm, and rng.draw_counts_sha256 (a hash over the sorted stream-name-to-draw-count map, which is the cheapest way to detect that a code change altered draw order). base_seed and replication_index sit in the hashed core; rng.draw_counts_sha256 sits in the provenance sidecar, because it is computed after the last event and cannot be carried by the first one (D-01).
A.7 The cross-language derivation, specified byte for byte¶
One device agent is written in Rust. Applying D-06, that agent derives its stream from the run seed and its device id through the derivation of A.1 rather than through a second mechanism, and the derivation is specified here in terms of named algorithms rather than in terms of whichever library either language happens to call. A conformance test asserts that both implementations produce identical draws for the same stream name and seed.
Three algorithms are named, in this order.
1. BLAKE2b. The core compression function is RFC 7693. The personalization parameter is not: RFC 7693 states that salting, personalized hashes, and tree hashing use parameter-block fields outside its scope, and refers to the BLAKE2 specification for them. Both sources are named in B.13 ref 23, and both are needed. Input is the UTF-8 bytes of the stream name. Digest size 16 bytes. Key length 0. Salt absent, which is the 16 zero bytes at parameter-block offsets 32 to 47. Personalization b"twinflow-rng" at offsets 48 to 63, 12 bytes zero-padded to 16. The 16-byte digest is read as four little-endian uint32 words (w0, w1, w2, w3), which become the spawn key.
2. numpy's SeedSequence mixing function. This is a published algorithm with fixed constants, and the constants are part of the contract. They are transcribed from numpy/random/bit_generator.pyx at v2.3.0 (retrieved 2026-08-09, HTTP 200):
| Constant | Value | Role |
|---|---|---|
DEFAULT_POOL_SIZE |
4 | pool words, so a 128-bit pool |
INIT_A |
0x43b0d7e5 |
initial multiplier for the mixing pass |
MULT_A |
0x931e8875 |
multiplier advance inside hashmix |
INIT_B |
0x8b51f9dd |
initial multiplier for output extraction |
MULT_B |
0x58f38ded |
multiplier advance inside generate_state |
MIX_MULT_L |
0xca01f9dd |
left multiplier in mix |
MIX_MULT_R |
0x4973f715 |
right multiplier in mix |
XSHIFT |
16 | the xor-shift distance |
All arithmetic is uint32 with wrapping overflow. h is one mutable variable shared across every call inside one invocation, and each hashmix call advances it before returning, which is why the loops below cannot be reordered.
hashmix(v): v ^= h; h = h * MULT_A; v = v * h; v ^= v >> XSHIFT; return v # advances h
mix(x, y): r = MIX_MULT_L * x - MIX_MULT_R * y; r ^= r >> XSHIFT; return r
mix_entropy(pool[4], entropy[8]):
h = INIT_A
for i in 0..3: pool[i] = hashmix(entropy[i])
for src in 0..3: for dst in 0..3: if src != dst: pool[dst] = mix(pool[dst], hashmix(pool[src]))
for src in 4..7: for dst in 0..3: pool[dst] = mix(pool[dst], hashmix(entropy[src]))
generate_state(n):
h = INIT_B
for i in 0..n-1:
v = pool[i mod 4]; v ^= h; h = h * MULT_B; v = v * h; v ^= v >> XSHIFT; out[i] = v
The entropy array is the four run-entropy words of A.1 followed by the four spawn-key words, in that order, giving the eight words the third loop consumes. A uint64 output word is two uint32 words read little-endian, low word first.
3. PCG64DXSM, the CM DXSM 128/64 variant. numpy names this member of the PCG family "PCG CM DXSM 128/64" and states that it "differs from PCG64 in that it uses the stronger DXSM output function", a 64-bit cheap multiplier in the LCG, and "outputs from the state before advancing it rather than advance-then-output" (numpy/random/_pcg64.pyx, the PCG64DXSM docstring, v2.3.0, retrieved 2026-08-09, HTTP 200).
Seeding is unchanged from PCG64, and that is worth stating plainly, because it fixes how much of an earlier PCG64 specification survives: all of the seeding half and none of the generation half. PCG64DXSM.__init__ consumes generate_state(4, uint64) exactly as PCG64.__init__ does, passing words 0 and 1 as the state and words 2 and 3 as the increment into the same pcg64_set_seed entry point, which calls pcg_setseq_128_srandom_r, the seeding routine shared with the XSL-RR variant. So the four words are read as (s0, s1, i0, i1), initstate = s0 * 2^64 + s1, initseq = i0 * 2^64 + i1, and the generator is initialized as PCG specifies: state zero, inc = initseq * 2 + 1, one step, state += initstate, one step, with the step being state = state * M_XSL + inc and M_XSL = 0x2360ed051fc65da44385df649fccf645. The seeded state and increment a PCG64DXSM holds are thus bit-identical to what a PCG64 seeded from the same SeedSequence would hold, and this was checked by reading both back out of numpy rather than by reasoning about the source.
Only generation differs. The output function is DXSM, "double xorshift multiply", it reads the state before the state advances, and the advance uses the 64-bit cheap multiplier M_CM = 0xda942042e4dd58b5 in place of M_XSL. All arithmetic is unsigned and wrapping, at 64 bits inside the output function and at 128 bits in the advance:
output(state): hi = state >> 64 # uint64
lo = (state mod 2^64) bitwise_or 1 # uint64, forced odd
hi = hi xor (hi >> 32)
hi = (hi * 0xda942042e4dd58b5) mod 2^64
hi = hi xor (hi >> 48)
hi = (hi * lo) mod 2^64
return hi
advance(state): state = (state * 0xda942042e4dd58b5 + inc) mod 2^128
next_uint64(): out = output(state); advance(state); return out
Three details in that block are the ones an implementer gets wrong. The output precedes the advance, where XSL-RR advances first. The cheap multiplier is a 64-bit constant widened to 128 in the advance, not a 128-bit constant. The forced odd bit on the low word is load-bearing, because multiplication modulo 2^64 by an even value is not a bijection, so an even lo would collapse distinct states onto one output. These are transcribed from pcg_output_cm_128_64, pcg_cm_step_r, and pcg_cm_random_r in numpy/random/src/pcg64/pcg64.h, with pcg_advance_lcg_128 in pcg64.c for the jump path, at v2.3.0 (retrieved 2026-08-09, HTTP 200). The same header also carries pcg_cm_srandom_r, which seeds with the cheap multiplier. numpy does not call it, and a second implementation that does will produce a different state from the same seed.
The Rust binding. The crate is rand_pcg, and the type is Lcg128CmDxsm64, exported under the alias Pcg64Dxsm. Its documentation states that it "corresponds to pcg_engines::cm_setseq_dxsm_128_64 from pcg_cpp and PCG64DXSM from NumPy" (rand_pcg 0.10.2 documentation, retrieved 2026-08-09, HTTP 200). Binding the type is not enough, and this is the trap: Lcg128CmDxsm64::new(state, stream) does not reproduce numpy's seeded state, because it adds the increment once and takes a single step with the cheap multiplier, where numpy takes two steps with M_XSL starting from zero. The agent computes the seeded state with the routine above and then calls Lcg128CmDxsm64::from_state(seeded_state, initseq), which sets increment = (initseq << 1) | 1 and leaves the state alone. from_state is why the crate floor is rand_pcg 0.10: version 0.9.0 exposes only new and advance on this type, so it cannot be handed a pre-seeded state and would need a vendored copy instead. The 0.10 series is MIT or Apache-2.0, which the inbound dependency license policy in ARCHITECTURE.md decision D15 accepts.
The conformance corpus. tests/fixtures/rng_kat.json holds known-answer vectors: for each of 64 stream names spanning every grammar form of A.2, at three base seeds and two replication indexes, the first 16 raw uint64 outputs and the first 16 doubles. test_rng_known_answers in Python and rng_known_answers in Rust read the same file and compare. A change to any of the three algorithms fails both suites in the same commit. The corpus is generated by a script that prints the numpy result, so the fixture records what numpy does rather than what this document says it does, and a disagreement between the two is itself a finding.
What the agent draws. The Rust agent owns the streams named device.agent.<device_id>.<quantity>, which is the per-entity form of the A.2 grammar: publish jitter, backoff jitter on reconnect, store-and-forward eviction choice, and sample-interval jitter for locally generated readings. It never draws a physical quantity; physical state arrives from the twin. This keeps the agent's contribution to the tape small enough that the conformance corpus covers it exhaustively.
A.8 Solvers and learned models¶
Applying D-04, a component whose output steers the simulation is bounded deterministically or it does not steer the simulation.
| Component | How it stays deterministic |
|---|---|
| Constraint solver (rostering, yard) | Deterministic budget rather than wall-clock seconds, a branch cap, one worker, and a fixed solver seed |
| Learned AMR dispatch (E11) | Pinned model artifact hash, fixed seed, fixed thread count, deterministic kernels, called through Inference |
| Learned detectors and forecasters | Same contract, and in simulation mode Inference binds a recorded-response adapter |
| Surrogate model (E28) | Advisory only: it ranks candidate scenarios and the full simulation confirms the winner, so it never steers a run |
Where determinism cannot be reached, the component becomes an advisory output recorded in the provenance sidecar, and the tape records the decision it produced rather than recomputing it on replay. test_inference_port_is_bound_to_recorded_adapter_in_simulation_mode fails when a simulation-mode run constructs a live inference client. Model training is a separate matter and is covered in E.4.
B. The common-cause distribution catalog¶
B.1 Where it lives and how it is parameterized¶
The catalog lives in config/distributions.yaml, validated at load against schemas/distributions.schema.json, and carries its own catalog_version (semver) recorded in every run manifest. facility.yaml references it by path and pins the version. Sensor signal models live in config/sensors/*.yaml (the sensor catalog, one entry per type) and follow the same family grammar.
Config keys below are dotted paths into distributions.yaml rooted at variability.. A leading sensors. denotes the sensor catalog instead.
Every family is parameterized in terms a practitioner can estimate, not in terms of the raw canonical parameters. The sampler converts. Conversions are unit tested against closed forms.
| Notation | Definition | Support |
|---|---|---|
lognormal(median=m, sigma_log=s) |
m * exp(s*Z), Z~N(0,1); mean m*exp(s^2/2) |
(0, inf) |
shifted_lognormal(shift=a, median=m, sigma_log=s) |
a + m*exp(s*Z); a is the physical floor |
(a, inf) |
gamma(mean=u, shape=k) |
scale u/k; k large means near-deterministic |
(0, inf) |
weibull(shape=k, scale=L) |
k>1 wear-out, k=1 memoryless, k<1 infant mortality |
(0, inf) |
beta_scaled(lo, hi, mean=u, conc=K) |
p=(u-lo)/(hi-lo), alpha=K*p, beta=K*(1-p), mapped to [lo,hi] |
[lo, hi] |
bernoulli(p) |
p may be a per-entity beta draw held constant for that entity |
{0,1} |
poisson(rate=L) |
per unit time or per trial | N0 |
negbin(mean=u, disp=r) |
var = u + u^2/r; r -> inf recovers Poisson |
N0 |
negbin_shifted(min=n, mean=u, disp=r) |
shifted so the minimum is n |
{n, n+1, ...} |
betabinom(n, mean_p=u, conc=K) |
overdispersed bounded count | {0..n} |
geometric(p) |
number of trials to first success | {1,2,...} |
exponential(mean=u) |
memoryless inter-arrival | (0, inf) |
uniform(lo, hi) |
used only where the mechanism is uniform by construction | [lo, hi] |
normal(mean=u, sigma=s) |
additive measurement error on a reading, never on a physical quantity | R |
ar1(phi, sigma) |
first-order autoregressive noise, stationary variance s^2/(1-phi^2) |
R |
nhpp(base_rate=L, profile=[..], seasonal=..) |
non-homogeneous Poisson, sampled by thinning against sup(profile)*L |
(0, inf) |
dirichlet(alpha=[..]) then categorical |
a category mix that itself varies by entity | simplex |
ou(mean=u, reversion=th, sigma=s) |
Ornstein-Uhlenbeck; exp(ou(..)) for strictly positive quantities |
R or (0,inf) |
gp_tail(threshold=u, shape=xi, scale=s) |
Generalized Pareto for exceedance magnitudes | [u, inf) |
rayleigh(sigma=s) |
radial magnitude of a 2D Gaussian error | [0, inf) |
wiener(drift_per_day=d, sigma_per_sqrt_day=s) |
random-walk drift with a deterministic slope | R |
semi_markov(states, holding={state: weibull}) |
machine state model | state set |
hierarchical(entity=<dist>, within=<dist>) |
two-level: an entity effect drawn once, noise drawn per event | inherited |
croston(occurrence=bernoulli(p), size=<dist>) |
intermittent demand | N0 |
bass(p, q, m) + negbin residual |
new-product adoption | N0 |
The family list is closed. test_every_catalog_family_is_declared reads config/distributions.yaml and catalog/sensors/*.yaml and fails on a family name absent from the table above, which is what stops a catalog entry from introducing an undocumented sampler.
Applying D-10, the sampler needs numpy and nothing else. The conversions below are closed forms, so no fitting library is imported at run time. The goodness-of-fit suite of F.3 needs scipy for the Anderson-Darling and chi-square distributions and declares it under the test extra.
Three conventions that matter downstream:
hierarchicalis not decoration. The two-level structure is what gives the Gage R and R study a ground truth: the entity-level effect is the true operator or gauge variance component, and the within-level effect is the true repeatability component. The MSA test suite compares the engine's estimated variance components against the provisioned values.- Deterministic parts stay deterministic. Conveyor transport time is
length / speed, ASRS crane time is the Chebyshev kinematic time, and putaway travel distance is fixed by the slotting assignment. Only the multiplier or the jitter is drawn. The catalog rows say so, and this keeps the twin's mechanistic core inspectable. normalappears on readings, not on quantities. A physical quantity with a floor never takes anormal, because the family's support is the whole real line and the quantity's is not. A measured reading does take one, because additive electrical noise on a measurement is unbounded even when the measured quantity is bounded. Section B.10 states the same rule from the sensor side, andtest_no_normal_family_on_a_bounded_physical_quantityfails when avariability.*key outside the sensor catalog namesnormal.
B.2 Inbound, yard, and receiving¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Truck inter-arrival (unappointed) | nhpp |
base 2.4/h, 24-slot hourly profile peaking 06:00-10:00, weekday multipliers | (0,inf) |
variability.inbound.truck_interarrival |
Arrivals cluster by time of day; a homogeneous Poisson would erase the congestion pattern the twin exists to show |
| Arrival deviation from appointment | shifted_lognormal |
shift -30 min, median 8 min, sigma_log 0.9 | (-30,inf) |
variability.inbound.appointment_deviation |
Carriers arrive at most ~30 min early by yard policy but can be arbitrarily late; the floor is physical, the tail is not |
| Appointment no-show | bernoulli |
p from beta_scaled(0,1,0.03,60) per carrier |
{0,1} |
variability.inbound.no_show |
Per-carrier propensity varies, and the beta prior gives carrier scorecards something real to measure |
| Pallets per trailer | betabinom |
n = trailer capacity 30, mean_p 0.83, conc 25 | {0..30} |
variability.inbound.trailer_pallet_count |
Bounded by trailer cube; overdispersed because load plans vary by supplier |
| Yard move (gate to door) | gamma |
mean 6.5 min, shape 6 | (0,inf) |
variability.inbound.yard_move |
Sum of several short sub-steps, which is what gamma is |
| Dock door turnaround overhead | gamma |
mean 11 min, shape 4 | (0,inf) |
variability.inbound.door_turnaround |
Spotting, chocking, paperwork, release; dock occupancy itself is emergent, not sampled |
| Trailer detention after release | lognormal |
median 6 min, sigma_log 0.8 | (0,inf) |
variability.inbound.detention |
Right-skewed waiting behavior with a real tail that drives detention cost |
| Unload duration per pallet | hierarchical(entity=lognormal(1.0,0.18), within=lognormal(median=52 s, sigma_log=0.30)) |
as shown | (0,inf) |
variability.receiving.unload_pallet |
Operator effect persists across the shift; per-pallet noise does not. Trailer unload time is the sum, not a separate draw |
| Floor-loaded carton unload | lognormal |
median 4.1 s/carton, sigma_log 0.42 | (0,inf) |
variability.receiving.unload_carton_floorload |
Manual handling; heavier tail than palletised |
| Receiving inspection duration | lognormal |
median 95 s, sigma_log 0.55 | (0,inf) |
variability.receiving.inspection |
Inspection escalates on findings, producing the long right tail |
| ASN-to-receipt discrepancy magnitude | negbin |
mean 0.4 units, disp 0.6 | N0 |
variability.receiving.asn_discrepancy |
Discrepancies cluster (a whole layer is short, not one unit) |
| Staging dwell before putaway | lognormal |
median 18 min, sigma_log 0.95 | (0,inf) |
variability.receiving.staging_dwell |
Congestion-driven, long-tailed |
B.3 Identification and data capture¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| RFID portal read rate (per portal, true) | beta_scaled |
lo 0, hi 1, mean 0.986, conc 400 | [0,1] |
variability.autoid.portal_read_rate |
A bounded ratio with a real left tail; provisioned per portal, so portals differ permanently |
| Tag read outcome per pass | bernoulli |
p = the portal's provisioned read rate, modulated by dwell and orientation | {0,1} |
variability.autoid.tag_read |
The physical event; E46 replaces the constant p with a geometry model without changing this row's role |
| Reads per tag per pass | poisson |
mean dwell_s * 6.5 reads/s |
N0 |
variability.autoid.reads_per_pass |
Zero is meaningful (a miss) so no truncation is needed; multiplicity drives the dedup logic |
| Cross-read from adjacent portal | bernoulli |
p from beta_scaled(0,1,0.004,900) per portal pair |
{0,1} |
variability.autoid.cross_read |
The false-positive class that plagues real deployments |
| Barcode scan duration | lognormal |
median 1.4 s, sigma_log 0.35 | (0,inf) |
variability.autoid.barcode_scan |
Short manual action |
| Barcode scan attempts to success | geometric |
p 0.94 | {1,2,..} |
variability.autoid.barcode_retries |
Retry count is the canonical geometric quantity |
| Scan-to-publish latency | lognormal |
median 42 ms, sigma_log 0.55 | (0,inf) |
variability.autoid.publish_latency |
Network latency is right-skewed at every scale |
| Publish-to-historian latency | lognormal |
median 15 ms, sigma_log 0.7 | (0,inf) |
variability.telemetry.ingest_latency |
Broker plus writer path; feeds the ingest SLO chart |
| Message loss (steady state) | bernoulli |
p 0.0002 | {0,1} |
variability.telemetry.message_loss |
At-least-once delivery bounds loss only above the buffer; see B.13 ref 8. F-NET-BUFOVERFLOW departs from this baseline |
| Pick-to-light confirm latency | gamma |
mean 0.7 s, shape 8 | (0,inf) |
variability.autoid.ptl_confirm |
Near-deterministic device response with a small tail |
B.4 Conveyance, sortation, storage, and automation¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Conveyor transport time | deterministic length/speed times beta_scaled(0.92,1.06,1.0,600) |
as shown | (0,inf) |
variability.conveyor.speed_multiplier |
The mechanism is deterministic; only realized belt speed varies, and it varies within physical bounds |
| Item gap on merge | gamma |
mean 0.9 m, shape 12 | (0,inf) |
variability.conveyor.merge_gap |
Tight, right-skewed spacing |
| Photo-eye blockage clear time | lognormal |
median 3.2 s, sigma_log 0.6 | (0,inf) |
variability.conveyor.blockage_clear |
Short interventions with occasional long ones |
| Baseline jam inter-occurrence | exponential |
mean 1 per 42,000 item-passes | (0,inf) |
variability.conveyor.jam_rate_baseline |
Jams at baseline rate are common cause; F-CONV-JAM multiplies this rate (see D.3) |
| Jam clear duration | lognormal |
median 4.5 min, sigma_log 0.85 | (0,inf) |
variability.conveyor.jam_clear |
Clearing is a product of several multiplicative delays, so its log is a sum; see B.13 ref 5 |
| Divert success per item | bernoulli |
p from beta_scaled(0,1,0.9975,2500) per diverter |
{0,1} |
variability.sortation.divert_success |
Bounded ratio, provisioned per diverter so mis-sort Pareto has structure |
| Divert actuation time | gamma |
mean 0.35 s, shape 20 | (0,inf) |
variability.sortation.divert_actuation |
Mechanical, tight |
| Recirculation count after miss | geometric |
p 0.86 | {1,2,..} |
variability.sortation.recirculations |
Produces the rework loops process mining is meant to find |
| Putaway travel time | deterministic distance / speed times lognormal(1.0, 0.22) |
as shown | (0,inf) |
variability.putaway.travel_multiplier |
Distance comes from slotting; the multiplier carries congestion and operator effect |
| Forklift speed multiplier | beta_scaled |
lo 0.60, hi 1.25, mean 1.0, conc 90 | [0.6,1.25] |
variability.putaway.speed_multiplier |
Physically bounded by the truck and the aisle |
| Put-to-location handling | lognormal |
median 26 s, sigma_log 0.33 | (0,inf) |
variability.putaway.deposit |
Short manual cycle |
| Location-occupied exception | bernoulli |
p 0.012 | {0,1} |
variability.putaway.location_exception |
Feeds the rework variant in process mining |
| Location-exception resolution | lognormal |
median 3.5 min, sigma_log 0.9 | (0,inf) |
variability.putaway.exception_resolution |
Escalating manual search |
| AMR task travel time | deterministic path length / speed times lognormal(1.0, 0.16) |
as shown | (0,inf) |
variability.amr.travel_multiplier |
Path is planned; the multiplier is traffic and control jitter. Congestion delay itself is emergent from the traffic model, not sampled |
| AMR pick or drop dwell | gamma |
mean 8.5 s, shape 14 | (0,inf) |
variability.amr.transfer_dwell |
Mechanical transfer |
| AMR energy per task | gamma |
mean 0.42 Wh/m * distance, shape 9 |
(0,inf) |
variability.amr.energy_per_task |
Energy is strictly positive and additive over legs |
| AMR battery state of charge | state, bounded [0,1] |
drain from energy draws, charge from the charger curve | [0,1] |
variability.amr.soc_bounds |
SOC is a state, not a draw; the invariant suite asserts it stays in bounds rather than clamping it |
| AMR charge time to target SOC | deterministic charge curve plus gamma(mean=1.0, shape=30) efficiency multiplier |
as shown | (0,inf) |
variability.amr.charge_efficiency |
Charger physics with unit-to-unit variation |
| AMR battery state of health | monotone non-increasing, wiener with negative drift |
drift -0.00018/day, sigma 0.00004 | (0,1] |
variability.amr.soh_degradation |
SOH only decreases; monotonicity is a property test, not a clamp |
| Deadlock resolution time | lognormal |
median 55 s, sigma_log 0.7 | (0,inf) |
variability.amr.deadlock_resolution |
Deadlock occurrence is emergent; only the recovery duration is drawn |
| Palletiser cycle time | weibull |
shape 6.5, scale 14.2 s | (0,inf) |
variability.palletiser.cycle |
Machine cycles cluster tightly with an occasional slow cycle; high-shape Weibull is that shape exactly |
| Palletiser jam per cycle | bernoulli |
p 0.00035 | {0,1} |
variability.palletiser.jam_rate_baseline |
Baseline mechanical fault rate |
| Palletiser jam clear | lognormal |
median 7 min, sigma_log 0.8 | (0,inf) |
variability.palletiser.jam_clear |
Maintainability |
| Depalletiser layer pick cycle | weibull |
shape 5.8, scale 11.0 s | (0,inf) |
variability.depalletiser.cycle |
Same rationale as palletiser |
| ASRS crane cycle time | deterministic Chebyshev kinematics plus gamma(mean=1.0, shape=40) |
as shown | (0,inf) |
variability.asrs.cycle_multiplier |
Simultaneous horizontal and vertical travel makes the time a Chebyshev metric, B.13 ref 6; only jitter is random |
| ASRS dual-command interleave gain | deterministic | scheduling policy | n/a | asrs.scheduling.policy |
Listed so readers know it is not stochastic |
| ASRS pick-face exception | bernoulli |
p 0.003 | {0,1} |
variability.asrs.exception_rate |
Feeds the retrieval SLA misses |
| Slotting decay (demand-mix drift) | dirichlet random walk on velocity shares |
conc 900, step 0.02 | simplex | variability.slotting.mix_drift |
Slotting staleness is a slow structural drift, not noise |
B.5 Outbound, e-commerce, cross-dock, and returns¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Pick handle time per line | hierarchical(entity=lognormal(1.0,0.20), within=lognormal(median=22 s, sigma_log=0.34)) |
as shown | (0,inf) |
variability.picking.line_handle |
Persistent picker skill plus per-line noise; the entity level is the MSA operator component |
| Pick handle time per unit | lognormal |
median 3.1 s, sigma_log 0.28 | (0,inf) |
variability.picking.unit_handle |
Short repeated action |
| Mispick per line | bernoulli |
p = base 0.0022 times fatigue multiplier times learning multiplier | {0,1} |
variability.picking.mispick_rate |
The rate is a base times two declared modulators, so the safety and workforce loops actually close |
| Each-pick walk time (zone) | deterministic distance / speed times lognormal(1.0, 0.19) |
as shown | (0,inf) |
variability.picking.walk_multiplier |
Same structure as putaway travel |
| Batch consolidation time | gamma |
mean 42 s, shape 5 | (0,inf) |
variability.picking.batch_consolidation |
Sum of short sub-steps |
| Pack time per carton | lognormal |
median 48 s, sigma_log 0.40 | (0,inf) |
variability.packing.carton |
Manual cycle with a real tail on awkward items |
| Carton fill rate achieved | beta_scaled |
lo 0.25, hi 0.98, mean 0.72, conc 30 | [0.25,0.98] |
variability.packing.fill_rate |
A bounded ratio with physical floor and ceiling; drives dim-weight cost |
| Label print and apply | gamma |
mean 4.2 s, shape 12 | (0,inf) |
variability.packing.label |
Device-paced |
| Load and stage per pallet | lognormal |
median 95 s, sigma_log 0.38 | (0,inf) |
variability.outbound.load_pallet |
Manual, right-skewed |
| Parcel sort induct rate | gamma |
mean 1.05 s/parcel, shape 25 | (0,inf) |
variability.outbound.parcel_induct |
Near-deterministic conveyor pace |
| Outbound door turnaround | gamma |
mean 14 min, shape 4 | (0,inf) |
variability.outbound.door_turnaround |
Mirrors inbound; doors are shared, so contention is emergent |
| Cross-dock staging dwell | lognormal |
median 47 min, sigma_log 1.05 | (0,inf) |
variability.crossdock.staging_dwell |
The heavy tail is the missed-connection mechanism |
| Flow-versus-store decision inputs | deterministic policy over urgency, congestion, storage cost | n/a | n/a | crossdock.policy |
Listed so readers know the decision is a rule, not a coin flip |
| Return rate per shipped unit (per SKU class) | beta_scaled |
A: lo 0 hi 0.25 mean 0.032 conc 220; B: mean 0.058; C: mean 0.091 | [0,0.25] |
variability.returns.rate_by_class |
Bounded ratio provisioned per class; the ceiling is a modeling bound on the rate, not a truncation of any sampled value |
| Return arrival lag after shipment | lognormal |
median 9.5 days, sigma_log 0.75 | (0,inf) |
variability.returns.arrival_lag |
Returns trickle in over months; the tail matters for the forecaster |
| Return reason code | dirichlet then categorical |
alpha over {damaged, wrong item, remorse, quality defect, late} varying by class | simplex | variability.returns.reason_mix |
The mix itself varies by lot and channel, which is what a Pareto chart is meant to reveal |
| Return triage duration | lognormal |
median 3.4 min, sigma_log 0.62 | (0,inf) |
variability.returns.triage |
Inspection with escalation |
| Disposition outcome | categorical from a dirichlet per grade |
alpha over | simplex | variability.returns.disposition_mix |
Same reasoning as reason codes |
| Refurbish duration | gamma |
mean 22 min, shape 3 | (0,inf) |
variability.returns.refurbish |
Multi-step rework |
| Recovery value fraction | beta_scaled |
lo 0, hi 1, mean 0.46, conc 12 | [0,1] |
variability.returns.recovery_fraction |
Bounded ratio feeding the reverse P&L |
B.6 Upstream production¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Batch stage duration (mix) | gamma |
mean 38 min, shape 14 | (0,inf) |
variability.production.batch_mix |
A batch stage is a sum of controlled sub-steps |
| Batch stage duration (coat) | gamma |
mean 26 min, shape 18 | (0,inf) |
variability.production.batch_coat |
Same |
| Batch stage duration (cure) | gamma |
mean 105 min, shape 30 | (0,inf) |
variability.production.batch_cure |
Long, tightly controlled stage |
| Batch profile trajectory deviation | ou |
reversion 0.08/min, sigma 0.6 units | R |
variability.production.profile_ou |
A controlled process variable mean-reverts; OU is the correct family and gives golden-batch scoring something real to score |
| Batch yield | beta_scaled |
lo 0.70, hi 1.00, mean 0.968, conc 320 | [0.70,1.00] |
variability.production.batch_yield |
Bounded ratio; the floor is the scrap-the-batch threshold, not a clamp on the draw |
| Discrete stage cycle (forming) | weibull |
shape 7.0, scale 9.4 s | (0,inf) |
variability.production.form_cycle |
Machine-paced |
| Discrete stage cycle (finishing) | weibull |
shape 5.2, scale 16.8 s | (0,inf) |
variability.production.finish_cycle |
More manual content, wider shape |
| Discrete stage cycle (packing) | weibull |
shape 6.1, scale 7.2 s | (0,inf) |
variability.production.pack_cycle |
Machine-paced |
| First-pass yield per stage | beta_scaled |
lo 0.80, hi 1.00, mean 0.991, conc 900 | [0.80,1.00] |
variability.production.fpy_by_stage |
Bounded ratio per stage; feeds the yield-through-genealogy claim |
| Scrap units per batch | negbin |
mean 3.1, disp 1.4 | N0 |
variability.production.scrap_count |
Scrap clusters; Poisson would understate the variance and make the chart look better than reality |
| Changeover internal time (SMED) | lognormal |
median 22 min, sigma_log 0.45 | (0,inf) |
variability.production.changeover_internal |
Setup with the classic right tail |
| Changeover external time (SMED) | lognormal |
median 14 min, sigma_log 0.5 | (0,inf) |
variability.production.changeover_external |
Split from internal so SMED analysis has two measurable levers |
| Sequence-dependent setup adder | deterministic matrix times lognormal(1.0, 0.25) |
as shown | (0,inf) |
variability.production.setup_matrix_multiplier |
The matrix is the engineering fact; the multiplier is execution variability |
| Machine state dwell | semi_markov |
states {run, idle, blocked, starved, down}; holding weibull per state |
state set | variability.production.machine_states |
True OEE decomposition needs state dwell, not a single availability number |
| Time between failures (wear-out) | weibull |
shape 2.3, scale 640 h | (0,inf) |
variability.reliability.ttf_wearout |
Weibull hazard rises when shape > 1, which is the wear-out region, B.13 ref 3 |
| Time between failures (random) | exponential |
mean 4,100 h | (0,inf) |
variability.reliability.ttf_random |
Constant hazard is the flat intrinsic-failure region, B.13 ref 3 |
| Time between failures (infant mortality) | weibull |
shape 0.6, scale 220 h, applies for the first 90 days after provisioning | (0,inf) |
variability.reliability.ttf_infant |
Weibull hazard falls when shape < 1, the early-failure region; the three rows are the bathtub as a mixture, B.13 ref 3 |
| Time to repair | lognormal |
median 48 min, sigma_log 0.85 | (0,inf) |
variability.reliability.ttr |
Diagnosis, part availability, and access multiply, so the log is a sum; see B.13 ref 5 and open question G.12 |
| Time to detect (manual rounds) | uniform over the round interval |
round interval 240 min | [0, 240] |
variability.reliability.manual_detect |
Detection by inspection is uniform within the inspection interval by construction |
| Spare part availability | bernoulli |
p 0.86 | {0,1} |
variability.reliability.spare_available |
Drives the long tail of TTR when the part is not on the shelf |
| Spare part lead time when stocked out | lognormal |
median 2.1 days, sigma_log 0.9 | (0,inf) |
variability.reliability.spare_leadtime |
The heavy tail is what makes a stockout expensive |
B.7 Supply network: suppliers, transport, procurement, finance¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Supplier lead time | hierarchical(entity=lognormal(1.0,0.25), within=lognormal(median, sigma_log per supplier)) |
median 11 days, sigma_log 0.35 | (0,inf) |
variability.supplier.lead_time |
The entity effect is the supplier's true reliability, which the scorecard is trying to estimate |
| Supplier OTIF (true rate) | beta_scaled |
lo 0, hi 1, mean 0.94, conc 90, provisioned per supplier | [0,1] |
variability.supplier.otif_rate |
Bounded ratio; the provisioned value is the ground truth the scorecard is scored against |
| Supplier short-ship fraction | beta_scaled |
lo 0, hi 1, mean 0.02, conc 300 | [0,1] |
variability.supplier.short_ship |
Bounded ratio |
| Supplier defect rate (true PPM) | beta_scaled |
lo 0, hi 0.05, mean 0.0021, conc 400, per supplier | [0,0.05] |
variability.supplier.defect_rate |
Provisioned per supplier so defect PPM trending has a real signal underneath |
| Defects per received lot | negbin |
mean = lot size times defect rate, disp 0.8 | N0 |
variability.supplier.lot_defect_count |
Defects cluster within a lot; this is the overdispersion acceptance sampling has to cope with |
| Supplier available capacity | beta_scaled |
lo 0.5, hi 1.15, mean 1.0, conc 60 | [0.5,1.15] |
variability.supplier.capacity_multiplier |
Capacity varies within physically meaningful bounds |
| PO acknowledgement lag | lognormal |
median 6 h, sigma_log 1.0 | (0,inf) |
variability.procurement.po_ack_lag |
Administrative delay, heavy-tailed |
| Three-way match exception | bernoulli |
p 0.041 | {0,1} |
variability.procurement.match_exception |
The exception rate that consumes real AP departments |
| RFQ bid price deviation from index | lognormal |
median 1.0, sigma_log 0.11 | (0,inf) |
variability.procurement.bid_deviation |
Multiplicative price variation is lognormal |
| Contract compliance (maverick spend) | bernoulli |
p 0.07 | {0,1} |
variability.procurement.maverick_rate |
Leakage quantification needs a rate to leak at |
| Transit time by mode | shifted_lognormal |
shift = free-flow lane time; median 0.18 times free-flow; sigma_log TL 0.30 / LTL 0.55 / parcel 0.42 | (free_flow, inf) |
variability.transport.transit_time |
Transit can never beat free-flow, so free-flow is the shift; everything above it is delay with no upper bound |
| LTL terminal touches | poisson |
mean 1.8 per lane | N0 |
variability.transport.terminal_touches |
Count of intermediate handlings |
| Terminal dwell per touch | gamma |
mean 9 h, shape 2.2 | (0,inf) |
variability.transport.terminal_dwell |
Right-skewed hold time |
| Carrier tender acceptance | bernoulli |
p from beta_scaled(0,1,0.91,70) per carrier per lane |
{0,1} |
variability.transport.tender_acceptance |
Bounded ratio provisioned per carrier-lane |
| Spot rate per lane | exp(ou(...)) |
log-rate reversion 0.05/day, sigma 0.09 | (0,inf) |
variability.transport.spot_rate |
Freight spot prices mean-revert in logs and are strictly positive |
| Diesel price index | exp(ou(...)) |
reversion 0.02/day, sigma 0.035 | (0,inf) |
variability.transport.fuel_index |
Same structure; drives the fuel-surcharge what-if |
| In-transit shock events | poisson |
mean 0.9 per 1,000 km | N0 |
variability.transport.shock_count |
Count of impact events |
| In-transit shock magnitude | gp_tail |
threshold 2.0 g, shape 0.28, scale 1.1 g | [2.0,inf) |
variability.transport.shock_magnitude |
Exceedances over a high threshold converge to the Generalized Pareto, B.13 ref 4; no upper bound, which is the point |
| Cargo temperature deviation | ou around ambient |
reversion 0.3/h, sigma 0.8 C | R |
variability.transport.cargo_temp_deviation |
Reefer control is a mean-reverting system |
| Customs or border clearance time | lognormal |
median 5 h, sigma_log 1.2 | (0,inf) |
variability.transport.clearance |
Very heavy tail; a small fraction sit for days |
| Invoice-to-payment lag | mixture: bernoulli(0.72) on-terms then gamma(mean=3 d, shape=2) past terms |
as shown | (0,inf) |
variability.finance.payment_lag |
Payments spike at term boundaries; a single smooth family would misrepresent the cash cycle |
| Customer payment default | bernoulli |
p 0.006 | {0,1} |
variability.finance.default_rate |
Feeds AR and working capital |
| Inventory shrink per cycle count | negbin |
mean 0.7 units per 1,000 counted, disp 0.5 | N0 |
variability.finance.shrink_count |
Shrink clusters by location |
| IT incident inter-arrival (twin's own stack) | exponential |
mean 96 h per monitored service | (0,inf) |
variability.itops.incident_interarrival |
Baseline service reliability |
| IT incident MTTR | lognormal |
median 38 min, sigma_log 1.0 | (0,inf) |
variability.itops.mttr |
Same maintainability rationale as physical repair |
| Change-failure per change | bernoulli |
p 0.09, multiplied by 2.4 for changes inside the Friday window | {0,1} |
variability.itops.change_failure_rate |
The Friday multiplier is a declared, testable modeling assumption, not folklore hidden in code |
| CVE arrivals per component | poisson |
mean 0.4 per component-quarter | N0 |
variability.itsec.cve_arrivals |
Vulnerability disclosure is a counting process |
B.8 Demand, orders, and customer service¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Demand per SKU per day (fast movers) | negbin around a structural mean |
mean = level x trend x weekly x annual x promo lift; disp 6.0 | N0 |
variability.demand.fast_mover |
Demand is overdispersed count data. Poisson would make the forecasting arena trivially easy, and so dishonest |
| Demand per SKU per day (slow movers) | croston(occurrence=bernoulli(0.14), size=negbin_shifted(1, mean 2.3, disp 1.1)) |
as shown | N0 |
variability.demand.slow_mover |
Intermittent demand needs a separate occurrence process, B.13 ref 13; without it the XZ cell of ABC/XYZ is empty |
| New product adoption | bass(p=0.03, q=0.38, m=SKU potential) plus negbin residual |
as shown | N0 |
variability.demand.npi_adoption |
The diffusion form is B.13 ref 12; the p and q defaults are judgment, see G.3. Gives the forecaster a cold start |
| Promotion lift multiplier realized | lognormal |
median = planned lift, sigma_log 0.30 | (0,inf) |
variability.demand.promo_lift_realised |
Plans are wrong multiplicatively; this is what makes marketing-mix ROI a causal question |
| Cannibalisation fraction | beta_scaled |
lo 0, hi 0.6, mean 0.18, conc 25 | [0,0.6] |
variability.demand.cannibalisation |
Bounded ratio |
| Forward-buy pull-forward fraction | beta_scaled |
lo 0, hi 0.8, mean 0.31, conc 20 | [0,0.8] |
variability.demand.forward_buy |
The effect that wrecks naive forecasts |
| Order inter-arrival within day | nhpp |
hourly profile with a pre-cutoff spike at 15:00-16:00 | (0,inf) |
variability.orders.interarrival |
The cutoff spike is real and it is what makes the cutoff what-if interesting |
| Order lines per order (wholesale) | negbin_shifted |
min 1, mean 8.4, disp 2.2 | {1,2,..} |
variability.orders.lines_wholesale |
Shifted so an order always has at least one line, with no truncation needed |
| Order lines per order (e-commerce) | negbin_shifted |
min 1, mean 1.9, disp 1.3 | {1,2,..} |
variability.orders.lines_ecom |
Parcel orders are short and overdispersed |
| Units per line | negbin_shifted |
min 1, mean 3.6, disp 1.8 | {1,2,..} |
variability.orders.units_per_line |
Same structure |
| Order change or cancel | bernoulli with a hazard rising toward cutoff |
base p 0.026 | {0,1} |
variability.orders.change_rate |
Cost depends on how far downstream the change lands, so the timing matters |
| Credit hold | bernoulli |
p 0.014 | {0,1} |
variability.orders.credit_hold |
Exception path in the order lifecycle |
| Sales rep forecast bias | hierarchical(entity=lognormal(1.09, 0.10), within=lognormal(1.0, 0.22)) |
as shown | (0,inf) |
variability.sales.rep_bias |
A persistent per-rep optimism the forecast-value-added analysis is meant to discover |
| Contacts generated per service failure | poisson |
mean by failure class: late 0.62, short 0.81, damaged 1.15 | N0 |
variability.service.contacts_per_failure |
Counting process driven by operational failure, which is the closed loop |
| Contact lag after failure | lognormal |
median 7 h, sigma_log 1.1 | (0,inf) |
variability.service.contact_lag |
Customers notice at very different speeds |
| Agent handle time | hierarchical(entity=lognormal(1.0,0.19), within=lognormal(median=340 s, sigma_log=0.55)) |
as shown | (0,inf) |
variability.service.handle_time |
Brown and colleagues (2005) report lognormal service times in a call center, B.13 ref 9; the agent effect is persistent skill |
| After-call work | gamma |
mean 65 s, shape 3 | (0,inf) |
variability.service.acw |
Short administrative tail |
| Escalation | bernoulli |
p 0.11, higher when order state is not visible to the agent | {0,1} |
variability.service.escalation_rate |
Makes the operational-visibility argument self-demonstrating |
| Repeat contacts on the same case | geometric |
p 0.78 | {1,2,..} |
variability.service.repeat_contacts |
Drives first-contact resolution |
| Caller patience before abandon | weibull |
shape 1.6, scale 210 s | (0,inf) |
variability.service.patience |
A Weibull patience lets the abandonment hazard vary with waiting time; shape 1 recovers the constant-hazard model, B.13 ref 10 |
| Churn hazard per service failure | discrete-time hazard, bernoulli per period |
logistic in cumulative failures and segment | {0,1} |
variability.service.churn_hazard |
Puts a revenue number on operational misses |
B.9 Labor and workforce¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Worker baseline productivity effect | lognormal provisioned once per worker |
median 1.0, sigma_log 0.17 | (0,inf) |
provision.workforce.productivity_effect |
A persistent worker trait; this is the operator variance component Gage R&R must recover |
| Within-worker task noise | lognormal per task |
median 1.0, sigma_log 0.12 | (0,inf) |
variability.workforce.task_noise |
The repeatability component |
| Learning curve rate | deterministic Wright power curve with a lognormal(1.0, 0.15) per-worker rate multiplier |
learning rate 0.88 | (0,inf) |
variability.workforce.learning_rate |
New hires ramp; the ramp is deterministic in form and variable in speed |
| Time to full productivity | derived from the learning curve | n/a | (0,inf) |
workforce.learning.threshold |
Listed so readers know it is derived, not drawn |
| Early-tenure error multiplier | deterministic decay to 1.0 over the ramp | start 2.4x | [1,inf) |
variability.workforce.tenure_error_multiplier |
Ties the HR layer to the quality findings |
| Absence per worker per shift | bernoulli with per-worker beta_scaled(0,1,0.041,55) propensity, plus a shift-level shared shock |
as shown | {0,1} |
variability.workforce.absence |
Absences correlate across a shift (weather, illness); independent Bernoulli would understate the staffing risk that rostering exists to manage |
| Shift-level absence shock | beta_scaled |
lo 1.0, hi 4.0, mean 1.06, conc 40, correlated with the weather state | [1,4] |
variability.workforce.absence_shock |
The shared component of correlated absence |
| No-show versus called-in-sick split | bernoulli |
p 0.28 no-show | {0,1} |
variability.workforce.no_show_split |
Changes how much notice the roster gets |
| Attrition hazard per month | bernoulli from a logistic hazard in overtime hours, strain index, tenure |
base 0.024/month | {0,1} |
variability.workforce.attrition_hazard |
Makes burnout show up as regretted turnover with cost attached |
| Requisition time to fill | lognormal |
median 24 days, sigma_log 0.6 | (0,inf) |
variability.workforce.time_to_fill |
Hiring lags demand, which is the whole point of the peak-season what-if |
| Offer acceptance | bernoulli |
p 0.71 | {0,1} |
variability.workforce.offer_acceptance |
Adds a second lag to headcount recovery |
| Certification pass | bernoulli |
p 0.89 | {0,1} |
variability.workforce.cert_pass |
Gates the skills matrix |
| Fatigue accumulation | deterministic accumulation from ergonomic load plus lognormal(1.0, 0.14) per-worker susceptibility |
as shown | [0,inf) |
variability.workforce.fatigue_susceptibility |
Individual tolerance varies; the accumulation itself is mechanistic |
| Near-miss events per exposure hour | poisson |
mean 0.019, scaled by proximity exposure and slip risk | N0 |
variability.safety.near_miss_rate |
Counting process; feeds the Heinrich-pyramid incident generation |
| Incident given near-miss frequency | bernoulli |
p 1/300 per near miss, configurable | {0,1} |
variability.safety.incident_ratio |
Heinrich's 300:29:1 ratio, B.13 ref 11, which is contested; it is config so a reader can change it |
| Injury severity given incident | categorical from dirichlet |
over | simplex | variability.safety.severity_mix |
Feeds TRIR and the insurance layer |
B.10 Sensors: the generic signal model¶
Every sensor type in config/sensors/*.yaml declares its reading as the same additive composition. Adding sensor type 300 is a catalog entry that fills in these slots, not new code.
reading(t) = quantise(
baseline(twin_state, t) # physics-informed, deterministic given state
+ unit_offset # provisioned once per device
+ drift(t) # slow, signed
+ noise(t) # fast
+ fault_overlay(t), # zero unless a fault instance is active
resolution)
| Component | Family | Parameters (catalog keys) | Support | Justification |
|---|---|---|---|---|
baseline |
deterministic function of twin state, optionally with a first-order lag | sensors.<type>.signal.baseline.*, lag_tau_s |
physical | The sensor measures something the twin already knows; thermal mass and similar dynamics are modeled as a lag, not as noise |
unit_offset |
normal(0, sigma_offset) provisioned once per device |
sensors.<type>.signal.unit_offset_sigma |
R |
Unit-to-unit variation is the gauge bias component in MSA, and it must be persistent per device to be measurable |
drift |
wiener(drift_per_day, sigma_per_sqrt_day), drift sign drawn per device |
sensors.<type>.signal.drift.* |
R |
Real drift has both a deterministic slope and a random-walk component; a pure ramp would be too easy to detect |
noise (white) |
normal(0, sigma_noise) |
sensors.<type>.signal.noise.sigma |
R |
Additive electrical noise on a measurement is unbounded even when the measured quantity is bounded, and that is correct |
noise (colored) |
ar1(phi, sigma) approximating 1/f |
sensors.<type>.signal.noise.phi |
R |
Vibration, strain, and current noise are not white; an AR(1) captures the autocorrelation that would otherwise make control limits wrong |
quantise |
deterministic rounding to ADC resolution | sensors.<type>.signal.resolution |
discrete grid | Resolution sets the number of distinct categories in MSA and must be modeled to make the discrimination ratio meaningful |
| Sample interval jitter | gamma(mean=nominal, shape=60) |
sensors.<type>.timing.interval_jitter |
(0,inf) |
Real devices do not sample on an exact grid |
| Clock offset | wiener plus a per-device skew_ppm ~ normal(0, 12) |
sensors.<type>.timing.clock |
R |
Baseline clock behavior; F-DEV-CLOCKDRIFT is an extreme value of the same mechanism |
| Per-sample dropout | bernoulli(p) with p ~ beta_scaled(0,1,0.0008,4000) per device |
sensors.<type>.reliability.dropout |
{0,1} |
Baseline lossiness, distinct from the injected outage faults |
No clamping rule, restated for sensors. A reading is never clipped to the physical range of the measured quantity. A thermocouple that reads -270 C after a calibration loss is emitted as -270 C, because that reading is exactly the fault signature a detector must catch. The validity range declared in sensors.<type>.validity is consumed by the metrics layer, which excludes out-of-range samples from KPI computation and raises a data-quality finding, while detectors read the raw stream. See open question G.9.
B.11 Sensors: per-type parameters¶
The authoritative per-type list is the YAML catalog, targeting 60 to 80 implemented types. This table fixes the family choices per type so a catalog entry cannot invent its own.
| Sensor type | Baseline | Noise | Drift | Notable support constraint | Config root |
|---|---|---|---|---|---|
| Vibration (velocity RMS) | function of speed, load, and bearing wear state | ar1(0.72, 0.11 mm/s) |
wiener(0.004/day, 0.002) |
[0,inf), so the noise composes multiplicatively in log space |
sensors.vibration_rms |
| Accelerometer (tri-axial, raw) | modal response to the drive train | ar1 per axis plus impulsive poisson/gp_tail hits |
bias wiener per axis |
R per axis |
sensors.accel_triaxial |
| Acoustic emission | hit rate rises with crack growth | hit count poisson, amplitude gp_tail(shape 0.31) |
threshold wiener |
[u,inf) amplitudes |
sensors.acoustic_emission |
| Strain gauge | load-proportional microstrain | normal(0, 4 ue) |
wiener(1.2 ue/day, 0.8) |
R (compression is negative) |
sensors.strain |
| Load cell / shelf weight | mass on the platform | normal(0, 0.08 kg) |
wiener(0.01 kg/day, 0.006) |
measurement R, true mass [0,inf) |
sensors.load_cell |
| Torque | drive torque from load | ar1(0.55, 0.6 Nm) |
wiener |
R (reverse rotation) |
sensors.torque |
| Rotary encoder / belt speed | commanded speed times slip factor | normal(0, 0.004 m/s) |
quantisation only | [0,inf) true speed |
sensors.encoder_speed |
| Thermocouple / RTD | ambient plus load rise, first-order lag tau=180 s |
normal(0, 0.35 C) |
wiener(0.02 C/day, 0.01) |
R |
sensors.temperature_contact |
| Motor temperature | as above with tau=420 s |
normal(0, 0.5 C) |
wiener(0.03 C/day, 0.015) |
R |
sensors.motor_temp |
| Motor current (RMS) | torque-proportional plus harmonic content | ar1(0.61, 0.09 A) |
wiener(0.004 A/day, 0.003) |
[0,inf) true current |
sensors.motor_current |
| Oil quality (particle count) | wear-rate integral | negbin(disp 1.2) per size band |
monotone rising with wear | N0 |
sensors.oil_particles |
| Ambient temperature | diurnal profile plus weather state | ou(reversion 0.5/h, sigma 0.4 C) |
wiener(0.01 C/day, 0.005) |
R |
sensors.ambient_temp |
| Relative humidity | weather state plus HVAC | ou(reversion 0.4/h, sigma 1.5 %) |
wiener(0.05 %/day, 0.03) |
true value [0,100], reading R |
sensors.humidity |
| Dew point | derived from temperature and humidity | inherited | inherited | R |
sensors.dew_point |
| CO2 | occupancy-driven step plus ventilation decay | ou(reversion 1.2/h, sigma 18 ppm) |
wiener(2 ppm/day, 1.1) |
[0,inf) true |
sensors.co2 |
| Particulate (PM2.5) | activity-driven | lognormal multiplicative noise |
wiener on the gain |
[0,inf) |
sensors.particulate |
| Water leak | binary state from an event | false-positive poisson(0.004/day) |
none | {0,1} |
sensors.water_leak |
| Thermal imaging | field derived from surface temperatures | normal(0, 0.9 C) per pixel plus fixed-pattern offset |
wiener on the offset field |
R |
sensors.thermal_camera |
| RFID reader | read events per B.3 | read outcome bernoulli |
read rate wiener downward |
{0,1} per pass |
sensors.rfid_reader |
| Barcode / QR scanner | scan events | outcome bernoulli, retries geometric |
none | {0,1} |
sensors.barcode_scanner |
| UWB location tag | true position from the twin | radial error rayleigh(0.19 m) with a bernoulli(0.03) multipath mixture at rayleigh(1.4 m) |
anchor bias wiener |
radial [0,inf) |
sensors.uwb_tag |
| BLE beacon RSSI | log-distance path loss | normal(0, 4.2 dB) shadowing |
wiener(0.05 dB/day, 0.03) |
R |
sensors.ble_beacon |
| Dock door state | binary from the twin | debounce chatter poisson(0.02/transition) |
none | {0,1} |
sensors.dock_door |
| Conveyor load cell | items on the section | normal(0, 0.6 kg) |
wiener |
measurement R |
sensors.conveyor_load |
| Pick-to-light | confirm events | latency gamma |
none | events | sensors.pick_to_light |
| Dock scale | trailer weight | normal(0, 12 kg) |
wiener(2 kg/day, 1.4) |
measurement R |
sensors.dock_scale |
| Trailer presence | binary | false read bernoulli(0.001) |
none | {0,1} |
sensors.trailer_presence |
| GPS / GNSS | true vehicle position | radial error rayleigh(2.6 m) with bernoulli(0.02) urban-canyon mixture at rayleigh(28 m) |
none | [0,inf) radial |
sensors.gnss |
| IMU (vehicle) | true motion | ar1 plus bias-instability wiener per axis (Allan-variance style) |
bias wiener |
R per axis |
sensors.imu |
| Fuel level | tank volume | slosh ar1(0.85, 1.8 L) |
wiener(0.2 L/day, 0.1) |
true [0,capacity] |
sensors.fuel_level |
| Tire pressure | temperature-compensated pressure | normal(0, 1.4 kPa) |
wiener(0.8 kPa/day, 0.4) slow leak |
measurement R |
sensors.tire_pressure |
| Engine temperature | load and ambient, tau=300 s |
normal(0, 0.8 C) |
wiener |
R |
sensors.engine_temp |
| Cargo temperature | reefer setpoint plus ou deviation |
normal(0, 0.25 C) |
wiener(0.015 C/day, 0.01) |
R |
sensors.cargo_temp |
| Cargo shock | impact events | count poisson, magnitude gp_tail |
none | [u,inf) |
sensors.cargo_shock |
| Cargo tilt | orientation | normal(0, 0.6 deg) |
wiener |
R |
sensors.cargo_tilt |
| Voltage | supply model | ar1(0.4, 0.9 V) |
wiener(0.02 V/day, 0.01) |
R |
sensors.voltage |
| Current (panel) | sum of loads | ar1(0.5, 0.7 A) |
wiener |
[0,inf) true |
sensors.panel_current |
| Power / power factor | derived from V and I | inherited plus normal(0, 0.006) on PF |
wiener on PF |
PF true [0,1] |
sensors.power_meter |
| Battery SOC | state from the AMR energy model | beta_scaled(0,1, true_soc, conc 900) measurement |
wiener on the estimator bias |
true [0,1] |
sensors.battery_soc |
| Battery SOH | monotone non-increasing state | normal(0, 0.004) measurement |
none | true (0,1] monotone |
sensors.battery_soh |
| UPS status | categorical state | transition bernoulli |
none | state set | sensors.ups_status |
| Smoke / gas / CO | concentration from an event model | false alarms poisson(0.0015/day) |
wiener on the baseline |
[0,inf) true |
sensors.gas_detector |
| E-stop status | binary from the twin | none | none | {0,1} |
sensors.estop |
| Machine guard | binary interlock | chatter poisson |
none | {0,1} |
sensors.machine_guard |
| Worker proximity | derived from UWB positions | inherited from UWB | inherited | [0,inf) |
sensors.worker_proximity |
| Fall detection | IMU-derived event | false positive bernoulli(0.0004/h) |
none | {0,1} |
sensors.fall_detect |
| pH | batch chemistry state | normal(0, 0.02 pH) |
wiener(0.01 pH/day, 0.006) |
true [0,14], reading R |
sensors.ph |
| Conductivity | batch state | normal(0, 12 uS/cm) |
wiener |
[0,inf) true |
sensors.conductivity |
| Viscosity | batch state and temperature | normal(0, 8 cP) |
wiener |
[0,inf) true |
sensors.viscosity |
| Flow (Coriolis) | commanded flow | normal(0, 0.4 % of rate) |
wiener(0.02 %/day, 0.01) |
R (bidirectional) |
sensors.flow_coriolis |
| Flow (magnetic) | commanded flow | normal(0, 0.8 % of rate) |
wiener |
R |
sensors.flow_magnetic |
| Level (radar) | vessel level | normal(0, 2.1 mm) plus bernoulli(0.006) foam-echo mixture |
wiener |
true [0,height] |
sensors.level_radar |
| Level (ultrasonic) | vessel level | normal(0, 6.5 mm), temperature dependent |
wiener |
true [0,height] |
sensors.level_ultrasonic |
| Dissolved oxygen | batch state | normal(0, 0.05 mg/L) |
wiener(0.02 mg/L/day, 0.01) membrane fouling |
[0,inf) true |
sensors.dissolved_oxygen |
| Corrosion (ER probe) | monotone metal loss | normal(0, 0.3 um) |
monotone rising | true monotone [0,inf) |
sensors.corrosion |
| Building strain | structural load | normal(0, 2.5 ue) |
wiener thermal |
R |
sensors.building_strain |
| Crack detection (AE) | as acoustic emission | poisson/gp_tail |
threshold wiener |
[u,inf) |
sensors.crack_ae |
| Roof load | snow and rain load from weather state | normal(0, 8 kg/m2) |
wiener |
[0,inf) true |
sensors.roof_load |
B.12 Exogenous drivers¶
| Quantity | Family | Parameters (defaults) | Support | Config key | Justification |
|---|---|---|---|---|---|
| Weather latent state | 3-dimensional ou over temperature anomaly, precipitation intensity, wind speed, with a declared correlation matrix |
reversion 0.15/h, sigma per axis | R^3 |
variability.exogenous.weather_state |
One shared latent driver, so demand, transit, energy, yard, and slip risk move together and the causal layer has a genuine confounder to find |
| Seasonal demand index | deterministic Fourier terms | annual and weekly harmonics | (0,inf) |
variability.exogenous.seasonality |
Structure belongs in the mean, not in the noise |
| Energy tariff | deterministic time-of-use schedule | per site | (0,inf) |
variability.exogenous.energy_tariff |
Listed so readers know it is not stochastic |
| Quarter-end sales push | deterministic multiplier on the last 10 days of a quarter | 1.35x | (0,inf) |
variability.exogenous.quarter_end |
The hockey stick that slams the DC every 13th week |
B.13 External references¶
This is the reference registry for the whole section, and sections C through F cite it by number. Applying R9 and D-11, a family choice, a signal signature, or a gate that appeals to something outside this repository names its source here with an edition and a locator, and carries a confidence tier.
Tier A is primary text retrieved and quotable. Tier B is two independent secondary sources in agreement. Tier C is one secondary source, and every tier C entry is attributed in the sentence that uses it. Nothing in this section rests on an unverified claim; where verification failed, section G records it as an open question instead.
| Ref | Claim it licenses | Source | Locator | Retrieved | Tier |
|---|---|---|---|---|---|
| 1 | Anderson-Darling weights the tails more than Kolmogorov-Smirnov does | NIST/SEMATECH e-Handbook of Statistical Methods | 1.3.5.14, Anderson-Darling Test | 2026-08-09, HTTP 200 | A |
| 2 | Three-sigma limits on a normal process leave 0.0027 outside, both tails together | NIST/SEMATECH e-Handbook of Statistical Methods | 6.3.1, What are Control Charts? | 2026-08-09, HTTP 200 | A |
| 3 | The bathtub has three regions, and Weibull hazard falls, flattens, or rises with its shape | NIST/SEMATECH e-Handbook of Statistical Methods | 8.1.2.4, 8.1.6.1, 8.1.6.2 | 2026-08-09, HTTP 200 | A |
| 4 | Exceedances over a high threshold converge to the Generalized Pareto | Pickands, "Statistical Inference Using Extreme Order Statistics", Ann. Statist. 3(1), 1975, DOI 10.1214/aos/1176343003; and Balkema and de Haan, "Residual Life Time at Great Age", Ann. Probab. 2(5), 1974, DOI 10.1214/aop/1176996548 | full records, Crossref | 2026-08-09, HTTP 200 | A |
| 5 | Logarithms of lognormal data are normal, so a product of delays is a sum in logs | NIST/SEMATECH e-Handbook of Statistical Methods | 8.1.6.4, Lognormal | 2026-08-09, HTTP 200 | A |
| 6 | Simultaneous horizontal and vertical crane travel makes cycle time a Chebyshev metric | Bozer and White, "Travel-Time Models for Automated Storage/Retrieval Systems", IIE Transactions 16(4), 1984, 329-338, DOI 10.1080/07408178408975252 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 7 | Rayleigh is the Weibull at shape 2 and models radial error from two equal independent normals | NIST/SEMATECH e-Handbook of Statistical Methods | 8.1.6.2, Weibull | 2026-08-09, HTTP 200 | A |
| 8 | QoS 1 is at-least-once, redelivery sets DUP, and a receiver must treat a repeat as a new message | MQTT Version 5.0, OASIS Standard, 07 March 2019 | 3.3.1.1 and 4.3.2, MQTT-3.3.1-1 and MQTT-4.3.2-5 | 2026-08-09, HTTP 200 | A |
| 9 | Call-center service times are lognormal | Brown and colleagues, "Statistical Analysis of a Telephone Call Center", JASA 100(469), 2005, 36-50, DOI 10.1198/016214504000001808 | bibliographic record, Crossref; full text not reached | 2026-08-09, HTTP 200/404 | C |
| 10 | The constant-hazard patience model is the one the Erlang-A queue assumes | Garnett, Mandelbaum, and Reiman, "Designing a Call Center with Impatient Customers", M&SOM 4(3), 2002, 208-227, DOI 10.1287/msom.4.3.208.7753 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 11 | One serious incident accompanies about 300 near misses | Heinrich's ratio, as stated by Patient Safety Team, BMJ Quality Improvement Reports, 2013, DOI 10.1136/bmjquality.u201086.w697 | abstract, Europe PMC PMC4652692 | 2026-08-09, HTTP 200 | C |
| 12 | The diffusion form with innovation and imitation coefficients | Bass, "A New Product Growth for Model Consumer Durables", Management Science 15(5), 1969, 215-227, DOI 10.1287/mnsc.15.5.215 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 13 | Intermittent demand needs a separate occurrence process | Croston, "Forecasting and Stock Control for Intermittent Demands", JORS 23(3), 1972, 289-303, DOI 10.1057/jors.1972.50 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 14 | NDEATH rides on the MQTT Will Message and bdSeq increments on every CONNECT | Eclipse Sparkplug Specification, version 3.0.0 | 2.4 and 5.4 | 2026-08-09, HTTP 200 | A |
| 15 | PCG64 low-58-bit collisions fail PractRand, the DXSM output function corrects that, and the residual is ordered below a 128-bit SeedSequence pool collision | numpy reference guide, "Upgrading PCG64 with PCG64DXSM" | full page | 2026-08-10, HTTP 200 | A |
| 16 | Nelson's rule numbering for the runs rules | Nelson, "The Shewhart Control Chart, Tests for Special Causes", J. Quality Technology 16(4), 1984, 237-239, DOI 10.1080/00224065.1984.11978921 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 17 | The exact in-control run length for the combined Western Electric rules | Champ and Woodall, "Exact Results for Shewhart Control Charts With Supplementary Runs Rules", Technometrics 29(4), 1987, 393-399, DOI 10.1080/00401706.1987.10488266 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 18 | False-discovery-rate control across a family of tests | Benjamini and Hochberg, "Controlling the False Discovery Rate", JRSS-B 57(1), 1995, 289-300, DOI 10.1111/j.2517-6161.1995.tb02031.x | bibliographic record, Crossref | 2026-08-09, HTTP 200 | A |
| 19 | The exact binomial confidence interval | Clopper and Pearson, "The Use of Confidence or Fiducial Limits Illustrated in the Case of the Binomial", Biometrika 26(4), 1934, 404-413, DOI 10.1093/biomet/26.4.404 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | A |
| 20 | Pooling chi-square cells so no expected count falls below five | Cochran, "Some Methods for Strengthening the Common Chi-Square Tests", Biometrics 10(4), 1954, 417, DOI 10.2307/3001616 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 21 | Anderson-Darling critical values depend on the tested distribution | Stephens, "EDF Statistics for Goodness of Fit and Some Comparisons", JASA 69(347), 1974, 730-737, DOI 10.1080/01621459.1974.10480196 | bibliographic record, Crossref, and ref 1 cites it | 2026-08-09, HTTP 200 | B |
| 22 | The power-law learning curve | Wright, "Factors Affecting the Cost of Airplanes", J. Aeronautical Sciences 3(4), 1936, 122-128, DOI 10.2514/8.155 | bibliographic record, Crossref | 2026-08-09, HTTP 200 | C |
| 23 | The BLAKE2b core function, and a 16-byte personalization at parameter-block offsets 48 to 63 | RFC 7693 for the core function, which states that personalization is outside its scope; Aumasson, Neves, Wilcox-O'Hearn, and Winnerlein, "BLAKE2: simpler, smaller, fast as MD5", for the parameter block; the Python hashlib documentation for the zero-padding |
RFC 7693 section 2.5 and Appendix A; BLAKE2 specification Table 1 and its worked parameter block; hashlib, BLAKE2 parameters | 2026-08-09, HTTP 200 | A |
| 24 | numpy's SeedSequence mixing constants, and the PCG64DXSM seeding, advance, and output path | numpy source, v2.3.0 | bit_generator.pyx, _pcg64.pyx, src/pcg64/pcg64.h, pcg64.c |
2026-08-09, HTTP 200 | A |
| 25 | The overlap relation for randomly seeded streams of a 128-bit-state generator | O'Neill, "Critiquing PCG's streams (and SplitMix's too)", PCG project blog, dated 2017-08-10, https://www.pcg-random.org/posts/critiquing-pcg-streams.html |
section "Solution 1: Larger State" | 2026-08-10, HTTP 200 | A |
| 26 | The DXSM design goals, its output function, the cheap multiplier, and its first testing report | O'Neill, DXSM announcement comment, numpy issue 13635, comment 506088698, dated 2019-06-27, https://github.com/numpy/numpy/issues/13635#issuecomment-506088698 |
whole comment, sections Goal, Details, Testing | 2026-08-10, HTTP 200 | A |
| 27 | The n**2 / 2**58 formula, the crossing hedge, and the PCG64DXSM PractRand non-detections |
numpy issue 16313, "The PCG implementation provided by Numpy has significant, dangerous self-correlation", opened 2020-05-20, closed 2021-05-05, https://github.com/numpy/numpy/issues/16313 |
comments 634764575 and 638589835 (Kern), 634903500 (O'Neill), 639151659 (Kern, PractRand logs), 726984639 (O'Neill) | 2026-08-10, HTTP 200 | A |
| 28 | The PCG project publishes no per-stream collision bound for the DXSM variant | O'Neill, "PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation", HMC-CS-2014-0905; the PCG project blog index; imneme/pcg-cpp at commit 871d0494 |
paper section 2.5.2 and no instance of the word collision; blog index pages 1 and 2, 30 posts, none on DXSM; commit message and README | 2026-08-10, HTTP 200 | A |
Two entries carry a warning rather than only a citation.
Ref 9 is tier C because the full text was not reached: the Wharton mirror returned HTTP 404 and the Technion mirror timed out, so only the Crossref record is verified. The sentence that uses it names the authors, and the family choice does not rest on the citation alone, since a handle time built from a fixed greeting, a variable diagnosis, and an occasional escalation is a product of delays and so lognormal by ref 5.
Ref 11 is tier C and the ratio it states is contested in the safety literature. It is exposed as variability.safety.incident_ratio so a reader can change it, the capability report prints the value it used, and open question G.13 records that the primary text was not retrieved.
C. The fault catalog: special-cause injection¶
C.1 How faults are injected¶
Two injection layers, kept strictly apart.
- Physical layer. The fault changes the twin's state or parameters: bearing wear multiplies the vibration baseline, a jam seizes a resource, a supplier outage stops ASN generation. Injected through a
FaultOverlayobject the twin consults when it evaluates a parameter. - Telemetry layer. The fault changes the message stream between the device agent and the broker: drop, duplicate, delay, reorder, corrupt, stall, or resend. Injected in the device agent's publish path and in the broker adapter.
Applying D-08, the telemetry layer injects at the Network port, which is the MQTT-shaped one, because every fault in C.4 is defined in terms of retained messages, quality of service, last will, or wildcard subscription. EventBus, which carries analytics fan-out and has no retain and no will, takes exactly two faults: F-SEC-RANSOM when the historian is the target, and F-IT-CHANGE when the change lands on the information layer rather than on the broker. A fault declaration names its port, and test_fault_declares_a_port_that_supports_its_primitives fails when an MQTT primitive is requested on EventBus.
Applying D-09, the primitives themselves are the kernel's. docs/design/foundations.md owns FaultKind, Fault, and FaultSchedule, and this section owns the catalog that composes them into named, labeled conditions. The kernel's Fault.rng_stream defaults to kernel.net.faults, which is the stream used when the kernel is exercised standalone in its own resilience tests. A catalog instance always overrides that default with its own fault.<fault_id>.<instance_id>.<quantity> name, so two instances of one fault class never share draws and R6 holds. test_catalog_instance_never_uses_the_kernel_default_stream fails on any materialised instance that leaves the default in place.
The separation is enforced, not merely documented. A stuck sensor does not stop the conveyor. Test test_telemetry_faults_are_physically_inert runs a scenario containing only telemetry-layer faults and asserts the physical KPI series is byte-identical to the fault-free run at the same seed.
That test only passes because of R6: faults transform samples, they never consume subsystem draws. A fault multiplies a parameter, replaces a returned value, or gates a publish. Any randomness a fault needs (jitter on a reconnect, which message a buffer drops, the exact progression of a degradation) comes from fault.<fault_id>.<instance_id>.<quantity>. Test test_fault_activation_does_not_change_draw_counts asserts the per-stream draw-count vector is identical with and without each fault.
C.2 The fault schedule¶
Two modes, both producing the same artifact.
- Explicit.
config/faults/<scenario>.yamllists instances with fixed onset times and parameters. This is what the labeled evaluation scenarios use, so their labels never move. - Stochastic. Fault classes declare arrival rates. At run start, before the sim clock advances, the
schedule.faultsstream samples the instances and materialises them into an explicit schedule, which is written toruns/<run_id>/fault_schedule.jsonand hashed into the manifest.
Materialising up front matters for two reasons. The fault stream never interleaves with subsystem streams, so scheduling cannot perturb the physics. And a counterfactual replay can carry the identical schedule forward verbatim while changing the config, which is what makes the counterfactual a controlled comparison instead of a new experiment.
Faults are not scheduled inside the LSS engine's Phase I baseline window unless the scenario explicitly opts in. SCN-CONTAM opts in on purpose, because a baseline computed on a contaminated window is a real and instructive SPC failure.
C.3 The ground-truth record¶
One record per fault instance, written to runs/<run_id>/ground_truth/faults.jsonl, plus a per-timestep companion series for degradation faults at runs/<run_id>/ground_truth/rul.parquet.
{
"fault_id": "F-MECH-BEARING",
"instance_id": "F-MECH-BEARING.0003",
"run_id": "b1f0...",
"target": { "kind": "asset", "id": "CNV-02-MTR", "subsystem": "conveyance" },
"onset_profile": "ramp",
"t_inject": 128400.0,
"t_effect_start": 128400.0,
"t_effect_end": 763200.0,
"t_clear": 763200.0,
"parameters": { "time_to_failure_h": 176.0, "severity_exponent": 2.1 },
"affected_streams": [
"site1/area-conv/line-1/CNV-02-MTR/vibration_rms",
"site1/area-conv/line-1/CNV-02-MTR/temperature",
"site1/area-conv/line-1/CNV-02-MTR/current_rms"
],
"affected_entities": {
"pallets": ["..."],
"lots": ["..."],
"orders": ["..."]
},
"expected_detectors": [
"spc.imr.vibration_rms",
"pdm.trend.vibration_rms",
"pdm.ttf.bearing"
],
"detectable_from": 165600.0,
"label_class": "special_cause",
"causal_edges": [
["bearing_wear", "vibration_rms"],
["bearing_wear", "motor_temp"],
["bearing_wear", "current_rms"]
],
"schedule_source": "explicit",
"compound_group_id": null
}
Field notes:
t_effect_startversust_inject: some faults are armed before they produce any observable effect (a firmware campaign that only misbehaves under load). Scoring usest_effect_start.detectable_fromis the earliest sim time at which the injected effect on the observed signal exceedsktimes the residual noise standard deviation, withkfromscoring.detectability_k(default 3). It is computed analytically from the fault parameters and the declared sensor model. It is what makes MTTD comparable across faults of different severities, and an alarm beforedetectable_fromis a leakage signal, not a triumph (see C.6).affected_entitiesis what the mock recall drill is scored against: the genealogy walk's blast radius is compared to this set for precision and recall.causal_edgesreference nodes in the declared causal graph, giving E30 a ground-truth structure to be scored on.
R7 enforcement. Ground-truth files live outside the historian and outside the UNS topic tree. Test test_no_label_leakage scans every table and topic a detector can read for any fault_id, instance_id, or ground-truth column name and fails on a match. Exported datasets (E25) may include labels only under exports/supervised/, and the dataset card states so.
C.4 Device and network faults¶
| ID | Description | Signal signature | Onset | Parameters | Subsystem | Expected detector | Ground truth emitted |
|---|---|---|---|---|---|---|---|
| F-DEV-DROP | Device loses its connection | Sample gap, the Will Message publishes the Sparkplug NDEATH (B.13 ref 14), no NBIRTH until the node returns | intermittent (two-state Markov) or step | mean_up_s, mean_down_s, n_devices, correlated |
device, gateway | fleet-health heartbeat timeout, message-gap monitor | instance with per-device up/down windows in affected_streams |
| F-DEV-CLOCKDRIFT | Clock skew grows until it breaks token minting and certificate validation | Source timestamps diverge from ingest time, then out-of-order or future-dated samples; JWT nbf/exp rejections; TLS notBefore/notAfter failures; historian rejects out-of-window writes |
ramp (ppm skew) then step at NTP resync | skew_ppm, resync_interval_s, jump_s, auth_tolerance_s |
device, gateway, security | timestamp-delta control chart, auth-failure rate chart, monotone-clock invariant on ingest order | instance plus the computed time at which skew crosses auth_tolerance_s |
| F-DEV-DUPREAD | Duplicate reads from one device | Same EPC and portal inside the dedup window; RFID counts exceed CV counts | step or intermittent burst | dup_probability, dup_multiplicity (geometric), burst_len |
auto-ID | RFID-versus-CV count reconciliation, p-chart on duplicate ratio | instance plus the exact duplicated message ids |
| F-DEV-GHOST | Phantom or cross-portal reads | Reads attributed to a portal the pallet never passed; genealogy shows a physically impossible transition | intermittent | cross_read_p, neighbor_portal |
auto-ID | genealogy plausibility rule, spatial-consistency check | instance plus the falsely attributed read ids |
| F-DEV-CRASHLOOP | Device reboots repeatedly | Repeated NBIRTH with bdSeq incrementing on each CONNECT and wrapping at 255 (B.13 ref 14), uptime resets, config back to defaults | cyclic | crash_period_s, boot_time_s, jitter_s |
device | births-per-hour chart, uptime monitor, config-compliance audit | instance plus each boot time |
| F-DEV-READRATE | Read rate degrades | Portal read rate shifts down; p-chart on reads per expected pass | ramp or step | delta_rate, ramp_h |
auto-ID | p-chart per portal; after E46, the physics-versus-device discriminator | instance plus the true read rate series |
| F-DEV-DRIFT | Sensor drift beyond the baseline rate | Slow bias growth; the six-in-a-row-increasing rule fires, then the nine-on-one-side rule (Nelson's set, B.13 ref 16, numbered in the LSS engine section) | ramp plus random walk | drift_rate_per_day, sigma_per_sqrt_day, sign |
any sensor | drift monitor, MSA bias re-check, cross-sensor residual chart | instance plus the true bias series |
| F-DEV-STUCK | Stuck-at fault | Variance collapses to zero; MR chart goes flat; the I-chart may look perfectly in control | step | mode (last, fixed, rail_high, rail_low), stuck_value, duration_s |
any sensor | zero-variance detector, moving-range chart, the fifteen-points-within-one-sigma rule (B.13 ref 16) | instance plus the held value |
| F-DEV-CALIB | Calibration loss (gain error, not just offset) | Scale error visible only across the measurement range; Gage R&R linearity fails | step or ramp | gain_error, offset_error |
any sensor | MSA linearity and bias study, cross-sensor ratio chart | instance plus the true gain and offset |
| F-DEV-NOISEUP | Noise floor rises (loose connector, EMI) | Moving-range chart shifts up while the mean is unchanged; Cpk collapses while the I-chart looks fine | step or intermittent | sigma_multiplier, duty_cycle |
any sensor | MR chart, capability trend, variance-ratio test | instance plus the true sigma multiplier |
| F-DEV-FIRMWARE | Botched firmware push | Canary ring only: unit change (C to F), scale error, schema violation, or crash loop; adoption curve stalls | step at each ring's rollout | ring_size, defect_type, rollback_threshold, ring_delay_s |
fleet lifecycle | canary health-score comparison, schema-registry validation failures, campaign KPI monitor | instance plus the exact device cohort that received the bad build, which is a clean two-sample test set |
| F-DEV-CERTEXPIRE | Certificate expiry or rotation failure | TLS handshake failures at a precise instant; device offline while the network is healthy | step | n_devices, expiry_time, rotation_fails |
device, security | auth-failure chart, fleet health, certificate-inventory audit | instance plus the affected device ids and expiry times |
| F-DEV-PROVISION | Zero-touch provisioning failure storm | Enrollment failure rate rises; devices stuck in pending; fleet count below plan |
step | failure_p, n_attempts |
fleet lifecycle | enrollment success-rate chart, fleet-inventory reconciliation | instance plus the failed enrollment ids |
| F-NET-BROKER | Broker outage followed by a reconnect stampede | Fleet-wide gap, then a replay burst far above steady-state rate; out-of-order source timestamps; historian write backpressure | step down, step up with stampede | outage_s, reconnect_jitter_s (set to 0 to reproduce the pathology), backoff |
broker, ingest | fleet-wide message-rate chart, ingest-lag SLO, error-budget burn | instance plus the expected replay volume, so store-and-forward can be checked for zero loss below buffer capacity |
| F-NET-BUFOVERFLOW | Device buffer overflows during an outage; loss is bounded | Monotone gap in device sequence numbers; the device reports a drop counter; counts short against CV; loss is bounded by buffer depth | step, once the outage exceeds buffer depth | buffer_depth_msgs, drop_policy (drop_oldest, drop_newest) |
device, ingest | sequence-gap monitor, genealogy closure violation, count reconciliation | instance plus the exact list of dropped message ids, so loss accounting is exact rather than estimated |
| F-NET-LATENCY | Uplink congestion inflates latency | Publish-to-historian delta shifts right with a heavier tail; the mean may move less than the 99th percentile | ramp or intermittent | median_multiplier, sigma_log_delta, duty_cycle |
network | latency SLO chart, percentile monitor | instance plus the true latency parameters |
| F-NET-REORDER | Out-of-order delivery | Ingest order disagrees with source order; windowed aggregations shift | intermittent | reorder_p, max_displacement |
network, ingest | monotone-source-timestamp counter, reorder rate monitor | instance plus reordered message ids |
| F-NET-REDELIVER | QoS 1 redelivery after reconnect | A retransmitted PUBLISH sets DUP, and a receiver must treat a repeat of an acknowledged packet identifier as a new message (B.13 ref 8) | step at reconnect | redeliver_fraction |
broker, ingest | idempotency assertion in the historian writer, duplicate-id counter | instance plus redelivered message ids |
| F-SEC-SPOOF | Spoofed Sparkplug messages from a rogue publisher | Metrics from a device id that already holds a live session; sequence-number conflicts; traffic crossing a Purdue conduit that must not carry it | intermittent | n_messages, target_device, zone_crossed |
security, broker | SIEM-analog correlation rule, conduit baseline deviation, session-conflict detector | instance plus spoofed message ids and the zone crossing |
| F-SEC-RANSOM | Historian or MES-analog encrypted | Ingest write failures, historian unavailable, restore drill triggered | step | component, encrypt_at, backup_age_h |
IT, security | ingest failure alarm, SLO breach, backup-restore drill scoring | instance plus RPO and RTO ground truth for the readiness report |
| F-IT-CHANGE | Bad configuration change to the broker | Latency or loss steps at a time that coincides with a change record | step | change_time, effect (latency, loss, auth), magnitude |
IT ops | change-failure-rate metric, onset-versus-change-window correlation | instance plus the change record id, so the correlation can be scored |
| F-CV-OCCLUSION | Camera occluded or lighting changes | CV counts diverge from RFID counts while RFID is demonstrably healthy | step or ramp | occlusion_fraction, illuminance_delta |
vision | sensor-disagreement audit finding, CV-versus-RFID reconciliation | instance plus the true CV miss rate, so the audit can be scored on attributing the disagreement to the right sensor |
C.5 Process, supply, and exogenous faults¶
| ID | Description | Signal signature | Onset | Parameters | Subsystem | Expected detector | Ground truth emitted |
|---|---|---|---|---|---|---|---|
| F-MECH-BEARING | Bearing degrades to failure | Vibration RMS rises, bearing-defect frequency energy grows, temperature follows with lag, current rises slightly, then a failure event | ramp (power law) terminating in failure | onset_time, time_to_failure_h, severity_exponent |
conveyance, factory machines | PdM trend with time-to-threshold, I-MR on vibration, learned model versus the trend baseline | instance plus a per-timestep true remaining-useful-life series in rul.parquet |
| F-MECH-CURRENT | Motor current rises from binding or misalignment | Current RMS up, power factor down, energy per pallet up, temperature up | ramp or step | current_multiplier, ramp_h |
conveyance, factory | current control chart, energy-per-unit finding, PdM trend | instance plus the true current multiplier series |
| F-MECH-BELTSLIP | Belt slips | Encoder speed diverges from commanded; throughput falls; item spacing variance rises; current pattern changes | intermittent then ramp | slip_fraction, duty_cycle |
conveyance | speed-versus-command residual chart, throughput chart, gap-variance chart | instance plus the true slip series |
| F-CONV-JAM | Conveyor jam at an elevated rate | Photo-eye blocked beyond threshold; upstream accumulation; downstream starvation | step (rate multiplier) with per-jam clear durations | rate_multiplier, mean_clear_min, section |
conveyance | jam-rate p-chart, starvation and blocking time, process-mining variant shift | instance plus the individual jam start and clear times |
| F-SORT-MISDIVERT | Diverter fails intermittently | Mis-sort rate rises; recirculation counts rise; a rework loop appears in the mined model | step or intermittent | success_delta, diverter_id |
sortation | p-chart on mis-sorts, process-mining rework-loop detector | instance plus the mis-sorted item ids |
| F-ASRS-CRANE | ASRS crane fault | One aisle's throughput drops to zero; the queue builds only in that aisle; retrieval SLA misses | step with a duration | aisle_id, duration_min |
ASRS | aisle throughput chart, retrieval SLA monitor, availability decomposition | instance plus the affected retrieval task ids |
| F-AMR-DEADLOCK | AMR fleet deadlocks in a corridor | Several AMRs stationary with assigned tasks; UWB positions static; batteries drain while idle; zone throughput drops | step, resolved on timeout | n_robots, corridor_id, timeout_s |
AMR fleet | zone throughput chart, task-age monitor, stationary-with-task detector | instance plus the deadlocked AMR ids and the resolution time |
| F-AMR-CONGEST | AMR congestion | Task durations inflate in the tail more than the mean; travel time per meter degrades | ramp with load | density_multiplier, zone_id |
AMR fleet | travel-time-per-meter chart, task-duration percentile monitor | instance plus the true congestion multiplier series |
| F-AMR-BATTERY | Battery health or charger fault | Usable capacity falls, charge time rises, charging trips become more frequent, fleet availability falls | ramp | soh_delta, charge_efficiency_delta, n_units |
AMR fleet | SOH trend, charge-time chart, fleet availability | instance plus the true SOH series |
| F-DOCK-CONGEST | Dock congestion | Queue length and detention rise; appointment adherence falls; shared inbound and outbound doors amplify it | ramp (arrival surge) or step (door out of service) | arrival_multiplier, doors_out, duration_h |
docks, yard | queue-length chart, detention chart, appointment adherence | instance plus per-truck queue entry and exit times |
| F-DOCK-DOOROOS | Dock door out of service | One door's utilization goes to zero, others saturate | step | door_id, duration_h |
docks | door utilization chart, capacity finding | instance plus the door id and window |
| F-SUP-BADLOT | Supplier ships a defective lot | Inbound defect rate spikes for one lot; returns spike weeks later; certificate of analysis out of spec | step, confined to a lot | supplier_id, lot_id, defect_rate, defect_mode |
supplier, quality | acceptance sampling, incoming defect p-chart, returns reason Pareto, genealogy trace-forward | instance plus the exact defective unit ids and the full true blast radius, which is what the recall drill is scored against |
| F-SUP-OUTAGE | Supplier goes down | ASNs stop; PO acknowledgements slip; downstream stockouts; echelon buffers drain | step, multi-day | supplier_id, duration_days, partial_capacity |
supplier, planning | OTIF scorecard chart, receipt-gap monitor, service-level chart | instance plus the suppressed PO and ASN ids |
| F-SUP-LEADTIME | Supplier lead time shifts | Lead time distribution moves right; safety stock is undersized; reorder points fail | step or ramp | supplier_id, median_multiplier, sigma_log_delta |
supplier, planning | lead-time control chart, scorecard, safety-stock adequacy finding | instance plus the true lead-time parameters |
| F-PROC-CHANGEOVER | Changeover overruns | Setup time distribution shifts right; schedule adherence falls; downstream starvation | step | median_multiplier, product_pair |
factory | changeover control chart, SMED analysis, schedule adherence | instance plus per-changeover true durations |
| F-PROC-STARVE | Upstream material starvation | A stage idles with no input; WIP drains between stages; takt is missed | step | stage_id, duration_min |
factory | machine-state decomposition (starved time), OEE availability loss | instance plus the starved intervals |
| F-QUAL-DRIFT | Quality drifts at an upstream production stage | Stage SPC mean shift or variance growth; Cpk decays; downstream first-pass yield falls; returns rise weeks later | ramp | stage_id, shift_sigma, variance_multiplier, ramp_h |
factory, quality | in-line SPC at the stage versus inbound inspection at the DC; the scored quantity is the detection-latency difference between the two | instance plus the true process mean and sigma series, and the affected lot ids |
| F-SLOT-DECAY | Slotting goes stale as the demand mix shifts | Travel distance per pick rises; picks per hour fall; no single station looks broken | slow ramp | mix_shift_rate, duration_days |
slotting | travel-per-pick control chart, re-slot payback finding | instance plus the true velocity-share trajectory |
| F-DEM-SHOCK | Demand shock | Order volume steps; forecast error spikes; forecast bias chart violates; dock congestion follows | step, ramp, or spike with decay | multiplier, category_scope, duration_days, decay_rate |
demand, planning | forecast-error control chart, bias chart, capacity finding | instance plus the true demand multiplier series by category |
| F-EXO-WEATHER | Named severe weather event | Correlated shifts across lane transit time, yard productivity, HVAC energy, slip and near-miss rate, and demand by category, all at once | ramp, peak, decay over hours to days | event_type (heat wave, winter storm), intensity, footprint, duration_h |
exogenous, all | multivariate detector, correlated-shift finding, and for E30 the test of whether causal discovery finds the shared cause rather than spurious pairwise edges | instance plus the full latent weather state series, which is the confounder ground truth |
| F-LAB-SHORT | Labor shortage | Staffed heads below plan; stations starve; overtime rises; error rate rises through the fatigue path | step (one shift) or ramp (attrition) | absence_multiplier, shift_scope, duration_shifts |
workforce | staffing-versus-plan chart, throughput chart, error-rate chart | instance plus the absent worker ids and the true staffing series |
| F-LAB-TURNOVER | Attrition wave | Experienced heads leave; the learning-curve mix regresses; error rate and cycle time rise together | ramp over weeks | hazard_multiplier, role_scope |
workforce | turnover rate chart, time-to-productivity mix, quality findings | instance plus the departing worker ids and their tenure |
| F-COLD-EXCURSION | Cold-chain excursion in transit | Cargo temperature leaves the specified band for a measurable duration; affected lots must be quarantined | step or ramp during a transit leg | shipment_id, delta_c, duration_min |
transport, quality | excursion detector, lot quarantine finding, insurance claim trigger | instance plus the affected lot ids and the true temperature series |
| F-TRANS-DISRUPT | Lane or carrier disruption | Transit times on a lane shift right; tender acceptance falls; expedite costs rise | step | lane_id, carrier_id, median_multiplier, duration_days |
transport | lane scorecard chart, transit control chart, freight-spend finding | instance plus the affected shipment ids |
C.6 Scoring detectors against truth¶
Matching rule. A detector alarm at sim time t on stream s is a true positive if an unmatched fault instance exists whose affected_streams contains s and for which t_effect_start <= t <= t_clear + scoring.grace_s. Each alarm matches at most one instance, resolved earliest-instance-first. Alarms outside every window are false positives. Instances with no matched alarm are false negatives.
Reported metrics, per fault class and overall:
- Precision, recall, F1.
- Mean time to detect, computed from
t_effect_start, and a second figure computed fromdetectable_fromso severities are comparable. - False alarms per 1,000 operating hours, reported against the chart's in-control average run length rather than against zero. This is the metric that makes the document's central point measurable. Three-sigma limits on a normal process leave 0.0027 of the distribution outside, both tails together (B.13 ref 2), so a single-rule individuals chart has an in-control average run length of
1 / 0.0027 = 370.4samples. A chart applied to an untransformed lognormal cycle time produces far more than that, and the excess is a mis-specified chart rather than a noisy process. The combined Western Electric rule set has its own exact in-control run length, near 91.75, from Champ and Woodall (B.13 ref 17), and a chart running that rule set is compared against 91.75 rather than against 370.4. - Detections before
detectable_from, whose expected count is zero. A non-zero count is investigated as a label-leakage or windowing bug, not celebrated.
Applying D-11, the false-alarm comparison is a gate and carries all four of a gate's parts. Its reference is external and named above. Its tolerance is set from the Monte Carlo noise floor of the measurement rather than chosen: the count of false alarms in a fault-free window of n samples is binomial, so the noise floor is the Clopper-Pearson interval around the count (B.13 ref 19). The gate declares its own sample size rather than inheriting whatever SCN-CLEAN happens to produce, and F.7 states both the size and the resulting interval. Its falsifier is stated: a detector whose measured in-control run length on SCN-CLEAN falls outside the interval around its chart's published value has either a wrong control limit or a wrong distributional assumption, and the report names which by re-running the same series under lss.chart_selection: declared. VG-VAR-01 in F.7 records the gate.
Because ground truth exists, every one of these is measured rather than asserted. That is the differentiator stated plainly in section D.
C.7 Labeled scenario packs¶
These are the fixed evaluation set. Each is a named config under config/scenarios/, with a pinned seed and a pinned fault schedule, and each has a golden ground-truth file.
| Scenario | Contents | Purpose |
|---|---|---|
SCN-CLEAN |
30 sim days, no faults | Measure the false alarm rate of every detector against nominal. Any detector that cannot pass this is not evaluated further |
SCN-DEVICE |
Every fault in C.4, spaced so windows do not overlap | Device and network detection precision and recall |
SCN-PDM |
12 F-MECH-BEARING and F-MECH-CURRENT instances across assets and severities | Predictive maintenance scoring with true RUL, and the baseline-versus-learned comparison the repo requires before shipping any model |
SCN-SUPPLY |
Supplier, demand, transport, and weather faults | Planning and network detection, and the causal layer's confounder test |
SCN-QUALITY |
F-QUAL-DRIFT plus F-SUP-BADLOT with full genealogy | Source SPC versus inbound inspection detection latency, and recall-drill blast-radius scoring |
SCN-MIXED |
Overlapping instances on shared assets and streams | The hard case: compound conditions, competing explanations, alarm flooding |
SCN-CONTAM |
A fault active during the Phase I baseline window | Demonstrates and measures the baseline-contamination failure mode |
SCN-RARE |
No faults, but seeded so the run contains known extreme common-cause tail draws | The scenario this whole document exists for: prove detectors distinguish a real tail from a special cause. Seeds are found by an offline sweep that records which (base_seed, replication_index) pairs produce an unload duration beyond the 0.9999 quantile, and the winning seeds are pinned in the scenario file so the run stays deterministic |
D. Common cause versus special cause¶
D.1 How a reader tells them apart¶
The test is mechanical, and that is the point.
- If the variation's source is an entry in
config/distributions.yamlor a sensor catalog signal model, it is common cause. It is the process behaving as designed, including its tails. - If the variation's source is an instance in the run's materialised fault schedule and appears in
ground_truth/faults.jsonl, it is special cause. It has an identifiable assignable origin, a start, and usually an end.
There is no third category and no ambiguity about which file to look in.
D.2 Why both exist¶
Without heavy, honest common-cause variation, every detector scores near-perfectly and the repo proves nothing. A control chart on a low-variance, thin-tailed process is a trivial exercise, and any anomaly detector clears it. Removing tails to make results look good would be exactly the dishonesty the repo is built to avoid, which is why R3 forbids truncation and CI lints for clamps.
Without labeled special causes, no detector can be scored at all. Real deployments live in this state permanently: an alarm fires, someone investigates, and the truth is whatever the investigation concluded. Precision and recall are unavailable because there is no answer key.
The boundary between the two is the thing the statistical engine is being tested on. The engine's entire job is to look at a stream that contains both and say which is which, on time, without flooding the operator.
D.3 The two declared boundary cases¶
Honesty requires naming where the boundary is genuinely fuzzy. There are exactly two places, and both are declared rather than hidden.
Rate-elevated event classes. Jams, mis-sorts, absences, and device dropouts all occur at a baseline rate in the distribution catalog and appear in the fault catalog as rate multipliers. A jam at the baseline rate is common cause. A jam inside an F-CONV-JAM window is special cause. Individual events are not labeled; the window is. Scoring so treats the window as the unit of truth, and a detector that flags a single baseline jam outside any window is counted as a false positive, correctly.
Common-cause tail draws. A lognormal unload duration can produce a value that looks exactly like a fault. It is labeled common cause because that is what it is. A detector that flags it is scored as a false positive. The document states plainly that a fraction of those false positives are the chart behaving correctly at its nominal false alarm rate, which is why C.6 requires false alarms to be reported against the theoretical in-control average run length rather than against zero.
This is also why chart selection matters, and why the engine gets two modes:
lss.chart_selection: empirical(default). The engine infers the distributional shape from a fault-free warm-up window and chooses the chart and any transformation from that. This is the honest number and it is the one the README publishes.lss.chart_selection: declared. The engine reads the family directly fromdistributions.yaml. This is an upper bound on achievable performance, useful for separating "the chart was wrong" from "the signal was not there".
Both are reported in the capability report. Publishing only the second would be cheating, and publishing only the first would hide how much of the gap is chart mis-specification.
D.4 The differentiator, stated plainly¶
Because the injection is known, twinflow measures detector precision, recall, and mean time to detect against truth. No real deployment can do this. A live plant can tell you an alarm fired and, eventually, what an engineer concluded; it cannot tell you how many real faults the detector silently missed, because the missed ones leave no record. That asymmetry is why published anomaly-detection performance in industry is mostly precision-shaped and almost never recall-shaped.
The same property applies across four layers, and each is scored the same way:
- Anomaly detection is scored against the fault windows.
- Predictive maintenance is scored against the true remaining-useful-life series.
- Process mining conformance is scored against the twin's designed reference model, which is a known ground-truth process. Applying D-14, the miner doing that scoring is
twinflow-procmine, written in this repository under Apache-2.0, because PM4Py andpm4pyminimalare AGPL-3.0 and section 13 of that license triggers on network interaction, which this project has in three places. PM4Py stays available as a development-only oracle that CI compares against without distributing or serving it, which is what gives the conformance numbers an external reference under D-11. - Causal discovery is scored against the declared causal graph and, in the weather case, against a known confounder.
The repo's shipping rule follows directly: a learned model ships only if it beats the statistical baseline on these labeled scenarios, and both numbers are published. This catalog is that labeled test set.
One honest limitation is stated in the same breath, in the README and here: synthetic labels are only as good as the generator that produced them. The measured numbers describe performance on twinflow's declared distributions and declared faults. They are not a claim about performance on any real operation, and the catalog is published in full so a reader can judge whether the assumptions are reasonable.
E. Reproducibility contract¶
E.1 What is recorded per run¶
Applying D-01, run identity is two objects and not one. The hashed core rides inside run_started, the first event in the log. The provenance sidecar is written to runs/<run_id>/manifest.json beside events.ndjson and never enters an event or a hash. docs/design/foundations.md section 3.3 owns both shapes (D-09); what follows is the stochastic view of them, which is the part this section is responsible for.
The split exists because the earlier single object carried a wall-clock start and a platform fingerprint into the first event of the log. That made the byte-identity tier unreachable by construction: two runs seconds apart could never match, and the cross-platform gate could never pass.
Hashed core, carried by run_started.
{
"run_id": "run_<26-char Crockford base32 of the content hash of this object>",
"base_seed": 8123456789012345678,
"replication_index": 0,
"mode": "simulation",
"scenario": "SCN-PDM",
"config_hash": "b2:...",
"schema_snapshot_hash": "b2:...",
"faults_hash": "b2:...",
"tick_hz": 1000,
"horizon_ticks": 2592000000,
"warmup_ticks": 172800000,
"rng": {
"mechanism": "SeedSequence(content-addressed spawn_key) -> PCG64DXSM",
"bit_generator": "PCG64DXSM",
"spawn_key_algorithm": "blake2b-128, person=twinflow-rng zero-padded to 16, 4x uint32 LE",
"stream_catalog_version": "3.2.0",
"distribution_catalog_version": "3.2.0"
}
}
Provenance sidecar, written to manifest.json.
{
"run_id": "run_...",
"invocation_id": "ULID, unique per invocation even when run_id repeats",
"derived_from": null,
"started_wall_utc": "...",
"finished_wall_utc": "...",
"config": {
"facility_yaml_sha256": "...",
"distributions_yaml_sha256": "...",
"sensor_catalog_sha256": "...",
"spec_limits_sha256": "...",
"metrics_layer_sha256": "...",
"resolved_config_sha256": "...",
"resolved_config_path": "runs/<run_id>/resolved_config.yaml"
},
"fault_schedule": {
"mode": "explicit",
"source_path": "config/faults/SCN-PDM.yaml",
"source_sha256": "...",
"materialised_sha256": "...",
"n_instances": 12
},
"rng_draw_counts_sha256": "...",
"stream_count_declared": 61402,
"code_version": {
"git_sha": "...",
"git_dirty": false,
"packages": {
"twinflow-rng": "0.9.1",
"twinflow-dist": "0.9.1",
"twinflow-faults": "0.9.1"
},
"python": "3.12.7",
"numpy": "2.1.3",
"simpy": "4.1.1",
"platform": {
"os": "linux",
"arch": "x86_64",
"libm": "glibc-2.39",
"cpu_features": ["avx2"],
"simd_disabled": ""
}
},
"sim_clock": { "t0_sim": 0.0, "t_end_sim": 2592000.0, "compression": "afap" },
"schemas": {
"telemetry": "2.1.0",
"finding": "1.4.0",
"genealogy": "1.2.0",
"order": "1.1.0",
"gl_posting": "1.0.0"
},
"warmup": { "warmup_hours": 48, "phase1_window_hours": 168 },
"outputs": {
"event_log_sha256": "...",
"findings_sha256": "...",
"ground_truth_sha256": "...",
"capability_report_sha256": "..."
}
}
The split has four consequences, and each one is a place an implementer would otherwise put a field on the wrong side.
- Git provenance is not part of run identity.
run_idaddresses the inputs of an experiment: seed, replication, config, schema snapshot, fault schedule, scenario, mode. A code change that leaves those inputs alone produces the samerun_idand a possibly different log, which is exactly the conditiontwinflow replayis built to detect and report against the CHANGELOG compatibility table. Folding the git sha into the id would hide that condition by making every build's run a different experiment. - The draw-count hash is sidecar. It is computed after the last draw, so the first event cannot carry it. It is compared between two runs of the same
run_id, and a difference names a draw-order change rather than a value change. stream_count_declaredis sidecar and is checked againstSTREAM_COUNT_CEILINGof A.1a on every run, so the collision bound is monitored on real configurations rather than assumed from the registry.- The carve-out is tested, not trusted.
test_run_started_carries_no_wall_clock_or_platform_fieldwalks therun_startedpayload schema and fails on any property whose name or type is a wall-clock instant, a host name, a package version, or a platform field. A field added later to the sidecar cannot drift into the hash without that test failing.
resolved_config.yaml is the config after defaults, includes, profile overlays, and environment overrides have been applied. It is the artifact that determines behavior, so it is the one that is hashed and stored, not the source files alone. invocation_id keeps two deliberate repeats distinguishable and lives in the sidecar, because a value that is unique per invocation would break byte identity if it entered the log.
E.2 Replay¶
Reads the manifest, restores resolved_config.yaml, restores the materialised fault schedule, re-runs, and asserts every hash in outputs matches. If code_version.git_sha differs from the current checkout, the CLI prints the difference and consults the compatibility table in CHANGELOG (per C6) to say whether the release is expected to reproduce that run. It does not silently proceed.
E.3 Counterfactual replay¶
twinflow replay --run <run_id> --set stations.putaway.scan_portals=2 --label "second portal at dock 3"
Rules:
base_seedandreplication_indexare carried over unchanged.- The materialised fault schedule is carried over verbatim. It is not resampled. The same faults hit at the same times, so the arms differ only by the config change.
- The config change is applied to the resolved config, producing a new resolved config, a new hash, and a new
run_id. The manifest setsderived_fromto the parent and stores the config diff. - The runner emits
crn_integrity.json: the per-stream draw counts for both arms and the set of streams present in only one arm. - The LSS engine reads
crn_integrity.jsonand applies a paired test only when every shared stream's draw counts match, otherwise an independent-samples test, and prints which it used and why.
Point 5 is the part a naive implementation gets wrong. Common random numbers give partial variance reduction whenever the change alters how many draws a stream produces, and treating a partially-aligned comparison as paired inflates the apparent significance of the improvement. The engine refuses to do that.
E.4 Determinism hazards that are actively managed¶
- Global RNG state. Forbidden repository-wide by lint
TWF-RNG-003. - Set iteration. Forbidden in drawing paths by
TWF-RNG-002, and the behavioral backstop is CI running the determinism scenario twice under differentPYTHONHASHSEEDvalues (D-03). - Dict ordering. Python dicts are insertion-ordered, which is enough, but insertion order must not depend on filesystem enumeration order. All directory scans are sorted, and any dict built from a set is sorted before use.
- Event-queue ties. Scheduling order fixed as
(sim_time, priority, monotonic_event_counter). The canonical order of the log is the separate(sim_ts, producer_id, seq)of D-07, and A.2 rule 4 states why the two are not the same thing. - Wall clock. Applying D-02, a wall clock may be read in exactly four places: the provenance sidecar writer, the paced-clock pacer, the observability exporter, and operator-facing log lines. In all four the value never enters an event payload, never enters the hashed tape, and never steers control flow. The pacer is the apparent exception and is not one: pacing changes when an event is emitted in wall time, never which event or in what order, and
test_pacing_does_not_change_the_tapecompares a paced run against an unpaced one. Everything else reads the injectedClock. - Hostname, PID, UUID4. Never enter the event log. Lint
TWF-DET-001fails onuuid4,time.time,datetime.now,socket.gethostname, andos.getpidin the twin, sensor, and LSS packages, andscripts/checks/nondeterminism-gate.shenforces the same outside the kernel package. - Threading and BLAS. Runs marked deterministic set
OMP_NUM_THREADS=1and equivalents, recorded in the manifest. - Float serialization. Floats are written with shortest round-trip repr, so a value that is bit-identical serializes identically.
- Transcendental functions.
log,exp, anderfinvare SIMD-dispatched in numpy and can differ across CPU microarchitectures and library builds. The byte-identity guarantee is so scoped to the platform recorded in the provenance sidecar. Across platforms, CI asserts the weaker contract in E.5. See open question G.1. - Machine learning training. Not covered by the byte-identity guarantee. Framework nondeterminism is out of scope here; model artifacts are versioned and hashed by the MLOps layer instead, and the guarantee in this section covers the simulation event log, the findings stream, and the ground-truth files. Inference is a different matter and is covered by A.8, because inference inside the loop steers the tape while training does not.
E.5 What CI asserts¶
| Check | Assertion | Where |
|---|---|---|
determinism_same_platform |
Run SCN-CLEAN twice in one job; event_log_sha256, findings_sha256, and ground_truth_sha256 are equal |
PR CI, every matrix cell |
determinism_hashseed |
Run SCN-CLEAN twice with different PYTHONHASHSEED values; the three hashes are equal (D-03) |
PR CI |
determinism_cross_platform |
See below. Business events identical, continuous fields inside a measured tolerance, and the observed maximum divergence reported (D-05) | PR CI |
replay_reproduces |
twinflow replay on a stored fixture run reproduces all output hashes |
PR CI |
counterfactual_carries_schedule |
A counterfactual replay's materialised fault schedule hash equals the parent's | PR CI |
no_clamp_in_sampler |
No clip, clamp, min(, max(, or np.minimum/np.maximum in the sampling path unless annotated # support-bound: <reason> where the bound is part of the declared support |
PR CI |
no_label_leakage |
No fault_id, instance_id, or ground-truth field name appears in any detector-readable table or UNS topic |
PR CI |
stream_append_non_perturbing |
See F.4 | PR CI |
registry_append_only |
No stream name removed or altered without retired_at and a major bump |
PR CI |
stream_count_under_ceiling |
Declared plus template-expanded stream count is below STREAM_COUNT_CEILING (A.1a) |
PR CI |
rng_cross_language_parity |
Python and Rust reproduce rng_kat.json exactly (A.7) |
PR CI |
goodness_of_fit |
See F.3; reduced sample size on PR, full nightly | PR CI and nightly |
manifest_schema |
Every produced manifest validates against the schema, and run_started carries the hashed core only |
PR CI |
The cross-platform check, stated the way D-05 requires. It does not assert a tolerance chosen in advance. It runs SCN-CLEAN in every matrix cell, then compares against the reference cell in two steps.
- Business events are identical. The ordered tuple of event type, entity id, and canonical order rank matches exactly. Any difference here is a defect, never a rounding artifact, because the discrete decisions the twin makes are comparisons against thresholds and a one-unit-in-last-place difference cannot flip one without also changing the tape's shape.
- Continuous fields agree inside a measured tolerance. The gate computes the maximum relative divergence across every continuous field and reports it. The tolerance is
divergence_tolerance, a config value seeded from the maximum observed over the previous 30 nightly runs plus a factor of two, and it is committed to the repository as a number with the date it was measured. When a run exceeds it, the gate names the two candidate explanations in its output: the tolerance was measured on too few platforms, or a real defect was introduced. It does not pick one.
The reason for measuring rather than asserting is stated once, in E.4: log, exp, and erfinv are SIMD-dispatched in numpy and can differ in the last bits across CPU microarchitectures and library builds, and inverse-CDF sampling puts those functions on the path of nearly every draw. A number chosen in advance for that divergence would be a guess wearing a gate's clothes.
E.6 Events and exported artifacts¶
This section declares no new event subject. It contributes the payload of twinflow.kernel.run_started, which foundations owns, and it consumes twinflow.kernel.fault_injected, which foundations also owns and which brackets each instance in the log (D-09). Applying D-07, both carry producer_id and a sequence number dense per (run_id, producer_id), and every reader of the ground-truth files merges them against the log under the canonical order (sim_ts, producer_id, seq).
Three artifacts leave a run and are not events.
| Artifact | Written by | Read by |
|---|---|---|
runs/<run_id>/fault_schedule.json |
the schedule materialiser | the counterfactual runner, the scoring harness |
runs/<run_id>/ground_truth/faults.jsonl |
the ground-truth writer | the scoring harness only, never a detector (R7) |
runs/<run_id>/ground_truth/rul.parquet |
the ground-truth writer | the predictive-maintenance scorer only |
For E25, a labeled export under exports/supervised/ carries a dataset card, and the card's stochastic fields come from this section: base_seed, replication_index, stream_catalog_version, distribution_catalog_version, the materialised fault schedule hash, the fault ids present with their instance counts, and the sentence that the labels are synthetic and describe twinflow's declared distributions rather than any real operation. test_dataset_card_carries_every_required_stochastic_field fails on a card missing any of them, which is what stops a labeled dataset from being published without the generator description that makes it interpretable.
F. Testing¶
F.1 Unit tests¶
- Parameterization conversions. Every family's practitioner parameters convert to canonical parameters correctly:
lognormal(median, sigma_log)to(mu, sigma),gamma(mean, shape)to(shape, scale),beta_scaled(lo, hi, mean, conc)to(alpha, beta)with the affine map,negbin(mean, disp)to(n, p). Tested against closed forms with exact expected values. - Moment checks at a fixed seed. Sample mean and variance fall inside a Monte Carlo confidence interval computed from the analytic moments. Uses a dedicated
test.*stream so it cannot perturb simulation streams. - Config validation. Out-of-range and mistyped parameters raise line-numbered errors with suggestions, per C5. A
sigma_logof zero, abeta_scaledwithlo >= hi, and anegbinwithdisp <= 0each have a named test. - Family registry integrity. The family named in
distributions.yamlresolves to the sampler class actually constructed. A typo cannot silently select a different family. - Fault parameter validation. Every fault's parameter schema rejects a negative duration, an unknown target id, and an onset window outside the run horizon.
F.2 Property-based invariants¶
Hypothesis-class generators over the schema-declared parameter ranges. Named invariants, each a separate test:
durations_strictly_positive- every sampled duration is> 0, strictly, for every valid parameter set. Float underflow to0.0counts as a failure; if a schema-legal parameter set can underflow, the schema range is wrong and gets tightened, not the sample clamped.bounded_ratios_within_bounds- everybeta_scaleddraw lies in[lo, hi]. The closed interval is intentional: a beta can return exactly0.0or1.0in float64, and consumers must tolerate it rather than the sampler hiding it.counts_are_nonnegative_integers- every count family returns a non-negative integer of the declared dtype, and shifted families respect their declared minimum.material_conservation- per station and per interval,units_in == units_out + wip_delta + scrap + shrink, as exact integer accounting with no tolerance.monotone_clock- sim time is non-decreasing along every entity's event sequence, and the global log is non-decreasing under the declared tie-break key. Carve-out: device-reported source timestamps may go backwards under F-DEV-CLOCKDRIFT and F-NET-REORDER. The invariant binds sim time and historian ingest order, never device-reported time, and the test asserts the carve-out is exercised.genealogy_closure- every lot, pallet, and carton has a parent chain terminating at a receipt or a production input; the genealogy graph is acyclic; every consumed unit appears in exactly one child; no orphans.ledger_balance- debits equal credits per posting and per period; inventory value change reconciles to receipts minus issues plus revaluation; cash movements reconcile to the statement.soc_and_soh_bounds- battery state of charge stays in[0,1]and state of health is monotone non-increasing, for every AMR over every run.resource_capacity_respected- no resource serves more than its declared capacity; no queue length is negative.no_negative_inventory- on-hand is>= 0at all times unlessinventory.allow_negativeis explicitly set.fault_window_wellformed- every ground-truth instance satisfiest_inject <= t_effect_start <= t_effect_end <= t_clear, and all lie inside the run horizon.telemetry_faults_physically_inert- a run with only telemetry-layer faults produces a byte-identical physical KPI series to the fault-free run at the same seed.draw_counts_invariant_under_faults- the per-stream draw-count vector is identical with and without every fault, individually and in combination. This is R6 made testable.causal_graph_is_dag- the declared causal graph is acyclic and every fault'scausal_edgesreference declared nodes.sensor_readings_are_not_clamped- driven to a synthetic extreme state, a sensor emits a reading outside the declared physical validity range. A pass proves no hidden clamp exists; a failure means someone added one.
F.3 Goodness-of-fit¶
For every entry in the catalog, draw N values from the implemented sampler and test against the declared CDF.
- Continuous families: Anderson-Darling. Chosen over Kolmogorov-Smirnov deliberately. The NIST/SEMATECH e-Handbook states that Anderson-Darling "gives more weight to the tails than does the K-S test" (B.13 ref 1), which is exactly where R3 makes correctness matter most, and that its critical values depend on the distribution being tested, which is why each family carries its own (B.13 ref 21).
- Discrete families: chi-square with tail pooling so no expected cell falls below 5, the convention Cochran (1954) states (B.13 ref 20). The current edition of the e-Handbook no longer prints that threshold in its chi-square page, so the rule is attributed to Cochran rather than to NIST.
- Tail-specific check, run for every family. Compare empirical exceedance probabilities at the 0.99, 0.999, and 0.9999 quantiles against the analytic values inside an exact binomial confidence interval, computed by the Clopper-Pearson method (B.13 ref 19). This is the check that catches an accidental clamp or cap, which a center-weighted test would pass without complaint. At
N = 500,000the expected count above the 0.9999 quantile is 50, so the interval is wide enough to be honest and narrow enough to catch a cap that removes the tail entirely. - Hierarchical families are tested by variance-component recovery: fit a random-effects model to a large sample and confirm the recovered entity and within components match the provisioned values within a confidence interval. This doubles as the ground-truth fixture for the Gage R&R validation.
- Process families (
ou,ar1,wiener,semi_markov,nhpp) are tested on their defining statistics: the OU stationary distribution and autocorrelation at declared lags, the AR(1) coefficient, the Wiener increment variance scaling with the square root of elapsed time, semi-Markov holding-time distributions per state, and the NHPP intensity recovered by binned counts against the declared profile.
Multiple-testing control. With roughly 170 catalog entries, testing at alpha = 0.01 would produce around 1.7 spurious failures per CI run and a permanently flaky suite. Two mitigations, both required:
- The GoF suite runs from a fixed dedicated seed distinct from any run seed, so a failure is deterministic and reproducible rather than intermittent.
- Benjamini-Hochberg false-discovery-rate control is applied across the whole suite at
q = 0.05(B.13 ref 18). The suite fails if the adjusted rejection set is non-empty, and the report names which entries were rejected with their raw and adjusted p-values.
Runtime budget. PR CI runs N = 20,000 per entry with correspondingly wider intervals, budgeted at 300 seconds. Nightly runs N = 500,000, budgeted at 25 minutes. Both are in the same test module, parameterized by an environment variable recorded in the report. F.7 holds the arithmetic that makes those two numbers checkable rather than aspirational.
F.4 The stream-addition test¶
Two forms, both required, because they catch different mistakes.
Form 1, digest fixture. tests/fixtures/stream_digests_v<catalog_version>.json maps every declared stream name to the SHA-256 of its first 32 draws at a pinned base seed. The test re-derives every stream and compares. It then registers a synthetic new stream test.synthetic.newstream_<uuid-free-counter>, draws from it, re-derives every original stream, and asserts every digest is still unchanged.
Form 2, end-to-end. Run SCN-CLEAN and record event_log_sha256. Register a no-op subsystem that claims a new stream and draws 10,000 values from it during the run. Re-run at the same seed. Assert event_log_sha256 is unchanged.
Form 2 is the one that catches an accidental positional spawn() slipping into the codebase, because form 1 would still pass if the fixture were regenerated carelessly. A companion static test asserts the string SeedSequence( and .spawn( appear nowhere outside twinflow_rng.
F.5 Scoring-harness tests¶
The detector scoring code is itself tested against hand-constructed fixtures, because a scoring bug would silently invalidate every published number.
- Alarm exactly at
t_effect_startcounts as a true positive. - Alarm exactly at
t_clear + grace_scounts as a true positive; one microsecond later does not. - Two alarms inside one window produce one true positive and one false positive.
- Two overlapping instances on the same stream and one alarm produce one true positive and one false negative under the greedy earliest-first rule.
- An instance with no
affected_streamsentry matching any monitored stream is excluded from recall with an explicit reason, not silently counted as missed. - False alarms per 1,000 hours is computed on operating hours, not sample count.
F.6 Golden files¶
Per C4, seeded end-to-end scenarios compare against golden files for the capability report, the value stream map, the financial statements, and ground_truth/faults.jsonl. Golden regeneration requires an explicit just regen-goldens invocation, produces a diff summary in the PR, and is never automatic.
F.7 Validation gates and runtime budgets¶
F.7.1 What this section may call a gate¶
Applying D-11, a gate here satisfies five conditions, and a check that misses any one of them is a test rather than a gate. It names a specific external published reference with an edition and a locator. Its tolerance is never tighter than the precision of the published value it checks. A gate over a stochastic quantity states a measured noise floor and sets its tolerance above it. It states the result that would falsify it. A statistic with no valid external reference is recorded in section G and never as a passing gate.
The distinction has teeth in one place a reader will look for it. The variance-component recovery check in F.3 compares the engine's estimates against twinflow's own provisioned values. That is a strong test and it is not a gate, because the reference is this repository. It stays in F.3 under its own name, and the Gage R and R claim it supports is gated in docs/design/lss-engine.md against a published measurement-system example instead.
Gates register in the same validation/valgates.yaml the LSS engine defines, under the VG-VAR prefix, and carry the same status field. just phase-gate <phase> exits non-zero when any gate assigned to that phase is not PASSING. A gate whose reference text has not been retrieved ships as PENDING with the reason recorded, which keeps the claim out of the capability report until the evidence exists.
| Gate | Statistic | Reference | Tolerance and noise floor | Falsified by | Status |
|---|---|---|---|---|---|
| VG-VAR-01 | In-control run length of a single three-sigma individuals rule | B.13 ref 2 | 1 / 0.0027 = 370.4. The noise floor is measured, not assumed: at 10^6 seeded in-control samples the expected alarm count is 2,700 and the Clopper-Pearson interval on the run length is 356.7 to 384.7, which is 3.7 to 3.9 percent either side |
A measured in-control run length outside 356.7 to 384.7 | PENDING |
| VG-VAR-02 | In-control run length of the combined Western Electric rule set | B.13 ref 17 | 91.75, printed to two decimals. The tolerance is the wider of that precision and the measured noise floor, and the noise floor governs: at 10^6 seeded samples the Clopper-Pearson interval is 90.05 to 93.49, about 1.9 percent either side | A measured in-control run length outside 90.05 to 93.49 | PENDING |
| VG-VAR-03 | Weibull hazard direction and magnitude across the shape parameter | B.13 ref 3 | The published hazard is a closed form, so agreement to 1e-12 relative at 20 points spanning shapes 0.5, 1.0, and 2.3 | A hazard that fails to fall below shape 1, stay flat at shape 1, or rise above shape 1 | PENDING |
| VG-VAR-04 | Rayleigh equals Weibull at shape 2 and equals the radial magnitude of two equal independent normals | B.13 ref 7 | Anderson-Darling at the suite alpha, on 500,000 draws from each of the three constructions | Any pair of the three constructions rejected against each other | PENDING |
| VG-VAR-05 | Generalized Pareto convergence of threshold exceedances | B.13 ref 4 | The fitted shape at rising thresholds converges to the parent's tail index inside two standard errors of the fit | A fitted shape outside two standard errors at the highest threshold with 10,000 exceedances | PENDING |
| VG-VAR-06 | The BLAKE2b core, and the parameter block of a personalized instance | B.13 ref 23 | Exact bytes, in two parts: the unkeyed BLAKE2b-512 of "abc" against the trace in RFC 7693 Appendix A, and the serialized parameter block of a personalized instance against the worked example in the BLAKE2 specification | Any differing byte in either part | PENDING |
| VG-VAR-07 | SeedSequence mixing and PCG64DXSM seeding, advance, and output, Python against Rust | B.13 ref 24 | Exact, on the 64-name known-answer corpus of A.7 | Any draw differing between the two languages, or either differing from numpy | PENDING |
| VG-VAR-08 | Chebyshev travel-time model for the ASRS crane | B.13 ref 6 | The published expected single-command cycle time for a square-in-time rack, to the digits the paper prints | An expected cycle time outside the published value at its printed precision | PENDING |
| VG-VAR-09 | QoS 1 redelivery behavior of the broker adapter under F-NET-REDELIVER | B.13 ref 8 | Behavioral, exact: DUP set on retransmission, and a repeat of an acknowledged packet identifier delivered as a new message | A retransmission with DUP clear, or a repeat suppressed by the adapter | PENDING |
| VG-VAR-10 | NDEATH and bdSeq behavior under F-DEV-DROP and F-DEV-CRASHLOOP | B.13 ref 14 | Behavioral, exact: NDEATH on the Will topic, bdSeq incrementing per CONNECT and wrapping to 0 after 255, NBIRTH carrying the matching bdSeq | A bdSeq that does not match between the Will payload and the following NBIRTH | PENDING |
Every gate is PENDING at the time of writing because no implementation exists yet. That is the honest state, and it is recorded rather than hidden: the capability report prints the gate table with its statuses, so a reader always sees how much of the claim is currently evidence.
Neither of the two Monte Carlo tolerances is a round number chosen for comfort. Each is the width of an exact binomial interval on an alarm count, computed at the stated sample size and printed as an interval so a reader can redo the arithmetic. VG-VAR-02 also shows why D-11's second condition matters: the paper prints 91.75 to two decimals, so a gate asserting more decimals would be checking the transcription rather than the implementation.
F.7.2 Runtime budgets¶
Applying D-13, every tier carries a budget and the arithmetic is tested rather than hoped for.
| Tier | Budget | What dominates it |
|---|---|---|
| F.1 unit | 60 s | Closed-form conversions and config validation, all cheap |
| F.2 property | 180 s | Hypothesis in its derandomized profile, max_examples tuned per invariant |
| F.3 goodness of fit, pull request | 300 s | 170 entries at N = 20,000, dominated by the test statistics rather than the draws |
| F.3 goodness of fit, nightly | 25 min per cell | 170 entries at N = 500,000 |
| F.4 stream addition | 120 s | Form 2 runs SCN-CLEAN twice with a synthetic stream registered |
| F.5 scoring harness | 30 s | Hand-constructed fixtures only |
| F.6 golden files | 600 s | The seeded end-to-end scenarios |
| F.7 validation gates | 300 s | The Monte Carlo gates VG-VAR-01, VG-VAR-02, and VG-VAR-05 |
The largest single job is 600 s, which is at or below the pull-request job cap the repository craft section states, and the nightly cell is 25 minutes against a 35 minute cap.
test_tier_budgets_fit_the_job_budget asserts the arithmetic from measurements rather than from assumed constants. It reads the per-entry timing that the previous nightly run recorded, multiplies by the current catalog entry count, and fails when the product exceeds the tier budget. A catalog that grows from 170 entries to 260 fails this test before it fails as a timeout, which is the difference between a defect with a name and a flaky job.
Two budget clamps live in the generators rather than only in the config validator, because a property test that generates a scenario longer than its own budget is a test that cannot pass. The scenario-horizon generator clamps at 60 simulated seconds for pacing-related properties, and the replication-count generator clamps at 8. test_generator_clamps_are_present asserts both clamps by inspecting the generator strategies, so removing a clamp fails immediately rather than at the next timeout.
F.7.3 Every test states its failure condition¶
Applying D-12, a test whose failure condition cannot be described is deleted and replaced. Three checks in this section are written specifically so they can fail, and each names the state of the world that fails it.
| Check | The observation that fails it |
|---|---|
sensor_readings_are_not_clamped |
A reading that stays inside the declared validity range when the state is driven past it |
telemetry_faults_physically_inert |
Any difference in the physical KPI series between a telemetry-faulted run and a clean run at one seed |
draw_counts_invariant_under_faults |
Any per-stream draw count differing between a faulted and an unfaulted run |
The pattern is the same in all three: the test is constructed so that the passing state is a specific, reachable observation rather than the absence of an error.
Phase placement¶
Every piece below is a committed milestone. Nothing here is optional, deferred, or a stretch goal; the phases order the work and never trim it.
| Piece | Phase | Why here |
|---|---|---|
The derivation of A.1, the stream registry, STREAM_CATALOG_VERSION, the append-only test |
P0 | Name-addressed derivation is the seam every later phase depends on. A positional derivation adopted at P1 invalidates every golden file each time a subsystem is added. |
| The cross-language known-answer corpus and the parity gate VG-VAR-07 | P0 | D-06 makes the derivation a cross-language contract, and the Rust agent arrives at P1. The corpus must exist before the second implementation, or parity becomes a negotiation. |
| The A.1a stream-count ceiling and its CI check | P0 | The ceiling constrains how many per-entity streams later phases may declare, so it has to bind before those phases declare them. |
config/distributions.yaml, its JSON Schema, the family grammar of B.1, the conversions |
P0 | R8 forbids a literal distribution parameter in code. Every phase from P1 writes samplers, so the config surface predates the first one. |
| The B.2 and B.3 rows the walking skeleton needs, plus the sensor signal model of B.10 | P1 | One RFID device, one temperature sensor, and one station need arrivals, unload, read outcome, and a reading composition, and nothing else. |
| The fault schedule materialiser, the ground-truth record of C.3, and R7 leakage enforcement | P1 | Labels written to the wrong place at P1 are labels a P2 detector can read. The leakage test has to predate the first detector. |
C.4 device and network faults, and SCN-DEVICE |
P1 | Component 2 names these failure modes as the reason the fleet layer exists. They arrive with the fleet. |
| The C.6 scoring harness, VG-VAR-01, and VG-VAR-02 | P2 | The LSS engine arrives at P2, and a detector that is not scored against truth is the exact claim this section exists to make checkable. |
| F.3 goodness of fit, F.7 budgets, and the multiple-testing control | P2 | The catalog is large enough by P2 that an ungated sampler change is invisible. |
B.4 conveyance and automation rows, C.5 mechanical faults, SCN-PDM |
P3b | They arrive with the automation and robotics layer that consumes them. |
| B.7 supply rows and the supplier and transport faults of C.5 | P3e | They arrive with the supplier network and outbound flows. |
B.6 production rows, SCN-QUALITY, and the source-versus-inbound detection comparison |
P3i | They arrive with the upstream factory. |
| B.9 workforce rows, the fatigue and attrition hazards, and the learning curve | 6a14 | They arrive with the HR and workforce layer that reads them. The rostering phase consumes the absence rows one milestone earlier through the stub the roadmap names. |
| The near-miss, incident, and injury-severity rows of B.9 | 6a10 | They arrive with the safety and ergonomics layer, which is the milestone that owns the safety and structural sensor categories. |
SCN-RARE and the offline seed sweep that finds its extreme tail draws |
P2 | The scenario exists to test whether a detector can tell a tail draw from a special cause, so it lands with the first detector rather than with the rows it exercises. |
| B.12 exogenous drivers wired to a real weather process, and F-EXO-WEATHER | P6 | E40 is a Phase 6 milestone. The driver registry seam ships at P0 with a null driver so this is a wiring job rather than a retrofit across eleven subsystems. |
| E46 geometry replacing the constant read probability in B.3 | P6 | E46 is a Phase 6 milestone. The B.3 row is written so the replacement changes the row's mechanism and not its role. |
Two sequencing notes, because each one would otherwise read as a forward dependency.
B.3's read-outcome row does not wait for E46. The row draws a bernoulli against a provisioned per-portal rate at P1. E46 replaces the constant probability with a geometry model at P6. Both forms occupy the same config key and the same stream name, so the P6 change is a family swap inside one row rather than a new stream, and the stream-versioning rule of A.3 covers the golden-file regeneration.
G.8's arrival model does not wait for E12. variability.inbound.truck_interarrival is an NHPP from P1. When yard scheduling lands at P6, the NHPP applies to the unappointed fraction and an appointment-plus-deviation row applies to the rest. Both rows exist in the catalog from the phase that needs the second one, and the scenario selects. The open part is which default ships, and G.8 records it.
G. Open questions¶
These are genuinely undecided. Each names the options and the consequence of picking wrong, and none is papered over with an invented answer. An entry that gets decided keeps its number and records the decision in place, because a reader who follows a citation to G.11 needs to find the answer there rather than a gap.
G.1 Whether to raise the cross-platform tier to byte identity. The two-tier claim itself is settled by D-05 and stated in R1 and E.5: byte-identical on a pinned platform, value-equivalent across platforms inside a measured tolerance. What stays open is whether to close the gap. numpy's log, exp, and erfinv are SIMD-dispatched and can differ in the last bits across CPU microarchitectures and library builds, and inverse-CDF sampling puts them on the path of nearly every draw. Options: (a) keep the two tiers as they are; (b) vendor deterministic implementations of the inverse CDFs the catalog needs, which would make the cross-platform tier byte-identical too; (c) set NPY_DISABLE_CPU_FEATURES to a common baseline across the matrix and measure how much of the divergence that removes. Option (b) is a real implementation cost and needs its own correctness gate against a published reference, which is not free for erfinv. Option (c) is cheap and its value is unknown until measured, so the first step is the measurement rather than the decision. Unresolved.
G.2 Rust device agent RNG parity, settled and its residual. The derivation question is closed. Applying D-06, the agent derives its streams from the run seed and its device id through the same name-addressed derivation, specified byte for byte in A.7 in terms of BLAKE2b, numpy's SeedSequence mixing function, and the CM DXSM 128/64 variant that numpy calls PCG64DXSM, with a cross-language known-answer corpus and gate VG-VAR-07. The Rust side binds rand_pcg::Lcg128CmDxsm64 at crate version 0.10 or later, which the crate documents as the same variant, so no vendored bit generator is needed. What the crate does not give free is the seeding: its new constructor steps the state with the cheap multiplier and numpy does not, so A.7 has the agent compute the seeded state itself and hand it to from_state. The residual open part is narrower and is genuinely open: the agent is a production-mode participant and does not join the single-process deterministic scheduler, so while its draws are reproducible and its emissions are ordered by the canonical (sim_ts, producer_id, seq) of D-07, the wall-time interleaving of its messages with the simulated fleet is not. Options: (a) accept that and score the agent's contribution under the value-equivalent tier only; (b) give the agent a simulation-mode transport that schedules its emissions on the sim clock, which makes the demo less honest about what an edge device is. Unresolved.
G.3 Provenance of the parameter values. Every numeric default in section B is engineering judgment. The family choices are separate and are sourced in B.13; this question is about the numbers inside them. No client data, employer data, or internal document may be used, which is a hard IP rule. Options: (a) ship judgment values clearly labeled as such; (b) fit what can be fitted to public datasets and cite them; (c) both, with a source: field per parameter taking judgment, public_dataset:<citation>, or textbook:<citation>. Option (c) is proposed, but the open part is how much of a 170-row catalog can be sourced, and whether a mostly-judgment catalog undercuts the reference-validation discipline established elsewhere. Two specific parameters are named here because each has a candidate published source that has not been checked: the Bass coefficients p = 0.03 and q = 0.38 in B.8, which sit close to values a published meta-analysis of diffusion applications is reported to give, and the learning rate 0.88 in B.9, whose curve form is Wright's (B.13 ref 22) but whose rate is not. Until those primary texts are read, both ship as judgment and are labeled as such. Unresolved.
G.4 Residual correlation between quantities. The catalog specifies marginals. Some correlation is handled structurally: worker random effects, the shared weather state, shared load driving temperature and current together. The residual correlation is currently independent by default, which understates the joint tail (the day when everything goes wrong at once is rarer in the model than in a real building). Options: (a) leave residuals independent and state the limitation; (b) add a Gaussian copula over a declared residual correlation matrix. A copula preserves the marginals, so the GoF suite still works, but the joint structure becomes much harder to test and the correlation matrix becomes a large, hard-to-justify config object. Unresolved.
G.5 Which chart-selection mode is the headline number. Section D.3 defines empirical and declared modes and reports both. The recommendation is that empirical is the published headline because declared gives the engine information no real deployment has. This needs an explicit decision because it changes every number in the README and the capability report, and reversing it later invalidates published comparisons.
G.6 Compound fault instances. When F-MECH-BEARING and F-CONV-JAM overlap on the same asset, is the truth two independent instances or one compound condition? The current rule keeps them independent and lets one alarm match one instance, which undercounts recall when a detector legitimately raises a single alarm for a compound condition. The compound_group_id field exists in the record for this purpose but its scoring semantics are not defined. Options: (a) keep instances independent and accept the recall penalty; (b) let one alarm satisfy every instance in a compound group; (c) score compound groups as their own class with separate metrics. Unresolved.
G.7 detectable_from for non-scalar signatures. The analytic noise-floor calculation works for a mean or variance shift on a scalar signal. It does not work for F-AMR-DEADLOCK, F-SORT-MISDIVERT, F-SEC-SPOOF, or F-EXO-WEATHER, whose signatures are structural or multivariate. Options: (a) compute detectable_from by an empirical calibration run per fault class and pin it in the scenario file; (b) omit the field for those classes and report MTTD only from t_effect_start, accepting that MTTD is then not comparable across fault classes; (c) define a class-specific detectability statistic per fault. Option (b) is the cheapest and the least informative. Unresolved.
G.8 Truck arrivals after yard scheduling lands. Today variability.inbound.truck_interarrival is an NHPP. Once E12 optimizes appointment slotting, arrivals become appointment-plus-deviation, and the NHPP row applies only to the unappointed fraction. The proposal is inbound.arrival_model: {poisson_process | appointment_plus_deviation} with both rows present in the catalog and the scenario choosing. The open part is whether switching the default breaks the golden files and recorded runs from earlier phases, and whether that break is handled by the stream-versioning rule in A.3 or by a catalog major-version bump. Unresolved.
G.9 Where out-of-range readings stop propagating. R2 and R4 mean a sensor reading is never clamped, so a calibration-loss fault can emit -270 C. The proposal is that detectors read raw values while the metrics layer applies the declared validity range, excludes out-of-range samples from KPI computation, and raises a data-quality finding. The open question is whether that exclusion can hide a genuine fault signature from any KPI-based detector, and whether the dashboard renders the raw value, the excluded marker, or both. A wrong choice here either pollutes every KPI or silently suppresses a fault. Unresolved.
G.10 Heavy tail shape parameters. Generalized Pareto tails are used for in-transit shock magnitudes and acoustic emission amplitudes, and mean-reverting log processes are used for spot rates. For GPD shape xi >= 0.5 the variance is infinite, and for xi >= 1 the mean is infinite. Freight spot markets and cargo damage arguably live in that region. Bounding xi is a parameter choice rather than a truncation, so R3 permits it, but if xi is left high then the LSS engine's mean-based charts on those quantities are formally invalid and the capability numbers computed from them are meaningless. Options: (a) bound xi below 0.5 and state the modeling assumption; (b) permit high xi and make the engine select rank-based or quantile-based methods for those quantities, detected from the declared family. Option (b) is more honest and more work, and it interacts directly with G.5. Unresolved.
G.11 PCG64 against PCG64DXSM. Decided 2026-08-09: PCG64DXSM. The three options this question posed were (a) keep PCG64 and hold the ceiling, (b) switch before Phase 0 closes, at the price of one coordinated change across four sections and no golden files, since none existed yet, and (c) switch later, at the price of regenerating every golden file plus a compatibility-table entry saying that no earlier recorded run reproduces. Option (b) was taken.
The reason is the arithmetic in A.1a rather than a preference. numpy documents that a pair of PCG64 streams sharing their lower 58 state bits fails PractRand when interleaved, that a seeded increment adds no protection against it, and that thousands of streams are comfortable while millions are the point to consider PCG64DXSM (B.13 ref 15). This section's declared ceiling of 750,000 streams sits between those ranges, and the computed expectation at that ceiling was 9.8e-7 colliding pairs. That is small and it is not nothing, and it was the only number in this document that argued for its own ceiling.
The deadline was the corpus freeze, not the end of Phase 0. Task 9 of the Phase 0a plan writes tests/fixtures/rng_kat.json, which is the first golden artifact in the repository, and the Rust crate is tested against that same file in Phase 1. Before the freeze the switch costs a document edit. After it, the switch costs a regenerated corpus. After the Rust crate lands, it costs a coordinated change in two languages against a corpus that is by then the evidence that they agree. So the decision was taken while it was still free.
What changed with it: A.1 and its alternatives discussion, the collision table and the ceiling rationale in A.1a, the lint text in A.4, the manifest fields in A.6, the whole of A.7 point 3 and the Rust binding, refs 15 and 24 in B.13, gate VG-VAR-07, and the matching statements in the foundations, roadmap, fleet, and twin sections. What did not change: the derivation of A.1, the stream grammar of A.2, the seeded state and increment, and every draw-order rule. Resolved.
G.12 The maintainability lognormal. Repair and clear durations in B.4 and B.6 use a lognormal, justified structurally: diagnosis time, part availability, and access multiply, so the log is a sum (B.13 ref 5). The maintainability literature is widely reported to prefer the lognormal for corrective maintenance time, and a defense handbook is the usual citation, but no primary text was retrieved for that claim, so it is not stated as fact anywhere in this section. Options: (a) leave the structural argument as the whole justification, which is defensible and slightly thin; (b) get MIL-HDBK-472 or an equivalent primary source and add a gate on a published repair-time example; (c) fit the family to a public maintenance dataset and cite the fit. Unresolved.
G.13 Whether a contested ratio may drive incident generation. B.9 generates incidents from near misses at a configurable 1/300, attributed to Heinrich's ratio through a single secondary source (B.13 ref 11), whose primary text was not retrieved. The ratio is contested in the safety literature, and a twin that reports a TRIR built on it inherits the contest. Options: (a) keep the ratio, keep it in config, and print the value used in every safety report, which is the current position; (b) retrieve the primary text and a modern critique, and ship both with the figure; (c) replace the fixed ratio with a hazard model whose parameters are fitted to a public injury dataset, which removes the pyramid assumption at the cost of a fitted layer nobody asked for. The capability report currently prints the ratio it used, so the assumption is visible either way. Unresolved.
G.14 No published birthday bound for PCG64DXSM. Answered 2026-08-10: none is published, by numpy or by the PCG project. Owner: John Ragon Hall, the repository owner, who also owns G.11. The question asked whether any primary text states a per-stream collision bound for PCG64DXSM. Four places were searched and each result is recorded in B.13 rather than left as an impression.
The PCG paper of 2014 predates DXSM by five years. It argues stream distinctness structurally, that streams are full period and entirely distinct, and it contains no instance of the word collision; its birthday material is about repeats inside one overtaxed generator, not about pairs of parallel streams (B.13 ref 28). The PCG project blog carries thirty posts and none is about DXSM. The author announced DXSM in numpy's issue tracker in June 2019 and said blog posts would follow (B.13 ref 26), was asked directly for one in June 2020, and confirmed in November 2020 that it still had not been written (B.13 ref 27). The pcg-cpp commit that added DXSM carries the one-line message "Add new output permutation, DXSM." and the project README does not mention the variant (B.13 ref 28). numpy's page states a comparison and not a value (B.13 ref 15).
So the answer is qualitative, and A.1a now states it as such instead of carrying a gap. What exists is a design claim from the PCG author, a comparative claim from numpy that a DXSM collision is less likely than a 128-bit SeedSequence pool collision, and empirical non-detection over stated tested ranges from both. A.1a separates those three kinds and says which are bounds and which are not.
One apparent conflict was checked and is not one. The issue that opened this line of work claimed 50% collision probability at about half a million generators, which would have contradicted numpy's 2^29. The claimant corrected it to half a billion in the next comment, agreeing with numpy (B.13 ref 27). No source disagreement survives.
Both consequences the earlier entry left open are now closed. The stream-count ceiling has a stated basis again and it is an operating one: A.1a fixes it by a declared headroom rule over the largest shipped scenario, and records that no published figure binds at this scale. And a genuinely quantitative parallel-stream relation was found in the PCG author's own writing, the 2017 overlap relation of B.13 ref 25, which A.1a computes at the ceiling; it does not answer the correlated-pair question, but it does put a number on the adjacent overlap hazard that this section previously left unstated. What remains is narrower than the question asked, and it moves to G.15.
G.15 The DXSM residual is evidenced, not bounded. Owner: John Ragon Hall, the repository owner, inherited from G.14. Scoped precisely: nobody has published the probability that two independently seeded PCG64DXSM streams are detectably correlated. A.1a's governing column is an upper bound borrowed from numpy's ordering claim, not a figure computed for DXSM, and the evidence beneath it is non-detection over a tested range. A test that does not fail sets no bound. This matters in exactly one case: a future result that puts the real figure below the 128-bit pool collision would make A.1a's governing column the wrong one and would move the ceiling's basis back onto the bit generator. Options: (a) hold the current position, stating the ordering claim as the bound and naming the evidence under it, which is what A.1a does; (b) watch for a primary text and restate if one appears, the two places it would land being the PCG blog post promised in 2019 and numpy's issue tracker; (c) measure it here, which R9 forbids, because this repository is never a reference for itself. Option (a) is in force and option (b) is the standing watch. Evidence that would change it: any published per-pair figure for PCG64DXSM, or a published PractRand failure for a PCG64DXSM pair that was seeded rather than adversarially constructed. Unresolved.