Gate model in practice

Note

This page shows the gate cost model at work on the assets, with its evidence and known limitations. For the routing model’s definition, parameters and defaults, see Models › Dynamic route rerouting.

Part of pyFDS-Evac. Reference for routing.cost_model. Provenance, review findings and the open questions live in gate-model-review-notes.md; this page documents what the shipped code does.

Route choice runs under one of two cost models, selected per deck with routing.cost_model in the scenario JSON:

valuewhat decides the exit
"gate" (default)Route optical depth decides which exits are available and orders the survivors; travel time breaks ties.
"additive"Smoke is a toll per metre walked, folded into one composite cost.

Under the gate one quantity does the whole of the smoke reasoning: the route’s optical depth tau = K_ave * L, the soot column the agent walks through. It refuses a route, it orders the routes it does not refuse, and it weights every Dijkstra edge — see How the gate decides.

Both are implemented in pyfds_evac/core/route_graph.py (RouteCostConfig, evaluate_route, rank_routes, evaluate_and_reroute).

Quick start

Nothing to configure: the gate is the default. To pin the pre-gate behaviour of an existing deck, set both keys — anticipate is independent of cost_model and defaults to true:

{
  "routing": {
    "cost_model": "additive",
    "anticipate": false
  }
}

To make the gate stricter or laxer, change tau_max — the optical depth a route may carry before it is refused:

{
  "routing": {
    "cost_model": "gate",
    "tau_max": 6.0,
    "tau_return_margin": 0.8,
    "current_exit_discount": 0.9,
    "tau_deadband": 0.1
  }
}

No vismap precondition. The gate reads no visibility model: its criterion is the optical depth along the route polyline. A deck of fully familiar agents therefore builds no visibility model and needs no --vis-cache: l_corridor takes 5 seconds and reproduces the cached run’s 82/18. A visibility model is still built for decks with discovery agents, which consult it to learn the graph.

How the gate decides

This page describes 081380b.

Per agent, per reevaluation tick:

  1. Path per exit. Dijkstra runs with each edge weighted by its own optical depth, k_avg * length (plus a 1e-6 * length floor, below).
  2. Availability. Each candidate route is tested twice. Optical depth: it is refused when tau = K_ave * L_eff exceeds tau_max (default 6), or tau_max * tau_return_margin if it is not the exit the agent already walks to. Dose: it is refused when its projected FED exceeds fed_rejection_threshold. Either refusal alone removes the route.
  3. Ordering among survivors. Optical depth first, travel time second. The sort key is (rejected, tier, tau, rank_cost, hops) with rank_cost = travel_time_s + w_queue * queue_time_s. The tau in the key is scaled by current_exit_discount (0.9) for the exit the agent already heads for. tier is the clean-exit tier, off by default.
  4. Adoption. The ordering proposes; the exit-switch anchor disposes. evaluate_and_reroute walks the ranked list and takes the first candidate _anchor_allows admits, stopping at the agent’s own exit — so the head of the sort is not necessarily the exit the agent walks to. See Churn protection for the rule.

Three separate tau comparisons happen per tick, and it is worth keeping them apart: tau_return_margin decides which routes are candidates, current_exit_discount decides their order, and tau_deadband decides whether the agent acts on that order. Only the third can change an exit.

One quantity refuses a route, orders it, and weights its edges. Before this design, Dijkstra minimised the additive composite, the survivors were ordered by travel time, and the gate judged them on optical depth — three currencies, and a gate could refuse an exit on a smoky path while a longer passable path to the same exit existed and was never offered.

Optical depth can order routes where a visibility band could not, because tau = K_ave * L already contains the distance. Two routes through equally thin haze order by length; in clear air every tau is zero and travel time decides alone; a cleaner route wins only by carrying enough less smoke to pay for its extra metres. A band compared cleanliness with no reference to how far the agent had to carry it, which is why it had to be kept out of the main sort: on l_corridor it put agents on the 58 m route while both routes read k_ave = 0.000. The band has been removed.

The 1e-6 * length floor on the edge weight is not a tuning constant. In clear air every k_avg is zero, so without it every path ties at weight zero and Dijkstra returns an arbitrary one. The floor makes the tie break on length. It is not configurable and has not been measured for its effect at small nonzero K.

In clear air K = 0, every tau is zero, nothing is refused, and the gate reduces to fastest-exit — which in clear air is nearest-exit. The two models were measured identical on the world_100 (7712 route-cost rows) and t_junction (4030 rows) clear-air runs, and the l_corridor clear-air pair evacuates 100/0 to the near exit under both with zero switches. Those runs predate 0d9bf79 entirely, and the ordering has changed since. Equivalence has not been re-measured under tau ordering, at K = 0 or at K = 1e-4.

Optical depth: what it measures, and what it does not

tau = K_ave * L_eff

K_ave is the length-weighted mean extinction over the route’s own polyline; L_eff is the distance still to walk. The product is the Beer-Lambert integral of extinction along the walked path — the soot column the agent passes through. It is an exposure statement.

