# Fluid review 02 — quality-gated solver upgrades

Working doc for the staged execution of the 2026-06-12 fluid review. Each stage
must leave `quality.spec.js` green; when a stage improves a metric its baseline
is tightened in the same change. Constraints: single-core JS, real-time ECS,
public API of effectors + `FluidField` frozen (state shape may change).

## Quality gate baselines (quality.spec.js)

| metric | stage 0 (baseline) | stage 2 | stage 3+4 | **stage 5 (MAC)** | notes |
|---|---|---|---|---|---|
| projection linear residual, SOR | 1.21 | 0.67 | 0.67 | 0.92 | 8 sweeps @ 16³; RHS grew 14.5→17.8 with face differencing |
| projection linear residual, PCG | 3.34e-2 | 1.16e-2 | 1.16e-2 | 1.39e-2 | iteration/tolerance-capped |
| cold post-div | 11.2 | 9.1 | 9.1 | **0.91 / 1.4e-2** | SOR / PCG. Now EQUALS the linear residual: D∘G ≡ L, the operator floor is gone |
| warm post-div, SOR | 9.22 | 0.70 | 0.35 | **9.6e-3** | 1000× vs stage 0 |
| warm post-div, PCG | 1.84 | 0.70 | 0.35 | **3.1e-5** | |
| vortex KE retention (60 steps) | 0.498 | 0.495 | 0.527 | 0.530 | sealed box, default SL+reflection |
| vortex peak-ω retention | 0.606 | 0.624 | 0.725 | **0.736** | |
| wall penetration (seed 8) | 5.72 | 6.96 | 6.65 | **0 (exact)** | MAC pins wall-face normal velocity outright |
| scalar mass retention | 0.231 | 0.849 | 0.837 | 0.818 | residual loss = gather SL + traces through the shell |
| diffusion wall leak | 16.9 | 0 | 0 | 0 | stage-1 fix holds |
| sealed-box gravity max\|vy\| | 1.24 | 1.24 | 0.97 | **0.070** | near-exact hydrostatic balance |
| checkerboard residual (10 steps) | 0.593 | 0.627 | 0.587 | **0.058** | pure compressive mode; collocated was blind to it |
| damped gravity terminal \|v\| | — | 4.92 = analytic | 4.92 | 4.92 | bounded forcing (was unbounded growth) |
| atmosphere terminal velocity | — | 8.00/8 | 8.00/8 | (8.00, −5.00) = analytic | GlobalFluidEffector wind+force+drag holds `wind + force/drag` exactly |
| step() 32×8×32 / 32³ / 64³ (ms) | 1.16 / 4.38 / 54.0 | 1.44 / 5.11 / — | 1.25 / 3.91 / 42.7 | 2.56 / 7.39 / 68.5 | MAC ≈ 2× collocated: 3 lattices + cross-component carriers; levers listed under follow-ups |

Perf reference (review probe, this machine): step() 32×8×32 = 1.16 ms,
32³ = 4.4 ms, 64³ = 54 ms. Division→reciprocal-table in SOR measured 14%
SLOWER in V8 — do not do it.

## Stages

- [x] **Stage 0 — quality gate.** `quality.spec.js`: 8 deterministic scenarios,
  baselines baked, runs by default (no timing). Deep sweeps stay in
  `bench.spec.js`.
- [x] **Stage 1 — bug fixes.** (183 fluid tests green; quality gate unchanged
  except the leak metric dropping to exactly 0.)
  - Wake effector: trail advance moves out of `apply()` for transform-managed
    effectors (multi-field contract).
  - Diffusion: no-flux at solid neighbours (walls stop acting as
    sources/sinks); neighbour mask recomputed once at top of `step()`.
  - `FluidField.shift`: migrate the solid mask (zero-fill exposed cells)
    instead of clearing it — removes the one-tick wall-less window after every
    re-anchor shift.
  - PCG: relative-tolerance early exit (cap unchanged).
