# ADR-007 — Combined trip distribution + assignment (Evans 1976): a P1-pure certificate **Status:** accepted (shipped in v1) **File:** `docs/design/adr-007-combined-distribution-assignment.md` ## Context Every fixed-demand task fixes the whole OD matrix; the elastic task (ADR-005) makes each pair's *total* demand a pointwise function of its own cost. **Combined trip distribution + assignment** (Evans 1976) goes one step further: only the trip-end margins are fixed — the productions `O_i` and attractions `D_j` — and the *entire* OD matrix `d_ij` is endogenous, distributed across pairs by a doubly-constrained **gravity** model at the equilibrium travel costs. Distribution and assignment, historically two separately-iterated models, become one convex program (Evans 1976; Sheffi, *Urban Transportation Networks* 1985 ch. 6; Boyles, Lownes & Unnikrishnan, *Transportation Network Analysis* §6): ``` min_{x,d} Σ_a ∫_0^{x_a} t_a(w) dw + (1/β) Σ_ij d_ij (ln d_ij − 1) s.t. Σ_j d_ij = O_i, Σ_i d_ij = D_j, d ≥ 0, x = assign(d) ``` Its stationarity conditions give **both** the doubly-constrained gravity `d_ij = A_i B_j exp(−β u_ij)` (`u_ij` = equilibrium OD cost, `A/B` = Furness balancing factors) **and** Wardrop route equilibrium of `d`. This is a genuinely new problem class, and — like elastic demand — it collides with the harness's fixed-OD assumptions. ADR-005 anticipated it ("Deferred: a combined distribution–assignment task (Evans 1976) that reuses this machinery"); this ADR is that reuse. ## Sourcing (dual-verified against open textbooks) The primary — **Evans (1976)**, *Derivation and analysis of some models for combining trip distribution and assignment*, Transportation Research 10(1), DOI `10.1016/0041-1647(76)90100-3` (canon key `evans1976derivation`, tier 1) — is paywalled; its abstract and the "Evans algorithm" convergence result are attributed unread. The **formulas implemented here** (the combined objective, the partial-linearization algorithm, the doubly-constrained gravity subproblem, the Furness/IPF balancing) were recovered from two primary-accessible textbooks read in full and cross-checked formula-for-formula: **Sheffi (1985) ch. 6** and **Boyles et al. TNA §6**. Wilson's entropy-maximising gravity model is the second intellectual parent (via the `(1/β) Σ d(ln d − 1)` term) but has no bibkey in the canon and is noted, not cited. Canon keys already present: `evans1976derivation`, `beckmann1956studies`, `florian1974method`, `sheffi1985urban`. ## Decision 1 — Represent the fixed margins as content-hashed scenario data `CombinedDemand(productions, attractions, beta)` (`core/scenario.py`) carries the fixed trip-end margins `O_i`, `D_j` and the single shared gravity dispersion `β`. It validates `Σ O = Σ D` (doubly-constrained feasibility) and `β > 0`, and exposes the reusable `gravity(od_cost)` — the doubly-constrained Furness / iterative-proportional-fitting recursion over the interzonal support (`i ≠ j`, `O_i > 0`, `D_j > 0`), made deterministic (fixed tolerance/iteration cap, exact-row final rescale) so the solver and the harness recompute byte-identical demand from the same costs. `Scenario.combined_demand` is optional and **hashed only when set**, appended after the SUE and elastic fields, so every prior scenario keeps its byte-identical hash — the golden Braess hash `cf00f411…` is asserted preserved (`test_golden_braess_hash_preserved`). Combined is mutually exclusive with the SUE fields and `elastic_demand` (each makes the OD demand non-fixed in an incompatible way). The ordinary `Demand.matrix` carries the **free-flow gravity** distribution — the deterministic uncongested-equilibrium OD matrix, a meaningful reference with the right margins and full support. ## Decision 2 — Solve with Evans' partial-linearization Frank-Wolfe `evans` (`EvansCombinedModel`, paradigm `static_ue_combined`) linearizes only the assignment (Beckmann) term and keeps the entropy term exact. At iterate `(x, d)` the subproblem ``` min_y Σ_ij u_ij y_ij + (1/β) Σ_ij y_ij(ln y_ij − 1) s.t. margins ``` has the closed-form solution `y = gravity(O, D, β, u)` (the doubly-constrained Furness balancing); its all-or-nothing assignment `w` gives the descent direction, and an **exact Brent line search** on the combined objective — root of `g(α) = t(x + α dx)·dx + (1/β) Σ ln(d + α dd)·dd`, nondecreasing on `[0,1]` — sets the step. `x` is kept a feasible assignment of `d` throughout (`x₀ = AON(gravity at free-flow)`, and every update advances `x` and `d` by the same step to convex combinations), so the route-equilibrium gap of `d` stays `≥ 0`. Only **real link flows** are emitted; the auxiliary gravity `y` is both the FW subproblem solution *and* the demand the harness recomputes. ## Decision 3 — A P1-pure certificate: recompute the demand, then score The harness knows `O, D, β` (content-hashed), so from the emitted link flows `v` it recomputes everything (`metrics/gaps.py`, gated on `scenario.combined_demand`), the elastic recipe with the gravity in place of `D(u)`: ``` t = link_cost(v) u = od_cost_matrix(t) # per-OD shortest-path cost over the gravity support d* = gravity(u) # doubly-constrained gravity demand at those costs relative_gap = (v·t − Σ u·d*) / (v·t) # route equilibrium given d* node_balance_residual = ‖ balance(v) − div(d*) ‖_∞ # demand-consistency + conservation realized_demand = Σ d* (= Σ O_i) # scored quantity ``` Both are pure functions of `(v, scenario)` — no self-report is trusted (P1) — and the model's self-reported `relative_gap` is *defined* to equal the scored gap, so the honesty diff is `~0`. The skim is driven by the gravity **support** (the fixed margins), not the reference matrix's nonzeros, so a reference entry that underflowed to zero can never desync the solver from the certificate. ### The feasibility gate — and an honest bound on soundness (adversarial-review finding) As for elastic (ADR-005), there is no given matrix, so through-node conservation alone is not a sufficient gate; the gate is `node_balance(v, d*)` (a phantom flow routing zero OD demand disagrees with `d* > 0` and is censored — regression-tested), plus the negative-excess guard (`SPTT > TSTT` censored). One property is **specific to combined demand** and must be stated plainly: the doubly-constrained gravity **always** reproduces the margins `O, D`, so `node_balance(v, d*)` only certifies that `v` routes the correct *margins* — it carries **no** information about the OD-pair *distribution* (unlike elastic, where `d* = D(u)` has cost-varying margins). The only distributional teeth is therefore the negative-excess guard. **This is NOT a full soundness guarantee, and we do not claim one.** An adversarial review constructed the exact counterexample: on a *cost-degenerate* instance where every margin-feasible flow induces the same uniform OD-cost skim `u`, all such flows satisfy `Σ u·d* = u·T = v·t`, so a wrong-distribution flow (e.g. "dump all trips on the cheapest links") certifies with `feasible = 1` and `relative_gap = 0` even though it is not the equilibrium. We verified this is **exactly the aggregate-vs-per-OD limitation the whole harness already documents** (single-commodity node balance is necessary, not sufficient, for multi-commodity feasibility): the **fixed-demand** certificate admits the *identical* false positive on the same network — it is inherited, not introduced by Evans. A fully sound link-flow-only certificate is impossible here, because distinct margin-feasible distributions that induce the same skim are indistinguishable at the link level; closing it needs per-OD / multi-commodity information the "real link flows only" contract withholds (a full multi-commodity feasibility check is future harness-wide work). We handle it three ways rather than hide it: 1. **The scored `relative_gap` remains a valid *necessary* condition** — a genuine combined equilibrium always passes — and stays P1-pure. 2. **The analytic anchor is deliberately made non-degenerate.** Its near/far link intercepts are spaced (1 vs 3) so `c_near(s) − c_far(T − s) = 0.2 s − 3 < 0` for every feasible split; the margin-feasible flows form a one-parameter family `v(s) = (s, T−s, T−s, s)` and `gap(s) ∝ (s − s*)·(c_near − c_far)` has a **unique** zero at the equilibrium `s* ≈ 6.92` (β = 0.5), every other `s` being censored (negative excess) or strictly gapped. So the anchor itself does **not** exhibit the limitation, and "dump on the cheapest links" is censored on it (regression-tested, `test_anchor_admits_only_the_true_equilibrium`). 3. **The limitation is pinned transparently** by `test_aggregate_multicommodity_limitation`, which constructs the degenerate instance, asserts the false positive, and asserts the fixed-demand certificate shares it. On any scenario carrying a reference (like the anchor) the `flow_rmse_vs_reference` column exposes such flows independently of the gap. ### Convergence and scope The solver early-stops on `max(route-gap, distribution-gap)`, both non-negative, so `target_relative_gap` never trips on the transiently-negative combined gap a naive stop would. The doubly-constrained gravity requires `Σ O = Σ D`; a disconnected instance raises during the shortest-path step and the certificate censors it. Per-OD dispersion parameters, a singly-constrained (production-constrained / destination-choice-logit) variant, and conjugate/bush acceleration of the inner assignment are future extensions. ## Consequences - **New:** `CombinedDemand`; `Scenario.combined_demand`; paradigm `static_ue_combined`; the `evans` model; the combined branch of `Evaluator` (scored `relative_gap`, `node_balance_residual`, `realized_demand`); `evans_symmetric_scenario` analytic anchor (symmetric bipartite → binary logit split, a scalar fixed point recomputed with brentq — flows `(p, q, q, p)`, `p ≈ 6.92` at `β = 0.5`, not trusted; intercepts spaced so the anchor is degeneracy-free); `tabench run --scenario evans`. - **Unchanged:** every prior scenario hash (golden Braess preserved); the fixed-demand / SO / SUE / elastic certificate paths; all prior models and tests (201 prior pass, +19 new = 220). - **Deferred:** per-OD dispersion; singly-constrained / logit destination-choice variant; conjugate/bush inner solver; large-network scaling of the `~zones²`-support gravity.