ADR-029 — DTALite assignment(): the second external engine, and the identity-map static-UE row

Status: accepted (shipped in v0.2) File: docs/design/adr-029-dtalite-tap.md

Context — the second external simulator, and a cost law that maps EXACTLY

ADR-027 shipped sumo-marouter, the first external simulator: its hardcoded linear-in-flow class law forced a mapping floor (Braess RG ~1.7e-4) because the scenario’s BPR could only be approximated. This ADR is the second external engine, the named dtalite-adapter sprint (canon zhou2014dtalite, tier 1, docs/REFERENCES.md): the PyPI DTALite wheel (Fang Tang + Xuesong Zhou, 0.8.1, ~1.3 MB, no declared dependencies, ctypes-loads a compiled engine). Its assignment() entry is a static Frank-Wolfe user-equilibrium solver with a per-link BPR VDF — so unlike marouter the compile map is the identity on (free_flow_time, b, power, capacity), and Sioux Falls power-4 becomes the marquee anchor rather than an “unrepresentable” refusal.

Everything below was verified by executing the pip wheel in this repo’s python (pip install DTALite==0.8.1) against the repo’s own scenarios and certifying the emitted flows through the UNCHANGED harness (P1). Three engine hazards make the subprocess wrapper mandatory rather than merely hygienic (§4).

Decision 1 — dependency mechanics (the adr-025/027 optional-extra precedent)

  • Extra: dtalite = ["DTALite>=0.8"] in [project.optional-dependencies], a floor not a pin (a == in library metadata conflicts with user environments). The FW/VDF behavior lives in a bundled .so that can shift under the floor, so the CI workflow pins DTALite==0.8.1 (the tested engine) as the anchor tripwire.

  • Guard — one deliberate deviation from the sumo shape. import DTALite has two side effects the sumo guard never had to consider: it prints a version banner to stdout and ctypes-loads the engine .so + libgomp into the host process. So the adapter module NEVER imports DTALite at module scope; it probes availability with importlib.util.find_spec("DTALite") and raises ModuleNotFoundError(name="DTALite") when absent. models/adapters/__init__.py and models/__init__.py swallow that by exact name (exc.name != "DTALite" re-raises — note the case: the module is DTALite, not dtalite). Engine-version provenance comes from importlib.metadata.version("DTALite"), never an import. A regression asserts import tabench prints nothing to stdout when the model registers (the banner-suppression test unique to this adapter), and that blocking DTALite via a meta_path finder unregisters the model while the numpy core still imports.

  • CI: a FIFTH job dtalite, a clone of the sumo/torch job shape (py3.12, checkout, setup-python, data cache + prefetch + TABENCH_REQUIRE_DATA=1, pip install DTALite==0.8.1 before pip install -e ".[dev,dtalite]", then only pytest tests/test_dtalite.py). No pip-cache step (the 1.3 MB wheel does not warrant it — the cache overhead exceeds the download). Kept separate from the sumo job (not folded) so either dependency’s breakage cannot kill the other’s signal (the ADR-027 Decision-1 reasoning; here the cost asymmetry that might justify folding does not exist — the wheel is tiny). actionlint-clean.

Decision 2 — the model contract

