Skip to content

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 Rng port signature, and the kernel network fault primitives. docs/design/foundations.md owns them (D-09). This section names them and never redeclares them.
  • The statistical methods that consume these streams. docs/design/lss-engine.md owns control charts, capability, measurement system analysis, hypothesis testing, and the VAL-GATE registry.
  • Per-sensor-type catalog entries as data. docs/design/sensor-catalog.md and catalog/sensors/*.yaml own 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 Inference port 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.

  1. 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.
  2. 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_array and get_assembled_entropy, v2.3.0, retrieved 2026-08-09, HTTP 200).
  3. Four entropy words means no zero-fill. numpy pads the run entropy to pool_size with zeros when a spawn key is present and the entropy is shorter than the pool. Fixing the entropy at four words, which is DEFAULT_POOL_SIZE, makes the padding branch unreachable and removes a version-sensitive behavior from the contract.
  4. The pool size stays at the default 4. That is a 128-bit entropy pool, and generate_state cycles 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). spawn is positional: it extends the parent's spawn key by the child's index and maintains an n_children_spawned counter, 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 PCG64 itself. PCG64 is numpy's current default, and it was this section's first choice. numpy documents a statistical weakness in it: a pair of PCG64 streams 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 consider PCG64DXSM (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 the PCG64.jumped method "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 about PCG64.jumped and does not restate it for PCG64DXSM.jumped, so it is quoted here for what it says about PCG64 and is not extended to the variant this section uses. The argument against jumped does not rest on it either way: k is 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. SeedSequence plus PCG64DXSM is chosen because the derivation then rests on two published algorithms rather than on a project-specific counter layout, and because PCG64DXSM is 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 that PCG64DXSM "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, that PCG64 is the construction a reader of numpy code already knows. That argument is weaker now and is not the deciding one, because PCG64DXSM is 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: under PCG64DXSM the governing bound is the 128-bit SeedSequence pool collision, and Philox seeded through the same SeedSequence inherits 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 a for statement 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 rule TWF-RNG-002 fails CI on for ... 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 different PYTHONHASHSEED values 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 marked retired_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:

  • hierarchical is 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.
  • normal appears on readings, not on quantities. A physical quantity with a floor never takes a normal, 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, and test_no_normal_family_on_a_bounded_physical_quantity fails when a variability.* key outside the sensor catalog names normal.

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 FaultOverlay object 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>.yaml lists 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.faults stream samples the instances and materialises them into an explicit schedule, which is written to runs/<run_id>/fault_schedule.json and 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_start versus t_inject: some faults are armed before they produce any observable effect (a firmware campaign that only misbehaves under load). Scoring uses t_effect_start.
  • detectable_from is the earliest sim time at which the injected effect on the observed signal exceeds k times the residual noise standard deviation, with k from scoring.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 before detectable_from is a leakage signal, not a triumph (see C.6).
  • affected_entities is what the mock recall drill is scored against: the genealogy walk's blast radius is compared to this set for precision and recall.
  • causal_edges reference 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 from detectable_from so 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.4 samples. 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.yaml or 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 from distributions.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 and pm4pyminimal are 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.

  1. Git provenance is not part of run identity. run_id addresses the inputs of an experiment: seed, replication, config, schema snapshot, fault schedule, scenario, mode. A code change that leaves those inputs alone produces the same run_id and a possibly different log, which is exactly the condition twinflow replay is 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.
  2. 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.
  3. stream_count_declared is sidecar and is checked against STREAM_COUNT_CEILING of A.1a on every run, so the collision bound is monitored on real configurations rather than assumed from the registry.
  4. The carve-out is tested, not trusted. test_run_started_carries_no_wall_clock_or_platform_field walks the run_started payload 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

twinflow replay --run <run_id>

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:

  1. base_seed and replication_index are carried over unchanged.
  2. 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.
  3. The config change is applied to the resolved config, producing a new resolved config, a new hash, and a new run_id. The manifest sets derived_from to the parent and stores the config diff.
  4. The runner emits crn_integrity.json: the per-stream draw counts for both arms and the set of streams present in only one arm.
  5. The LSS engine reads crn_integrity.json and 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 different PYTHONHASHSEED values (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_tape compares a paced run against an unpaced one. Everything else reads the injected Clock.
  • Hostname, PID, UUID4. Never enter the event log. Lint TWF-DET-001 fails on uuid4, time.time, datetime.now, socket.gethostname, and os.getpid in the twin, sensor, and LSS packages, and scripts/checks/nondeterminism-gate.sh enforces the same outside the kernel package.
  • Threading and BLAS. Runs marked deterministic set OMP_NUM_THREADS=1 and 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, and erfinv are 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.

  1. 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.
  2. 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_log of zero, a beta_scaled with lo >= hi, and a negbin with disp <= 0 each have a named test.
  • Family registry integrity. The family named in distributions.yaml resolves 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:

  1. durations_strictly_positive - every sampled duration is > 0, strictly, for every valid parameter set. Float underflow to 0.0 counts as a failure; if a schema-legal parameter set can underflow, the schema range is wrong and gets tightened, not the sample clamped.
  2. bounded_ratios_within_bounds - every beta_scaled draw lies in [lo, hi]. The closed interval is intentional: a beta can return exactly 0.0 or 1.0 in float64, and consumers must tolerate it rather than the sampler hiding it.
  3. counts_are_nonnegative_integers - every count family returns a non-negative integer of the declared dtype, and shifted families respect their declared minimum.
  4. material_conservation - per station and per interval, units_in == units_out + wip_delta + scrap + shrink, as exact integer accounting with no tolerance.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. resource_capacity_respected - no resource serves more than its declared capacity; no queue length is negative.
  10. no_negative_inventory - on-hand is >= 0 at all times unless inventory.allow_negative is explicitly set.
  11. fault_window_wellformed - every ground-truth instance satisfies t_inject <= t_effect_start <= t_effect_end <= t_clear, and all lie inside the run horizon.
  12. 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.
  13. 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.
  14. causal_graph_is_dag - the declared causal graph is acyclic and every fault's causal_edges reference declared nodes.
  15. 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,000 the 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:

  1. The GoF suite runs from a fixed dedicated seed distinct from any run seed, so a failure is deterministic and reproducible rather than intermittent.
  2. 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_start counts as a true positive.
  • Alarm exactly at t_clear + grace_s counts 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_streams entry 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.