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 new file mode 100644 index 00000000..0baa1979 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md @@ -0,0 +1,187 @@ +# Portal-Flood Bounded-Propagation Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the indoor doorway "flap" by porting retail's *bounded* portal-flood propagation into `PortalVisibilityBuilder` — keeping re-processing on growth (retail-faithful) but stopping the reciprocal/drift churn that makes membership eye-sensitive. + +**Architecture:** The flap is `PortalVisibilityBuilder.Build`'s unbounded re-enqueue churn (`cs:322`, `MaxReprocessPerCell=16` hack): redundant reciprocal back-contributions yield drifted non-empty slivers → `grew` → re-enqueue, and the churn's fixpoint shifts under sub-cm eye motion. Retail bounds it structurally (monotonic `update_count` watermark + empty reciprocal). **Phase 1 instruments + pins the exact acdream divergence at the live doorway (it's float-drift-dependent — a runtime fact, not derivable from decomp). Phase 2 ports the bound, gated on Phase 1's evidence.** Spec: `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION banner). + +**Tech Stack:** C# / .NET 10, xUnit. GL-free unit tests (`AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`). Live capture via `dotnet run` against local ACE. + +**Non-goals (unchanged from spec):** no rooting/camera/clip-math-rewrite/seal change; physics + the 4 rest-stability regression tests stay; `Build_ViewGrowthAfterDoneCell` stays GREEN (re-processing is kept). + +--- + +## PHASE 1 — Instrument & pin the exact divergence + +### Task 1: Add the portal-churn probe flag + +**Files:** +- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` (after `ProbePvInputEnabled`, ~line 144) +- Test: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset() +{ + // Env var is absent in the test host, so the flag must default false (inert probe). + Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled); +} +``` + +- [ ] **Step 2: Run it — expect FAIL** (compile error: `ProbePortalChurnEnabled` does not exist) + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ProbePortalChurn_DefaultsFalse"` +Expected: FAIL (does not compile). + +- [ ] **Step 3: Add the flag** (in `RenderingDiagnostics`, mirroring `ProbePvInputEnabled`) + +```csharp +/// +/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits +/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues, +/// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins +/// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted +/// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships. +/// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1. +/// +public static bool ProbePortalChurnEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; +``` + +- [ ] **Step 4: Run it — expect PASS.** Run the same filter. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +git commit -m "feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin" +``` + +### Task 2: Instrument `PortalVisibilityBuilder.Build` churn + reciprocal + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the pop loop ~145-178; the reciprocal site ~295-306; the re-enqueue ~322; before `return frame` ~341) + +- [ ] **Step 1: Add a per-Build churn accumulator** (top of `Build`, after `var popCounts = ...` ~line 112) + +```csharp +// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn +// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off. +bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled; +int churnReenqueues = 0; +var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; +``` + +- [ ] **Step 2: Record reciprocal pre→post** (at the reciprocal site, right after `ApplyReciprocalClip(...)` ~line 297, before the `if (clippedRegion.Count == 0)` check) + +```csharp +if (churnProbe) + churnReciprocal!.Append(System.FormattableString.Invariant( + $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); +``` + +- [ ] **Step 3: Count re-enqueues** (inside the `if (grew && ... queued.Add(neighbourId))` block ~line 322, after `todo.Insert(neighbour, dist)`) + +```csharp +if (churnProbe) churnReenqueues++; +``` + +- [ ] **Step 4: Emit the summary** (just before `return frame;` ~line 341) + +```csharp +if (churnProbe) +{ + int maxPop = 0; uint maxCell = 0; int rePopped = 0; + foreach (var kv in popCounts) + { + if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; } + if (kv.Value > 1) rePopped++; + } + Console.WriteLine(System.FormattableString.Invariant( + $"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} " + + $"reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}" + + churnReciprocal)); +} +``` + +- [ ] **Step 5: Build** — Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: `Build succeeded`. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +git commit -m "diag(render): [portal-churn] probe — per-Build re-enqueue + reciprocal pre/post" +``` + +### Task 3: Deterministic re-pop unit test (probe baseline) + +**Files:** +- Modify: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` + +- [ ] **Step 1: Write the test** — the `ViewGrowthAfterDoneCell` topology re-pops `B` (legitimate late growth from `D`). This proves the re-pop path is exercised deterministically (so the probe + later fix have a non-flaky anchor) without needing live float-drift. + +```csharp +[Fact] +public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell() +{ + // Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as + // Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B + // through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix). + const uint A = 0x0001, B = 0x0002, D = 0x0003; + var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0)); + a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0), + new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0)); + b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2)); + d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var all = new Dictionary { [A] = a, [B] = b, [D] = d }; + + var frame = Build(a, all); + + // Membership (the flap-relevant output) is each cell once, regardless of re-pops. + Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray()); + Assert.Contains(D, frame.OrderedVisibleCells); +} +``` + +- [ ] **Step 2: Run it — expect PASS** (documents current behavior; the re-pop happens internally, membership stays deduped). + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_ViewGrowthAfterDoneCell_RePopsGrownCell"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +git commit -m "test(render): deterministic re-pop anchor for the bounded-propagation pin" +``` + +### Task 4: Live capture at the doorway + pin (CHECKPOINT — needs the user) + +- [ ] **Step 1: Launch with the churn probe** (add `$env:ACDREAM_PROBE_PORTAL_CHURN = "1"` to a copy of `launch-flap-capture.ps1`; keep `ACDREAM_PROBE_PVINPUT=1` for correlation). `dotnet build` green first, then launch in background, tee to `flap-churn.log`. +- [ ] **Step 2: User reproduces** — stand at the cottage doorway, turn the camera back and forth (the flap). ~15 s. +- [ ] **Step 3: Analyze** `flap-churn.log`: for the flap frames (correlate with `[pv-input]` flood flips), inspect `[portal-churn]`: which cells hit high `maxPop` (churn → near 16), and the `recip[...]` pre→post counts — is a redundant reciprocal back-contribution staying **non-empty** (`pre->post` both >0 on a cell that already contributed)? That is the predicted divergence. +- [ ] **Step 4: Pin + write the Phase 2 fix plan.** Record the pinned divergence (the exact cell/portal/condition where the redundant contribution stays non-empty) in a short findings note, and write `docs/superpowers/plans/2026-06-08-portal-flood-bound-fix.md` (Phase 2) with the exact, evidence-grounded code change. **Do not write Phase 2 code before this pin.** + +--- + +## PHASE 2 — Port the bound (outline; finalized from Phase 1's pin) + +**Shape (locked; exact predicate from Task 4):** make redundant reciprocal / re-clip contributions **not** generate a new propagatable slice — matching retail's empty-reciprocal (`OtherPortalClip`→no `copy_view`) + monotonic `update_count` watermark — then **remove `MaxReprocessPerCell` + `popCounts`** (termination becomes structural). Keep re-processing on growth (the `AddRegion` union + the deferred re-process), so `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN. Most-likely concrete form (confirm in Task 4): gate the `grew → re-enqueue` so a back-contribution whose clipped region is already covered by the cell's accumulated view (retail: empty after reciprocal clip) does not count as growth — via the faithful reciprocal/watermark, NOT an epsilon dedup heuristic. + +**Tests:** the new eye-sweep stability test (membership a single contiguous run as the eye sweeps — synthetic grazing topology if reproducible, else the live `[pv-input]` gate); all existing `PortalVisibilityBuilderTests` green incl. `Build_ViewGrowthAfterDoneCell_*`, `Build_IsDeterministic_*`, `Builder_CyclicGraph/Hub` termination; the 4 physics rest-stability guards green. + +**Acceptance (visual gate — the real one):** at the cottage doorway, turn the camera back and forth and walk through — interior rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]` `maxPop` ≤ small constant (no near-16 churn). Then strip the apparatus (`[portal-churn]`, `[pv-input]`, the launch scripts). + +--- + +## Self-Review notes +- Spec coverage: Phase 1 implements the spec's "instrument + pin first" requirement; Phase 2 implements the bounded-propagation fix. `Build_ViewGrowthAfterDoneCell` explicitly kept green (spec correction). No rooting/camera/clip/seal change. +- Phase 2 is intentionally an outline: its exact predicate is the runtime fact Phase 1 pins (the spec + this plan both flag this). Phase 2 gets its own no-placeholder plan after Task 4 — this is the apparatus-first discipline, not a deferred placeholder.