- [x] **Stage 2 — boundary semantics + forcing stability.** (195 fluid tests
  green. New `FluidField.pressure_diag` derived buffer carries the per-cell
  Laplacian diagonal; mask kernel bakes both in one pass.)
  - Domain edges are OPEN (ghost p = 0): out-of-bounds faces count in the
    Laplacian diagonal and read zero pressure; solid faces stay reflecting
    (Neumann). Sealed boxes are built explicitly with solid cells (see
    `seal_box` in quality.spec.js).
  - PCG null-space mean-subtraction made conditional — only for fully sealed
    fields (open systems are non-singular; subtracting the mean there would
    solve a different RHS). Disconnected sealed pockets remain a documented
    limitation.
  - `velocity_damping` knob on the simulator (exponential, default 0) — the
    solver's only energy sink under sustained forcing; gives constant forces
    a terminal velocity.
  - New ambient-wind drive — relaxes toward a target wind velocity instead of
    integrating constant acceleration (no unbounded accumulation; frame-rate
    independent). Shipped first as a separate `AmbientWindFluidEffector`,
    later UNIFIED into `GlobalFluidEffector` as the `wind` + `drag` fields of
    the atmosphere model `dv/dt = force + drag·(wind − v)` (exact-ODE
    integrator, terminal velocity `wind + force/drag`) — one global effector
    instead of two competing ones.
- [x] **Stage 3 — transport quality.** (213 tests green.)
  - Fused 3-channel trilinear sampler (`scs3d_sample_linear3`); generalized
    SL velocity kernel with separate carrier; MacCormack velocity + scalar
    kernels with Selle's monotone limiter; `AdvectionScheme` enum;
    `advection_reflection` step structure (Zehnder 2018, half-step form).
  - **Measured scheme matrix** (sealed vortex, ke / peak-ω retention @ 1 s;
    step ms @ 32×8×32):
    | combo | ke | ω | ms | 10 s stability |
    |---|---|---|---|---|
    | SL (Stam baseline) | 0.495 | 0.624 | 0.92 | stable |
    | **SL + reflection (DEFAULT)** | 0.527 | 0.725 | 1.10 | stable (peak 0.99) |
    | MacCormack | 0.773 | 0.640 | 1.35 | **unstable** (peak 1.91) |
    | MacCormack + reflection | 1.016 | 1.081 | 2.49 | **unstable** (peak 3.44) |
  - **Key negative finding:** on the collocated grid, MacCormack's
    anti-diffusive corrector amplifies the projection's operator-floor
    residue — transient energy growth ×1.9–3.4 within 10 s, and passive
    scalar total mass inflates ×1.8 in one second. Damping 0.2 does not tame
    it. The kernels are correct in isolation (specs); the INTERACTION with
    the collocated pressure floor is the problem. **MacCormack is therefore
    default-off and gated on MAC staggering** — this is the strongest
    argument yet for stage 5.
  - Defaults: `advection_scheme = SEMI_LAGRANGIAN`,
    `advection_reflection = true`. Reflection alone is a clean win: stable,
    +20% step cost, warm steady-state divergence halves again (0.71 → 0.35),
    sealed-gravity settles better (1.24 → 0.97).
- [x] **Stage 4 — perf polish (bench-gated).** (219 tests green; quality gate
  bit-identical.)
  - SOR interior fast path (`mask === 63` → branch-free constant-diagonal
    body, no diag read): measured **37% faster** per solve at 64³, noise-level
    at 32×8×32, bit-identical results. Adopted.
  - Rejected by measurement (stage-0 probe): division → reciprocal-table in
    the SOR update was 14% SLOWER in V8. Recorded so nobody retries it.
  - `vorticity_confinement` knob (Fedkiw 2001), default off — two O(N) passes
    on existing scratch when enabled; pairs with `velocity_damping`.
  - Final step() medians (this machine): 32×8×32 = 1.25 ms, 32³ = 3.91 ms,
    64³ = 42.7 ms — at 32³ and up faster than the stage-0 baseline despite
    reflection being on.
