fix(routing): close the three #20-review solver gaps (issue #21)#66
Conversation
Gap 1 — time-blind visited pruning: the visited map now stores componentwise minima of arrival clock AND maneuver count per prune cell (VisitedStamp); a candidate is pruned only when both stored minima are <= its own, so a later-clock full-step arrival can no longer prune a later-ring but earlier-clock substepped thread. Gap 2 — blocked direct arrivals: when the exact arrival leg to the destination is blocked, the direct heading now falls through to the normal step with its dtS/2..dtS/8 substep retry (the destination-pocket mirror of the #20 origin-pocket fix) instead of being consumed. Gap 3 — capture-hop validation: the endpoint-capture hop end->destination is now segmentNavigable-validated like every other edge; previously it could cross non-navigable cells unchecked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clock-aware visited pruning deliberately prunes less, growing the Flensburg->Marstal solve from ~39 s to ~44 s locally. With CI runners 6-10x slower the old 240 s per-test budget sat inside the risk band (44 s x 6 = 264 s), so raise it to 480 s. Never trust local timing margins for CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #66 review — out-of-diff / non-blocking notes (2 Minors)Minor 1 — gap-1 core change has no end-to-end regression test (surviving time-blind mutant). Confirmed: reverting the call site Ruling: acceptable as documented — no pinning test required. I re-derived the monotone-improvement claim independently: Minor 2 — open-water +43 % wall-time is overhead, not search-widening. In synchronized open water the Both are non-actionable; the only fix I'm requesting is the Major timeout widening on the inline thread. |
…re gate Address the four review findings on the #21 solver follow-ups: - stampVisited now takes a single VisitedStamp instead of two positional numbers, so the tMs/maneuvers axes can never be swapped at a call site. - The endpoint-capture branch runs the cheap distance/ETA gates before the expensive segmentNavigable mask walk (all conjuncts are side-effect-free; result is bit-identical, only evaluation order/cost changes). - Correct the gap-3 comment: the 176 m figure is the hand probe; the real 7.0833 kn solver child ends 180.5 m from D, and all ring-1 children stay inside the 185.2 m capture radius. - Widen the Flensburg->Marstal explicit timeout 480_000 -> 600_000 for slower CI runners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the three theoretical solver gaps the #20 fix review identified in
app/src/routing/isochrone.ts— all verified real, none previously observed in tests.Gap 1 — time-blind
visitedpruning across desynchronized clocksvisitedstored min maneuvers only; with mixed per-node clocks after substepping, a later-clock node reaching a prune cell in an earlier ring could stamp it and prune a later-ring-but-earlier-clock substepped thread (realistic worst case: ETA suboptimality). The map now stores aVisitedStamp— componentwise minima of arrival clock and maneuver count — and a candidate is pruned only when the stamp dominates on BOTH axes (visitedDominates), the fix direction prescribed in the issue. Componentwise minima can combine two different stampers into a dominator neither was alone; that residual is documented in code and is strictly less pruning than the maneuvers-only rule it replaces (the new rule adds a conjunct). Termination is preserved: clocks are monotone along paths and per-cell minima stabilize.Testing honesty: I could not construct an end-to-end synthetic-mask trigger with independently derivable expectations, for structural reasons:
Per the issue's framing ("none observed in any test", spurious unreachable "would need contrived horizon-edge geometry") the invariant is instead pinned at unit level on the exported helpers (
visitedDominatestruth table,stampVisitedcomponentwise-minima semantics, hand literals). Known limitation, stated openly: the call-site mutantseen.maneuvers <= child.maneuvers(i.e. pre-change behavior) survives the suite — verified by an explicit mutation check. The stamping call site is now swap-proof by construction:stampVisitedtakes a single named-fieldVisitedStamp, so itstMsandmaneuverscan no longer be transposed the way two same-typed positional numbers could.Gap 2 — blocked direct-arrival candidates get the substep retry
The direct-arrival branch consumed its candidate with
continueeven when the arrival leg was blocked, so the direct heading never got the dtS/2..dtS/8 substep retry every other blocked candidate gets — the destination-pocket mirror of the #20 origin-pocket fix. Thecontinuenow sits inside the navigable branch; the blocked case falls through to the normal step with its substep retry. (The full step along the same great circle is always blocked when the direct line is, so the fall-through reaches the substep loop.)Test: fine 50 m-cell mask, full-height wall 326 m east of the origin, destination 501 m east (0.2707 nm ≤ the 0.3 nm full step, so the direct candidate takes the arrival branch). Wind 12 kn from N → heading 090 = TWA −90 →
TEST_POLARgrid point 7.2 kn exactly; first substep 7.2 kn × 75 s = 0.15 nm = 278 m fits with 48 m margin. Heading 090 exists only as the direct candidate (EXTRA_TWAS has ±85/±95; beat 42°, gybe 165° at 12 kn), and the nearest non-direct substep endpoint (heading 085) lies 24.2 m away, so a 0.005 nm tolerance on asegmentNavigablespy uniquely identifies the direct retry. The sealed pocket stays a correctno-route/unreachable.Gap 3 — the endpoint-capture hop is mask-validated
The final capture hop child→destination was the only solver edge that skipped
segmentNavigable. It is now validated like every other edge.Test: destination 451 m east of the origin, 75 m behind the wall — the ring-1 heading-085 substep child ends √((451.2−276.7)² + 24.2²) = 176 m = 0.0951 nm from it, inside the 0.1 nm capture radius, while every line from that water to the destination crosses the wall. Before the fix, solve returned an
okroute whose final hop crossed land; now the sealed pocket is correctlyno-route/unreachable.Mutation checks
continuerestoredDeliberately skipped
The issue's optional pinned "Flensburg→Marstal at 3.0 m stays unreachable" correct-negative test is not added: #53 will deliberately change exactly that behavior (find the shallow route and warn the user), so pinning it now would create churn. The behavior remains documented in
realmask.repro.test.ts.Verification
npm --prefix app run typecheck/lint— clean.app/src/routing/isochrone.followups.test.ts— 4/4 pass (0.8 s).npm --prefix app run test— 50 files / 478 tests all green, wall 4:19.invariants.property+realmask.reprovitest duration 242.9 s → 284.1 s (+17%);realmask.reproalone 43.3 s → 49.9 s; Flensburg→Marstal 38.8 s → 43.7 s (+12.7%). The cost is the intended semantics — clock-aware pruning prunes strictly less, widening the search front;MAX_FRONTIERstill bounds the worst case. Because 43.7 s × the documented 6-10× CI slowdown brushes the Marstal test's old 240 s per-test budget, the second commit raises that explicit timeout to 600 s (loosening only — no timeout was tightened).Closes #21
🤖 Generated with Claude Code