DTALiteTapModel (dtalite-tap), registered with class-level capabilities, in src/tabench/models/adapters/dtalite_tap.py (one module — the mapping is a near-identity so no separate _io module clarifies anything).

  • Paradigm: reuse "heuristic" (the aon/sumo-marouter precedent). The Evaluator branches on the scenario’s task fields, not the paradigm, so a heuristic-paradigm model earns the certified relative_gap/AEC/Beckmann on static scenarios exactly like aon. We do NOT declare static_ue: white-box status means “internals match the scenario’s declared costs”, and while the engine IS a genuine FW UE solver on the exactly-mapped BPR, the certified gap tells the truth either way. No capabilities.py change, no Evaluator branch, no scenario field, no hash change.

  • deterministic=True, provides_gap=False, seedable=False. The FW loop has no RNG: link_performance.csv is byte-identical across reruns at number_of_processors=1 (re-confirmed in-tree by md5). Unlike marouter there is no command line to pin a provenance seed on, so seedable=False (the RngBundle root seed still lands in the manifest via seed_info). provides_gap=False: the harness recomputes the gap; the engine’s self-reported gap is recorded as provenance only (§ Measured, it uses a different normalization).

  • Budget (P6). iterations number_of_iterations (floored at 1); wall_seconds is threaded as a single deadline through all three phases (write inputs → engine subprocess → parse); sp_calls is unmappable (the engine hides its Dijkstra count): coords.sp_calls = 0, and an sp_calls-ONLY budget raises ValueError up front — never silently unbounded. target_relative_gap is ignored (the engine runs a fixed count, no gap-target hook) — disclosed. Unlike ADR-027, the executed iteration count IS recoverable from summary_log_file.txt, so coords.iterations reports the executed FW count and self_report carries the configured count.

  • Error surfaces, three distinct. (a) missing DTALite → absent from registry (guard); (b) nonzero exit, timeout, .so load failure in the child, missing/empty link_performance.csv, or a read-back mismatch → RuntimeError with command + stdout/stderr tails (an engine failure is infrastructure, NEVER laundered into feasible=0); (c) emitted-but-bad flows → the normal harness censor path, zero adapter logic. Zero off-diagonal demand → in-adapter exact-zero short-circuit before any subprocess.

  • Hygiene. Each solve runs in a tempfile dir prefixed tabench-dtalite- (cleanup guaranteed on raise via finally); the keep_files factor keeps it (last_workdir). The child’s CWD confinement means the engine’s ≥9 output CSVs never touch the repo/CWD.

Decision 3 — the compile map is the IDENTITY (the central content)

The engine’s per-link VDF, measured and source-confirmed, is t = vdf_fftt · (1 + vdf_alpha · (I/Link_Capacity)^vdf_beta) with I = V/lanes/period_hours/vdf_plf and Link_Capacity = lanes·capacity. Writing lanes = 1, a 1-hour demand period (07:00–08:00), vdf_plf = 1, and the repo values verbatim (vdf_fftt = free_flow_time, vdf_alpha = b, vdf_beta = power, capacity = capacity, toll = 0) collapses this to the repo BPR t = fft·(1 + b·(v/cap)^power) exactly. Five engine traps are neutralized:

  • Grouped-input requirement (the review CRITICAL). The engine builds its adjacency from contiguous FirstLinkFrom/LastLinkFrom node ranges, so link.csv must be written grouped/sorted by (from_node_id, to_node_id). An ungrouped file silently corrupts routing: a permuted Braess certifies feasible=1 at the WRONG flows (RG 0.208 vs the true 0.0118) and a permuted Sioux Falls sends the FW loop into an infinite loop (which, under an iterations-only budget with no wall, hangs solve() forever). The adapter now writes links sorted by node pair; the read-back matches by node pair, so the emitted order is transparent to it and the solve is permutation-invariant.

  • The capacity fmax(0.1, cap) clamp (the review MAJOR). The engine clamps capacity at fmax(0.1, cap) in the cost law only (Link_Travel_Time; the Beckmann integral is left unclamped — internally inconsistent below 0.1), so a capacity in (1e-4, 0.1) equilibrates under a DIFFERENT BPR while passing the echo read-back (measured A2 rel 0.93, certified feasible=1 RG 0.799 with false “pure FW truncation” provenance). The adapter refuses any capacity < 0.1, naming the clamp. The engine’s other fmax floors — lanes 0.01, period 0.001, plf 0.0001 — are all neutralized by our fixed lanes=1 / period=1 h / plf=1.

  • The lanes² trap (measured): for lanes = L the engine divides the ratio by L²·capacity while its own doc column divides by L·capacity — the two agree only at L = 1. The adapter ALWAYS writes lanes = 1 with capacity = the total link capacity, so the assignment-driving cost is the textbook BPR.

  • Case sensitivity (measured): the engine matches column names case-sensitively and silently ignores an uppercased column (VDF_Alpha → default 0.15). The adapter writes lowercase columns and the read-back (below) catches any that were ignored.

  • vdf_fftt override: writing vdf_fftt explicitly overrides the length/free_speed-derived free-flow time, so the abstract native cost units pass through verbatim (no mph/km conversion enters the cost). Confirmed: Sioux Falls fft = 6.0 echoes 6.0000 exactly.

