acdream/docs/research/2026-08-02-collision-throughput-handoff/design-note.md
Erik 71604331cf wip(physics): collision O(changed) delta-commit (O1-O3) - ON HOLD, feel-test failed
Publication-throughput rework per the D2 design (docs/research/
2026-08-02-collision-throughput-handoff/design-note.md): O1 per-prefix
installed-key ledgers replacing the seal's full-map scans; O2 per-
landblock delta commit (LandblockReplacementApplyCursor against the
active root) replacing whole-world TransferTo; O3 empty staging root,
commit-time reflood (CObjCell::init_objects 0x0052B420 ->
recalc_cross_cells 0x00515A30), journal/peer-rebase machinery deleted
(~1,900 lines net).

Automated gates green: Runtime 999, Core physics 2,135, App 4,039/3,
Headless 79, complete solution 10,812/0/4; lifecycle gate PASS
(connected-world-gate-20260802-193029). Soak 194423: publication-side
acceptance fully met (37 -> 4 failures, all convergence dims zero,
loadedLandblocks baseline-identical, waitCue 6/9 -> 1/9).

COMMITTED AS WIP ON USER DIRECTION - NOT ACCEPTED. The user feel-test
FAILED on this tree: monsters still pop into existence at close range,
monsters spawned mid-air far ahead, static placements visibly wrong,
plus 243x "Landblock already has a full retirement receipt"
InvalidOperationException catch-retry loop during origin recenter
(launch-feeltest-oclone.log). The 4 remaining soak failures
(pendingLandblockRetirements 131/122 at the Caul->Sawato stops) and the
implementer's "exposed pre-existing" classification are under
re-judgment against that loop. Dual reviews were dispatched and then
stopped mid-flight on user direction; NO review has passed this commit.
Full problem inventory + next-agent instructions:
docs/research/2026-08-02-collision-throughput-handoff/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:06:59 +02:00

25 KiB
Raw Permalink Blame History

O(changed) collision clone — design note

Phase: research + design only. No production edits, nothing staged, no probes left behind. Worktree C:\Users\erikn\.codex\worktrees\af5e\acdream, branch codex/port-claude-agents, HEAD c52ce14a.

Problem: the collision-generation staging clone is O(resident world) per landblock publication, so loading an N-landblock ring costs O(N²). The far ring never converges. C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit pop-in, failing nine-stop soak) but did not cause it.


(a) What the one-leaf-per-step invariant actually protects

It is a frame-time bound. Nothing else.

The whole-world copy did not arrive with 6b28ff99. It arrived one commit earlier, in be94bc9b "fix(physics): activate collision generations atomically" (2026-07-31), as a synchronous copy performed in a single call at admission:

// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
    staging._landblocks[id] = landblock;
staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache);

6b28ff99 "make collision activation starvation-free" replaced that with CollisionStagingBuilder (src/AcDream.Core/Physics/PhysicsEngine.cs:785-941), which performs the same copy chopped into single leaves across frames. The retired AD-6 row states the purpose verbatim (docs/architecture/retail-divergence-register.md:113):

"Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike."

The committed test says the same thing three ways (tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991):

Assertion line What it pins
Assert.InRange(admissionAllocation, 1L, 128L*1024L) :973 admission allocates a constant
Assert.Equal(0, prepared.Engine.LandblockCount) :974 admission copies no resident landblock
Assert.InRange(step.WorkUnits, 0, 1) :983 the copy is chopped to one leaf per host step
Assert.True(advances > residentLandblocks) :988 it really walked the resident world
Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount) :989 the draft ends up holding the whole world

So the invariant protects hitch avoidance: a dense resident world must not produce one long synchronous copy inside a single update step. It is a scheduling property asserted as a mechanism, which is why the batching lever tripped it.

