sc-tap — Side-constrained (capacitated) user equilibrium (Larsson & Patriksson 1995)

What. Side-constrained UE adds a hard per-link capacity v_a u_a on top of the BPR latency. At the equilibrium the KKT multiplier of a binding constraint acts as a queueing delay, so travellers equalize the capacity-augmented cost t_a + β_a, not the raw cost. sc-tap solves the augmented Wardrop problem ([larsson1995augmented], docs/REFERENCES.md).

Why it is in the benchmark. It is the capacitated branch of UE (ADR-009): a binding capacity makes the raw gap non-zero while the augmented equilibrium holds. See the model compendium and docs/ARCHITECTURE.md (P1).

Scope. Runs on the built-in side-constrained anchor (demand 10, capacity 4 on link 3→2) and certifies capacity feasibility and the binding-constraint anchor.

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 are shown only as provenance and diffed against the certificate as an honesty check, exactly as the harness treats them (README, Certified, not self-reported).

# Setup. `sc-tap` is a core 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,
    SideConstrainedModel,
    Trace,
    sc_two_route_scenario,
    viz,
)

The scenario

Two disjoint 2-link routes, demand 10, a hard capacity of 4 on the route-A link 3→2. Plain UE would put f_A = 5.5 there, so the capacity binds: f_A = 4, f_B = 6, with a queueing multiplier β = 1 + D − 2·cap = 3. Content-hashed (P2).

scenario = sc_two_route_scenario()
net = scenario.network

print(f"scenario      : {scenario.name}")
print(f"content hash  : {scenario.content_hash()[:16]}…")
print(f"total demand  : {scenario.demand.total}")
print(f"side capacities: {scenario.side_capacities}  (link 3->2 capped at 4)")
scenario      : sc-tworoute
content hash  : 0dc8ae19da506625…
total demand  : 10.0
side capacities: [1.e+06 4.e+00 1.e+06 1.e+06]  (link 3->2 capped at 4)

Solve

The model contract (CONTRIBUTING.md): a model receives (scenario, budget, rng, trace), records checkpoints, and respects the budget. Budgets are hardware-free (iterations / shortest-path calls; wall-clock is recorded but never the ranking axis, P7). Whatever the model writes into self_report is provenance, not a score.

model = SideConstrainedModel()
bundle = model.solve(scenario, Budget(iterations=100), RngBundle(0), Trace())

final = bundle.final
print(f"model                 : {model.name}")
print(f"emitted flows         : {np.round(final.link_flows, 6)}")
print(f"self Wardrop gap       : {final.self_report['relative_gap']:.3e}  (provenance only)")
print(f"self augmented gap     : {final.self_report['augmented_relative_gap']:.3e}  (provenance only)")
print(f"self max multiplier    : {final.self_report['max_multiplier']:.4f}  (queue delay, provenance)")
model                 : sc-tap
emitted flows         : [4. 4. 6. 6.]
self Wardrop gap       : 2.647e-01  (provenance only)
self augmented gap     : 5.099e-11  (provenance only)
self max multiplier    : 3.0000  (queue delay, provenance)

Certify (P1)

The scored certificate is capacity feasibility: the harness recomputes the maximum capacity violation and confirms sc_capacity_feasible = 1. The ordinary Wardrop gap is non-zero — the binding capacity creates a queueing delay, so raw costs are unequal — and the model equalizes the augmented cost instead (its augmented_relative_gap 0 is provenance). We recompute the binding anchor (f_A = 4, f_B = 6) in-cell.

evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows)
gap = metrics["relative_gap"]
print(f"Wardrop relative gap    : {gap:.3e}   (non-zero: binding capacity -> queue delay)")
print(f"max capacity violation  : {metrics['max_capacity_violation']:.2e}")
print(f"capacity feasible       : {metrics['sc_capacity_feasible']:.0f}")
print(f"feasible                : {metrics['feasible']:.0f}")

assert metrics["feasible"] == 1.0
assert metrics["sc_capacity_feasible"] == 1.0
assert metrics["max_capacity_violation"] < 1e-6

# Honesty diff (P1): the (non-zero) raw Wardrop gap self-report matches the certificate.
assert np.isclose(final.self_report["relative_gap"], gap, rtol=1e-9, atol=1e-9)

# Analytic anchor RECOMPUTED: capacity 4 binds -> f_A = cap, f_B = D - cap.
demand = scenario.demand.total
cap = float(scenario.side_capacities[1])  # the capacity on link 3->2
ref_flows = np.array([cap, cap, demand - cap, demand - cap])
print(f"binding anchor f_A, f_B : {cap}, {demand - cap}  (recomputed)")
assert np.allclose(final.link_flows, ref_flows, atol=1e-4)

# The two counterfactuals named in the Scope markdown, recomputed (not quoted).
# Plain (uncapacitated) UE equalizes 1+f_A = 2+f_B with f_A+f_B=D -> f_A*=(D+1)/2.
plain_ue_f_a = 0.5 * (demand + 1.0)
print(f"plain-UE counterfactual f_A : {plain_ue_f_a}  (capacity {cap} binds below it)")
assert np.isclose(plain_ue_f_a, 5.5)
assert cap < plain_ue_f_a  # confirms the capacity actually binds

# The queueing multiplier beta = c_B(D-cap) - c_A(cap) = (2+D-cap) - (1+cap) = 1+D-2*cap,
# checked against the model's own self-reported max_multiplier (provenance).
beta = 1.0 + demand - 2.0 * cap
print(f"queueing multiplier beta    : {beta}  (self-report: "
      f"{final.self_report['max_multiplier']:.4f})")
assert np.isclose(beta, 3.0)
assert np.isclose(final.self_report["max_multiplier"], beta, atol=1e-4)
Wardrop relative gap    : 2.647e-01   (non-zero: binding capacity -> queue delay)
max capacity violation  : 6.37e-11
capacity feasible       : 1
feasible                : 1
binding anchor f_A, f_B : 4.0, 6.0  (recomputed)
plain-UE counterfactual f_A : 5.5  (capacity 4.0 binds below it)
queueing multiplier beta    : 3.0  (self-report: 3.0000)

Visualize

Both figures come from tabench.viz. Left/top: the side-constrained link flows — the capped link 3→2 carries exactly its capacity 4. Right/bottom: the emitted flows against the recomputed binding anchor — on-diagonal at (4, 4, 6, 6).

display(viz.plot_network_flows(net, final.link_flows))
display(viz.plot_flow_scatter(("SC-UE binding anchor (analytic)", ref_flows), {"sc-tap": final.link_flows}))
../../_images/3dff2f008aa2597aa60e456da8eb2265d30314e4f9764f9d4d4dc950579155bf.png ../../_images/3d79b9c01b3b466659ac8be67e37eac8a292fc8c98e230c74b5125686b66f5a9.png

Takeaways & pointers

  • Capacity is the certificate. sc_capacity_feasible = 1, zero violation; the raw Wardrop gap is non-zero because the queue delay makes raw costs unequal (reported).

  • Augmented equilibrium. The flow equalizes t_a + β_a, the model’s provenance augmented_relative_gap 0.

  • Where next. Uncapacitated UE: bfw; ADR-009 in the model compendium.