The results document
Every command that runs a model produces a JSON document carrying the recipe as well as the answer — a number with no engine version, model hash, and seed attached to it is not reproducible, and a verdict that cannot be reproduced is not a verdict.
The run report (run, and each side of compare)
{
"manifest": {
"model_name": "inspection-booth",
"model_hash": "a258a8d2af4acd3a",
"engine_version": "0.0.0",
"rustc_version": "1.97.1",
"build_target": "x86_64-unknown-linux-gnu",
"schema_version": 1,
"replications": 12,
"replication_ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
},
"kpis": {
"cycle_seconds": {
"point": 312.4,
"lo": 298.1,
"hi": 326.7,
"half_width": 14.3,
"relative_precision": 0.0458,
"replications": 12
},
"completed": { "point": 2016.0, "lo": 1987.2, "hi": 2044.8, "half_width": 28.8, "relative_precision": 0.0143, "replications": 12 },
"utilisation_pct.booth": { "point": 71.2, "lo": 68.9, "hi": 73.5, "half_width": 2.3, "relative_precision": 0.0323, "replications": 12 }
},
"warmup_discarded": [15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
"trace_hashes": ["a91c...", "7bd0...", "..."],
"events_total": 1048203,
"wall_seconds": 0.986,
"seconds_per_replication": 0.0822
}
manifestidentifies exactly what produced this result. ⚠️rustc_versionandbuild_targetare recorded because bit-identical determinism is only promised on a fixed toolchain — see Determinism — so a result carried across a toolchain change is a result whose comparability nobody has checked.replication_idsmatters for comparisons: two results sharing the same ids are paired, which is what makes a latercomparebetween them legitimate.kpisis a map from KPI name to an interval — never a bare number. Every entry has apointestimate, alo/hibound, ahalf_width, arelative_precision(the half-width as a fraction of the point estimate — the usual stopping criterion for "have I run enough replications"), and the replication count behind it. A KPI whose true value is zero has infinite relative precision by construction (there is nothing to be a fraction of) — this is normal and expected for a count that never happened, not a formatting error.- Beyond
cycle_seconds,completed, andreleased, the map includes one entry per resource (utilisation_pct.<id>,availability_pct.<id>,queue.<id>), per product (cycle_seconds.<product>), and per consumable (level.<id>,stockouts.<id>,stockout_wait_s.<id>) that the model declares. warmup_discardedis what MSER-5 actually threw away, per replication — always one shared value across every replication (see Experiments), reported per replication anyway so a wrong warm-up choice is visible in the document rather than hidden inside an average.trace_hashes— one per replication — is what makes two runs' agreement checkable rather than assumed: two runs with the same hash executed the same events, in the same order, with the same outcome.seconds_per_replicationis reported because optimal sample allocation across several candidate models is proportional tosigma / sqrt(cost), not tosigmaalone — comparing variants that differ in how expensive they are to simulate without this figure silently costs a constant factor.
The comparison document (compare, propose)
Wraps a full run report for baseline and one for candidate, plus:
{
"kpi": "cycle_seconds",
"difference": { "point": -41.2, "lo": -58.0, "hi": -24.4, "half_width": 16.8, "relative_precision": 0.4078, "replications": 12 },
"verdict": "Better",
"next_step": "none -- the comparison concluded",
"indifference": 3600.0,
"lower_is_better": true,
"pairing_efficiency": 4.7,
"paired": true,
"baseline": { "...": "a run report" },
"candidate": { "...": "a run report" }
}
differenceis the interval on the paired difference (candidate minus baseline) — the quantity the verdict is actually computed from, not a difference of two separately-reported points.verdictis one ofBetter,Worse,NoMeaningfulDifference, orInsufficientEvidence— see Experiments for what each one means and why there are four rather than two.next_stepdescribes what would resolve an inconclusive comparison: a specific number of further replications, or an honest statement that more replications will not help.pairing_efficiencyis the variance reduction actually achieved by running both arms with the same random draws (common random numbers), as a ratio of unpaired to paired variance — 1.0 means pairing achieved nothing, and values are capped at 1,000,000 to avoid reporting spurious precision when the two arms are nearly perfectly correlated.pairedis true only when baseline and candidate used the same replication ids; a comparison can still be valid without this, just considerably less precise.
Flow records (run --flow DIR, read by cwright trace)
--flow DIR on run writes one zstd-compressed Parquet file per replication — one
row per entity per step:
| column | type | meaning |
|---|---|---|
entity | uint32 | The entity (lot) this row is about. |
product | uint16 | Which product's route it is following, as an index. |
step | uint16 | Which step of that route, as an index. ⚠️ A rework excursion is walked as steps appended after the declared route, so an index at or past products[].steps.length is an excursion step rather than a step you can find in the document. |
resource | uint16 | Which resource (tool group) performed it, as an index. |
server | uint16 | Which individual server within that group — not just the group, so a per-equipment question can be answered without falling back to an aggregate. |
visit | uint8 | Which visit this is, for a re-entrant route that returns to the same step more than once. |
t_arrive | int64 (ticks) | When the entity arrived at this step's queue. |
t_start | int64 (ticks) | When service actually began. |
t_end | int64 (ticks) | When service finished. |
setup_ticks | int64 | Time spent on a changeover immediately before this step's service. |
queue_ticks | int64 | Time spent waiting (derived from t_arrive/t_start). |
process_ticks | int64 | Time spent actually being processed (derived from t_start/t_end, minus setup). |
Times are in ticks, not seconds — divide by the model's tick_scale_per_second to convert.
Rows are sorted by t_end, then entity, then step; row groups are 1,000,000 rows, which is a
deliberate floor rather than a tunable knob — groups under roughly 5,000 rows make queries 5–10×
slower, since a reader pays per-group metadata for almost no data in return.
Reading it
The whole point of Parquet here is that nothing bespoke is needed to read it:
import pandas as pd
df = pd.read_parquet("flow/r0.parquet")
df["queue_seconds"] = df["queue_ticks"] / 1000 # tick_scale_per_second from the model
df.groupby("resource")["queue_seconds"].mean()
import polars as pl
pl.scan_parquet("flow/*.parquet").group_by("resource").agg(
pl.col("queue_ticks").mean()
).collect()
-- DuckDB, across every replication at once
SELECT resource, server, avg(queue_ticks) AS mean_queue_ticks
FROM read_parquet('flow/*.parquet')
GROUP BY resource, server
ORDER BY mean_queue_ticks DESC;
A time-window query (everything between two ticks) prunes row groups efficiently, because the
file is sorted on t_end; filtering by entity or server alone will scan the whole file — a
known and accepted trade rather than an oversight, since time-window queries are the common case.