ADR-027 — SUMO marouter: the first external-simulator adapter, and the simulator-to-benchmark model gap

Status: accepted (shipped in v0.2) File: docs/design/adr-027-sumo-marouter.md

Context — Phase 4 opens, and a stale assumption is overturned

Phases 1–3 shipped white-box solvers, day-to-day dynamics, DTA/DNL primitives, estimators, and three learned models. Phase 4 is the first external simulator: Lopez et al. (2018) “Microscopic Traffic Simulation using SUMO” (IEEE ITSC, canon lopez2018microscopic, tier 1), the tool paper for the SUMO package whose macroscopic assignment tool marouter this adapter wraps.

TASKS.md recorded that this row “cannot validate in CI.” That assumption is now false and this ADR corrects it. eclipse-sumo is pip-installable as a binary wheel (eclipse-sumo==1.27.1, manylinux, ~150 MB) that ships the real marouter/netconvert ELF binaries inside the package at sumo.SUMO_HOME/bin. So the adapter can be a fully CI-validated benchmark model behind an optional extra — exactly the torch-extra precedent from adr-025 — and the whole pilot below was verified by running SUMO 1.27.1 on this box.

This is act four of the accuracy-vs-certificate story (act 1 ridge: accuracy ≠ certificate; act 2 implicit-ue-nn: feasibility ≠ equilibrium quality; act 3 het-gnn: the feasibility-mechanism gradient). Act 4: a production simulator’s macroscopic assignment, run to its own convergence under its own hardcoded cost law, emits perfectly demand-feasible flows (audit passes, mass exact) whose certified relative gap under the scenario’s DECLARED BPR costs is real but small. The row is the first number for the simulator-to-benchmark model gap that practitioners implicitly set to zero when calibrating against SUMO.

Decision 1 — dependency mechanics (the adr-025 torch precedent)

  • Extra: sumo = ["eclipse-sumo>=1.27"] in [project.optional-dependencies], a floor not a pin. A hard == in library metadata conflicts with user environments and blocks fixes. The het-gnn unpinned-dep lesson is solved where it belongs — in the CI workflow, which installs eclipse-sumo==1.27.1 before pip install -e ".[dev,sumo]" (already-satisfied → no re-resolve), so the hardcoded-vdf mapping-floor anchors are pinned to the tested engine.

  • Guard: models/adapters/__init__.py and models/__init__.py wrap from .sumo_marouter import SumoMarouterModel in try/except ModuleNotFoundError, re-raising unless exc.name == "sumo". On a core install the model is simply not registered, so MODEL_REGISTRY / tabench list lack it and the register-model invariant holds. The sumo-free matrix legs are the live regression that import tabench works without it.

  • Binary discovery is sumo.SUMO_HOME-only. Never PATH / shutil.which / the ambient SUMO_HOME env var: this very box ships a stale SUMO_HOME=/opt/sumo-1.12 beside the 1.27.1 binaries — the exact discovery hazard. Every subprocess gets absolute wheel-binary paths and env={**os.environ, "SUMO_HOME": sumo.SUMO_HOME}.

  • CI: a FOURTH job sumo, a clone of the torch-job shape (py3.12, pip cache keyed pip-sumo-<hash(pyproject)>, data cache + prefetch + TABENCH_REQUIRE_DATA=1, pinned install, then only pytest tests/test_sumo_marouter.py). Not folded into the torch job (rejected): folding couples a ~150 MB sumo download onto the torch cache key and makes either dependency’s breakage kill the other’s signal. The job runs in ~2–4 min (marouter solves the anchors in < 0.5 s).

Decision 2 — the model contract