It is not a sighting distance. The criterion grew out of one (see Where the 6 comes from below), but Jin’s visibility law (Visibility through smoke) describes a straight, unobstructed line to a sign. Integrating K around two corners measures how much smoke you walk through, not how far you can see. The two coincide only on a straight corridor. The names changed at 0d9bf79 to say what the quantity is.

The estimator averages K; it does not take the route’s worst point. A maximum over sampled cells is a step function of where the agent stands: one dense cell entering the sample swings the estimate by an order of magnitude between ticks, and measured on world100 the same 28.9 m route reported 91 m of sight, then 8 m, then 91 m again on consecutive seconds, with the ordering flipping each time. k_max_route is still computed and reported, and still decides the all-refused fallback’s switch margin, where the question is which walk is survivable rather than which is cleanest.

The line of sight is a diagnostic, not a gate. fdsvismap’s obstruction-aware sight line to an exit’s own sign is the more faithful measurement of what an occupant can see, but it is defined only where a sign resolves — so selecting the criterion per exit let sign geometry decide which exits were tested at all. Measured before b16e900: on l_corridor the far exit lies around two corners, never resolved a sight line, was therefore never tested, and the diversion the gate exists to produce vanished (84/16 became 100/0). On world100 one exit was tested by sight line 43 times and fell back 2095 times, so moving a sign two metres would have changed which exits were gated. Mixing the two was worse still: the same 22 m route read 9.9 m on one tick and 68.3 m on the next as the sight line resolved. Not seeing a sign is a fact about wayfinding, not about whether a route can be walked, so it belongs in the cognitive map (cognitive_map.expand_from_visibility), not in this gate.

The rejection reason names the quantity and both factors of it:

tau 8.41 > 6.00 (K_ave 0.145 x 58.0 m)
tau 5.20 > 4.80 (K_ave 0.388 x 13.4 m)

The second is a rival exit, held to tau_max * tau_return_margin = 4.8.

Where the 6 comes from. FDS+Evac’s tier-4 door test computes L2_tmp = d * 0.5 / (3.0 / K_ave_Door) and strikes the door out when L2_tmp >= 1.0 (evac.f90:16794, :16799). That expression is K_ave * d / 6, so the test is exactly tau > 6 with Jin’s c = 3. The threshold is therefore citable, but the quantity it is applied to is not the same quantity: FDS+Evac’s K_ave_Door is a mean along See_door’s straight sight line, and for a door with no resolved sight line the distance is an L1 norm (evac.f90:16796); pyFDS-Evac averages K along the walked polyline. Two further scope limits: the test lives in the tier-4 last-resort branch, reached only once no smoke-free door is available and looping only over doors that are already known or visible; and the strike-out there (Is_Visible_Door(i) = .FALSE., :16800-16801) lasts one call of Change_Target_Door, whose door arrays are reset at :16170-16171. FDS+Evac’s only lasting smoke memory is a weak mark in a lone agent’s known-door list on its previous target once K_ave >= 0.3 /m there (:16628-16637), and only if that door already has an entry in the list filled at initialisation (the loop rewrites entries but never adds one). A “some smoke” mark forces the door unknown only in the periodic re-evaluation; a “too much smoke” mark only drops the door from the list, and it can become known again (see model-comparison.md). pyFDS-Evac deliberately keeps no such memory. So: the gate is inspired by FDS+Evac’s tier-4 visibility door rule and inherits its threshold with a citation; it does not implement it. tau_max has not been calibrated against a soot-dose or FED-equivalent limit. That is open work.

Under FDS+Evac’s primary rule the criterion is different again — minimise time among doors satisfying K_ave_Door < ABS(FED_DOOR_CRIT) = 0.03 /m (evac.f90:16601, :16608; FED_DOOR_CRIT = -100 becomes 3.0/100 at :5496). pyFDS-Evac ships that absolute criterion as the opt-in clean-exit tier below. See model-comparison.md.

The diversion is a departure from FDS+Evac, not a reproduction of it

The threshold is borrowed; the place the quantity is used is not, and that difference is what produces the diversion the model exists for.

In FDS+Evac’s first three tiers the rank is T_tmp — a time in tier 1 when FAC_DOOR_QUEUE is active, a plain L2 or L1 distance norm otherwise — and K_ave_Door enters only as the boolean admission test L2_tmp < ABS(FED_DOOR_CRIT):

IF (T_tmp < L2_min .AND. L2_tmp < ABS(FED_DOOR_CRIT)) THEN   ! :16601, :16690, :16737
   L2_min = MAX(0.0_EB, T_tmp)
   i_tmp  = i
END IF

Smoke can move a door between tiers, or out of the admitted set; it cannot reorder the doors inside a tier, and geometry does not reorder itself. Here tau is the ordering, at every tick and for every candidate.

