fw-elastic — Elastic-demand user equilibrium (Florian & Nguyen 1974)¶
What. Elastic demand makes the trip table respond to congestion: the realized
demand of an OD pair is d_rs = D_rs(u_rs), a decreasing function of that pair’s
equilibrium cost. fw-elastic solves it by the classic excess-demand (Gartner)
transformation — a dummy arc absorbs unmet demand — then runs Frank–Wolfe on the
augmented network ([florian1974method],
docs/REFERENCES.md).
Why it is in the benchmark. It is the variable-demand branch off fixed-demand UE (ADR-005): the scored quantity gains a realized demand alongside the gap. See the model compendium and docs/ARCHITECTURE.md (P1).
Scope. Runs on the built-in elastic two-route anchor (reference demand d0 = 10, linear law, u0 = 10) and certifies both the gap and the realized demand.
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. `fw-elastic` 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,
ElasticDemandFWModel,
Evaluator,
RngBundle,
Trace,
elastic_two_route_scenario,
viz,
)
The scenario¶
Two disjoint 2-link routes with linear elastic demand: reference demand d0 = 10
(demand at zero cost), law D(u) = d0 · max(0, 1 − u/u0) with u0 = 10. The analytic
elastic UE is rational: u = 5, f_A = 3, f_B = 2, realized demand 5. Content-hashed (P2).
scenario = elastic_two_route_scenario()
net = scenario.network
print(f"scenario : {scenario.name}")
print(f"content hash : {scenario.content_hash()[:16]}…")
print(f"reference demand: {scenario.demand.total} (d0 = D(0), an upper bound)")
print(f"elastic law : {scenario.elastic_demand.form} (param u0 = {scenario.elastic_demand.param})")
scenario : elastic-tworoute
content hash : 943422eda3fee142…
reference demand: 10.0 (d0 = D(0), an upper bound)
elastic law : linear (param u0 = 10.0)
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 = ElasticDemandFWModel()
bundle = model.solve(scenario, Budget(iterations=200), RngBundle(0), Trace())
final = bundle.final
print(f"model : {model.name}")
print(f"emitted flows : {np.round(final.link_flows, 6)}")
print(f"self-reported gap : {final.self_report['relative_gap']:.3e} (provenance only)")
print(f"self realized demand: {final.self_report['realized_demand']:.4f} (provenance only)")
model : fw-elastic
emitted flows : [3. 3. 2. 2.]
self-reported gap : 1.421e-16 (provenance only)
self realized demand: 5.0000 (provenance only)
Certify (P1)¶
Two scored quantities (ADR-005): the relative gap on the excess-demand-augmented problem, and the realized demand — recomputed by the harness as Σ D_rs(u_rs) from the emitted flows, never taken from the model. We recompute the analytic anchor (f_A = 3, f_B = 2, realized demand 5) in-cell.
evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows)
gap = metrics["relative_gap"]
realized = metrics["realized_demand"]
print(f"certified relative gap : {gap:.3e}")
print(f"certified realized dem : {realized:.6f}")
print(f"feasible : {metrics['feasible']:.0f}")
assert metrics["feasible"] == 1.0
assert abs(gap) < 1e-10
# Honesty diff (P1): both self-reports must match the certificate.
assert np.isclose(final.self_report["relative_gap"], gap, rtol=1e-9, atol=1e-12)
assert np.isclose(final.self_report["realized_demand"], realized, rtol=1e-9, atol=1e-9)
# Analytic anchor RECOMPUTED: elastic UE f_A=3, f_B=2, realized demand 5 of d0=10.
ref_flows = np.array([3.0, 3.0, 2.0, 2.0])
assert np.allclose(final.link_flows, ref_flows, atol=1e-4)
assert abs(realized - 5.0) < 1e-6
print(f"realized / reference : {realized:.3f} / {scenario.demand.total:.0f} (half the demand priced out by congestion)")
certified relative gap : 1.421e-16
certified realized dem : 5.000000
feasible : 1
realized / reference : 5.000 / 10 (half the demand priced out by congestion)
Visualize¶
Both figures come from tabench.viz. Left/top: the elastic-UE link flows. Right/bottom:
the emitted flows against the analytic elastic UE — on-diagonal means the solver
recovered f_A = 3, f_B = 2, and (certified separately) the realized demand 5.
display(viz.plot_network_flows(net, final.link_flows))
display(viz.plot_flow_scatter(("elastic UE (analytic)", ref_flows), {"fw-elastic": final.link_flows}))
Takeaways & pointers¶
Two certified numbers. The gap AND the realized demand are recomputed by the harness; the self-reports were only diffed against them.
Demand responds. Half the reference demand is priced out by congestion — the variable-demand departure from fixed-demand UE.
Where next. Fixed-demand UE:
bfw; the combined distribution+assignment step:evans; ADR-005 in the model compendium.