ADR-001 — Logit SUE: Dial’s STOCH loading, MSA-SUE, and the fixed-point certificate¶
Status: accepted (implemented in v0.x)
File: docs/design/adr-001-logit-sue-dial-certificate.md
Context¶
The roadmap (ARCHITECTURE §5, v0.x) calls for logit stochastic user equilibrium as the
first route-choice component, and T1 already promises “SUE fixed-point residual for SUE
tasks” (ARCHITECTURE §2). The hard question is not the solver — MSA with a stochastic
loader is textbook (Sheffi 1985, ch. 12) — but the certificate: P1 demands that the
harness recompute every scored metric from (link_flows, scenario) alone, for black
boxes included. For UE this is the externally computed relative gap. For SUE there is no
analogue via an objective: Fisk’s (1980) equivalent convex program is
min Σ_a ∫₀^{v_a} t_a(s) ds + (1/θ) Σ_rs Σ_p f_p^rs ln f_p^rs
whose entropy term is a function of path flows f_p, and a link-flow vector does not
determine its path-flow decomposition (nor, under Dial, even the admissible path set,
which moves with costs). So the Fisk objective cannot be evaluated from emitted link
flows and cannot be the certificate. What can be evaluated from link flows is the
defining fixed-point condition of SUE itself: v = L(t(v), θ), where L is a pinned,
deterministic stochastic-loading map. This ADR pins that map (Dial’s STOCH), the solver
(MSA), the certificate (an L1 fixed-point residual in AEC-like per-traveler units), and
where θ lives (an optional Scenario field that leaves every existing content hash
unchanged).
Terminology note: “stochastic” in SUE refers to traveler perception, not algorithmic
randomness. Dial’s logit loading is a closed-form deterministic function of (t, θ) —
no RNG, no Monte Carlo — so this model runs on the deterministic track (M=1). P5’s
“stochastic track: SUE” wording should be refined to “simulation-based SUE (e.g. probit,
v1)”; logit-SUE-via-Dial belongs with FW and MSA.
Decision 1 — The loading map: Dial’s STOCH, double-pass, origin-based efficiency¶
L(t, θ) is Dial’s (1971) STOCH algorithm in the double-pass form with the
origin-based efficient-link criterion r(i) < r(j), exactly as presented in Sheffi
(1985, §11.2 — the canonical free reference implementers actually use).
Dial’s paper defines a “reasonable” (efficient) path with two labels — every link must
lead away from the origin (r(i) < r(j), r = shortest time from the origin) and
toward the destination (s(i) > s(j), s = shortest time to the destination) — but
his recommended double-pass algorithm, and Sheffi’s textbook presentation of it, retain
only the origin criterion. We follow that choice because:
Reproducibility. Sheffi §11.2 is the presentation of record; matching it means a third party implementing “Dial per Sheffi” reproduces our certificate bit-for-bit in exact arithmetic. The two-label variant makes the efficient set destination-dependent, and implementations disagree on details.
Complexity. Origin-based efficiency lets one forward+backward sweep per origin serve all destinations (like AON):
O(|origins| · (m + n log n)). The two-label criterion needs per-OD-pair sweeps:O(|zones|²)label trees.Task-definition honesty. Whatever criterion we pick defines the benchmark’s SUE task; models and harness share the same map, so the choice must be pinned, not optimal. We pin the canonical one and record it in the scenario card (
sue.loading: dial-stoch).
Exact algorithm (per origin o with a positive demand row)¶
All computation is on the PathEngine expanded graph (models/_paths.py): centroids
below first_thru_node keep their index in the tail role and get an arc-less shadow
index in the head role, so no efficient path can traverse a centroid — the distance
labels r(·) automatically honor centroid restrictions. Costs t = Network.link_cost(v)
are generalized (BPR time + toll/distance fixed cost), strictly positive and finite
(guarded exactly as in PathEngine._graph).
Labels. Dijkstra from
o’s tail index:r(i)for every expanded node.Efficient links.
E_o = { a=(i→j) : r(i), r(j) finite and r(i) < r(j) }, strict float comparison, no tolerance (ties excluded; see Decision 3).Link likelihood (Sheffi eq. 11.7-style):
L_a = exp(x_a)withx_a = θ·(r(j) − r(i) − t_a)fora ∈ E_o, elseL_a = 0. By shortest-path optimalityr(j) ≤ r(i) + t_a, sox_a ≤ 0andL_a ≤ 1: likelihoods can never overflow, andx_a = 0exactly on shortest-path links.Forward pass (node weights, log domain), ascending
r. Sheffi’s recursion isW(o)=1,W(j) = Σ_{(i→j)∈E_o} L_ij · W(i).W(j)sumsexp(θ(r(j) − C_P))over efficient pathsP: o→j(telescoping product), and the number of efficient paths can be exponential —Wcan overflow float64 on large networks at small θ. We therefore computeb(j) = log W(j)directly:b(o)=0,b(j) = logsumexp_{(i→j)∈E_o} ( x_ij + b(i) )(max-shifted). This is algebraically identical to Sheffi’s recursion and overflow-free. Invariant: every reachable node’s shortest path consists of efficient links withx=0, soW(j) ≥ 1,b(j) ≥ 0— no zero-weight node, no 0/0 in the backward pass, ever (guaranteed by the Network validationfree_flow_time > 0).Backward pass (assignment), descending
r. Initialize node volumeV(head_index(d)) = q_odfor each destinationd ≠ owithq_od > 0(intrazonal demand skipped; unreachable positive-demand destination raisesRuntimeError, mirroringPathEngine.all_or_nothing). Sweep nodes in descendingr; at nodejwithV(j) > 0, split over incoming efficient links with fractionsφ_ij = exp(x_ij + b(i) − b(j))— each exponent ≤ 0 by definition ofb(j), soφ ∈ [0,1]withΣφ = 1in exact arithmetic; renormalize by the float sum so link flows conserve demand to machine precision — thenflow_ij += V(j)·φ_ij,V(i) += V(j)·φ_ij.
The resulting path shares are exactly the logit model over Dial-efficient paths:
Pr(P) = exp(−θ C_P) / Σ_{P'∈E_o} exp(−θ C_{P'}).
Processing order and ties. Nodes with equal r cannot be joined by an efficient
link (strict inequality), so any order among equal labels is correct; a stable argsort
on r (as in PathEngine) keeps runs bit-deterministic. Underflow of exp(x) for deep
detours silently rounds negligible path shares to zero — benign, since shortest-path
terms are exp(0)=1 and never underflow.
θ units. θ multiplies generalized-cost differences, so its unit is 1/(native cost
unit) and is network-specific (P9): Sioux Falls times are 0.01-hour units, so
θ = 1.0 there means 100 h⁻¹. Scenario cards must state the unit next to the value.
θ → ∞ limit. Shares concentrate on minimum-cost efficient paths, split evenly across exact ties (unlike AON’s arbitrary single-tree tie-breaking) — documented, and irrelevant off ties.
Decision 2 — The reference solver: MSA-SUE (sue-msa)¶
models/sue_logit.py::DialSUEModel, registered as sue-msa, mirrors msa.py:
v_1 = L(t(0), θ)(stochastic loading at free-flow costs); then fork = 1, 2, …:y_k = L(t(v_k), θ); checkpointv_kwith self-reportsue_fixed_point_residual = ‖y_k − v_k‖₁ / D; stepv_{k+1} = v_k + (y_k − v_k)/k(the repo’s MSA indexing; identical tov_{k+1} = v_k + (1/(k+1))(L(t(v_k)) − v_k)up to an index shift). Powell & Sheffi (1982) prove convergence for predetermined Blum step sizes (Σα_k = ∞,Σα_k² < ∞) given a continuous loading map; Dial’s cost-dependent efficient sets makeLonly piecewise continuous, so the guarantee is local (efficient set constant near the fixed point) — a documented caveat, not a blocker (Decision 3).θ is read from
scenario.sue_theta;NoneraisesValueError— θ is task data, not a model factor, so no solver may tune it (P7).Budget convention: one full Dial load = 1
sp_call(the same one-batched-sweep unit as oneall_or_nothingin FW/MSA; the Dijkstra sweep dominates both).Capabilities(paradigm="sue", deterministic=True, provides_gap=True, seedable=True)—"sue"is already inPARADIGMS;deterministic=Trueroutes it to M=1 in the runner.The self-reported residual at checkpoint
v_kis byte-comparable to the harness value (both are‖L(t(v_k),θ) − v_k‖₁/D), giving the FW-style honesty regression for free.
Why the Fisk objective is not reported: it needs path flows (entropy term) that link flows do not determine; under Dial the path set additionally moves with costs. Stated in the module docstring so nobody “fixes” this later.
Decision 3 — The certificate: SUE fixed-point residual (extends P1)¶
For a scenario with sue_theta = θ, the harness computes, from emitted flows v that
pass the existing demand-aware feasibility audit:
t = network.link_cost(v) # same costs as the UE certification
v̂ = L(t, θ) # ONE Dial load, deterministic
sue_fixed_point_residual = ‖v − v̂‖₁ / D # D = total demand
Norm and normalization. Both v (audited) and v̂ (constructed) route the same OD
totals, so v − v̂ is a pure redistribution of flow among routes; the L1 norm measures
the total redistributed volume in vehicle·link units. Dividing by total demand D
yields misallocated link-traversals per traveler — the same intensive, per-traveler
convention as AEC (= excess time per traveler), zero exactly at equilibrium, and
comparable across ladder rungs (Braess D=6 vs Chicago D≈10⁶). L2 has no flow
interpretation and is dominated by high-volume links; L∞ ignores diffuse misallocation;
normalizing by ‖v‖₁ instead of D would conflate residual with average trip length.
Discontinuity caveat. t ↦ L(t, θ) is discontinuous exactly where an efficient set
changes — a label tie r(i) = r(j), a measure-zero event in cost space but reachable at
symmetric networks. If a scenario’s fixed point sits on a tie, the residual need not
approach 0 and MSA can oscillate between the two branch maps. Within a fixed efficient
set, L is smooth and the residual is locally Lipschitz
(≈ ‖(I − ∇L·∇t)(v − v*)‖₁/D near v*). Consequences: the strict-inequality rule is
pinned (no tolerance parameter to disagree about); SUE scenario cards must verify —
analytically or empirically — that the fixed point is tie-free, recording exceptions in
the known-defects style (P9).
Expected magnitudes. At an exact analytic fixed point the residual is pure float
noise (cost evaluation + Dijkstra + logsumexp + brentq root tolerance): observed
< 1e-12 on the two-route scenario; tests assert < 1e-8. For MSA-after-k the harness
residual at checkpoint v_k is the MSA direction norm; measured on the two-route
network: 2.8e-3 (k=10), 6.2e-6 (k=100), 1.1e-7 (k=500); on real networks expect
markedly slower, roughly O(1/k)-like decay (Powell & Sheffi prove no rate).
Why this is P1-valid for black boxes. The residual is a function of
(link_flows, scenario, θ) only — no path flows, no iterates, no model internals — and
costs one deterministic Dial load per checkpoint (≈ one AON; certification cost for SUE
scenarios roughly doubles). Censoring precedes certification: flows failing the demand
audit get feasible = 0 and a NaN residual, so an all-zero emission is censored, never
scored. UE metrics (relative gap, AEC, Beckmann) remain descriptive columns on SUE
tasks — they are strictly positive at SUE by design — and the SUE task ranks by the
residual alone. Fit-vs-gap stays a pair, never a scalar (ARCHITECTURE §2).
Decision 4 — Where θ lives: Scenario.sue_theta, hash-append-when-set¶
@dataclass(frozen=True)
class Scenario:
...
reference: ReferenceSolution | None = None
family: str = field(default="")
sue_theta: float | None = None # NEW: logit dispersion, 1/(native cost unit)
__post_init__ gains: sue_theta must be None or finite and > 0.
content_hash() gains, after the existing array loop and before hexdigest():
if self.sue_theta is not None:
h.update(f"sue_theta={float(self.sue_theta)!r};".encode())
Because the update is conditional and appended last, the byte stream fed to SHA-256 when
sue_theta is None is identical to today’s — every existing hash (Braess
cf00f411cdccec88…, Sioux Falls pins in manifests, trained_on lineage tokens) is
preserved, enforced by a new golden-hash regression test. When θ is set the hash
changes, which P2 requires: θ parametrizes a scored metric, so two scenarios differing
only in θ are different benchmark instances and must never share a hash. !r on the
float matches the existing tw=…!r;dw=…!r convention (exact round-trip repr).
Alternatives rejected. Task object: the right home eventually, but no Task class
exists in code (tasks are YAML strings), and θ must be content-hashed today; the
Scenario field is forward-compatible — a v1 Task layer can construct/point to the hashed
scenario. Evaluator argument: violates P2 — two runs with different θ would share a
scenario_hash, a silently different problem masquerading as the same instance, and
manifests would not pin θ. Model factor: worst — models tuning the task definition
violates P7 outright.
Decision 5 — Analytic anchor: the two-route scenario¶
Network validation forbids parallel links, so the classic two-parallel-links example
is realized as two disjoint 2-link routes. data/builtin.py::two_route_scenario(demand=4.0, sue_theta=0.5): nodes 1 (origin zone), 2 (destination zone), 3, 4; first_thru_node=1;
links, in order, with linear costs via the existing bpr_linear device:
link |
cost |
role |
|---|---|---|
1→3 |
|
route A first leg |
3→2 |
|
route A, |
1→4 |
|
route B first leg |
4→2 |
|
route B, |
Since r(3) = r(4) = 1 < min(c_A, c_B) ∈ [1.5, ∞) at every nonnegative flow, both
routes are Dial-efficient at all costs encountered, so L reduces exactly to a binary
logit and the SUE fixed point to the scalar equation
f_A = D / (1 + exp(θ·(c_A(f_A) − c_B(D − f_A)))), solvable by brentq inside the test
(no hard-coded truth digits). Key verified numbers (θ=0.5, D=4): free-flow Dial load
(1.7512939965, …); fixed point f_A* = 2.2990959494; UE f_A = 2.5; θ-ladder
|f_A*(θ) − 2.5| = 0.2009, 0.0724, 0.0318, 0.0034 for θ = 0.5, 2, 5, 50; certificate
≈ 1.165 after shifting 0.5 units A→B. Full test list in the test-plan section below.
Decision 6 — Integration¶
The loader lives in src/tabench/models/_stoch.py as a StochEngine class
(mirroring _paths.py::PathEngine), because both the SUE model and the Evaluator need
it and metrics/gaps.py already imports ..models._paths.PathEngine — the precedent
direction is metrics → models → core, acyclic. _stoch.py imports only
core.scenario (+ scipy) and reuses PathEngine’s expansion. A top-level shared
routing/ package (moving _paths.py too) is the cleaner long-term layering but a
larger refactor; deferred, noted for v1.
Touch points: Evaluator gains the residual column when scenario.sue_theta is set;
runner CSV gains sue_fixed_point_residual and self_sue_residual;
models/sue_logit.py + registry export; scenarios/0tworoute-sue.yaml card with an
explicit sue: {theta, theta_units, loading} block; ARCHITECTURE metric-definitions
bullet + P5 wording refinement; README model/metric rows. Exact diffs in the
implementation spec accompanying this ADR.
Consequences¶
Anything emitting feasible link flows on a SUE scenario — a 1971 STOCH heir or a 2025 GNN — receives an externally certified SUE residual, in per-traveler units, from one deterministic Dial load. P1 extends to SUE with no new trust assumptions.
The efficient-set discontinuity is accepted (canonical fidelity over smoothness) and managed by scenario-card vetting rather than by a hidden tie tolerance.
Existing content hashes, manifests, and lineage tokens are untouched; a golden-hash test makes that a regression guarantee.
Evaluator cost on SUE scenarios roughly doubles per checkpoint (AON + Dial load); acceptable, with a shared-Dijkstra optimization available later.
References¶
Dial, R.B. (1971). A probabilistic multipath traffic assignment model which obviates path enumeration. Transportation Research 5(2), 83–111.
Sheffi, Y. (1985). Urban Transportation Networks, ch. 11 (STOCH), ch. 12 (MSA-SUE). Free PDF at sheffi.mit.edu.
Fisk, C. (1980). Some developments in equilibrium traffic assignment. Transportation Research Part B 14(3), 243–255.
Powell, W.B. & Sheffi, Y. (1982). The convergence of equilibrium algorithms with predetermined step sizes. Transportation Science 16(1), 45–55.
Daganzo, C.F. & Sheffi, Y. (1977). On stochastic models of traffic assignment. Transportation Science 11(3), 253–274.