evans — Combined trip distribution + assignment (Evans 1976)

What. Evans fuses trip distribution and assignment into one convex program: the OD matrix is endogenous, distributed by a doubly-constrained entropy/gravity model at the equilibrium travel costs, while the flows satisfy Wardrop UE. evans alternates an all-or-nothing assignment, an entropy distribution update, and a line search to the joint optimum ([evans1976derivation], docs/REFERENCES.md).

Why it is in the benchmark. It replaces the ad-hoc feedback loop between separate distribution and assignment steps with one convergent program (ADR-007). See the model compendium and docs/ARCHITECTURE.md (P1).

Scope. Runs on the built-in symmetric Evans anchor (2 origins × 2 destinations, β = 0.5) whose equilibrium collapses to a binary logit split, and certifies the gap.

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. `evans` 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,
    EvansCombinedModel,
    RngBundle,
    Trace,
    evans_symmetric_scenario,
    viz,
)

The scenario

Two origins (1, 2) and two destinations (3, 4), each producing/attracting 10 trips, one congestible link per OD pair. By symmetry the doubly-constrained gravity collapses to a binary logit split at dispersion β = 0.5 — a scalar fixed point. Content-hashed (P2).

scenario = evans_symmetric_scenario()
net = scenario.network

print(f"scenario      : {scenario.name}")
print(f"content hash  : {scenario.content_hash()[:16]}…")
print(f"zones         : {scenario.demand.n_zones}  (2 origins, 2 destinations)")
print(f"total trips   : {scenario.combined_demand.total}  (gravity dispersion beta = {scenario.combined_demand.beta})")
scenario      : evans
content hash  : 69a9ca0453081aee…
zones         : 4  (2 origins, 2 destinations)
total trips   : 20.0  (gravity dispersion beta = 0.5)

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 = EvansCombinedModel()
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 distribution gap: {final.self_report['distribution_gap']:.3e}  (provenance only)")
model              : evans
emitted flows      : [6.917388 3.082612 3.082612 6.917388]
self-reported gap  : 0.000e+00  (provenance only)
self distribution gap: 4.441e-16  (provenance only)

Certify (P1)

The harness certifies the relative gap of the joint program (assignment + distribution) from the emitted flows. We recompute the symmetric equilibrium split in-cell with brentq — the gravity collapses to p = trips / (1 + exp(β(c_near(p) c_far(q)))), c_near = 1 + 0.1 p, c_far = 3 + 0.1 q, q = trips p — rather than quoting it.

from scipy.optimize import brentq

evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows)
gap = metrics["relative_gap"]
print(f"certified relative gap : {gap:.3e}")
print(f"feasible               : {metrics['feasible']:.0f}")

assert metrics["feasible"] == 1.0
assert abs(gap) < 1e-10

# Honesty diff (P1).
assert np.isclose(final.self_report["relative_gap"], gap, rtol=1e-9, atol=1e-12)

# Analytic anchor RECOMPUTED: the symmetric logit split via brentq.
trips = 10.0
beta = scenario.combined_demand.beta

def _split(p):
    c_near = 1.0 + 0.1 * p
    c_far = 3.0 + 0.1 * (trips - p)
    return p - trips / (1.0 + np.exp(beta * (c_near - c_far)))

p = brentq(_split, 0.0, trips)
q = trips - p
ref_flows = np.array([p, q, q, p])  # link order 1->3, 1->4, 2->3, 2->4
print(f"logit split p, q       : {p:.4f}, {q:.4f}  (recomputed via brentq)")
assert np.allclose(final.link_flows, ref_flows, atol=1e-3)
certified relative gap : 0.000e+00
feasible               : 1
logit split p, q       : 6.9174, 3.0826  (recomputed via brentq)

Visualize

Both figures come from tabench.viz. Left/top: the Evans equilibrium link flows. Right/bottom: the emitted flows against the brentq-recomputed symmetric split — on-diagonal means the combined program reached the joint optimum.

display(viz.plot_network_flows(net, final.link_flows))
display(viz.plot_flow_scatter(("Evans equilibrium (brentq)", ref_flows), {"evans": final.link_flows}))
../../_images/f6a3363356313be2cd767767ad9073d378901c8168ad16f36ad465628ef27055.png ../../_images/dd97c2540baa53cb6b83797f206fc6afca1b7d39de425a3575ebb3bbb81198db.png

Takeaways & pointers

  • Certified, not self-reported. The joint gap came from Evaluator; the self-reports (assignment + distribution) were only diffed against it.

  • Endogenous demand. The OD matrix is an output, distributed by gravity at the equilibrium costs — the split recomputed here via brentq.

  • Where next. Fixed-demand UE: bfw; elastic (total) demand: fw-elastic; ADR-007 in the model compendium.