SumoMarouterModel (sumo-marouter), registered with class-level capabilities, in src/tabench/models/adapters/sumo_marouter.py.

  • Paradigm: reuse "heuristic" (the aon precedent). The Evaluator branches on scenario task fields, not paradigm, so a heuristic-paradigm model earns the certified relative_gap on static scenarios exactly like aon. A new external token is a core PARADIGMS change buying zero harness behavior; external-ness is provenance (the manifest records the engine version). No capabilities.py change.

  • deterministic=True, provides_gap=False, seedable=True. marouter’s SUE/logit/MSA path uses no RNG — netload is byte-identical across seeds AND reruns — so the seed is drawn from the RngBundle and pinned on the command line for provenance, and --routing-threads 1 is unconditional (byte- determinism verified only single-threaded). Caveat: the anchors were measured on the manylinux_2_28 x86_64 wheel (glibc ≥ 2.28, what ubuntu-latest CI selects); the manylinux2014 build for older glibc was not measured.

  • Budget (P6). iterations --max-iterations (floored at 1 — a 0 cap makes marouter emit the all-zero flow gawron/lohse are refused for). wall_seconds is threaded as a single deadline through BOTH the netconvert compile phase and the marouter run (the review MAJOR: the compile phase was silently unbounded, and combined with a lane explosion the overrun was unbounded — a tiny wall now raises promptly in either phase). sp_calls is unmappable (marouter exposes no Dijkstra count): coords.sp_calls = 0, and an sp_calls-ONLY budget raises ValueError up front — never silently unbounded (the adr-025 wall_seconds MAJOR, inverted). ONE checkpoint. coords.iterations records the configured cap with a self_report disclosure engine_iterations_executed_unknown=1 — marouter does not report the executed count (marouter -v was checked once: no iteration line), so this is an honest over-report flagged, not hidden. target_relative_gap is ignored (single shot; --tolerance is SUE stability under ITS costs, not the repo gap).

  • Error surfaces, three distinct. (a) missing sumo → absent from registry; (b) netconvert / marouter nonzero exit, timeout, or unparseable netload → RuntimeError with command + stderr tail — an engine crash is an infrastructure failure and must NEVER be laundered into feasible=0; (c) emitted-but-bad flows → the normal harness censor path, no adapter logic. Zero off-diagonal demand → in-adapter exact-zero short-circuit before any subprocess.

  • Hygiene. Each solve runs in a tempfile working dir with prefix tabench-sumo- (cleanup guaranteed on raise via finally); the keep_files factor keeps it for debugging (last_workdir). Never writes to CWD/repo.

Decision 3 — the compile map and the representability floors (the central honest content)

marouter does not take a user BPR function. Its congestion law is hardcoded per road class (src/marouter/ROMAAssignments.cpp: getCapacity + capacityConstraintFunction, “based on the definitions in PTV-Validate and in the VISUM-Cologne network”); with --capacities.default every edge uses one default class whose latency is linear in flow, measured on 1.27.1:

t(f) = t0 * (1 + K * f / C),   t0 = length / speed   (free-flow seconds)
speed > 26 m/s : C = 1400 * numLanes,  K = 2
speed <=  5 m/s : C =  200 * numLanes,  K = 6

A repo link with linear latency t(v) = A + B v (power == 1 only; A=fft, B=fft·b/cap) compiles, at time scale tau (s/native-cost) and flow scale s (veh/h per native flow), to a SUMO edge solving

t0    = A·tau                        -> length   = A·tau·speed
slope = t0·K/C = tau·B/s             -> numLanes = A·K·s / (B·cap_per_lane)

s = s_base · m, where s_base is the smallest scale making every representable link’s lane count an exact integer (so those links match the repo BPR to machine precision — the A2 anchor) and m an integer scaling the forced intercepts below tolerance (integrality is preserved for any m).

THREE representability bounds are unavoidable (slope/intercept coupled through t0; lane counts and edge lengths physically bounded). Each is validated BEFORE any file is written and refused loudly — never silently clamped or capped (the review CRITICAL/MAJORs):

  • Zero-intercept links (A 0, B > 0): the true intercept is 0 but every edge has t0 = tau·B·(C/K)/s > 0 (speed 5 band, C/K = 200·lanes/6). Forced abstract intercept eps = B·(200·lanes/6)/s must stay ≤ _INTERCEPT_FLOOR_TOL AND its edge length 5·tau·eps must clear netconvert’s silent 0.1 m minimum- length clamp — so the eps-edge lane count is chosen to satisfy both, refusing when it cannot. (The clamp was the review CRITICAL: at default factors a sub-0.1 m eps-edge compiled to 0.1 m, silently 3× the intercept AND slope, scoring wrong flows with false mapping-floor provenance.)

  • Zero-slope links (B 0, A > 0): every edge has slope > 0; the parasitic abstract slope A·K·s/C is minimized with many lanes, bounded ≤ _PARASITIC_SLOPE_TOL (default 0.004 → 1000 lanes on the two-route legs) — refused (not silently capped at _MAX_LANES) when the cap cannot reach it.

  • Lane quantization: numLanes must be an exact positive integer ≤ _MAX_LANES. The flow scale is rationalized with a bounded denominator (10**6, not the 10**12 that chased binary-double noise) and the coefficient reproduced to relative 1e-9; a generic decimal parameter (whose s/lane count explodes) is refused up front — a documented capability limit, not an hours-long netconvert hang. s = s_base · m (s_base the lcm making every representable link integral; m bounds the forced intercepts and honors min_lanes).