What it does NOT protect

  • Not concurrent-reader isolation. That is CollisionWorldStateSlot.TransferTo's single Volatile.Write (src/AcDream.Core/Physics/CollisionWorldState.cs:66-84). And a full threading audit of every writer and reader of CollisionWorldState found no concurrent reader or writer exists: GameWindow runs one Silk.NET loop thread; UpdateFrameOrchestrator.Tick runs _streaming.Tick()DrainAndApply and then the live/physics/camera phases strictly sequentially on that thread; the only background workers (LandblockStreamer worker thread, EnvCellRenderer Parallel.ForEach, ObjectMeshManager Task.Run) never touch PhysicsDataCache / CellGraph / ShadowObjectRegistry / PhysicsEngine — grep of LandblockBuildFactory.cs and LandblockMesh.cs for those types returns zero hits. Every one of BeginCollisionAdmission (:2040), PrepareCollisionGeneration (:2092), AdvanceCollisionGenerationPreparation (:2123), StageCollisionAssets (:2218), AdvanceCollisionGenerationSeal (:2298), CommitCollisionGeneration (:2382), CancelCollisionGeneration (:2139) passes through RuntimePhysicsState.EnsureCollisionMutationThread (:2857-2869). The ConcurrentDictionary choices are load-bearing only for single-threaded mutate-while-enumerating (PhysicsDataCache.cs:958-984, and the seal cursor holding a live enumerator across frames at :1081-1160) — the cross-thread rationale in the CellGraph.cs:17 and PhysicsDataCache.cs:14-20 doc comments is stale after 6b28ff99.
  • Not admission fairness. That is the separate journal/coalescing machinery (RuntimePhysicsState.cs:475-529, research doc step 5) — the other half of what "starvation-free" meant. It is orthogonal to the leaf metering and stays.

What the pre-6b28ff99 mechanism did

be94bc9b's commit was a delta apply, not a root swap:

// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99
DataCache.CommitLandblockReplacement(replacement.DataCache);   // O(changed)
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);

6b28ff99 replaced those three lines with stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld) (PhysicsEngine.cs:304-321). That is the change that made the clone load-bearing. Before it, the clone was a build sandbox; after it, the clone is the world that gets published, so every leaf not cloned is a leaf deleted from the world.

Before be94bc9b the client mutated the active maps in place across many frames — the genuinely non-equivalent state the research doc describes ("the active PhysicsDataCache, CellGraph, PhysicsEngine, buildings, static shadows, and retained-object refloods changed at different cursors", docs/research/2026-07-31-atomic-collision-generation.md:12-16). The atomicity requirement is "the multi-frame build must not be observable", not "the whole world must be swapped". A delta applied inside one synchronous update-thread call satisfies it.

The cost is worse than F4 measured

F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per publication to the staging clone. The seal does the same walk again: the replacement builder holds live enumerators over four _staging maps and four _active maps (PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158) plus two in CellGraph.cs:184, 203, using CapturePrefixOne / CaptureRemovalOne — a full scan of each map to find O(target) keys. Real per-publication cost is therefore roughly 23× resident world, not 1×. Fixing only the clone leaves O(N²) in the seal. Any design that does not also scope the removal capture is not a fix.


(b) Candidate designs

D1 — Structural sharing (persistent/immutable CollisionWorldState)

Replace the ~20 mutable maps with persistent maps (HAMT / ImmutableDictionary) so a staging clone shares unchanged subtrees and copies only the changed path.

  • Blast radius: every read and write site of every map in CollisionWorldState, PhysicsDataCache, CellGraph, ShadowObjectRegistry, PhysicsEngine.
  • Invariant changes: none semantically; the root swap survives unchanged, so the atomicity story is untouched.
  • Throughput: admission O(1), clone O(1), commit O(changed · log N). Excellent on the copy axis.
  • Why rejected: it pays for the copy with the query. A HAMT probe is several times a Dictionary probe and allocates on write; the resolver runs thousands of these per frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation collision (docs/plans/2026-07-25-modern-runtime-slice-i.md; I1 measured 0 B/resolve). D1 optimizes the rare operation at the expense of the hot one and fights the I-series architecture head-on.

