← Explainer · Markdown · Source on GitHub

# Pressure multigrid

This folder holds the geometric multigrid preconditioner of the pressure PCG solve and the solver shell that uses it,
`PcgMgSolverGpu`. This README is the implemented contract for the level plan, the level buffers, the V-cycle, the
smoother, the transfers, the device footprints, the V-cycle's resource budget, the kernel rules, the limits and the tests
([contract homes](https://github.com/hankbeasley/polycfd/blob/b7f5b9ba6f52355d9b196b586e4a6c8719d8feea/docs/w4-design/README.md#where-implemented-contracts-live)). The solver interface
`IPcgSolverGpu`, the PCG loop both solvers share (`PcgIteration`), the pressure regions, the results and the solve's
resource budget are specified in the [Projection README](/src/PolyCfd.Gpu/Projection/README), and the physics of the regions in
the [physics guide](/docs/PHYSICS_GUIDE#pcg-and-multigrid). The
[multigrid design](/docs/GPU_MULTIGRID_DESIGN) and the
[cut-cell multigrid design](/docs/CUT_CELL_MULTIGRID_DESIGN) keep the original rationale.

**Status (2026-09-26, SOL-02b):** a Chebyshev polynomial smoother on D⁻¹A (Gershgorin λmax = 2; degree 3 over [2/5, 2]
before and after the coarse correction, degree 10 over [2/79, 2] on the coarsest level), the exact volume-weighted
adjoint of injection as restriction, injection prolongation and a kept coarse right-hand side; `MultigridCycle` is the
multigrid `IPressurePreconditioner` of the shared PCG loop. The damped Jacobi smoother and the volume-weighted average
restriction it replaced were removed. Measurements are in the [smoother](#smoother) section and in
[performance validation](/docs/PERFORMANCE_VALIDATION#chebyshev-multigrid-smoother-attribution--2026-09-26);
the quick, multigrid and full `verify-all` suites pass
([complete validation](/docs/COMPLETE_VALIDATION#chebyshev-solvers-full-suite--2026-09-26)).

| File | Role |
| --- | --- |
| `PcgMgSolverGpu.cs` | The solver shell: hierarchy, geometry updates of the finest level, the cycle lent to the shared `PcgIteration`, internal hierarchy diagnostics |
| `MultigridCycle.cs` | The V-cycle `z = M r` (`IPressurePreconditioner`) and the level buffer roles |
| `MultigridSmoother.cs` | The smoothing method, its constants, schedules and derivation (`MultigridPass`) |
| `MultigridKernels.cs` | Kernels: the fused Chebyshev step, the adjoint restriction, injection prolongation and the inverse diagonal |
| `GpuMgLevel.cs` | One level's device metrics, volumes, operator, halo, diagonal, classification and vectors; `DeviceBytes` |
| `GpuMgHierarchy.cs` | `PlanLevels`, level construction, coarse geometry rebuilt from the SDF, `CoarseThickeningMode` |
| `../PressureStencil.cs` | A level's halo fill plus operator: `ApplyCorrection` and `ApplyPhysical` |

## Solver

```csharp
using var solver = new PcgMgSolverGpu(backend, block);            // maxLevels: 0 = unlimited, as the projection builds it
using var bcSet = DevicePatchBcSet.AllPeriodic(backend, block);
var result = solver.Solve(d_rhs, d_pressure, new SolverOptions { MaxIterations = 100, AbsTolerance = 1e-6, RelTolerance = 1e-6 },
    bcSet.Pressure);                                               // pressure descriptors only
```

The constructor takes the backend, the finest block, `maxLevels` (tests use 0 to 4) and an optional logger (the
projection passes none). The smoother and its schedules are not parameters.
`ApplyPreconditioner`, `Preconditioner`, `DotWeighted`, `LevelCount`, `Hierarchy`, `CoarseThickening` and
`GetLevelCellClasses` are internal hierarchy diagnostics for tests (`InternalsVisibleTo("PolyCfd.Gpu.Tests")`). `Solve`,
`PreparePressureRegions` and `GaugePressure` delegate to the shared `PcgIteration`, which projects every connected
pressure region without a fixed-pressure boundary out of the recursive residual after each update, refuses a region
whose net source exceeds the roundoff-scaled limit, and returns a bounded `SolveResult.FailureSummary` when it does not
converge ([Projection README](/src/PolyCfd.Gpu/Projection/README#one-pcg-loop)). It writes no files.

## Levels

`GpuMgHierarchy.PlanLevels(finest, minSize, maxLevels)` is the one coarsening rule: every dimension is halved with
ceiling division, (n + 1) / 2, while the shortest dimension of the last level is above `minSize`
(`PcgMgSolverGpu.MinimumCoarseSize` = 4), and at most `maxLevels` levels are kept (0 = unlimited). A 17×9×8 grid has
levels 17×9×8 and 9×5×4; a 128³ grid has six levels down to 4³. The hierarchy constructor and the memory estimator
both use it.

Level 0 borrows nothing from the finest block's device state: it uploads its own half metrics and classification.
A coarse block has spacing `fineDx·Nx/coarseNx`; its geometry is rebuilt at coarse resolution from the finest block's
geometry descriptor, or trivial without one. For moving geometry, `PrepareGeometry` binds the finest level to the
update's device geometry and rebuilds every coarse level from the rotated SDF (`RebuildCoarseGeometry`), with the solid
thickening scaled by `CoarseThickeningMode` (default `ConstantPhysical`: the finest physical offset on every level).
Each rebuild also rebuilds that level's volumes, classification and inverse diagonal.

**Buffer roles** (`MultigridCycle` owns the rules; `GpuMgLevel` documents them):

| Buffer | Role |
| --- | --- |
| Level 0 x / b | The PCG `z` / `r`, borrowed; r is never written. Level 0 has no `Correction` or `Rhs` |
| `Correction` | x on levels ≥ 1 |
| `Rhs` | b on levels ≥ 1: the restricted right-hand side, written only by the finer level's restriction and only read on its own level |
| `Residual` | r = b − A x before restriction; the smoother's Chebyshev direction while the level smooths |
| `Temp` | A x for the smoother and the residual |

`GpuMgLevel` must not know which smoother runs, PCG, or estimator category names.

## V-cycle

`MultigridCycle.Apply(r, z, boundaries)` computes `z = M r` with one recursive V-cycle. On every level x is cleared,
then:

1. pre-smooth (`MultigridPass.Pre`);
2. form r = b − A x with homogeneous halos and restrict it into the coarse `Rhs`;
3. recurse with the coarse `Correction` and `Rhs`;
4. add the coarse correction by injection;
5. post-smooth toward the same b (`MultigridPass.Post`).

The coarsest level only smooths (`MultigridPass.Coarsest`). Every level solves a correction equation, so the stencil
always uses the homogeneous boundary values; nonzero prescribed values have already entered the PCG residual. M is
linear and the same fixed map on every call. It allocates nothing and makes no host synchronization.

**Kept coarse right-hand side.** Each coarse level keeps its restricted right-hand side in `Rhs`, so post-smoothing
relaxes toward the same b as pre-smoothing. The Chebyshev smoother needs it: its error propagation oscillates across the
upper spectrum (down to −1/T_m(σ)), and relaxing toward b − A x_pre instead, as the damped Jacobi cycle did with an
aliased right-hand side, undoes pre-smoothing there and can make the level cycle indefinite. In the 2026-09-25 ratio
study the fan needed 8.6 PCG iterations with the aliased right-hand side against 5.9 with a kept one at ratio 8, and
ratios 20 and 30 failed aliased.

**Symmetry and positivity.** PCG needs M symmetric and positive definite in the volume-weighted inner product
`<a, b>_V = Σ V_i a_i b_i`. Both hold by construction, up to float rounding, on every level whose flux matrix is
symmetric (all levels except geometry crossing a periodic boundary, see [limits](#limits)). A pass from a zero start
applies Q = q(D⁻¹A)D⁻¹ with q(λ) = (1 − P_m(λ))/λ. Q is V-symmetric because V·D·(D⁻¹A) is the symmetric flux matrix,
and positive definite because |P_m| < 1 on (0, 2] and q(0) = −P_m′(0) > 0. With the same polynomial before and after
the coarse correction, the kept right-hand side and the restriction R = V_c⁻¹PᵀV_f (the V-adjoint of the prolongation P,
see [transfers](#transfers)), one level's cycle is

```text
M = Q(2I − AQ) + (I − QA) P M_c R (I − AQ)
```

The first term is ((1 − P_m²)/λ)(D⁻¹A)·D⁻¹, symmetric positive definite; the second is V-symmetric and positive
semidefinite whenever the coarse cycle M_c is; the coarsest level applies Q alone. By induction M is symmetric positive
definite whatever the coarse operators are: rediscretized rather than Galerkin coarse operators change the convergence
rate, not definiteness. What keeps it: the identical pre/post polynomial, |P_m| ≤ 1 on [0, λmax], a positive coarsest
q and the kept right-hand side (an aliased one breaks the identity above). Cells with a zero inverse diagonal (solid,
zero volume or no open face) receive no correction, so M is definite on the cells the operator couples. Measured
asymmetry 2.0e-9 to 1.2e-7 on uniform, sphere, cylinder and rotated fan hierarchies (`MultigridSymmetryTests`, bound
1e-6; the damped Jacobi cycle with the average restriction measured 1.9e-3 on the sphere); the tests also sample
⟨M r, r⟩_V > 0 on every hierarchy as a guard.

The cycle must not know PCG scalars, tolerances, pressure regions, failure policy or files.

## Smoother

`MultigridSmoother.Smooth(level, x, b, work, pass, boundaries)` is the only place that knows the smoothing method. There
is one smoother, so there is no interface; replacing the method replaces this class body.

**Method.** A degree-m Chebyshev iteration on D⁻¹A over [λmin, λmax] (Saad, *Iterative Methods for Sparse Linear
Systems*, Alg. 12.1), one fused kernel per step: `d = [first ? 0 : dScale_k·d] + zScale_k·D⁻¹(b − A x); x += d`, with
the scalars from `Chebyshev.SmootherSchedule` (`src/PolyCfd.Core/Numerics/Chebyshev.cs`), computed once per process.
After m steps the error is P_m(D⁻¹A)e₀ with P_m(λ) = T_m((θ − λ)/δ)/T_m(σ) (θ, δ the interval's midpoint and
half-width, σ = θ/δ), independent of the start. D⁻¹ is the level's inverse diagonal (`MultigridKernels.BuildJacobiDiagonal`,
shared with the Jacobi preconditioner); the direction d lives in the lent `work` buffer (the level's `Residual`, which must
not alias b or x) and A x in `Temp`.

**λmax = 2 on every level (Gershgorin).** A non-solid row is (A p)_i = (1/V_i) Σ_f c_f (p_i − p_nb(f)), c_f =
α_f A_f / h_f ≥ 0, and D_i = (1/V_i) Σ_f c_f over the same six faces. An interior or periodic face adds c_f to the
diagonal and c_f to the off-diagonal row sum, a zero-gradient face nothing, a homogeneous fixed-value face (ghost −p_i)
2c_f to the diagonal only, so every row of D⁻¹A has |a_ii| + Σ_{j≠i} |a_ij| ≤ 2. Where the flux matrix is symmetric the
spectrum is real in [0, 2]; the bound is attained by the checkerboard of an uncut periodic box, so no per-level
eigenvalue estimate is needed.

**Schedules.**

| Pass | Degree | Interval | Largest \|P\| on the interval | Operator applications |
| --- | --- | --- | --- | --- |
| `Pre`, `Post` | 3 | [2/5, 2] (fine ratio 5) | 1/9 | 2 (pre, zero start), 3 (post) |
| `Coarsest` | 10 | [2/79, 2] | 0.21 | 9 (zero start) |

The same polynomial on both passes keeps the cycle self-adjoint. For any λmin in (0, λmax), |P_m| ≤ 1 on all of
[0, λmax], so no real mode grows. 2/79 ≈ (1 − cos(π/8))/3 is the smallest eigenvalue of D⁻¹A on an uncut 4³ box with
one fixed-value face and zero-gradient walls (D counting every face, as `BuildJacobiDiagonal` does; an uncut periodic
4³ box has 1/3 and an all-zero-gradient one (2 − √2)/6). It is applied as a fixed ratio chosen by measurement (on the
backward step's coarsest 48×4×4 level ratios 79 and 200 gave the fewest PCG iterations; a degree-10 polynomial cannot
reduce modes far below λmax/100 anyway). The coarsest response to a constant residual,
q(0) = −P′(0) = 43.4 (against 7 for the ten Jacobi sweeps it replaced), makes the per-iteration region projection of
`PcgIteration` necessary on singular problems: without it the experiment stalled in 6 of 96 singular fan solves.

**Zero start.** On `Pre` and `Coarsest` x is zero on entry, so the first step's A x is exactly zero: the step skips the
halo fill and operator and computes the same bits. Evidence (2026-09-26, measured on the fine-ratio-8 candidate; the
skip does not depend on the schedule, because A·0 = 0 whatever the step scalars): one-V-cycle digests on eight
hierarchies (uniform periodic and anchored, 17×9×8, sphere, single level, periodic seam and two rotated fans) were
bitwise equal with and without the skip, and a 128³ fan V-cycle took 3.07 ms against 3.44 ms.

**Fine ratio: 5, measured against 8** (2026-09-26, idle GPU, two interleaved rounds against the frozen SOL-02a build,
whose damped Jacobi smoother is shown for reference; the rule was to adopt 5 only if fan PCG iterations and pressure time
both fell by more than the round-to-round spread with no singular stall):

| Measurement | Jacobi (SOL-02a) | Chebyshev ratio 8 | Chebyshev ratio 5 |
| --- | --- | --- | --- |
| 128³ fan, 10 steps: mean PCG iterations | 6.6 | 5.3 | 4.7 |
| 128³ fan, 10 steps: pressure ms/step (rounds 1, 2) | 40.11, 39.90 | 33.45, 33.60 | 30.40, 30.64 |
| 128³ fan, 71 steps: mean PCG iterations | — | 4.45 | 4.25 |
| Backward step, first 100 steps: mean PCG iterations | 33.9 | 19.9 | 17.5 |
| Backward step: pressure ms/step (rounds 1, 2) | 61.63, 60.94 | 34.63, 34.49 | 30.53, 29.39 |
| 96 singular 128³ fan solves (24 angles × 2 thickenings × 2 seeds): stalls, mean, max iterations | — | 0, 10.95, 15 | 0, 10.83, 16 |

Iteration counts were identical in both rounds. Evidence: ignored `output/sol02b/attribution/`, `output/sol02b/study/`,
`output/sol02b/fan-long/` and the idle checks in `output/validation/sol02b-idle-checks/`.

**Contract** (method-agnostic, tested through the preconditioner in `MultigridSymmetryTests`): the result is linear in
b; b is never written; on `Pre` and `Coarsest` the input x is zero (the cycle clears it, so an incoming z is ignored).
With the transfers, the pre and post passes give a V-cycle that is symmetric in the volume-weighted inner product to
float rounding. `MultigridPass` lets an asymmetric pair (forward and backward Gauss-Seidel, SSOR) express its passes.
The smoother must not know PCG, regions, geometry building, estimator categories, scenario settings or the level index.

## Transfers

- **Restriction** (`MultigridKernels.Restrict`): R = V_c⁻¹ Pᵀ V_f, each coarse value Σ V_f r_f over the non-solid
  children divided by the coarse cell's own volume V_c (0 where V_c = 0). It is the exact adjoint of the prolongation,
  ⟨R r_f, e_c⟩_{V_c} = ⟨r_f, P e_c⟩_{V_f}, for every e_c that is zero where V_c = 0; `MultigridTransferTests` measures
  the defect at 5e-9 or less at every level pair of uniform, sphere, 17×9×8 and rotated fan hierarchies.
- **Prolongation** (`MultigridKernels.Prolongate`): injection, adding the parent's correction to every non-solid fine
  cell. Trilinear interpolation was dropped because it hurt cut-cell convergence.
- **Rebuilt levels.** Coarse levels rebuilt from an SDF (the moving fan) have V_c ≠ Σ V_f, and a coarse cell can be solid
  while some of its children are fluid. On every tested fan level the zero-volume coarse cells are exactly the solid
  ones; their inverse diagonal is zero and the prolongation skips solid cells, so a V-cycle leaves their correction
  exactly zero and the identity holds (`MultigridTransferTests`).
- **Right-hand-side amplification.** A coarse value is scaled by Σ V_f / V_c relative to the children's mean: 1 on
  uncut cells, larger where a small coarse cut cell covers large fine children. Measured maxima over cut coarse cells of
  the 128³ fan: 8.7 (0°) and 8.0 (18.75°) with `ConstantPhysical`, 10.5 and 9.2 with `SameCellFraction`; the tests bound
  it by 16.
- **Odd sizes.** Coarse spacing is `fineDx·Nx/coarseNx`, so V_c ≠ Σ V_f even without geometry: on 17×9×8 an interior
  coarse cell has Σ V_f / V_c = 8/6.8 ≈ 1.18 and the clipped last x layer 4/6.8. The adjoint identity still holds.

## Stencil

`PressureStencil.ApplyCorrection(x, result, boundaries)` fills the halo from `boundaries.Homogeneous` and applies the
operator (smoother steps, the V-cycle residual, PCG A·p); `ApplyPhysical` uses the physical values (the PCG initial
residual and the failure summary's true residual). Callers always pass the physical descriptors, so a correction
cannot pick up prescribed values. The inverse diagonal stays in `MultigridKernels.BuildJacobiDiagonal`, shared with
the Jacobi preconditioner.

## Footprints

Each owner reports its device bytes beside its allocations, and
[`ScenarioMemoryEstimator`](https://github.com/hankbeasley/polycfd/blob/b7f5b9ba6f52355d9b196b586e4a6c8719d8feea/src/PolyCfd.Gpu/Scenarios/ScenarioMemoryEstimator.cs) composes them without re-deriving a layout:

| Owner | Bytes (c cells, f staggered faces, h packed halo cells) | Estimator category |
| --- | --- | --- |
| `GpuMgHierarchy.PlanLevels` | the levels | the estimate's `PressureLevels`, one category per level |
| `GpuMgLevel.DeviceBytes(size, finest)` | 2(c + f) half metrics + 4c volumes + c operator classes + 4h halo + 12c diagonal, residual and temp + (coarse levels only) 8c correction and right-hand side + c level classes | `pressure-level-<i>` |
| `PcgIteration.DeviceBytes(cells)` (both solvers) | 20c: x, r, z, p and A·p | `pressure-pcg` (multigrid) |
| `PcgSolverGpu.DeviceBytes(size)` (Jacobi) | 4(c + f) two metric uploads + 20c `PcgIteration` + 4c inverse diagonal (`JacobiPreconditioner.DeviceBytes`) + 4c volumes + c classes + 4h halo | `pressure-jacobi` |
| `PressureComponentsGpu.DeviceWorkspaceBytes(cells)` (both solvers, lazy) | 8c labels and parents + fused-pass partial sums and per-region scalars, at the smallest group size (16) | `pressure-regions` |
| `PressureFailureProbe.WorkspaceBytes(cells)` (both solvers, lazy) | `DeviceStatisticsGpu.WorstCaseBytes`: one 32-byte `FieldStatistics` partial per cell plus the result (group size one) | `pressure-failure-statistics` |

SOL-02b removed level 0's unused correction (−4c₀) and added the kept right-hand side on every coarse level (+4c_l): −1,792 B
for the Workbench preflight fixture's 8³ grid (levels 8³ and 4³) and about −6.9 MiB for the 128³ fan, and took estimator
model `single-block-6`. The byte functions assume host metrics and a cell classification, as the projection constructs every
solver. `PressureSolverFootprintTests` constructs each solver under `DeviceMemoryAudit` and requires exactly these bytes
and the literal expected levels. The two lazy workspaces depend on the accelerator's group size, so the estimator reports
their device-independent allowance; the tests require a rebuild that retains the region workspace, and the first
non-converged solve, to allocate exactly the owner's layout at the device's group size
(`PressureComponentsGpu.DeviceWorkspaceBytes(cells, groupSize)`, `DeviceStatisticsGpu.DeviceBytes`) and no more than
the allowance. `InspectionPreflightTests` checks the whole session. A buffer change updates its owner's function and bumps the
estimator ModelVersion with every copy
([changing the model version](https://github.com/hankbeasley/polycfd/blob/b7f5b9ba6f52355d9b196b586e4a6c8719d8feea/docs/RESOURCE_PREFLIGHT.md#changing-the-model-version)).

## Resources

- **V-cycle:** kernel launches only; no allocation, upload, download or host synchronization. Per V-cycle each level
  above the coarsest applies its stencil 6 times (2 pre-smoothing, 1 residual, 3 post-smoothing) and the coarsest 9 times.
- **PCG loop:** three scalar host reads per iteration and a bounded constant per solve, audited by
  `PressureSolverResourceTests` for both solvers; the budget table is in the
  [Projection README](/src/PolyCfd.Gpu/Projection/README#resource-budget).

## Kernel rules

Every new or rewritten multigrid kernel (the smoother step, the residual and the transfers) follows these rules:

- **No nested integer conditionals.** ILGPU 1.5.3 in a C# Release CUDA build silently narrows a nested integer join
  with a boolean-derived branch to bool ([bug report](https://github.com/hankbeasley/polycfd/blob/b7f5b9ba6f52355d9b196b586e4a6c8719d8feea/docs/ILGPU_PERIODIC_INDEX_BUG_REPORT.md)); select every
  integer with separate single-level statements (the Chebyshev step's `first` and `zeroStart` flags are separate integers
  tested in separate statements).
- **Correct for any launch group.** Kernels bounds-check every thread of `KernelLaunchHelpers.Make3D` and use no group
  reduction; a future group reduction must match the actual launch group (the CPU accelerator uses about 16 threads per
  group), never the CUDA 32×4×2.
- **CPU coverage is reported, not claimed.** Kernels must compile for the CPU accelerator; CPU test passes are not run
  (user directive), and results say "CPU backend not run (user directive)".
- **Independent checks:** multigrid is checked against references that do not share the cycle: a host double V-cycle of
  the same method (`MultigridTransferTests`), the host Chebyshev polynomial on eigenmodes (`ChebyshevSmootherMethodTests`),
  the Jacobi solver (`PcgMgSolverGpuTests.SolutionsAreConsistent`), independent host residuals
  (`PressureSolverContractTests`) and manufactured Dirichlet profiles (`NonzeroPressureBoundaryTests`).

## Limits

- **Coarse cut-cell geometry is rediscretized, not averaged:** each coarse level samples the SDF, so thin features can
  thicken, close a passage or drop out of a coarse level (`CoarseThickeningMode`); the hierarchy tests check
  connectivity on the fan. This affects the convergence rate, not the cycle's symmetry or positivity, which hold by
  construction on every level with a symmetric flux matrix (see the V-cycle).
- **Geometry crossing a periodic boundary.** The two wrap apertures of a periodic direction are built independently, so
  the operator can be nonsymmetric there, with complex eigenvalues inside the Gershgorin disc |λ − 1| ≤ 1 on which a
  real-interval polynomial is not bounded by 1: at worst 1.39 for the pre/post polynomial and about 760 for the coarsest
  one, though near the real axis they stay small (1.0002 and 1.04 for |Im λ| ≤ 0.01; 1.82 for the coarsest at 0.05).
  The guard (`MultigridTransferTests`, a sphere mirrored across the x seam and a sphere cut by it, 32³) converges in 5
  and 6 iterations with independent residuals of 5.4e-7 and 2.6e-7; it is a guard, not a general support claim.
- **Single-level and truncated hierarchies** (`maxLevels` 1–3): the degree-10 coarsest schedule is then the whole
  preconditioner or a large coarsest "solve". Guard: a 32³ sphere converges in 12–23 iterations with one level and 6–11
  with two or three (`MultigridTransferTests`).
- **Rebuilt fan levels:** solid coarse cells over fluid children and the right-hand-side amplification bound above.
- **Odd sizes:** the adjoint scaling above; covered by the adjoint and footprint tests on 17×9×8.
- **Fixed 2× coarsening** stops at the shortest dimension, so a thin or anisotropic domain keeps few levels.

## Testing

The multigrid validation suite selects GPU tests by name, not only the classes in this folder: shared PCG, pressure,
projection, BLAS, boundary and halo, div-grad, moving-wall, session and moving-workflow tests
([choose coverage](/docs/COMPLETE_VALIDATION#choose-coverage-for-the-change)). The focused command is the
suite's filter:

```bash
dotnet test test/PolyCfd.Gpu.Tests -c Release --settings test/gpu.runsettings --filter "(FullyQualifiedName~Multigrid|FullyQualifiedName~Pcg|FullyQualifiedName~Pressure|FullyQualifiedName~Projection|FullyQualifiedName~GridBlas|FullyQualifiedName~Boundary|FullyQualifiedName~Halo|FullyQualifiedName~DivGrad|FullyQualifiedName~MovingWall|FullyQualifiedName~SimulationSessionTests|FullyQualifiedName~MovingScenarioWorkflowTests)&Backend!=CPU"
```

Tests follow the [test kinds](https://github.com/hankbeasley/polycfd/blob/b7f5b9ba6f52355d9b196b586e4a6c8719d8feea/test/README.md#test-kinds): untagged tests are contracts any correct smoother
or transfer pair passes; `Kind=MethodSpecific` marks tests that encode the current method (`ChebyshevSmootherMethodTests`
and the host double V-cycle comparison in `MultigridTransferTests`), the only ones rewritten when the method changes; `Kind=Efficiency` marks iteration-count ceilings and comparisons, which are generous and never
the correctness check. Labelled tests that also hold contract assertions (connectivity, convergence, solid-cell values)
keep them, with a comment naming the efficiency check, because splitting them would change the protected selection;
those assertions stay contracts. Every test in the `PolyCfd.Gpu.Tests.Multigrid` namespace joins the protected multigrid
selection. The rotated fan rows share `FanTestGeometry` (the fan case's 128³ grid, mesh and thickening).

| Contract | Tests |
| --- | --- |
| The cycle is symmetric in `<.,.>_V` to float rounding (below 1e-6 on uniform, sphere, cylinder and four rotated fan hierarchies: 0° and 18.75°, both thickening modes), positive on a sampled vector, linear in r (below 1e-6; 2e-6 on the six-level fan hierarchies), never writes r and ignores the incoming z | `MultigridSymmetryTests` |
| The restriction is the V-adjoint of injection at every level pair (below 1e-7; uniform, sphere, 17×9×8, four fan hierarchies); a V-cycle leaves zero-volume and solid coarse cells exactly zero; fan right-hand-side amplification at most 16; a GPU V-cycle equals a host double V-cycle of the same smoother and transfers (below 2e-6; 8³ and 16³, periodic and anchored; `Kind=MethodSpecific`) and each coarse `Rhs` still holds its restricted right-hand side afterwards; periodic-seam, single-level and truncated guards are converged or honestly unconverged and finite | `MultigridTransferTests` |
| A Chebyshev pass scales each eigenmode of D⁻¹A on an uncut periodic box by the host polynomial and never writes b (`Kind=MethodSpecific`) | `ChebyshevSmootherMethodTests` |
| Solves converge, including cut cells and moving fan hierarchies | `PcgMgSolverGpuTests`, `CutCellMultigridTests`, `FanHierarchyTests` |
| Regions, refusal and failure report (both solvers), including sealed pockets with and without an outlet under the Chebyshev cycle | `PressureRegionSolveTests`, `PressureSolveFailureReportTests` |
| Solver contract and transfer budget over both kinds (independent host residuals, honest cap, geometry revision, preconditioner map) | `Numerics/Pcg/PressureSolverContractTests`, `Numerics/Pcg/PressureSolverResourceTests` ([Projection README](/src/PolyCfd.Gpu/Projection/README#testing)) |
| Levels and device bytes, including the lazy region and failure-statistics workspaces | `PressureSolverFootprintTests` |
| Multigrid needs fewer iterations (`Kind=Efficiency`) | `PcgMgSolverGpuTests.ConvergesFasterThanJacobi`, `CutCellMultigridTests.MultigridSolver_WithBoxObstacle_ConvergesFasterThanJacobi`, `FanHierarchyTests.FanCoarseLevels_ConnectivityAndIterations_AcrossAnglesAndThickeningModes` (thickening modes) |
| Iteration ceilings (`Kind=Efficiency`; the other assertions in these tests are contracts) | `CutCellMultigridTests.MultigridSolver_WithSphereObstacle_Converges` (< 100), `CutCellMultigridTests.MultigridSolver_WithSmallSphere_MaintainsGeometryAtCoarseResolutions` (< 50), `FanHierarchyTests.TwoRegionFineLevelSolveConvergesAtTheInvestigatedStallAngle` (≤ 40) |

## Changing the smoother

1. Replace the body of `MultigridSmoother` (kernel, constants, schedules, derivation). Keep `Smooth`'s signature; scratch
   comes from the level buffer the cycle lends (`work`), which must not alias b or x.
2. Keep `MultigridCycle` and the transfers unless the change needs them; keep the coarse right-hand side separate from
   any buffer the level writes.
3. Build new kernels by the kernel rules above.
4. Run the contract tests unchanged; tighten `MultigridSymmetryTests` and `MultigridTransferTests` bounds when the change
   earns it, never loosen them. Rewrite only `Kind=MethodSpecific` tests of the old method (`ChebyshevSmootherMethodTests`
   and `MultigridTransferTests.VCycleMatchesAHostDoubleVCycleAndKeepsTheCoarseRightHandSide`, whose host cycle encodes
   the smoother and transfers); keep their names, because the multigrid selection lists tests by name.
5. If level buffers change, update `GpuMgLevel.DeviceBytes`, the roles table and the estimator ModelVersion with every
   copy.
6. Update this README and the pointers to it, then run the `verify-all` multigrid suite (and quick; full before a
   broad claim) ([choose coverage](/docs/COMPLETE_VALIDATION#choose-coverage-for-the-change)); never
   refresh a baseline to pass.