Read-back (the house compile-check) — echo checks plus a LIVE A2 cost-match. link_performance.csv echoes vdf_fftt/vdf_alpha/vdf_beta/vdf_plf/link_capacity per link. The adapter verifies every repo link is matched exactly once by (from_node_id, to_node_id), the row count equals the link count (no phantom/dropped link), each echoed parameter equals the declared value within the engine’s float32/4-decimal precision, and the volume is finite. Because the echo check is vacuous below its atol = 1e-3 absolute floor (a doctored/ignored sub-1e-3 vdf_fftt is accepted — the review MINOR), the LIVE gate is a runtime A2 cost-match: the engine’s own travel_time column must equal network.link_cost(flows) on EVERY link (the identity map’s payoff), to relative 1e-3 (measured legit max ~2e-5 on the anchors, ~7.6e-4 on Barcelona’s power-16.83 links). This closes the echo blind spot AND catches the capacity clamp (rel 0.93) and an ignored vdf_fftt (rel 1). Every cell conversion and the file read are wrapped so a corrupt-but-parseable output raises the contract RuntimeError (with stdout+stderr tails), never a raw ValueError/TypeError that would collide with the ValueError refusal channel (the review MAJOR). A mismatch raises RuntimeError — this is what makes returncode == 0 untrustworthy safe: the engine exits 0 on missing files, dropped links, and zone_id != node_id with zero/garbage flows, so success is DEFINED as the read-back (echo + A2 + mass) matching, not the exit code.

BIG-M ceiling → per-origin mass gate (the review MAJOR). The engine’s ~1e7 generalized-cost ceiling silently ZEROES an OD whose congested cost exceeds it (measured: a single link cap=100, power=4 carries demand=9000 but emits 0.0 at demand=10000), producing a well-formed all-zero-flow row that passes the echo/A2 read-back and would otherwise launder to feasible=0. So after the read-back the adapter runs a per-origin mass gate: each origin zone’s emitted outflow must cover its routable demand (through traffic only ADDS, so a shortfall is always an engine drop, never a solution — measured deficit on clean inputs is 0, far below the 4-decimal rounding floor ~1e-4). A deficit raises RuntimeError with the engine diagnostics. The per-origin (not merely total) check covers the multi-OD partial-drop variant.

Wall deadline through the parse phase (the review MINOR). The single wall_seconds deadline is checked after the subprocess AND after the read-back, so a slow large-network parse cannot silently overrun the budget the ADR promises to enforce end-to-end.

Zone convention. Zones are nodes 1..n_zones with zone_id == node_id (the hard engine check); other nodes get zone_id = 0. settings.csv first_through_node_id carries the scenario’s TNTP first_thru_node verbatim — verified to route Sioux Falls (ftn = 1, all 24 nodes are zones) correctly, all 76 links matched.

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

