dtalite-tap — DTALite’s static Frank-Wolfe, an IDENTITY compile map¶
What. dtalite-tap wraps the PyPI DTALite wheel’s assignment() entry
(Zhou & Taylor 2014, [zhou2014dtalite],
docs/REFERENCES.md): a static Frank-Wolfe
user-equilibrium solver — the wheel’s own source calls it TAPLite, a
Bar-Gera FW.zip-derived link-based FW loop on per-link BPR costs. This
adapter compiles a fixed-demand scenario into the engine’s GMNS CSVs, runs
assignment() in a throwaway subprocess, reads the flows back, and lets the
harness certify the equilibrium gap under the scenario’s DECLARED BPR (P1).
Why it is in the benchmark, and why it is different from sumo-marouter.
sumo-marouter (adr-027) mapped the repo’s BPR into a hardcoded linear class
law — a real, measured MAPPING FLOOR. DTALite’s per-link
vdf_fftt/vdf_alpha/vdf_beta is, with the right constants, the repo BPR
t = fft (1 + b (v/cap)^power) exactly — there is no mapping floor to
separate out. Sioux Falls’ power=4 links, unrepresentable in marouter’s
linear law, map here with no cost-model approximation at all — the first
external engine on the power-4 ladder. See
docs/design/adr-029-dtalite-tap.md
for the full derivation (the tool-paper sourcing discipline: zhou2014dtalite
anchors the software LINEAGE, not this static-FW formulation, which is
Bar-Gera’s) and every measured anchor.
Scope. The identity compile map (Decision 3), a certified Sioux Falls
power-4 run with the gap recomputed in-cell, the returncode-never-trusted
lesson (the engine exits 0 on corrupted input — success is defined by the
read-back, not the exit code) and the sorted-links fix it motivated, and the
honest headline against a converged bfw.
How this notebook is graded¶
A notebook never claims a number it does not compute in that cell. Every
scored quantity below is recomputed live by the P1 Evaluator from the flows
the engine emitted, in the cell where it is claimed. The engine’s own
self-reported gap (engine_relative_gap, a DIFFERENT (TSTT-SPTT)/SPTT
normalization) is shown only as provenance, diffed against the certificate,
never trusted on its own — the same discipline as every other model here
(README, Certified, not self-reported).
# Setup. `dtalite-tap` is GUARDED behind the optional `DTALite` wheel
# (`pip install tabench[dtalite]`). Unlike the torch/sumo guards, this one
# NEVER `import DTALite` even to probe it: the package prints a version
# banner to stdout and ctypes-loads a compiled engine + OpenMP into the host
# process on import. `importlib.util.find_spec` touches neither -- the exact
# probe the adapter module itself uses (adr-029).
%matplotlib inline
import dataclasses
import importlib.util
if importlib.util.find_spec("DTALite") is None:
raise ModuleNotFoundError(
"dtalite-tap needs the optional 'dtalite' extra: pip install tabench[dtalite]"
)
import numpy as np
from tabench import (
BiconjugateFrankWolfeModel,
Budget,
Evaluator,
RngBundle,
Trace,
braess_scenario,
load_scenario,
two_route_scenario,
viz,
)
from tabench.core.scenario import Demand, Scenario
from tabench.models.adapters.dtalite_tap import DTALiteTapModel
The identity compile map (adr-029, Decision 3)¶
Writing lanes = 1, a 1-hour demand period, vdf_plf = 1, and the repo’s
(free_flow_time, b, power, capacity) verbatim into the engine’s per-link VDF
t = vdf_fftt (1 + vdf_alpha (I / (lanes cap period plf))^vdf_beta) collapses
it to the repo BPR EXACTLY — no approximation, unlike marouter’s linear class
law. The adapter enforces this at runtime, not just by construction: every
solve re-verifies the engine’s own travel_time column against
network.link_cost on every link (A2), and raises rather than certifying a
row if it does not match — so a successful solve() below IS the mapping-
fidelity proof, not merely an assumption.
scenario = load_scenario("siouxfalls") # BPR power == 4: marouter-unrepresentable
print(f"scenario : {scenario.name}")
print(f"content hash : {scenario.content_hash()[:16]}…")
print(f"links/power : {scenario.network.n_links} (power={sorted(set(scenario.network.power.tolist()))})")
model = DTALiteTapModel()
bundle = model.solve(scenario, Budget(iterations=100), RngBundle(0), Trace())
final = bundle.final
metrics = Evaluator(scenario).evaluate(final.link_flows)
print(f"certified relative gap : {metrics['relative_gap']:.4e}")
print(f"feasible : {metrics['feasible']:.0f}")
print(f"engine (provenance) : engine_relative_gap={final.self_report['engine_relative_gap']:.4e}, "
f"executed FW iterations={final.self_report['engine_iterations_executed']:.0f}")
assert metrics["feasible"] == 1.0
assert metrics["relative_gap"] < 5e-2 # the engine's line-search floor (measured ~5.0e-3)
oracle = scenario.reference.link_flows
nrmse = float(np.sqrt(np.mean((final.link_flows - oracle) ** 2)) / np.sqrt(np.mean(oracle ** 2)))
print(f"flow NRMSE vs best-known UE : {nrmse:.4f} (measured ~1.5-1.6%)")
assert nrmse < 0.05
scenario : siouxfalls
content hash : 7f5035885f0a03ef…
links/power : 76 (power=[4.0])
certified relative gap : 5.0343e-03
feasible : 1
engine (provenance) : engine_relative_gap=5.0598e-03, executed FW iterations=99
flow NRMSE vs best-known UE : 0.0147 (measured ~1.5-1.6%)
returncode == 0 is never trusted, and why links are written sorted¶
Almost no bad input crashes this engine: missing files, dropped links, a
zone_id != node_id mismatch — all exit 0 with zero or garbage flows. So
success here is DEFINED as the read-back (every repo link matched exactly
once, the echoed VDF parameters agreeing, and the A2 cost-match holding), not
the exit code. The CRITICAL finding this discipline caught: the engine builds
its adjacency from CONTIGUOUS (from_node_id, to_node_id) ranges, so an
UNGROUPED link.csv silently corrupts routing — a permuted Braess once
certified feasible=1 at the WRONG flows (measured RG 0.208 vs the true
0.0118), and a permuted Sioux Falls sent the Frank-Wolfe loop into an
INFINITE loop. The fix: links are always written sorted by node pair. Proven
live below — a permuted network reproduces the unpermuted solve exactly.
base = braess_scenario()
f_base = DTALiteTapModel().solve(base, Budget(iterations=100), RngBundle(0), Trace()).final.link_flows
net = base.network
for perm in ([4, 3, 2, 1, 0], [0, 2, 1, 3, 4], [3, 0, 4, 1, 2]):
p = np.asarray(perm)
pnet = dataclasses.replace(
net, init_node=net.init_node[p], term_node=net.term_node[p],
capacity=net.capacity[p], length=net.length[p],
free_flow_time=net.free_flow_time[p], b=net.b[p], power=net.power[p],
toll=net.toll[p], link_type=net.link_type[p],
)
psc = Scenario(name="braess-perm", network=pnet, demand=Demand(base.demand.matrix))
f_perm = DTALiteTapModel().solve(psc, Budget(iterations=100), RngBundle(0), Trace()).final.link_flows
assert np.allclose(f_perm, f_base[p], atol=1e-6)
print(f"unpermuted flows : {np.round(f_base, 4)}")
print("3 permuted link orderings reproduce the unpermuted solve (mapped back), atol=1e-6: OK")
unpermuted flows : [4.1999 1.8001 2.4096 1.7903 4.2097]
3 permuted link orderings reproduce the unpermuted solve (mapped back), atol=1e-6: OK
The honest headline: a converged bfw still wins the convergence axis¶
The engine’s Armijo line search collapses to step 0 within a few iterations (the certified gap freezes at a floor — Braess ~1.2e-2, Sioux Falls ~5.0e-3), far above a converged white-box solver. Because the compile map is the identity (A2 above), that floor is honestly the engine’s own line-search stall, not a cost-model mismatch — “DTALite’s static assignment certifies RG X at N iterations on the SAME BPR the white-box solvers optimize.”
bfw_trace = Trace()
BiconjugateFrankWolfeModel().solve(
scenario, Budget(iterations=300, target_relative_gap=1e-12), RngBundle(0), bfw_trace
)
bfw_gap = Evaluator(scenario).evaluate(bfw_trace.final.link_flows)["relative_gap"]
print(f"bfw (converged) : relative_gap={bfw_gap:.3e}")
print(f"dtalite-tap : relative_gap={metrics['relative_gap']:.3e}")
assert bfw_gap < metrics["relative_gap"] / 10.0
bfw (converged) : relative_gap=3.228e-06
dtalite-tap : relative_gap=5.034e-03
Refusals this adapter makes, naming the field¶
sue_theta and the other endogenous-task fields are refused (the engine’s
static assignment() cannot represent them certifiably); a link capacity
below 0.1 is refused because the engine clamps it at fmax(0.1, cap) in
the cost law only (the Beckmann integral stays unclamped — a link in
(1e-4, 0.1) would equilibrate under a DIFFERENT BPR while still passing a
naive echo read-back, measured relative A2 error ~0.93).
try:
DTALiteTapModel().solve(two_route_scenario(), Budget(iterations=5), RngBundle(0), Trace())
raise AssertionError("expected an SUE-theta scenario to be refused")
except ValueError as exc:
print(f"SUE-theta scenario refused : {exc}"[:110] + "…")
low_cap_net = dataclasses.replace(two_route_scenario(sue_theta=None).network,
capacity=np.full(4, 0.05))
low_cap_sc = Scenario(name="low-cap", network=low_cap_net,
demand=Demand(two_route_scenario(sue_theta=None).demand.matrix))
try:
DTALiteTapModel().solve(low_cap_sc, Budget(iterations=5), RngBundle(0), Trace())
raise AssertionError("expected a sub-0.1 capacity to be refused")
except ValueError as exc:
print(f"capacity < 0.1 refused : {exc}"[:110] + "…")
SUE-theta scenario refused : dtalite-tap accepts only fixed-demand deterministic-UE scenarios; scenario 'tworo…
capacity < 0.1 refused : dtalite-tap cannot represent scenario 'low-cap': a link capacity < 0.1 is CLAMPE…
Visualize¶
The certified artifact is per-link flows on a road Network — the same
artifact shape every static model here emits — so tabench.viz applies
directly (adr-035’s viz rule).
display(viz.plot_network_flows(scenario.network, final.link_flows))
display(viz.plot_flow_scatter(("best-known UE", oracle), {"dtalite-tap": final.link_flows}))
Takeaways & pointers¶
No mapping floor — the ceiling is line-search stall. Because the compile map is the identity (verified by the adapter’s own runtime A2 gate), the certified gap here is honestly FW truncation, not a cost-model approximation — unlike
sumo-marouter.returncode == 0proves nothing. The engine exits 0 on corrupted or dropped input; success is defined by the read-back, which is why links are ALWAYS written sorted by node pair — verified live above across three permutations.The power-4 ladder, finally reachable by an external engine. Sioux Falls’
power=4links were unrepresentable insumo-marouter’s linear class law; DTALite’s per-linkvdf_betamaps them exactly.Where next.
spsa-sumo(04-spsa-sumo.ipynb) for a simulator-in-the-loop T2 estimator built onsumo-marouter; the full engine-hazard writeup (theExitMessage/getchar()hang risk, thelanes²trap, the BIG-M mass gate) in docs/design/adr-029-dtalite-tap.md.