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%)

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}))
../../_images/39fe4b1a2e8e0284028452fc1d7313f970c6f8197d596ca8131a8fdb15cfef74.png ../../_images/727d9f63dcf796e1491a0cf1f1a93e27ba93acc875a2868fe35529edbdf46b03.png

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 == 0 proves 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=4 links were unrepresentable in sumo-marouter’s linear class law; DTALite’s per-link vdf_beta maps them exactly.

  • Where next. spsa-sumo (04-spsa-sumo.ipynb) for a simulator-in-the-loop T2 estimator built on sumo-marouter; the full engine-hazard writeup (the ExitMessage/getchar() hang risk, the lanes² trap, the BIG-M mass gate) in docs/design/adr-029-dtalite-tap.md.