ADR-042 — odme-dtalite: DTALite’s static ODME as one more guarded T2 estimator row¶
Status: accepted (shipped in v0.2)
File: docs/design/adr-042-odme-dtalite.md
Context — the T2 thesis, and a second engine-in-the-loop estimator¶
T2 (ADR-002) ships a demand-free estimation contract: anything that emits an OD
matrix is scored by the identical pinned-bfw certifier (metrics/estimation.py),
which recomputes count-fit and OD-fit from the emitted OD through its own reference
assignment (P1, ADR-002 Decision 2). spsa-sumo (ADR-028) was the first guarded
estimator riding a production engine, and — crucially — it did so with zero task,
runner, or certifier changes (the adr-028 ideal): the estimator emits, the unchanged
certifier scores.
DTALite 0.8.1 (Zhou & Taylor 2014, canon zhou2014dtalite — the tool paper whose title
names “fast model evaluation and calibration”) hides an Origin-Destination Matrix
Estimation routine (performODME) inside the same static assignment() entry the
dtalite-tap T1 adapter (ADR-029) already wraps: it runs in TAPLite.cpp’s
AssignmentAPI() right after the Frank-Wolfe loop when settings.csv carries
odme_mode=1. There is NO separate ODME API. It is a gradient-descent OD calibrator —
given a seed OD, a target OD, and sensor link counts (obs_volume), it re-weights the
route-flows the base assignment discovered so the modeled link volumes approach the
counts. This ADR ships it as odme-dtalite, the second engine-in-the-loop T2 estimator,
under the same adr-028 zero-certifier-change ideal.
Decision 1 — a GUARDED STATIC estimator on the UNCHANGED certifier¶
odme-dtalite is a DtaliteODMEEstimator(ODEstimator) registered in
ESTIMATOR_REGISTRY behind a guarded import (Decision 6). It emits an OD matrix
through ODResultBundle.final.od_matrix — the SAME channel spsa/gls/spsa-sumo
use — and the EXISTING pinned-bfw ODCertifier scores it unchanged. No
estimation/base.py, metrics/estimation.py (ODCertifier), experiments/runner.py,
or EstimationTask code is touched. The scope-probe confirmed this is possible:
ODME lives on the static TAPLite path (never SimulationAPI/trajectory.csv), so the
OD it emits is certifiable through the exact same declared-BPR pinned-bfw pin every
static T2 row uses — a guarded static estimator, not a D2 observational certificate
(which would only have been forced if ODME needed a re-simulated held-out count).
A DTALite-pinned certifier was considered and REJECTED, for the same reason adr-028 rejected a sumo-pinned one: scoring estimators against the very engine one of them runs in the loop is a conflict of interest, and the whole T2 point is that the certifier is model-blind. Keeping the pinned-bfw certifier makes the standard self-vs-certified honesty diff MEASURE the engine-in-the-loop bias (Decision 5).
Decision 2 — route_output=1 is REQUIRED (the non-obvious, measured pin)¶
This is the load-bearing finding of the sprint, and it is not in the design dossier
— it was caught by the mandatory measure-first pilot (below). ODME does not read the
Frank-Wolfe MainVolume when it computes each observed link’s modeled volume; it
reconstructs that volume from the stored route/path history. With DTALite’s lean
route_output=0 (the dtalite-tap T1 default), that history is never populated, so the
reconstruction collapses to ~0 on every sensor. ODME then sees a fake, enormous deficit
on every observed link and inflates every OD cell to its box ceiling.
Measured on the Sioux Falls marquee anchor (below), with route_output=0:
the emitted OD total demand is +40 % over truth, od_rmse ~2× WORSE than the prior,
and the certified obs_count_rmse ~7× WORSE — a degenerate estimator that would have
silently shipped had the pilot not measured the certified before/after. Flipping to
route_output=1 restores a faithful reconstruction (the engine’s internal predicted
volume matches the observed count to ~1.0 ratio) and turns the row non-degenerate
(Decision 3). settings.csv therefore pins route_output=1 AND odme_mode=1; a
structural test fails if either reverts. route_output=1 is part of the pinned
estimator identity.
Decision 3 — the marquee anchor is Sioux Falls scale (the tol=1 floor), measured¶
The pilot (ruling: pilot-and-decide the anchor BEFORE the full build) proved a
non-degenerate recovery exists. Marquee anchor: siouxfalls UE (24 zones / 76 links
/ 528 OD pairs, BPR power-4), clean counts, sensors random coverage 0.5 (38 links),
held-out coverage 0.2, stale prior cv=0.3, seed 7. Through the UNCHANGED pinned-bfw
certifier:
metric |
prior baseline |
odme-dtalite |
ratio |
|---|---|---|---|
|
994.8 |
365.3 |
0.37× |
|
816.5 |
556.6 |
0.68× |
|
267.4 |
264.5 |
0.99× |
|
0.014 |
−0.002 |
— |
od_feasible = 1, certificate_converged = 1, certificate_gap < 1e-6, and the ODME
gradient descent ran 69 iterations (never a “Convergence reached after 0 iterations”
no-op). This is a real “estimator beats prior” row: ODME measurably improves BOTH the
observed count fit it calibrates AND the ranking held-out count fit. od_rmse barely
moves — ODME is a count-matcher and Sioux Falls OD is not linearly identifiable from
these sensors (od_identifiable = 0, reported, honest).
Why Sioux Falls, not Braess. DTALite’s ODME convergence tolerance is a HARDCODED
absolute gradient-norm floor tol=1 (Decision 4). On Braess-scale demand (~6), a
realistic count mismatch never clears the floor and ODME no-ops with “0 iterations”
(measured). The marquee anchor MUST be at a demand magnitude where realistic deviations
clear the fixed floor — Sioux Falls (OD mean ~683, min 100) does; Braess does not. This
is the same A4 scale choice dtalite-tap made.
Decision 4 — the disclosed engine envelope (part of the pinned 0.8.1 identity)¶
Three hardcoded, non-configurable engine behaviors bound what this row can do. They are disclosed as HONEST scope boundaries — reproducibility-good, like marouter’s hardcoded linear vdf (ADR-027) — not defects:
A hardcoded
[0.5·min(seed,target), 1.5·max(seed,target)]box baked into the line search, no settings-file exposure. Withdemand_target = prior, the estimator can only recover a truth cell within ~2× of the prior. Measured: with a large count mismatch the emitted cell lands at EXACTLY1.5×and sits there for the rest of the descent (a hard wall, not a soft penalty).demand_target_frac(default 1.0) is a documented one-sided dial:demand_target.csv = prior · frac, so>1raises the upper bound,<1lowers the lower bound (never both), at the cost of biasing the (also hardcoded) OD-regularization pull toward the inflated target. It is a hashed estimator-identity factor.Hardcoded penalty weights (
w_link=0.1,w_od=0.01,w_vmt=1e-6) and the ABSOLUTEtol=1convergence floor (Decision 3). Onlyodme_mode/odme_vmtare settings-configurable; the rest are C++ literals, along with the fixed 400-iteration descent.budget.iterationstherefore maps to the base FWnumber_of_iterationsonly — the ODME descent itself is not budget-controllable, which is disclosed, not hidden.A route-history fidelity caveat. ODME re-weights routes the prior BFW/AON assignment already loaded; it cannot invent flow on an unloaded route.
route_output=1(Decision 2) is what makes the captured routes’ reconstruction faithful; a genuinely unloaded observed link stays a blind spot.
Scope: single-mode “auto” demand-only, matching dtalite-tap’s sprint-1 scope.
Native multiclass ODME (mode_type.csv) is a named deferred follow-up. The scenario
envelope IS the T1 adapter’s — refusals (nonzero fixed cost / toll, sub-0.1 capacity
clamp, the SUE-family task fields) are delegated to
DTALiteTapModel._refuse_unrepresentable verbatim (the spsa-sumo precedent). Note
power≠1 is representable here (DTALite’s VDF is the repo BPR exactly), unlike the
marouter-backed spsa-sumo which refused power-4.
Decision 5 — link_performance.csv is corrupt under ODME; source the OD from od_performance.csv ONLY¶
Measured: under odme_mode=1 a lossy post-ODME “final synchronization” reconstructs the
link volumes from the route-history representation rather than the Frank-Wolfe
MainVolume, so they no longer conserve OD demand. The illustrative signature — a link
reporting volume=0 while its own travel_time implies a nonzero flow, and total
reconstructed link flow not matching total OD demand even at 0 gradient steps — was
measured on a partially-loaded two-route toy net (the design-probe designB
instance), and reproduces generally under the reconstruction-starved route_output=0
regime. It does NOT reproduce uniformly on the SHIPPED route_output=1 + fully-loaded
marquee (on Sioux Falls 0/76 links show the volume=0/travel_time>0 inversion — every
link is loaded). The
substantive invariant is config-independent and is what the row relies on:
link_performance.csv is structurally never read (no open() touches it; the emitted
OD is byte-identical after deleting the file, pinned by a test), so whatever corruption
it carries is unreachable by the certifier regardless of scenario or settings. The OD
estimate is therefore sourced exclusively from od_performance.csv’s volume column
(= MDODflow), which is unaffected. And the dtalite-tap
odme_mode=0 read-back gates (the A2 cost-match / per-origin mass-gate / echo-check) are
NEVER reused on an ODME run — they are validated only for odme_mode=0. The read-back
transports dtalite-tap’s “every repo link matched exactly once” discipline to the
OD-pair domain: every prior-support pair present (completeness), a pair’s route rows
consistent, no off-support/phantom pair, every volume finite and non-negative — else
RuntimeError (never trust returncode alone; the engine exits 0 on garbage).
Decision 6 — the anti-laundering property, and the honesty diff as a measured bias¶
The anti-laundering property is sacred and holds by construction.
ODCertifier.certify() (read in full) accepts only a raw (Z,Z) numpy OD and re-runs
its OWN pinned bfw solve — there is no DTALite import, no engine branch anywhere in that
file. The ONLY channel from odme-dtalite to the harness is
ODResultBundle.final.od_matrix. So even though DTALite’s own link_performance.csv is
corrupt (Decision 5), that corruption is structurally unreachable by the certifier. A
buggy or adversarial DTALite run can at worst emit a bad OD that certifies honestly as
a no-improvement row — it CANNOT forge a good certificate.
This is pinned from both sides by tests (Decision 7). Measured on the marquee: DTALite’s
OWN self-reported count fit (from its stalled-assignment, box-clamped predicted volumes)
is obs_count_rmse ≈ 8, while the pinned-bfw certifier scores the emitted OD at
≈ 365. The certifier ignored the rosy self-report entirely. That large self-vs-certified
diff MEASURES the engine-in-the-loop bias (the spsa-sumo reframing: DTALite descends
its own stalled law under a hardcoded box; the certificate re-assigns under the declared
BPR), reported as provenance, never a bound or a score.
Decision 7 — the FIRST guarded ODME estimator (guard shape, subprocess discipline)¶
Guarded byte-parallel to the spsa-sumo block (and the T1 model guard): a module-top
find_spec("DTALite") probe raises ModuleNotFoundError(name="DTALite"), swallowed by
exact name in estimation/__init__.py — the exact CAPITAL DTALite name (the wheel’s
module name, ADR-029), never imported in-host (the wheel prints a banner and ctypes-loads
an OpenMP engine on import). Subprocess discipline is the adr-029 contract verbatim:
subprocess-only ([sys.executable, "-c", "import DTALite; DTALite.assignment()"]),
stdin=DEVNULL, OMP_NUM_THREADS=1 (a correctness pin — the assignment/ODME path was
measured race-free, bit-identical across 21/21 runs at OMP∈{1,4}, but the pin is kept as
defense-in-depth), tempdir-per-run with finally cleanup, one wall deadline threaded
across write → subprocess → parse, returncode never trusted (success = read-back), and
an engine crash/timeout RAISES RuntimeError (never a false estimate). An sp_calls-only
budget is refused up front (the engine hides its Dijkstra count — the adr-027/028/029
pattern).
Determinism / wall math (measured on this box, DTALite 0.8.1 wheel)¶
The assignment+ODME path is byte-deterministic under OMP_NUM_THREADS=1: the marquee’s
od_performance.csv is md5-identical across 3 reruns, so the estimator is
deterministic=True, seedable=False (no engine seed to pin — the RngBundle root seed
lands in the bundle as provenance). The ODME subprocess wall at the marquee scale is
~0.4–2.0 s (base number_of_iterations 20→400); the row rides the EXISTING dtalite CI
job (same 0.8.1 pin), adding tests/test_odme_dtalite.py to that job’s explicit file
list and odme-dtalite to its notebook -k filter. No new CI job.
Honest sourcing¶
zhou2014dtalite anchors the DTALite software lineage (tool-paper discipline, the
lopez2018/adr-029 precedent): its title names calibration, of which ODME is an instance,
so no new bib entry is needed. The row validates the ADAPTER + engine fidelity, never the
paper’s numerics. The engine version + the ODME settings (odme_mode=1, route_output=1,
the hardcoded weights/box/tol it implies) are part of the estimator’s pinned identity.
This is the same wheel and .so (md5 e179ed66…, S4-verified) as dtalite-tap and
dtalite-simulation — three rows, three engine entries, one pinned wheel.
Consequences¶
One more T2 leaderboard row, on the SAME pinned-bfw certifier and the SAME separate-no table as every static estimator (directly comparable — unlike the EDOC observational rows). Additive; the golden Braess content hash
cf00f411…is byte-identical.A new, disclosed pattern: a guarded estimator whose engine has a hardcoded representability box + convergence floor, honestly bounded rather than tuned. The marquee is chosen to clear the floor; a small-demand anchor no-ops (disclosed).
The measure-first pilot earned its keep: the
route_output=0degeneration would have shipped a silently-broken always-inflating estimator.route_output=1is now pinned and tested.
Adversarial review — measured hazards, all disclosed, none blocking¶
route_output=0degeneration (Decision 2) — the sprint’s headline finding, caught by the pilot, pinned by a structural settings test.link_performance corruption (Decision 5) — never read; a test deletes it post-run and re-parses
od_performance.csvto byte-identical OD.the
[0.5,1.5]×box +tol=1floor (Decisions 3,4) — disclosed envelope; the marquee clears the floor with 69 iterations, anddemand_target_fracis a documented one-sided dial.anti-laundering (Decision 6) — pinned from both sides: the certifier ignores DTALite’s rosy self-report (measured 8 vs certified 365), and a poisoned OD certifies feasible-but-worse.