- [x] **Stage 5 — MAC staggering migration (user-approved).** 223 tests green.
  - Layout: `velocity_x/y/z` are face lattices ((rx+1)·ry·rz etc.); new derived
    `face_solid_x/y/z` pin masks (either adjacent cell solid → face velocity is
    a boundary condition). `setVelocityAt`/`addVelocityAt` write both faces per
    axis; `sampleVelocity` interpolates per staggered lattice. Public
    signatures unchanged.
  - Kernels: new `v3_mac_*` divergence / gradient / SL + MacCormack advection
    (velocity + scalar) / face-solid / vorticity confinement. The pressure
    solvers and the mask/diag builder needed ZERO changes — stage 2's
    open-boundary diagonal already was the MAC system. The legacy `v3_grid_*`
    collocated kernels remain as standalone utilities with their specs.
  - Exactness confirmed by the gate: cold post-divergence == linear residual
    (D∘G ≡ L); wall-face penetration exactly 0; hydrostatic balance to 0.07;
    the compressive checkerboard is annihilated (0.59 → 0.058).
  - Scheme matrix on MAC: MC velocity is now long-horizon STABLE (10 s peak
    0.96 vs collocated 1.9×), but (a) MC+reflection still pumps (3.6×) and
    (b) MC SCALAR transport still inflates mass ~1.7× — back-traces sample
    through solid walls and the corrector amplifies the bleed. Defaults stay
    SL + reflection; MacCormack documented opt-in.
  - Perf: 2.56 / 7.39 / 68.5 ms at 32×8×32 / 32³ / 64³ (~2× collocated). The
    SL kernel's cross-component carrier reconstruction was optimized from
    general trilinear to the exact fixed 4-tap MAC average (−31..39% step),
    pinned by a trilinear-reference equivalence spec.

## Phase A (post-MAC) — solid-aware traces + MacCormack default. DONE.

- Back-traces (and MacCormack probes) are CLIPPED against solid cells
  (`v3_mac_clip_trace`, bisection); scalar samples are solid-MASKED with
  weight renormalization (`v3_grid_sample_scalar_masked`). Kills momentum
  tunnelling through thin walls (spec-pinned) and wall dye bleed.
- **Negative finding, gate-corrected:** MacCormack SCALAR mass inflation
  (1.71×) was NOT wall sampling — bit-identical before/after the fix. It is
  the corrector re-adding ~double the gather bias: intrinsic
  non-conservation. Scalars are therefore ALWAYS semi-Lagrangian
  (hard-coded; `advection_scheme` selects the VELOCITY scheme only).
- MC + reflection re-probed WITH clipping: still pumps (10 s peak 3.7×) —
  intrinsic to the pairing, stays barred.