Numbers are what the 0.8.1 wheel produced in-tree; bounds in the tests are loose ceilings calibrated to them (version-robust — the .so can shift under the floor).

  • A1 — Braess (builtin, power=1, the fft=1e-6/alpha=1e7 sentinels). feasible = 1, node balance 0, certified RG 1.183e-2 at 100 iterations, frozen from ~iter 40 (the engine’s Armijo line search collapses to step 0 — the honest convergence ceiling, §”What it means”). The golden Braess content hash cf00f411… is re-asserted byte-identical. The sentinels survive the parse: vdf_fftt = 1e-6 echoes as 0.0 (4-decimal display rounding) but the internal cost still matches (A2 below); vdf_alpha = 1e7 echoes exactly. The representation survives the engine’s CSV float parse — no silent corruption, so the row ships.

  • A2 — cost-matched anchor (MANDATORY). The engine’s own travel_time column equals scenario.network.link_cost(v) at the emitted volume on every link (the identity map’s payoff — there is no mapping floor to separate out, unlike marouter): measured max relative error 7.1e-6 on Braess (the two sentinel links; the clean links match to ~1e-15) and 2.2e-5 on Sioux Falls power-4 (dominated by the engine’s 4-decimal travel_time column, not a cost-model error). The test pins < 1e-3. This proves the certified gap is pure FW truncation, not a cost mismatch — the row does not ship without it.

  • A3 — two-route deterministic UE. two_route_scenario(sue_theta=None): feasible = 1, RG = 0.0 (exact UE, flows (2.5, 2.5, 1.5, 1.5)). Needs no theta calibration at all (contrast marouter’s calibrated logit theta) — the engine solves deterministic UE natively, and on this well-conditioned instance exactly.

  • A4 — Sioux Falls power-4 (the marquee, marouter-impossible). feasible = 1, all 76 links matched, certified RG 5.034e-3 at 100 iterations (frozen from ~iter 100), flow NRMSE 1.58% vs the best-known TNTP UE flows, A2 max-rel 2.2e-5. The first external engine on the power-4 ladder — where marouter’s linear law refused, DTALite’s per-link vdf_beta maps it exactly. The ~5.0e-3 stall floor is a Braess/Sioux-Falls figure; larger nets converge tighter (below).

  • Big-net exposure (the review’s first large-network run). At the scenario budget (iterations=500), both mixed-power TNTP nets certify with the sort fix and pass the A2 / mass gates: Winnipeg (2836 links, powers 0–6.87) feasible=1, RG 1.176e-3, A2 max rel 5.2e-4, wall ~7 s; Barcelona (2522 links, powers up to 16.83) feasible=1, RG 5.48e-4, A2 max rel 7.6e-4 (the tightest legit A2 margin under the 1e-3 gate), wall ~5.2 s. On both, the engine self-gap equals RG/(1−RG) to 6 digits (the converged regime, below).

  • Negative controls + review regressions. iterations=1 runs the pure all-or-nothing load (0 FW line searches — measured: number_of_iterations = 1 executes zero loop iterations after the initial AON), certifying feasible = 1 with a much worse gap (Braess RG 1.9e-1) — an honest near-AON row that also pins the dial live: RG(1) > RG(100) (compared at iteration 1, not an intermediate — the gap freezes from ~iter 40, so 5 vs 100 is a plateau). Byte-determinism rerun. The refusal matrix (below). A tiny wall_seconds RAISES RuntimeError, and a slow parse RAISES too (the deadline covers the parse phase). Permuted link arrays reproduce the unpermuted solve (the sort fix). The capacity clamp refuses; the BIG-M drop raises; garbage engine cells and a wrong travel_time raise _ReadBackError (the A2 gate); a torn summary pair keeps the last complete (iter, gap). Read-back rejects a short/phantom output and an ignored VDF parameter. Temp-dir hygiene on the normal and raising paths.

The iterations off-by-one, resolved by measurement. number_of_iterations = N logs Frank-Wolfe iterations 1..N-1 in summary_log_file.txt (N−1 line searches after an initial AON) and labels the link_performance.csv row iteration_no = N. So DOSSIER B’s source read (N → N−1) is correct, not DOSSIER A’s “100 → 100”. N = 1 → 0 line searches = the pure AON (valid, non-empty); N = 0 → accessibility mode (empty output). So the floor is 1 (the AON is a legitimate near-AON row); coords.iterations reports the executed N−1, and self_report.engine_configured_iterations = N.

The self-reported gap definition, resolved by measurement. The engine’s printed gap is (TSTT SPTT)/max(0.1, SPTT) in percent (a clamped SPTT/least-TT denominator), whereas the repo’s certified relative_gap normalizes by TSTT. On converged anchors it equals RG/(1−RG) to 6 digits (verified on Sioux Falls, Winnipeg and Barcelona; Braess engine 0.011978 vs RG/(1−RG) 0.011976), so it is NOT the repo gap. Off the converged regime it can be negative or frozen (a stalled instance emits a stale/SPTT>TSTT gap; the 0.1 denominator clamp distorts it further). It is recorded as self_report.engine_relative_gap (provenance only); the row is certified on the harness value alone, and the self-report is never gated on.

