dtd-swap-sue — Smith & Watling’s (2016) route-swap SUE dynamics

What. The SUE sibling of dtd-swap: the same proportional route-swap, but driven by the Fisk-generalized cost C_k = c_k + (1/θ) ln h_k, so the rest point is the logit stochastic user equilibrium (Fisk 1980), not deterministic Wardrop UE.

Why it is in the benchmark. It is validated against the analytic logit-SUE split and self-reports the same Dial-STOCH certificate the harness recomputes; Fisk’s convex objective is its Lyapunov function and the generalized-cost disequilibrium vanishes at SUE. See the model compendium ([smith2016routeswapping], docs/REFERENCES.md) and the certificate design in docs/ARCHITECTURE.md (P1).

Scope. Runs the process on a built-in scenario and certifies the result; it does not benchmark day-to-day models against each other. Reference: Smith & Watling (2016), Transportation Research Part B 85.

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 model emitted, in the cell where it is claimed. Model self-reports (the per-day gap/residual, the Lyapunov value) are shown only as provenance and diffed against the certificate, exactly as the harness treats them (README, Certified, not self-reported).

# Setup. `dtd-swap-sue` is a core day-to-day model: a plain `pip install -e .` suffices —
# no optional extra, so no guard cell. The inline backend is Agg-based (headless CI
# renders into the notebook); NEVER matplotlib.use("Agg") in-kernel — it silently
# suppresses inline figure capture.
%matplotlib inline
import numpy as np

from tabench import (
    Budget,
    Evaluator,
    RngBundle,
    RouteSwapSUEModel,
    Trace,
    two_route_scenario,
    viz,
)

The scenario

The built-in two-route logit-SUE anchor (demand 4, dispersion θ=0.5): route A cost 2+f_A, route B cost 1.5+2 f_B. Its rest point is the binary-logit stochastic user equilibrium, recomputed analytically in the certify cell — NOT the deterministic Wardrop UE.

scenario = two_route_scenario()
net = scenario.network
print(f"scenario      : {scenario.name}")
print(f"content hash  : {scenario.content_hash()[:16]}…")
print(f"links         : {net.n_links}  (tail→head: "
      + ", ".join(f"{i}->{j}" for i, j in zip(net.init_node, net.term_node)) + ")")
print(f"total demand  : {scenario.demand.total}")
print("task          : logit SUE, θ=0.5")
scenario      : tworoute
content hash  : 9b98e58b339b702f…
links         : 4  (tail→head: 1->3, 3->2, 1->4, 4->2)
total demand  : 4.0
task          : logit SUE, θ=0.5

Run the adjustment process

The model contract (CONTRIBUTING.md): a model receives (scenario, budget, rng, trace) and records one checkpoint per day — here a budget iteration is a day. Everything the model writes into self_report (the per-day gap/residual, the Lyapunov value) is provenance, not a score.

bundle_trace = Trace()
model = RouteSwapSUEModel()
model.solve(scenario, Budget(iterations=500, target_relative_gap=1e-8),
            RngBundle(0), bundle_trace)
final = bundle_trace.final
print(f"model            : {model.name}")
print(f"days simulated   : {final.coords.iterations}  "
      f"({final.coords.sp_calls} shortest-path calls)")
print(f"emitted flows    : {np.round(final.link_flows, 6)}")
print(f"self-reported residual: {final.self_report['sue_fixed_point_residual']:.3e}  (provenance only)")
model            : dtd-swap-sue
days simulated   : 45  (90 shortest-path calls)
emitted flows    : [2.299096 2.299096 1.700904 1.700904]
self-reported residual: 9.861e-09  (provenance only)

Certify (P1) — the SUE fixed point AND the descent

The harness recomputes the ADR-001 Dial-STOCH residual from the emitted flows. Certified here: (1) the terminal flows are the logit SUE — residual → 0 with the analytic binary- logit anchor recomputed in-cell, while the deterministic UE gap stays positive (a descriptive column, like sue-msa); (2) the model’s Lyapunov / provenance signature.

from scipy.optimize import brentq
evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows)
residual = metrics["sue_fixed_point_residual"]
print(f"certified SUE residual : {residual:.3e}")
print(f"feasible               : {metrics['feasible']:.0f}")
print(f"UE relative gap        : {metrics['relative_gap']:.3f}  (stays >0 — logit SUE ≠ deterministic UE)")
assert metrics["feasible"] == 1.0
assert residual < 1e-5
assert metrics["relative_gap"] > 0.01  # descriptive column: not the deterministic UE

