diff --git a/CLAUDE.md b/CLAUDE.md index 1983eaa..ce128a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.5 — Forge 3D integration (semi-implicit Euler) — code-complete, PR #58 open | +| Current milestone | `m1.1.3-hf-epa-frame-dependence` — narrowphase EPA deep-path order-dependence hotfix — code-complete, PR open. M1.1.5 (integration) CLOSED, merged + tagged `v0.11.5-integration-euler`. | | Last released tag | `v0.11.5-integration-euler` (posted by Guy after merge) | -| Active branch | `phase-1/forge/integration-euler` (PR #58 open, not merged) | -| Next planned milestone | The standalone hotfix `m1.1.3-hf-epa-frame-dependence` (scheduled BEFORE M1.1.6 — its first real consumer; see Open decisions), then M1.1.6 — contact solver (Sequential Impulses, warm-start consuming the M1.1.3/4 `feature_id`s), seventh core sub-milestone of the M1.1 rigid arc (M1.1.0–15, `PhysicsModule` freeze at M1.1.15). M1.1.0 (foundations), M1.1.1 (broadphase BVH), M1.1.2 (narrowphase GJK), M1.1.3 (narrowphase EPA + manifold), M1.1.4 (narrowphase analytic fast paths ss/sb/cc/bb), and M1.1.5 (integration) are CLOSED. The M1.0 core-language series (M1.0.0–M1.0.18) is CLOSED — C1.6 tag `v0.10.18-extension-additive-warning`. Reserved (Tier-1-dependent, NOT core gaps): `future`, `override`, `quantize`. | +| Active branch | `phase-1/forge/epa-frame-dependence-hotfix` (PR open, not merged) | +| Next planned milestone | M1.1.6 — contact solver (Sequential Impulses, warm-start consuming the M1.1.3/4 `feature_id`s), seventh core sub-milestone of the M1.1 rigid arc (M1.1.0–15, `PhysicsModule` freeze at M1.1.15). M1.1.0–M1.1.5 CLOSED (tags `v0.11.0`–`v0.11.5`); the standalone hotfix `m1.1.3-hf-epa-frame-dependence` is the CURRENT milestone (see Current milestone row), untagged by hotfix policy. The M1.0 core-language series (M1.0.0–M1.0.18) is CLOSED — C1.6 tag `v0.10.18-extension-additive-warning`. Reserved (Tier-1-dependent, NOT core gaps): `future`, `override`, `quantize`. | ## Tags @@ -72,8 +72,8 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, |---|---|---|---| | `M1.1.1-HF1` | 2026-07-13 | `50086b1` | Standalone hotfix (external Codex review, 7/7 defects verified on `main` then fixed; zero-deferred-debt). D3/D4 **reserve-then-mutate** (new invariant name): `EntityIdentityStore.release` + `Registry.release`/`freeSlot` reserve free-list capacity before any mutation — on OOM, no observable mutation, call retryable (FailingAllocator tests). D7: `World.despawn` purges the entity's `entity_extensions` entry (owned names freed at despawn, not stranded until `deinit`). D6: `RuntimeHeader.validate(bin_len)` (version + u64-widened section bounds) + data-hash check in `Loader.readBin`; `LoadError += MalformedAsset`; `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 1→2 (tracked pinned-set migration). D5: `sendWithHandles` flushes a short `sendmsg` via the shared `writeAll` loop (fds ride the first segment; remainder carries no ancillary; `n == 0` → `BrokenPipe`); POSIX only, Windows stays `Unimplemented` (Phase 3). D1: loaded resource strings are **refcounted** persistent blocks owned by their `StringSlot` (ecs_bridge read-old/write-new/decref-old discipline); `LoadResult.resource_strings` removed (teardown parity with interp-written strings). D2: `loadFromBytes` is loader-transactional — per-resource journal (pre-write snapshot; null = fresh), commit decrefs replaced old blocks, committed-guarded errdefer rolls back in **LIFO** (decref new + restore/remove; reverse-order despawn, D7 purges extensions); LIFO order corrected at E4 review (forward undo corrupted the refcount on duplicate same-cid entries — regression test pinned). Contract excludes user-hook side effects; best-effort under OOM. | | `M1.1.1-HF2` | 2026-07-16 | `b54115b` | Standalone hotfix (second external Codex review on post-HF1 `main`; 6 fixable defects verified then fixed, 1 documented non-defect — zero-deferred-debt). C1: `EntityIdentityStore.allocate` maintains `free_indices.capacity >= slots.len` (fresh path reserves via `ensureTotalCapacity(slots.len + 1)` BEFORE growing — B1 corrected the frozen `ensureUnusedCapacity(gpa, 1)`, which froze capacity at 1); `release` is infallible `void` (`appendAssumeCapacity`), the three `spawn*` errdefers drop their swallowing `catch {}`, `despawn` drops its `try` — an allocated handle is always releasable without allocation. C4: Tier-0 `World.releaseResourcePayloads(gpa)` owns the uniform resource-payload decref walk (strings + collections, via registry `FieldKind`), idempotent by slot zeroing; called from `Interpreter.deinit` BEFORE destroying immortal `persistent_literals` (ordering is load-bearing — reversal is a UAF) and from `World.deinit` before `resources.deinit`; the `bridge.resources`-specific walk is removed — bare worlds and out-of-bridge resources no longer leak. C2: scene `instantiate` pre-reserves `spawned` + `uuid_to_entity` to accessor totals, per-entity inserts are assume-capacity — no fallible insert after a spawn, no orphan outside the rollback (exhaustive fail-index sweep test); entity uuid ordinals validated (out-of-range and duplicate → MalformedScene) before dereference, and the entity total is guarded against u32 overflow — C2b. C6: `ResourceEdit.dirty_before` captured pre-`getMutResource`, restored on rollback via `ResourceStore.setDirty` (pub Tier-0-internal seam) — a rejected load leaves no spurious `when resource T changed`. C3: asset `Loader.finish` reserves the payload-map slot BEFORE `allocWithUuid`; `putAssumeCapacity` post-alloc; the fragile `unload catch {}` unwind is gone. C5: IPC posix `writeAll` sends via `send(MSG_NOSIGNAL)` (EINTR/n==0 semantics verbatim) + `SO_NOSIGPIPE` at fd creation on macOS — peer-closed sends error instead of killing the process. C7 (non-defect): `Broadphase.Proxy` without generation packing stays as designed (documented tradeoff, internal ids); a one-line spec guard lands at the M1.1.x integration milestone that first consumes proxy ids. Review process fix applied: every gate crossed the reserve-then-mutate invariant against ALL composite call surfaces, not each leaf in isolation. | -| `M1.1.1-HF3` | (pending merge) | (pending merge) | Standalone hotfix (external Codex reviews #3 AND #4; review #3 on post-HF2 `main` — 9 findings, 8 fixable verified, 1 partially obsolete — gates E1–E6; review #4 on the E1–E6 branch pre-merge — 6 further findings R10–R15, all verified — gates E7–E8; zero-deferred-debt; review #3's scope was itself counter-reviewed before freeze). R1+R10+R11: standalone `src/core/scene/validate.zig` — full structural validation of `.scene.bin` BEFORE any accessor exposure (section order/bounds, string/schema/uuid/crossref refs, column geometry, resource `data_size == schemaSize` (R10 — the loader memcpys behind a ReleaseFast-stripped assert), string-field offsets bounded, schema indices per archetype STRICTLY increasing (R11, normative "sorted ascending"; covers extension prefabs via the shared `openVerified`)), all arithmetic checked; `buildSchemaRemap` rejects two schema entries resolving to one runtime `ComponentId`; seeded-mutation test (10 000 hash-fixed corruptions) walks EVERY accessor getter on validation survivors (E1 review STOP: survivors were initially not getter-exercised). R3+R15: PNG `max_dimension = 16384` cap, checked size math, exact expected inflated size precomputed from IHDR (Adam7 pass table shared budget↔walk), `inflate`/`zlib.decompress` gain `max_out` (single `appendByte` choke point) + strict output-length equality; inflated `raw` freed the instant deinterlacing completes — peak residency ~2×(w×h×4)+IDAT, not ~3× (R15; a configurable byte budget was REVIEWED AND REJECTED for the hotfix — new API surface; the cap + exact budget already bound the footprint). R4+R13: glTF bounds-checked `accessorAt`/`viewAt`/`bufferAt`, semantic accessor validation with the Khronos-conformant interleaved span `byteOffset + stride×(count−1) + elem` (R13 — `count×stride` falsely rejected valid interleaved buffers), declared `type` checked (`VEC2`/`VEC3`/`SCALAR`) before any buffer read, `indices < vertex_count`; `MalformedGltf`. R6+R12: grouped Tier-0 `World.addComponentsDynamic` + `prepareRemoveComponentsDynamic`/`commitRemoveComponentsDynamic`/`abortRemoveComponentsDynamic` (ONE migration; ALL fallible work precedes the first observable mutation; real duplicate/present checks — `DuplicateComponent`/`UnknownComponent` — replace a phantom assert, R11(c)); `activateExtension` prevalidate → reserve → single-fallible-step → infallible commit → `on_attach` (commits BEFORE the hook, HF1/D2 contract) + `ExtensionAlreadyActive` on re-activation (R12(d), closes the hook-only re-fire); `deactivateExtension` prevalidate → PREPARE → `on_detach` (errdefer abort) → infallible commit (R12 — after the hook succeeds, not one fallible step remains; OOM-sweep test with a hook-call counter proves `on_detach` fires exactly once); strict mono-entity cardinality shared by both paths (`extEntityArchetype`); the FABRICATED "§30.5 reject policy" comments removed — reject is the PROVISIONAL runtime policy recorded in the HF3 brief; last-wins runtime semantics = open design surface (see Open decisions). R2: `chmod 0600` post-bind hard-fail + fail-closed peer-UID check on every `accept` (`SO_PEERCRED`/`getpeereid`) — the authoritative boundary closing the bind→chmod TOCTOU; socket path stays spec-pinned in `/tmp`; umask-000 test (serialized, restored; Linux mode read via `statx` — `std.c.Stat` is void on Linux). R5+R14: `command_encoder` — `destroy` waits idle unconditionally and FREES the command buffer (the "pool reset handles it" claim was false — monotonic pool growth closed) and `create` gains the missing post-allocate `errdefer` (R14); errdefers on `createView`/`createRender`/`createCompute`/debug messenger; swapchain `create` unified native-cleanup errdefer (`views_created` + `registered`) fixing the mid-loop AND the `swapchain_owned` post-adoption native leaks; `deinit` destroys views before images. R8: `createRender` attachment guard (mirror of `render_pass.begin`) + `max_color_attachments` device-limit check. R7: `reload` validates `asset_type` vs `handle.type_tag` before any payload mutation → `AssetTypeMismatch`. R9: `retain` guards saturated `u32` → `ReferenceCountOverflow`; generation-wrap slots PERMANENTLY retired (bounded documented leak; `AssetHandle` `u64` layout FROZEN C0.5, generation stays `u16`). `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 2→3 (single bump covering both pinned-set widenings). Review-process note: review #4 caught two E1-review misses (resource `data_size`, the normative sorted-indices line) and one frozen-brief contract weaker than achievable (fallible-but-atomic post-hook remove) — the corrected contract is the prepare/commit split. Residual risks recorded in the brief: glTF attribute-count consistency unconstrained (no in-tree consumer zips), one-shot server accept after `PeerCredentialMismatch` (reachable only in the TOCTOU microsecond). CI hardening (E9/E9b, post-review-#4 Windows failures): the park test is now deterministic (two-phase proof — Σ parks_entered > Σ parks_completed establishes a live park, then a completed increase proves the wake; new stats-only `parks_entered` counter incremented under the park-path lock, propagated to WorkerStats/Snapshot/reset/dump with the `completed <= entered` invariant); watchdog coverage extended to EVERY Scheduler-starting test INCLUDING teardown (`no_alloc_steady_state*` armed the global watchdog around `Scheduler.deinit()` — the prior local wrapper only covered dispatch loops); the windows-2025/ReleaseSafe hang (4 consecutive pre-E9b occurrences) did not recur on the post-E9b merge run — unexplained, instrumented, first recurrence self-names via the dump. | -| `M1.1.1-HF4` | (pending merge) | (pending merge) | Standalone hotfix (dedicated spec session + two follow-up Codex reviews) — ratify **reject** as the normative additive-extension-conflict policy (`engine-scene-serialization.md` / `etch-reference-part2.md §30.4–30.5` / `etch-diagnostics.md §18.3` corrected). The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. A static conflict is a **fatal cook error** `E1797 ExtensionAdditiveConflict` (widened from the two-extension-only case; distinct static message per form) — reclassified from the non-fatal M1.0.18 warning whose sole reader was a test, so the cook silently produced `.scene.bin` files the loader then refused to load. `detectExtensionConflicts` runs two passes: pass 1 = a resolver-independent lexical dedup of the extension names (form c, so `cook`'s null-resolver path still rejects `["X","X"]`); pass 2 = base-seeded component counting over each resolved extension's schema (forms a/b), guarded by `validate.structure` before any accessor getter (P1#3 — no panic on a malformed-but-rehashed prefab). The dead warning infra is removed (`Warning` struct, `Cooked.warnings`, `Builder.warnings`, `emitAdditiveConflict`, the now-unused `token` import). Runtime UNCHANGED — `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c); only the loader's conflict-policy comments normalized (normative, cite `engine-scene-serialization.md`). Establishes the `cooked ⇒ loadable` invariant. Tests: 8 cook (the three fatal forms incl. a resolver-less duplicate, disjoint-OK, fail-on-first, `cooked ⇒ loadable` contract, malformed-but-rehashed no-panic) + a runtime dynamic-reject. Closes the extension-conflict Open decision. `@exclusive_with` stays a future unimplemented escape hatch. | +| `M1.1.1-HF3` | 2026-07-17 | `ac430a0` | Standalone hotfix (external Codex reviews #3 AND #4; review #3 on post-HF2 `main` — 9 findings, 8 fixable verified, 1 partially obsolete — gates E1–E6; review #4 on the E1–E6 branch pre-merge — 6 further findings R10–R15, all verified — gates E7–E8; zero-deferred-debt; review #3's scope was itself counter-reviewed before freeze). R1+R10+R11: standalone `src/core/scene/validate.zig` — full structural validation of `.scene.bin` BEFORE any accessor exposure (section order/bounds, string/schema/uuid/crossref refs, column geometry, resource `data_size == schemaSize` (R10 — the loader memcpys behind a ReleaseFast-stripped assert), string-field offsets bounded, schema indices per archetype STRICTLY increasing (R11, normative "sorted ascending"; covers extension prefabs via the shared `openVerified`)), all arithmetic checked; `buildSchemaRemap` rejects two schema entries resolving to one runtime `ComponentId`; seeded-mutation test (10 000 hash-fixed corruptions) walks EVERY accessor getter on validation survivors (E1 review STOP: survivors were initially not getter-exercised). R3+R15: PNG `max_dimension = 16384` cap, checked size math, exact expected inflated size precomputed from IHDR (Adam7 pass table shared budget↔walk), `inflate`/`zlib.decompress` gain `max_out` (single `appendByte` choke point) + strict output-length equality; inflated `raw` freed the instant deinterlacing completes — peak residency ~2×(w×h×4)+IDAT, not ~3× (R15; a configurable byte budget was REVIEWED AND REJECTED for the hotfix — new API surface; the cap + exact budget already bound the footprint). R4+R13: glTF bounds-checked `accessorAt`/`viewAt`/`bufferAt`, semantic accessor validation with the Khronos-conformant interleaved span `byteOffset + stride×(count−1) + elem` (R13 — `count×stride` falsely rejected valid interleaved buffers), declared `type` checked (`VEC2`/`VEC3`/`SCALAR`) before any buffer read, `indices < vertex_count`; `MalformedGltf`. R6+R12: grouped Tier-0 `World.addComponentsDynamic` + `prepareRemoveComponentsDynamic`/`commitRemoveComponentsDynamic`/`abortRemoveComponentsDynamic` (ONE migration; ALL fallible work precedes the first observable mutation; real duplicate/present checks — `DuplicateComponent`/`UnknownComponent` — replace a phantom assert, R11(c)); `activateExtension` prevalidate → reserve → single-fallible-step → infallible commit → `on_attach` (commits BEFORE the hook, HF1/D2 contract) + `ExtensionAlreadyActive` on re-activation (R12(d), closes the hook-only re-fire); `deactivateExtension` prevalidate → PREPARE → `on_detach` (errdefer abort) → infallible commit (R12 — after the hook succeeds, not one fallible step remains; OOM-sweep test with a hook-call counter proves `on_detach` fires exactly once); strict mono-entity cardinality shared by both paths (`extEntityArchetype`); the FABRICATED "§30.5 reject policy" comments removed — reject is the PROVISIONAL runtime policy recorded in the HF3 brief; last-wins runtime semantics = open design surface (see Open decisions). R2: `chmod 0600` post-bind hard-fail + fail-closed peer-UID check on every `accept` (`SO_PEERCRED`/`getpeereid`) — the authoritative boundary closing the bind→chmod TOCTOU; socket path stays spec-pinned in `/tmp`; umask-000 test (serialized, restored; Linux mode read via `statx` — `std.c.Stat` is void on Linux). R5+R14: `command_encoder` — `destroy` waits idle unconditionally and FREES the command buffer (the "pool reset handles it" claim was false — monotonic pool growth closed) and `create` gains the missing post-allocate `errdefer` (R14); errdefers on `createView`/`createRender`/`createCompute`/debug messenger; swapchain `create` unified native-cleanup errdefer (`views_created` + `registered`) fixing the mid-loop AND the `swapchain_owned` post-adoption native leaks; `deinit` destroys views before images. R8: `createRender` attachment guard (mirror of `render_pass.begin`) + `max_color_attachments` device-limit check. R7: `reload` validates `asset_type` vs `handle.type_tag` before any payload mutation → `AssetTypeMismatch`. R9: `retain` guards saturated `u32` → `ReferenceCountOverflow`; generation-wrap slots PERMANENTLY retired (bounded documented leak; `AssetHandle` `u64` layout FROZEN C0.5, generation stays `u16`). `WELD_ASSET_PIPELINE_PROTOCOL_VERSION` 2→3 (single bump covering both pinned-set widenings). Review-process note: review #4 caught two E1-review misses (resource `data_size`, the normative sorted-indices line) and one frozen-brief contract weaker than achievable (fallible-but-atomic post-hook remove) — the corrected contract is the prepare/commit split. Residual risks recorded in the brief: glTF attribute-count consistency unconstrained (no in-tree consumer zips), one-shot server accept after `PeerCredentialMismatch` (reachable only in the TOCTOU microsecond). CI hardening (E9/E9b, post-review-#4 Windows failures): the park test is now deterministic (two-phase proof — Σ parks_entered > Σ parks_completed establishes a live park, then a completed increase proves the wake; new stats-only `parks_entered` counter incremented under the park-path lock, propagated to WorkerStats/Snapshot/reset/dump with the `completed <= entered` invariant); watchdog coverage extended to EVERY Scheduler-starting test INCLUDING teardown (`no_alloc_steady_state*` armed the global watchdog around `Scheduler.deinit()` — the prior local wrapper only covered dispatch loops); the windows-2025/ReleaseSafe hang (4 consecutive pre-E9b occurrences) did not recur on the post-E9b merge run — unexplained, instrumented, first recurrence self-names via the dump. | +| `M1.1.1-HF4` | 2026-07-19 | `921d455` | Standalone hotfix (dedicated spec session + two follow-up Codex reviews) — ratify **reject** as the normative additive-extension-conflict policy (`engine-scene-serialization.md` / `etch-reference-part2.md §30.4–30.5` / `etch-diagnostics.md §18.3` corrected). The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. A static conflict is a **fatal cook error** `E1797 ExtensionAdditiveConflict` (widened from the two-extension-only case; distinct static message per form) — reclassified from the non-fatal M1.0.18 warning whose sole reader was a test, so the cook silently produced `.scene.bin` files the loader then refused to load. `detectExtensionConflicts` runs two passes: pass 1 = a resolver-independent lexical dedup of the extension names (form c, so `cook`'s null-resolver path still rejects `["X","X"]`); pass 2 = base-seeded component counting over each resolved extension's schema (forms a/b), guarded by `validate.structure` before any accessor getter (P1#3 — no panic on a malformed-but-rehashed prefab). The dead warning infra is removed (`Warning` struct, `Cooked.warnings`, `Builder.warnings`, `emitAdditiveConflict`, the now-unused `token` import). Runtime UNCHANGED — `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c); only the loader's conflict-policy comments normalized (normative, cite `engine-scene-serialization.md`). Establishes the `cooked ⇒ loadable` invariant. Tests: 8 cook (the three fatal forms incl. a resolver-less duplicate, disjoint-OK, fail-on-first, `cooked ⇒ loadable` contract, malformed-but-rehashed no-panic) + a runtime dynamic-reject. Closes the extension-conflict Open decision. `@exclusive_with` stays a future unimplemented escape hatch. | ## Hypotheses validated by spikes @@ -116,10 +116,10 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **M1.0.17 scope boundary (resource collection fields)** — `T[]`/`[K: V]`/`Set` execute as `resource` fields in the tree-walker. **Decision A (persistent representation):** distinct persistent `Value` tags mirroring the `.string_run`/`.string_persistent` split (zone at the tag, no runtime discriminant); rejected the "persistent bit on the existing handle". **Decision a (Tier-0 drop registry):** `persistent.zig` exposes `DropFn` + `registerDrop`; the interp registers collection drops at init; `runDrop` never reinterprets a payload as an Etch type (Tier-0 stays Etch-decoupled). **Element bound:** value-POD + `string` + enum, on `resource` ONLY; **`Entity` element REFUSED** (E0222 — cross-reference-table remap not wired in persistent collections); nested collections refused (E0222). **Closures long-life REFUSED (no gap, NOT debt):** recon proved only synchronous same-body closure consumers; escaping is a Phase-2 HIR/bytecode lowering. **Recorded deviations (Claude.ai round-trips):** FROZEN element bound refined to exclude `Entity`; the brief's `.array` kind corrected to `.slice` (`T[]` is `.slice`); `descriptor.zig` NOT touched (resource field layout lives in `interp.compileTypeDecl`, shared with `scene_cook.zig`); **E5 gate added mid-milestone** (scene-cook Etch parity + Tier-0 loader guard — deferring the Etch renderers would re-open Etch after M1.0.x, forbidden); set `for-in` and a `.set_lit` renderer were forward-predicted then refuted by recon. **Out (frontiers, NOT debt):** full persistent-block reconstruction of a collection `resource` instance at scene-**load** = `src/core/scene/loader.zig`, Tier-0 → scene-serialization / asset-pipeline milestone (**M1.6**), never Etch; collection semantics in **codegen** = Phase 2 (`etch-ast-ir.md`). **Open decision (plan):** `for x in set` is type-rejected ("not in the M0.8 minimal subset") — C1.6 gap for a later M1.0.x milestone, or a stdlib feature outside C1.6? Unlocking touches every rule-arena set + the differential corpus, so it stays OUT of M1.0.17. New diagnostic: E0222. - **M1.1.0 scope boundary (Forge 3D foundations)**: `src/interfaces/PhysicsModule.zig` + the `PhysicsModule(Impl)` comptime wrapper + `core.ModuleContext` are DEFERRED to the milestone that wires forge_3d as a stepping module — instantiating the wrapper needs `ModuleContext`, whose spec carries an unresolved Tier-0→Tier-1 reference (`asset_loader: *AssetLoader`), and the repo precedent (RenderModule, C0.5) froze a module-root namespace, not a `src/interfaces/` file: the location ruling belongs to the interface-landing milestone. The day-1 contract is carried by the `api/` descriptor types (mirrored verbatim); when the interface file lands, declarations move there and `api/` re-exports — zero call sites. `Velocity` stays a core component (`api/` re-exports; moving it would invert core→Tier-1). `Mat4` and a math-level `Transform` pose type: excluded, purely additive (first consumers later). Shapes beyond sphere/box/capsule: `error.UnsupportedShape` until their sub-milestones (pre-freeze, additive). Descriptor validation policy (typed errors on degenerate mass/geometry) is a later milestone — M1.1.0 guards the dynamic path with a `mass > 0` debug assert only. Pending KB spec patch on `engine-tier-interfaces.md` §1 (ShapeType set, damping default, `foundation.math`, union-form `ShapeDescriptor`), produced at milestone close. - **M1.1.1 scope boundary (broadphase dynamic multi-layer BVH)**: `pipeline/broadphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`; the scalar is the comptime parameter `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports), and `user_data` is an opaque `u32` (forge_3d passes the packed `BodyId`). Determinism by construction (anticipates M1.1.14): NO hash containers anywhere — index pool + LIFO free-list, tree shape a pure function of the op sequence, pairs sorted by packed `u64 (min<<32)|max` + adjacent-deduped; SAH/balance ties fixed. `insert` realizes the brief's schematic `insert(aabb, user_data) u32` as `insert(gpa, tight_aabb, user_data) !u32` (unmanaged-first convention `§3`, `BodyManager.addBody` precedent) — a realization, not a scope change (Guy-confirmed, no Recorded deviation). `computePairs` is **moved-driven** and consumes (clears) the per-layer moved-logs; a stale moved id (freed, not reused) is skipped via `isLiveLeaf`; `user_data` must be unique across all layers (documented on `Broadphase.insert`). The `default_layer_pairs` matrix is a `const`; an overridable `CollisionConfig` matrix is a LATER milestone (the field `Body.collision_layer` is stored now but nothing consumes it — object-layer pair filtering unwired). **OUT (not gaps, additive on the same structure):** raycast/shapecast tree traversal (M1.1.9–10), velocity-based AABB prediction / speculative expansion (M1.1.5), trigger/debris `BodyDescriptor` layer-assignment sources (M1.1.13 — only `body_type`-derived static/dynamic exercised now), `PhysicsWorld`/stepping/`ModuleContext`/`PhysicsModule` instantiation (M1.1.15), parallel/GPU broadphase (Phase 4+), narrowphase. **File-length note:** `broadphase.zig` is 686 lines (over the 500-line Review guideline) — the brief mandates `Bvh(T)` + `Broadphase(T)` + `BroadphaseLayer` + `BroadphaseConfig` + pair generation in this single file; conscious brief-driven overage, not lint-enforced. **E0 directory flatten** removed the `solvers_3d/` wrapper (`solvers_3d/forge_3d/` → `forge_3d/`); `engine-directory-structure.md` §9.1 and `engine-tier-interfaces.md` were pre-patched to the flat layout; the stale `solvers_{2d,3d}/` reference in `engine-zig-conventions.md §14` (KB, out of repo) remains for a KB reconciliation. -- **M1.1.2 scope boundary (narrowphase GJK convex detection)**: `pipeline/narrowphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`/`broadphase`; the scalar is the comptime `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports). GJK runs on the convex **cores** (point/segment/box) + an inflation radius (Jolt convex-radius architecture); touch = `dist(cores) ≤ r_a + r_b`, `separated` iff the core distance exceeds `r_a + r_b` beyond an ABSOLUTE noise-scaled contact margin `conv_k·floatEps(T)·coordScale` (exact touch → `shallow`, face-inclusive convention). Distance-based (NOT boolean) GJK: the simplex stores `(w, support_a, support_b)` triplets so closest points on A/B reconstruct from the barycentrics; the Voronoi simplex solver is Ericson (not Johnson, not signed-volumes). Determinism by construction (anticipates M1.1.14): no hash containers, no trig (dot/cross only), `max_gjk_iterations = 32`, named relative progress tolerance, fixed support tie-breaks; the search direction is NEVER normalized (squared distances, one `sqrt`); computation frozen in the frame of A (`rot_rel`/`pos_rel` precompute, conjugate-rotate never `inverse()`) — changing the frame after M1.1.14 would break bit-exactness. `GjkResult.deep` carries the terminal origin-enclosing simplex as the EPA seed — no depth/normal/manifold here. The forge_3d-side adapters (`shape.supportShape`, `BodyManager.gjkPair`) own the `Shape → SupportShape` conversion + the `BodyId`-level entry, mirroring the broadphase `user_data` discipline. **Classification robustness (six post-delivery correction cycles, Guy-directed — RESOLVES the E3 absolute-`deep_eps`, the Fix 3 vertex-relative residual, the P1/P2 remaining geometric dependencies, the P1b accumulated-rounding under-dimensioning, the P1c A/B-order asymmetry, AND the P1d anisotropic-tetra false-degeneracy; the extreme-aspect radius-0 box `.deep` residual is DOCUMENTED and deferred to M1.1.4/M1.1.3):** GUIDING PRINCIPLE — no `.deep`/`.shallow`/`.separated` decision depends on a GEOMETRIC quantity (radius, shape size, `w`-vertex magnitude); `.deep` = geometric enclosure; every remaining threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude, `@sqrt(maxSupportMagSq)`), absorbing only float rounding noise. (a) `.deep` = a non-degenerate tetra enclosing the origin (`count==4`, degeneracy tested DIMENSIONLESSLY `|bary_det|² ≤ deg_rel²·(|b−a|²·|c−a|²·|d−a|²)` = squared normalized volume, aspect-ratio-invariant — P1d replaced the `maxEdgeSq³` normalization that rejected valid anisotropic tetra and read a sharp box's interior `.separated`), NO distance threshold on that path; (a2, Fix 3b→P2) the sole distance early-out (`degenerateOriginReached`) is a numerical-NOISE floor `closest·closest ≤ mach_eps²·maxSupportMagSq` (scaled by the ABSOLUTE support magnitude), with `mach_eps = noise_k·floatEps(T)` (P2 recalibrated from the 8×-eps `1e-6` literal — which was a visible geometric threshold, ~8.7e-5 at half-extent 50 — down to `noise_k`≈2 ULPs); the earlier vertex-relative `rel_deep²·maxVertexMag²` (Fix 3) reproduced the bug for large shapes and was removed; (b) an anti-cycling duplicate-support guard kills degenerate Minkowski tetrahedra at the source; (c, Fix 4→P1) `.separated iff dist − r_sum > conv_k·floatEps(T)·coordScale` — an ABSOLUTE additive margin (P1 removed the `r_sum·(1+contact_rel)` form, whose `contact_rel·r_sum` slack — 0.1 m at r_sum 1000 — was a collision margin beyond the core radius, out of scope; it mis-classified two r=500 spheres 5 cm apart as `.shallow`), honoring the frozen touch=shallow rule via the convergence-noise margin; (c2, P1b) `conv_k` = 16, not 2 — 2 ULP under-dimensioned the ACCUMULATED pipeline rounding (quaternion + Voronoi tetra + sqrt), so an exact tangency (Codex case: `dist − r_sum` = 7.15e-7 ≈ 2.1 ULP relative, zero convergence residue) tipped `.separated`; 16 ULP bounds it and stays at the noise level (the deep noise floor keeps `noise_k = 2`, a distinct point-noise floor — aligning them recreates a P1b/P2 false positive). Scale-robust, verified at O(1), half-extents 50–500, and at the f32 noise floor (~9 orders tighter in f64 — an f32 fundamental limit, not a geometric margin); a measure-zero EXACT tangency can still, in rare pathological configs (~1/20000 in sweep), tip `.separated` by one ULP — irreducible float limit, corrected next frame by penetration, no absolute guarantee claimed. (c3, P1c) the contact margin's `coord_scale` is SYMMETRIC by construction — `|pos_b − pos_a| + coreExtent(a) + coreExtent(b)` (`coreExtent` = the core's max local support magnitude, radius excluded: point→0, segment→half_height, box→`|half_extents|`) — replacing the A-frame terminal-support magnitude `@sqrt(maxSupportMagSq)`, which was frame-dependent and made one tangency classify `.separated` in one A/B order and `.shallow` in the other (a structural defect, not calibration). `.deep`/`.shallow`/`.separated` is therefore invariant under an A/B swap by construction, asserted by `gjk classification is order-independent`; `maxSupportMagSq` stays (anti-cycling guard + deep noise floor — `w`-magnitude, a distinct quantity). **File-length note:** `narrowphase.zig` is 726 lines (over the 500 Review guideline) — the brief mandates the whole GJK stack (support shapes + simplex solver + loop + result) in this single file (mirror of `broadphase.zig` at 686); conscious brief-driven overage. **OUT (not gaps; additive/later):** EPA + penetration depth + contact normal + manifold (M1.1.3 — in particular do NOT compute `depth = r_a+r_b−dist` or a normal for `.shallow`); pair-type fast paths ss/sb/cc/bb (M1.1.4); speculative-contact thresholds / margins beyond the core radius / box convex radius (box radius 0 here); `collision_layer`/`CollisionConfig` matrix wiring; stepping/`PhysicsWorld`/`PhysicsModule` instantiation (M1.1.15); raycast/shapecast/point queries (M1.1.9–10); shapes beyond sphere/box/capsule (`SupportShape.Core` stays additively extensible); batched/SIMD pair processing; `forge_2d` (M1.8); robust `.deep` for radius-0 box cores of EXTREME aspect ratio (>~50:1) — a GJK-f32 limit on sharp cores (tetra degeneracy at extreme anisotropy + premature anti-cycling termination before the enclosure tetra forms), reliable to ~30:1, deferred to M1.1.4 analytic box fast paths / M1.1.3 EPA (P1d). +- **M1.1.2 scope boundary (narrowphase GJK convex detection)**: `pipeline/narrowphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`/`broadphase`; the scalar is the comptime `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports). GJK runs on the convex **cores** (point/segment/box) + an inflation radius (Jolt convex-radius architecture); touch = `dist(cores) ≤ r_a + r_b`, `separated` iff the core distance exceeds `r_a + r_b` beyond an ABSOLUTE noise-scaled contact margin `conv_k·floatEps(T)·coordScale` (an inflated exact touch `dist == r_a + r_b` → `shallow` iff `r_sum > contact_margin` — the RD-4 band is evaluated FIRST, so a sub-noise inflation radius `0 < r_sum <= contact_margin` classifies deep, benign either way; for hard cores `r_sum == 0` an exact touch `dist == 0` is the RD-4 deep band; face-inclusive convention). Distance-based (NOT boolean) GJK: the simplex stores `(w, support_a, support_b)` triplets so closest points on A/B reconstruct from the barycentrics; the Voronoi simplex solver is Ericson (not Johnson, not signed-volumes). Determinism by construction (anticipates M1.1.14): no hash containers, no trig (dot/cross only), `max_gjk_iterations = 32`, named relative progress tolerance, fixed support tie-breaks; the search direction is NEVER normalized (squared distances, one `sqrt`); computation frozen in the frame of A (`rot_rel`/`pos_rel` precompute, conjugate-rotate never `inverse()`) — changing the frame after M1.1.14 would break bit-exactness. `GjkResult.deep` carries the terminal simplex (origin-enclosing, or the RD-4 band terminal) as the EPA seed — no depth/normal/manifold here. The forge_3d-side adapters (`shape.supportShape`, `BodyManager.gjkPair`) own the `Shape → SupportShape` conversion + the `BodyId`-level entry, mirroring the broadphase `user_data` discipline. **Classification robustness (six post-delivery correction cycles, Guy-directed — RESOLVES the E3 absolute-`deep_eps`, the Fix 3 vertex-relative residual, the P1/P2 remaining geometric dependencies, the P1b accumulated-rounding under-dimensioning, the P1c A/B-order asymmetry, AND the P1d anisotropic-tetra false-degeneracy; the extreme-aspect radius-0 box `.deep` residual is DOCUMENTED and deferred to M1.1.4/M1.1.3):** GUIDING PRINCIPLE — no `.deep`/`.shallow`/`.separated` decision depends on a GEOMETRIC quantity (radius, shape size, `w`-vertex magnitude); `.deep` = geometric enclosure OR the RD-4 accumulated-rounding witness band (`dist ≤ contact_margin`, M1.1.3-HF); every remaining threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude, `@sqrt(maxSupportMagSq)`), absorbing only float rounding noise. (a) `.deep` = a non-degenerate tetra enclosing the origin (`count==4`, degeneracy tested DIMENSIONLESSLY `|bary_det|² ≤ deg_rel²·(|b−a|²·|c−a|²·|d−a|²)` = squared normalized volume, aspect-ratio-invariant — P1d replaced the `maxEdgeSq³` normalization that rejected valid anisotropic tetra and read a sharp box's interior `.separated`), NO distance threshold on the enclosure path (the RD-4 band is the post-loop classifier, `conv_k`-scaled); (a2, Fix 3b→P2) the sole distance early-out (`degenerateOriginReached`) is a numerical-NOISE floor `closest·closest ≤ mach_eps²·maxSupportMagSq` (scaled by the ABSOLUTE support magnitude), with `mach_eps = noise_k·floatEps(T)` (P2 recalibrated from the 8×-eps `1e-6` literal — which was a visible geometric threshold, ~8.7e-5 at half-extent 50 — down to `noise_k`≈2 ULPs); the earlier vertex-relative `rel_deep²·maxVertexMag²` (Fix 3) reproduced the bug for large shapes and was removed; (b) an anti-cycling duplicate-support guard kills degenerate Minkowski tetrahedra at the source; (c, Fix 4→P1) `.separated iff dist − r_sum > conv_k·floatEps(T)·coordScale` — an ABSOLUTE additive margin (P1 removed the `r_sum·(1+contact_rel)` form, whose `contact_rel·r_sum` slack — 0.1 m at r_sum 1000 — was a collision margin beyond the core radius, out of scope; it mis-classified two r=500 spheres 5 cm apart as `.shallow`), honoring the frozen touch=shallow rule via the convergence-noise margin; (c2, P1b) `conv_k` = 16, not 2 — 2 ULP under-dimensioned the ACCUMULATED pipeline rounding (quaternion + Voronoi tetra + sqrt), so an exact tangency (Codex case: `dist − r_sum` = 7.15e-7 ≈ 2.1 ULP relative, zero convergence residue) tipped `.separated`; 16 ULP bounds it and stays at the noise level (the deep noise floor keeps `noise_k = 2`, a distinct point-noise floor — aligning them recreates a P1b/P2 false positive). Scale-robust, verified at O(1), half-extents 50–500, and at the f32 noise floor (~9 orders tighter in f64 — an f32 fundamental limit, not a geometric margin); a measure-zero EXACT tangency can still, in rare pathological configs (~1/20000 in sweep), tip `.separated` by one ULP — irreducible float limit, corrected next frame by penetration, no absolute guarantee claimed. (c3, P1c) the contact margin's `coord_scale` is SYMMETRIC by construction — `|pos_b − pos_a| + coreExtent(a) + coreExtent(b)` (`coreExtent` = the core's max local support magnitude, radius excluded: point→0, segment→half_height, box→`|half_extents|`) — replacing the A-frame terminal-support magnitude `@sqrt(maxSupportMagSq)`, which was frame-dependent and made one tangency classify `.separated` in one A/B order and `.shallow` in the other (a structural defect, not calibration). `.deep`/`.shallow`/`.separated` is therefore invariant under an A/B swap by construction, asserted by `gjk classification is order-independent`; `maxSupportMagSq` stays (anti-cycling guard + deep noise floor — `w`-magnitude, a distinct quantity). **File-length note:** `narrowphase.zig` is 726 lines (over the 500 Review guideline) — the brief mandates the whole GJK stack (support shapes + simplex solver + loop + result) in this single file (mirror of `broadphase.zig` at 686); conscious brief-driven overage. **OUT (not gaps; additive/later):** EPA + penetration depth + contact normal + manifold (M1.1.3 — in particular do NOT compute `depth = r_a+r_b−dist` or a normal for `.shallow`); pair-type fast paths ss/sb/cc/bb (M1.1.4); speculative-contact thresholds / margins beyond the core radius / box convex radius (box radius 0 here); `collision_layer`/`CollisionConfig` matrix wiring; stepping/`PhysicsWorld`/`PhysicsModule` instantiation (M1.1.15); raycast/shapecast/point queries (M1.1.9–10); shapes beyond sphere/box/capsule (`SupportShape.Core` stays additively extensible); batched/SIMD pair processing; `forge_2d` (M1.8); robust `.deep` for radius-0 box cores of EXTREME aspect ratio (>~50:1) — a GJK-f32 limit on sharp cores (tetra degeneracy at extreme anisotropy + premature anti-cycling termination before the enclosure tetra forms), reliable to ~30:1, deferred to M1.1.4 analytic box fast paths / M1.1.3 EPA (P1d). - **Extension-conflict semantics (last-wins vs reject) — RESOLVED (M1.1.1-HF4)**: the dedicated post-HF3 spec session ratified **reject** as the normative, permanent policy for additive extension conflicts, superseding the former "last-extension-wins" prose. The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. Static conflict → **fatal cook error** `E1797 ExtensionAdditiveConflict` (all three forms, distinct static message; no output; `weld check` fails in CI), guaranteeing `cooked ⇒ loadable`; dynamic activation → runtime `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c), atomic (entity unchanged). Specs corrected (`engine-scene-serialization.md` additive-conflict note §695–706, `etch-reference-part2.md §30.4–30.5`, `etch-diagnostics.md §18.3` E1797 row + the W1791→E1797 reclassification note); code comments cite `engine-scene-serialization.md` (the authority), not the HF3 brief. Deactivation needs no provenance — the reject invariant means no two active declarants ever share a component. Cook detection: form (c) is a resolver-independent lexical pass (so the public `cook` null-resolver path rejects duplicates); forms (a)/(b) need the prefab resolver and are `validate.structure`-guarded before any accessor getter. Alternative examined and rejected in the session: last-wins-with-provenance (masking + shadowed-byte side table + out-of-order deactivate + `.sav` shadow-stack serialization — ≈5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, undoes the HF3 deactivate simplification — to support a design error). `@exclusive_with` remains a future, unimplemented escape hatch. - **Scope boundary M1.1.3** — Deferred to M1.1.4: extreme-aspect (>~50:1) radius-0 box `.deep` classification (GJK-f32 limit upstream of EPA), and analytic fast paths superseding the generic clipper for common pairs. Deferred until the shapes ship (convex hull/compound/mesh, ~M1.1.19-20, co-designed with M1.1.6 warm-start): generic-clipper feature ids for non-fast-path convex shapes. In M1.1.3 the feature_id contract is fully met for box/sphere/capsule (all producers class-tagged, unique, frame-stable via collidePair). **Closed for M1.1.3 scope.** -- **M1.1.4 — generic EPA deep-rotated frame-dependence (M1.1.3 DEFECT, tracked).** `collideOrderedGeneric` (GJK/EPA in the frame of A) is order-dependent for a DEEP, ROTATED convex pair even at 1:1 aspect (sweep config: pen 1.047 forward, pen 0/count 1 reversed) — violates the frozen order-independence contract (`engine-physics-forge.md §303`), independent of the P1d aspect limit. The box/box SAT fast path is order-independent by construction and neutralizes the exposure FOR box/box; **residual: capsule/box + sphere/capsule stay on the generic EPA path** (demo pairs). Deferred to a dedicated hotfix `m1.1.3-hf-epa-frame-dependence` (out of M1.1.4 scope — gjk.zig/epa.zig untouched). Detected + recorded at M1.1.4 E3. SCHEDULED: before M1.1.6 (Sequential Impulses is the first real consumer of deep manifolds under sustained contact — the frame-dependence would corrupt warm-starting; M1.1.5 was free-flight and never invoked EPA). +- **M1.1.4 — generic EPA deep-rotated frame-dependence (M1.1.3 DEFECT) — RESOLVED (hotfix `m1.1.3-hf-epa-frame-dependence`, before M1.1.6).** `collideOrderedGeneric` (GJK/EPA in the frame of A) was order-dependent for a DEEP, ROTATED convex pair even at 1:1 aspect (violating the `engine-physics-forge.md §3` order-independence contract), on the residual capsule/box + sphere/capsule generic path (box/box was already neutralised by the M1.1.4 SAT fast path). Resolved by three fixes, all in the hotfix: (1) `epa.zig` expansion robustness — coplanar-tolerant visibility, connected visible region (flood-fill from the closest face), transactional commit-on-success expansion, skip-face non-convergence semantics, a realizable exhaustion fallback, and a flat-face terminal-face reconstruction restricted to the converged plane; (2) the E3 intrinsic point⊖segment world normal (segment-owner's world rotation + explicit ownership sign → exact bit negation across orders); (3) **RD-4** — a `gjk.zig` post-loop deep band `if (dist <= contact_margin) deep`, reusing the existing `conv_k` accumulated-rounding bound (`noise_k` untouched, so the P1b/P2 calibration is preserved), closing a deep/shallow classification stall that mis-classified a genuinely-deep overlap `.shallow` at dist≈0 (arm64 f32-unit / f64-×0.01, scalar/scale/order-dependent). Validated by a 390-config × 2-order × 3-scale order-equivalence sweep with mandatory SAT classification + EPA diagnostics (no corrupt path fires), f32 + `-Dphysics_f64=true`, debug + ReleaseSafe, arm64 + x86-64 CI. See `briefs/m1.1.3-hf-epa-frame-dependence.md`. - **M1.1.4 scope boundary — fast paths.** Four frozen pairs only (ss/sb/cc/bb), radius-0 box. OUT (generic, correct): capsule/box, sphere/capsule (capsule/box a post-demo additive candidate after profiling). OUT (later): warm-start (M1.1.6), stepping/solver (M1.1.5+), Plane/Mesh (M1.1.11). `collideOrdered` count>1 point-set is fixed-order; full order-independence via `collide`/`collidePair` (frozen M1.1.3 contract, inherited). **Precision boundary (out of scope):** contact-point POSITION accuracy far from body A's centre is f32-limited (absolute noise ~floatEps·|pos|); order-independence + count are correct (extent-based dedup), but a feature below f32 resolution at its position has an inherently ambiguous count — a `generateManifold` precision characteristic (would need clip-recentring), noted for a future precision hotfix. **Closed for M1.1.4 scope.** - **M1.1.5 scope boundary (integration, semi-implicit Euler).** Integration is INTERNAL to forge_3d (`pipeline/integration.zig`, re-exported at `Real` by `root.zig`); NOT wired into `PhysicsModule` — the `step` orchestration (broadphase → narrowphase → solve → integrate) and the ECS Transform sync land at M1.1.15. `integrate(dt)` is substep-neutral: damping applies once per call; the substep-count sensitivity of the clamped-linear form (`(1−d·dt/N)^N → e^{−d·dt}`) is the orchestrator's decision at M1.1.15. OUT (later/additive, NOT debt): contact response / Sequential Impulses / friction / restitution / warm-start (M1.1.6); sleeping (M1.1.8); CCD; kinematic position-from-velocity (additive branch, no refactor); the gyroscopic term `ω × (I·ω)` (additive implicit-gyroscopic option); exact non-linearized orientation integration (additive strategy flag — introduces an `|ω|` division singular at zero, deliberately avoided in the first-order default); ECS ↔ BodyManager sync + `PhysicsModule` instantiation (M1.1.15); SIMD / island-parallel / batched integration (M1.1.8+/Phase 4+); absolute integration perf targets / bench (land with the full `step`, per the M1.1.3/M1.1.4 precedent). The day-1 `PhysicsModule` 3D interface asymmetry (no `addTorque`/`setAngularVelocity`, unlike 2D's `addTorque2D`/`setAngularVelocity2D`) is FLAGGED for the M1.1.15 freeze review — the internal `BodyManager` mutators exist either way. diff --git a/briefs/m1.1.3-hf-epa-frame-dependence.md b/briefs/m1.1.3-hf-epa-frame-dependence.md new file mode 100644 index 0000000..e0dc6ff --- /dev/null +++ b/briefs/m1.1.3-hf-epa-frame-dependence.md @@ -0,0 +1,204 @@ +# m1.1.3-hf-epa-frame-dependence — Forge 3D narrowphase: generic EPA deep-path order-dependence (hotfix) + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/forge/epa-frame-dependence-hotfix` +> **Planned tag:** none — hotfix milestones are not tagged (Guy decision, 2026-07-14; M1.1.1-HF precedent). The `CLAUDE.md` Tags table gains a `M1.1.3-HF` row keyed by the squash-merge hash, post-merge. +> **Dependencies:** M1.1.3 (defect origin — `epa.zig`/`manifold.zig` delivered and frozen there), M1.1.4 (SAT fast paths, threshold-class discipline, differential harness, the tracked Open-decision entry). Sequence predecessor / branch base: M1.1.5 (`v0.11.5-integration-euler`, `main` @ `6e9ad44`). +> **Opened:** 2026-07-23 +> **Closed:** 2026-07-24 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Closes the tracked Open decision "M1.1.4 — generic EPA deep-rotated frame-dependence (M1.1.3 DEFECT)". `collideOrderedGeneric` (GJK → EPA → `generateManifold`, computed in the frame of A) violates the `engine-physics-forge.md` §3 narrowphase order-independence contract for deep, rotated convex pairs. Recon on the real binary (Zig 0.16.0, `v0.11.5-integration-euler`) reproduced 16 divergences over a 390-config deep+rotated sweep and separated the defect into three signatures with distinct root causes, all inside `epa.zig`: + +- **S1 — polytope corruption → wrong depth (correctness bug).** `expandPolytope`'s visibility test is strictly `> 0`: a support **coplanar** with faces it should replace (ubiquitous on polyhedral Minkowski differences) leaves stale interior faces; `closestFaceIndex` selects them, the support along their normal duplicates an existing vertex, and the anti-cycling exit **returns the corrupt face's distance as the depth**. Reproduced pins: two 1:1 unit cubes, roll 0.5 about Z, offset (0.2, 0.4, 0.1) → EPA depth **0.0 in both A/B orders** (true core MTV = 1.9, axis ±Z, verified by independent 15-axis SAT); offset (0.1, 0.1, 0.1) → 0.4543 vs 1.2389 (true 1.9). A depth of ~0 for a ~1.9 overlap would make the M1.1.6 Sequential Impulses solver apply no correction (sinking) and warm-start against garbage normals. Order-dependence is the *detector*; the corruption is the disease — canonicalization alone would freeze deterministically wrong answers. +- **S2 — asymmetric tie selection (correct depth, divergent normals).** On configs with multiple exactly-equal MTV minima (capsule centered in a box), both orders converge to the correct depth but pick different — individually valid — tie normals (the tie is broken by face-evaluation order in the computation frame). Ratified as a flip band by the §3 reformulation (see Notes); no code change. +- **S3 — 1-D Minkowski degenerate normal is frame-dependent.** Sphere/capsule `.deep` exists only with the sphere center exactly on the capsule axis (point⊖segment is 1-D); `degenerateResult`'s 2-vertex branch derives its perpendicular normal from the segment direction **in the computation frame**, so the two orders return non-negation-related normals. Depth is correct in both orders. Fixed here by an intrinsic world-space derivation (targeted to point⊖segment only). + +Scheduled BEFORE M1.1.6 (its first real consumer of deep manifolds under sustained contact). `gjk.zig` is NOT at fault (deep classification correct and symmetric in every diverging case — P1c holds) and is untouched. External Codex review folded in after source-verification: the duplicate-with-progress fallback is fully specified with an exhaustion rule and explicit diagnostics (no global test-only counter); the seed-canonicalization proposal (former "Fix B") is WITHDRAWN — it would introduce a new tie×pose-boundary flip surface into `collidePair`'s currently flip-free seed frame; the capsule/box oracle is the exact 6-axis zonotope SAT, not directional sampling; residual `genericConsistent` rejections must be SAT-classified, never merely labeled "tie". + +## Scope + +All new thresholds follow the M1.1.4-frozen discipline: no absolute geometric constant; normalization guards at true zero only; margins of the form `k · floatEps(T) · scale` with named constants. `EpaResult` and `ContactManifold`/`ContactPoint` stay FROZEN; the `epa()` signature is package-internal and may change (it gains a diagnostics parameter). + +- **E1 — Independent oracles + RED reproduction suite (tests only; no production change).** + - **(a) Zonotope SAT oracle for segment⊖box.** Test-side helper: the Minkowski difference of a segment core and a box core is a 4-generator zonotope; its facet normals are the pairwise generator cross products = the 3 box face axes + the 3 `box_axis × segment_axis` crosses. Exact finite SAT over these 6 candidate axes (skip near-zero cross axes with a relative threshold, the M1.1.4 SAT discipline): core MTV depth + the set of minimal axes within a named numeric tie band `tie_k · floatEps(T) · coordScale`. Returns depth, minimal axis, and the count of tie-band minimal axes. + - **(b) Box/box oracle.** Reuse/extract the existing inline 15-axis SAT scan from `fast_paths_test.zig` (also returning the tie-band minimal-axis count). + - **(c) S1 pins (RED at this gate).** Unit cubes (half-extents 1,1,1), rot_a identity: (i) rot_b = roll 0.5 about Z, offset (0.2, 0.4, 0.1) — expected core depth 1.9, axis ±Z (unique minimum; next axis 1.957); (ii) roll 0.5 about Z, offset (0.1, 0.1, 0.1) — expected 1.9, ±Z; (iii) pitch 0.4 about X, offset (0.1, 0.1, 0.1) — expected 1.9, ±X. Each asserted in BOTH A/B orders, at both levels: `EpaResult.depth` vs the SAT oracle within a named tolerance, and `collideOrderedGeneric` max penetration likewise (r_sum = 0 here), normal within a named angular/component tolerance of the oracle axis. + - **(d) S3 pins (RED).** Sphere (r 0.7) centered on the axis of a capsule (half-height 1, r 0.5), several capsule world rotations (at least yaw π/4 about Y and one tilted axis): in both orders — manifold count 1, penetration = r_sum = 1.2, and EPA normals of the two orders are exact bit negations of each other (see E3 for why bit-exactness is achievable). + - **(e) Order-equivalence sweep (RED where it pins the defect).** The 390-config recon sweep, generator parameters frozen: pairs {box/box 1:1; capsule (hh 1, r 0.5)/box 1:1:1; sphere (r 0.7)/capsule (hh 1, r 0.5)}; rot_a ∈ {identity, axis (3,1,2)/‖·‖ angle 0.9}; rot_b ∈ {pitch 0.4 about X; axis (1,1,1)/√3 angle 0.62; roll 0.5 about Z; axis (1,2,3)/‖·‖ angle 0.7; yaw π/4 about Y}; 13 offsets {(0,.3,0), (.2,.4,.1), (0,.6,0), (.3,.2,−.2), (0,.9,0), (.5,.5,.3), (.1,.1,.1), (.15,.35,.25), (.4,.1,.2), (.25,.55,−.15), (.05,.25,.45), (.33,.44,.11), (−.2,.3,.4)}. Both A/B orders through `collideOrderedGeneric`. Assertions per config: equal null-ness; depth equal within a named tolerance; count equal OR the config is SAT-confirmed as a tie (≥ 2 minimal axes within the tie band); normal negation-related within a named tolerance OR SAT-confirmed tie. **Any divergence not SAT-classified as a tie is a test failure.** Scale variants: the same sweep at ×0.01 and ×100 (positions and extents scaled together). + - CC runs the new suite at this gate and logs the observed RED evidence (which pins fail, observed values) in the Execution log before any fix lands. The pre-existing suite stays green. +- **E2 — Fix A: `epa.zig` expansion robustness.** + - **(a) Coplanar-tolerant visibility.** `expandPolytope` visibility becomes `(w.w − verts[f.a].w) · f.normal ≥ −vis_eps` with `vis_eps = vis_k · floatEps(T) · loop_scale` (named `vis_k`, distinct constant; `loop_scale` is the existing polytope coordinate scale) — a support coplanar-within-noise with a face removes that face instead of stranding it as a stale interior face. + - **(b) Connected visible region.** The removal set is restricted to the edge-connected component (across shared face edges) containing the current closest face — which is visible from `w` by construction (`d − best.dist > loop_tol`). Prevents a relaxed visibility from carving disconnected regions and producing multiple horizon loops (Jolt lineage). + - **(c) Transactional expansion.** `expandPolytope` must be commit-on-success: build the post-expansion face set in fixed-capacity scratch and commit only if every fan face is valid. On failure, the polytope is observably unmodified (today a mid-fan bail leaves a topological hole; continuing on a holed polytope is unsound and the E2(d) skip-and-continue rule requires a valid polytope). + - **(d) Non-convergence exit semantics.** The convergence test (`d − best.dist ≤ loop_tol`) stays FIRST, unchanged. Any subsequent non-convergence event on the current closest face — support duplicates an existing vertex while `d − best.dist > loop_tol`, transactional expansion failure, `max_verts` reached — marks that face SKIPPED (excluded from `closestFaceIndex` selection; the face stays in the face list, topology untouched) and the loop continues with the next closest non-skipped face. The loop NEVER returns the polytope distance of a face that failed to prove convergence. + - **(e) Realizable fallback on exhaustion.** Every support query of the expansion loop records a realizable candidate `(n, d = w.w · n, support_a, support_b)` — `n` is the queried unit face normal, so the plane `{x · n = d}` is a supporting hyperplane of the Minkowski difference and `d` is a realizable (conservative, ≥ true) penetration depth along a valid separation axis. On exhaustion (no selectable non-skipped face remains) the function returns the minimum-`d` recorded candidate: normal `n` and the support pair mapped to world via `rot_a`/`pos_a`, depth `max(d, 0)`. Deterministic (fixed query order, strict `<` first-wins). Never a silent return of a rejected face. + - **(f) Iteration-cap exit.** At `max_epa_iterations`, return the current best NON-skipped face (existing `expanded_ok` refresh logic adapted to respect skip marks); if every face is skipped, the E2(e) fallback applies. + - **(g) `EpaDiagnostics`.** `epa()` gains a final parameter `diag: ?*EpaDiagnostics`; the struct (package-level, re-exported by the narrowphase `root.zig` facade alongside `EpaResult`) carries: `exit: enum { converged, iteration_cap, fallback_exhausted, degenerate_low_dim, defensive_non_deep_seed }`, `iterations: u32`, `faces_skipped: u32`, `fallback_used: bool`. Written only when the pointer is non-null; zero stored state otherwise (M1.1.14 no-hidden-state discipline; island-parallel-safe). The production call site (`collideOrderedGeneric` in `manifold.zig`) passes `null`. + - **(h) In-file fallback unit tests.** Inline `test` blocks in `epa.zig` (private access) exercising: a skipped face is never selected; the exhaustion fallback returns the minimal recorded realizable candidate; determinism of the fallback. + - Gate exit: the E1(c) S1 pins are GREEN in both orders; the full pre-existing suite stays green. +- **E3 — S3: intrinsic world-space degenerate normal (point⊖segment only).** + - In `degenerateResult`, when the vertex count is 2 AND exactly one core is `.segment` and the other `.point`: derive `v_world = rot_seg.rotateVec3(unit_y) · s`, where `rot_seg` is the segment-owning shape's world rotation and `s = +1` if the segment core is `shape_a`, `−1` if `shape_b`; return `n_world = perpAxis(v_world)` directly as the world normal (bypassing the `worldResult` `rot_a` mapping for the normal; closest points unchanged, still barycentric + `rot_a`-mapped). `rot_seg` is the same input bits in both call orders and `s` flips, so `v_world(B,A) = −v_world(A,B)` exactly; `perpAxis` is odd under negation with a `|·|`-invariant axis selection, so the two orders return exact bit negations on a given target. `epa()`/`degenerateResult` will need the two world rotations (or the segment owner's) threaded to this branch — an internal-signature change, allowed. + - All other degenerate combinations (2-vertex segment⊖segment collinear, the ≥3-vertex planar branch, the 1-vertex branch) keep their current behavior, documented as best-effort (their GJK terminal vertices are not clean negations across orders — no exactness claim is made for them). + - Gate exit: the E1(d) S3 pins are GREEN (bit-exact normal negation asserted, with the rationale documented at the test site); full suite green. +- **E4 — Sweep classification, `genericConsistent` narrowing, boundary pins, close.** + - **(a) Sweep green with mandatory classification.** The E1(e) sweep (both scales, f32 and `-Dphysics_f64=true`) passes with every residual cross-order divergence SAT-classified as a tie; the run additionally drives `epa()` with diagnostics on every sweep config and asserts `fallback_used == false` and `exit == converged` everywhere except the S3/degenerate configs (`degenerate_low_dim`) — the corrupt path never fires post-fix. + - **(b) `genericConsistent` (fast_paths_test.zig) narrowed, not removed.** On a cross-order inconsistency it must confirm the tie via the box/box SAT (≥ 2 minimal axes within the tie band) before skipping; an unclassified inconsistency fails the test. The existing `compared >= 20` floor stays. + - **(c) `q`/`−q` double-cover pin.** Config: the E1(c)(iii) pitch-0.4 pin (deep, non-tie). `collide(A, B(q_b))` vs `collide(A, B(−q_b))`: bit-identical geometry (quaternion rotation is even in sign — every `rotateVec3`/`rot_rel` term is a product of two quaternion components; CC verifies this against the `foundation/math` quaternion source at E1 and notes it in the Execution log), while `poseAfter`'s lexicographic quaternion compare flips the internal canonical order. Assert: equal count, depth within a named tolerance, normals within a named tolerance of each other (both are the caller's A→B — NOT negated). `feature_id` equality is NOT asserted (bare `collide` is pose-canonical; no fid stability contract — `manifold.zig` doc). + - **(d) `collidePair` ±ε boundary pin on a generic deep pair.** Capsule/box deep config straddling a `poseAfter` position boundary (±1e-4 about coordinate equality), driven through `BodyManager.collidePair` (fixed BodyId order): assert count equality, normal within its band, and `feature_id` SET equality across the boundary — the manifold_test.zig "collidePair feature ids are stable across a pose-order boundary" pattern, extended to the generic deep path. This is a regression pin: nothing pose-order-dependent remains in `collidePair`'s seed path, and this test protects that property against any future seed-canonicalization attempt. + - **(e) Close.** `CLAUDE.md` §3.4 (patch content produced by Claude.ai at the E4 review, applied by CC on-branch): Current-state lines 13/15 corrected (M1.1.5 is merged and tagged `v0.11.5-integration-euler`, not "PR #58 open"); Tags-table HF3/HF4 rows `(pending merge)` → dates + hashes `ac430a0` / `921d455`; the Open-decision entry "M1.1.4 — generic EPA deep-rotated frame-dependence" resolved with a pointer to this brief; the M1.1.3-HF Tags row itself is added by Guy post-squash (hash unknown on-branch). Full suite green f32 + `-Dphysics_f64=true`, debug + ReleaseSafe. + +## Out of scope + +- **Seed canonicalization inside `collideOrderedGeneric` (the withdrawn "Fix B").** It would make the seed frame flip at `poseAfter` boundaries during persistent contacts driven by `collidePair` — whose seed frame today never flips (BodyId order) — and on an S2 tie the normal would jump between two valid MTVs, flipping `generateManifold`'s reference-face selection and the `feature_id`s without geometric transition. The E4(d) pin makes this regression-detectable. +- **Cross-order bit-exact symmetry of EPA.** Unachievable in float across two computation frames (different rounding lattices). The ratified contract is geometric equivalence + documented flip bands (§3 reformulation, see Notes). The single exception where bit-exact negation IS achievable and required: the E3 point⊖segment degenerate normal (same input bits, sign-explicit). +- **The M1.1.4 precision boundary (clip-recentring).** Contact-point POSITION accuracy far from body A's centre — a `generateManifold` clipping-precision characteristic, distinct defect class, no shared code path with this fix, no M1.1.6 pressure. Stays tracked for a future precision hotfix. +- **`gjk.zig`.** Not at fault (recon: deep classification correct and symmetric in all diverging cases). If the post-fix sweep reveals a residual seed-side dependency, no-deferred-debt applies and it is fixed here via a recorded deviation. +- **`support.zig`, `fast_paths.zig` production code.** Untouched (test file changes only). +- **S2 tie normals within one order / other 1-D Minkowski combos.** Deterministic per fixed order today; ratified flip-band semantics. No tie-break redesign. The E3 exactness claim covers point⊖segment only. +- **New pairs, new shapes, the P1d extreme-aspect limit, solver/warm-start (M1.1.6), stepping/`PhysicsModule` (M1.1.15), benches.** +- **Spec file edits on-branch.** The `engine-physics-forge.md` §3 reformulation ("au bit près" → geometric equivalence + flip bands; the order-independence paragraph gaining the ratified tie/flip-band semantics) is produced by Claude.ai as a complete re-uploadable file at the E4 review; CC edits NO spec file. + +## Specs to read first + +1. `engine-physics-forge.md` — §3 Collision System → Narrowphase (the three GJK regimes, the manifold/`feature_id` contract, the **Order-independence** paragraph, and the fast-path "au bit près" clause — the two clauses under reformulation by this hotfix). +2. `engine-phase-1-plan.md` — the M1.1 rigid-arc table (M1.1.6 Sequential Impulses is the first consumer of deep manifolds + `feature_id` warm-starting). +3. `engine-zig-conventions.md` — Zig 0.16.x conventions (no `usingnamespace`, `unreachable` only on proven invariants, English doc comments on public API, naming, test pinning §13). + +## Files to create or modify + +- `src/modules/forge/forge_3d/pipeline/narrowphase/epa.zig` — **modify** — E2 Fix A (visibility tolerance, connected visible region, transactional `expandPolytope`, skip-face exit semantics, realizable fallback, `EpaDiagnostics`, in-file fallback tests) + E3 S3 branch in `degenerateResult`. +- `src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig` — **modify** — `collideOrderedGeneric` call site passes `null` diagnostics (and threads whatever the E3 internal-signature change requires). No semantic change to `collide`/`collideOrdered`/`collideOrderedGeneric` ordering. +- `src/modules/forge/forge_3d/pipeline/narrowphase/root.zig` — **modify** — re-export `EpaDiagnostics` alongside `EpaResult`. +- `src/modules/forge/forge_3d/tests/epa_robustness_test.zig` — **create** — E1 oracles (zonotope SAT, box/box SAT extraction), S1 pins, S3 pins, the 390-config × 2-order × 3-scale sweep with SAT classification and diagnostics assertions, the E4(c) `q`/`−q` pin. +- `src/modules/forge/forge_3d/tests/fast_paths_test.zig` — **modify** — `genericConsistent` SAT-classification narrowing (E4(b)); share/extract the 15-axis SAT helper for reuse by the new suite. +- `src/modules/forge/forge_3d/tests/manifold_test.zig` — **modify** — E4(d) `collidePair` ±ε deep-pair boundary pin (pattern of the existing pose-order-boundary test). +- `src/modules/forge/forge_3d/root.zig` — **modify** — pin `tests/epa_robustness_test.zig` in the comptime test block (§13). +- `CLAUDE.md` — **modify** — §3.4 at E4(e) (patch content produced by Claude.ai at review). + +## Acceptance criteria + +### Tests + +Named tolerances at every assertion site (depth tolerance, normal tolerance, SAT tie band) — sized and documented, never bare `1e-6` literals for the f32 path. + +- `epa.zig` (inline) — `test "epa skipped faces are never selected"` — E2(h). +- `epa.zig` (inline) — `test "epa exhaustion fallback returns the minimal realizable candidate"` — E2(h), deterministic. +- `tests/epa_robustness_test.zig` — `test "segment-box zonotope sat oracle self-checks"` — oracle sanity on closed-form configs (axis-aligned capsule over a box face; parallel-axis degeneracy skips the zero crosses). +- `tests/epa_robustness_test.zig` — `test "deep rotated box pair matches sat in both orders"` — the three E1(c) S1 pins, EPA depth + generic manifold penetration + normal vs oracle, both orders. +- `tests/epa_robustness_test.zig` — `test "on-axis sphere-capsule normal is exactly negated across orders"` — E1(d)/E3, bit-exact negation + count 1 + pen 1.2, several rotations, both orders. +- `tests/epa_robustness_test.zig` — `test "generic deep path is order-equivalent over the sweep"` — E1(e)/E4(a): 390 configs × 2 orders × scales {0.01, 1, 100}; unclassified divergence fails; diagnostics assert `fallback_used == false` and converged/degenerate exits only. +- `tests/epa_robustness_test.zig` — `test "collide is invariant under the quaternion double cover"` — E4(c). +- `tests/manifold_test.zig` — `test "collidePair deep generic manifold is stable across a pose-order boundary"` — E4(d), fid-set equality. +- `tests/fast_paths_test.zig` — `genericConsistent` rejections SAT-classified; `compared >= 20` floor retained. +- Full pre-existing suite green; all of the above green in `debug` AND `ReleaseSafe`, at f32 AND `-Dphysics_f64=true`. + +### Benchmarks + +None (hotfix; narrowphase perf is benched with the full `step`, later). The E2 skip/fallback machinery must add no allocation and no hash container (determinism by construction). + +### Observable behavior + +- `zig build test-forge-3d` green (f32 + f64, debug + ReleaseSafe), including the new `epa_robustness_test.zig` suite whose sweep output demonstrates zero unclassified cross-order divergences. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe), including the `-Dphysics_f64=true` local sweep +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/forge/epa-frame-dependence-hotfix` +- **Final tag:** none (hotfix policy; `CLAUDE.md` Tags row `M1.1.3-HF` + squash hash added by Guy post-merge) +- **PR title:** `Phase 1 / Forge / Narrowphase EPA order-dependence hotfix (M1.1.3-HF)` +- **Squash commit title suffix:** `(M1.1.3-HF)` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **RED-first is mandatory.** E1 lands the failing pins with logged evidence BEFORE any `epa.zig` change. The branch may carry failing NEW tests between E1 and E3; the pre-existing suite must stay green at every gate. +- **Why the fallback is sound.** A recorded candidate `(n, d = support(n)·n)` defines a supporting hyperplane of the Minkowski difference: translating by `d·n` separates the cores, so `d` is a realizable penetration depth along a valid axis — conservative (≥ the true minimum), never the S1 under-estimate of a corrupt interior face. The convergence test remains the primary exit; the fallback exists so the defensive path can NEVER silently return a rejected face's polytope distance. +- **Why S3 bit-exactness holds (and only there).** `v_world = rot_seg·unit_y·s` consumes identical input bits in both orders with an explicit sign flip; IEEE negation is exact; `perpAxis`'s axis selection compares `|components|` (negation-invariant) and its cross product and normalization are odd/even respectively in the sign of `v`. GJK terminal vertices, by contrast, come from independent descents in different frames — no negation relation is claimed for any other degenerate branch. +- **Flip-band semantics (feeds the §3 reformulation).** Post-fix, the generic fixed-order entries guarantee cross-order *geometric equivalence*: equal null-ness, equal depth within accumulated-rounding tolerance, and negation-related normals — except inside documented flip bands: exact MTV ties (S2 — normals may differ among equally-minimal axes; count may differ at topological ties), and non-point⊖segment low-dimensional degeneracies. Full order-independence remains the property of `collide` (pose-canonical) and `collidePair` (BodyId-canonical), unchanged. +- **Diagnostics discipline.** `?*EpaDiagnostics` is caller-owned, written only when non-null, holds no allocation and no retained state — compatible with the M1.1.14 determinism freeze and M1.1.8 island parallelism. It is a test/tooling seam, not an API commitment; `EpaResult` alone stays the frozen contract. +- **Recon artifacts.** The reproduction harness values in E1(c)/(e) were measured on the real `v0.11.5` binary; the SAT expectations (1.9 / ±Z / ±X) were hand-verified over the full 15-axis set including edge crosses. If CC's RED run observes different values (compiler/platform variance), record the observed values in the Execution log — the assertions target the ORACLE, not the recon transcript. +- **Codex second-opinion (verified against source) folded in:** duplicate-with-progress fallback fully specified with exhaustion semantics + explicit `?*EpaDiagnostics` (no global counter); Fix B withdrawn (tie×pose-boundary flip surface); S3 derived intrinsically with an explicit ownership sign (GJK-vertex reconstruction rejected — no negation guarantee); zonotope SAT oracle replacing directional sampling; `q`/`−q` double-cover as the only bit-identical boundary pin, ±ε crossings compared in equivalence classes; `genericConsistent` rejections SAT-classified, unclassified divergence fails. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [x] `engine-physics-forge.md` (§3 narrowphase) — read 2026-07-23 17:07 +- [x] `engine-phase-1-plan.md` (M1.1 arc) — read 2026-07-23 17:07 +- [x] `engine-zig-conventions.md` — read 2026-07-23 17:07 + +## Execution log + +*One entry per logical unit of work. Chronological. Short — 1 to 3 lines per entry.* + +- **2026-07-23 — Setup (Steps 1–2).** Branch `phase-1/forge/epa-frame-dependence-hotfix` off `main` @ `6e9ad44` (`v0.11.5-integration-euler`). Brief copied verbatim (commit `98563fb`, pushed — remote confirmed). Specs read in full (17:07): `engine-physics-forge.md` §3, `engine-phase-1-plan.md` M1.1, `engine-zig-conventions.md`. Status → ACTIVE. +- **2026-07-23 — E1 code (tests only, no production change).** Extracted the box/box 15-axis SAT into shared `pub fn satBoxBox` in `fast_paths_test.zig` (returns depth+axis+tie_count; `depth` byte-identical to the former `satMinOverlap`, existing caller updated). Added the 6-axis segment⊖box zonotope oracle `satSegBox` + the RED pins (S1/S3) + the 390-config × 2-order × 3-scale sweep with SAT classification in the new `tests/epa_robustness_test.zig`; pinned it in `forge_3d/root.zig`. `tie_count` counts DISTINCT minimal-band directions (colinear dedup `sat_dir_colinear=0.999`) — corrected after the first run over-counted the roll-about-Z ±Z direction 6× (face-A Z + face-B Z + 4 edge-crosses). +- **2026-07-23 — E1 RED evidence (`zig build test-forge-3d`, f32 debug, BEFORE any epa.zig fix).** 113 pass / 3 fail; the oracle self-check + the whole pre-existing suite are GREEN. Observed failures (assertions target the oracle, not the recon transcript): + - **S1** `deep rotated box pair matches sat in both orders`: FAIL at the EPA-depth assertion. Pin (i) (unit cubes, roll 0.5 about Z, offset (0.2,0.4,0.1)), order A→B: **EPA depth = 0.0** vs SAT oracle MTV **1.9** (unique ±Z minimum, tie_count 1), tol 5e-3. Matches the recon's "0.0 in both orders". + - **S3** `on-axis sphere-capsule normal is exactly negated across orders`: count = 1 and penetration = r_sum = 1.2 both hold; FAIL only at the bit-exact normal negation — the degenerate 2-vertex normal is frame-dependent across the two orders (not `n(B,A) == −n(A,B)`). + - **Sweep** `generic deep path is order-equivalent over the sweep`: FAIL at an unclassified cross-order divergence (`satTieCount < 2` on a non-tie config) — the deep-rotated frame-dependence. +- **2026-07-23 — E1 push reconciliation.** The `pre-push` hook runs `zig build test` (which includes the forge_3d suite), and `--no-verify` is forbidden — so the three RED pins cannot be pushed as hard failures. Per Guy's decision, each pin is gated behind `error.SkipZigTest` (`red_gate_s1/s3/sweep`, un-gated per fix: s1→E2, s3→E3, sweep→E4). `zig build test-forge-3d` → 113 pass / **3 skipped** / 0 fail; full `zig build test` exits 0 (the macOS `failed command` lines are the documented pre-existing cosmetic noise). Branch pushable at each gate with no hook bypass; the RED evidence lives in this log. Ratified as [[RD-1]]. +- **2026-07-23 — E4 reminder (logged now so the un-gating does not drop it).** When `EpaDiagnostics` lands in E2, the E1 sweep test must additionally drive `epa()` with a diagnostics pointer on every config and assert `fallback_used == false` and `exit ∈ {converged, degenerate_low_dim}` (E4(a)) — the corrupt path must never fire post-fix. Wire this when `red_gate_sweep` is removed at E4, not before. +- **2026-07-23 — E2 (Fix A: epa.zig expansion robustness + diagnostics).** `expandPolytope`: coplanar-tolerant visibility (`>= -vis_eps`, `vis_k=16`), connected-component flood-fill of the visible region from the closest face, and commit-on-success (all fan faces validated in scratch before any mutation — polytope observably unmodified on failure). Non-convergence events (support duplicates a vertex / expansion fails / `max_verts`) mark the face SKIPPED, never returned; a realizable fallback candidate (min supporting-plane depth) is recorded per support query and returned on exhaustion. `EpaDiagnostics` (`?*`, written only when non-null) added + re-exported by the narrowphase facade; production call site passes `null`. Inline tests: skip-never-selected, fallback-minimal, fallback-determinism. **S1 un-gated (`red_gate_s1=false`); pins (i)/(ii)/(iii) green both orders.** +- **2026-07-23 — E2 fix-as-you-go: `terminalFace` reconstruction.** Coplanar-tolerant visibility re-triangulates a flat contact face into coplanar triangles (equal plane distance); `closestNonSkipped` picking first-by-index could clamp the reconstructed closest point to a sub-triangle NOT containing the origin's projection → a gross position error (observed: swapped-order deep sphere/box under rotation — generic (0.24,-0.58,0.59) vs correct (0.58,-0.50,0.93)), regressing `fast_paths_test`'s sphere/box differential. Fixed by reconstructing from the near-minimal-plane face with the genuinely-smallest closest-point (depth unchanged, position correct); restores the pre-existing suite to green. Deep-path correctness (no FROZEN change). +- **2026-07-23 — Forced signature ripples.** `epa()` gaining `diag: ?*EpaDiagnostics` (E2(g)) compelled a mechanical `, null` at every call site: `manifold.zig` (production), `forge_3d/root.zig` (wrapper), `manifold_test.zig`, and `epa_test.zig` — the last NOT in the brief's file list; a compile-forced `null`, no semantic change. +- **2026-07-23 — E2 verified.** `zig build test-forge-3d` GREEN at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe (118 pass / 2 skip — S3 + sweep still gated). `fmt`/`lint`/`build` clean. (A rapid back-to-back 4-config loop produced one transient FAIL — cache contention; each config passes on a clean run.) +- **2026-07-23 — E2 review corrections (STOP-E2 round-trip).** (1) `terminalFace` + both reconstruction exits IGNORED skip marks — a `.deep`-path E2(d) violation: a skipped face holding the global minimum at a converged/cap exit (reachable via skip→continue→converge-elsewhere, no expansion between, marks still live) would anchor the near-minimal band on the rejected face and return its distance (the S1 form). Fixed: `skipped` threaded into `terminalFace`; `min_dist` + band over NON-skipped faces only; both call sites updated. New inline E2(h) test: a skipped face with the global-minimum distance is not selected. (2) RD-3 evidence gathered (per-mode, verbatim — see RD-3) and the frozen (0.1,0.1,0.1) config re-added as a status-conditional pin (`checkDeepBoxPinIfDeep`) alongside the retargeted (0.2,0.4,0.1) unconditional pin, preserving coverage where the config is `.deep`. +- **2026-07-23 — E3 (S3: intrinsic point⊖segment degenerate normal).** `epa()` gains `rot_b` (B's world rotation, EXACT bits — `relpose` folds in `conj(rot_a)` and cannot reproduce them), threaded to `degenerateResult`. In the 2-vertex branch, `pointSegmentPair` detects a point⊖segment pair and derives the normal intrinsically in world: `v_world = rot_seg·unit_y·s` (`rot_seg` = segment owner's world rotation; `s = +1` if the segment is shape_a, else −1), `n_world = perpAxis(v_world)` returned directly (bypassing the `rot_a` normal mapping; closest points stay barycentric + `rot_a`-mapped). `rot_seg` is identical bits in both orders and `s` flips → `v_world(B,A) = −v_world(A,B)` exactly; `perpAxis` is odd with a `|·|`-invariant axis → the two orders' EPA normals are EXACT bit negations. Other 2-vertex branches unchanged (best-effort). Signature ripple: `epa()` `rot_b` added at all 5 sites (`manifold.zig`, `forge_3d/root.zig` [unused wrapper], `epa_test.zig`, `manifold_test.zig`, `epa_robustness_test.zig`). **S3 un-gated (`red_gate_s3=false`).** The S3 pin asserts count 1 + pen r_sum on the manifold and the bit-negation on the RAW `epa()` normal — `generateManifold` round-trips the normal through A's frame (rot_a differs per order), losing bit-exactness, so the negation contract is on the EPA normal (brief E1(d)). +- **2026-07-23 — E3 verified.** `zig build test-forge-3d` GREEN f32 + `-Dphysics_f64=true`, debug + ReleaseSafe (119 pass / 1 skip — sweep still gated). `fmt`/`lint`/`build` clean. +- **2026-07-23 — E3 review correction (STOP-E3 round-trip).** The bit-negation assertion had been moved off the MANIFOLD normal (the committed E1 form) onto the raw `epa()` normal, on the FALSE premise that `generateManifold` round-trips the manifold normal through A's frame. Corrected: on the count-1 point-core path, `generateManifold`'s `conj(rot_a)·n_world` is used ONLY for supporting-face selection; `pointCoreContact` returns `.normal = n_world` VERBATIM (a pure copy, no arithmetic), so the E3 bit-negation propagates to the manifold unchanged — verified on this arm64 HEAD (f32/f64, both orders, all 3 S3 configs) and on Guy's x86-64. The manifold-level assertion `ab.?.normal.eql(ba.?.normal.neg())` is RESTORED as the primary CONSUMER guarantee (M1.1.6 warm-start consumes manifolds, not EpaResults); the raw `epa()` assertion is KEPT as a complement documenting the source. The faulty comment is corrected. +- **2026-07-23 — E4 fixes 1–3 + RD-4 diagnosis (STOP for the gjk design review).** Codex P2 (skip-flag resurrection): `expandPolytope` remaps `skipped` in lockstep with the compaction (no post-expansion `@memset` reset) — a face that failed duplicate-progress stays skipped rather than re-failing identically; inline test added. Codex P3 (`terminalFace`): restricted to `sel`'s converged PLANE (`term_k` coplanarity + `sel.dist` band, else fall back to `sel`) so a different-normal never-converged face is never adopted; inline test added (different-normal-in-band rejected). Codex (a) (SAT ±sign): a bilateral minimal direction (`|dc·axis| ≤ band`) counts as TWO tie directions in `satBoxBox`/`satSegBox` — the generic normal can pick the same absolute axis (un-negated) in both orders. RD-4 diagnosed (root cause + reproduced stall data — see RD-4); `red_gate_rd4` pins the stall RED (gated), STOP for the gjk.zig design decision before de-gating the sweep. Fixes 1–3 GREEN f32/f64 × debug/ReleaseSafe (121 pass / 1 skip). +- **2026-07-23 — E4 close (RD-4 design GO'd → sweep green → pins → CLAUDE.md → close).** RD-4 gjk fix (design C′, Guy-ratified): post-loop deep band `if (dist <= contact_margin) return deepResult(...)` at the classifier, `contact_margin` reused (conv_k, no new constant, `noise_k` untouched), covering all three loop exits. `red_gate_rd4` un-gated → RD-4 pins green (arm64 f32 + f64); "separated stays separated" boundary pin added. Then: **E4(a)** `red_gate_sweep` un-gated → the 390-config × 2-order × 3-scale sweep is GREEN with mandatory SAT classification + per-config EPA diagnostics (`fallback_used == false`, exit ∈ {converged, degenerate_low_dim} — no corrupt path fires). **E4(b)** `genericConsistent` narrowed: an inconsistency must be a SAT MTV tie OR the documented M1.1.4 `collideOrdered` count-order-dependence (null-ness + depth + negated normal agree, only count differs — see RD-5), else it fails; `compared >= 20` floor kept. **E4(c)** q/−q double-cover pin (pitch-0.4 deep, non-tie: count/depth/normal invariant, NOT negated). **E4(d)** `collidePair` ±ε deep-pair boundary pin (capsule/box, unique-Y MTV, fid-set stable across the poseAfter x=0 boundary). **E4(e)** `CLAUDE.md` patched (Current-state 13/15, HF3/HF4 dates+hashes `ac430a0`/`921d455`, M1.1.4 Open-decision RESOLVED + RD-4 line). RD-4 verified: `rotateVec3` even in q's sign confirmed against `quat.zig` (E4(c) premise). +- **2026-07-24 — E4-STOP corrections (Status → ACTIVE → re-close; four fixes, all source-founded).** The brief was closed prematurely; re-opened ACTIVE for four review-founded corrections (no code-behaviour change, all in tests + docs). **(1) q/−q pin never reached the quaternion compare (RD-6).** The E4(c) pin used distinct A/B positions, so `poseAfter` (manifold.zig:806-808) decides canonical order on POSITION alone and the q vs −q leg is bit-identical trivially — the double-cover assertion was vacuous. Rewritten as two legs: Leg 1 keeps the distinct-position case and asserts FULL bit-identical manifold equality (count/normal/positions/penetration/feature_id); Leg 2 uses COINCIDENT positions (a capsule/box deep pair, `o = (0,0,0)`) so `poseAfter` falls through to the lexicographic quaternion compare, and asserts count + depth-within-tol + normal-axis-within-tol (`|dot| > 1 − tol`, NOT sign — a coincident-centre bilateral MTV has no order-independent normal sign), anchored to the `satSegBox` MTV oracle. **(2) `GjkResult` public doc contradicted the RD-4 deep band.** `.deep` no longer implies origin-enclosing — corrected the type doc (gjk.zig:308-315) and the `simplex` field doc (gjk.zig:334) to "origin-enclosing OR the RD-4 rounding-band terminal (a Minkowski witness at noise distance, not necessarily enclosing); distance 0; closest points unspecified." **(3) `genericConsistent` else branch excused null-ness/depth under the tie.** Restructured to assert UNCONDITIONALLY `ab != null and ba != null` + `depth` agreement first, THEN accept only the axis divergence via `tie (SAT ≥2) OR count_only (negated-normal agreement)`. A null-ness or depth divergence now fails regardless of tie. **(4) `CLAUDE.md` "Next planned milestone" (line 16) still listed the hotfix as next** — replaced with the M1.1.6 text; the hotfix is the CURRENT milestone (Current-milestone row), untagged by policy. RD-6 added to Recorded deviations. Suite re-verified green f32/f64 × debug/ReleaseSafe (arm64); x86-64 via CI. +- **2026-07-24 — light STOP corrections (Codex P2 + P3; Status → ACTIVE → re-close).** Two review follow-ups, no behaviour change (one new test + doc sync). **P2 — missing in-band false-deep pin.** Added `test "rd-4 in-band false-deep is benign"` to `epa_robustness_test.zig`, the complement of the "stay separated" boundary pin. PRECISION: the gap is BAND-RELATIVE, recomputed at `Real` — `gap = m0/2`, `m0 = conv_k·floatEps(Real)·coord_scale0`, `coord_scale0 = 2 + 2·√3` (two unit boxes, `coreExtent(box) = |(1,1,1)|`, `gjk.zig`). `contact_margin` is monotone-increasing in the gap, so `gap = m0/2 < contact_margin(gap)` is PROVABLY in-band at any scalar (no iteration; an absolute gap would be in-band in f32 but ~1e8× out in f64 — the precision trap). Leg 1 (radius-0 boxes, both orders): `.deep` (non-enclosing band terminal), generic non-null, count ≥ 1, penetration benign (`≤ 8·m0` — the non-enclosing seed clamps EPA depth ≈ 0, so the false-deep fabricates no spurious depth, the S1 class); normal direction unasserted (noise-dominated at pen ≈ 0). Leg 2 (two r-0.5 spheres, centres `gap` apart): penetration ≈ `r_sum` ≈ 1.0 both orders — pins the "former shallow result to within ε" (`gjk.zig` RD-4 comment) with the correct inflated depth. **P3 — RD-4 doc sync, three sites.** `gjk()` doc (`gjk.zig`): classification now states `.deep` iff `dist ≤ contact_margin` (RD-4 witness band, terminal not necessarily enclosing), `.separated` iff `dist − r_sum` exceeds it, else `.shallow`. `expandToTetra` doc (`epa.zig`): "origin-enclosing for a genuine deep seed; for an RD-4 band seed the origin may lie marginally outside (the downstream depth clamp handles it)". `CLAUDE.md` M1.1.2 entry: three surgical retouches (`.deep` = geometric enclosure OR the RD-4 witness band; "NO distance threshold on the enclosure path (the RD-4 band is the post-loop classifier, `conv_k`-scaled)"; "the terminal simplex (origin-enclosing, or the RD-4 band terminal)"). **127** tests green (0 skipped) f32/f64 × debug/ReleaseSafe (arm64); x86-64 via CI. Re-closed. +- **2026-07-24 — micro STOP (Codex P3 ×3; single commit, no behaviour change).** Three doc/test refinements; GO-merge suspended one round. **(1) In-band false-deep pin — Leg 2 spheres → INFLATED boxes (supersedes the light-STOP Leg 2).** The point-core sphere Leg 2 could NOT exercise the RD-4 band it was meant to pin: a sphere pair's `coreExtent = 0`, so `coord_scale = gap` and `gap = m0/2` lands OUT of its own tiny band (`.shallow`), never reaching the deep-band + EPA path. Replaced with two INFLATED boxes (unit box core + radius 0.5, `r_sum = 1.0`) at centres `2 + gap`: the box cores keep `coreExtent = √3`, so the band is identical to Leg 1 and `gap` is in-band → `gjk .deep` on a genuinely-disjoint pair (non-enclosing terminal) → EPA depth clamps ≈ 0 → manifold penetration recovers `r_sum − 0 ≈ 1.0` (the "r_sum added after a false-deep" path). Both orders assert `.deep` + `maxPen ≈ r_sum`. **(2) `r_sum > 0` qualification of the shallow inflated-touch rule**, two sites: the `gjk()` doc (`gjk.zig`) and the `CLAUDE.md` M1.1.2 entry — "an inflated exact touch (`dist == r_a+r_b`, `r_sum > 0`) stays shallow; for hard cores (`r_sum == 0`) the shallow band is empty — an exact touch (`dist == 0`) is the RD-4 deep band". **(3) epa.zig non-enclosing-seed docs:** the module header (low-dimensional-seed contract) + the `Face` struct doc now state that an RD-4 band seed may expand to a NON-enclosing tetra (origin marginally outside; `Face.dist` may be marginally negative; the downstream depth clamp returns ≈ 0). Test count UNCHANGED (127; leg modified, not added). All 127 green f32/f64 × debug/ReleaseSafe (arm64); x86-64 via CI. Re-closed. +- **2026-07-24 — final micro STOP (Codex P3 doc-only; single commit, behaviour + tests unchanged).** Precision correction of the inflated-touch classification wording. The prior round's "`r_sum > 0` → shallow" was IMPRECISE: because the RD-4 band (`dist <= contact_margin`) is evaluated FIRST, an exact inflated touch (`dist == r_a + r_b`) is shallow IFF `r_sum > contact_margin`; a sub-noise inflation radius (`0 < r_sum <= contact_margin`) classifies DEEP — benign either way (EPA clamps depth ≈ 0, manifold penetration ≈ `r_sum` in both regimes); for hard cores (`r_sum == 0`) `dist == 0` is the RD-4 deep band. FOUR sites (the three Guy enumerated + one adjacent for consistency): the `gjk()` doc, the post-loop three-band classifier comment, the neighbouring separated-comparison note (all `gjk.zig`), and the `CLAUDE.md` M1.1.2 entry. Behaviour / API / test count unchanged (127; squash message unchanged). All 127 green f32/f64 × debug/ReleaseSafe (arm64); x86-64 via CI. + +## Recorded deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* + +- **RD-1 (ratified 2026-07-23, GO-E1 round-trip) — skip-gating the RED pins.** The FROZEN Notes clause "The branch may carry failing NEW tests between E1 and E3" is inapplicable under this repo's `pre-push` hook, which runs `zig build test` (forge_3d suite included) with `--no-verify` forbidden (non-negotiable rule): three hard-failing pins cannot be pushed for gate review. The ratified resolution is to gate each RED pin behind `error.SkipZigTest` (`red_gate_s1/s3/sweep`), un-gated per fix (s1→E2, s3→E3, sweep→E4); the suite stays green and pushable with no bypass, and the observed RED values live in the Execution log. Recorded by commit `450b5e5`. +- **RD-2 (ratified 2026-07-23, GO-E1 round-trip) — E1(a) zero-cross skip at TRUE zero, not a relative threshold.** The FROZEN E1(a) text says "skip near-zero cross axes with a relative threshold (the M1.1.4 SAT discipline)". Implemented instead as a skip at the TRUE zero only (`l2 <= 0`): a small-but-nonzero `box_axis × segment_axis` (or box/box edge) cross is a genuine zonotope facet normal — dropping it with a relative threshold would make the oracle *inexact* (it could miss the true minimal axis), the opposite of what a trustworthy oracle needs. Normalizing the overlap by `√l2` already handles magnitude; only an exactly-degenerate (parallel) cross carries no separating information — matching the in-tree M1.1.4 box/box SAT. The frozen text was faulty; deviation ratified in this round-trip. Recorded by commit `ce7920b`. +- **RD-3 (2026-07-23, E2 — ratification requested at the E2 review) — pin (iii): the frozen (0.1,0.1,0.1) is a cross-platform deep/shallow boundary; retargeted + kept status-conditional.** FROZEN E1(c)(iii) = pitch 0.4 about X, offset (0.1,0.1,0.1). **Evidence on THIS HEAD — Darwin arm64, Zig 0.16.0** (`narrowphase.gjk` on the two BodyId-free orders): + - f32 Debug AND f32 ReleaseSafe: `A→B status=.shallow dist=0.00000055`, `B→A status=.deep dist=0.0`. + - f64 Debug AND f64 ReleaseSafe: `A→B .deep`, `B→A .deep`. + Guy's HEAD (**Linux x86-64, Zig 0.16.0**): both orders `.deep`, dist 0, in f32 Debug, f32 ReleaseSafe AND f64. The verbatim A→B failure that surfaced this (before the guard): `expected .deep, found .shallow` at `checkDeepBoxPin`'s `GjkResult.Status.deep` assertion. So the config sits **exactly on the GJK deep/shallow classification boundary** (`dist ≈ 5.5e-7 ≈ 0`): its class is not deterministic across arch / scalar / A-B order (arm64-f32-A→B → `.shallow`; x86-64, or f64, or arm64-f32-B→A → `.deep`). A `.shallow` order reaches no EPA, so it cannot exercise the S1 depth defect there. **Resolution (accepted by Guy, evidence-backed):** (1) an UNCONDITIONAL retargeted pin at (0.2,0.4,0.1) — `.deep` on both platforms/scalars, oracle-targeted, unique minimum — is the third S1 pin; (2) the frozen (0.1,0.1,0.1) config is kept as a STATUS-CONDITIONAL pin (`checkDeepBoxPinIfDeep`: assert only if this build classifies it `.deep`, else skip), so it is exercised on x86-64 (both orders), arm64-f64 (both orders) and arm64-f32 (the B→A order), and simply skipped where `.shallow`. GJK is untouched and out of scope; this boundary sensitivity is recorded as data for the M1.1.14 cross-platform determinism freeze. Recorded on-branch at E2. +- **RD-4 (2026-07-23, E4 — ratified by Guy; gjk.zig fix DESIGN decided at the E4 review) — in-milestone gjk.zig deep/shallow stall fix.** Pre-committed by the FROZEN scope: "gjk.zig — Not at fault … If the post-fix sweep reveals a residual seed-side dependency, no-deferred-debt applies and it is fixed here via a recorded deviation." A genuinely-deep box/box overlap (SAT MTV of the S1 1.9 class) is mis-classified `.shallow` at dist≈0 by GJK — the pen-0-vs-MTV error is exactly the S1 class this hotfix exists to kill, here produced by the GJK stage instead of EPA, NOT a ratifiable measure-zero flip band (this supersedes the RD-3 "flip band" framing for the deep case). **Root cause:** GJK converges at the progress-test break (`gjk.zig:429`) to a NON-enclosing terminal ~2.66·floatEps·scale from the origin, which exceeds the `degenerateOriginReached` noise floor (`noise_k = 2`, `gjk.zig:421`), so enclosure is unrecognized and the post-loop classifier reads `dist ≈ 0` as `.shallow`. Flips with scalar/scale/order — reproduced on this arm64 HEAD: f32 unit A→B `.shallow` dist `5.48e-7` (scount 0); f64 ×0.01 A→B `.shallow` dist `0`; x86-64 `.deep` on these (per Guy). Naively widening `noise_k` reintroduces a documented P1b/P2 false positive (`CLAUDE.md` § M1.1.2 scope), so the fix is delicate; its DESIGN is decided at the E4 review on the real source (candidates: a convergence-stall→deep gate keyed to the `conv_k` accumulated-rounding bound already used for the shallow/separated boundary, leaving the P1b/P2-calibrated in-loop floor untouched; or a re-scoped floor). **Scope:** strictly deep/shallow termination robustness — a converged stall at `dist ≤ margin` without enclosure on a genuinely-deep config classifies deep. RED-first: `red_gate_rd4` pins the stall (S1-class penetration-vs-SAT-oracle, ×{0.01,1,100}, both orders), gated until the fix. Cross-platform validation (arm64 + x86-64 CI) at the E4 gate exit. `gjk.zig` otherwise untouched. **RESOLVED** (design C′, Guy-ratified at the E4 review): post-loop deep band `if (dist <= contact_margin) return deepResult(...)` at the classifier, `contact_margin` reused (conv_k accumulated-rounding bound; NO new constant; `noise_k` untouched), covering all three loop exits; a positive origin-reached witness (`coord_scale` symmetric ⇒ order-independent). RD-4 pins + the full order-equivalence sweep green f32/f64 × debug/ReleaseSafe on arm64. Recorded by commit `d451324`. +- **RD-6 (2026-07-24, E4-STOP — ratified by Guy at this round-trip) — the E4(c) q/−q pin's frozen design presumed the quaternion compare was reached; it is not, for any distinct-position pair.** The FROZEN E4(c) specifies a q vs −q assertion on "the E1(c)(iii) pitch-0.4 pin" — a config with DISTINCT A/B positions. But `collide`'s pose canonicalization (`poseAfter`, manifold.zig:806-808) compares POSITIONS lexicographically FIRST and only falls through to the lexicographic quaternion compare when positions are bit-equal; so for any distinct-position pair the q and −q legs canonicalize identically and the pin is bit-identical BY POSITION, never exercising the double-cover path it was written to guard. Since `rotateVec3` is even in the quaternion's sign, the geometry is identical either way — the frozen single-config design cannot fail, so it does not test the intended property. **Resolution (ratified):** split into two legs — Leg 1 (distinct positions) asserts full bit-identical manifold equality (the strongest available check on that config); Leg 2 (COINCIDENT positions, a capsule/box deep pair) forces `poseAfter` into the quaternion compare, asserting count + depth (tol) + normal AXIS (`|dot| > 1 − tol`, not sign — a coincident-centre bilateral MTV has no order-independent normal sign, so a sign assertion would be wrong), anchored to the `satSegBox` MTV oracle. Faithful to E4(c)'s intent (double-cover invariance of `collide`), realized on a config that actually reaches the guarded path. Recorded on-branch at the E4-STOP round-trip. +- **RD-5 (2026-07-23, E4 — ratification requested at the E4 review) — E4(b) genericConsistent accepts the documented count-only class, not only SAT MTV ties.** The FROZEN E4(b) says a cross-order inconsistency "must confirm the tie via the box/box SAT (≥ 2 minimal axes) before skipping." A residual box/box differential config (off=(0,1.8,0), a shallow-overlap contact) is inconsistent by COUNT ONLY (2 vs 3 clipped points) with perfectly negated normals (ndot=1.0) and agreeing depth — the M1.1.4-documented `collideOrdered` count-order-dependence (a clip vertex below f32 resolution: `collideOrdered` count is fixed-order, only `collide`/`collidePair` are fully order-independent), a distinct BENIGN class from the depth/normal frame-dependence this hotfix targets, and NOT a SAT MTV-axis tie (the minimum is unique). Requiring an MTV-tie for it would fail a documented benign case. The narrowing classifies an inconsistency as legitimate iff it is EITHER a SAT MTV tie OR count-only (null-ness + depth + negated normal all agree); any other divergence (a null-ness / depth / NORMAL divergence — the frame-dependence class) fails. Faithful to E4(b)'s intent ("an unclassified inconsistency fails" — this one IS classified, as the count-order-dependence). Recorded by commit `93e372d`. + +## Blockers encountered + +*Blocking points that required a return to Claude.ai. If 2+ distinct blockers: re-scope signal.* + +## Closing notes + +- **What worked:** The gate-by-gate RED-first plan held. E1 pinned the three defect signatures against INDEPENDENT SAT oracles (zonotope segment⊖box + the extracted 15-axis box/box), reproducing the recon; E2's `epa.zig` expansion robustness (coplanar-tolerant visibility + connected visible region + transactional commit-on-success + skip-face + realizable fallback + `EpaDiagnostics`) turned S1's depth-0 into the correct MTV; E3's intrinsic point⊖segment world normal gave exact cross-order bit negation; RD-4's `gjk.zig` post-loop deep band closed the deep/shallow classification stall. The 390-config × 2-order × 3-scale order-equivalence sweep is GREEN with mandatory SAT classification + EPA diagnostics (no corrupt path fires), f32 + f64, debug + ReleaseSafe. +- **What deviated from the original spec:** RD-1 (skip-gate the RED pins under the green-enforcing pre-push, no bypass); RD-2 (E1(a) zero-cross skip at TRUE zero, not a relative threshold — the frozen text was faulty); RD-3 (pin (iii) retargeted + kept status-conditional — the frozen (0.1,0.1,0.1) is a cross-platform deep/shallow boundary); RD-4 (in-milestone `gjk.zig` deep-band fix, pre-committed by the no-deferred-debt clause; design C′ Guy-ratified); RD-5 (E4(b) also accepts the documented M1.1.4 `collideOrdered` count-order-dependence, not only SAT MTV ties); RD-6 (the E4(c) q/−q pin split into two legs so it actually reaches the `poseAfter` quaternion compare — the frozen single distinct-position config could not, being canonicalized on position alone). Also fix-as-you-go: `terminalFace` flat-face reconstruction (E2, a consequence of coplanar visibility; kept the pre-existing sphere/box differential green); the S3 pin asserts the bit-negation on BOTH the manifold (consumer guarantee) and the raw `epa()` normal (source). +- **What to flag explicitly in review:** RD-3, RD-4 (implementation), RD-5, and RD-6 need final ratification. RD-5 is the one that most stretches a FROZEN clause (E4(b) "confirm the tie via the box/box SAT") — the count-only class is a distinct benign M1.1.4-documented behavior, not an MTV tie. RD-6 corrects a FROZEN E4(c) design that could not exercise its own target (the q/−q pin was canonicalized on position, never reaching the `poseAfter` quaternion compare). `gjk.zig` was touched ONLY for RD-4's deep band (one `if` + comment, reusing `contact_margin`, `noise_k` untouched) and the RD-4-consistent `GjkResult` doc correction (E4-STOP fix 2). The §303 §3 reformulation (three-band classification + flip bands) is Claude.ai's close deliverable, not in this diff. +- **Final measurements:** `zig build test-forge-3d` — all **127** tests pass (0 skipped) at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe on arm64/macOS. No benchmarks (hotfix). The E2 skip/fallback machinery adds no allocation and no hash container. +- **Residual risks / tech debt left intentionally:** The M1.1.4 precision boundary (contact-point POSITION far from A's centre, clip-recentring) stays a separate future precision hotfix — untouched here (distinct defect class). The P1d extreme-aspect (>~50:1) box GJK-f32 limit is unchanged (out of scope). `collideOrdered` count-order-dependence (RD-5) is documented, not a defect — full order-independence remains a property of `collide`/`collidePair`. Cross-platform (x86-64) validation is via CI on the pushed branch. diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/epa.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/epa.zig index 814b7ff..323b0e1 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/epa.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/epa.zig @@ -19,11 +19,13 @@ //! //! **Low-dimensional seeds (brief flag-6 contract).** A `simplex_count < 4` seed //! (coincident cores, point-on-segment, crossing segments) is tetra-expanded to a -//! non-degenerate origin-enclosing tetrahedron before the loop. When the -//! Minkowski difference is genuinely < 3-D (the cores touch along a point / line / -//! plane — a zero core penetration), no tetra exists; EPA returns the best -//! lower-dimensional feature: a unit separation normal and depth 0 (the deep↔ -//! shallow boundary; the manifold's deep depth is then `0 + r_sum`). +//! non-degenerate tetrahedron around the seed before the loop — origin-enclosing +//! for a genuine deep seed; an RD-4 band seed (m1.1.3-hf) may expand to a +//! NON-enclosing tetra (origin marginally outside; the downstream depth clamp +//! returns ≈ 0). When the Minkowski difference is genuinely < 3-D (the cores touch +//! along a point / line / plane — a zero core penetration), no tetra exists; EPA +//! returns the best lower-dimensional feature: a unit separation normal and depth +//! 0 (the deep↔shallow boundary; the manifold's deep depth is then `0 + r_sum`). //! //! **Dependency discipline (brief Notes).** Imports `foundation` (math) + the //! sibling `support.zig` / `gjk.zig` ONLY. Determinism by construction: no hash @@ -58,9 +60,40 @@ pub fn EpaResult(comptime T: type) type { }; } +/// Optional diagnostics for a single `epa()` call (brief E2(g)) — a test/tooling +/// seam, NOT part of the frozen `EpaResult` contract. Written only when a non-null +/// pointer is passed; `epa` holds no retained state otherwise (M1.1.14 +/// no-hidden-state / M1.1.8 island-parallel-safe). The production call site +/// (`collideOrderedGeneric`) passes `null`. +pub const EpaDiagnostics = struct { + /// How the expansion terminated. + pub const Exit = enum { + /// A face proved convergence (its support did not advance past its plane). + converged, + /// `max_epa_iterations` reached; returned the best non-skipped face. + iteration_cap, + /// Every face was skipped; returned the realizable fallback candidate. + fallback_exhausted, + /// A `< 3-D` Minkowski difference (`expandToTetra`/initial-tetra failed). + degenerate_low_dim, + /// The defensive non-`.deep` seed path (`simplex_count == 0`). + defensive_non_deep_seed, + }; + exit: Exit, + /// Expansion loop iterations executed. + iterations: u32, + /// Number of skip events (a face excluded from selection for non-convergence). + faces_skipped: u32, + /// Whether the exhaustion fallback (not any face's distance) produced the result. + fallback_used: bool, +}; + /// One polytope face over the Minkowski difference of the cores: three vertex /// indices (CCW as seen from outside), the outward unit normal (away from the -/// enclosed origin), and the origin-to-plane distance (`normal · vertex`, ≥ 0). +/// enclosed origin), and the origin-to-plane distance (`normal · vertex`): ≥ 0 for +/// an enclosing polytope; may be marginally NEGATIVE on a non-enclosing RD-4 band +/// seed (m1.1.3-hf), where the origin lies just outside — the downstream depth +/// clamp handles it. fn Face(comptime T: type) type { return struct { a: u32, @@ -79,10 +112,60 @@ const max_verts: usize = 4 + max_epa_iterations; // 36 const max_faces: usize = 4 * max_epa_iterations; // 128 (> 2·36 − 4 = 68) const max_silhouette: usize = 4 * max_epa_iterations; // 128 +// Coplanar-tolerance factor for `expandPolytope` visibility (brief E2(a)). A +// support within `vis_k · floatEps(T) · loop_scale` of a face's plane counts as +// beyond it — so a support COPLANAR-within-noise with faces it should replace +// removes them instead of stranding stale interior faces (the S1 depth-0 +// corruption). Bounds only float noise at the polytope coordinate scale; a +// distinct constant from the surface-convergence tolerance `rel`, matching the +// gjk.zig `conv_k` magnitude. +const vis_k = 16; + +// Coplanarity factor for `terminalFace` (brief E2 / Codex P3): a candidate face +// whose normal has `n·n_sel >= 1 - term_k·floatEps(T)` counts as on the SAME plane +// as the converged/selected face. Coplanar re-fan triangles differ only by +// per-triangle normalization noise (a few ULP), far inside this band; a genuinely +// distinct contact plane differs by a real angle and is rejected. Bounds float +// noise only. +const term_k = 64; + +/// Realizable-fallback accumulator (brief E2(e)): the minimum-depth supporting +/// hyperplane seen across the expansion's support queries. Each recorded +/// candidate `(n, d = support(n)·n)` defines a supporting plane of the Minkowski +/// difference, so `d` is a conservative (≥ true) penetration depth along a valid +/// separation axis — never the S1 under-estimate of a corrupt interior face. +/// `consider` keeps the strict minimum, first-wins on ties (deterministic). +/// Extracted as a struct so E2(h) can unit-test the selection rule directly. +fn Fallback(comptime T: type) type { + return struct { + const Self = @This(); + const Vec3T = math.Vec(3, T); + have: bool = false, + d: T = 0, + n: Vec3T = Vec3T.unit_x, + sa: Vec3T = Vec3T.zero, + sb: Vec3T = Vec3T.zero, + + fn consider(self: *Self, d: T, n: Vec3T, sa: Vec3T, sb: Vec3T) void { + if (!self.have or d < self.d) { + self.have = true; + self.d = d; + self.n = n; + self.sa = sa; + self.sb = sb; + } + } + }; +} + /// EPA on the cores of `shape_a`/`shape_b` at their world poses, seeded from the /// GJK terminal `.deep` simplex. Computes in A's frame (B via `relpose`), maps /// the result to world via `rot_a`/`pos_a`. See the file header for the frame, -/// the low-dimensional-seed contract, and determinism. +/// the low-dimensional-seed contract, and determinism. `rot_b` (B's world +/// rotation, exact input bits) is consumed ONLY by the point⊖segment degenerate +/// branch (E3): it derives the world normal intrinsically from the segment +/// owner's rotation so the two call orders bit-negate — a quantity `relpose` +/// (which folds in `conj(rot_a)`) cannot reproduce bit-exactly. pub fn epa( comptime T: type, shape_a: support.SupportShape(T), @@ -90,7 +173,9 @@ pub fn epa( rot_a: math.Quat(T), relpose: support.RelativePose(T), shape_b: support.SupportShape(T), + rot_b: math.Quat(T), seed: gjk_mod.GjkResult(T), + diag: ?*EpaDiagnostics, ) EpaResult(T) { const Vec3T = math.Vec(3, T); const VertexT = support.Vertex(T); @@ -102,6 +187,7 @@ pub fn epa( if (vcount == 0) { // Not a deep seed (defensive: `epa` is only called on `.deep`). Return a // zero-penetration result along a fixed axis rather than read undefined. + writeDiag(diag, .defensive_non_deep_seed, 0, 0, false); return degenerateFixedAxis(T, shape_a, rot_a, pos_a, relpose, shape_b); } for (0..vcount) |i| verts[i] = seed.simplex[i]; @@ -115,7 +201,8 @@ pub fn epa( // --- Expand a low-dimensional seed to a non-degenerate tetrahedron --- if (!expandToTetra(T, shape_a, relpose, shape_b, &verts, &vcount, tol_sq)) { - return degenerateResult(T, shape_a, rot_a, pos_a, relpose, shape_b, verts[0..vcount]); + writeDiag(diag, .degenerate_low_dim, 0, 0, false); + return degenerateResult(T, shape_a, rot_a, pos_a, relpose, shape_b, rot_b, verts[0..vcount]); } // Interior reference point (the tetra centroid), strictly inside the @@ -132,6 +219,8 @@ pub fn epa( const loop_scale = polytopeScale(T, verts[0..vcount]); const loop_tol = rel * loop_scale; const loop_tol_sq = loop_tol * loop_tol; + // Coplanar-tolerant visibility margin (brief E2(a)); bounds float noise only. + const vis_eps: T = vis_k * std.math.floatEps(T) * loop_scale; // --- Build the initial tetrahedron faces --- var faces: [max_faces]FaceT = undefined; @@ -145,45 +234,139 @@ pub fn epa( } if (fcount < 4) { // A degenerate seed tetra (collinear/coplanar 4th vertex slipped through). - return degenerateResult(T, shape_a, rot_a, pos_a, relpose, shape_b, verts[0..vcount]); + writeDiag(diag, .degenerate_low_dim, 0, 0, false); + return degenerateResult(T, shape_a, rot_a, pos_a, relpose, shape_b, rot_b, verts[0..vcount]); } - // --- Expanding-polytope loop --- - // `best` is set to the current closest face at the top of every iteration, so - // on any `break` it holds a valid face of the last complete polytope (a face - // copy — safe even if a bailed expansion left `faces` partially modified, - // since vertices are only ever appended). `expanded_ok` tracks the - // iteration-cap exit, where the final successful expansion left `best` one - // step stale and it must be refreshed against the (valid) grown polytope. - var best = faces[closestFaceIndex(T, faces[0..fcount])]; - var expanded_ok = false; + // --- Expanding-polytope loop (brief E2(d)/(e)/(f)) --- + // Convergence is tested FIRST and is the ONLY path that returns a face's + // polytope distance as the depth. A face that hits a non-convergence event — + // the support duplicates an existing vertex, the transactional expansion + // fails, or `max_verts` is reached — is marked SKIPPED (excluded from + // selection, topology untouched) and the loop retries the next-closest + // non-skipped face; it NEVER returns that face's distance. A successful + // expansion rebuilds the face list, so the index-keyed skip marks are REMAPPED + // in lockstep with the compaction (a kept face keeps its flag). Every support + // query records a realizable fallback candidate; on exhaustion + // (no selectable face) the minimum-depth candidate is returned instead of any + // rejected face's distance. + var skipped: [max_faces]bool = undefined; + @memset(skipped[0..fcount], false); + var faces_skipped: u32 = 0; + var fb: Fallback(T) = .{}; + + var converged: ?FaceT = null; var iter: u32 = 0; while (iter < max_epa_iterations) : (iter += 1) { - expanded_ok = false; - best = faces[closestFaceIndex(T, faces[0..fcount])]; + const sel = closestNonSkipped(T, faces[0..fcount], skipped[0..fcount]) orelse break; + const best = faces[sel]; const w = support.minkowskiSupport(T, shape_a, relpose, shape_b, best.normal); const d = w.w.dot(best.normal); - // Converged: the support in the closest face's normal does not advance - // past its plane beyond float noise ⇒ the face is on the surface. - if (d - best.dist <= loop_tol) break; - // Anti-cycling: the support duplicates an existing polytope vertex. - if (duplicate(T, verts[0..vcount], w.w, loop_tol_sq)) break; - if (vcount >= max_verts) break; - - // Re-triangulate: remove faces visible from `w`, add `w`, connect the - // silhouette. If the geometry degenerates, keep the current best face. - if (!expandPolytope(T, &verts, &vcount, &faces, &fcount, w)) break; - expanded_ok = true; + // Realizable fallback candidate: {x · best.normal = d} is a supporting + // hyperplane of the Minkowski difference, so `d` is a conservative depth. + fb.consider(d, best.normal, w.support_a, w.support_b); + + // Converged: the support does not advance past the plane beyond noise. + if (d - best.dist <= loop_tol) { + converged = best; + break; + } + // Non-convergence: this face cannot advance the polytope → skip it. + if (duplicate(T, verts[0..vcount], w.w, loop_tol_sq) or vcount >= max_verts) { + skipped[sel] = true; + faces_skipped += 1; + continue; + } + // Transactional expansion remaps the skip flags in lockstep with the face + // compaction (kept faces keep their flag, new fan faces start fresh), so a + // face that failed duplicate-progress STAYS skipped rather than being + // resurrected to re-fail identically — its support/dist are unchanged since + // vertices are only ever appended (Codex P2). + if (!expandPolytope(T, &verts, &vcount, &faces, &fcount, skipped[0..], w, sel, vis_eps)) { + skipped[sel] = true; + faces_skipped += 1; + } + } + + if (converged) |sel| { + writeDiag(diag, .converged, iter, faces_skipped, false); + const term = terminalFace(T, verts[0..vcount], faces[0..fcount], skipped[0..fcount], sel, loop_tol); + return reconstructFromFace(T, verts[0..vcount], term, rot_a, pos_a); + } + // Not converged: an iteration-cap exit (a non-skipped face still selectable) + // returns the best terminal face on the selected plane; only true exhaustion + // (all skipped) falls back. + if (closestNonSkipped(T, faces[0..fcount], skipped[0..fcount])) |sel_i| { + writeDiag(diag, .iteration_cap, iter, faces_skipped, false); + const term = terminalFace(T, verts[0..vcount], faces[0..fcount], skipped[0..fcount], faces[sel_i], loop_tol); + return reconstructFromFace(T, verts[0..vcount], term, rot_a, pos_a); + } + writeDiag(diag, .fallback_exhausted, iter, faces_skipped, true); + if (fb.have) { + return worldResult(T, rot_a, pos_a, fb.n, @max(fb.d, 0), fb.sa, fb.sb); + } + return worldResult(T, rot_a, pos_a, Vec3T.unit_x, 0, Vec3T.zero, Vec3T.zero); +} + +/// Write EPA diagnostics through a caller-owned pointer; a no-op on `null` +/// (brief E2(g) — no retained state when diagnostics are off). +fn writeDiag(diag: ?*EpaDiagnostics, exit: EpaDiagnostics.Exit, iterations: u32, faces_skipped: u32, fallback_used: bool) void { + if (diag) |p| p.* = .{ + .exit = exit, + .iterations = iterations, + .faces_skipped = faces_skipped, + .fallback_used = fallback_used, + }; +} + +/// Squared distance from the origin to a face's triangle (its closest point). +fn triClosestSq(comptime T: type, verts: []const support.Vertex(T), f: Face(T)) T { + const tri = gjk_mod.Simplex(T).closestOriginTriangle(verts[f.a].w, verts[f.b].w, verts[f.c].w); + return tri.closest.dot(tri.closest); +} + +/// The terminal face to reconstruct from, given the SELECTED converged / cap face +/// `sel`. Among NON-SKIPPED faces COPLANAR with `sel` (normal within +/// `term_k·floatEps(T)` of parallel AND plane distance within `tol` of `sel.dist`), +/// the one whose triangle is genuinely closest to the origin. A flat contact face +/// is split by the re-fan into coplanar triangles that all share the minimal +/// `dist`, but only the one containing the origin's projection yields the true +/// closest point, and picking first-by-index (as `closestNonSkipped` does for +/// expansion) can clamp the reconstructed point to the wrong sub-triangle. +/// Restricting to `sel`'s PLANE (not merely near-minimal distance) guarantees we +/// never adopt a DIFFERENT-normal face that was never proven converged, and never +/// move the depth (a coplanar face shares `sel.dist`). SKIPPED faces are excluded +/// (returning a skipped face's distance is the S1 form E2(d) forbids). Falls back +/// to `sel` when no coplanar candidate is closer (Codex P3). Deterministic (first +/// wins on ties). +fn terminalFace(comptime T: type, verts: []const support.Vertex(T), faces: []const Face(T), skipped: []const bool, sel: Face(T), tol: T) Face(T) { + const coplanar_min: T = 1 - term_k * std.math.floatEps(T); + var best = sel; + var best_c2 = triClosestSq(T, verts, sel); + for (faces, 0..) |f, i| { + if (skipped[i]) continue; + if (f.normal.dot(sel.normal) < coplanar_min) continue; // not coplanar with sel's plane + if (@abs(f.dist - sel.dist) > tol) continue; + const c2 = triClosestSq(T, verts, f); + if (c2 < best_c2) { + best_c2 = c2; + best = f; + } } - if (expanded_ok) best = faces[closestFaceIndex(T, faces[0..fcount])]; + return best; +} - // --- Reconstruct closest points from the terminal face barycentrics --- +/// Reconstruct the world-space `EpaResult` from a terminal face: normal + core +/// depth from the face, closest points from the face barycentrics (as GJK does), +/// all mapped to world via `rot_a`/`pos_a`. +fn reconstructFromFace(comptime T: type, verts: []const support.Vertex(T), best: Face(T), rot_a: math.Quat(T), pos_a: math.Vec(3, T)) EpaResult(T) { + const Vec3T = math.Vec(3, T); const va = verts[best.a]; const vb = verts[best.b]; const vc = verts[best.c]; const tri = gjk_mod.Simplex(T).closestOriginTriangle(va.w, vb.w, vc.w); - const face_verts = [3]VertexT{ va, vb, vc }; + const face_verts = [3]support.Vertex(T){ va, vb, vc }; var ca = Vec3T.zero; var cb = Vec3T.zero; for (0..tri.count) |i| { @@ -191,7 +374,6 @@ pub fn epa( ca = ca.add(fv.support_a.scale(tri.bary[i])); cb = cb.add(fv.support_b.scale(tri.bary[i])); } - return .{ .normal = rot_a.rotateVec3(best.normal), .depth = @max(best.dist, 0), @@ -247,33 +429,36 @@ fn makeFace(comptime T: type, verts: *const [max_verts]support.Vertex(T), ia: u3 return .{ .a = ia, .b = ib, .c = ic, .normal = normal, .dist = normal.dot(va) }; } -/// Build a fan face (a silhouette edge `ia→ib` closed to the new vertex `ic`), -/// PRESERVING the inherited horizon winding rather than reorienting against the -/// interior point. The horizon edges are collected from the (consistently -/// outward-wound) visible faces, so `(ia, ib, ic)` is already outward-CCW and the -/// resulting fan is winding-consistent with the kept neighbours by construction — -/// no interior sign test, which can flip for a face near-tangent to the interior -/// (adversarial-review finding 1). Returns null for a sliver (near-collinear) -/// triangle, which the caller treats as a bail-out (keeping the current best -/// face) rather than leaving a topological hole (finding 2). -fn makeWoundFace(comptime T: type, verts: *const [max_verts]support.Vertex(T), ia: u32, ib: u32, ic: u32) ?Face(T) { +/// Build a fan face — a silhouette edge `ia→ib` closed to the new vertex, whose +/// index will be `ic` and whose position is `cpos` (passed explicitly because the +/// transactional expansion validates ALL fan faces BEFORE the vertex is appended, +/// brief E2(c)). PRESERVES the inherited horizon winding: the silhouette edges +/// come from consistently outward-wound removed faces, so `(ia, ib, new)` is +/// already outward-CCW and the fan is winding-consistent with the kept neighbours +/// by construction — no interior sign test, which can flip for a face near-tangent +/// to the interior. Returns null for a sliver (near-collinear) triangle, which +/// aborts the transactional expansion with the polytope observably unmodified. +fn makeWoundFaceAt(comptime T: type, verts: *const [max_verts]support.Vertex(T), ia: u32, ib: u32, ic: u32, cpos: math.Vec(3, T)) ?Face(T) { const va = verts[ia].w; - const n = verts[ib].w.sub(va).cross(verts[ic].w.sub(va)); + const n = verts[ib].w.sub(va).cross(cpos.sub(va)); const len_sq = n.dot(n); if (!(len_sq > 0)) return null; // sliver / degenerate triangle const normal = n.scale(1.0 / @sqrt(len_sq)); return .{ .a = ia, .b = ib, .c = ic, .normal = normal, .dist = normal.dot(va) }; } -/// Index of the face closest to the origin (minimum `dist`; first wins on ties — -/// deterministic). -fn closestFaceIndex(comptime T: type, faces: []const Face(T)) usize { - var best: usize = 0; - var best_d: T = faces[0].dist; +/// Index of the closest NON-SKIPPED face (minimum `dist`; first wins on ties — +/// deterministic). Null when every face is skipped — the exhaustion signal that +/// drives the realizable fallback (brief E2(d)/(e)). A skipped face is never +/// selected. +fn closestNonSkipped(comptime T: type, faces: []const Face(T), skipped: []const bool) ?usize { + var best: ?usize = null; + var best_d: T = 0; for (faces, 0..) |f, i| { - if (f.dist < best_d) { - best_d = f.dist; + if (skipped[i]) continue; + if (best == null or f.dist < best_d) { best = i; + best_d = f.dist; } } return best; @@ -289,40 +474,81 @@ fn duplicate(comptime T: type, verts: []const support.Vertex(T), w: math.Vec(3, return false; } -/// One EPA expansion step: append `w`, delete every face visible from it, and -/// re-triangulate the silhouette (each horizon edge → a new face to `w`). Returns -/// false if the step degenerates (no visible face, no silhouette, or every new -/// face is a sliver) — the caller then keeps the current best face. +/// One EPA expansion step (brief E2(a)/(b)/(c)): re-triangulate so the new +/// support `w` becomes a vertex. COMMIT-ON-SUCCESS — every fan face is validated +/// in scratch first, and the polytope is mutated only if the whole expansion is +/// valid; on ANY failure it is observably UNMODIFIED, so the caller can skip this +/// face and retry another. Steps: +/// (a) visibility with a coplanar tolerance `vis_eps` — a support coplanar +/// within noise removes the face instead of stranding it (the S1 fix); +/// (b) restrict the removal set to the edge-connected component of visible faces +/// containing `closest` (visible by construction), so a relaxed visibility +/// cannot carve a disconnected region into multiple horizon loops (Jolt); +/// (c) fan the component's silhouette to `w`, validating all fan faces first. +/// Returns false (no mutation) when the closest face is not visible, the +/// silhouette is empty, a fan face is a sliver, or the buffers cannot hold it. +/// On commit, `skipped` is remapped in lockstep with the face compaction — a kept +/// face carries its flag to its new index, a new fan face starts unskipped — so a +/// face known to fail is not resurrected (Codex P2). fn expandPolytope( comptime T: type, verts: *[max_verts]support.Vertex(T), vcount: *usize, faces: *[max_faces]Face(T), fcount: *usize, + skipped: []bool, w: support.Vertex(T), + closest: usize, + vis_eps: T, ) bool { const FaceT = Face(T); + const n = fcount.*; + + // (a) Coplanar-tolerant visibility: `w` at/beyond a face's plane within noise. var visible: [max_faces]bool = undefined; - var any_visible = false; - for (0..fcount.*) |fi| { + for (0..n) |fi| { const f = faces[fi]; - // Visible iff `w` is strictly beyond this face's plane. - visible[fi] = w.w.sub(verts[f.a].w).dot(f.normal) > 0; - if (visible[fi]) any_visible = true; + visible[fi] = w.w.sub(verts[f.a].w).dot(f.normal) >= -vis_eps; + } + if (!visible[closest]) return false; // closest is visible by construction; defensive + + // (b) Flood-fill the connected visible component containing `closest` across + // shared (reversed-twin) edges. Only this component is removed. + var remove: [max_faces]bool = undefined; + @memset(remove[0..n], false); + remove[closest] = true; + var stack: [max_faces]usize = undefined; + stack[0] = closest; + var sp: usize = 1; + while (sp > 0) { + sp -= 1; + const cur = stack[sp]; + const f = faces[cur]; + const edges = [3][2]u32{ .{ f.a, f.b }, .{ f.b, f.c }, .{ f.c, f.a } }; + for (edges) |e| { + if (faceWithEdge(FaceT, faces[0..n], cur, e[1], e[0])) |nb| { + if (visible[nb] and !remove[nb]) { + remove[nb] = true; + stack[sp] = nb; + sp += 1; + } + } + } } - if (!any_visible) return false; - // Collect the silhouette: a directed edge (i,j) of a visible face whose twin - // (j,i) belongs to no other visible face (the horizon between removed and - // kept faces). Consistent winding makes twins reversed. + // Silhouette: an edge of a removed face whose twin is not itself removed. var sil: [max_silhouette][2]u32 = undefined; var scount: usize = 0; - for (0..fcount.*) |fi| { - if (!visible[fi]) continue; + var removed_count: usize = 0; + for (0..n) |fi| { + if (!remove[fi]) continue; + removed_count += 1; const f = faces[fi]; const edges = [3][2]u32{ .{ f.a, f.b }, .{ f.b, f.c }, .{ f.c, f.a } }; for (edges) |e| { - if (!twinAmongVisible(FaceT, faces[0..fcount.*], visible[0..fcount.*], fi, e[1], e[0])) { + const twin = faceWithEdge(FaceT, faces[0..n], fi, e[1], e[0]); + const twin_removed = if (twin) |ti| remove[ti] else false; + if (!twin_removed) { if (scount >= max_silhouette) return false; sil[scount] = e; scount += 1; @@ -331,46 +557,56 @@ fn expandPolytope( } if (scount == 0) return false; - // Compact out the visible faces. + // (c) Validate the whole expansion in scratch BEFORE any mutation. + if (vcount.* >= max_verts) return false; + if ((n - removed_count) + scount > max_faces) return false; + const wi: u32 = @intCast(vcount.*); + var fan: [max_silhouette]FaceT = undefined; + for (0..scount) |si| { + fan[si] = makeWoundFaceAt(T, verts, sil[si][0], sil[si][1], wi, w.w) orelse return false; + } + + // Commit: compact out removed faces (remapping `skipped` in lockstep — a kept + // face carries its flag to its new index), append the vertex, append the fan + // (new faces start unskipped). `nf <= fi` throughout, so the in-place remap + // never overwrites an unread entry. var nf: usize = 0; - for (0..fcount.*) |fi| { - if (!visible[fi]) { + for (0..n) |fi| { + if (!remove[fi]) { faces[nf] = faces[fi]; + skipped[nf] = skipped[fi]; nf += 1; } } fcount.* = nf; - - // Append the new vertex. - const wi: u32 = @intCast(vcount.*); verts[vcount.*] = w; vcount.* += 1; - - // Fan the silhouette to the new vertex, preserving inherited winding. A - // sliver fan face would leave a topological hole (breaking the manifold - // invariant `twinAmongVisible` relies on), so bail out cleanly instead. - for (sil[0..scount]) |e| { - const f = makeWoundFace(T, verts, e[0], e[1], wi) orelse return false; - if (fcount.* >= max_faces) return false; - faces[fcount.*] = f; + for (0..scount) |si| { + skipped[fcount.*] = false; + faces[fcount.*] = fan[si]; fcount.* += 1; } return fcount.* >= 4; } -/// Whether directed edge (x,y) belongs to any visible face other than `skip`. -fn twinAmongVisible(comptime FaceT: type, faces: []const FaceT, visible: []const bool, skip: usize, x: u32, y: u32) bool { +/// Index of the face containing directed edge `(x, y)`, other than `skip` (null +/// if none). Consistent winding makes an interior edge appear as `(x, y)` in one +/// face and `(y, x)` in its neighbour — so this finds both horizon adjacency +/// (flood-fill) and silhouette twins. +fn faceWithEdge(comptime FaceT: type, faces: []const FaceT, skip: usize, x: u32, y: u32) ?usize { for (faces, 0..) |f, fi| { - if (fi == skip or !visible[fi]) continue; - if ((f.a == x and f.b == y) or (f.b == x and f.c == y) or (f.c == x and f.a == y)) return true; + if (fi == skip) continue; + if ((f.a == x and f.b == y) or (f.b == x and f.c == y) or (f.c == x and f.a == y)) return fi; } - return false; + return null; } -/// Progressively add supports to reach a non-degenerate origin-enclosing -/// tetrahedron from a 1..4-vertex seed. Returns false when the Minkowski -/// difference is genuinely < 3-D (no tetra exists) — the caller then returns a -/// degenerate (zero-penetration) result. Blow-up directions are deterministic. +/// Progressively add supports to reach a non-degenerate tetrahedron around the +/// seed from a 1..4-vertex seed — origin-enclosing for a genuine deep seed; for an +/// RD-4 band seed (m1.1.3-hf) the origin may lie marginally outside (the +/// downstream depth clamp handles it). Returns false when the Minkowski difference +/// is genuinely < 3-D (no tetra exists) — the caller then returns a degenerate +/// (zero-penetration) result. Blow-up directions are deterministic. fn expandToTetra( comptime T: type, shape_a: support.SupportShape(T), @@ -463,6 +699,18 @@ fn distToLineSq(comptime T: type, p: math.Vec(3, T), a: math.Vec(3, T), b: math. return p.sub(closest).dot(p.sub(closest)); } +/// If exactly one core is a segment and the other a point, returns whether the +/// segment is `shape_a` (`true`) or `shape_b` (`false`); `null` otherwise. Drives +/// the E3 intrinsic world-space degenerate normal (point⊖segment only). +fn pointSegmentPair(comptime T: type, shape_a: support.SupportShape(T), shape_b: support.SupportShape(T)) ?bool { + const Tag = std.meta.Tag(support.SupportShape(T).Core); + const ta = std.meta.activeTag(shape_a.core); + const tb = std.meta.activeTag(shape_b.core); + if (ta == Tag.segment and tb == Tag.point) return true; // segment is shape_a + if (ta == Tag.point and tb == Tag.segment) return false; // segment is shape_b + return null; +} + /// Degenerate result for a < 3-D Minkowski difference (the cores touch along a /// point / line / plane — zero core penetration). Returns a deterministic unit /// separation normal and depth 0, with the closest points on the touching @@ -475,13 +723,12 @@ fn degenerateResult( pos_a: math.Vec(3, T), relpose: support.RelativePose(T), shape_b: support.SupportShape(T), + rot_b: math.Quat(T), verts: []const support.Vertex(T), ) EpaResult(T) { const Vec3T = math.Vec(3, T); const S = gjk_mod.Simplex(T); - _ = shape_a; _ = relpose; - _ = shape_b; switch (verts.len) { 1 => { @@ -489,16 +736,37 @@ fn degenerateResult( return worldResult(T, rot_a, pos_a, Vec3T.unit_x, 0, verts[0].support_a, verts[0].support_b); }, 2 => { - // Segment-like: normal ⟂ the segment; closest on the segment. + // Segment-like: closest on the segment; normal ⟂ the segment. const seg = S.closestOriginSegment(verts[0].w, verts[1].w); - const n = perpAxis(T, verts[1].w.sub(verts[0].w)); var ca = Vec3T.zero; var cb = Vec3T.zero; for (0..seg.count) |i| { ca = ca.add(verts[seg.indices[i]].support_a.scale(seg.bary[i])); cb = cb.add(verts[seg.indices[i]].support_b.scale(seg.bary[i])); } - return worldResult(T, rot_a, pos_a, n, @max(@sqrt(seg.closest.dot(seg.closest)), 0), ca, cb); + const depth = @max(@sqrt(seg.closest.dot(seg.closest)), 0); + // E3 — point⊖segment (a 1-D Minkowski difference) derives its + // perpendicular normal INTRINSICALLY in world from the segment-owning + // shape's world rotation with EXACT input bits and an explicit + // ownership sign, so `v_world(B,A) = −v_world(A,B)` and (perpAxis being + // odd with a `|·|`-invariant axis) the two call orders return exact bit + // negations. `n_world` is already world, so it bypasses the `worldResult` + // `rot_a` mapping; closest points stay barycentric + `rot_a`-mapped. + if (pointSegmentPair(T, shape_a, shape_b)) |seg_is_a| { + const rot_seg = if (seg_is_a) rot_a else rot_b; + const s: T = if (seg_is_a) 1 else -1; + const v_world = rot_seg.rotateVec3(Vec3T.unit_y).scale(s); + return .{ + .normal = perpAxis(T, v_world), + .depth = depth, + .closest_a = rot_a.rotateVec3(ca).add(pos_a), + .closest_b = rot_a.rotateVec3(cb).add(pos_a), + }; + } + // Other 2-vertex cases (segment⊖segment collinear, …): the A-frame + // segment direction, best-effort (no cross-order exactness claimed). + const n = perpAxis(T, verts[1].w.sub(verts[0].w)); + return worldResult(T, rot_a, pos_a, n, depth, ca, cb); }, else => { // Planar (≥ 3 verts): normal = the plane normal; closest on the triangle. @@ -545,3 +813,126 @@ fn worldResult(comptime T: type, rot_a: math.Quat(T), pos_a: math.Vec(3, T), nor .closest_b = rot_a.rotateVec3(cb).add(pos_a), }; } + +// --- In-file fallback / skip unit tests (brief E2(h)) --- + +test "epa skipped faces are never selected" { + const F = Face(f32); + const nx = math.Vec(3, f32).unit_x; + var faces = [_]F{ + .{ .a = 0, .b = 0, .c = 0, .normal = nx, .dist = 0.5 }, // closest + .{ .a = 0, .b = 0, .c = 0, .normal = nx, .dist = 1.0 }, + .{ .a = 0, .b = 0, .c = 0, .normal = nx, .dist = 2.0 }, + }; + var skipped = [_]bool{ false, false, false }; + try std.testing.expectEqual(@as(?usize, 0), closestNonSkipped(f32, &faces, &skipped)); + skipped[0] = true; // the closest is now skipped and must never be selected + try std.testing.expectEqual(@as(?usize, 1), closestNonSkipped(f32, &faces, &skipped)); + skipped[1] = true; + try std.testing.expectEqual(@as(?usize, 2), closestNonSkipped(f32, &faces, &skipped)); + skipped[2] = true; // all skipped → exhaustion (null drives the fallback) + try std.testing.expectEqual(@as(?usize, null), closestNonSkipped(f32, &faces, &skipped)); +} + +test "epa exhaustion fallback returns the minimal realizable candidate" { + const V = math.Vec(3, f32); + var fb: Fallback(f32) = .{}; + try std.testing.expect(!fb.have); + fb.consider(2.0, V.unit_x, V.zero, V.zero); + fb.consider(1.0, V.unit_y, V.zero, V.zero); // new minimum depth + fb.consider(1.5, V.unit_z, V.zero, V.zero); // not the minimum + try std.testing.expect(fb.have); + try std.testing.expectEqual(@as(f32, 1.0), fb.d); + try std.testing.expect(fb.n.eql(V.unit_y)); +} + +test "epa fallback selection is deterministic (first wins on ties)" { + const V = math.Vec(3, f32); + var fb: Fallback(f32) = .{}; + fb.consider(1.0, V.unit_x, V.zero, V.zero); + fb.consider(1.0, V.unit_y, V.zero, V.zero); // equal depth must NOT replace (strict <) + try std.testing.expect(fb.n.eql(V.unit_x)); +} + +test "epa terminalFace ignores a skipped coplanar face with a smaller closest" { + const V = math.Vec(3, f32); + const Vx = support.Vertex(f32); + const z = V.zero; + // sel (face 0): plane y = 1, a FAR triangle whose closest-to-origin (~4.4) is + // clamped to its edge (projection outside it). Face 1: a COPLANAR sibling on + // y = 1 containing the projection (closest 1.0), but SKIPPED. terminalFace must + // return sel — a skipped face's distance is never adopted (E2(d)). + const verts = [_]Vx{ + .{ .w = V.fromArray(.{ 3, 1, 3 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 4, 1, 3 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 3.5, 1, 4 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ -1, 1, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 1, 1, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 0, 1, 1 }), .support_a = z, .support_b = z }, + }; + const sel = Face(f32){ .a = 0, .b = 1, .c = 2, .normal = V.unit_y, .dist = 1.0 }; + const faces = [_]Face(f32){ sel, .{ .a = 3, .b = 4, .c = 5, .normal = V.unit_y, .dist = 1.0 } }; + const skipped = [_]bool{ false, true }; + const t = terminalFace(f32, &verts, &faces, &skipped, sel, 1.0e-4); + try std.testing.expect(t.a == 0 and t.b == 1 and t.c == 2); // sel, not the skipped sibling +} + +test "epa terminalFace rejects a different-normal face in the distance band" { + const V = math.Vec(3, f32); + const Vx = support.Vertex(f32); + const z = V.zero; + // sel (face 0): plane x = 1, closest-to-origin 1.0. Face 1: plane y = 0.7 + // (a DIFFERENT normal) within the distance band, whose closest 0.7 is smaller + // — a plane-agnostic scan would adopt it, but its normal was never proven + // converged, so terminalFace must reject it and keep sel's plane (Codex P3). + const verts = [_]Vx{ + .{ .w = V.fromArray(.{ 1, -1, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 1, 1, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 1, 0, 1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ -1, 0.7, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 1, 0.7, -1 }), .support_a = z, .support_b = z }, + .{ .w = V.fromArray(.{ 0, 0.7, 1 }), .support_a = z, .support_b = z }, + }; + const sel = Face(f32){ .a = 0, .b = 1, .c = 2, .normal = V.unit_x, .dist = 1.0 }; + const faces = [_]Face(f32){ sel, .{ .a = 3, .b = 4, .c = 5, .normal = V.unit_y, .dist = 0.7 } }; + const skipped = [_]bool{ false, false }; + const t = terminalFace(f32, &verts, &faces, &skipped, sel, 0.5); + try std.testing.expect(t.normal.eql(V.unit_x)); // kept sel's plane, rejected the +Y face +} + +test "epa expandPolytope keeps a kept face's skip flag through a successful expansion" { + const V = math.Vec(3, f32); + const Vx = support.Vertex(f32); + const z = V.zero; + // A regular tetra around the origin; expand beyond face 0 only (faces 1-3 kept). + // Mark kept face 1 skipped BEFORE the expansion — the lockstep remap must carry + // the flag to its new compacted index (Codex P2: no resurrection). + var verts: [max_verts]Vx = undefined; + verts[0] = .{ .w = V.fromArray(.{ 1, 1, 1 }), .support_a = z, .support_b = z }; + verts[1] = .{ .w = V.fromArray(.{ 1, -1, -1 }), .support_a = z, .support_b = z }; + verts[2] = .{ .w = V.fromArray(.{ -1, 1, -1 }), .support_a = z, .support_b = z }; + verts[3] = .{ .w = V.fromArray(.{ -1, -1, 1 }), .support_a = z, .support_b = z }; + var vcount: usize = 4; + var faces: [max_faces]Face(f32) = undefined; + var fcount: usize = 0; + const tetra = [4][3]u32{ .{ 0, 1, 2 }, .{ 0, 1, 3 }, .{ 0, 2, 3 }, .{ 1, 2, 3 } }; + for (tetra) |t| { + faces[fcount] = makeFace(f32, &verts, t[0], t[1], t[2], V.zero).?; + fcount += 1; + } + var skipped: [max_faces]bool = undefined; + @memset(skipped[0..fcount], false); + const kept = faces[1]; // a face NOT beyond `w` → kept + skipped[1] = true; + // `w` just past face 0's plane makes ONLY face 0 visible (the others sit behind). + const w = Vx{ .w = faces[0].normal.scale(faces[0].dist + 1), .support_a = z, .support_b = z }; + try std.testing.expect(expandPolytope(f32, &verts, &vcount, &faces, &fcount, skipped[0..], w, 0, 1.0e-5)); + var found = false; + for (0..fcount) |i| { + if (faces[i].a == kept.a and faces[i].b == kept.b and faces[i].c == kept.c) { + try std.testing.expect(skipped[i]); // flag survived the compaction + found = true; + } + } + try std.testing.expect(found); +} diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/gjk.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/gjk.zig index cfec418..64e78d7 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/gjk.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/gjk.zig @@ -310,9 +310,12 @@ pub const max_gjk_iterations: u32 = 32; /// - `.separated` / `.shallow`: `distance` (core distance) and `closest_a` / /// `closest_b` (the closest points on each core, **world** space) are valid; /// `simplex_count` is 0. -/// - `.deep`: `simplex[0..simplex_count]` is the terminal origin-enclosing -/// simplex — the EPA seed for M1.1.3; `distance` is 0 and the closest points -/// are unspecified. No depth / normal is computed here (that is M1.1.3). +/// - `.deep`: `simplex[0..simplex_count]` is the terminal simplex — either an +/// origin-ENCLOSING simplex, OR (M1.1.3-HF RD-4) a terminal within the +/// accumulated-rounding band of the origin (a Minkowski witness at noise +/// distance from the origin, NOT necessarily enclosing) — the EPA seed; +/// `distance` is 0 and the closest points are unspecified. No depth / normal is +/// computed here (that is M1.1.3 / EPA). pub fn GjkResult(comptime T: type) type { return struct { const Vec3T = math.Vec(3, T); @@ -331,7 +334,8 @@ pub fn GjkResult(comptime T: type) type { closest_a: Vec3T, /// Closest point on B's core, world space (`.separated`/`.shallow`). closest_b: Vec3T, - /// Terminal origin-enclosing simplex (`.deep`; entries `[0..simplex_count]`). + /// Terminal `.deep` simplex — origin-enclosing OR the RD-4 rounding-band + /// terminal (not necessarily enclosing); entries `[0..simplex_count]`. simplex: [4]Vertex, /// Number of valid `simplex` entries (`.deep`: 1..4; otherwise 0). simplex_count: u8, @@ -348,10 +352,17 @@ pub fn GjkResult(comptime T: type) type { /// /// Classification (brief Notes): if the terminal simplex encloses the origin the /// cores intersect → `.deep`; otherwise the converged core distance `dist` gives -/// `.separated` iff `dist − (r_a + r_b)` exceeds the contact margin (an absolute -/// float-noise margin `conv_k · floatEps(T) · coordScale`, see the tolerance -/// block), else `.shallow`. An exact inflated touch (`dist == r_a + r_b`), and a -/// separation within that noise margin, both count as shallow. +/// `.deep` iff `dist ≤ the contact margin` (the RD-4 witness band, m1.1.3-hf — a +/// Minkowski point at noise distance from the origin; the terminal is NOT +/// necessarily enclosing), `.separated` iff `dist − (r_a + r_b)` exceeds it, else +/// `.shallow`. The contact margin is an absolute float-noise bound +/// `conv_k · floatEps(T) · coordScale` (see the tolerance block). An exact inflated +/// touch (`dist == r_a + r_b`) stays shallow iff `r_sum > contact_margin` — the +/// RD-4 band is evaluated FIRST, so a sub-noise inflation radius +/// (`0 < r_sum <= contact_margin`) classifies deep; benign either way: EPA clamps +/// depth to ~0 and the manifold penetration is ~`r_sum` in both regimes. For hard +/// cores (`r_sum == 0`) the shallow band is empty — an exact touch (`dist == 0`) is +/// the RD-4 deep band. pub fn gjk( comptime T: type, shape_a: support.SupportShape(T), @@ -492,11 +503,38 @@ pub fn gjk( // independent — unlike the terminal simplex's A-frame support magnitude, // which after cancellation reflects only who is A and made a tangency read // `.separated` in one order and `.shallow` in the other (P1c). The comparison - // is additive on the already-computed `dist`; the frozen convention keeps an - // exact inflated touch (`dist == r_sum`) shallow. + // is additive on the already-computed `dist`. It is reached only after the + // RD-4 band below (`dist <= contact_margin`) did not fire, so the frozen + // convention keeps an exact inflated touch (`dist == r_sum`) shallow exactly + // when `r_sum > contact_margin` — a sub-noise `r_sum` is caught deep above. const r_sum = shape_a.radius + shape_b.radius; const coord_scale = pos_b.sub(pos_a).length() + coreExtent(T, shape_a) + coreExtent(T, shape_b); const contact_margin = conv_k * std.math.floatEps(T) * coord_scale; + // RD-4 — deep band (m1.1.3-hf, C′). A terminal within `contact_margin` of the + // ORIGIN is a POSITIVE witness of enclosure: a Minkowski point at noise + // distance from the origin ⇒ the cores touch to measurement precision ⇒ the + // deep regime by definition. This holds at EVERY loop exit — the progress-test + // break, the anti-cycling duplicate break, and the iteration bound — so a + // single post-loop test covers all three (a convergence stall can leave a + // NON-enclosing terminal ~2.66·floatEps·scale from the origin, above the + // `degenerateOriginReached` noise floor, which then read `.shallow` at dist ≈ 0 + // on a genuinely-deep overlap: the arm64 f32-unit / f64-×0.01 stall). The + // margin REUSES `contact_margin` in its existing role — the accumulated + // `dist`-rounding bound — applied to the origin-side boundary of the SAME + // classification; `noise_k` (the distinct in-loop point-noise floor) is + // UNTOUCHED, so the P1b/P2 calibration is preserved. `coord_scale` is symmetric + // under an A/B swap by construction (see above), so the band is + // order-independent. Three bands result: `[0, m]` deep, `(m, r_sum + m]` + // shallow, beyond `r_sum + m` separated — the inflated touch `dist == r_sum` + // stays shallow IFF `r_sum > m` (the `[0, m]` deep band is checked FIRST, so a + // sub-noise radius `0 < r_sum <= m` lands the touch in the deep band); for + // `r_sum == 0` the `[0, m]` band absorbs `dist == 0` (hard cores want EPA's MTV, + // not a pen ≈ 0 witness). Benign either way — EPA clamps depth to ≈ 0 and the + // manifold penetration is ≈ r_sum in both regimes. A false-deep on a true + // near-touch (`dist ∈ (0, m]`, cores actually disjoint) seeds EPA from a + // non-enclosing terminal → depth clamps to 0 → manifold penetration + // `r_sum − 0`, the former shallow result to within ε — safe. + if (dist <= contact_margin) return deepResult(T, verts, count); return .{ .status = if (dist - r_sum > contact_margin) Res.Status.separated else Res.Status.shallow, .distance = dist, diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index 60e808a..4d208ab 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -176,7 +176,7 @@ pub fn collideOrderedGeneric( var closest_b: Vec3T = undefined; var base_penetration: T = undefined; if (g.status == .deep) { - const e = epa_mod.epa(T, shape_a, pos_a, rot_a, relpose, shape_b, g); + const e = epa_mod.epa(T, shape_a, pos_a, rot_a, relpose, shape_b, rot_b, g, null); n_world = e.normal; closest_a = e.closest_a; closest_b = e.closest_b; diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index f2b73e2..06293e2 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -46,6 +46,9 @@ pub const gjk = gjk_mod.gjk; /// EPA penetration result (world normal A→B, core depth, world closest points). pub const EpaResult = epa_mod.EpaResult; +/// Optional per-call EPA diagnostics (exit kind, iterations, skips, fallback) — +/// a test/tooling seam, not part of the frozen `EpaResult` contract. +pub const EpaDiagnostics = epa_mod.EpaDiagnostics; /// The EPA expansion iteration ceiling (scalar-independent). pub const max_epa_iterations = epa_mod.max_epa_iterations; /// EPA over a `.deep` GJK seed — penetration axis + core depth. diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index c8dbb2e..61f3585 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -102,8 +102,8 @@ pub const ContactPoint = narrowphase.ContactPoint(Real); /// EPA over a `.deep` GJK seed — penetration axis + core depth. The `Real`-bound /// entry; `collide` runs it internally on the deep path. -pub fn epa(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, relpose: RelativePose, shape_b: SupportShape, seed: GjkResult) EpaResult { - return narrowphase.epa(Real, shape_a, pos_a, rot_a, relpose, shape_b, seed); +pub fn epa(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, relpose: RelativePose, shape_b: SupportShape, rot_b: Quatr, seed: GjkResult) EpaResult { + return narrowphase.epa(Real, shape_a, pos_a, rot_a, relpose, shape_b, rot_b, seed, null); } /// Full narrowphase (GJK → shallow/deep contact manifold) between two support @@ -159,4 +159,5 @@ comptime { _ = @import("tests/epa_test.zig"); _ = @import("tests/manifold_test.zig"); _ = @import("tests/fast_paths_test.zig"); + _ = @import("tests/epa_robustness_test.zig"); } diff --git a/src/modules/forge/forge_3d/tests/epa_robustness_test.zig b/src/modules/forge/forge_3d/tests/epa_robustness_test.zig new file mode 100644 index 0000000..7fd5d0d --- /dev/null +++ b/src/modules/forge/forge_3d/tests/epa_robustness_test.zig @@ -0,0 +1,577 @@ +//! M1.1.3-HF — generic EPA deep-path order-dependence: reproduction suite. +//! +//! This file pins the `collideOrderedGeneric` (GJK → EPA → generateManifold) +//! order-independence contract for deep, rotated convex pairs against +//! INDEPENDENT separating-axis oracles (no GJK/EPA in the oracle path). At the +//! E1 gate it is RED-first: the S1 (polytope corruption → wrong depth) and S3 +//! (1-D Minkowski degenerate normal frame-dependence) pins fail, and the +//! order-equivalence sweep exposes the frame-dependence, BEFORE any epa.zig fix +//! lands (E2/E3). The assertions target the ORACLE, never a recon transcript +//! (engine-physics-forge.md §3 Order-independence; brief E1). +//! +//! Oracles: +//! - box/box: the shared 15-axis SAT `fast_paths_test.satBoxBox` (depth + axis +//! + tie-band count). +//! - segment⊖box (capsule/box): the exact 6-axis zonotope SAT `satSegBox` +//! below (3 box face axes + 3 box-axis×segment-axis crosses). + +const std = @import("std"); +const config = @import("../config.zig"); +const narrowphase = @import("../pipeline/narrowphase/root.zig"); +const fast_paths_test = @import("fast_paths_test.zig"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const Quatr = config.Quatr; +const SupportShape = narrowphase.SupportShape(Real); +const GjkResult = narrowphase.GjkResult(Real); +const ContactManifold = narrowphase.ContactManifold(Real); +const RelativePose = narrowphase.RelativePose(Real); +const EpaDiagnostics = narrowphase.EpaDiagnostics; +const testing = std.testing; + +const satBoxBox = fast_paths_test.satBoxBox; +const sat_tie_k = fast_paths_test.sat_tie_k; +const sat_dir_colinear = fast_paths_test.sat_dir_colinear; + +// Depth tolerance at unit scale — the generic EPA depth carries the full +// convergence residual of the descent + polytope expansion (the class +// fast_paths_test.diff_tol bounds), looser than an analytic bound. The sweep +// scales it by the config scale (depths scale, so does the f32 residual). +const depth_tol: Real = if (Real == f32) 5.0e-3 else 1.0e-7; +// Normal tolerance — component-wise; a unit normal is scale-independent, so this +// is NOT scaled. ~0.3° in f32; the exact-negation S3 claim uses `.eql`, not this. +const normal_tol: Real = if (Real == f32) 5.0e-3 else 1.0e-6; + +// RED-first gates (M1.1.3-HF). Each defect pin below is RED until its fix lands; +// it skips (`error.SkipZigTest`) so the pre-push `zig build test` stays green +// while the branch carries the reproduction suite for gate-by-gate review — no +// hook bypass. The observed RED values are journaled in the brief (E1). Flip to +// false per gate as the fix turns each green: s1 → E2 (epa.zig Fix A), s3 → E3 +// (intrinsic degenerate normal), sweep → E4 (full order-equivalence). +const red_gate_s1 = false; // un-gated at E2: epa.zig Fix A lands (S1 green both orders) +const red_gate_s3 = false; // un-gated at E3: intrinsic point⊖segment normal (bit-negated) +const red_gate_rd4 = false; // un-gated at E4: RD-4 gjk.zig deep-band fix (C′) lands + +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +fn boxShape(hx: Real, hy: Real, hz: Real) SupportShape { + return .{ .core = .{ .box = vr(hx, hy, hz) }, .radius = 0 }; +} + +fn sphereShape(radius: Real) SupportShape { + return .{ .core = .point, .radius = radius }; +} + +fn capsuleShape(half_height: Real, radius: Real) SupportShape { + return .{ .core = .{ .segment = half_height }, .radius = radius }; +} + +/// The deepest per-point penetration of a manifold — the true MTV depth for a +/// deep contact (mirror of fast_paths_test.maxPen). +fn maxPen(m: ContactManifold) Real { + var p: Real = 0; + for (0..m.count) |i| p = @max(p, m.points[i].penetration); + return p; +} + +// --------------------------------------------------------------------------- +// segment⊖box zonotope SAT oracle (E1(a)) +// --------------------------------------------------------------------------- + +const SegBoxSatResult = struct { + depth: Real, + axis: Vec3r, + tie_count: u32, +}; + +/// Exact segment⊖box separating-axis oracle (core MTV, radii excluded — matches +/// EPA core depth). The Minkowski difference of a segment core (capsule; a +/// half-length `half_height` segment on the shape's local Y) and a box core is a +/// 4-generator zonotope whose facet normals are the 3 box face axes plus the 3 +/// `box_axis × segment_axis` crosses — 6 candidate axes. A cross that is +/// strictly zero (box axis ∥ segment axis) carries no information and is +/// skipped. Cores assumed overlapping. +fn satSegBox(seg_center: Vec3r, seg_rot: Quatr, half_height: Real, box_center: Vec3r, box_rot: Quatr, he_v: Vec3r) SegBoxSatResult { + const s_axis = seg_rot.rotateVec3(Vec3r.unit_y); + const bx = [3]Vec3r{ + box_rot.rotateVec3(Vec3r.unit_x), + box_rot.rotateVec3(Vec3r.unit_y), + box_rot.rotateVec3(Vec3r.unit_z), + }; + const he = he_v.toArray(); + const dc = box_center.sub(seg_center); + const ov = struct { + fn f(L: Vec3r, s: Vec3r, hh: Real, b: [3]Vec3r, hb: [3]Real, d: Vec3r) Real { + var rbp: Real = 0; + for (0..3) |k| rbp += hb[k] * @abs(b[k].dot(L)); + const rsp = hh * @abs(s.dot(L)); + return rsp + rbp - @abs(d.dot(L)); + } + }.f; + + var cand_axis: [6]Vec3r = undefined; + var cand_ov: [6]Real = undefined; + var n: usize = 0; + for (0..3) |k| { + cand_axis[n] = bx[k]; + cand_ov[n] = ov(bx[k], s_axis, half_height, bx, he, dc); + n += 1; + } + for (0..3) |k| { + const l_raw = bx[k].cross(s_axis); + const l2 = l_raw.dot(l_raw); + if (l2 <= 0) continue; // box axis k ∥ segment axis — degenerate cross, skipped + const sq = @sqrt(l2); + cand_axis[n] = l_raw.scale(1.0 / sq); + cand_ov[n] = ov(l_raw, s_axis, half_height, bx, he, dc) / sq; + n += 1; + } + + var depth: Real = std.math.floatMax(Real); + var axis: Vec3r = Vec3r.unit_x; + for (0..n) |i| { + if (cand_ov[i] < depth) { + depth = cand_ov[i]; + axis = cand_axis[i]; + } + } + const coord_scale = dc.length() + half_height + he_v.length(); + const band = sat_tie_k * std.math.floatEps(Real) * coord_scale; + // Count DISTINCT minimal-band directions (colinear slots merged, v/−v same); a + // BILATERAL minimal direction (`|dc·axis| <= band`) counts twice — both signs + // are equally-minimal MTV, so the generic normal may pick the same absolute + // axis (not negated) across orders (Codex (a)). + var seen: [6]Vec3r = undefined; + var seen_count: usize = 0; + var tie_count: u32 = 0; + for (0..n) |i| { + if (cand_ov[i] - depth > band) continue; + var is_new = true; + for (0..seen_count) |j| { + if (@abs(cand_axis[i].dot(seen[j])) > sat_dir_colinear) { + is_new = false; + break; + } + } + if (is_new) { + seen[seen_count] = cand_axis[i]; + seen_count += 1; + tie_count += if (@abs(dc.dot(cand_axis[i])) <= band) 2 else 1; + } + } + return .{ .depth = depth, .axis = axis, .tie_count = tie_count }; +} + +test "segment-box zonotope sat oracle self-checks" { + // Axis-aligned capsule (segment on +Y) whose lower endpoint dips into a unit + // box's top face. Segment center (0,1.5,0), half_height 1 → segment y∈[0.5,2.5]; + // box [-1,1]^3. Overlap along +Y = 1 (he_y) + 1 (hh·|Y·Y|) − 1.5 (|dc·Y|) = 0.5, + // the minimum; along ±X/±Z the segment (x=z=0) sits inside the box so overlap + // is 1. The box-Y × segment-Y cross is exactly zero and MUST be skipped (else + // a div-by-zero would poison the result) — a finite, correct 0.5 proves it. + { + const r = satSegBox(vr(0, 1.5, 0), Quatr.identity, 1, vr(0, 0, 0), Quatr.identity, vr(1, 1, 1)); + try testing.expectApproxEqAbs(@as(Real, 0.5), r.depth, 1.0e-6); + try testing.expect(@abs(r.axis.dot(Vec3r.unit_y)) > 1 - 1.0e-6); + try testing.expectEqual(@as(u32, 1), r.tie_count); + } + // The MTV is invariant under a rigid global transform: rotate + translate the + // whole configuration and the depth is unchanged (the axis rotates with it). + { + const g = Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.83); + const t = vr(-4, 7, 2.5); + const seg_c = g.rotateVec3(vr(0, 1.5, 0)).add(t); + const box_c = g.rotateVec3(vr(0, 0, 0)).add(t); + const r = satSegBox(seg_c, g, 1, box_c, g, vr(1, 1, 1)); + try testing.expectApproxEqAbs(@as(Real, 0.5), r.depth, 1.0e-5); + } +} + +// --------------------------------------------------------------------------- +// S1 — polytope corruption → wrong depth (RED at E1, GREEN at E2) +// --------------------------------------------------------------------------- + +/// Drive one deep box/box pin in one A/B order: assert the SAT oracle sees a +/// unique minimum, that GJK classifies `.deep`, and that BOTH the raw EPA depth +/// and the generic manifold's max penetration match the oracle MTV (r_sum = 0 +/// for radius-0 boxes), with the manifold normal colinear with the oracle axis. +fn checkDeepBoxPin(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) !void { + const o = satBoxBox(pa, ra, sa.core.box, pb, rb, sb.core.box); + try testing.expectEqual(@as(u32, 1), o.tie_count); // unique minimum (oracle sanity) + + const g = narrowphase.gjk(Real, sa, pa, ra, sb, pb, rb); + try testing.expectEqual(GjkResult.Status.deep, g.status); + + const relpose = RelativePose.init(pa, ra, pb, rb); + const e = narrowphase.epa(Real, sa, pa, ra, relpose, sb, rb, g, null); + try testing.expectApproxEqAbs(o.depth, e.depth, depth_tol); + + const m_opt = narrowphase.collideOrderedGeneric(Real, sa, pa, ra, sb, pb, rb); + try testing.expect(m_opt != null); + const m = m_opt.?; + try testing.expectApproxEqAbs(o.depth, maxPen(m), depth_tol); + try testing.expect(@abs(m.normal.dot(o.axis)) > 1 - normal_tol); +} + +/// Run the deep box pin ONLY where this build classifies the pair `.deep`. Used +/// for the frozen (0.1,0.1,0.1) pitch-X config (iii), which GJK classifies +/// `.deep` on x86-64/Linux but `.shallow` on this arm64/macOS build — a +/// cross-platform float divergence on the deep/shallow boundary (RD-3; GJK is out +/// of scope). Skipping the assertion when `.shallow` (rather than failing) keeps +/// coverage on platforms that reach EPA, without falsely asserting deep here. +fn checkDeepBoxPinIfDeep(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) !void { + const g = narrowphase.gjk(Real, sa, pa, ra, sb, pb, rb); + if (g.status != GjkResult.Status.deep) return; // not deep on this platform + try checkDeepBoxPin(sa, pa, ra, sb, pb, rb); +} + +test "deep rotated box pair matches sat in both orders" { + if (red_gate_s1) return error.SkipZigTest; // RED at E1 (EPA depth 0.0 vs oracle 1.9); un-gate at E2 + const box = boxShape(1, 1, 1); + const z = Vec3r.unit_z; + const x = Vec3r.unit_x; + + // (i) roll 0.5 about Z, offset (0.2, 0.4, 0.1): MTV 1.9 along ±Z (next 1.957). + { + const rb = Quatr.fromAxisAngle(z, 0.5); + try checkDeepBoxPin(box, vr(0, 0, 0), Quatr.identity, box, vr(0.2, 0.4, 0.1), rb); + try checkDeepBoxPin(box, vr(0.2, 0.4, 0.1), rb, box, vr(0, 0, 0), Quatr.identity); + } + // (ii) roll 0.5 about Z, offset (0.1, 0.1, 0.1): MTV 1.9 along ±Z. + { + const rb = Quatr.fromAxisAngle(z, 0.5); + try checkDeepBoxPin(box, vr(0, 0, 0), Quatr.identity, box, vr(0.1, 0.1, 0.1), rb); + try checkDeepBoxPin(box, vr(0.1, 0.1, 0.1), rb, box, vr(0, 0, 0), Quatr.identity); + } + // (iii) pitch 0.4 about X. The FROZEN offset (0.1,0.1,0.1) is `.deep` on + // x86-64/Linux but `.shallow` on this arm64/macOS build (RD-3) — run + // conditionally on status so it covers platforms that reach EPA without + // falsely asserting deep here. The retargeted (0.2,0.4,0.1) is `.deep` on both + // and is the unconditional third pin. checkDeepBoxPin asserts against the SAT + // oracle (unique minimum), not a hard-coded axis. + { + const rb = Quatr.fromAxisAngle(x, 0.4); + try checkDeepBoxPinIfDeep(box, vr(0, 0, 0), Quatr.identity, box, vr(0.1, 0.1, 0.1), rb); + try checkDeepBoxPinIfDeep(box, vr(0.1, 0.1, 0.1), rb, box, vr(0, 0, 0), Quatr.identity); + try checkDeepBoxPin(box, vr(0, 0, 0), Quatr.identity, box, vr(0.2, 0.4, 0.1), rb); + try checkDeepBoxPin(box, vr(0.2, 0.4, 0.1), rb, box, vr(0, 0, 0), Quatr.identity); + } +} + +// --------------------------------------------------------------------------- +// S3 — 1-D Minkowski degenerate normal frame-dependence (RED at E1, GREEN at E3) +// --------------------------------------------------------------------------- + +test "on-axis sphere-capsule normal is exactly negated across orders" { + if (red_gate_s3) return error.SkipZigTest; // RED at E1 (normal not bit-negated across orders); un-gate at E3 + const sphere = sphereShape(0.7); + const cap = capsuleShape(1.0, 0.5); + const r_sum: Real = 1.2; + // Sphere center exactly on the capsule axis: place both centers at the same + // point (segment midpoint). point⊖segment is then 1-D — the degenerate EPA + // path — with core distance 0 (penetration = r_sum). + const center = vr(0.3, -0.2, 0.5); + const rots = [_]Quatr{ + Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0), + Quatr.fromAxisAngle(vr(1, 1, 0).normalize(), 0.7), + Quatr.identity, + }; + for (rots) |rc| { + // Manifold-level: a single witness contact, penetration = r_sum, both orders. + const ab = narrowphase.collideOrderedGeneric(Real, sphere, center, Quatr.identity, cap, center, rc); + const ba = narrowphase.collideOrderedGeneric(Real, cap, center, rc, sphere, center, Quatr.identity); + try testing.expect(ab != null and ba != null); + try testing.expectEqual(@as(u8, 1), ab.?.count); + try testing.expectEqual(@as(u8, 1), ba.?.count); + try testing.expectApproxEqAbs(r_sum, maxPen(ab.?), depth_tol); + try testing.expectApproxEqAbs(r_sum, maxPen(ba.?), depth_tol); + + // Manifold-level EXACT bit negation — the CONSUMER guarantee (M1.1.6 + // warm-start consumes manifolds, not EpaResults). On the count-1 point-core + // path, generateManifold's A-frame rotation is used ONLY for supporting-face + // selection; pointCoreContact returns `.normal = n_world` VERBATIM, so the + // E3 EPA bit-negation propagates to the manifold unchanged (a pure copy, + // platform-independent — no arithmetic on the normal between e.normal and + // the manifold). + try testing.expect(ab.?.normal.eql(ba.?.normal.neg())); + + // Complement — the same bit negation at its SOURCE, the raw epa() normal + // (the E3 intrinsic point⊖segment derivation), documenting where it arises. + const g_ab = narrowphase.gjk(Real, sphere, center, Quatr.identity, cap, center, rc); + const g_ba = narrowphase.gjk(Real, cap, center, rc, sphere, center, Quatr.identity); + try testing.expectEqual(GjkResult.Status.deep, g_ab.status); + try testing.expectEqual(GjkResult.Status.deep, g_ba.status); + const e_ab = narrowphase.epa(Real, sphere, center, Quatr.identity, RelativePose.init(center, Quatr.identity, center, rc), cap, rc, g_ab, null); + const e_ba = narrowphase.epa(Real, cap, center, rc, RelativePose.init(center, rc, center, Quatr.identity), sphere, Quatr.identity, g_ba, null); + try testing.expect(e_ab.normal.eql(e_ba.normal.neg())); + } +} + +// --------------------------------------------------------------------------- +// Order-equivalence sweep with SAT classification (E1(e); GREEN at E4) +// --------------------------------------------------------------------------- + +test "deep-boundary GJK stall classifies deep, not near-zero shallow (RD-4)" { + if (red_gate_rd4) return error.SkipZigTest; // un-gated when the gjk.zig stall fix lands + // The frozen pitch-X offset is a GJK deep/shallow-boundary stall: GJK converges + // to a non-enclosing terminal ~2.66·floatEps·scale from the origin on a + // genuinely-deep overlap, and (the noise floor being tighter) mis-reports + // `.shallow` with dist≈0 — a pen-0-vs-MTV error of the S1 class, produced by the + // GJK stage instead of EPA. It flips with scalar/scale/order (RD-4). The generic + // manifold's max penetration must match the SAT oracle at every scale, both + // orders — never the ~0 of the mis-classified shallow. + const rb = Quatr.fromAxisAngle(Vec3r.unit_x, 0.4); + const off_unit = vr(0.1, 0.1, 0.1); + const scales = [_]Real{ 0.01, 1, 100 }; + for (scales) |k| { + const box = boxShape(k, k, k); + const off = off_unit.scale(k); + const dtol = depth_tol * k; + const o0 = satBoxBox(vr(0, 0, 0), Quatr.identity, box.core.box, off, rb, box.core.box); + const m0 = narrowphase.collideOrderedGeneric(Real, box, vr(0, 0, 0), Quatr.identity, box, off, rb); + try testing.expect(m0 != null); + try testing.expectApproxEqAbs(o0.depth, maxPen(m0.?), dtol); + const o1 = satBoxBox(off, rb, box.core.box, vr(0, 0, 0), Quatr.identity, box.core.box); + const m1 = narrowphase.collideOrderedGeneric(Real, box, off, rb, box, vr(0, 0, 0), Quatr.identity); + try testing.expect(m1 != null); + try testing.expectApproxEqAbs(o1.depth, maxPen(m1.?), dtol); + } +} + +test "collide is invariant under the quaternion double cover" { + // `-q` is the double-cover partner (RD-6: the E4(c) design presumed poseAfter's + // quaternion compare is reached — false for any distinct-position pair, since + // poseAfter compares positions FIRST). Two legs: + const box = boxShape(1, 1, 1); + // Leg 1 — DISTINCT positions (pb.x = 0.2): poseAfter decides on POSITION and + // never reaches the quaternion compare, so the SAME internal order runs both + // sides. `rotateVec3` is even in q's sign (verified against quat.zig), so the + // manifold is BIT-IDENTICAL — a regression pin that the quaternion + // representative's sign never leaks into the result (full manifold, exact). + { + const q = Quatr.fromAxisAngle(Vec3r.unit_x, 0.4); + const nq = Quatr{ .x = -q.x, .y = -q.y, .z = -q.z, .w = -q.w }; + const m_q = narrowphase.collide(Real, box, vr(0, 0, 0), Quatr.identity, box, vr(0.2, 0.4, 0.1), q).?; + const m_nq = narrowphase.collide(Real, box, vr(0, 0, 0), Quatr.identity, box, vr(0.2, 0.4, 0.1), nq).?; + try testing.expectEqual(m_q.count, m_nq.count); + try testing.expect(m_q.normal.eql(m_nq.normal)); + for (0..m_q.count) |i| { + try testing.expect(m_q.points[i].position.eql(m_nq.points[i].position)); + try testing.expectEqual(m_q.points[i].penetration, m_nq.points[i].penetration); + try testing.expectEqual(m_q.points[i].feature_id, m_nq.points[i].feature_id); + } + } + // Leg 2 — COINCIDENT positions (both at the origin) force poseAfter to the + // quaternion compare, where q's sign flips the canonical order at the first + // component (capsule identity x=0 vs box q.x=±0.199 for pitch-0.4-X). The + // caller's A→B manifold must still be invariant. The capsule's Y segment runs + // through the box centre ⇒ deep; the MTV is core ~1.0 on ±X but BILATERAL + // (t = dc·X = 0), so the normal AXIS is asserted, its SIGN is free. + { + const cap = capsuleShape(1, 0.5); + const q = Quatr.fromAxisAngle(Vec3r.unit_x, 0.4); + const nq = Quatr{ .x = -q.x, .y = -q.y, .z = -q.z, .w = -q.w }; + const o = vr(0, 0, 0); + const m_q = narrowphase.collide(Real, cap, o, Quatr.identity, box, o, q).?; + const m_nq = narrowphase.collide(Real, cap, o, Quatr.identity, box, o, nq).?; + try testing.expectEqual(m_q.count, m_nq.count); + try testing.expectApproxEqAbs(maxPen(m_q), maxPen(m_nq), depth_tol); + try testing.expect(@abs(m_q.normal.dot(m_nq.normal)) > 1 - normal_tol); + // Manifold penetration = core MTV (satSegBox) + r_sum (capsule radius 0.5). + const sat = satSegBox(o, Quatr.identity, 1, o, q, vr(1, 1, 1)); + try testing.expectApproxEqAbs(sat.depth + 0.5, maxPen(m_q), depth_tol); + } +} + +test "separated radius-0 boxes stay separated (RD-4 band lower boundary)" { + // Two unit boxes with a small but REAL core gap (~145·contact_margin at unit + // scale) must classify separated — the RD-4 deep band (`dist <= m`) must not + // swallow a genuine separation just above it. collideOrderedGeneric → null. + const box = boxShape(1, 1, 1); + const gap: Real = 1.0e-3; + try testing.expect(narrowphase.collideOrderedGeneric(Real, box, vr(0, 0, 0), Quatr.identity, box, vr(2 + gap, 0, 0), Quatr.identity) == null); +} + +test "rd-4 in-band false-deep is benign" { + // Complement of the "stay separated" boundary pin above: a core gap INSIDE the + // RD-4 band (`dist <= contact_margin`) must classify `.deep` (a non-enclosing + // terminal at noise distance from the origin) yet stay BENIGN downstream — + // near-zero penetration for hard cores, and the correct inflated depth for + // inflated boxes — in BOTH A/B orders. + // + // PRECISION: the gap is RELATIVE to the band, recomputed at `Real`. The band is + // `m = conv_k · floatEps(Real) · coord_scale`, `coord_scale = |Δpos| + + // coreExtent(a) + coreExtent(b)` (gjk.zig classifier). For two unit boxes + // (`coreExtent(box) = |(1,1,1)| = √3`) at centres 0 / (2+gap): `coord_scale = + // (2+gap) + 2·√3`. We size `m0` at gap≈0 (Δpos = 2); `contact_margin` is + // monotone INCREASING in gap, so `m0 ≤ contact_margin(gap)` for every gap ≥ 0, + // hence `gap = m0/2 < m0 ≤ contact_margin(gap)` is PROVABLY in-band at any + // `Real` — no fixed-point iteration needed. (An absolute gap like 3e-6 would be + // in-band in f32 but ~1e8× OUT of band in f64 — the trap this avoids.) + const conv_k: Real = 16; // gjk.zig contact-margin constant (kept in sync) + const eps = std.math.floatEps(Real); + const core_ext_box: Real = @sqrt(@as(Real, 3)); // |(1,1,1)| — gjk.zig coreExtent(box) + const coord_scale0: Real = 2 + 2 * core_ext_box; // Δpos = 2 at gap ≈ 0 + const m0: Real = conv_k * eps * coord_scale0; + const gap: Real = m0 / 2; // provably ≤ contact_margin(gap) ⇒ in-band + + // Leg 1 — radius-0 boxes: the band fires on a HARD-core near-touch (cores + // disjoint by `dist ≈ gap`, no enclosure). Both orders: `.deep` (the RD-4 + // band, a non-enclosing terminal, `dist ≈ gap ≈ 4× the in-loop noise floor, + // 0.5× the contact margin`); generic non-null; count ≥ 1; penetration BENIGN + // (the non-enclosing seed clamps EPA depth to ≈ 0 — the false-deep must NOT + // fabricate a spurious depth, the S1 defect class). The normal DIRECTION is + // left unasserted: at pen ≈ 0 it is noise-dominated. + { + const box = boxShape(1, 1, 1); + const a = vr(0, 0, 0); + const b = vr(2 + gap, 0, 0); + const pen_tol: Real = 8 * m0; // band scale + inline for (.{ .{ a, b }, .{ b, a } }) |o| { // A→B, then B→A + const pa = o[0]; + const pb = o[1]; + const g = narrowphase.gjk(Real, box, pa, Quatr.identity, box, pb, Quatr.identity); + try testing.expectEqual(GjkResult.Status.deep, g.status); + const m = narrowphase.collideOrderedGeneric(Real, box, pa, Quatr.identity, box, pb, Quatr.identity); + try testing.expect(m != null); + try testing.expect(m.?.count >= 1); + try testing.expect(maxPen(m.?) <= pen_tol); + } + } + + // Leg 2 (inflated) — two INFLATED boxes (unit box core + radius 0.5, `r_sum = + // 1.0`), centres `2 + gap` apart with the SAME `gap = m0/2`. The box cores keep + // `coreExtent = √3`, so `coord_scale` — hence the band — is identical to Leg 1 + // and `gap` is in-band: `gjk` classifies `.deep` on a GENUINELY-DISJOINT pair + // (cores separated by `dist ≈ gap`, terminal NON-enclosing). The non-enclosing + // seed clamps EPA depth to ≈ 0, so the manifold recovers the former-shallow + // penetration `r_sum − 0 ≈ 1.0` — the "r_sum added after a false-deep" path. + // Point cores CANNOT exercise this: a sphere pair's `coreExtent = 0` ⇒ + // `coord_scale = gap` ⇒ the gap is OUT of its own tiny band (`.shallow`), never + // reaching the deep-band + EPA path this leg pins. Both orders. + { + const infl = SupportShape{ .core = .{ .box = vr(1, 1, 1) }, .radius = 0.5 }; + const a = vr(0, 0, 0); + const b = vr(2 + gap, 0, 0); + const r_sum: Real = 1.0; + inline for (.{ .{ a, b }, .{ b, a } }) |o| { // A→B, then B→A + const pa = o[0]; + const pb = o[1]; + const g = narrowphase.gjk(Real, infl, pa, Quatr.identity, infl, pb, Quatr.identity); + try testing.expectEqual(GjkResult.Status.deep, g.status); + const m = narrowphase.collideOrderedGeneric(Real, infl, pa, Quatr.identity, infl, pb, Quatr.identity); + try testing.expect(m != null); + try testing.expectApproxEqAbs(r_sum, maxPen(m.?), depth_tol); + } + } +} + +const PairKind = enum { box_box, cap_box, sph_cap }; + +fn pairShapes(pk: PairKind, k: Real) [2]SupportShape { + return switch (pk) { + .box_box => .{ boxShape(k, k, k), boxShape(k, k, k) }, + .cap_box => .{ capsuleShape(k, 0.5 * k), boxShape(k, k, k) }, + .sph_cap => .{ sphereShape(0.7 * k), capsuleShape(k, 0.5 * k) }, + }; +} + +/// SAT tie-band minimal-axis count for the pair, by core kind. box/box uses the +/// 15-axis SAT; capsule/box (either order) uses the 6-axis segment⊖box zonotope +/// SAT; anything else (sphere/capsule etc.) has no MTV oracle → 0, so an +/// unclassified divergence there is a genuine defect, never tie-excused. +fn satTieCount(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) u32 { + switch (sa.core) { + .box => |hea| switch (sb.core) { + .box => |heb| return satBoxBox(pa, ra, hea, pb, rb, heb).tie_count, + .segment => |hh| return satSegBox(pb, rb, hh, pa, ra, hea).tie_count, + .point => return 0, + }, + .segment => |hh| switch (sb.core) { + .box => |heb| return satSegBox(pa, ra, hh, pb, rb, heb).tie_count, + else => return 0, + }, + .point => return 0, + } +} + +/// The core order-equivalence check: the two A/B orders of `collideOrderedGeneric` +/// must agree on null-ness and depth (depth is order-independent even at a tie), +/// and agree on count + negated normal EXCEPT inside a SAT-confirmed MTV tie +/// (≥ 2 minimal axes). Any divergence not classified as a tie fails. +/// Drive `epa()` with diagnostics on a deep config and assert the corrupt paths +/// never fire post-fix (E4(a)): no exhaustion fallback, and the exit is only +/// `converged` (the expanding path) or `degenerate_low_dim` (a genuine < 3-D +/// Minkowski, e.g. an on-axis point⊖segment). `iteration_cap` / +/// `fallback_exhausted` / `defensive_non_deep_seed` would signal a residual +/// non-convergence. A no-op on non-deep configs (epa is not called there). +fn assertEpaDiagClean(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) !void { + const g = narrowphase.gjk(Real, sa, pa, ra, sb, pb, rb); + if (g.status != GjkResult.Status.deep) return; + const relpose = RelativePose.init(pa, ra, pb, rb); + var diag: EpaDiagnostics = undefined; + _ = narrowphase.epa(Real, sa, pa, ra, relpose, sb, rb, g, &diag); + try testing.expect(!diag.fallback_used); + try testing.expect(diag.exit == EpaDiagnostics.Exit.converged or diag.exit == EpaDiagnostics.Exit.degenerate_low_dim); +} + +fn assertOrderEquivalent(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, dtol: Real) !void { + const ab = narrowphase.collideOrderedGeneric(Real, sa, pa, ra, sb, pb, rb); + const ba = narrowphase.collideOrderedGeneric(Real, sb, pb, rb, sa, pa, ra); + try testing.expectEqual(ab == null, ba == null); + if (ab == null) return; + const ma = ab.?; + const mb = ba.?; + try testing.expectApproxEqAbs(maxPen(ma), maxPen(mb), dtol); + const count_ok = ma.count == mb.count; + const normal_ok = ma.normal.approxEql(mb.normal.neg(), normal_tol); + if (!count_ok or !normal_ok) { + const tie = satTieCount(sa, pa, ra, sb, pb, rb) >= 2; + try testing.expect(tie); + } +} + +test "generic deep path is order-equivalent over the sweep" { + const rot_a_set = [_]Quatr{ + Quatr.identity, + Quatr.fromAxisAngle(vr(3, 1, 2).normalize(), 0.9), + }; + const rot_b_set = [_]Quatr{ + Quatr.fromAxisAngle(Vec3r.unit_x, 0.4), + Quatr.fromAxisAngle(vr(1, 1, 1).normalize(), 0.62), + Quatr.fromAxisAngle(Vec3r.unit_z, 0.5), + Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7), + Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0), + }; + const offsets = [_]Vec3r{ + vr(0, 0.3, 0), vr(0.2, 0.4, 0.1), vr(0, 0.6, 0), + vr(0.3, 0.2, -0.2), vr(0, 0.9, 0), vr(0.5, 0.5, 0.3), + vr(0.1, 0.1, 0.1), vr(0.15, 0.35, 0.25), vr(0.4, 0.1, 0.2), + vr(0.25, 0.55, -0.15), vr(0.05, 0.25, 0.45), vr(0.33, 0.44, 0.11), + vr(-0.2, 0.3, 0.4), + }; + const scales = [_]Real{ 0.01, 1, 100 }; + const pairs = [_]PairKind{ .box_box, .cap_box, .sph_cap }; + + for (scales) |k| { + const dtol = depth_tol * k; + for (pairs) |pk| { + const s = pairShapes(pk, k); + for (rot_a_set) |ra| { + for (rot_b_set) |rb| { + for (offsets) |off| { + const pb = off.scale(k); + try assertOrderEquivalent(s[0], vr(0, 0, 0), ra, s[1], pb, rb, dtol); + // E4(a): the corrupt EPA paths (fallback / iteration-cap / + // defensive) must never fire post-fix, either order. + try assertEpaDiagClean(s[0], vr(0, 0, 0), ra, s[1], pb, rb); + try assertEpaDiagClean(s[1], pb, rb, s[0], vr(0, 0, 0), ra); + } + } + } + } + } +} diff --git a/src/modules/forge/forge_3d/tests/epa_test.zig b/src/modules/forge/forge_3d/tests/epa_test.zig index 77257c7..6b46545 100644 --- a/src/modules/forge/forge_3d/tests/epa_test.zig +++ b/src/modules/forge/forge_3d/tests/epa_test.zig @@ -45,7 +45,7 @@ fn finite3(v: Vec3r) bool { fn deepEpa(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) EpaResult { const relpose = RelativePose.init(pa, ra, pb, rb); const g = narrowphase.gjk(Real, sa, pa, ra, sb, pb, rb); - return narrowphase.epa(Real, sa, pa, ra, relpose, sb, g); + return narrowphase.epa(Real, sa, pa, ra, relpose, sb, rb, g, null); } test "epa penetration axis and depth match analytic" { diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 0b3820b..43e00ff 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -66,11 +66,48 @@ fn maxPen(m: ContactManifold) Real { return p; } -/// An INDEPENDENT box/box separating-axis min-overlap scan, recomputed inline in -/// the test (a second, trivial SAT) so the fast kernel's reported depth can be -/// checked against the true MTV WITHOUT any GJK/EPA oracle. Returns the least -/// overlap over the 15 axes (both boxes assumed radius 0 and overlapping). -fn satMinOverlap(pa: Vec3r, ra: Quatr, hea_v: Vec3r, pb: Vec3r, rb: Quatr, heb_v: Vec3r) Real { +/// Tie-band scale for SAT minimal-axis classification. Two axes whose TRUE +/// overlaps are equal (an MTV tie) must count together, while a real geometric +/// gap (e.g. the S1 pins' 1.9 vs 1.957 next axis) stays a unique minimum. The +/// band `sat_tie_k · floatEps(T) · coordScale` bounds the accumulated +/// projection/normalization rounding of the per-axis overlaps; `coordScale` is +/// symmetric (`|Δpos| + |he_a| + |he_b|`), mirroring the gjk.zig contact-margin +/// scale. 128 sits ~8× above the observed f32 projection noise (~7e-6 at unit +/// scale) and ~950× below the S1 geometric gap (~0.057). Shared by the +/// epa_robustness_test.zig cross-order tie classification (M1.1.3-HF E4). +pub const sat_tie_k: Real = 128; + +/// Colinearity threshold for deduplicating minimal-band axes into DISTINCT +/// directions: two unit axes with `|u·w| > sat_dir_colinear` are the same +/// direction (v and −v identical). A single geometric MTV direction is +/// re-derived as several candidate axes — e.g. a roll-about-Z box pair yields +/// ±Z as face-A Z, face-B Z, AND four edge-crosses — so slots must be collapsed +/// to directions before counting a tie. 0.999 ≈ 2.6°, far below the angular +/// separation of genuinely distinct minimal MTV axes. +pub const sat_dir_colinear: Real = 0.999; + +/// Result of the independent box/box separating-axis oracle: the MTV `depth` +/// (least overlap over the 15 axes), the normalized minimal-overlap `axis` +/// (first-wins on ties, sign per the raw axis — a separating direction, sign +/// not canonicalized), and `tie_count` — how many DISTINCT directions (colinear +/// slots merged via `sat_dir_colinear`, v and −v identical) lie within the +/// `sat_tie_k` band of the minimum. ≥ 2 marks a genuine MTV tie; the same +/// direction re-derived as several candidate axes counts once, but a BILATERAL +/// minimal direction (centers aligned along it within the band, so both signs are +/// equally-minimal MTV) counts twice. +pub const BoxSatResult = struct { + depth: Real, + axis: Vec3r, + tie_count: u32, +}; + +/// An INDEPENDENT box/box separating-axis scan over the 15 axes (3 face axes of +/// A, 3 of B, 9 edge crosses), recomputed WITHOUT any GJK/EPA — the true MTV +/// oracle. Both boxes are radius 0 and assumed overlapping. `depth` is +/// byte-identical to the former inline `satMinOverlap` (same axis order, same +/// `@min` selection, same `ov_raw / √l2` normalization); `axis` and `tie_count` +/// are added so the M1.1.3-HF suite can SAT-classify cross-order divergences. +pub fn satBoxBox(pa: Vec3r, ra: Quatr, hea_v: Vec3r, pb: Vec3r, rb: Quatr, heb_v: Vec3r) BoxSatResult { const axa = [3]Vec3r{ ra.rotateVec3(Vec3r.unit_x), ra.rotateVec3(Vec3r.unit_y), ra.rotateVec3(Vec3r.unit_z) }; const axb = [3]Vec3r{ rb.rotateVec3(Vec3r.unit_x), rb.rotateVec3(Vec3r.unit_y), rb.rotateVec3(Vec3r.unit_z) }; const hea = hea_v.toArray(); @@ -87,22 +124,71 @@ fn satMinOverlap(pa: Vec3r, ra: Quatr, hea_v: Vec3r, pb: Vec3r, rb: Quatr, heb_v return rap + rbp - @abs(d.dot(L)); } }.f; - var mn: Real = std.math.floatMax(Real); - for (0..3) |k| mn = @min(mn, ov(axa[k], axa, hea, axb, heb, dc)); - for (0..3) |k| mn = @min(mn, ov(axb[k], axa, hea, axb, heb, dc)); + + // Collect (unit axis, overlap) in the fixed order face-A, face-B, edge-cross. + // The overlap of an edge axis is `ov(l_raw) / √l2` (NOT `ov(l_raw)·(1/√l2)`) + // so the reduced minimum is bit-identical to the former satMinOverlap. + var cand_axis: [15]Vec3r = undefined; + var cand_ov: [15]Real = undefined; + var n: usize = 0; + for (0..3) |k| { + cand_axis[n] = axa[k]; + cand_ov[n] = ov(axa[k], axa, hea, axb, heb, dc); + n += 1; + } + for (0..3) |k| { + cand_axis[n] = axb[k]; + cand_ov[n] = ov(axb[k], axa, hea, axb, heb, dc); + n += 1; + } for (0..3) |i| { for (0..3) |j| { const l_raw = axa[i].cross(axb[j]); const l2 = l_raw.dot(l_raw); - // Independent of the kernel: test EVERY non-parallel edge axis (only a - // strictly-zero cross carries no info). The normalized overlap is - // `ov_raw / √l2` — no `1e-6` skip is copied, so this oracle would catch - // a kernel that dropped a separating/min axis. + // Only a strictly-zero cross (parallel edges) carries no info. if (l2 <= 0) continue; - mn = @min(mn, ov(l_raw, axa, hea, axb, heb, dc) / @sqrt(l2)); + const sq = @sqrt(l2); + cand_axis[n] = l_raw.scale(1.0 / sq); + cand_ov[n] = ov(l_raw, axa, hea, axb, heb, dc) / sq; + n += 1; + } + } + + var depth: Real = std.math.floatMax(Real); + var axis: Vec3r = Vec3r.unit_x; + for (0..n) |i| { + if (cand_ov[i] < depth) { + depth = cand_ov[i]; + axis = cand_axis[i]; + } + } + const coord_scale = dc.length() + hea_v.length() + heb_v.length(); + const band = sat_tie_k * std.math.floatEps(Real) * coord_scale; + // Count DISTINCT minimal-band directions: collapse colinear candidate slots + // (the same geometric axis re-derived as face + edge-cross) to one. A minimal + // direction whose center-separation projection is within the band + // (`|dc·axis| <= band`) is BILATERAL — both signs are equally-minimal MTV + // directions, and the generic normal can pick the same absolute axis (NOT + // negated) in the two orders — so it counts as TWO tie directions (Codex (a)). + var seen: [15]Vec3r = undefined; + var seen_count: usize = 0; + var tie_count: u32 = 0; + for (0..n) |i| { + if (cand_ov[i] - depth > band) continue; + var is_new = true; + for (0..seen_count) |j| { + if (@abs(cand_axis[i].dot(seen[j])) > sat_dir_colinear) { + is_new = false; + break; + } + } + if (is_new) { + seen[seen_count] = cand_axis[i]; + seen_count += 1; + tie_count += if (@abs(dc.dot(cand_axis[i])) <= band) 2 else 1; } } - return mn; + return .{ .depth = depth, .axis = axis, .tie_count = tie_count }; } /// Exact manifold equality (bit-for-bit) — for pairs the dispatcher does NOT @@ -319,13 +405,13 @@ test "sphere/box P1d deep extreme aspect (closed-form)" { } /// Whether the generic oracle is SELF-CONSISTENT on this pair — same null-ness, -/// same `count`, and negated normal across the two A/B orders. `collideOrdered` -/// is fixed-order and runs GJK/EPA in the frame of A; for a deep, rotated pair -/// EPA can converge to DIFFERENT faces in the two frames (an M1.1.3 EPA -/// frame-dependence, NOT a fast-path issue — the SAT fast path is order- -/// independent by construction; see the order-independence test). Where generic -/// disagrees with itself it is an unreliable oracle, so the differential skips -/// it. This is the concrete motivation for the analytic fast path. +/// same `count`, and negated normal across the two A/B orders. The M1.1.3 generic +/// EPA deep-rotated frame-dependence is FIXED in M1.1.3-HF (`epa.zig` expansion +/// robustness + the `gjk.zig` deep band), so the generic oracle is now order- +/// consistent except at exact MTV ties — where the two orders may pick the same +/// absolute axis (a bilateral minimum) or different equally-minimal axes, giving +/// a non-negated or count-differing normal. The caller SAT-classifies any +/// inconsistency: an exact tie is skipped, anything else fails (E4(b)). fn genericConsistent(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) bool { const ab = generic(sa, pa, ra, sb, pb, rb); const ba = generic(sb, pb, rb, sa, pa, ra); @@ -338,11 +424,11 @@ fn genericConsistent(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, p test "box/box SAT differential vs generic (<=30:1)" { // Cube A at the origin vs cube B rotated/offset — a broad sweep hitting the // face-face, face-vertex and edge-edge regimes. Per config, `collideOrdered` - // is compared to the generic oracle in BOTH A/B orders, but only where that - // oracle is self-consistent across orders (deep rotated pairs where the - // generic EPA is frame-dependent — an M1.1.3 EPA limitation the SAT fast path - // does not share — are skipped via `genericConsistent`). Geometry-only (fid - // tie bands excluded); fid-exact is asserted on the clean explicit configs. + // is compared to the generic oracle in BOTH A/B orders where that oracle is + // self-consistent; a residual inconsistency (post-M1.1.3-HF, only at an exact + // MTV tie) is SAT-confirmed as a tie before it is skipped — an unclassified + // one fails (E4(b)). Geometry-only (fid tie bands excluded); fid-exact is + // asserted on the clean explicit configs. const box = boxShape(1, 1, 1); const rots = [_]Quatr{ Quatr.identity, @@ -364,11 +450,30 @@ test "box/box SAT differential vs generic (<=30:1)" { const ra = g; const rb = g.mul(r); const m = ordered(box, pa, ra, box, pb, rb) orelse continue; - // Compare fast vs generic in both orders, only where the oracle - // is self-consistent (i.e. its EPA converged reliably). + // Compare fast vs generic in both orders where the oracle is + // self-consistent. Post-fix (M1.1.3-HF), a residual cross-order + // inconsistency is permitted ONLY as an exact MTV tie, SAT-confirmed + // (>= 2 minimal directions / a bilateral axis); anything else is a + // defect (E4(b)) — never a silent skip. if (genericConsistent(box, pa, ra, box, pb, rb)) { try expectBothOrdersUnordered(box, pa, ra, box, pb, rb, false); compared += 1; + } else { + // A residual inconsistency is classified, never silently skipped + // (E4(b)). UNCONDITIONAL: null-ness AND depth must always agree — + // a null-ness or depth divergence is the frame-dependence class + // and fails, tie or not. The residual (normal not negated, or a + // differing count) is then legitimate iff an exact MTV tie (SAT, + // >= 2 minimal directions) OR the documented M1.1.4 `collideOrdered` + // COUNT-order-dependence (the normal still negates; depth already + // asserted equal) — RD-5. + const ab = generic(box, pa, ra, box, pb, rb); + const ba = generic(box, pb, rb, box, pa, ra); + try testing.expect(ab != null and ba != null); + try testing.expectApproxEqAbs(maxPen(ab.?), maxPen(ba.?), diff_tol); + const tie = satBoxBox(pa, ra, box.core.box, pb, rb, box.core.box).tie_count >= 2; + const count_only = ab.?.normal.approxEql(ba.?.normal.neg(), diff_tol); + try testing.expect(tie or count_only); } if (m.count >= 3) saw_face = true; if (m.count == 1) saw_single = true; @@ -490,7 +595,7 @@ test "box/box SAT deep rotated is correct (oracle-free)" { // (a) order-independence. try expectOrderIndependent(p.a, vr(0, 0, 0), Quatr.identity, p.b, o, r); // (b) depth == the true MTV (independent inline SAT scan). - const mtv = satMinOverlap(vr(0, 0, 0), Quatr.identity, p.a.core.box, o, r, p.b.core.box); + const mtv = satBoxBox(vr(0, 0, 0), Quatr.identity, p.a.core.box, o, r, p.b.core.box).depth; try testing.expectApproxEqAbs(mtv, maxPen(m0), diff_tol); // (c) frame-invariance under a rigid global transform: count and // depth invariant, normal rotated by g. (The exact 4-point SUBSET diff --git a/src/modules/forge/forge_3d/tests/manifold_test.zig b/src/modules/forge/forge_3d/tests/manifold_test.zig index 019a681..8001538 100644 --- a/src/modules/forge/forge_3d/tests/manifold_test.zig +++ b/src/modules/forge/forge_3d/tests/manifold_test.zig @@ -442,7 +442,7 @@ test "edge-edge penetration is measured along the contact axis" { const g = narrowphase.gjk(Real, box, pa, Quatr.identity, box, pb, yaw); try testing.expectEqual(narrowphase.GjkResult(Real).Status.deep, g.status); const relpose = narrowphase.RelativePose(Real).init(pa, Quatr.identity, pb, yaw); - const e = narrowphase.epa(Real, box, pa, Quatr.identity, relpose, box, g); + const e = narrowphase.epa(Real, box, pa, Quatr.identity, relpose, box, yaw, g, null); // Confirm the contact axis is genuinely oblique (not a face normal) so this // exercises the edge path, not the face-face quad path. const na = e.normal.toArray(); @@ -635,6 +635,53 @@ test "collidePair feature ids are stable across a pose-order boundary" { } } +test "collidePair deep generic manifold is stable across a pose-order boundary" { + // M1.1.3-HF E4(d): the same pose-order-boundary guard on the GENERIC deep path + // (a capsule/box pair — no box/box fast path — so it runs GJK/EPA). A capsule + // at the origin, two boxes straddling the poseAfter x = 0 lexicographic boundary + // by ±1e-4, deep-overlapping. `collidePair` drives a FIXED body-id order, so + // count, normal and the feature_id SET are stable across the boundary — a + // regression pin that nothing pose-order-dependent remains in `collidePair`'s + // seed path (guarding against any future seed-canonicalization, the withdrawn + // "Fix B"). + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + var bm = BodyManager{}; + defer bm.deinit(gpa); + const cap = try store.createShape(gpa, .{ .capsule = .{ .radius = 0.5, .half_height = 1 } }); + const box = try store.createShape(gpa, .{ .box = .{ .half_extents = ApiVec3.splat(1) } }); + // A = capsule at the origin; two B boxes straddling x = 0 by ±1e-4. y = 1.2 + // makes the MTV a UNIQUE +Y (~0.8, the box's lower face vs the segment) below + // the ~1.0 X/Z penetrations, so the normal is stable under the tiny X-straddle + // (a near-tie config would legitimately tip the normal). Deep: the capsule's + // Y-axis segment (y∈[-1,1]) passes through the box core (y∈[0.2,2.2]). + // addBoxBodyAt is a generic shape+position helper (name is historical). + const id_a = try addBoxBodyAt(gpa, &bm, &store, cap, 0, 0, 0, 0); + const id_neg = try addBoxBodyAt(gpa, &bm, &store, box, 1, -0.0001, 1.2, 0); + const id_pos = try addBoxBodyAt(gpa, &bm, &store, box, 2, 0.0001, 1.2, 0); + + const m_neg = bm.collidePair(&store, id_a, id_neg).?; + const m_pos = bm.collidePair(&store, id_a, id_pos).?; + try testing.expectEqual(m_neg.count, m_pos.count); + try testing.expect(m_neg.normal.approxEql(m_pos.normal, tol)); + // feature_id SET equality across the boundary, both directions. + for (0..m_neg.count) |i| { + var found = false; + for (0..m_pos.count) |j| { + if (m_neg.points[i].feature_id == m_pos.points[j].feature_id) found = true; + } + try testing.expect(found); + } + for (0..m_pos.count) |i| { + var found = false; + for (0..m_neg.count) |j| { + if (m_pos.points[i].feature_id == m_neg.points[j].feature_id) found = true; + } + try testing.expect(found); + } +} + test "broadphase pairs to manifolds via collidePair" { const gpa = testing.allocator; var store = ShapeStore{};