Two silent-failure hazards of the engine are caught rather than trusted: (1) the compile read-back reparses net.net.xml and verifies every lane’s length/speed/numLanes equals the declared value, raising RuntimeError on mismatch (the 0.1 m clamp backstop, and any other geometry rewrite); (2) marouter reverts an edge to free-flow when a path’s cumulative time exceeds the OD window (a hardcoded 1 h by default — it collapsed Braess to AON at demand=360), so the $OR;D2 window is sized from a worst-path bound under the mapped law, the trip count is scaled by the window so the flow RATE (and thus the equilibrium) is window-invariant (recovered by flow_scale = s·window), a No interval matches stderr is treated as RuntimeError, and the netload is required to carry exactly one interval (multi-interval would silently drop mass).

Other mapping pitfalls, all hit and neutralized: netconvert rounds declared lengths to --precision (default 2 → 0.8% slope error on sub-metre edges; use 6); geometry-based connection pruning silently drops turns on an abstract net (it collapsed Braess to a single path) → the converter emits an explicit connection file allowing every in→out movement (minus U-turns), geometry-independent; --weights.minor-penalty defaults to 1.5 s (zeroed); TAZ source/sink on the zone’s boundary edges conserves flow exactly; netload is read at --precision 9; flows are read from the entered attribute (exact macroscopic doubles), never the integerized route file; theta_sumo = theta_repo/tau (per second); tolls / generalized-cost fixed terms (toll_weight·toll + distance_weight·length) have no marouter hook and are refused (they would be silently dropped).

Because the certificate recomputes every metric repo-side from v = entered/flow_scale in native units, tau and s divide out of mass exactly (link flows are native) — but tau sets the compiled edge lengths (too small → the 0.1 m clamp) and the path times vs the OD window (too large → free-flow revert), so its factor bounds are the validated envelope [0.2, 30], backstopped by the read-back and window sizing (P1/P9).

Measured anchors (all certified by the harness under the true BPR, P1)

  • A1 Braess (SUE, logit, theta=200, beta=gamma=0, paths=4, tolerance=1e-7): flows (3.998, 2.002, 1.996, 2.002, 3.998) vs oracle (4,2,2,2,4); certified relative_gap 1.74e-4, node_balance ~7e-14, feasible=1. This equals the analytic mapping floor (the exact UE of the eps-perturbed costs; the pilot measured 1.727e-4 at s=14000) — marouter contributes essentially zero extra error. A converged bfw certifies ~1e-16 (the white-box solver wins the convergence axis, as expected).

  • A2 cost-matched anchor (the hardest risk). marouter’s internal traveltime equals the repo BPR cost at the emitted flows on representable links to ≤ 1e-6 (measured ~2e-10 Braess, ~4e-10 two-route). This SEPARATES the mapping floor (the eps/parasitic perturbations on the non-representable links) from any solver error — without it the certified gap is not interpretable. The row is not shipped without A2.

  • A3 two-route UE-approx (asymmetric; deterministic UE f_A = 2.5 at D=4): feasible=1, relative_gap 5.4e-4 < 1e-3; bfw orders better. theta is calibrated on THIS anchor, never Braess — Braess’s UE is the equal logit split at any theta (the theta-tuning trap), so its gap is theta-insensitive while the asymmetric two-route needs theta 50 to approach the deterministic UE. theta=200 gives comfortable margin with no exp saturation (stable through theta=1000).

  • Negative controls (honest rows). assignment_method="incremental" (the marouter DEFAULT, a non-equilibrium loading) certifies relative_gap 0.07 on Braess — feasible but far from equilibrium. "UE" raises (marouter silently falls back to SUE). gawron/lohse are refused (they emit all-zero flows the harness would censor).

  • Determinism. Byte-identical netload across same-seed reruns AND across seeds (no RNG in the SUE path).