One qualification, and it matters. Smoke is not absent from FDS+Evac’s ordering everywhere: the tier-4 last-resort branch minimises L2_tmp directly (IF (L2_tmp < L2_min), :16803), and under the default FED_DOOR_CRIT < 0 that L2_tmp is tau/6. So the reference does rank on smoke — but only after tiers 1-3 have all failed to find any admitted door, only over doors already known or visible, on a bee-line (or L1) distance to the door rather than a walked route, and with a strike-out there that lasts one call (:16799-16801; reset at :16170-16171). Stated exactly: pyFDS-Evac promotes FDS+Evac’s last-resort ranking criterion to its primary one, and drops the one lasting smoke memory the reference has, a weak mark on a lone agent’s previous target once K_ave >= 0.3 /m (:16628-16637). “Smoke never enters FDS+Evac’s ordering” is too strong and should not be written; “smoke never enters the ordering until every smoke-free tier is exhausted” is what the source supports.

The measured consequence, and what it is not. On l_corridor the model diverts 18 of 100 agents to the longer, cleaner route. The reference criterion would not: tiers 1-3 rank on distance, both doors clear the K_ave < 0.03 /m admission test until the smoke is well developed, and the near exit is nearer throughout — so essentially every occupant goes near, roughly 100/0. That 100/0 is a reasoned prediction from the source, not a measured FDS+Evac run. No run of the reference criterion on this deck exists here. The nearest empirical proxy is the deck’s own additive model, which also ranks on a distance-like composite and does evacuate 100/0 (see Evidence) — a proxy, not the same criterion. The diversion stands or falls on its own merits, not on fidelity to the reference.

A geometric bias rides along with the change of quantity. FDS+Evac applies its threshold to a straight-line d; pyFDS-Evac applies it to the walked polyline L, and L/d is not the same on every route. On l_corridor from the spawn centroid (11.5, 15.0). Both legs are computed corner-to-corner along the corridor centreline, which is why they come out a metre under the 26 m / 46 m node-to-node figures the Evidence section quotes — those run spawn-polygon to exit-polygon through the graph’s own nodes:

routewalked Lbee-line dL/d
near (exit A, (0, 1.5))25.0 m17.7 m1.410
far (exit B, (45, 26.5))45.0 m35.4 m1.271

At equal K_ave the polyline form is therefore about 11 % (1.410 / 1.271) stricter on the near route than the far one, purely from geometry — a systematic tilt toward the diversion that no smoke measurement put there. The tilt is against the L2 bee line specifically; against the L1 norm FDS+Evac uses for non-visible doors (:16796) it vanishes on this deck, because both corridors are axis-aligned and the L1 distance equals the polyline exactly. The sign and size of the bias on other geometries have not been measured.

Route choice is an optimality bound, not a perception-limited model

Issue #125. Two assumptions apply to the same agent on the same tick and point in opposite directions:

  • The cognitive map assumes the agent does not know the building. A familiarity = 0 agent routes only over cognitive_subgraph and does not know an exit exists until it reads a sign — through VisibilityModel, which is genuinely perception-limited (obstruction-aware sight lines, sign facing, extinction along the line).
  • Route choice assumes the agent knows the smoke field. To choose among the exits it does know, it integrates tau = K_ave * L_remaining over the whole remaining route, including legs it has never visited and corners it cannot see past, and with anticipate = True and foresight_horizon_s = inf at times that have not happened yet. The extinction sampler is global; the cognitive map never touches it.

These are different kinds of knowledge — topology versus state — so it is not a formal contradiction. It is still not a coherent position, and it is sharpest exactly where the discovery tier’s modelling is most careful.

What this affects. Not map growth: what an agent learns and when runs through VisibilityModel alone, so results about map expansion, exploration order, wander behaviour and the lost-exit failure mode stand. What it undercuts is any claim that a discovery agent’s route choice is perception-limited. It is not.

So name what the model is. Route choice here is an optimality bound: what an evacuee with perfect knowledge of the smoke field would choose over the part of the building it happens to know. Not “what a perfectly informed evacuee would choose” — the topology restriction is real and still binds. That bound is a legitimate and useful thing to publish, and the discovery tier remains a perception-limited model of wayfinding; but the two are different claims and only one of them is about smoke.

Nothing is fixed here. foresight_horizon_s is the one lever already shipped that bounds half of it (the temporal half); it defaults to inf. #125 records the three options and evaluates none.

Two asymmetries favour the exit the agent already walks to

tau_return_margin (default 0.8) is a deadband on feasibility. The current route is judged against the bare tau_max; a rival must come in under tau_max * 0.8 before it is even a candidate:

budget = tau_max                        (current exit)
budget = tau_max * tau_return_margin    (any other exit)

Without it a route whose tau sits near the budget toggles in and out of the feasible set every tick and the agent follows it. It replaces the old sight_return_margin, which multiplied a sight requirement (1.25 up) where this one scales a budget (0.8 down).

current_exit_discount (default 0.9) is a deadband on ordering. Only the current exit’s tau is discounted, and only in the sort key, so it holds its place unless a rival is clearly cleaner rather than momentarily cleaner.