D1b — Landblock-sliced root (per-prefix immutable slice + small map)

Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain / outdoor cells / LandblockPhysics live in one immutable LandblockCollisionSlice, and the root becomes Dictionary<prefix, slice> (~625 entries). Commit = one dictionary write per prefix.

  • Blast radius: every keyed read becomes mask + two probes; the seal's removal scans collapse to "old slice vs new slice". The shadow registry does not partitionShadowEntityCells, ShadowEntityShapes, ShadowEntityRegistrations, ShadowOwnerVersions are owner-keyed and owners legitimately span prefixes (that is the whole retained-owner problem), so the shadow half needs a separate mechanism.
  • Invariant changes: the atomic unit becomes the slice; the root swap disappears.
  • Throughput: O(changed) by construction, and atomic even for a hypothetical concurrent reader.
  • Verdict: this is the right answer if concurrent readers existed. They do not. Keep it on the shelf as the migration target should the runtime ever go multi-threaded; do not pay its refactor cost now.

CommitLandblockReplacement drains the already-existing PhysicsEngine.LandblockReplacementApplyCursor (PhysicsEngine.cs:568-777) against the active root inside one synchronous call, instead of TransferTo. The staging root becomes empty-at-admission (target content only); CollisionStagingBuilder phases 08 are deleted.

The delta record already exists and is already tested: PreparedPhysicsDataCacheLandblock (PhysicsDataCache.cs:1255-1270) is exactly lists of key/value pairs to install and lists of ids to remove, all target-scoped. The apply cursor already handles removals, installs, terrain, the 0x40 synthesized outdoor cells, the landblock itself, and yields the reflood owner ids to the caller at phase 12 (PhysicsEngine.cs:702-712). Today it is used to rebase a committed peer delta into a later draft; pointing its destination at the active engine is a constructor argument, not new machinery.

What breaks, honestly:

  1. Readers mid-query — nothing. Single-threaded, evidenced above. A drained cursor inside one call is indivisible with respect to every reader that exists.
  2. Re-entrancy — real, and the audit flagged it: OwnerMutated / OwnerPrefixMembershipChanged (ShadowObjectRegistry.cs:86-87) can fire mid-delta. Precedent already exists: the commit brackets itself with _suppressCollisionOwnerJournal = true (RuntimePhysicsState.cs:2491-2501). Extend that bracket to cover the whole apply.
  3. Cross-frame enumerators — the seal holds live enumerators over the active maps across frames (PhysicsDataCache.cs:1092, 1136, 1158). A delta apply now mutates the maps those enumerators walk. ConcurrentDictionary will not throw, but the observed set is unspecified. O1 below removes those enumerators entirely, which is why O1 must land first.
  4. The retirement machineryLandblockRetirementCursor (PhysicsEngine.cs:348-...) currently retires from an off-side draft. Same cursor, destination becomes the active root, still drained in one call.
  5. The reflood context — the seal currently computes retained-owner refloods against a full staging world. With an empty staging root that context is gone, so the reflood moves to the commit call, against the now-current active world. That is precisely retail: CObjCell::init_objects (0x0052B420) → CPhysicsObj::recalc_cross_cells (0x00515A30), already the retail anchor cited on the AD-6 row.
  6. The peer-rebase / journal apparatus — with no snapshot there is nothing to rebase. EnqueueCommittedRebase (RuntimePhysicsState.cs:563-578, 2502-2507) and most of the journal become dead. Delete them in the same slice; do not leave dead invariants guarding a deleted mechanism.
  • Throughput: per publication ≈ target payload (~70200 leaves at the measured ~184 ns/leaf) + the owners touching the target, versus today's ~23 × 20,000. Roughly 300× less work per publication, and — decisively — independent of resident-world size, so total ring load goes O(N²) → O(N). At the failing run's numbers that is ~13.7 M leaf copies for a 625-landblock ring down to ~44 K.

