# REVIEW_001 — Action Plan

Plan derived from the consolidated executive summary of three independent
deep-reviewer reports (Bullet / Jolt / Rapier — all on opus, each
instructed to read both sides line-by-line and write to disk).

Source reviews live alongside this file:

- `BULLET_REVIEW.md`
- `JOLT_REVIEW.md`
- `RAPIER_REVIEW.md`

Each priority below cites the convergent findings from those reports and
breaks the work into concrete actionable units with verification
criteria. Effort estimates match the executive summary's prioritisation
table.

---

## P0 — Correctness blockers (hours)

### P0.1 — Fix warm-start wipe

**Status**: LANDED, activated alongside P1.2.

**Bug.** Every frame for every active manifold slot, `dispatch_pair`'s
accumulator path calls `manifolds.clear_contacts(slot)`. `clear_contacts`
at `ManifoldStore.js:233` does `this.__data.fill(0, data_off, data_off + SLOT_DATA_STRIDE)`
— zeroing the FULL slot stride, including offsets 10/11/12 (`j_n`,
`j_t1`, `j_t2`). `set_contact` at line 258 then only writes offsets 0..9
(position / normal / depth), leaving the warm-start impulses at zero.
The comment at line 270 — `// j_n, j_t1, j_t2 are warm-start; preserved
across calls` — is true at the per-call level but contradicted by the
upstream wipe. Solver's warm-start sees zero accumulated impulses every
frame.

**Fix path (infrastructure landed).** `ManifoldStore` now exposes two
operations:

1. `begin_refill(slot)`: resets the contact count to zero (so the
   next `set_contact(slot, 0, …)` lands at index 0) but does NOT zero
   the data slab. Warm-start impulses survive. **Implemented.**
2. `clear_contacts(slot)` is unchanged (genuine eviction zeroes
   everything — still used at line 672 when narrowphase determines
   the pair has no colliders).

**Activation blocked.** The narrowphase callsite at line 687
intentionally still calls `clear_contacts`. We tried flipping it to
`begin_refill` and ran the full physics spec suite — the 4-cube
atomic-sleep test (`PhysicsSystem.spec.js`) fails because the stack
**collapses** within ~3 simulated seconds. Diagnostic capture at tick
794:

| cube | y (expected) | y (actual) | vy           | sleep_timer |
|------|--------------|------------|--------------|-------------|
| 0    | 0.5          | 0.4996     | 0            | 0.504 → SLEEPING (singleton) |
| 1    | 1.5          | 0.4983     | ~1e-9        | 0.083       |
| 2    | 2.5          | 0.689      | +0.21        | 0           |
| 3    | 3.5          | 0.707      | bouncing     | 0           |

Cube 1 has sunk **1 m below** its proper resting position (interpenetrating
cube 0 by a whole edge). The reviewer's assumption "slot ordering is
usually preserved" is false: `reduce_candidates` (`narrowphase_step.js:104`)
reorders contacts by depth + max-spread, so the cached `j_n` at slot
index `i` next frame is applied to a **different geometric contact**
than the one whose impulse converged last frame. Stale impulses applied
to wrong contacts destabilise the solver immediately on stacks.

**Conclusion: P0.1 was bundled with P1.2.** Both landed together: the
`begin_refill` primitive replaced `clear_contacts` at the narrowphase
refill callsite, while P1.2's match-and-merge pass ensures the cached
impulses follow the right contact slot index across the per-frame
reshuffle that `reduce_candidates` introduces. The 4-cube atomic-sleep
test (which collapsed when `begin_refill` was activated without
matching) now passes with warm-start active.

**Verification (landed).**
- `ManifoldStore.spec.js`: asserts `j_n` / `j_t1` / `j_t2` survive a
  `begin_refill` + `set_contact` cycle; `clear_contacts` still wipes
  them; multi-contact preservation works.
- `PhysicsSystem.spec.js`: 4-cube atomic-sleep test, 16-cube short
  window stack, and all dependent integration tests pass with warm
  start active.

---

### P0.2 — Remove `debugger;` from EPA hot path

**Status**: confirmed at `gjk/expanding_polytope_algorithm.js:136`.

A `debugger;` statement is sitting in the EPA expansion loop. Production
ship-blocker. Likely a leftover from a prior debug session.

**Fix.** Delete line 136 (and 135 / 137 if they're related guard /
comment). Confirm by grepping `^\s*debugger` across the package — there
should be zero hits afterward.