What the certified row MEANS

“Industrially converged” is not “certified equilibrium.” marouter equilibrates its OWN model; the certified gap under the scenario’s declared BPR conflates (a) marouter’s SUE truncation and (b) the mapping floor. On a linear scenario the adapter drives (b) to the measured representability floor and A2 proves the matched links contribute nothing, so the small residual is the honest simulator-to-benchmark gap, not “SUMO is bad at UE.” Per the adr-025 lesson: the headline always names WHOSE equilibrium and WHICH axis — bfw-beats-it is pinned on the convergence axis only.

Alternatives considered

  • duarouter one-shot — free-flow all-or-nothing, adds nothing over the repo’s aon. Rejected.

  • duaIterate.py microscopic DUA — a genuinely different equilibrium concept (congestion emerges from car-following, not BPR), so it cannot be certified against the repo’s static BPR costs. It belongs to a future DTA/DNL ladder, not this static row. (It would also need the stale SUMO_HOME fixed; harmless for marouter/netconvert.)

  • Folding into the torch CI job — rejected (Decision 1): couples two heavy independent downloads and cross-contaminates their signals.

  • A new external paradigm token / an Evaluator branch / a scenario field — none built: the heuristic paradigm + the existing certificate already score the row honestly; the golden Braess hash is re-asserted byte-identical.

  • MATSim / DTALite / TraCI / libsumo / a Docker adapter — out of scope (Java/ C++ with no wheels, or online-control APIs); the eclipse-sumo wheel obsoletes the Docker route for SUMO.

Honest sourcing

  • lopez2018microscopic is a TOOL paper (IEEE ITSC, DOI 10.1109/ITSC.2018.8569938, canon-verified, PDF attributed unread). The citation anchors the SUMO suite (marouter/netconvert ship in the package it describes); the benchmark row validates the ADAPTER’s fidelity (mapping floor + harness-certified gap), never the paper’s numerics — it contains no marouter assignment numbers to validate against.

  • The vdf lineage is PTV-Validate / VISUM-Cologne, per the SUMO source comment in ROMAAssignments.cpp — NOT the ITSC paper. The route-choice c-logit commonality is Cascetta-style; the default methods are Gawron/Lohse. These are named from SUMO option names and code structure; the original PTV / Gawron documents were attributed unread.

  • The capacity-restraint law and capacity rules were established empirically here (fit + probes on the installed 1.27.1 binary), agreeing with the read source to < 1e-9; the ADR rests on the measured anchors, and the CI pins eclipse-sumo==1.27.1 as the tripwire since the tables are hardcoded upstream.

  • Adapter-refused traps, documented: marouter’s --assignment-method UE silently degrades to SUE (warning only); the DEFAULT method is non-equilibrium incremental. An adapter that trusted defaults would emit a RG ~1e-1 flow that still passes feasibility and get mislabeled as an equilibrium solver — so the adapter requests SUE explicitly, refuses UE, and restricts route choice to logit.

Consequences

  • New: SumoMarouterModel (sumo-marouter, registered when sumo present); src/tabench/models/adapters/_sumo_io.py (converter) + sumo_marouter.py; the sumo optional extra; one CI job; tests/test_sumo_marouter.py (30 tests, including the review regressions: compile read-back, the eps-edge min-length clamp, the high-demand OD-window sizing, toll refusal, lane-explosion refusal, the compile-phase wall budget, the time_scale envelope, the parasitic-slope cap, the iterations=0 floor, and the empty/multi-interval netload guards). No new certificate, scenario field, Evaluator branch, or paradigm; no change to capabilities.py, gaps.py, or any hash.

  • Unchanged: the Evaluator, the fairness gate, every hash (the golden Braess content hash is re-asserted byte-identical in the new test file), and the numpy-only core (import tabench and the full 731-test suite pass without the wheel).

  • Follow-ups: the microscopic duaIterate DTA row (a different equilibrium concept, DTA/DNL track); MATSim / DTALite adapters (each its own ADR); large-network lane-quantization scaling (1000 lanes verified; the upper bound on extreme-capacity-ratio nets is untested).