Its provenance is FDS+Evac’s FAC_DOOR_OLD2 = 0.9 (evac.f90:1572), which is applied as L2_tmp = FAC_DOOR_OLD2 * L2_tmp to the current door at :16626 and :16803 — and at :16803 that L2_tmp is the tau/6 of the tier-4 test, i.e. the same quantity, discounted in the same place, inside the loop that minimises it to pick a door. The shipped comment on tau_of in route_graph.py cites that provenance correctly since 9508181; an earlier version cited FAC_DOOR_WAIT, which is at :1570 and discounts the current door’s travel time (T_tmp), not its smoke. FAC_DOOR_WAIT is still the correct citation for exit_switch_anchor and _PATH_IMPROVEMENT_THRESHOLD, which are time comparisons, and the code cites it only there.

A reviewer will ask why both. They act on different stages — one on the feasible set, one on the order within it — but they have not been measured independently, and no run isolates the contribution of either.

The clean-exit tier (off by default)

clean_extinction_threshold adds one rank above optical depth. A route whose smokiest leg stays at or below the threshold is clean, and clean routes outrank smoky ones outright however far they are; among routes of the same tier, optical depth then time decides. The sort key is (rejected, tier, tau, rank_cost, hops).

This is FDS+Evac’s primary door rule (evac.f90:16601, :16608), and its threshold is not a new constant: FED_DOOR_CRIT = -100 becomes 3.0/100 = 0.03 /m at :5496, which is Jin’s S = 3/K at a 100 m sighting distance. Two differences from the reference implementation are worth stating:

  • FDS+Evac’s tier 1 is a hard filter. IF (T_tmp < L2_min .AND. L2_tmp < ABS(FED_DOOR_CRIT)) only ever selects a qualifying door; when no door qualifies the tier picks nothing and the search falls to the next tier of doors. Falling through to plain time ranking when our tier is empty is pyFDS-Evac’s choice, not FDS+Evac’s.
  • Membership is measured on the smokiest leg, each leg its own mean. The route mean would dilute a smoky stretch with whatever clear corridor follows, so a long route could qualify by being long — the mirror of the length penalty the gate exists to remove. The worst sample is the step function that made sighting distances jump between ticks. FDS+Evac applies its 0.03 to K_ave_Door, a per-door average, for the same reason — though note that K_ave_Door is a mean along a bee-line sight line (See_door, evac.f90:16486, assigned at :16497), not a maximum over legs.

clean_exit_margin is hysteresis on membership for the exit the agent already heads for: its limit is clean_extinction_threshold / clean_exit_margin. FDS+Evac supplies the value — FAC_DOOR_OLD = 0.1 (evac.f90:1571), applied as L2_tmp = FAC_DOOR_OLD * L2_tmp for the current door (:16591), so the door an agent already walks to stays smoke-free up to ten times the criterion.

The exit-switch anchor has a matching clause: a rival that is clean while the current exit is not bypasses the anchor. It was added because the anchor’s other bypass, then keyed on the visibility band, could not fire between a clean route and one 3.3x smokier — both saturated the band. That reasoning is now stale: the bypass compares optical depth, which does discriminate there. The clause has not been re-measured since, and the tier ships off anyway.

It ships off (clean_extinction_threshold = 0.0, which means no route is ever clean) because measurement refuted it. On l_corridor over five seeds the far-exit share was 15-22 with the tier against 15-17 without, identical on four of the five; the prediction that motivated the tier was 25-40. The tier is not inert — it is non-empty on 52 % of decision ticks and changes the ordering on 41 % — but the effect is churn rather than redirection: agents flip toward the clean exit and back before reaching the junction. Median RSET rises 17 % (71.2 s to 83.7 s) and monotonicity goes from 0 returns to an abandoned exit to 34-38 agents per run.

The proposed mechanism is a threshold too sharp for the signal. Tick-to-tick movement in a leg mean has median 0.0072 /m and p90 0.134 /m, against a hysteresis band of 0.0075 /m — narrower than the median jump, 116 crossings per run. The band is also one-sided: the incumbent is relaxed to 0.0375 /m while a rival is admitted at the bare 0.03 /m. And because tier membership is binary, a crossing does not reorder the list, it swaps which objective is in force, so the target jumps. FDS+Evac has only a weak memory against that: a mark on a lone agent’s previous target once K_ave >= 0.3 /m (evac.f90:16628-16637), which acts weakly (see model-comparison.md); here nothing is remembered, by design.

Read the refutation as provisional. A band of 0.0075 /m is 0.03 / 0.8 - 0.03, so those runs were made at clean_exit_margin = 0.8 — the value from_routing_params still hands out, and the value the same commit records as invented. At the 0.1 that FAC_DOOR_OLD supplies, the incumbent’s limit is 0.3 /m and the band is 0.27 /m: twice the p90 of the drift instead of a third of the median, which is a different regime for exactly the quantity the mechanism blames. The tier has not been measured at 0.1. Anyone re-opening this should set the key explicitly and re-run before trusting either the numbers or the explanation.