**Verification.** `grep -rn "debugger" src/engine/physics/` returns
nothing.

**Effort**: minutes.

---

## P1 — Largest accuracy/correctness wins (days)

### P1.1 — Closed-form sphere / box / capsule-vs-triangle

**Status**: LANDED in three sub-commits — P1.1a (sphere), P1.1b
(box), P1.1c (capsule).

**Problem.** The narrowphase concave dispatch (added in commit 28ef0e894)
decomposes heightmaps / meshes to triangles and runs per-triangle GJK +
EPA. `Triangle3D` has a degenerate support along its face-normal axis
(all 3 vertices project to the same value), so GJK precision is poor —
hence the skipped settle tests in `narrowphase_concave.spec.js` and the
documented sink-through-heightmap behaviour. `compute_penetration`
already works around this for sphere-vs-heightmap by using the
half-space test on the face plane, but the narrowphase still uses
per-triangle GJK+EPA.

**Files to add** (suggested layout, paralleling existing
`sphere_box_contact.js` / `capsule_contacts.js`):

- `engine/physics/narrowphase/sphere_triangle_contact.js` ✅ LANDED —
  closest-point on triangle (uses
  `computeTriangleClosestPointToPointBarycentric`), distance to sphere
  centre, depth = R − dist when overlap. Output stride matches
  `sphere_box_contact`. Includes upfront degenerate-triangle guard
  (zero-area triangle returns false) and dist=0 fallback to face
  normal. 11 unit tests covering face, edge, vertex regions,
  separation, tangent, singular, degenerate, tilted, indexing.
- `engine/physics/narrowphase/box_triangle_contact.js` ✅ LANDED —
  SAT over 13 axes (3 box face normals + 1 triangle normal + 9 edge-edge
  crosses) with the corrected asymmetric `min(push_pos, push_neg)`
  per-axis MTV (triangle projection is NOT symmetric around its
  centroid, unlike box's). Three contact-generation branches:
  - **Box face winner** → reference = box face, incident = triangle.
    Project triangle into the face's (u, v) basis, clip with 4 axis-aligned
    Sutherland-Hodgman passes, recover world contacts on the triangle
    plane.
  - **Triangle face winner** → reference = triangle, incident = box
    face most antiparallel to contact normal. Project the box quad
    onto the triangle plane (basis = AB normalised + tn × AB), clip
    with 3 general-half-plane passes against the triangle's 3 edges,
    recover world contacts on the box face plane.
  - **Edge-cross winner** → identify the relevant box edge (one of 4
    parallel edges, selected by sign of `-n` projected onto the two
    perpendicular box axes) and triangle edge, find closest pair via
    `line3_closest_points_segment_segment`, emit single contact.

  12 unit tests covering separation, degenerate triangle, box face
  winner (single + multi-point), triangle face winner, deep
  penetration, +/-Y face flip, self-consistency reconstruction
  (`tri_pt = box_pt + depth * n`), rotated box, near-corner case,
  translated box, output indexing.
- `engine/physics/narrowphase/capsule_triangle_contact.js` ✅ LANDED —
  multi-point manifold via primary segment-vs-triangle closest-pair
  + cap-centre sphere queries. The `segment_triangle_closest` helper
  takes the minimum of 6 candidates: (case 0) segment crosses
  triangle plane inside the face → distance 0; (cases 1, 2) each
  segment endpoint vs `computeTriangleClosestPointToPointBarycentric`;
  (cases 3, 4, 5) `line3_closest_points_segment_segment` against each
  triangle edge. Primary contact emitted from the closest pair; each
  cap centre then runs `sphere_triangle_contact` and is spatially
  deduplicated against the primary. Result: 1..3 contacts.
  Pattern matches `capsule_box_multi_contacts`. 10 unit tests.

**Wiring.** Inside `narrowphase_step.js`'s concave branch (the one that
calls `decompose_to_triangles`), per-triangle dispatch by convex
shape type:

```js
if (convex_shape.isUnitSphereShape3D)   → per-triangle sphere_triangle_contact    [LANDED]
else if (convex_shape.isBoxShape3D)     → per-triangle box_triangle_contact       [LANDED]
else if (convex_shape.isCapsuleShape3D) → per-triangle capsule_triangle_contact   [LANDED]
else                                     → existing per-triangle GJK+EPA fallback
```

