ADR-037 — sumo-duaiterate: the first EDOC-1 row, and the shipped external-dynamic substrate¶
Status: accepted (shipped)
File: docs/design/adr-037-sumo-duaiterate.md
Context — EDOC-1’s first shipping row¶
ADR-036 is the design
authority for EDOC-1, the external-dynamic-engine observational certificate: a
frozen-field best-response gap RG_D1 with gates G0–G4, a versioned
canonicalization surface (tabench-edoc-canon-v1;, R10), and a certifier-owned
multi-OD time-dependent shortest-path harness. It shipped no code — like
ADR-030, it is a documentation-only record whose two-round adversarial review
(1 CRITICAL + 11 MAJORs, on a document, before any code existed) fixed the design
up front.
This ADR ships the first row and, with it, the shared substrate ADR-036
specified: the certifier (tabench.metrics.edoc_gaps), the canonicalizer
(tabench.edoc.canon), the occupancy-aware frozen field + per-first-edge
origin-wait profiles (tabench.edoc.field), the non-FIFO-sound TD-SP
(tabench.edoc.tdsp), the frozen EdocScenario instance (tabench.edoc.scenario),
and the replay-harness types (tabench.edoc.replay). The row itself is
sumo-duaiterate — SUMO’s dynamic user-assignment driver duaIterate.py on the
shipped eclipse-sumo==1.27.1 wheel — resolving the ADR-027 duaIterate deferral.
lopez2018microscopic anchors the software lineage (the tool-paper discipline of
ADR-027/029; the row validates the ADAPTER + engine fidelity, never the ITSC
paper’s numerics). Every number below was measured on this box on 2026-07-16 by
running the pinned wheel; the engine-free substrate is exercised in
tests/test_edoc.py and the live engine in tests/test_sumo_duaiterate.py (the
sumo CI leg).
Decision — the row¶
sumo-duaiterate is an EDOC producer, not a TrafficAssignmentModel¶
Unlike sumo-marouter (a static macroscopic SUE whose flows are certified against
the scenario’s declared BPR — ADR-027), duaIterate is a dynamic mesoscopic
assignment with no declared cost law to certify against. Per ADR-036 the engine
IS the instance: the certifier re-derives every scored number by re-running the
pinned engine on the model’s emitted plans (G1). So the adapter
(tabench.models.adapters.sumo_duaiterate.SumoDuaIterateAdapter) emits the ADR-036
artifact contract — plans P, the door-to-door experienced record X, and
provenance — for EdocEvaluator to certify; it is not registered in
MODEL_REGISTRY (that registry is the static gap-certified track). All ADR-027
subprocess discipline is inherited: binaries and the tools/assign/duaIterate.py
driver are addressed only through sumo.SUMO_HOME (never the stale
/opt/sumo-1.12); stdin=DEVNULL; a single wall deadline threads netconvert +
every duaIterate iteration + the replay(s) + the R3 duarouter cross-check; a return
code is never trusted (every step re-reads the artifact it wrote); an infra failure
RAISES RuntimeError and is never laundered into feasible=0; every run is a
mkdtemp tree removed in finally.
The instance → SUMO compile is deterministic and fully hashed (substrate extension)¶
The abstract EdocScenario graph carried only edge_fftt — not enough to compile a
SUMO net deterministically or to hash the network’s dynamics (ADR-036 hashes
“the network + demand artifacts” and places capacity drops inside
route-distinguishing edges). Two per-edge/scalar fields were added, both hashed:
edge_lanes(per-edgeint64): the ENGINE-side capacity dial. SUMO meso flow capacity is a function of lanes + freespeed + the pinned meso config — measured ~1584 veh/h/lane at 13.89 m/s (a saturating single-lane flood: steadyentered≈ 132 veh / 300 s). A capacity drop is therefore fewer lanes on a route-distinguishing edge; no separate veh/h field exists because capacity is emergent from lanes + speed + config, which is how meso actually works.canon_speed_mps(scalar, default 13.89): the canonical freespeed. Edgelength = fftt · canon_speed_mps, so free-flow time =length / speed = ffttexactly;numLanes = edge_lanes,speed = canon_speed_mps. Node coordinates are a deterministic 2-D layout carrying no cost (explicit lengths override geometry; meso junction control is off), so the whole net is a function of already-hashed fields. A compile read-back re-parsesnet.net.xmland RAISES if any edge’snumLanes/lengthdid not survive (netconvert’s silent 0.1 m min-length clamp — the_sumo_iohazard).
A third hashed scalar, r3_tolerance_s (default 15 s), pins the R3 cross-check
agreement bound (parity with replay_deadline_s). semantic_config is derived
from the pinned meso option set so a drift in those options moves the instance hash
(it cannot silently change dynamics under a frozen hash). The mechanical
hash-coverage test (mutate every field → the digest must move; ADR-024) covers the
three new dials; the golden Braess hash cf00f411… is byte-untouched (the static
scenario class is domain-separated).
X is produced by the adapter’s own pinned replay (never scraped from duaIterate)¶
Measured on 1.27.1: a bare pinned sumo meso replay is bit-deterministic (twin
runs byte-identical → the G1 determinism double passes), but it does not reproduce
duaIterate’s last-iteration tripinfo to the second (152 vs 153 s per agent —
duaIterate’s internal sumo call uses different options). So scraping X from
duaIterate’s internal tripinfo would G1-censor every honest run. Resolution: the
adapter emits P = the duaIterate final chosen-routes file and derives X + the
experienced-cost field from its own pinned meso replay of P; the certifier’s G1
re-runs the identical pinned replay (recompiling the net from the hashed fields, so
the emit-time and both certifier replays agree byte-for-byte) and reproduces X by
construction. Trips are emitted junction-to-junction (fromJunction/toJunction
duarouter--junction-taz) so the routes duaIterate chooses are pure scenario-edge walks — no src/sink edges that would break G2 route validity. Meso teleport is disabled (--time-to-teleport -1): a gridlocked instance runs to the declared horizon and shows as backlog / incomplete (censored by G3), rather than silently teleporting demand away (the ADR-036 head-block-loss hazard); the wall deadline is the hard stop against a true hang.
Artifact-contract clarification (refines ADR-036; ADR-036 itself is unchanged).
This row makes ADR-036’s emitted-artifact contract precise: X and the frozen
field are DEFINED by the pinned replay config (a hashed instance field) applied to
P; the solver-internal experienced records are provenance, never gated. The
“pinned replay config” is the pinned meso option set + Δ + seed, all inside the
instance hash (semantic_config is derived from _MESO_OPTS, and Δ/seed are their
own hashed fields), so the map P ↦ (X, field) is frozen with the instance. The
justification is measured: duaIterate’s last-iteration internal tripinfo differs from
the pinned replay by 152 vs 153 s per agent (its internal sumo call uses
different options), so gating on the solver’s self-reported experienced record would
false-censor every honest run. This is the stronger reading of “the engine is the
instance,” not a weakening: the certified claim is precisely “P is best-response
stable under the pinned replay map,” and the solver that produced P (duaIterate
— an MSA-style loop — or anything else) is exactly as model-blind as every other
solver in the benchmark. There is no self-certification loop: G1 independently
re-runs the pinned replay (recompiling the net from the hashed fields), so a doctored
X still diverges from the replay and censors.
Deliverables (all shipped this sprint)¶
The certifier substrate (G0–G4 +
RG_D1), canonicalizer, field builder, TD-SP,EdocScenario, replay types — engine-free, tested intests/test_edoc.py(35 tests after the S2 fix batch’s engine-free pins). The G0 split adds a runner engine-pin: the ReplayRunner asserts the installedeclipse-sumoversion (importlib.metadata) equals the instance pin before replaying (assert_engine_pin) — a mismatch RAISESValueError(a config error, never a censor). The walk-enumeration TD-SP is the reference implementation of ADR-036’s pinned universe (“walks under a hashed length bound”); its exhaustive minimum is trivially sound. Upgrade path: a later row needing a faster label-correcting time-expanded search MUST prove equivalence against this reference on the same universe before substituting it.The
sumo-duaiterateadapter — compile → duaIterate → pinned replay → the R3 duarouter cross-check.The row certification entry point (
certify_emitted, added in the S2 fix batch): the G0–G4 +RG_D1substrate certificate (EdocEvaluator) PLUS the mandatory R3 cross-check (F4) PLUS the separation-vetting assertion (F10), all under one wall deadline.EdocEvaluatorstays engine-agnostic (it cannot runduarouternor know a family’s engine-run separation status), so the row couples both here — the realization of ADR-036 R3’s “mandatory cross-check on that engine’s rows.”The R3 duarouter cross-check (
duarouter_recost_crosscheck, wired intocertify_emitted): re-cost the driven plans with the pinnedduarouteron the replay’s frozen dump and compare, per agent, to the substrate’s driven field cost on the same field (no routing, no origin wait on either side — like-for-like). A disagreement beyondr3_tolerance_sRAISES: the field the certifier scores on must be the field the engine’s own weight reader sees.The scenario family —
build_diamond_scenario(the parametric O→N1→D / O→N2→D diamond with the 1-lane drop ona2),reference_scenario(the pinned instance),shared_bottleneck_scenario(the refusal demonstration), andnegative_control_separation(the gate).
Family constants — re-derived with the SHIPPED estimator (R4)¶
Per ADR-036 R4, the family floor and separation factor were re-derived with the
shipped estimator (occupancy-aware field + non-FIFO walk-enumeration TD-SP +
per-first-edge origin-wait profile), not the pilot estimator. The pinned
reference_scenario (2026-07-16, eclipse-sumo 1.27.1, seed 42):
dial |
value |
how derived |
|---|---|---|
topology |
diamond, route A ( |
A is uniquely free-flow-shorter → AON piles onto the |
|
|
|
|
13.89 |
length = fftt·speed (≈ 972 m / 1042 m) |
demand |
720 agents, quantum 2.0 s (≈ 1800 veh/h, ~1.14× the 1584 veh/h/lane capacity) |
AON saturates the bottleneck; converged splits |
Δ / |
300 s / 16 (4800 s horizon) |
Δ=300 aggregation; horizon covers completion |
separation |
AON |
declared |
floor |
field-vs-experienced delta ≈ 8.8 s (converged) |
declared |
R3 |
duarouter re-cost vs substrate field cost: max ≈ 5.0 s, mean ≈ 1.3 s |
declared |
backlog |
max ≈ 0 s at the reference operating point |
|
Track decision — deterministic, single seed (R5/deliverable 6)¶
Cross-seed converged RG_D1 measured at seeds {42, 7, 123, 2024} = {0.0215, 0.0232,
0.0274, 0.0211} → range 0.0063, well below the resolution floor_gap ≈ 0.094
(15 s / mean cost ≈ 160 s). The seed variation is below the resolution floor, so
the row is on the deterministic track with a single pinned seed (42), disclosed —
not the R5 stochastic macrorep-list track (that ships with matsim, the stochastic
flagship). Timings: emit (compile + duaIterate 18 iters + replay) ≈ 3–4 s; certify
(G1 double + scoring) ≈ 0.5 s; the full sumo-gated test file (17 tests after the
S2 fix batch’s engine-gated pins, incl. the separation gate’s 4 emits + the refusal)
≈ 26 s — within the sumo leg’s 2–4 min shape.
Consequences¶
New code, one CI-leg extension, no hash change to any existing instance. The substrate + adapter +
tests/test_sumo_duaiterate.pyare additive; the sumo CI job gains the one file; the golden Braess hash is byte-identical (re-asserted in both the substrate and row tests).eclipse-sumostays an optional extra behind the guarded import inmodels/__init__.py; the numpy/scipy core (and the EDOC substrate’s own tests) run without it.R8 leaderboard non-comparability.
sumo-duaiteratescoresRG_D1— the frozen-field best-response gap, NOT the Wardroprelative_gapof the static rows. It lives on a separate leaderboard table and its number is never compared tosumo-marouter’s ~1.7e-4 mapping floor or any staticrelative_gap(ADR-036’s headline: “the frozen-field gap is a different, honestly-labeled quantity on its own scale”).ROADMAP. The
lopez2018microscopicrow annotation extends with asumo-duaiterate(adr-036/037) unblock pointer + the R8 non-comparability sentence (it carries no deferral note to remove). MODELS.md gains no card — the EDOC track is notgenerate_models.py’sMODEL_REGISTRYsurface, and no card exists to annotate (hand-annotated like every shipped flip;tools/generate_references.pyis never run).Named follow-ups (each its own sprint): the
matsimrow (the P8 macrorep + bootstrap-CI stochastic track), thedtalite-simulationrow (the R9vehicle.csvconstruction + ~600 veh/h inflow law), and the BO4Mob stage-2 D2 certificate — all reuse this substrate. The row’s tutorial notebook (tutorials/11-external/02-sumo-duaiterate.ipynb, ADR-035 schema{track:"external", unit:"sumo-duaiterate", requires_extra:"sumo"}) lands in this row’s commit, once the11-externaltrack’s01-sumo-marouter.ipynbco-exists (the within-folder numbering gate is contiguous-from-01).EDOC producers join the tutorials coverage manifest in the same commit that registers them. Because an EDOC row is not in
MODEL_REGISTRY(the registry-driven coverage gate would miss it), the row binds to the tutorials gate explicitly: a guarded_track_manifestentry ("sumo-duaiterate"→ the adapter’s public symbol, imported behind the sameModuleNotFoundError/sumoguard as the adapter itself, so it is enforced on the sumo CI leg and invisible on the core-only legs) lands atomically with the notebook — never via the shrink-only_ALLOWLIST(which stays untouched: a new unit shipping notebook + manifest in the same commit as the unit IS that invariant working).
Adversarial review — S2 finding record¶
Two review rounds ran against this row before it lands. Round 1 (infra-numerics lens, on the uncommitted code) surfaced the CRITICAL crash-vs-censor launder plus four MAJORs; round 2 (soundness-forgery + adr-fidelity lenses, re-run fresh) converged on the SAME CRITICAL from three independent probes and added the R3-wiring, G2-tolerance, and separation-vetting findings. All were repro-confirmed against the pinned eclipse-sumo 1.27.1 wheel and each carries a regression pin that fails under fix-removal. No hashed instance constant changed (the reference/golden hashes are byte-untouched — the fixes touch certifier behavior and code constants, not the instance definition). The batch, deduped:
# |
Sev |
Finding |
Fix |
Pin |
|---|---|---|---|---|
F1 |
CRIT |
certifier-side infra faults (deadline pre-exhaustion, missing binary, netconvert/compile failure, read-back mismatch) laundered into |
|
|
F2 |
MAJOR |
wall-kill orphaned the |
|
|
F3 |
MAJOR |
hashed |
|
|
F4 |
MAJOR |
the R3 duarouter cross-check was never invoked on any certification path |
wired into the new |
|
F5 |
MAJOR |
no departure-window construction gate (negative + beyond-horizon departures constructed) |
|
|
F6 |
MAJOR |
the netconvert 0.1 m clamp read-back was dead code (0.5 m absolute tolerance unreachable) |
|
|
F7 |
MINOR |
G2 departure tolerance was |
tightened to exact-within- |
|
F8 |
MINOR |
ambient |
|
|
F9 |
MINOR |
(a) unparseable read-backs raised raw |
(a) wrap parse/gzip failures to the contract |
|
F10 |
NOTE |
the negative-control separation refusal was procedural (a separate callable |
|
un-vetted branch of |
F1 censor-scope boundary (ratified). PlanReplayFailure is the ONE censor
signal and it fires ONLY on the pinned sumo plan-replay subprocess crashing
(nonzero rc) or timing out (the wall hit during the replay step). A missing or
garbage plan-run artifact after rc=0 is deliberately an infrastructure
RuntimeError RAISE, not a censor — because this row disables meso teleport
(--time-to-teleport -1), so an invalid or gridlocked emitted plan runs to --end
and yields a tripinfo whose missing agents are censored by G1/G3, never a
missing/garbage file. A file that vanishes or is unparseable after a rc=0 replay
is therefore an engine/disk anomaly (certifier-side infrastructure), not an invalid
emission. This is the boundary that reconciles R6’s first arm (engine crash while
replaying model plans → censor) with its second arm (certifier crash on valid inputs
→ RAISE) for this engine, and it is what lets F1 (three RAISE pins) and F9(a)
(garbage → contract RuntimeError RAISE) hold simultaneously. Consistent with
ADR-036 R6 — no certificate semantics changed.
Sources (the honest ledger)¶
The row’s numbers are the ADR author’s own executed measurements, not pilot
provenance; the ADR-036 pilot anchors (converged RG_D1 0.01526, AON 0.17203, 11×,
floor 25.6 s on the pilot’s net3 with the pilot estimator) are not comparable in
kind — a different net and the pilot estimator — and are superseded for the row by
the re-derived shipped-estimator constants above.
MEASURED THIS SPRINT (eclipse-sumo 1.27.1 wheel, this box, 2026-07-16; fresh scratch dirs, never mutating any pilot tree): the meso ~1584 veh/h/lane capacity (single-lane saturating flood); the reference converged/AON/separation/floor/R3/ cross-seed constants tabulated above; the replay bit-determinism (twin-run byte-identical canonical hash) and its non-identity to duaIterate’s internal tripinfo; the
fromJunction/toJunction+--junction-tazroute-choice mechanic; the compile read-back.ATTRIBUTED UNREAD (canon tool paper, software lineage — tool-paper discipline): Lopez et al. (2018), Microscopic Traffic Simulation using SUMO, IEEE ITSC (
lopez2018microscopic, tier 1) — anchors theduaIterate/sumo/duaroutertoolchain’s lineage, per the ADR-027 precedent; Gawron (1998) IJMPC (gawron1998iterative) —duaIterate’s default route-choice dynamics.Design authority + repo precedents read in full:
docs/design/adr-036-external-dynamic-observational-certificate.md(the certificate this row realizes) andadr-027-sumo-marouter.md(the subprocess-discipline precedent,_sumo_iohazards);src/tabench/models/adapters/_sumo_io.py(sumo_binary/sumo_env); the shipped EDOC substrate modules. Noreferences.bibedit and notools/generate_references.pyrun were made.