Turn it on for a deck that has a genuinely clean alternative to reach for, and read the clean and k_leg_max columns of the route-cost CSV when you do.

Dose vetoes an exit; it does not rank

FED enters route choice as a veto only. Each route’s fed_max_route is the dose already taken plus the dose predicted over the walk (current_fed + sum(fed_growth)), and a route above fed_rejection_threshold (default 1.0, incapacitation) is refused. Refusal is asymmetric in the same way as optical depth: the current exit is held to the bare threshold so an agent flees a lethal door at once, while a rival must come in under fed_rejection_threshold * fed_return_margin (0.9). A dose refusal is a “must flee” rejection, so it bypasses the exit-switch anchor — hysteresis cannot pin an agent to a door that will kill it.

Surviving routes are then ordered by optical depth and time. Dose never makes one exit outrank another; it only removes exits. tests/test_route_gate.py::TestDoseVetoesAnExit covers all three parts in clear air, so the smoke gate cannot be the cause.

This is FDS+Evac’s other branch, and we run both halves at once. In evac.f90 (Change_Target_Door, :16775-:16803) the sign of FED_DOOR_CRIT selects between a dose criterion and a smoke criterion — they are alternatives, not layers — and the default of -100.0 (:1524) selects the smoke branch that this model implements. Which smoke criterion depends on the tier: absolute K_ave_Door in tier 1, the 0.5 x d sight ratio in tier 4. Two further differences are worth knowing:

  • FDS+Evac’s chosen quantity both strikes a door out (L2_tmp >= 1.0 marks it not visible) and ranks the survivors (L2_tmp < L2_min picks the door). Since 0d9bf79 pyFDS-Evac’s optical depth does the same — it refuses and it ranks. Dose still only strikes out.
  • pyFDS-Evac applies dose and optical depth together rather than choosing one.

On the fires we have measured, the dose veto never fires. On l_corridor’s fire_1MW_west run the largest fed_max_route over 5049 route-cost rows is 0.0016, against a threshold of 1.0, and world100 is reported the same way. On these fires the model is exposure-gated wayfinding, not hazard avoidance: every refusal that changes an exit comes from the optical-depth criterion, and nothing in the run is near a tenability limit.

Refusals are not remembered

The criterion is measured against the distance still to walk, so it relaxes as the agent closes in: smoke that refuses a door at 40 m accepts it at 2 m. Every tick re-decides from the current field; there is no permanent exit death.

When every route is refused

The agent still has to move. rank_routes re-sorts the refused routes by (tau_route, rank_cost) — least smoke to walk through, then quickest — and un-rejects the head with a fallback: prefix on its reason. The agent keeps its current target unless a rival’s worst extinction is better by more than fallback_switch_margin (default 0.2), i.e. unless

rival.k_max_route <= current.k_max_route * (1 - fallback_switch_margin)

The fallback sort uses the undiscounted tau_route; current_exit_discount applies to the feasible ordering only, and the fallback_switch_margin on k_max_route is the hysteresis here instead. Ordering refused routes by k_max alone once put a 51 m route ahead of a 22 m one on 2.0 m of sight against 1.8 m — two tenths of a metre of visibility, neither usable, deciding a 29 m detour. tau carries the distance with it, so the least-bad walk is the one with least smoke to walk through.

Churn protection

Three mechanisms hold an agent on its exit.

The exit-switch anchor. Under "additive" a different exit is adopted only when its rank cost beats old_cost * exit_switch_anchor (default 0.9). Under the gate, _anchor_allows decides in this order (25a6f8f):

  1. The current exit is a “must flee” rejection — adopt. In practice only a dose rejection reaches this; see limitations.
  2. The rival is clean and the current exit is not — adopt.
  3. The rival is not feasible — fall through to the rank_cost comparison.
  4. Otherwise a symmetric deadband on tau, with margin = tau_max * tau_deadband (6 x 0.1 = 0.6):
delta = current.tau_route - candidate.tau_route
delta >  margin  ->  adopt
delta < -margin  ->  refuse
otherwise        ->  candidate.rank_cost < current.rank_cost * exit_switch_anchor

Symmetric is the point. Before 25a6f8f only the adopt half existed: leaving an exit had to clear a margin in tau, while returning to it fell straight through to the time comparison, which the nearer exit wins unconditionally. Departure cost a margin and the return was free — the same shape of failure as the clean tier’s, one level up. Hysteresis applied to one side of a disjunction is not hysteresis.

Absolute, not a ratio. tau is zero in clear air, so a ratio test reads 0 < 0: no agent could switch at all, and w_queue would count for nothing exactly where decks calibrate it. An earlier ratio-only form produced 109 returns to abandoned exits on l_corridor.

A route the ordering promotes but the anchor refuses no longer hides the rest of the list: candidates are tried in rank order and the first the anchor would admit wins, stopping at the agent’s own exit.