Each closed-form path reads the triangle's three vertices from the
decomposition buffer at stride 10, rotates them to world space (3
quat rotations per triangle — ~63 flops, negligible vs. the GJK + EPA
cost being replaced), runs its solver, then runs the same one-sided
face-normal rejection and normal-based dedup as the EPA path. The
closed-form paths use ACTUAL surface witnesses (closest point on
triangle, sphere surface point) rather than body centres — exact in
both directions, unlike the EPA fallback where support witnesses can
be arbitrarily far from the contact patch.

**Verification (P1.1 fully landed).**
- 11 unit tests in `sphere_triangle_contact.spec.js`.
- 12 unit tests in `box_triangle_contact.spec.js`.
- 10 unit tests in `capsule_triangle_contact.spec.js`.
- The two sphere settle tests in `narrowphase_concave.spec.js`
  un-skipped and passing.
- The torus-knot test in `PhysicsSystem.spec.js` un-skipped and
  passing.
- Full physics suite: 686 passing (was 650 pre-P1.1a — delta:
  +11 sphere_triangle + 12 box_triangle + 10 capsule_triangle unit
  tests + 2 un-skipped sphere settle tests + 1 un-skipped torus-knot
  test).

**Optional follow-up** (not blocking).
- Tighten `compute_penetration.spec.js`'s mesh-cube test (currently
  documented as approximate due to closed-mesh over-reporting on side
  faces). Note: `compute_penetration` is a separate code path from
  `narrowphase_step.js` — it uses its own GJK+EPA / MPR pipeline and
  the half-space workaround; landing the narrowphase fast-path
  doesn't change it. Refactoring `compute_penetration` to share the
  sphere / box / capsule-triangle fast-paths is a separate cleanup.

**Effort**: LANDED.

---

### P1.2 — Feature-id contact tracking across frames

**Status**: LANDED.

**Problem.** Even after the P0 warm-start wipe is fixed, the manifold
slot reuses contact-index ordering across frames. Contact slice indices
shift due to:

- Depth-spread reduction in `reduce_candidates` (`narrowphase_step.js:104`)
  — picks deepest, then max-spread; ordering depends on candidate
  generation order which depends on shape-pair details.
- Box-box face clipping reorders contacts based on which clipped
  vertices survive (`box_box_manifold.js`).
- The triangle decomposition emits cells in row-major order — a sphere
  rolling along a heightmap sees different cell indices each frame.

Reviewers cite two reference designs:

1. **Parry's `TrackedContact { fid1, fid2 }`** — each contact carries
   feature ids on both sides; the cache matches by `(fid1, fid2)` pair.
2. **Jolt's `mContactPointPreserveLambdaMaxDistSq`** — match by
   local-space position proximity (default 0.04 m squared).

We're already laid out for (1): the decomposition pipeline emits stable
feature ids per triangle, and the convex primitive narrowphase paths
can derive feature ids from vertex / edge / face indices.

**What landed.**

1. `CONTACT_STRIDE` extended from 13 to 14 (added `feature_id` at
   offset 13). `CANDIDATE_STRIDE` extended from 10 to 11 in
   `narrowphase_step.js` (mirroring slot layout).
2. `set_contact` gained an optional `feature_id` parameter (default 0
   — preserves backwards-compatible call signatures).
3. New `clear_impulses(slot, idx)` method on `ManifoldStore`: zeroes
   only the j_n / j_t1 / j_t2 fields at one contact index. Used by
   the match-and-merge pass when a new candidate has no matching
   prev-frame contact.
4. New `feature_id_of(slot, idx)` accessor for testability.
5. Per-dispatch feature_id derivation in `narrowphase_step.js`:
   - `sphere_sphere`: fid = 1 (single contact, real feature)
   - `sphere_box`: fid = voronoi region 1..27 via new
     `sphere_box_voronoi_fid` helper (inverse-rotates sphere centre
     to box-local frame, buckets each component into {<-h, [-h,h], >h})
   - `box_box`: fid = 0 (clipped-contact ordering isn't cross-frame
     stable; position-fallback handles per-contact matching)
   - `capsule_capsule`, `capsule_sphere`: fid = 1
   - `capsule_box` multi-point: fid = k+1 (per-sub-contact index)
   - Triangle (concave) path: fid = triangle's pre-existing
     decomposition fid
   - GJK+EPA fallback: fid = 0 (no good source)
