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:
| value | what 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:
- Path per exit. Dijkstra runs with each edge weighted by its own optical
depth,
k_avg * length(plus a1e-6 * lengthfloor, below). - Availability. Each candidate route is tested twice. Optical depth:
it is refused when
tau = K_ave * L_effexceedstau_max(default 6), ortau_max * tau_return_marginif it is not the exit the agent already walks to. Dose: it is refused when its projected FED exceedsfed_rejection_threshold. Either refusal alone removes the route. - Ordering among survivors. Optical depth first, travel time second. The
sort key is
(rejected, tier, tau, rank_cost, hops)withrank_cost = travel_time_s + w_queue * queue_time_s. Thetauin the key is scaled bycurrent_exit_discount(0.9) for the exit the agent already heads for.tieris the clean-exit tier, off by default. - Adoption. The ordering proposes; the exit-switch anchor disposes.
evaluate_and_reroutewalks the ranked list and takes the first candidate_anchor_allowsadmits, 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_effK_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 IFSmoke 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:
| route | walked L | bee-line d | L/d |
|---|---|---|---|
| near (exit A, (0, 1.5)) | 25.0 m | 17.7 m | 1.410 |
| far (exit B, (45, 26.5)) | 45.0 m | 35.4 m | 1.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 = 0agent routes only overcognitive_subgraphand does not know an exit exists until it reads a sign — throughVisibilityModel, 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_remainingover the whole remaining route, including legs it has never visited and corners it cannot see past, and withanticipate = Trueandforesight_horizon_s = infat 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 thatK_ave_Dooris 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.0marks it not visible) and ranks the survivors (L2_tmp < L2_minpicks the door). Since0d9bf79pyFDS-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):
- The current exit is a “must flee” rejection — adopt. In practice only a dose rejection reaches this; see limitations.
- The rival is clean and the current exit is not — adopt.
- The rival is not
feasible— fall through to therank_costcomparison. - Otherwise a symmetric deadband on
tau, withmargin = 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_anchorSymmetric 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:
| deck | before (4ce4ac7) | at 0d9bf79 | at 9f55f6e | at 25a6f8f |
|---|---|---|---|---|
world100, far clean exit E3 | 12 agents | 39 agents, 9 switches, 0 returns | unchanged | not re-reported |
l_corridor, returns to abandoned exits | 0 | 51 across 20 agents | 34 across 14 agents | 34 across 14 agents |
l_corridor, switches | 4 | 74 | 55 | 55 |
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
tauthe anchor’s currency — replacing therank_costratio test with atauratio test — tookl_corridorfrom 51 returns to 90.tauis zero in clear air, so every ratio test degenerates to0 < 0and the anchor stops discriminating. This is also whyrank_coststays a time: atauanchor 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.1term — 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 key | Default | Effect | Under "gate" | Under "additive" |
|---|---|---|---|---|
cost_model | "gate" | Selects the model. Unvalidated: any other string behaves as "additive". | — | — |
tau_max | 6.0 | Optical depth K_ave * L a route may carry before it is refused. Also orders the feasible routes. | active | inert |
tau_return_margin | 0.8 | Factor a rival exit’s budget is multiplied by, so switching needs a cleaner route than staying. | active | inert |
current_exit_discount | 0.9 | Factor the current exit’s tau is scaled by in the sort key. FDS+Evac’s FAC_DOOR_OLD2 is 0.9. | active | inert |
tau_deadband | 0.1 | Half-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. | active | inert |
clean_extinction_threshold | 0.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. | active | inert |
clean_exit_margin | 0.1 | Divides the threshold for the exit the agent already heads for. FDS+Evac’s FAC_DOOR_OLD is 0.1. | active | inert |
anticipate | true | Price each segment at the agent’s arrival time. | active | active |
foresight_horizon_s | inf | Cap on how far ahead anticipation reaches, in seconds. | active | active |
fallback_switch_margin | 0.2 | Hysteresis when every route is refused. | active | inert |
w_smoke | 1.0 | Smoke 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_fed | 10.0 | FED weight. Same. | inert (reported only) | active |
w_queue | 0.0 | Congestion weight, off by default. | active (as w_queue * queue_time_s on the rank cost) | active (as distance-equivalent in the composite) |
fed_rejection_threshold | 1.0 | Projected FED above which a route is refused. Veto only: dose never ranks. | active | active |
visibility_extinction_threshold | 0.5 | K 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. | inert | active |
sampling_step_m | 2.0 | Spacing of extinction samples along an edge polyline. | active | active |
base_speed_m_per_s | 1.3 | Clear-air walking speed. Sets travel time, anticipation, and the queue conversion. It is not a speed floor; min_speed_factor is. | active | active |
alpha | 0.706 | Router’s copy of the linear speed-law coefficient, for travel time only; agents walk with SmokeSpeedConfig (smoke-speed model). | active | active |
beta | -0.057 | Same, the slope. | active | active |
min_speed_factor | 0.1 | Same, the floor on the router’s speed factor. | active | active |
default_exit_capacity | 1.3 | Fallback exit capacity, agents/s, when the exit sets none. | active | active |
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 int = 40-60 s, the window in which the two routes’taucross over; att = 40 stheir 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/dbias tilts the criterion by geometry alone.tauis measured on the walked polyline where the reference measures on a straight line, andL/dis 1.41 onl_corridor’s near route against 1.27 on the far one — so at equalK_avethe 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_allowsfalls through torank_cost, a travel time, whenever the two routes’tauare within the deadband. So the two currencies still meet, and mixing them was the presumed cause ofl_corridor’s 34 returns. Measurement at25a6f8fdoes 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
tauor on the exit choice.tau_return_marginon feasibility,current_exit_discounton the sort,tau_deadbandon the anchor,exit_switch_anchoron 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 * lengthedge-weight floor is a hard-coded tiebreaker. It decides path choice in clear air, where everyk_avg * lengthis zero. Its effect at small nonzeroKhas not been measured. tau_max = 6is 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_thresholdis dead code under the default model._must_flee_rejectionfires only on a rejection reason startingFEDor containing"visible"; the gate’s only reason string startstau. 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 asmin_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. Withforesight_horizon_s = infthe 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, andcurrent_exit_discount=FAC_DOOR_OLD2. Neither was fitted here either. - Clear-air equivalence has not been re-measured since
0d9bf79. The gate now orders bytauand weights edges bytau; the last equivalence runs predate both. In clear air everytauis zero and the argument still holds by construction, but it is an argument, not a measurement. cost_modelis 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):
| run | fire | near / far | switches |
|---|---|---|---|
| gate | fire_1MW_west | 84 / 16 | 14 smoke_reroute |
| additive | fire_1MW_west | 100 / 0 | 23 smoke_reroute |
| gate, control | fire_1MW | 99 / 1 | — |
| gate, clear air | none | 100 / 0 | 0 |
| additive, clear air | none | 100 / 0 | 0 |
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:
| refusal | near exit | far exit |
|---|---|---|
sight (path) | 200 | 1020 |
fallback: sight (path) | 12 | 25 |
fallback: sight (los) | 130 | 0 |
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
- FDS+Evac Technical Reference and User’s Guide — Korhonen (2021).
materials/evac.f90— the reference implementation; line-level citations for the door criteria are in gate-model-review-notes.md.- Boerger et al. (2024) — waypoint-based visibility, Beer-Lambert integrated extinction (Eq. 8-9).
- Visibility through smoke — Jin’s law and its sources.
- Routing model — defaults and deviations from the literature.
- assets/l_corridor/README.md — the deck, its fire, and the measured smoke contrast.