Refusals (naming the field)

sue_theta, elastic_demand, combined_demand, br_epsilon, side_capacities, link_interaction, multiclass each raise ValueError naming the field (the engine’s static assignment() cannot represent them in a certifiable form). Nonzero fixed_cost (toll_weight·toll + distance_weight·length) is refused: the engine HAS a toll column, but its toll/vot time-conversion is unvalidated and a negative toll KILLS the host process, so tolls are refused now (the possible lift is recorded here, not silently run — unlike marouter, where a toll model is impossible). A link capacity < 0.1 is refused up front (the engine’s fmax(0.1, cap) cost-law clamp would equilibrate it under a different BPR; a capacity 1e-4 is also silently dropped). An sp_calls-only budget is refused (no Dijkstra count is exposed); sp_calls = 0 is disclosed. multiclass is the natural sprint-2 extension (the engine does native multiclass via mode_type.csv), refused here pending a per-class-flow certificate.

What the certified row MEANS (headline discipline, adr-025/027)

The row is the honest engine Frank-Wolfe convergence under an exactly-represented cost law. Because the compile map is the identity and A2 proves every link’s cost matches, the certified RG at budget B is FW truncation error — and here that truncation is the engine’s own line-search stall: the Armijo backtracking collapses to step 0 within a few iterations (measured: gap frozen from ~iter 40 on Braess, ~iter 100 on Sioux Falls) and never recovers, so the certified gap floors at ~1e-2…1e-3, far above a converged bfw’s ~1e-16 (pinned: bfw beats it by orders of magnitude on Braess). The headline names WHOSE equilibrium and WHICH axis: “DTALite’s static assignment certifies RG X at N iterations on the same BPR the white-box solvers optimize — the first external engine scored on the power-4 ladder; bfw/algb beat it on the convergence axis, as expected.” The ceiling is the wheel-engine-as-shipped’s line-search stall, not the mapping (there is none) and not the benchmark.

Alternatives considered

  • path4gmns (the sounder engine) — the sibling PyPI package (Peiheng Li + Xuesong Zhou) exposes an in-process find_ue/find_ue_fw with iteration AND target-gap parameters and a returned gap (a cleaner P6 map than a zero-argument CWD-coupled ctypes call), real license text, and active maintenance. It was NOT chosen for sprint 1 only because dtalite-adapter is the named sprint target with the journal anchor (zhou2014dtalite); path4gmns has no journal tool paper of its own (a GitHub citation only). Recorded as a possible future row — same GMNS dialect and datasets, the same honest-sourcing caveat (validate the adapter+engine artifact, not paper numerics).

  • simulation() (mesoscopic DTA) — the engine’s separate DTA_SimulationAPI is the Zhou & Taylor (2014) queue-based within-day simulator (agent/trajectory outputs). None of the repo’s dynamic certifiers (dta/, dnl/, estimation_dynamic.py) accepts an external simulator’s trajectories on a static TNTP scenario; wiring one needs a new dynamic scenario family + certificate ADR — a named non-goal here (the follow-up on the DTA/DNL ladder, where the 2014 paper is genuinely on-point).

  • The engine’s built-in ODME (odme_mode, Lu/Zhou/Zhang lineage) — a named future T2 guarded estimator, the spsa-sumo (adr-028) analog: the same adapter reused inside the estimation track, certified by the unchanged pinned-bfw certifier. Not in sprint 1.

  • In-process ctypes — disqualified (§4): ExitMessage = getchar() + exit() in-process would hang or kill the harness, assignment() reads/writes CWD (needing process-global chdir), and a second call in one process doubles the flows.

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