Adversarial review

Three independent lenses (soundness, formulation, numerics), each executing Python/pytest/marouter; every finding CONFIRMED by a runnable repro and fixed with a per-finding regression (streak: 16/16 sprints with at least one material defect; 30 sumo tests after the fixes, from 19).

CRITICAL (formulation): netconvert’s silent 0.1 m minimum-length clamp corrupted zero-intercept eps-edges. The compile map’s smallest edges could fall below netconvert’s undocumented length floor, which clamps them WITHOUT error — scoring wrong flows while the trace’s mapping-floor provenance claimed otherwise. The shipped anchors passed only because their lengths happened to clear the clamp. FIXED structurally: a compile read-back reparses the compiled net.net.xml and verifies every edge’s length/lanes/speed against the declared values (mismatch = RuntimeError, the crash discipline), and zero-intercept lane counts are chosen so eps-edges clear the clamp while the intercept stays within the floor tolerance — refusing when both cannot hold.

MAJORs, all fixed + pinned: (a) the hardcoded 1-hour OD window made marouter silently revert edges to free-flow when path times crossed 3600 s — an AON collapse that fired at the shipped Braess demand dial of 360; the window is now sized from a worst-path AON bound under the mapped law, the ‘No interval matches’ stderr is treated as an engine failure, and the netload parse verifies exactly one interval (multiple would silently drop mass); (b) tolled scenarios ran under a silently WRONG cost model (the compile map dropped fixed_cost; a tolled two-route scored gap 0.293 with false provenance) — any nonzero fixed_cost now refuses loudly; (c) the exact- rational flow scale exploded on ordinary decimal coefficients (2-decimal capacities → 1e5..1e6-lane edges → netconvert hangs measured at >690 s on 49/50 realistic fuzz scenarios) — rationalization is now denominator-bounded with a reproduction check, and ANY link whose quantized lane count exceeds the documented cap refuses before a subprocess spawns; (d) the wall_seconds budget never covered the COMPILE phase (a 3 s budget ran 23 s) — one deadline now threads through both the netconvert and marouter subprocesses.

MINORs/NOTEs, fixed: time_scale FactorSpec bounds admitted values that silently broke the mapping (bounds narrowed to the validated envelope; the false ‘divides out of the certificate’ doc corrected — tau divides out of mass, not the certified gap; the read-back now catches residual corruption as a raise); the _MAX_LANES cap silently violated the parasitic-slope claim (now a refusal naming the link and achievable floor); Budget(iterations=0) produced a censored all-zero row (floored at 1); a well-formed but edge-less exit-0 netload was laundered into the censor path (now raises when zero matching edges coexist with positive demand); missing binaries surfaced as bare FileNotFoundError (wrapped into the contract’s diagnostic RuntimeError); the CI engine pin gained sumo-data==1.27.1 (it floated independently); a test helper leaked its workdir on failure paths.

Survived (highlights): the vdf tables re-fit by measurement against the installed 1.27.1 across speed bands and lane counts (exact match to the adapter’s tables); both anchor floors independently re-derived (Braess eps-perturbed exact UE → RG 1.736e-4 vs the adapter’s 1.739e-4; two-route mapped fixed point matched to 5.6e-8); the cost-matched anchor (~1e-10 on representable links); command-injection immunity (list argv, scenario fields never reach the command line); env hardening against a booby-trapped ambient SUMO_HOME; temp hygiene across normal/refusal/raise/timeout paths with no zombie processes; byte-determinism across seeds, tempdirs, and processes; fresh-venv simulations of the core install (neither torch nor sumo pulled) and of the CI install order (no re-resolution); actionlint-clean workflow; no exact-decimal pin on any marouter output (version-robustness audit).