The deadbands. tau_return_margin (0.8) and fed_return_margin (0.9) make a rival exit harder to qualify than the current one; current_exit_discount (0.9) makes it harder to outrank it; tau_deadband (0.1 of tau_max) makes it harder to switch onto, in both directions. Four constants, three of them acting on the same quantity at three different stages. A reviewer will reasonably ask why, and no run isolates any one of them.

Monotonicity holds on world100 and does not on l_corridor. The requirement is that an agent never returns to an exit it has abandoned. At 0d9bf79 and 9f55f6e:

deckbefore (4ce4ac7)at 0d9bf79at 9f55f6eat 25a6f8f
world100, far clean exit E312 agents39 agents, 9 switches, 0 returnsunchangednot re-reported
l_corridor, returns to abandoned exits051 across 20 agents34 across 14 agents34 across 14 agents
l_corridor, switches4745555
l_corridor, far-exit share~18~18~18~18

The world100 result is what the model was asked for — prefer a clean exit even when far — and no earlier version of the gate produced it. l_corridor regressed, from no returns to 34, and the far-exit share did not move to pay for it. The figures come from the commit messages of 0d9bf79, 9f55f6e and 25a6f8f; no CSV for them is in the results folder. 25a6f8f reports only l_corridor, so the world100 column for it is unverified rather than measured.

Most of l_corridor’s 34 returns are not oscillation. Making the anchor’s tau deadband symmetric at 25a6f8f changed nothing measurable, and that is the finding: of the 34 returns, 29 have the returned-to route cleaner by more than the deadband — median 0.95 of optical depth against a margin of 0.6 — so the agent is following a field that genuinely reversed, not flickering across a threshold. No further constant can damp those. Whether a memoryless model should follow a reversing field is a modelling question, and it is open.

Enabling the clean-exit tier gives further violations back (34-38 agents per run on l_corridor, measured before 0d9bf79), which is why it is off.

Three attempts that did not close it, recorded so they are not repeated.

  • Making tau the anchor’s currency — replacing the rank_cost ratio test with a tau ratio test — took l_corridor from 51 returns to 90. tau is zero in clear air, so every ratio test degenerates to 0 < 0 and the anchor stops discriminating. This is also why rank_cost stays a time: a tau anchor would let no agent switch in clear air, and a congestion weight would count for nothing exactly where decks calibrate one.
  • An absolute floor on the one-sided bypass — the tau_max * 0.1 term — took 109 returns to 51. It helped; it did not close the problem.
  • Making that deadband symmetric (25a6f8f) — the correct fix for a real asymmetry, and it moved nothing: 34 returns before and after.

Anticipation

With anticipate (default true), each segment is priced at the time the agent would arrive there rather than the time it decides:

arrival_time = now + min(distance_walked_so_far / base_speed_m_per_s,
                         foresight_horizon_s)

The unimpeded base_speed_m_per_s is used, not the smoke-reduced speed: the reduction depends on the smoke at the arrival time being computed, and one pass settles what a second would only refine. foresight_horizon_s defaults to infinity, which is perfect foresight of the FDS solution; a finite horizon models an occupant who can only judge the near future.

anticipate is independent of cost_model. It applies under "additive" too, which is why pinning pre-gate behaviour needs both keys.

The additive model

composite = effective_length * (1 + w_smoke * K_ave)
          + w_fed * FED_max
          + w_queue * base_speed_m_per_s * queue_time      (when w_queue > 0)

and routes are ordered by (rejected, composite, hops).

Both smoke and length terms scale with route length, so a long clean detour pays for its own length and can never win however large w_smoke is — sweeping it 1 → 20 on assets/world_100 moved 12 of 120 agents. That is the reason the gate exists.

composite_cost is still computed and reported under the gate; it simply does not rank.

Configuration

Every key below is read from the scenario’s routing block by RouteCostConfig.from_routing_params. Keys are flat, not nested:

{
  "routing": {
    "cost_model": "gate",
    "tau_max": 6.0
  }
}
JSON keyDefaultEffectUnder "gate"Under "additive"
cost_model"gate"Selects the model. Unvalidated: any other string behaves as "additive".——
tau_max6.0Optical depth K_ave * L a route may carry before it is refused. Also orders the feasible routes.activeinert
tau_return_margin0.8Factor a rival exit’s budget is multiplied by, so switching needs a cleaner route than staying.activeinert
current_exit_discount0.9Factor the current exit’s tau is scaled by in the sort key. FDS+Evac’s FAC_DOOR_OLD2 is 0.9.activeinert
tau_deadband0.1Half-width of the exit-switch anchor’s symmetric tau deadband, as a fraction of tau_max (so 0.6 by default). FDS+Evac applies no hysteresis to this veto — evac.f90:16799 tests the raw value.activeinert
clean_extinction_threshold0.0 (off)Extinction at or below which a route’s smokiest leg makes the exit clean; clean exits outrank smoky ones. FDS+Evac’s value is 0.03.activeinert
clean_exit_margin0.1Divides the threshold for the exit the agent already heads for. FDS+Evac’s FAC_DOOR_OLD is 0.1.activeinert
anticipatetruePrice each segment at the agent’s arrival time.activeactive
foresight_horizon_sinfCap on how far ahead anticipation reaches, in seconds.activeactive
fallback_switch_margin0.2Hysteresis when every route is refused.activeinert
w_smoke1.0Smoke weight in the additive composite and its Dijkstra edge weights. Since 0d9bf79 the gate weights edges by their own tau, so neither weight reaches route choice under the gate; the composite is still reported.inert (reported only)active
w_fed10.0FED weight. Same.inert (reported only)active
w_queue0.0Congestion weight, off by default.active (as w_queue * queue_time_s on the rank cost)active (as distance-equivalent in the composite)
fed_rejection_threshold1.0Projected FED above which a route is refused. Veto only: dose never ranks.activeactive
visibility_extinction_threshold0.5K above which a segment is flagged non-visible; a route whose segments are all non-visible is refused when some other route has a visible segment.inertactive
sampling_step_m2.0Spacing of extinction samples along an edge polyline.activeactive
base_speed_m_per_s1.3Clear-air walking speed. Sets travel time, anticipation, and the queue conversion. It is not a speed floor; min_speed_factor is.activeactive
alpha0.706Router’s copy of the linear speed-law coefficient, for travel time only; agents walk with SmokeSpeedConfig (smoke-speed model).activeactive
beta-0.057Same, the slope.activeactive
min_speed_factor0.1Same, the floor on the router’s speed factor.activeactive
default_exit_capacity1.3Fallback exit capacity, agents/s, when the exit sets none.activeactive

clean_exit_margin had two disagreeing defaults — 0.1 in the dataclass, 0.8 from from_routing_params — until 9508181 made both 0.1, the value FAC_DOOR_OLD supplies. The clean-tier measurements below were made at 0.8 and have not been repeated at 0.1.

Two RouteCostConfig fields are not readable from the routing block and keep their dataclass defaults in any scenario run: fed_return_margin (0.9, the asymmetric FED hysteresis) and impassable_extinction_threshold (3.0, the route-average extinction above which a smoke rejection bypasses the anchor). Setting them requires constructing RouteCostConfig in Python.

exit_switch_anchor (default 0.9) belongs to RerouteConfig, not to RouteCostConfig.

Known limitations

These are real and documented, not hypothetical. Details and measurements are in gate-model-review-notes.md. This section is the single consolidated statement; anyone assessing the model should be able to read it alone.

Two open issues carry the unresolved ones.

  • #124 — route choice oscillates at a genuine optical-depth crossover. l_corridor’s 34 returns, 31 of them in t = 40-60 s, the window in which the two routes’ tau cross over; at t = 40 s their distributions overlap by 61 %. The crossover swings through 1.5-3x and sails past any hysteresis constant that would not also blind the model to real change. What is missing is commitment — hysteresis in time or in progress along a leg — not a bigger threshold. Three attempts are already recorded as failures below.
  • #125 — route choice is not perception-limited. See Route choice is an optimality bound. It bounds what the discovery tier can be said to demonstrate about route choice; it does not touch map growth.

And the model is a departure, not a reproduction. tau is the ordering here; in FDS+Evac smoke ranks only inside the tier-4 last resort, over known-or-visible doors, on a bee line, with a strike-out that lasts one call. See The diversion is a departure. The 100/0 attributed to the reference criterion on l_corridor is a reasoned prediction from evac.f90, not a measured run of it.

  • The L/d bias tilts the criterion by geometry alone. tau is measured on the walked polyline where the reference measures on a straight line, and L/d is 1.41 on l_corridor’s near route against 1.27 on the far one — so at equal K_ave the polyline form is about 11 % stricter on the near route, in the same direction as the diversion the deck is used to demonstrate. Not measured on any other geometry.
  • The ordering is in optical depth and the anchor is partly in time. The ordering is tau; _anchor_allows falls through to rank_cost, a travel time, whenever the two routes’ tau are within the deadband. So the two currencies still meet, and mixing them was the presumed cause of l_corridor’s 34 returns. Measurement at 25a6f8f does not support that reading: 29 of the 34 have the returned-to route cleaner by more than the deadband, so they are the ordering correctly following a field that reversed. What remains open is the modelling question — whether a memoryless model should follow a reversing field at all — not a missing constant.
  • Four hysteresis mechanisms act on tau or on the exit choice. tau_return_margin on feasibility, current_exit_discount on the sort, tau_deadband on the anchor, exit_switch_anchor on the time fallthrough. None has been measured in isolation, and the last two are both keyed to 0.9 and 0.1 x 6 without a joint sweep.
  • The 1e-6 * length edge-weight floor is a hard-coded tiebreaker. It decides path choice in clear air, where every k_avg * length is zero. Its effect at small nonzero K has not been measured.
  • tau_max = 6 is uncalibrated as an exposure budget. The threshold is citable from FDS+Evac’s tier-4 rule, but that rule applies it to a straight sight line, not to a walked route, and nothing here checks 6 against a soot-dose or FED-equivalent limit.
  • impassable_extinction_threshold is dead code under the default model. _must_flee_rejection fires only on a rejection reason starting FED or containing "visible"; the gate’s only reason string starts tau. So no smoke rejection bypasses the exit-switch anchor, at any density, and the key still takes a value and does nothing. visibility_extinction_threshold (0.5 /m, per segment) is likewise skipped under the gate. The FED bypass survives, and on the fires measured here FED never reaches its threshold, so in practice nothing bypasses the anchor.
  • Anticipation samples the field too early. Segments are priced at now + walked_so_far / base_speed_m_per_s, the unimpeded speed, while an agent in smoke walks at as little as min_speed_factor = 0.1 of it. The clock therefore runs ahead of the agent systematically, and it runs furthest ahead exactly where the smoke is thickest. With foresight_horizon_s = inf the agent also has perfect foresight of the FDS solution.
  • Six hysteresis constants, none calibrated. exit_switch_anchor (0.9), fallback_switch_margin (0.2), fed_return_margin (0.9), tau_return_margin (0.8), tau_deadband (0.1), and _PATH_IMPROVEMENT_THRESHOLD (10 %) are all chosen to stop measured churn, not fitted to observed behaviour. Two have FDS+Evac values behind them: clean_exit_margin = FAC_DOOR_OLD, and current_exit_discount = FAC_DOOR_OLD2. Neither was fitted here either.
  • Clear-air equivalence has not been re-measured since 0d9bf79. The gate now orders by tau and weights edges by tau; the last equivalence runs predate both. In clear air every tau is zero and the argument still holds by construction, but it is an argument, not a measurement.
  • cost_model is an unvalidated free string. A typo silently yields the additive model.
  • FIC does not participate in routing under either model. It drives the Purser slowdown and incapacitation only. FIC and the optical-depth gate are driven by the same smoke, so routing on both would double-count.