Honest sourcing (the central section)

  • zhou2014dtalite anchors the software LINEAGE, not the formulation (tool-paper discipline, exactly as ADR-027 did for lopez2018microscopic). The Cogent Engineering 2014 paper (read in full: CC-BY 3.0, DOI 10.1080/23311916.2014.961345) describes a mesoscopic queue-based simulator + ODME and contains no BPR/VDF anywhere — its “assignment” is gradient-projection path-flow adjustment toward a gap-function DUE, specified only by citation (Lu, Mahmassani & Zhou 2009). It reports no equilibrium benchmark numerics (the Triangle Regional case study is proprietary and un-shipped), so like lopez2018microscopic it is a tool-paper anchor: the row validates adapter + engine fidelity, never the paper’s numbers.

  • What assignment() actually is. The wheel’s only C++ is TAPLite.cpp, whose header states it is “built based on … Bar-Gera’s FW.zip”. DTALite.assignment()DTA_AssignmentAPI() is therefore a classic static link-based Frank-Wolfe TAP loop on BPR costs, derived from Bar-Gera’s FW.zip — the 2014 paper’s mesoscopic DUE machinery is NOT exercised (that is the separate simulation() entry). The model card says so explicitly. Wheel↔source identity was cross-checked two ways (nm -D exports exactly DTA_AssignmentAPI/DTA_SimulationAPI; the .so’s format strings match TAPLite.cpp), but the wheel binary is not provably built from the public source — the behaviors here are the executed wheel’s, version-scoped to 0.8.1.

  • Artifact provenance. PyPI DTALite 0.8.1 (2025-04-08), authors Fang Tang + Xuesong Zhou, home github.com/itsfangtang/DTALite_release; the release’s own citation line is “Tang, F., Zheng, H., and Zhou, X. (2025)”. A young package (4 releases). The PyPI metadata asserts Apache 2.0, but the LICENSE file in the source repo is empty (0 bytes) — GitHub reports SPDX NOASSERTION. For CI consumption (pip install, run the artifact) this is acceptable (the metadata grant is the authors’ declaration, nothing is copyleft), but the repo vendors no engine code and commits no sample data (the TNTP-derived data_sets/ are academic-use-only with no license metadata; the harness generates its own Braess/two-route GMNS and downloads Sioux Falls checksummed on demand, P9). The empty-LICENSE state is recorded here.

Wall math

One wall_seconds deadline is computed once from solve() start and threaded through input-writing → the single engine subprocess → output parsing (only one subprocess phase, unlike marouter’s two). With OMP_NUM_THREADS = 1 (belt-and-braces with number_of_processors = 1 — the engine’s default OpenMP thread count is all cores, pure spin: measured Sioux Falls 8.5 s wall by default vs 0.1 s at OMP_NUM_THREADS=1), a solve is python startup + import DTALite (banner + .so load) + assignment() + output write ≈ 0.5 s (Braess/two-route trivially; Sioux Falls 100 FW iterations ~0.1 s CPU). The CI job: setup ~40 s, install ~20 s (1.3 MB wheel), data prefetch cached, anchors seconds → ~1.5–3 min.

Consequences

  • New: DTALiteTapModel (dtalite-tap, registered when DTALite present); src/tabench/models/adapters/dtalite_tap.py; the dtalite optional extra; one CI job (the fifth); tests/test_dtalite.py (26 tests — A1–A4, the near-AON/monotonicity/ determinism controls, the refusal + sp_calls-only + wall-timeout gates, the read-back gate, temp-dir hygiene, the banner-suppression + core-install guard unique to this adapter, and the review regressions: the permuted-links sort, the capacity-clamp refusal, the BIG-M mass-gate raise, the garbage-cell + wrong-travel_time A2 read-back, the parse-phase deadline, and the summary torn-pair). 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 suite pass without the wheel — the dtalite-free matrix legs are the live regression).

  • Follow-ups: the mesoscopic simulation() DTA row (a different equilibrium concept, the DTA/DNL track); the built-in ODME as a T2 guarded estimator (the spsa-sumo analog); a path4gmns static row (the sounder API, no journal anchor); the toll/vot lift of the fixed_cost refusal (once the time-conversion is validated); multiclass via mode_type.csv — note the read-back/mass gate consume the pce-weighted volume column, which equals mod_vol_auto at pce = 1 (the single-mode case); a multiclass lift must switch to the per-mode mod_vol_<mode> columns and needs a per-class-flow certificate.

Adversarial review