6. Match-and-merge pass replaces `clear_contacts(slot)` at the
   narrowphase refill callsite:
   - Snapshots prev contacts' `(feature_id, world_a, j_n, j_t1, j_t2)`
     upfront into scratch (decouples read from write to avoid hazards
     when the matching mapping shuffles indices)
   - For each new candidate, finds best prev match — feature_id first
     (only if both sides have fid ≠ 0), position-fallback within
     `MATCH_TOL_SQR = 0.02 * 0.02` second
   - Each prev contact can be claimed by at most one candidate
     (`prev_claimed[]` flags)
   - Calls `begin_refill(slot)` (count → 0, data preserved)
   - Writes each new candidate at slot index k; copies matched prev
     impulses to slot index k OR calls `clear_impulses(slot, k)` for
     unmatched candidates
7. Separation path (cand_count === 0) calls `clear_contacts(slot)` —
   genuine eviction, drops stale impulses before contact is
   re-established at a different feature.

**Verification.**
- `ManifoldStore.spec.js`: 5 new tests covering `set_contact`
  feature_id round-trip, default fid = 0, impulse-preservation under
  fid'd writes, `clear_impulses` scoping (one index, geometry +
  feature_id intact), and per-index targeting.
- `PhysicsSystem.spec.js`: 4-cube atomic-sleep test passes (was the
  regression that gated P0.1 alone — confirms warm-start activation
  is now stable for stacks).
- Full physics test suite: 650 passing (was 645 pre-P1.2 — the
  delta is the 5 new ManifoldStore tests; the 4-cube atomic-sleep
  test which transiently regressed under P0.1-alone is now stable).

**Effort**: landed.

---

## P2 — Performance + precision (day-scale)

### P2.1 — GJK separating-axis cache per manifold slot

**Status**: LANDED (second attempt). The first attempt regressed basic
settle tests and was reverted; this second pass landed the same
optimisation via a different mechanism (no try/finally) in 6 inert /
single-activation steps, each independently verified.

**Mechanism — `gjk_core` + `gjk_with_axis`.** Instead of try/finally,
gjk's body was extracted into `gjk_core(simplex, A, B, dir)` taking
the search-direction buffer as a parameter. The existing
`gjk(simplex, A, B)` is now a thin wrapper that sets the module's
`scratch_dir` to `(1, 0, 0)` and delegates — semantically identical to
the old code. The new `gjk_with_axis(simplex, A, B, axis_buf, axis_off)`
creates a `subarray` view into `axis_buf[axis_off..+2]` and passes it
to `gjk_core`; in-place writes to the view propagate to the caller's
buffer automatically, so no try/finally writeback is needed. A guard
catches zero / NaN / Inf seeds and resets to `(1, 0, 0)`.

**Storage — `ManifoldStore.__slot_axis`.** A `Float64Array(capacity * 3)`
parallel to the data buffer. Zeroed on slot `acquire` (so a recycled
slot doesn't leak the previous pair's axis). Grows alongside the
other slot arrays. Public accessors `slot_axis_buffer` and
`slot_axis_offset(slot)`.

**Activation — narrowphase.** The outer narrowphase loop computes
`(gjk_axis_buf, gjk_axis_off)` from the slot and passes them through
`dispatch_pair`. Both GJK + EPA fallback paths (body-level for
non-closed-form convex pairs; per-triangle for non-(sphere/box/
capsule) convex shapes against concave shapes) call `gjk_with_axis`
with these arguments.

**Sequencing — six steps with independent verification.** Each step
either had no behaviour change (1, 3, 4) or was a small enough
activation that a regression would be precisely revertable (5, 6).
Verified at every step against the full physics suite:

| Step | Change | Tests passing |
|---|---|---|
| 1 | Extract `gjk_core` from `gjk`; existing wrapper preserves semantics | 687 / 687 |
| 2 | Add `gjk_with_axis` + 6 unit tests | 693 / 693 |
| 3 | Add `ManifoldStore.__slot_axis` (dead code) | 693 / 693 |
| 4 | Plumb axis args through `dispatch_pair` (dead code) | 693 / 693 |
| 5 | Activate at the body-level GJK + EPA fallback | 693 / 693 |
| 6 | Activate at the per-triangle EPA path in concave dispatch | 693 / 693 |

