diff --git a/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md b/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md index 4152d7d7..8c83d239 100644 --- a/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md +++ b/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md @@ -1,10 +1,17 @@ -> **⛔ SUPERSEDED / REFUTED 2026-06-08 (evening). DO NOT IMPLEMENT.** Live `ACDREAM_PROBE_PORTAL_CHURN` -> measured ZERO churn at the flap (`maxPop=1` across ~13k oscillating frames; 0 of ~63k reciprocals -> ever clipped empty). The flap is NOT re-enqueue churn — it is a STRUCTURAL divergence (retail has -> ONE `DrawInside(viewer_cell)` path, no inside/outside branch; we invented one + a unified flood). -> Decision: full retail port (Option A). Canonical: -> [`docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md`](../../research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md). -> (The Phase-1 churn *probe* this plan added is fine and was the tool that disproved the hypothesis.) +> **✅ REVIVED 2026-06-09.** Phase 1 (the churn probe) is done; **Phase 2 (port the bound) is now the +> active R-A2b work** — design: [`../specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`](../specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md). +> The `⛔` banner below was wrong: the `maxPop=1` reading came from a **camera-turn-at-rest** capture +> (the calm position, root `0172`), the wrong reproduction. A 2026-06-09 doorway **walk-through** capture +> (`launch-churn-confirm.log`, the proper Phase-1 Task-4 pin) measured `maxPop=16` on 44 % of frames → +> churn confirmed. Task 4's prediction ("redundant reciprocal back-contribution stays non-empty") is +> confirmed by `recip=1->1, grew=True`. +> +> --- +> +> **⛔ (HISTORICAL — corrected above) SUPERSEDED 2026-06-08 (evening).** Live `ACDREAM_PROBE_PORTAL_CHURN` +> measured `maxPop=1` — **but on the wrong reproduction (camera turn at rest), not a doorway crossing +> (see the revival note above).** The Phase-1 churn *probe* this plan added is correct and is the tool +> that ultimately confirmed (not disproved) the churn once aimed at the actual flap. # Portal-Flood Bounded-Propagation Port Implementation Plan diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md index 4e938adc..ae3bca98 100644 --- a/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md +++ b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md @@ -1,6 +1,17 @@ -> **⛔ SUPERSEDED / REFUTED 2026-06-08 (evening). DO NOT IMPLEMENT.** Both this spec's directions -> (enqueue-once AND the revised bounded-propagation) are dead: live measurement found ZERO portal -> re-enqueue churn at the flap (`maxPop=1`). The flap is a STRUCTURAL divergence — retail renders +> **✅ REVIVED 2026-06-09 — the REVISION banner (bounded propagation) is the live design; see +> [`2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`](2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md).** +> The `⛔` banner below was wrong: the `maxPop=1` "refutation" was a **camera-turn-at-rest** capture (the +> calm position, root `0172`), NOT a doorway crossing. A 2026-06-09 walk-through re-capture +> (`launch-churn-confirm.log`) measured `maxPop=16` on 44 % of frames — the churn is confirmed at +> flap-time. The "enqueue-once" half (§4/§5) stays dead (re-processing IS retail-faithful); the +> **bounded-propagation** half (REVISION banner) is what ships, as R-A2b. +> +> --- +> +> **⛔ (HISTORICAL — corrected above) SUPERSEDED / REFUTED 2026-06-08 (evening).** Both this spec's directions +> (enqueue-once AND the revised bounded-propagation) were called dead: live measurement found ZERO portal +> re-enqueue churn at the flap (`maxPop=1`) — **but that sample was the wrong reproduction (see the revival +> note above).** The flap is a STRUCTURAL divergence — retail renders > inside+outside through ONE `DrawInside(viewer_cell)` path with no inside/outside branch, and is > robust to its own ~36 µm eye jitter via many small per-building floods. Decision: full retail port > (Option A). Canonical: diff --git a/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md b/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md new file mode 100644 index 00000000..9e8559c8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md @@ -0,0 +1,195 @@ +# R-A2b — Portal-Flood Bounded Propagation (the indoor "flap" fix) + +**Date:** 2026-06-09 +**Branch:** `claude/thirsty-goldberg-51bb9b` +**Phase:** full retail render port (Option A) → R-A2b +**Status:** design — approved direction (Option A, the faithful clip), pending written-spec review. + +> **Revives** `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION +> banner = "bounded propagation") and `docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md`. +> Both were marked `⛔ SUPERSEDED` on the strength of a single `maxPop=1` capture that turned out to be the +> **wrong reproduction** (camera-turn at rest, not a doorway crossing). This session re-ran the pin with a +> slow walk-through and measured `maxPop=16` on a fifth of frames — the churn is real. Their banners are +> corrected to redirect here. + +--- + +## 1. Summary + +The indoor **flap** (grey/background flashing through doorways while *moving*) is a portal-flood +**re-enqueue churn** in `PortalVisibilityBuilder.Build`. When the camera crosses an interior doorway, the +two rooms sharing that doorway (`0171`↔`0173` at the Holtburg cottage) mutually re-contribute through the +shared portal. Each pass, the near-side re-clip produces a **drifted near-duplicate** region; the +reciprocal leaves it non-empty; `AddRegion`'s exact-polygon dedup doesn't recognize it → `grew=true` → +the neighbour re-enqueues. It ping-pongs to the `MaxReprocessPerCell=16` cap, which cuts the flood at an +**arbitrary depth**. Because the cut depth depends on the exact eye position, sub-cm eye creep makes the +visible cell set swing (2↔4 cells) frame-to-frame → the grey flap. + +**The fix (Option A — approved):** port retail's *bounded* propagation. A candidate contribution that is +**already covered by the neighbour's accumulated view does not count as growth** (no re-enqueue); only the +**uncovered remainder** propagates. This mirrors retail, where a redundant contribution **clips to empty +before `copy_view` appends it**, so the flood terminates structurally. Remove the `MaxReprocessPerCell` + +`popCounts` band-aid (termination is now by construction). Keep re-processing of genuinely-new slices. +Scope: `PortalVisibilityBuilder` only — no camera, rooting, clip-math, or seal change. + +--- + +## 2. Diagnosis — verified this session (the verify-first gate) + +The 2026-06-08 handoff gated the fix on a measurement gate (`docs/research/2026-06-08-indoor-flap-edgeon-vs-camera-position-handoff.md` §5). Results: + +### 2.1 §5.3 — retail's clip collapses at edge-on (the "port clip robustness" idea is dead) +`PView::GetClip` (`:432344`) → `ACRender::polyClipFinish` (`:702749`) bails when the clipped polygon drops +below 3 vertices (`:702863`, no guard band). `ClipPortals` (`:433654`) only propagates `if (ecx_8 != 0)`. +`ConstructView` (`:433750`) rebuilds the flood every frame, no cross-frame hysteresis. Our +`PortalProjection.ClipToRegion` collapses identically. **Edge-on area-collapse is geometric — there is no +retail clip robustness to port.** That option is off the table. + +### 2.2 §5.1 — the flap is a same-root flood oscillation, not a root-swap +`analyze_flap_vis.py` over `launch-camprobe.log`: of ~4,009 `vis` transitions, **3,984 are same-root vs +25 root-changes (99.4 % same-root)**. The flap is a flood-membership oscillation *inside* room `0171`, not +the "going-outside" root swap, and not the root doorway's D5 rescue flip (3,836/3,984 transitions had no +change in the root doorway's clip/D-band/side). + +### 2.3 The mechanism — `[pv-trace]` in `launch-camprobe.log` +At a near-stationary eye (`157.30, 7.8x, 96.25`, ~1 cm creep), one `Build` call shows `0171` popped ~19× +and `0173` ~20×, each round `p1->0173 addCell polys=1 clipVerts=4 recip=1->1 grew=True queued=True`, the +`processed` watermark climbing 0→1→…→19 until the cap binds at 16. The mutual contribution does **not** +shrink (constant `clipVerts=4`, `polys=1`) — it is the same doorway aperture, drifting. Per-cell view +counts swing 1↔53 and cells `016F`/`0172` flicker in/out → the flap. + +### 2.4 Confirmation — `launch-churn-confirm.log` (live walk-through, this session) +`analyze_churn_confirm.py`: **44.4 % of frames `maxPop ≥ 2`; worst `maxPop = 16`** (cap saturated, 3,745 +frames); root `0171` `maxPopMax=16`; `[flap]` vis oscillation reproduced (187 transitions, vis `2/3/4`). +The calm baseline (player at rest, root `0172`) sits at `maxPop=1` — **that is exactly the position the +2026-06-08 "refuted (maxPop=1)" capture sampled.** The DO-NOT was an unrepresentative sample; the churn is +confirmed at flap-time. + +### 2.5 Why retail doesn't churn (termination primitive) +`Render::copy_view` (`:344784`) — the slice-adder — **just appends** (with internal consecutive-vertex +cleanup); it has **no redundancy check** (confirmed by reading it). So retail's termination is **upstream**: +a redundant re-contribution does **not generate a new propagatable slice** — via the clip going empty +(`GetClip`/`OtherPortalClip` < 3 verts) and/or the monotonic `update_count` watermark (each slice processed +once). The exact primitive (empty-clip vs watermark vs both) is confirmed in the plan by tracing the +`ClipPortals`/`AddToCell`/`AdjustCellView` mutual-cycle in full. Either way the *observable* contract is: +**a redundant contribution adds no new visible area, so it does not grow the view.** Our +`ApplyReciprocalClip` → `AddRegion` path violates that — it leaves the redundant contribution non-empty +(`recip=1->1`) and `AddRegion`'s polygon-equality dedup can't catch the drifted near-duplicate → spurious +`grew`. + +--- + +## 3. Retail grounding (the traversal being matched) + +From `docs/research/named-retail/acclient_2013_pseudo_c.txt`: + +- `PView::ConstructView` (`:433750`): per-frame flood — `cell_todo_num=0`, seed root, pop one cell at a + time, append to `cell_draw_list` (= membership), `ClipPortals(cell, 0)`, then `AddViewToPortals`. +- `PView::ClipPortals` (`:433572`): processes the cell's view slices `[update_count, view_count)`; + per portal `GetClip`; exit portal → `copy_view`/landscape; neighbour → `OtherPortalClip`. Propagates + **only when the clipped result is non-empty** (`ecx_8 != 0` / `eax_16 != 0`). +- `PView::AddViewToPortals` (`:433446`): first discovery (`processed_stamp==0`) → `InitCell` + + `InsCellTodoList` (enqueue once); growth (`processed_stamp != view_stamp`) → `AddToCell` + `FixCellList`, + then `processed_stamp = view_stamp` (**no re-enqueue**). +- `PView::AddToCell` (`:433050`): incremental — clips the cell's portals against **only the newly-added + slices**; does not re-contribute to `OutsideView`. +- `PView::OtherPortalClip` (`:433524`): reciprocal back-clip; yields empty for a redundant back-contribution. +- `Render::copy_view` (`:344784`): appends a slice; **no dedup** (confirms the empty-for-redundant + decision is upstream, in the clip). + +**Takeaway:** retail re-processes growth (faithful — keep it), but a redundant re-contribution adds **no +new visible area** → no new propagatable slice → termination (via empty clip and/or the monotonic +watermark; §2.5). Our divergence is purely that redundant re-contributions stay non-empty and grow the view. + +--- + +## 4. The fix (design — Option A) + +**Scope: `PortalVisibilityBuilder` only.** + +**4.1 Bounded growth (the core change).** A candidate contribution to a neighbour grows the neighbour's +view (and may re-enqueue) **only by the area not already covered by that neighbour's accumulated view**. +Concretely, before unioning a candidate region into `frame.CellViews[neighbour]`, intersect/subtract it +against the neighbour's existing accumulated regions and keep only the **uncovered remainder**; `grew` is +true iff that remainder is non-empty. A drifted near-duplicate of an already-covered region has +~zero uncovered area → `grew=false` → no re-enqueue → the mutual cycle terminates. This is retail's +"redundant → empty," expressed on our region representation, and it is **drift-tolerant by construction** +(it tests *coverage*, not polygon equality — so it is NOT the rejected epsilon-dedup band-aid). + +**4.2 Remove the band-aid.** Delete `MaxReprocessPerCell` and `popCounts` and the per-pop re-enqueue cap +logic in both `Build` and `BuildFromExterior`. With redundant contributions no longer growing the view, +termination is structural (each cell's genuinely-new slices process a bounded number of times; the flood +converges as the aperture is covered). + +**4.3 Keep re-processing of genuinely-new slices.** A contribution that *does* add uncovered area still +grows the view and re-enqueues, so late-discovered slices still reach exit portals +(`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN). + +**Exact code form (→ implementation plan, Task 1).** Whether 4.1 is implemented as (i) a polygon +coverage test in `AddRegion` (candidate ⊆ union(existing) → no growth), (ii) an uncovered-remainder +set-difference before the union, or (iii) matching retail's `ClipPortals` slice-watermark + `AddToCell` +in-place growth, is finalized in the plan by reading the retail `ClipPortals`/`AddToCell`/`AdjustCellView` +slice loop in full and choosing the smallest faithful form. The **principle** (redundant/covered → +no growth; uncovered remainder propagates; cap removed; genuine re-processing kept) is fixed here. + +**Unchanged (explicit):** `ProjectToClip`/`ClipToRegion`, `EyeInsidePortalOpening`, the reciprocal +`ApplyReciprocalClip`, the `OutsideView` exit contribution, rooting (`clipRoot = viewerRoot ?? _outdoorNode`), +the camera, and the landscape-through-door seal. No new heuristic, hysteresis, or epsilon. + +--- + +## 5. Testing (TDD) + +1. **Eye-sweep membership stability (new, the RED→GREEN driver).** In `AcDream.App.Tests`, build the flood + at a sequence of eye positions stepping monotonically across a grazing doorway (synthetic two-room + + shared-portal topology reproducing the `0171↔0173` mutual aperture). **Assert each cell's membership + across the sweep is a single contiguous run** — no `present→absent→present` flicker — and, if surfaced, + per-cell pop count ≤ a small constant. RED under the churn, GREEN after the bound. +2. **Termination without the cap.** Diamond + cycle fixtures: assert the flood terminates with + `MaxReprocessPerCell` removed, `OrderedVisibleCells` deduped, each reachable cell present once. +3. **No membership regression.** `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`, + `Build_IsDeterministic_*`, `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, + `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 over-inclusion guard), and the + cellar/window/look-in tests stay GREEN. The 4 physics rest-stability guards stay GREEN. +4. **Visual gate (user) — acceptance.** At the cottage doorway: walk through and turn the camera — interior + rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]` + `maxPop` ≤ a small constant (no near-16 churn). Then strip the `[portal-churn]`/`[flap]`/`[pv-trace]` + apparatus. + +`dotnet build` + `dotnet test` green before the visual gate. + +--- + +## 6. Scope / non-goals / risks + +- **In scope:** `PortalVisibilityBuilder` bounded-growth (4.1) + cap removal (4.2) in both `Build` and + `BuildFromExterior`; the new tests. +- **Under-inclusion risk + mitigation:** an over-aggressive "covered" test could drop a genuinely-visible + cell (a hole). Mitigation: "covered" is conservative (drop a candidate's growth only when fully covered); + the #95 over-inclusion guard, the eye-standing/look-in/cellar tests, and the new eye-sweep test (must not + drop a cell mid-sweep) bound both directions. Surface any test tension during implementation; do not + weaken a test to pass. +- **§4 camera (deferred, separate divergence):** the eye floating edge-on (retail's eye is pulled in, + collided 93 % at the doorway — `flap-cam-measure.log`) can make the churn fire more often, but is **not** + required for this fix — the churn is a real flood bug at any eye position. Revisit as a follow-up only if + a residual remains after R-A2b. +- **No** rooting / clip-math-rewrite / seal / physics change. + +--- + +## 7. Apparatus + references + +- **Captures (untracked, large):** `launch-churn-confirm.log` (this session's walk-through — + `maxPop=16`, 44 % churn); `launch-camprobe.log` (`[pv-trace]` `0171↔0173` churn detail); + `flap-churn.log` (the `maxPop=1` camera-turn-at-rest = the wrong reproduction that mis-shelved the spec). +- **Analyzers (throwaway):** `analyze_flap_vis.py` (same-root vs root-swap split), `analyze_churn_confirm.py` + (maxPop distribution + flap reproduction). +- **Probes:** `ACDREAM_PROBE_FLAP=1` (`[flap]` / `[pv-trace]`), `ACDREAM_PROBE_PORTAL_CHURN=1` + (`[portal-churn]` per-Build maxPop + reciprocal pre→post). Strip after the visual gate. +- **Retail anchors:** `ConstructView` `:433750`, `ClipPortals` `:433572`, `AddViewToPortals` `:433446`, + `AddToCell` `:433050`, `FixCellList` `:433407`, `AdjustCellView` `:433741`, `OtherPortalClip` `:433524`, + `copy_view` `:344784`, `GetClip` `:432344`, `polyClipFinish` `:702749`. +- **Revived (banners redirected here):** `2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION = + bounded propagation), `2026-06-08-portal-flood-bounded-propagation.md` (Phase 1 done; Phase 2 = this). +- **Memory to correct after ship:** `project_indoor_flap_rootcause` — the churn is confirmed at flap-time + (`maxPop=16`); the "churn refuted (maxPop=1)" verdict was a non-flapping (camera-turn-at-rest) sample.