Three independent lenses (soundness, formulation, numerics), each executing Python/pytest against the running 0.8.1 wheel; every finding CONFIRMED by a runnable repro and fixed with a per-finding regression (streak: 18/18 sprints with at least one material defect; 26 dtalite tests after the fixes, from 19).

CRITICAL (soundness): an ungrouped link.csv corrupts the engine’s adjacency. The engine builds routing from contiguous FirstLinkFrom/LastLinkFrom node ranges, so a link.csv NOT grouped by from-node silently mis-routes: a permuted Braess certified feasible=1 at the WRONG flows (RG 0.208 vs 0.0118), and a permuted Sioux Falls sent the Frank-Wolfe loop into an infinite loop — which, under an iterations-only budget (timeout=None), hangs solve() forever. FIXED: _write_gmns writes links sorted by (from_node_id, to_node_id); the read-back matches by node pair, so the solve is now permutation-invariant (pinned by a permuted-Braess-reproduces-the-solve regression).

MAJORs, all fixed + pinned: (a) the engine clamps capacity at fmax(0.1, cap) in the cost law only (the Beckmann integral unclamped), so a capacity in (1e-4, 0.1) equilibrated under a DIFFERENT BPR with a passing echo read-back (measured A2 rel 0.93, false “pure FW truncation” provenance) — the refusal threshold is raised to < 0.1, naming the clamp; (b) the ~1e7 BIG-M cost ceiling silently ZEROES an OD whose congested cost exceeds it (single link cap=100 power=4: demand=9000 → 9000, demand=10000 → 0), laundering to feasible=0 — a per-origin mass gate now RAISES on any origin whose outflow fails to cover its demand; (c) a corrupt-but-parseable engine output raised a raw ValueError/TypeError that escaped solve() and collided with the ValueError refusal channel — every cell conversion and the file read are now wrapped to _ReadBackError → the contract RuntimeError with stdout+stderr tails.

MINORs, fixed: the echo read-back was vacuous below its atol=1e-3 (a doctored/ignored sub-1e-3 vdf_fftt was accepted) — a runtime A2 cost-match (engine travel_time == repo BPR at emitted flows on every link, rel 1e-3) is now the live gate, catching both the fftt drift and the capacity clamp; the wall deadline did not cover the parse phase (a slow parse overran a 1.5 s budget to 3 s) — one deadline now bounds the read-back too; the summary parser could pair an iter from one line with a stale gap from another under format drift (now commits the two together); the zero-demand path omitted engine_configured_iterations (now emits both keys); the self-gap docstring overstated the 1/(1−RG) relation (scoped to the converged regime — it can be negative/frozen off it).

Survived (highlights): Anaheim ftn=39 end-to-end — the explicit first_through_node_id semantics match TNTP exactly on grouped input (feasible=1, RG 5.94e-5, ZERO through-centroid traffic against a bfw detector control); illegal- shortcut flows cannot certify a negative gap (the Evaluator’s nonnegative-excess audit censors them); the identity compile map re-verified by the A2 cost-match on every link across Braess, Sioux Falls, a 60-net random adversarial parameter sweep (fft 1e-6..1e3, b 0..1e7, power {0, 1, 2.5, 4, 6.87}, zero law mismatches), Winnipeg (2836 links, powers 0–6.87, RG 1.176e-3) and Barcelona (2522 links, powers to 16.83, RG 5.48e-4) — the first big-net exposure, both feasible=1 and A2 ≤ 7.6e-4 under the 1e-3 gate; byte-determinism across reruns and under a hostile parent OMP_NUM_THREADS=64 (the child override wins); 8-way parallel solves with no cross-talk; the mid-engine wall kill (no leaked tempdirs, no zombie children); the banner-suppression + core-install guard (blocked-DTALite unregisters the model, import tabench stdout empty); the ValueError-refusal vs RuntimeError-crash separation; CI install-order simulation in fresh venvs (pin-first, no re-resolution; the core leg collects 731 tests without the wheel); temp-dir hygiene across the normal/refusal/raise/timeout paths; the golden Braess content hash re-asserted byte-identical.