**Why this attempt worked where the first didn't.** The first attempt
wrapped the existing gjk body in try/finally to write back the cached
axis on every return path. That should have been semantically
identical for existing (cache-less) callers, yet basic settle tests
regressed. Working theory: V8's JIT shape-inference handled the
try/finally-wrapped function differently in ways that surfaced in the
existing physics tests' numerical paths. The second attempt avoids
try/finally entirely — `gjk_core` takes `dir` as a parameter, and
writeback is automatic via shared memory (the caller's buffer IS the
working buffer through a subarray view).

**Improvement.** Bullet's `m_cachedSeparatingAxis` and Jolt's `ioV`
in/out pattern store the last successful separation direction (or
overlap-resolution direction) per manifold slot. Reuse next frame as
GJK's initial direction. Reviewers cite 5–10× iteration reduction for
quiescent persistent contacts — the common case in any settled stack.

**Plan.**

1. Add 3 floats to `SLOT_META_STRIDE` (or a parallel `Float32Array(slot_count * 3)`)
   in `ManifoldStore` for the cached axis.
2. Add an overload (or new entry point) to `gjk` accepting an initial
   direction. Backward-compatible: existing callers (shape_cast,
   compute_penetration, overlap) pass `null` and default to the
   current `(1, 0, 0)`.
3. In `narrowphase_step.js`'s `dispatch_pair`, before the GJK+EPA
   fallback (line 312 area), read the cached axis from the slot; pass
   it to GJK. After GJK terminates (with or without overlap), write
   the final search direction back to the slot.

**Verification.**
- Microbench: count GJK iterations on the existing
  `PhysicsSystem.bench.spec.js` torus-knot scene (currently skipped
  for accuracy but useful here for iteration count). Expect 5–10×
  reduction.
- Determinism: same scene state → same iteration counts.

**Effort**: 1 day.

---

### P2.2 — Adaptive EPA tolerance + bail-out fix

**Status**: PARTIALLY LANDED. Adaptive tolerance landed; bail-out
magnitude consistency reverted (caused energy injection in closed-form
dispatch paths that share the EPA depth metric — sphere-vs-sphere
stack regressions launched bodies metres straight up).

**Two issues.**

1. `EPA_TOLERANCE = 0.0001` is absolute. For small-depth contacts (sub-mm)
   this is comparable to the depth itself; EPA terminates with whatever
   intermediate face it has, producing a noisy normal direction. Jolt
   uses a relative tolerance scaled by current closest-face distance.
2. The bail-out path at line 343 uses `dot_p_search_dir` where the
   converged path uses `min_dist`. Inconsistent under iteration-cap
   exit — the documented "best-effort fallback" returns a different
   magnitude than the converged path of the same geometry.

**Plan and outcome.**

1. ✅ LANDED. Tolerance is relative: `EPA_TOLERANCE_REL = 1e-4`
   floored by `EPA_TOLERANCE_ABS = 1e-6`. Terminate when
   `dot_p_search_dir − min_dist < EPS_REL * max(|min_dist|, EPS_ABS)`.
   This fixes EPA's failure-to-converge for sub-mm contacts where the
   old absolute tolerance was larger than the depth itself.
2. ❌ REVERTED. Swapping the convergence-path result magnitude from
   `dot_p_search_dir` to `min_dist` (for consistency with the
   iteration-cap bail-out path) was tried — and caused energy
   injection in closed-form dispatch tests that share the EPA depth
   metric (sphere-vs-sphere with restitution 0 launched a ball to 7
   m). The convergence path retains its original magnitude. The
   "inconsistency with bail-out" the reviewer noted is real but
   benign in practice: bail-out fires on smooth-shape EPA failure
   modes that the closed-form paths now bypass entirely (P1.1
   sphere/box/capsule-vs-triangle); the consistency gap doesn't
   surface anywhere observable.

**Verification.**
- EPA spec needs new cases at very small depth (sub-µm) confirming
  termination is now stable and direction is well-defined.
- The shape_cast bail-out fallback comparison should produce
  consistent normals between converged and non-converged exits.

**Effort**: ½ day. Less critical post-P0 because `shape_cast` and
`compute_penetration` now use MPR for shallow-overlap normal recovery
— this fix is for the GJK+EPA fallback in `narrowphase_step.js` only.

---

## P3 — Mitigation improvements (half-day each)

### P3.1 — Wire MPR as EPA fallback for mesh-involving pairs