Evidence

assets/l_corridor is the deck the model is judged on: a near exit reached by passing the fire, and a clean way round. Since a98f8bb the spawn sits in the middle of the vertical leg and the two routes are 26 m and 46 m, a 1.8x ratio; before that it sat 3 m from the junction and they were 11 m and 58 m, 5.3x — a spread wide enough that no smoke could justify the detour. At a98f8bb exit shares were unchanged across the move at 84 / 16, with 4 switches and no agent returning to an abandoned exit; at 7a3617d they are 82 / 18, with 55 switches and 34 returns across 14 agents. The numbers below predate the move, so read their route lengths against the old geometry. Results are in <sciebo>/fds-evac-data/l_corridor/evac/RESULTS.md (100 agents, seed 1, familiarity 1.0):

runfirenear / farswitches
gatefire_1MW_west84 / 1614 smoke_reroute
additivefire_1MW_west100 / 023 smoke_reroute
gate, controlfire_1MW99 / 1—
gate, clear airnone100 / 00
additive, clear airnone100 / 00

The control run puts the fire east of the junction, where the far route smokes first; the gate then diverts 1 agent instead of 16, so it is not simply preferring long routes.

The table is the run made at b3babc0, before the band left the ordering. The 84/16 split was re-measured after cea33ce and reported unchanged; that re-run is not in the folder above, so take the attribution from the commit message rather than from a CSV.

At 45e146f the reported state is l_corridor 84 near / 16 far with 2 switches and no returns to an abandoned exit, and world100 E1 91 / E2 17 / E3 12 with 5 switches and no returns. a98f8bb then moved the l_corridor spawn and re-reported 84 / 16 with 4 switches, still no returns. The world100 far clean exit is used at all only since b16e900. These figures come from the commit messages of b16e900, 45e146f and a98f8bb; no CSV for them is in the results folder.

Since 0d9bf79 these are history. Ranking on optical depth moved world100’s far clean exit from 12 agents to 39, and moved l_corridor from no returns to an abandoned exit to 51, then 34 at 9f55f6e, with the far-exit share unchanged at about 18. See Churn protection for the full table. No archived result set exists for either; both come from the commit messages.

Read the headline with its caveat. Counted directly from f_gate_costs.csv (3498 rows, the b3babc0 run), the refusals break down as:

refusalnear exitfar exit
sight (path)2001020
fallback: sight (path)1225
fallback: sight (los)1300

So in that run the path criterion did the work, and it refused the near exit 200 times as well as the far one — the split is not a one-sided refusal of the long way round. The los criterion appears there only under a fallback: prefix, i.e. in ticks where every route was already refused. That matters before anyone calibrates tau_max. The los criterion no longer gates at all (b16e900), and the reason strings are tau ... since 0d9bf79, so a current CSV carries neither label.

The route-cost CSV columns changed at 0d9bf79. min_visibility_m and band are gone; tau_route replaces both. Any analysis script reading the old columns needs updating.

assets/t_junction is not a route-choice benchmark. Its 2 MW PVC fire drives route K to about 10.7 /m, so every route on it carries an optical depth far above any plausible budget and all of them are refused. Keep it as a lethality and speed-collapse case.

References

Last updated on