Skip to content

Solver: adaptive substeps for real coastline geometry (Closes #20)#22

Merged
DocGerd merged 2 commits into
mainfrom
fix/solver-real-mask
Jul 15, 2026
Merged

Solver: adaptive substeps for real coastline geometry (Closes #20)#22
DocGerd merged 2 commits into
mainfrom
fix/solver-real-mask

Conversation

@DocGerd

@DocGerd DocGerd commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Root cause

A full isochrone step is longer than real harbor arms are straight. Out of Flensburg, every one of the 39 first-expansion candidates produced a 507–1165 m straight segment (dtS = 300 s at ~4 nm to go; 2× that at 600 s) inside a channel only ~200–420 m wide (4–9 cells at 46 m/cell) with a bend within ~1 km. segmentNavigable correctly rejected all of them, the frontier went 1 → 0 at t = 0, and the solver reported unreachable for any route departing the harbor. Synthetic-mask tests never caught this because their channels are wider than one step.

Hypotheses ruled out with instrumentation: heading quantization (headings within ~5° of the channel axis existed and died on segment length), MAX_FRONTIER (max observed 18,848 < 30,000; the death happens at frontier size 1), snap placement (origin moved ~14 m onto an 8.9 m cell).

Marstal premise correction

Issue #20's premise that "the flood-fill gate proves both harbor snaps reach open water at 3.0 m" is wrong for Marstal: its snap cell sits in a 119-cell pocket that 4-connects to open water only at safety depth ≤ 2.3 m (measured with the app's exact predicate; matches the CONNECTIVITY_EXCEPTIONS_M = {"marstal": 2.0} entry from PR #8). At the 3.0 m default, unreachable is the correct answer for the shipped mask — a data limitation in the issue #9 family, not a solver bug.

Fix

Three changes in isochrone.ts, one uniform mechanism, no new passes:

  • Blocked-candidate substepping. When a candidate's full-step segment fails segmentNavigable, retry the same heading at dtS/2, dtS/4, dtS/8 and take the largest substep that fits; the child carries the honest shorter clock. Navigability still comes solely from segmentNavigable at query time; maneuver pricing is untouched (the penalty is charged once, and a substep whose effective distance reaches 0 stops the descent).
  • better() prefers earlier tMs first, so same-cell pruning keeps earlier arrivals once substeps desynchronize node clocks.
  • Loop guards use the earliest frontier clock (best-ETA cutoff, beyond-horizon), children are horizon-checked individually, and onProgress reports the new frontier's minimum clock rather than the ring clock (which can drift up to 7/8 dtS ahead under substeps — relevant for the Phase E progress UI).

All three are no-ops while the frontier is time-synchronized (the pre-fix invariant: no substeps ⇒ all clocks equal), which is why every synthetic golden/property test passes unmodified.

Acceptance tests (realmask.repro.test.ts, real shipped mask + polars)

  • Flensburg → Glücksburg at DEFAULT_SETTINGS via planRoute: ok, both rigs, ~0.6 h for ~4 nm, every leg re-validated against the mask at 3.0 m.
  • Same pair under wind from 0/90/135/180/315: ok ×5 (~35 ms each).
  • Progress-clock regression: the first Flensburg frontier is guaranteed all-substepped; reported clock must be < ring clock and monotonic.
  • Flensburg → Marstal (spec acceptance) via planRoute at safetyDepthM: 2.3: ok, ETA 8.0 h, ~38 nm, legs mask-validated at 2.3 m. Runs at 2.3 m with the data caveat documented in-test; tighten to DEFAULT_SETTINGS if the mask ever resolves the dredged approach at 3.0 m.

The real-asset test gets its own tsconfig.test.json (node builtins) so node globals never leak into the browser app project.

Suite evidence

  • Full suite: 20 files / 110 tests green; tsc -b --noEmit and eslint src clean.
  • Property/invariants suite runtime unchanged: 227.8 s pre-fix → 226.2 s post-fix (substepping only fires where a segment check already failed). Full-suite wall delta (+13 s) is entirely the new real-mask file (~40 s test time, Marstal-dominated) — well inside the 900 s budget.

Remaining theoretical gaps (tracked in #21)

  • visited/byKey pruning is time-blind across desynchronized clocks (~220×190 m cells keyed by min maneuvers); substepped threading through prune-cell-sized dead ends could in theory starve — not observed in any tested geometry.
  • Blocked direct-arrival candidates are consumed rather than falling through to substepped expansion, and the < 0.1 nm endpoint-capture hop is not mask-validated (latent: the per-leg mask assertions pass on all tested routes).

Diagnosis report: .superpowers/sdd/issue20-diagnosis.md.

Closes #20

🤖 Generated with Claude Code

Patrick Kuhn and others added 2 commits July 15, 2026 18:35
… navigable (issue #20)

Root cause: a full isochrone step (0.5-2 km at 150-600 s dt) is longer than
real harbor arms are straight. Out of Flensburg (~350 m wide channel, bend
within 1 km) every candidate heading's segment crossed land within one step,
so the frontier died at the first expansion and the solver reported
'unreachable' for any route departing the harbor. Synthetic-mask tests never
caught this because their channels are wider than one step.

Fix, in isochrone.ts:
- When a candidate's full-step segment fails segmentNavigable, retry the same
  heading at dt/2, dt/4, dt/8 and take the largest substep that fits; the
  child carries the honest shorter clock. Navigability still comes solely
  from segmentNavigable at query time; maneuver pricing is untouched.
- better() prefers earlier tMs (no-op while clocks are synchronized) so
  same-cell pruning keeps earlier arrivals once substeps desynchronize.
- Loop guards (best-ETA cutoff, beyond-horizon) use the earliest frontier
  clock, and children are horizon-checked individually; both no-ops in the
  synchronized case.

Also corrects a stale cell-size comment in mask.ts (real mask is ~46 m
cells, not ~557x321 m - those were the synthetic fixture's dimensions).

New regression tests run against the real shipped mask/polars
(realmask.repro.test.ts, own tsconfig.test.json for node builtins):
Flensburg->Gluecksburg at defaults, a 5-direction wind sweep, and the spec
acceptance Flensburg->Marstal - at safetyDepthM 2.3 because Marstal's snap
cell only 4-connects to open water at <= 2.3 m in the shipped mask (see
CONNECTIVITY_EXCEPTIONS_M in pipeline/verify_mask.py, PR #8); at the 3.0 m
default 'unreachable' is the correct answer for this data.

Full suite 110/110 green; property suite runtime unchanged (227.8s -> 226.2s).

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onProgress reported the global ring clock, which post-substepping can run
up to 7/8 dtS ahead of the earliest live node. Report the new frontier's
minimum clock instead (identical to the ring clock when no substeps
occurred; empty frontier falls back to the ring). Regression-asserted in
the real-mask suite where the first Flensburg frontier is guaranteed to be
entirely substepped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread app/src/routing/realmask.repro.test.ts
@DocGerd

DocGerd commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Gate review (strong-model, doubles as this branch's self-review): Ready to merge — Yes. No-op claims verified by induction over the time-synchronized pre-substep frontier; termination structural + sealed-pocket probe (11 ms); heterogeneous-clock domination sound; design principles intact (query-time navigability only, no new passes, maneuver pricing unchanged); 110/110 incl. property suite at unchanged runtime, independently re-run. Residual theoretical gaps tracked in #21. The only post-review delta (55ac84a) is the review-prescribed progress fix, under reviewer confirmation now.

Comment thread app/src/routing/isochrone.ts
Comment thread app/src/routing/isochrone.ts
@DocGerd
DocGerd merged commit 1aa4b60 into main Jul 15, 2026
1 check passed
@DocGerd
DocGerd deleted the fix/solver-real-mask branch July 15, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solver: real harbor-to-harbor routes return 'unreachable' despite connected mask (blocks Flensburg→Marstal acceptance)

1 participant