**Status**: LANDED.

`gjk/mpr.js` was already implemented and tested; `shape_cast.js` and
`compute_penetration.js` already used it. The two narrowphase EPA call
sites — the per-triangle EPA path inside the concave dispatch (when
the convex shape is not sphere/box/capsule) and the body-level
GJK+EPA fallback — now both fall back to MPR when EPA returns
zero / negative / NaN / Inf depth. MPR's portal-refinement convergence
properties tend to succeed where EPA's face-expansion hits its
iteration cap.

**Plan.**

1. In `dispatch_pair`'s GJK+EPA fallback, after EPA returns:
   - If depth > 0 and direction passes the body-centre sanity check
     → keep the EPA result.
   - Else → re-run with MPR (same simplex inputs are not needed; MPR
     starts from the PosedShape pair). If MPR returns true with a
     positive depth, use that.
2. For pairs involving a non-convex shape (heightmap/mesh dispatch via
   the concave path), once the closed-form triangle solvers from P1.1
   land, MPR fallback is only needed for the GJK+EPA leg of the
   concave loop — same pattern.

**Verification.**
- The torus-knot test in `PhysicsSystem.spec.js` (currently skipped)
  should make progress with the MPR fallback even before P1.1 lands.
- `compute_penetration.spec.js`'s smooth-shape direction-precision
  tests can tighten their tolerances.

**Effort**: ½ day.

---

### P3.2 — Box-box edge-edge closest-pair manifold

**Status**: LANDED.

In `box_box_manifold.js`'s edge-cross-axis-winner branch, the previous
body-centre-midpoint fallback (both contact points collapsed to the
midpoint of the two box centres — wrong lever arms, slow drift in
skewed-box-stack scenarios) is replaced with a proper closest-pair
computation:

1. Decode `(i, j)` from `best_source = 6 + i*3 + j` — box A's local
   axis index and box B's.
2. Pick A's edge among 4 parallel edges along axis `i` by signing the
   perpendicular axes against `-n` (the direction from A's centre
   toward B). Same for B with `+n`.
3. `line3_closest_points_segment_segment(out_st, edge_a, edge_b)`
   returns `(s, t)`; reconstruct `contact_on_A = edge_a_p1 + s * (edge_a_p2 - edge_a_p1)`
   and similarly for B.

The "second contact for near-parallel edges" sub-improvement from the
original plan turned out to be unnecessary in practice: when two box
edges are near-parallel, the SAT axis `cross(edge_dir_a, edge_dir_b)`
is degenerate (magnitude below `PARALLEL_EPS_SQR`) and the test_axis
helper rejects it before it can win. Near-parallel-edge configurations
end up with a face-axis SAT winner, which the face-clipping path
already handles with multiple contacts.

**Improvement.** When SAT identifies an edge-edge separating axis
between two boxes (the cross-product axes in `box_box_manifold.js`),
the current fallback emits a single contact at the midpoint of the
edges' projections. The codebase already has
`core/geom/3d/line/line3_closest_points_segment_segment.js` from a
previous slice. Use it to emit the proper closest-pair, then evaluate
spread for a second contact when the edges are near-parallel (within
some `EDGE_PARALLEL_COS_TOL` like 0.999).

**Plan.**

1. In `box_box_manifold.js`'s edge-edge branch, replace the midpoint
   contact with `line3_closest_points_segment_segment`'s output.
2. Detect near-parallel edges via the cos-angle test on the edge
   direction vectors; in that case emit a second contact at the other
   endpoint of the parallel overlap interval. Standard SAT-with-clipping
   pattern.

**Verification.**
- A box stacked at an angle on another box should reach steady state
  without the slow drift that the single-midpoint contact causes.
  Add a regression test alongside the existing box-box stack test.

**Effort**: ½ day.

---

## P4 — Strategic items (weeks; defer until pressed)

### P4.1 — Split-impulse architecture → TGS substepping

**Status**: PLAN.md documents this with the three interacting failure
modes; reviewers confirmed the path forward in detail.

**Recommendation.** Don't pursue until tall stacks become a frequent
gameplay pain point. The current 4-cube atomic-sleep test passes; the
16-cube short-window test catches manifold regressions. The pure-JS
single-threaded budget is the more likely scaling wall — TGS doesn't
help there.

