Files
OpenFrontIO/docs/MaskExpanding.md
scamiv 7bd7d35d92 Add mask-expanding for adaptive corridor refinement
- Introduced a new `MaskExpanding.md` documentation detailing the mask-expanding BFS approach, which enhances pathfinding by allowing corridor expansion without restarting the search.
- Updated `CoarseToFineWaterPath` to utilize the new mask-expanding strategy, improving efficiency by resuming searches instead of clearing state upon corridor tightness.
- Enhanced `MultiSourceAnyTargetBFS` with a new method to support mask expansion, allowing for dynamic adjustment of allowed regions during BFS execution.
- Implemented data structures and core loop adjustments to facilitate both fast and optimal variants of the mask-expanding approach, ensuring soundness and performance improvements in pathfinding.
- Suggested milestones for future enhancements and optimizations in corridor repair and pathfinding strategies.
2025-12-28 15:34:19 +01:00

146 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mask-expanding BFS (adaptive corridor refinement, no restart)
Purpose: keep the “coarse corridor” win, but avoid repeated **restart + re-walk** churn when the corridor is too tight.
This is the “performance-first” sibling of:
- `docs/CoarseToFine.md` (coarse corridor + safe fallback)
- `docs/LocalCorridorWidening.md` (visited-driven local relaxation)
Key idea: run one fine-res search; if the queue exhausts because the corridor is too restrictive, **expand the mask and keep going** without clearing the fine BFS state.
## What changes (vs restart-based local widening)
Today (A2-style):
- attempt = run fine BFS inside current mask
- on failure: widen mask, **restart** fine BFS (visited/prev cleared via new stamp)
No-restart:
- run fine BFS inside current mask
- on queue empty: widen mask, **resume** fine BFS (keep visited/prev/queue state)
This avoids re-enqueueing and re-walking large already-explored areas on “almost works” corridors.
## Correctness note (dont hand-wave)
Naively resuming a FIFO BFS after expanding the allowed set can change shortest-path guarantees, because newly-allowed tiles might introduce shorter routes to areas you already visited.
Invariant: once a fine tile is marked visited, it is never “unvisited” again — mask expansion only enables additional neighbors/regions and never invalidates already-visited tiles. This is why the fast variant remains sound (valid path) and why we can justify not clearing `visitedStamp` when expanding the mask.
Two viable interpretations:
1) **Fast variant (good enough for corridor repair):**
- accept that expanding the mask mid-run can produce a path that is not strictly shortest in the *final* expanded region
- still produces a valid path and is often much faster
2) **Optimal variant (paper-grade):**
- track `dist[tile]` (or level) and allow “relaxation” when new tiles become allowed
- process newly enabled nodes in non-decreasing distance (Dial/bucket queue or heap)
- guarantees shortest path in the final allowed region
For OpenFront boats, the fast variant may already be acceptable because the corridor is a heuristic bound anyway; if we care about strict optimality, use the optimal variant.
This doc describes the implementation in a way that supports either, with minimal extra plumbing.
## Groundwork we already have
From existing coarse-to-fine:
- `fineToCoarse: Uint32Array` mapping
- `allowedMask` as `(tileToRegion, regionStamp, stamp)` using stamps
From `LocalCorridorWidening` implementation:
- `visitedMaskOut` to collect “which coarse regions were actually explored” in a failed attempt
- widening by 1-ring around visited coarse regions, cumulative
We reuse that exact widening rule; the only change is: dont restart the fine search.
## Data structures (recommended)
Inside `MultiSourceAnyTargetBFS` (or a sibling specialized class):
- `visitedStamp[tile]` (already exists)
- `prev[tile]` (already exists)
- `startOf[tile]` (already exists)
- `queue[]`, `head`, `tail` (already exists)
Additionally for the optimal variant:
- `dist[tile]: Int32Array` (init -1; set on visit)
- `deferred[]` or a bucket/heap for nodes that become enabled later
Mask tracking:
- `allowedCoarseStamp[coarseCell]` (cumulative allowed regions)
- `visitedCoarseStamp[coarseCell]` (per-expansion snapshot; used to widen)
## Core loop (fast variant)
Pseudocode:
1) Build initial allowed mask from coarse spine corridor (`r0`).
2) Seed BFS queue from fine seedNodes filtered by allowed mask.
3) Run BFS:
- when exploring neighbors:
- if neighbor is blocked by allowed mask: **skip** (but see below)
- otherwise visit/enqueue as usual
4) When `head == tail` (queue empty):
- widen `allowedCoarseStamp` by 1 ring around `visitedCoarseStamp` from the last phase
- **activate newly enabled frontier nodes**:
- for each visited fine tile, re-check its neighbors that were previously mask-blocked
- enqueue any newly-allowed, unvisited neighbors
- continue BFS
5) Stop when a target is dequeued.
The only missing piece is “activate newly enabled frontier nodes” efficiently.
### Frontier activation strategies
**Strategy F1 (simple, may be OK):**
- keep an `Int32Array deferredTiles` of “neighbor candidates that were blocked by mask”
- when mask widens, scan deferredTiles and enqueue those that are now allowed
- keep deferredTiles deduped via a stamp array to avoid blowup
**Strategy F2 (faster, more code):**
- maintain a per-coarse-region list of deferred fine tiles
- when a coarse region becomes allowed, enqueue only tiles in that regions list
F2 is the “hot path” answer; F1 is the “get it working + measure” answer.
## Core loop (optimal variant)
If we want shortest paths in the final expanded region, treat mask-expansion as adding nodes that can introduce shorter routes.
Minimal way:
- maintain `dist[tile]`
- when a neighbor becomes newly allowed:
- `nd = dist[cur] + 1`
- if `dist[neighbor] == -1 || nd < dist[neighbor]`:
- update `dist`, `prev`, `startOf`
- push neighbor into a structure processed in increasing `dist`
Implementation options:
- bucket queue (Dial) since edge weights are 1
- binary heap (slower constants, simpler reasoning)
## Where this plugs in
Implement as a variant of the current coarse-to-fine helper:
- `findWaterPathFromSeedsMaskExpanding(...)` (fineMap + coarseMap + opts)
- reuse the same `allowedMask` and the same visited-driven widening rule
- keep the same guardrail: if expansions exceed `k`, fall back to unrestricted fine BFS
This is a stepping stone to Spine & Portals:
- This improves “corridor repair” without restarts
- Spine & Portals changes the big-O by avoiding fine search over open water entirely
## Suggested milestones
1) Implement the fast variant with deferred frontier list (F1) and measure.
2) If it helps but still shows spikes, upgrade frontier activation to F2.
3) If strict optimality is needed, implement the optimal variant (dist + buckets/heap).
## Note: depth-gated BFS (future)
We can reuse the same “monotonic relaxation” idea for “prefer deep water” by adding a second passability mask like `gm.magnitude(tile) >= minDepth` and relaxing `minDepth` only if needed. This stays BFS-friendly (still unweighted), but changes the objective to “deepest-possible path, then shortest within that depth”; if we need strict shortest for the relaxed threshold, restart per-threshold or use the optimal semantics.
## BSP-ish note
Both mask expansion and depth-gating are “BSP-ish” in the same sense: they incrementally relax constraints (expand the allowed subset) without invalidating already explored space. This makes the search behave more like progressive partition refinement than a single global floodfill, even though we are not literally constructing a BSP tree.