D3 — Adjacency-scoped clone (the tempting middle ground) — rejected as unsafe

Copy only leaves in the target's 3×3 landblock neighbourhood. One predicate change in CopyOneOutsideTarget (PhysicsEngine.cs:949-961); clone drops ~20,000 → ~630 and becomes O(1) in world size.

Rejected for a structural reason worth stating plainly: while commit is a whole-root transfer, "clone less" means "delete more." Anything not copied into the draft is absent from the root that replaces the world. A partial clone is therefore a silent world-erasure bug, not a perf tuning knob. Only after commit becomes a delta does bounded context become safe — at which point D2 has already removed the need for it. It also leaves the seal's O(world) scans untouched, so O(N²) survives regardless.


(c) Recommendation

Take D2, in three landable slices, with O1 first.

Rationale in one line: the delta-apply commit path is not a new invention — it is the mechanism that shipped in be94bc9b and was deleted by 6b28ff99 to buy an atomicity guarantee against concurrent readers that do not exist; restoring it makes the cost O(changed) by construction and moves the client toward retail's init_objects shape, not away from it.

Invariant-test replacement

Delete from DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep (tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991) the three assertions that pin the clone itself — :983 Assert.InRange(step.WorkUnits, 0, 1), :988 Assert.True(advances > residentLandblocks), :989 Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount). They assert the exact mechanism being removed.

Replace with CollisionPreparationCostIsIndependentOfResidentWorldSize — a strictly stronger invariant, because it pins the property (bounded, world-size-independent work) rather than a mechanism:

Run the full admission → preparation → seal → commit sequence twice,
at residentLandblocks = 32 and residentLandblocks = 256.

Assert total preparation advances(32) == total preparation advances(256)   // O(changed)
Assert total seal WorkUnits(32)      == total seal WorkUnits(256)          // closes the seal scans
Assert every step.WorkUnits <= K                                           // K = retained per-step bound
Assert admissionAllocation in [1, 128 KiB]                                 // kept from :973
Assert prepared.Engine.LandblockCount == 0 after preparation completes     // stronger than :974:
                                                                           // the draft now holds ONLY the target

Add two more:

  • CommitAppliesOneLandblockDeltaInASingleCall — the engine-mutating CommitCollisionGeneration call drains the apply cursor to Completed before it returns; the active world holds no target-prefix content before it and the complete target after it, with no observable intermediate.
  • CommitTimeRefloodMatchesPrecomputedReflood — for a fixed scenario, the owner set and each owner's resulting cross-cell set after a commit-time reflood are equal to what the pre-change staged reflood produced. This is the proof that D2 is a scheduling change and not a semantics change, and it is the test that makes the perf framing in (d) legitimate.

Keep unchanged: every Assert.InRange(seal.WorkUnits, 0, 1) at :558, :667, :747, :848, :1797, :2020, :2318, :2428, :3033 — the seal stays metered; the zero-managed-byte commit assertions (the delta lists are built during seal, so the apply must still be allocation-free); and CommittedPreparationRevokesItsStagingCollisionRoot (:1664) in spirit — the staging root must still be revoked after commit, it simply no longer becomes the active root.

Migration plan

Slice Change Gate
O1 Per-prefix installed-key ledger in CollisionWorldState, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (PhysicsDataCache.cs:1081-1160, CellGraph.cs:184, 203) to enumerate that set. Removes the cross-frame active-map enumerators. Behaviour-identical; a pure win that lands alone. existing suites green + the new seal-independence assertion
O2 PhysicsEngine.CommitLandblockReplacement drains LandblockReplacementApplyCursor against the active root instead of TransferTo. Extend the _suppressCollisionOwnerJournal bracket over the whole apply. Retirement cursor destination → active root. focused Runtime physics suite + connected lifecycle gate
O3 Empty staging root: delete CollisionStagingBuilder phases 08. Move retained-owner reflood into the commit call (retail init_objectsrecalc_cross_cells). Delete the now-dead peer-rebase/journal paths and their tests. full ladder below