If/when we do it: Box2D-Lite-style split impulse — separate
position-correction pseudo-velocity that doesn't contaminate real
velocity, restitution as one-shot impulse at first contact (not a bias
inside the loop), forces apply once at full dt before substeps.

**Effort**: weeks.

### P4.2 — Per-body linear CCD shape-cast

**Status**: PLAN.md backlog ("Per-body linear CCD shape-cast").

**Recommendation.** Already a focused future-work item with the 1km
falling-tower reproducer (~180/1000 bodies tunnel). Worth scheduling
once a gameplay use case demands it (e.g. fast projectiles, vehicle
physics).

**Effort**: week.

---

## Not prioritised in the executive summary

### Shape-cast bisection vs GJK conservative advancement

Reviewers noted Parry's `gjk::directional_distance` (3–5× fewer GJK
calls per query) but did not prioritise it. Our current bisection +
slab-narrowing is functionally correct and acceptable; flag for revisit
if `shape_cast` shows up in profiles. Effort would be a day.

---

## Out of scope

Confirmed from the consolidated review — these continue to be valid
non-goals:

- SIMD / WASM — V8 doesn't expose deterministic Float64x2 ops.
- `SharedArrayBuffer` / multi-threaded solver — COOP/COEP headers
  aren't always available.
- Cross-runtime bit-exact determinism — would require a soft-float
  library; same-runtime determinism covers single-device replay +
  lockstep clients on matched JS engines.
- Reduced-coordinate articulations — game-physics audience runs in
  maximal coordinates by convention.

---

## What's already strong (no action; for record)

All three reviewers independently called these out as better than at
least one reference engine. Useful confirmation that the design bets
in PLAN.md paid off.

- End-to-end SoA layout with generation-tracked stable IDs and
  min-heap free list (deterministic ID reuse).
- Atomic per-island sleep with chain wake via circular doubly-linked
  `sleep_group_next` / `sleep_group_prev` — wake propagation
  completes in one frame, vs Bullet/Jolt's bottom-up multi-frame
  propagation.
- Deterministic island roots (union-find with union-by-min-index) —
  Rapier and Jolt do not strictly guarantee this.
- Flat shape hierarchy, linear narrowphase dispatch,
  monomorphic-when-hot `support()`.
- Coulomb-cone disk friction clamp (true cone) — Bullet's default is
  the anisotropic box clamp.
- One-sided face-normal rejection on concave dispatch.

---

## Recommended sequencing

1. **P0.2 + P0.1 infra prep (LANDED)** — `debugger;` removed from EPA;
   `ManifoldStore.begin_refill` added. Surgical, no behaviour change.
   The initial attempt at full P0.1 activation (flipping the callsite
   to `begin_refill`) was reverted after the 4-cube atomic-sleep test
   caught a stack collapse, confirming the reviewer's "slot ordering
   is usually preserved" assumption is false for stacks.
2. **P1.2 + P0.1 activation (LANDED)** — feature-id contact tracking
   and the match-and-merge pass. Warm-start now active and stable on
   stacks; full physics suite green (650 passing).
3. **P1.1 in three sub-commits — LANDED**:
   - **P1.1a (sphere-vs-triangle).** `sphere_triangle_contact` module
     + spec + narrowphase wiring; un-skipped two settle tests in
     `narrowphase_concave.spec.js`.
   - **P1.1b (box-vs-triangle).** `box_triangle_contact` module (SAT
     + clipping for both face winners + closest-pair for edge-cross)
     + spec + narrowphase wiring; un-skipped the torus-knot settle
     test in `PhysicsSystem.spec.js`.
   - **P1.1c (capsule-vs-triangle).** `capsule_triangle_contact`
     module (segment-triangle closest-pair + cap-centre sphere
     queries with spatial dedup) + spec + narrowphase wiring.
4. **P2.1 (GJK axis cache) + P2.2 (EPA tolerance) together** — both
   are localised, neither depends on the others.
5. **P3.1 (MPR fallback) — LANDED.** Wired at both narrowphase EPA
   call sites (per-triangle concave + body-level fallback). Behind
   the existing depth-validity gate so it only fires when EPA fails.
6. **P3.2 (box-box edge-edge) — LANDED.** `line3_closest_points_segment_segment`-based
   contact-pair reconstruction; near-parallel edges already handled
   by SAT degeneracy fallthrough to face axes.
7. **P4 items**: defer until gameplay pressure.