- **Defaults: `advection_scheme = MACCORMACK` (velocity),
  `advection_reflection = false`.** Gate: vortex KE retention 0.53 → 0.80
  on the default path; sealed gravity 0.070 → 0.047; warm divergence
  settles at 2.96e-2 / 2.8e-4 (SOR/PCG — one mid-step projection fewer than
  SL+refl's 9.6e-3 / 3.1e-5; still ~300× below pre-MAC).
- **Perf lesson (measured 8.6×!):** V8 refuses to inline loop-bearing
  callees — calling `v3_mac_clip_trace` per face made the SL kernel 8.6×
  slower. Hot kernels now inline the straight-line "end cell solid?" test
  and call the bisection helper only on wall hits (performance contract
  documented on the helper). Final step(): MC default 2.74 ms @ 32×8×32 /
  10.4 ms @ 32³; SL+refl 1.97 / 7.35.

## Phase B — FluidObstacleSystem. DONE.

- `FluidObstacle` component (inflation knob) + `FluidObstacleSystem`:
  voxelizes each (FluidObstacle, Collider, Transform) entity's shape into
  every overlapping FluidComponent's solid mask per tick, via
  `PosedShape3D.signed_distance_at_point` (new posed point queries on
  PosedShape3D, spec'd). Rigid pose — Transform.scale ignored, matching the
  physics collider convention. Cell solid iff sdf(centre) <= inflation;
  iteration clipped to the shape AABB.
- Ownership contract: with >= 1 obstacle the system clears + rebuilds EVERY
  field's mask each tick (moving obstacles leave no stale walls); with none
  it touches nothing. Register BEFORE FluidSystem.
- 8 system tests incl. rotation, inflation-for-thin-walls, origin offset,
  broad-phase, and a same-tick FluidSystem integration check.

## Phase C — moving-solid face velocity BCs. DONE.

- **Pinned faces are the wall's normal-velocity BC**, not dead storage:
  - `v3_mac_compute_face_solid` takes the velocity arrays and zeroes a face
    only on the unpinned→pinned TRANSITION (static-wall default).
    Pinned→pinned recomputes preserve the stored BC; pinned→unpinned keeps
    the wall velocity behind as the uncovered fluid's wake seed. Spec'd
    (`v3_mac_compute_face_solid.spec.js`).
  - `v3_mac_compute_divergence` reads pinned faces at face value (the
    face_solid params are gone) — this is the entire transmission mechanism:
    a stamped wall face shows up as inflow/outflow in the adjacent fluid
    cell and the projection does the rest.
  - The pressure gradient, advection, damping and effectors all SKIP pinned
    faces, so a stamped BC survives the whole step bit-exactly (asserted in
    quality 8).
- `FluidObstacleSystem` second pass: per obstacle, the wall velocity is
  **`RigidBody.linearVelocity`** (world m/s — the obstacle tuple is the full
  physics tuple FluidObstacle+Collider+RigidBody+Transform); cells it
  voxelized get all six face slots stamped with the grid-units velocity.
  The system refreshes each field's masks BEFORE stamping so the transition
  zeroing is consumed there and the stamped values stick through the
  simulator's own per-step refresh.
- **Velocity source rationale** (replaced the initial Transform-delta
  finite difference in the same session): pose differencing turns teleports
  (spawn snaps, network corrections) into one-tick air detonations — the
  exact failure mode BodyKind.KinematicPosition was deferred over in the
  contact solver. Reading the body instead: air and contacts agree on wall
  speed, first tick stamps (no `_prev` warm-up), no system-private pose
  state to lose on snapshot restore, sleeping bodies stamp nothing (solver
  zeroes velocities on sleep), and `angularVelocity` sits ready for the
  ω×r follow-up. Pose-animated colliders read as stationary walls that
  teleport — for air just as for contacts; movers go through
  KinematicVelocity/Dynamic. Teleport spec pins the no-blast contract.
- **Stop transition**: a wall that stops must stamp ONE more pass (zeros)
  over its faces — pinned values persist by design, so without it the
  divergence sees phantom inflow forever (`_was_moving` flag; obstacles
  that never move never pay). Piston spec covers move → push → stop.
- Rotation-induced surface velocity (ω × r) is NOT modelled — a spinner
  reads as a translating wall only (documented on the system).
- Ordering contract: PhysicsSystem → FluidObstacleSystem → FluidSystem.
- Gate: scenario 8 (piston slab, faces stamped +4, 10 steps) — mean u ahead
  2.08 baked, wake side 2.60 sign-checked; BC face value asserted == 4
  after 10 steps. Static scenarios all unchanged.

## Follow-ups (ordered by leverage)

1. **Obstacle voxelization change detection** — static scenes currently pay
   the full clear+rebuild every tick.
2. **MC + reflection** — only via the second-order advection-reflection
   formulation (Narain et al. 2019, implicit-midpoint form); the naive
   pairing re-injects corrector overshoots (3.7×, with or without clipping).
3. **bench.spec.js MAC refresh** — the `.skip` suites run, but the
   per-primitive sections still measure the legacy collocated kernels.
4. **ω × r surface velocity for rotating obstacles** — needs per-cell wall
   velocity instead of one translation vector per obstacle; same stamping
   machinery, finer-grained source.

## Review findings driving this (2026-06-12 session)

1. WakeFluidEffector mutates trail state in `apply()` → second field in the
   same step sees a zero segment (confirmed: A=240, B=0).
2. `v3_grid_apply_diffusion` gathers from solid neighbours → dye leak 16.9/100
   through a wall in one step (confirmed).
3. `FluidSystem` re-anchor shifts clear solids, then step runs immediately —
   one tick with no walls per cell crossing.
4. Closed Neumann edges + one-sided boundary divergence: uniform body force
   produces zero discrete divergence → projection never opposes it → velocity
   grows without bound (confirmed: −5/−10/−15 over 30/60/90 steps). No damping
   term exists anywhere.
5. Collocated grid: compact Laplacian ≠ div∘grad → checkerboard null mode
   (interior div exactly 0 for ±1 mode, confirmed) + operator floor (cold
   post-div 11.2 of 14.5 even with exact linear solve).
6. Advection backtrace samples through/inside solids.
7. PCG always runs the full SOR-scaled iteration budget (no tolerance exit);
   mean-subtraction assumes a single connected fluid component.