# Analytic binary-logit SUE anchor, recomputed in-cell (θ=0.5, demand 4): the root of
# f_A = D / (1 + exp(θ(c_A − c_B))), c_A = 2 + f_A, c_B = 1.5 + 2(4 − f_A).
def _resid(f_a):
    c_a, c_b = 2.0 + f_a, 1.5 + 2.0 * (4.0 - f_a)
    return f_a - 4.0 / (1.0 + np.exp(0.5 * (c_a - c_b)))
f_a = brentq(_resid, 0.0, 4.0, xtol=1e-12)
ref_flows = np.array([f_a, f_a, 4.0 - f_a, 4.0 - f_a])
print(f"analytic logit SUE f_A : {f_a:.6f}")
assert np.allclose(final.link_flows, ref_flows, atol=1e-3)
# Honesty (P1): the model self-reports the SAME residual the harness recomputes.
assert np.isclose(
    final.self_report["sue_fixed_point_residual"], residual, rtol=1e-9, atol=1e-12
)
# Fisk's convex objective is the Lyapunov function (monotone ↓ to the SUE minimum); the
# generalized-cost disequilibrium V(h) vanishes at the logit SUE. UNLIKE Beckmann
# (dtd-swap/-link/-friesz), the certified Evaluator has no Fisk-objective counterpart
# to diff against -- Fisk's entropy term needs per-ROUTE flows h, which the link-flow-
# only certificate does not carry -- so both series below are PROVENANCE ONLY, never
# scored (the terminal SUE fixed point IS certified above, independently, via the
# residual + the recomputed analytic anchor).
fisk = [s.self_report["fisk_objective"] for s in bundle_trace]
diseq = [s.self_report["sue_disequilibrium"] for s in bundle_trace]
assert all(
    fisk[i] >= fisk[i + 1] - 1e-9 * max(abs(fisk[i]), 1.0) for i in range(len(fisk) - 1)
)
assert max(diseq) > 1.0 and diseq[-1] < 1e-6
print(f"Fisk descent (provenance only)       : {fisk[0]:.3f}{fisk[-1]:.3f}  (monotone ✓)")
print(f"SUE disequilibrium (provenance only) : {max(diseq):.3g}{diseq[-1]:.2e}  (→ 0 at SUE)")
certified SUE residual : 9.861e-09
feasible               : 1
UE relative gap        : 0.056  (stays >0 — logit SUE ≠ deterministic UE)
analytic logit SUE f_A : 2.299096
Fisk descent (provenance only)       : 25.090 → 10.321  (monotone ✓)
SUE disequilibrium (provenance only) : 55.3 → 6.92e-16  (→ 0 at SUE)

Visualize

Both figures come from tabench.viz, the house visualizer — every plotted number is one certified above. Left/top: the certified terminal link flows on the network. Right/bottom: the emitted flows against the fixed point recomputed in the certify cell — points on the y = x guide mean the day-to-day process settled on it link-for-link.

# Certified terminal flows on the network (house style via tabench.viz).
display(viz.plot_network_flows(net, final.link_flows))

# Emitted flows vs the logit SUE recomputed above (off-diagonal == not settled).
display(viz.plot_flow_scatter(("logit SUE", ref_flows), {"dtd-swap-sue": final.link_flows}))
../../_images/d64cf0df4bac49cea7dbdc9c7c9600e032a2d40315ab99afee8bd12db9d0b9e9.png ../../_images/18d1ef78fbe27ac04eabd00fd8bb71eb4856cbb743650ff9daf9abb147c89a74.png

Takeaways & pointers

  • Certified, not self-reported. The SUE residual came from Evaluator; the model’s self-reported residual matched it to float precision (both call the same pinned Dial-STOCH load).

  • Scoped, not universal. On dense/strongly-congested multi-OD networks this can settle on an EXACT rest point (disequilibrium ~ 0) whose certified residual is O(1): never-pruned stale routes retain a logit share Dial doesn’t assign, and the efficient-route enumeration is capped at max_routes=4096 (a RuntimeWarning, never silent, if a dense OD’s efficient set exceeds it) — “like sue-msa” does NOT hold there; use sue-msa for a tight residual on such instances (both plateaus are regression-pinned, see docs/MODELS.md).

  • The day-to-day signature is the point. A UE/SUE solver gives you the fixed point; a day-to-day model gives you the adjustment path to it.

  • Where next. the deterministic route-swap dtd-swap; the cost-smoothing SUE process dtd-horowitz; the lineage in the model compendium.