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:

  1. 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.

  2. 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.

  3. 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).

  1. Labels. Dijkstra from o’s tail index: r(i) for every expanded node.

  2. 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).

  3. Link likelihood (Sheffi eq. 11.7-style): L_a = exp(x_a) with x_a = θ·(r(j) r(i) t_a) for a E_o, else L_a = 0. By shortest-path optimality r(j) r(i) + t_a, so x_a 0 and L_a 1: likelihoods can never overflow, and x_a = 0 exactly on shortest-path links.

  4. Forward pass (node weights, log domain), ascending r. Sheffi’s recursion is W(o)=1, W(j) = Σ_{(i→j)∈E_o} L_ij · W(i). W(j) sums exp(θ(r(j) C_P)) over efficient paths P: o→j (telescoping product), and the number of efficient paths can be exponential — W can overflow float64 on large networks at small θ. We therefore compute b(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 with x=0, so W(j) 1, b(j) 0 — no zero-weight node, no 0/0 in the backward pass, ever (guaranteed by the Network validation free_flow_time > 0).

  5. Backward pass (assignment), descending r. Initialize node volume V(head_index(d)) = q_od for each destination d o with q_od > 0 (intrazonal demand skipped; unreachable positive-demand destination raises RuntimeError, mirroring PathEngine.all_or_nothing). Sweep nodes in descending r; at node j with V(j) > 0, split over incoming efficient links with fractions φ_ij = exp(x_ij + b(i) b(j)) — each exponent ≤ 0 by definition of b(j), so φ [0,1] with Σφ = 1 in exact arithmetic; renormalize by the float sum so link flows conserve demand to machine precision — then flow_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 for k = 1, 2, : y_k = L(t(v_k), θ); checkpoint v_k with self-report sue_fixed_point_residual = ‖y_k v_k‖₁ / D; step v_{k+1} = v_k + (y_k v_k)/k (the repo’s MSA indexing; identical to v_{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 make L only 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; None raises ValueError — θ 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 one all_or_nothing in FW/MSA; the Dijkstra sweep dominates both).

  • Capabilities(paradigm="sue", deterministic=True, provides_gap=True, seedable=True)"sue" is already in PARADIGMS; deterministic=True routes it to M=1 in the runner.

  • The self-reported residual at checkpoint v_k is 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 (constructed) route the same OD totals, so 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

t = 1 (constant)

route A first leg

3→2

t = 1 + f

route A, c_A(f_A) = 2 + f_A

1→4

t = 1 (constant)

route B first leg

4→2

t = 0.5 + 2f

route B, c_B(f_B) = 1.5 + 2 f_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.