Gate ladder (O3 closeout)

  1. Focused: Runtime physics collision-generation suite, App LandblockPhysicsPublisherTests, Headless HeadlessSessionHostTests.

  2. Complete Release solution: baseline to match or beat is 10,808 passed / 0 failed / 4 skips (-m:1, ACDREAM_PAK_PATH).

  3. Connected lifecycle/reconnect gate: signature must hold — Passed=true, Failures=[], both sessions ExitCode=0, zero render-shadow mismatches, zero pending deltas, graceful exits.

  4. Nine-stop soak must reach Passed: true with Failures: []. The failing run is logs/connected-r6-soak-20260802-143157.report.json (37 failures, Passed: false); the passing baseline is logs/connected-r6-soak-20260727-004942.report.json (Passed: true, commit a9a822f2). Concrete acceptance, per checkpoint:

    Key Failing run Required
    resources.streamingWork.deferredCompletions 92501 at 8/9 stops 0 at all 9
    resources.streamingWork.farBacklog same values 0 at all 9
    resources.streamingWork.pendingPublications 1 at 8/9 0 at all 9
    resources.streamingWork.deferredAdoptedCpuBytes 1.58.5 MB 0 at all 9
    resources.streamingWork.oldestDeferredAgeMilliseconds 37,76469,728 0
    resources.loadedLandblocks 124533 625 at the eight outdoor stops
    reveal.waitCueShown true at 6/9 false at all 9 — this is the user-reported "extended/stuck portal space"
    streamingWork.lifetimeFrameOverrunCount 1,706 materially lower
    streamingWork.maximumOperationStage "publication-index-physics" must no longer name this stage

    aerlinthe (sequence 4) is the control, not a target. It is the one indoor destination and the one stop that is already clean in the failing run (374/173 vs the baseline's 374/176, every streaming counter 0) — precisely because an indoor destination streams few landblocks, so O(N²) never bites. It must stay clean; do not expect it to reach 625.

  5. Frame time must not regress — and must recover. Route-level cpuUs from frame-history-summary.json, microseconds:

    p50 p95 p99 p999
    baseline 20260727 9,730 41,262 44,875 63,474
    failing 20260802 17,001 47,434 57,061 102,876

    Gate on the sharper per-checkpoint window numbers: checkpointWindows[].metrics.cpuUs p99 within +10 % of the 20260727 baseline at every stop. The worst offenders are caul-plateau 101,408 → target ≈ 47,821; caul-return 103,309 → ≈ 46,938; caul-baseline 108,148 → ≈ 43,664; sawato-baseline 49,748 → ≈ 10,592. Frame count should recover toward the baseline's 35,492 frames / 498.5 s from the failing run's 25,965 / 583.9 s.

  6. Do NOT gate on these — retest only after convergence. trackedGpuBytes (62 MB failing vs 474 MB baseline), meshRenderData 588 vs 607, meshEstimatedBytes 233.8 MB vs 268.6 MB, and the inverted CPU mesh-cache hit ratio (3,666 hits / 5,689 misses vs 20,825 / 6,845) are all far-ring-never-converged artifacts of the same mechanism. F4 already reached this conclusion; a leak investigation before convergence is restored will chase a ghost.

Register / plan bookkeeping (same commit as the code)

  • docs/architecture/retail-divergence-register.md:113 — the retired AD-6 row describes the deleted mechanism verbatim ("one shared off-side CollisionWorldState", "one zero-managed-byte volatile root transfer", the journal, the peer rebases). A retired row still documents what shipped; leaving it describing a deleted clone is exactly the out-of-sync failure the register rules forbid. Rewrite it to the delta-apply mechanism. The row's retail anchor is already CObjCell::init_objectsrecalc_cross_cells (0x0052b420 / 0x00515a30) — the new mechanism is closer to that anchor, so no new deviation row is created.
  • Judgment call for the implementer, do not assume: the original AD-6 deviation was "Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL recalc_cross_cells" (be94bc9b register diff). If O3's commit-time reflood is again per-landblock rather than per-cell, decide explicitly whether AD-6 must be un-retired or a successor row added, and record the decision. Flagged, not decided here.
  • docs/research/2026-07-31-atomic-collision-generation.md — steps 2, 3, 5, 6, 7, 8 and most of the "Deterministic evidence" list describe the clone / journal / rebase. Rewrite in the same commit.
  • memory/project_collision_port.md — the 37-line block 6b28ff99 added is now wrong.
  • claude-memory/project_physics_collision_digest.md — add two DO-NOT-RETRY entries: (1) "Do not re-introduce a whole-world staging clone. The atomic unit is the landblock delta applied in one update-thread call; the runtime is single-threaded and the root swap buys nothing." (2) "Batching N leaves per staging step is not a fix — measured 1.8× at N=256, not convergence, and it trips the committed invariant test."
  • docs/ISSUES.md — F4 established this regression is not in the C3c diff, so it needs its own issue id (the C3c smoke-test commit c52ce14a filed #279 for a different finding). File it, and reference it from the O1/O2/O3 commit messages.
  • Rollback: each slice lands as one commit with its own recorded git revert SHA, per the Modern Runtime convention.

(d) Perf-work framing

This is modern-runtime infrastructure, not retail-scoped behaviour work. The delta apply installs the identical PreparedPhysicsDataCacheLandblock content that TransferTo publishes today — the same cells, flat cells, EnvCell topology, buildings, terrain, synthesized outdoor cells, landblock, and owner set. Only the path by which that content reaches the active root changes, and only the amount of work done to get there. Collision results, contact planes, walkable polygons, membership, and therefore game feel are bit-identical.

The project's render-perf-not-faithfulness-gated rule (claude-memory/feedback_render_perf_not_faithfulness_gated.md) applies: throughput work that is pixel- and feel-identical does not need a retail-behaviour gate. But because this is collision, the acceptance bar is still the connected gates plus the user's visual pass — green unit tests prove nothing about a streaming convergence bug.

Two guards keep the framing honest:

  1. The direction of travel is toward retail, not away. Retail hydrates a cell synchronously in CObjCell::init_objects and refloods the objects associated with it via recalc_cross_cells. A per-landblock delta applied in one update-thread call is the streaming-shaped version of exactly that. The whole-world clone was the adaptation; removing it retires an adaptation rather than adding one.
  2. The one thing that could change feel is reflood timing — owners near the target re-flooding at commit rather than from a pre-computed staged set. CommitTimeRefloodMatchesPrecomputedReflood (above) is the specific test that turns that from an assumption into evidence. If that test cannot be made to pass, the perf framing is void and the slice needs a behaviour gate.

Explicitly not a workaround. Per the no-workarounds rule, note what this is not: no suppression flag, no grace period, no budget loosening, no early-return guard at the symptom. The root cause is an algorithm that is quadratic in resident-world size, and the fix is to make it linear by restoring the per-landblock atomic unit the mechanism had before 6b28ff99.

Measure before and after

A stripped-after probe should count, per publication: (clone leaves, seal leaves, apply leaves) and wall-clock for each. F4 measured only the clone (median 19,736 / p90 32,135 / max 38,021 leaves, median 3.64 ms, 1,584 preparations = 8.53 s CPU in one 4-minute capped session). The seal was never measured and D2 must beat both. Expected after O3: clone leaves 0, seal leaves ≈ target payload, apply leaves ≈ target payload, total per publication well under 100 µs and flat as the ring fills.