# Simulation and viewer contracts

Scenario, verification, measurement and result-format contracts updated 2026-09-22; host lifecycle seams 2026-09-23; planned transport selection linked 2026-09-26 (no runtime change). Architecture acceptance and explicit
deferrals are in [the implementation record](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/ARCHITECTURE_IMPLEMENTATION_EVIDENCE.md);
current workflow verification and limitations are on the [work board](/docs/WORKBOARD).
Lower-level operator APIs remain supported; these contracts provide an application entry point.

## Execution and ownership

**Performance-first policy revised 2026-09-26; numerical migration pending:** keep
working fields, repeated numerical kernels, complete reductions, solver coefficients
and numerical checks on-device. Host setup and bounded control/status/layout metadata
are allowed, including device-computed completion flags used to schedule launches.
Measure transfer cadence, synchronization and memory; prohibit bulk field/partial
downloads for CPU numerical completion and new CPU simulation fallbacks.
The [GPU-01 plan](/docs/plans/gpu-resident-processing) defines the boundaries, current
gaps and migration evidence. The current `Advance`/`StepReport` API and field residency
below do not establish closure. AMR-02 may retain inventoried legacy paths while
preserving science/performance and adding no bulk numerical readbacks; full GPU-01
closure is not its prerequisite. Requested outputs remain a separate boundary.

For file or typed-data authoring, start with [scenario format 1.2](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/SCENARIO_FORMAT.md):
Core owns the DTOs/preflight, GPU resolves a validated snapshot into the contracts below, and
`PolyCfd.Execution` owns the shared accepted-step loop, file output, status and replay support.
It also owns saved-run inspection and [parameter studies/comparisons](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/PARAMETER_STUDIES.md).
Study orchestration uses the same loop sequentially, with optional measured comparison.
A host scientific outcome is recorded separately from numerical completion and can stop
further variants. Comparison reads saved metadata/scalars
without creating a session or loading field payloads.
The CLI hosts ordinary runs and scientific verification; Validation supplies observers and
reference policy and consumes validated scenarios directly with separate scientific and run settings.
Validation has no Web/ASP.NET dependency. The CLI composes optional export/viewer observers
through `VerificationRunOptions.CreateObserver`, before device allocation. The returned
consumers are owned by the run; headless verification needs no viewer or callback.
Execution owns shared VTU/HDF5 export observers; Web owns the live viewer observer/host.
Composite observation unions due field/geometry requests before the shared capture.
All use `scenario-run.json`; scientific runs add `verification.json`.
The serialized profile includes bounded fields/sources, masks, rotation and adaptive controls.
Neither scenarios nor output records are restart checkpoints.

```text
SimulationDefinition + SimulationExecutionOptions
                ↓
SimulationSession (Core + GPU only)
     ├─ Advance → compact StepReport
     ├─ Capture(field masks) → borrowed host state
     └─ completed device state → requested GPU slices
                ↓
validation / lossless files / replaceable live preview
```

A session owns device state, domain BCs, integrator and disposable factory-created sources. The caller owns the backend; dispose the session first. `Create` owns its host grid geometry; the attaching constructor borrows the supplied block/state. Direct runtime callers lend moving builders/motion and must keep them alive through the session.
`ScenarioResolver.CreateSession` instead transfers ownership of its created builder/cache to the session;
these are disposed after the integrator. Fresh-integrator experiments retain device fields and motion ownership. A session and its device operators have one numerical owner, not concurrent callers.

`SimulationExecutionOptions.Advection`, `TimeIntegratorGpu` and `AdvectionGpu`
default to limited `MacCormack`. Explicit `SemiLagrangian` remains supported.
Scenario inputs still require an explicit scheme; resolving or replaying them
preserves that selection. See [advection tradeoffs](/docs/PHYSICS_GUIDE#advection).

**Planned, not implemented:** [TRN-01 method selection](/docs/amrex-alignment/SINGLE_LEVEL_TRANSPORT#bounded-method-selection)
will bind one immutable method per run to shared device views, with implementation-owned
scratch and explicit capability, timestep, sampling and integration-stage requirements.
It retains useful current methods and adds one qualified conservative option. Typed
inputs, serialization and replay must preserve the choice; unknown/unsupported
combinations must fail without fallback. Both fixed and adaptive paths must enforce
method-specific hard limits on the device. Static host dispatch selects an input
method; numerical checks and dependent data remain GPU-resident, with bounded host
status-based scheduling allowed by GPU-01. This is future work, not a claim that the current mutable
operator API already enforces these contracts. Derivations and qualification evidence
are outlined in the [algorithm review guide](/docs/amrex-alignment/ALGORITHMS).

`Advance` does not download full fields, except that a moving pressure topology with more than 16 connected regions is classified on the host at every step (about 12 bytes per cell to the host; see [resource estimates](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/RESOURCE_PREFLIGHT.md)). `StepReport` records actual start/dt/end time, pressure and U/V/W diffusion results, completion and state usability. A non-converged pressure result carries a bounded, device-computed [failure summary](#pressure-failure-summary); solvers write no files. Solver convergence is separate from a case's physical acceptance. A failed in-place stage can modify fields: the session stops and rejects capture; it does not allocate rollback snapshots. Pre-step cancellation preserves the last valid state. Fixed steps preserve overshoot unless `ClampFinalStep` is explicit; adaptive steps always clip to the remaining final time.

`Capture(StateCaptureFields, GeometryCaptureFields)` downloads only newly requested fields for a completed step. Its returned host object is borrowed and may change at the next capture; fields omitted from the mask are not promised current. Initial host data requires no copy. File/diagnostic adapters decide due consumers before capture. Required files apply backpressure and preserve output; live previews coalesce/drop obsolete demand.

```csharp
using PolyCfd.Core.BC.Patches;
using PolyCfd.Core.Core;
using PolyCfd.Gpu;
using PolyCfd.Gpu.Core;
using PolyCfd.Gpu.Integration;

using var backend = IlgpuBackend.CreateDefault();
var definition = new SimulationDefinition {
    Size = new GridSize(8, 8, 8), Dx = .125f,
    SetInitialCondition = (state, _) => {
        state.UInterior.Span.Clear(); state.VInterior.Span.Clear();
        state.WInterior.Span.Clear(); state.PInterior.Span.Clear();
    },
    CreatePatchBoundaryConditions = s => PatchBcSetFactory.AllPeriodic(s.Nx, s.Ny, s.Nz)
};
using var session = SimulationSession.Create(backend, definition,
    new SimulationExecutionOptions { Timestep = .001f, FinalTime = .003f });
while (!session.IsComplete) {
    var report = session.Advance();
    if (!report.StateUsable || !report.SolversConverged)
        throw new InvalidOperationException("Application acceptance failed");
}
var host = session.Capture(StateCaptureFields.P, GeometryCaptureFields.None);
// Consume host.PInterior here; no U/V/W/geometry readback was requested.
```

The executable version of this independent caller is in `SimulationSessionTests` in the GPU test project; that caller uses only Core/GPU runtime APIs. Validation adds case callbacks/force windows/baseline policy through its descriptor adapter; those policies do not belong in the runtime.

## Host lifecycle seams

`ScenarioRunner.Run` and `StudyRunner.Run` take optional `ScenarioRunOptions`/`StudyRunOptions`
for hosts such as the workbench worker (`PolyCfd.Worker`, which passes planned IDs and lifecycle
hooks, and only an optional live-result observer that records the session and demands no capture). The
CLI omits them: IDs stay generated and outputs unchanged.

- **Identity.** A caller may allocate the run ID, study ID and a variant→run ID map. IDs are
  32 lowercase hex characters (`ScenarioRunRecord.IsRunId`); the map names exactly the variants,
  is unique and excludes the study ID. Invalid options throw `ArgumentException` before any
  device or output. `scientificId` never includes the run ID.
- **Notifications** (`ScenarioRunLifecycle`, `StudyRunLifecycle`) run on the runner thread with
  detached records. Phases: `Allocating` before backend creation, `Initializing` after session
  creation, `Advancing`, `Finalizing`. `Materialized` follows publication of the record and
  initial status. `AcceptedProgress` (run ID, step, time, dt, geometry revision, status time)
  follows initial publication and, per step, solver/finite acceptance, due field/measurement
  output (including the final step's forced measurement) and `status.json`; it requests no
  capture. On cancellation the last accepted step's forced measurement follows its progress; it
  is not a new accepted state. `AfterStep` remains the pre-acceptance timing boundary. Studies
  add variant start/finish and study phases.
- **Host failures.** A throwing callback stops execution as `failed` with a
  `Host lifecycle callback … failed` message, and the finalization error is that
  `RunLifecycleException`. Setup callbacks propagate like setup failures; a throwing `Finalized`
  propagates after the terminal write. An `OperationCanceledException` from a callback after the
  run's token was canceled is not a failure: the runner's next check gives its ordinary
  cancellation outcome, and a later genuine failure keeps the cancellation message.
- **Finalization.** A failure before a run status exists propagates after `Finalized` with the
  planned run ID, the error, a null status and (unless already claimed) a null directory. For a
  materialized run, observers, session, `InitializeBackend` resources and backend are disposed
  in that order, every disposal failure is collected, and only then is the terminal
  `status.json` written; a teardown failure makes it `failed`. `.run-lock` is held until then.
  `Finalized.Status` is the persisted terminal status, or null when the terminal write failed;
  `Run` then throws and `status.json` keeps its last `running` snapshot (later read as
  `interrupted`). `Finalized.Error` carries only non-numerical causes: setup, host callback,
  teardown or terminal write. `Finalized.Failure` classifies a persisted `failed` status when the
  runner knows why (`RunFailure`): `Numerical` for a rejected step (a failed solve or non-finite
  fields, detected before that step was published), `Output` for an output write (`IOException` or
  refused access), `Host` for a host callback; otherwise, and for any other status, it is null. It
  changes no numerical behaviour; the workbench worker maps it to a job's terminal reason.
  Observer artifacts written in `Stopped` precede teardown; callers
  that publish terminal evidence there must reconcile it with the returned status, as the
  verifier's `verification.json` and the fan diagnostic's `experiment.json` do. A study stays
  `running` until its configured comparison is written and comparable; `StudyFinalized` follows
  the same status and error rules.
- **Optional consumers.** `OptionalScenarioObserver` isolates a preview: its failure or
  `Detach()` stops its demand and callbacks (`Failure` records the cause) and it contributes no
  scientific outcome. Required observers and scenario field export are never isolated. Frame
  and slice acquisition tokens are `runId:source:sequence` and each provider numbers from 1, so
  tokens are unique for one frame provider and one slice provider per run binding. A host that
  binds several providers of one source to a run, or rebinds one with `Reset`, must not share
  token caches between those bindings.
- **Devices.** `IlgpuBackend.EnumerateDevices()` lists CUDA, OpenCL and CPU devices from the
  shared context without creating accelerators. `ScenarioBackend.Resolve("auto"|"cpu"|"cuda")`
  returns the device `Create` would use; auto is CUDA device 0 when any CUDA device exists,
  otherwise CPU device 0 (the same resolver as `CreateDefault`; OpenCL is never automatic). That rule is
  `IlgpuBackend.SelectDefaultDevice(listing)`, a pure function a host applies to a cached device listing
  (the workbench service never enumerates devices itself); it selects nothing, rather than the CPU, when
  CUDA devices are listed without device 0.
  `ScenarioBackend.Create(ExecutionDevice)` checks the enumerated type, index, name and memory
  before creating an accelerator, re-checks the created accelerator, and throws rather than
  falling back; OpenCL targets are rejected. The identity has no PCI bus ID, so two identical
  GPUs exchanged at one index are indistinguishable. Accelerator creation errors carry the inner cause.

## Extension seams

- Sources receive `SourceStageContext`: borrowed device velocity views, grid, time and dt. The integrator owns BC application and force-before-diffusion order. The old source overload remains a compatibility adapter.
- Pressure solvers implement `IPcgSolverGpu` ([code](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Gpu/IPcgSolverGpu.cs)): required members only, pressure descriptors only (`DevicePatchBcSet.Pressure`), `PrepareGeometry` as the only geometry entry, solver-owned pressure regions and gauge, honest results with a bounded failure summary, and no files. Both built-in solvers share one PCG loop (`PcgIteration`) behind an internal preconditioner seam. The interface, the loop, the regions, `SolveResult` and the resource budget are specified in the [Projection README](/src/PolyCfd.Gpu/Projection/README#solver-interface); `ProjectionGpu` disposes the solver it creates and, unless `ownsCustomSolver: false` borrows it, an injected one. See [the timestep equations](/docs/PHYSICS_GUIDE#2-one-time-step).
- `IMovingGeometry` coordinates sampled geometry and wall velocity. `IRigidRotationGeometry` selects the existing device rotation wall/continuity kernels, independently of the concrete class. `IDeviceSdfMotion` supplies device SDF sampling; legacy `GetRotatedSdfAt` implementations remain supported. Setup verifies device sampling; a zero/nonfinite rotation axis is rejected. General-motion volume-difference/host wall fallback is retained. This does not validate new deformation or moving viscous-wall physics.
- Six ordered domain patches support whole-face pressure conditions and partial velocity masks, including the backward-step inlet. Periodic pairing, layout, values and indices are checked at setup. A pressure condition covers a whole face; a face cannot be split into independent pressure patches (disconnected fluid regions are the solvers' pressure regions above). Immutable pressure descriptors are resolution-independent and shared across MG levels; no per-solve coarse velocity index uploads remain.

Both integrator steps pass `DiffusionSolveSettings.Default` (800 total iterations, relative `1e-6`), the values
`ScenarioEffectiveSettings` records, as the required settings argument of `DiffusionGpu.DiffuseVelocity`, which
returns a typed `DiffusionResult`. The operator, method, exits and budget are in the
[Diffusion README](/src/PolyCfd.Gpu/Diffusion/README).

## Memory and invalidation

CPU spans are only for host buffers. A `DeviceBuffer<T>` exposes a borrowed ILGPU view or an explicitly named copy, never an implicit host span. `GridMetrics.UploadToDevice` creates a device owner from host data; `BorrowDeviceMetrics` requires already-device-backed metrics on the same backend. Legacy `ToGpu` remains for compatibility and must not be assumed to transfer ownership.

Current device geometry owns fractions/classification. Fine operators bind borrowed views; pressure solvers own float volume weights/diagonals and per-level state. Shape/backend compatibility is checked when binding. A new geometry revision invalidates weights, diagonal, connectivity and output geometry; previous volumes remain available for the general motion source. Coarse samples remain distinct. The integrator binds new geometry before disposing the previous owner and disposes owners after borrowers. Legacy copy-based geometry updates restore owned destinations instead of writing through borrowed views. No per-revision array hashing is used.

Projection/BLAS scratch and scale delegates are retained; the pressure-region compact lists of a topology with more than 16 connected regions are reallocated at each region rebuild. Static cut-face kernel families compile only on first use; moving-wall-only runs avoid compiling unused static modes. Existing stream/pool completion barriers stay in place. Async HTTP does not mean async GPU readback; copies complete before CPU consumers acquire their payload.

## Capture, encoding and browser lifetime

Live planes use the [PCFD version 2 result bundle](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Core/LiveViewer/BINARY_FORMAT.md#version-2-result-bundle),
as saved planes do. Saved snapshots and full-volume live geometry use
[HDF5 field-frame version 1](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/HDF5_FIELD_FORMAT.md), with ZYX dataset dimensions and native
X-fast values. The retired version 1 envelope, the older live binary headers and
unversioned HDF5 files are rejected. Explicit offline conversion preserves maintained historical samples.
Scientific fields remain float32; native aperture geometry keeps its binary16 values
without quantization; classification uses uint8. Pressure bytes remain kinematic pressure.

`ISimulationFrameSource.Acquire` returns a lease or
Available/Unavailable/Expired/Canceled/Busy/Unsupported status. Frame identity carries
the canonical run, source-qualified capture token, completed step, time and geometry
revision. The captured timestep is retained for HDF5 provenance. CPU full captures
use at most three slots and 64 leases, with a conservative 128 MiB reservation for
fields, native geometry revisions and replacement headroom checked before allocation.
An oversized full capture reports unsupported; grid metadata and independently bounded
GPU plane capture remain available. Static geometry is shared by revision.

The serializer retains its lease until writing completes. One provider-wide encoding
gate limits transient payload work to 64 MiB. HDF5 snapshots use a bounded buffer with
writer scratch accounted separately; encoded HDF5 is not cached on retained frames.
Disposed leases clear their frame reference and release callback. Clients must not
keep borrowed arrays after disposing their lease. Full capture demand is suspended
while every replacement slot is pinned; retained-token reads do not request another
device capture. No client means no automatic initial or recurring full-state readback.

`ISimulationSliceSource` is a completed-step demand mailbox: at most eight distinct
pending requests, 64 waiters, 64 active leases and three retained results. Identical
requests share one capture; the numerical owner services one batch per completed
step. Only requested native planes, cell velocity, scalar speed and/or cell
classification are gathered (`LivePlaneCapture` over `DeviceSliceCapture`; a cell vector and
its speed on one plane share one gather), into owned version 2 products. Classification costs one byte per requested cell and
allows current-frame solid masking without a full geometry download. Requests for
pressure or speed alone do not capture an unused velocity vector.

The combined packet must fit 64 MiB, including descriptor and send headroom. Retained
and outstanding leased arrays share a 192 MiB host budget; eviction cannot release
bytes still held by a consumer. Acquisition disposal clears the payload reference
before returning its lease. Canceled demand with no remaining waiters is removed
without a device copy. An unavailable subset of an old capture expires rather than
silently capturing a newer state. Full SDF, aperture arrays and HDF5 snapshots remain
separate full-frame consumers; a slice token does not identify a full-frame capture.
The CPU full-frame and GPU plane limits are independent per-path limits, not one
192 MiB budget shared across the entire worker or host.

`GET /api/results.bin?products=pressure.kinematic,geometry.cellClass&axis=Z&index=0`
returns one coherent version 2 bundle. Add `velocity.speed`, `velocity.cell` or native
`velocity.u/v/w` only when needed. The host also exposes JSON grid metadata, a geometry
HDF5 frame and snapshot routes documented in [PolyCfd.Web](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Web/README.md). Unknown
API routes return 404. Responses expose `X-PolyCfd-Frame`, `X-PolyCfd-Run`,
`X-PolyCfd-Step`, `X-PolyCfd-Time`, `X-PolyCfd-Geometry` and an ETag. Use
`?frame=<token>` to request an already captured subset. Expired tokens return 410,
unavailable/busy sources 503, unsupported or malformed requests 400, client
cancellation 499, and the response deadline 504 when headers have not been sent.
After response start, failed sends abort the connection. Full-frame ETags support
`If-None-Match` and 304 responses. The host admits at most eight binary/HDF5 sends,
uses 64 KiB chunks, a five-second idle-write deadline and a 30-second total response
deadline, and retains the capture lease through completion or cancellation. These
network deadlines do not limit the numerical solver.

The workbench worker applies the same lifetimes to live results from a job
([worker live results](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Worker/README.md#live-results)): its observer borrows
the session only between `Started` and `Stopped`; captures run only on the runner thread
inside `AcceptedProgress` (after acceptance, finite checks and required publication), read
the completed device state and geometry of that step and return owned products before the
next step, with their device staging reserved (64 MiB) and released at `Stopped`, before the
session is disposed. Nothing borrowed reaches the IPC writer. A token names one completed
frame (`{runId}:live:{step}`) and is served only from retained owned captures; no client
means no capture and no readback.

The browser consumes one requested product bundle and preserves its native row/column
axes, including Z planes without a transpose copy. Scalar rendering uses a float32
sample texture, a byte classification texture and a display palette on one quad;
changing display range does not expand samples into per-cell geometry. Solid cells
and nonfinite scalar values are not rendered as valid fluid values. Replacing or
disposing a plane disposes its textures and material. A packet's declared size is
bounded before response-buffer allocation. Visible slice admission estimates samples,
classification, glyph and texture storage before acquisition, allowing 64 MiB CPU and
32 MiB GPU for the new view plus equal replacement headroom (128/64 MiB total).
This covers slice preview, not simultaneous offline files, HDF5 library memory, full
geometry or a future two-pane workbench. Offline HDF5 import has separate 32 MiB
input and 64 MiB decoded-array limits. Aggregate browser memory admission remains
part of the future workbench design.

`DatasetSession` normalizes capabilities and cancellation; the live request gate
permits one refresh at a time and rejects stale results. Live refresh requests
selected products instead of polling full HDF5 snapshots. Compatible changes retain
the GridModel/tools and invalidate data by revision. `ScientificSceneRuntime` owns
tools; desktop/XR adapters own cameras, render loops and input. Tools attach to
`contentRoot: Object3D`; orbit controls are optional in XR. The earlier desktop and
emulated XR checks are historical evidence, not hardware acceptance. Actual headset
entry/exit, grip movement and retained scale remain unverified. The grip operation
moves/rotates the root and preserves scale; it does not implement two-controller scaling.

`Hdf5FrameReader` and `Hdf5PlaneReader` supply bounded selected reads as a Core library API:
one bounded header parse per open frame (with a storage check of contiguous datasets), then
plane reads that check payload size, decoded chunk work, chunk count, actual read bytes and
cancellation (`Hdf5ReadLimitException` for a refused limit) with bounded decompression, and
`Estimate` states a plane's work from its header. The frame reader registers a bounded deflate
filter process-wide in place of PureHDF's stock one. The workbench service serves saved frames
through it, with request admission, cumulative per-request work caps and retained-result
budgets ([saved results](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Workbench/Results/README.md)); the existing viewer has
no saved-run route.

## Reproducibility and diagnostics

Stationary callers can use the [aligned-section measurement API](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/SECTION_MEASUREMENTS.md) for
signed aperture-weighted flow and area-averaged static pressure, with explicit time windows.
Create detached host stencils and retained device reducers once; sample only accepted completed
states, and rebuild after geometry changes. Device sampling copies one compact reduction result,
not full fields. Scenario 1.2 resolves named selections/windows, checks pressure connectivity at
setup and publishes accepted scalar artifacts independently of HDF5. The independent
[pressure-driven channel study](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/DUCT_ACCURACY.md) validates integrated flow/pressure,
conservation, settling and refinement for that bounded laminar case.

Ordinary and scientific runs share `scenario-run.json` and asset snapshots. Scientific runs
add `verification.json` with versioned reference policy, samples, acceptance and build identity.
`baseline replay <directory>` compares unchanged inputs/reference policy on the current build;
ordinary `replay <scenario-run.json> --output <destination>` requires matching solver builds.
An explicit `baseline convert` preserves complete legacy manifest/CSV bytes and rejects missing
inputs. There is no runtime folder/CSV inference. See the
[verification guide](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/validation/PolyCfd.Validation/README.md#baselines-and-replay).

`SimulationDescriptor` holds a validated scenario and scientific callbacks, never a second
physical constructor. `ScenarioCaseRunner` observes the shared loop, requests due captures and
applies scientific checks only after solver/finite-field acceptance. Observers dispose before
the session and backend. Required outputs preserve completed-frame identity and apply backpressure.

In the validation CLI, `--hdf5-interval` and `--csv-interval` decouple output cadences; `--vtu-interval` remains. `--detailed-debug` enables expensive moving host scans. `--no-stage-profiling` removes optional synchronized stage timing while retaining coarse regression timing. `--memory-audit <json>` counts explicit logical H2D/D2H/D2D payloads, allocation/retained bytes and barriers by setup/step/geometry/output. It excludes hidden ILGPU allocations and is not a physical PCIe measurement. Compare timings with audit disabled and identical profiling/output settings. Scenario cadence is declared in its `output` and `measurements` objects. No accepted performance reference is automatically replaced by this work.

### Pressure failure summary

A non-converged solve of either built-in pressure solver returns the bounded, device-computed
`SolveResult.FailureSummary` (`PressureSolveFailure`, [code](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/src/PolyCfd.Core/Numerics/PressureSolveFailure.cs)):
true residual, worst cells, solution drift and non-finite counts, with no field copies and no files.
`ScenarioStepAcceptance` includes it (and any `RegionalImbalance`) in its step-failure exception, whose message
`ScenarioRunner` records as the `status.json` message. Its content, host transfers and rules are specified in the
[Projection README](/src/PolyCfd.Gpu/Projection/README#pressure-failure-summary).

### Optional solver-stage observation

`SimulationSession.StageObserver` (also available on `TimeIntegratorGpu`) is an optional
`Action<StepStageContext>` for diagnosing changes within one attempted step. It is a runtime
binding, not a serialized scenario option. Attach it only on steps you need, then set it to
`null`. The null path performs seven null checks and one delegate read per attempted step;
it creates no diagnostic buffers, context objects, kernels, transfers or synchronization.
The context is a value type and is constructed only when an observer is present.

The callback runs synchronously on the numerical owner at these boundaries, in order:

| Stage | Meaning |
| --- | --- |
| `AfterAdvection` | After velocity transport; moving geometry and cell transitions already updated |
| `AfterSources` | Velocity including body forces, before the pressure predictor |
| `AfterPressurePredictor` | Static diffusion input including previous physical pressure; unchanged for the moving formulation |
| `AfterDiffusion` | Diffused velocity, before the separate immersed-wall update |
| `BeforeProjection` | After that immersed-wall update; input to the pressure correction |
| `AfterProjection` | Corrected velocity and newly solved kinematic pressure, including optional residual projection |
| `AfterBoundaryConditions` | After the existing final domain BC application (static path) or moving cut-face update (moving path) |

Stages still fire when sources/diffusion/immersed-wall operations are absent. `StartTime`
and `Dt` identify the attempted step with its actual adaptive/clipped timestep; intermediate
fields have no independent physical timestamp. Pressure before `AfterProjection` is the previous physical field; afterward it is the
new physical pressure, with the static correction already accumulated. No stage, including the last, promises finite fields or solver
convergence: wait for `StepReport` and the application's scientific checks. A failed step
can leave a partial diagnostic trace; never publish it as an accepted frame or restart state.

`State` and its buffers are borrowed only during the callback. Do not modify/dispose them,
retain views, re-enter `Advance`/`Capture`, or launch asynchronous readers. Enqueue diagnostic
GPU operations on the same backend's default stream. The observer adds no automatic device
barrier; existing selective capture performs its own explicit readback. Retain only owned
host results or complete a copy into caller-owned device storage on that stream.
An observer exception follows the normal failed in-place step path: the session stops,
rejects capture and does not roll back. The callback is borrowed, never disposed by the runtime;
its capture/reduction resources remain the caller's responsibility. It survives
`freshIntegrator` experiments and is not called on pre-step cancellation.

For a configured session, compare one native MAC slice around pressure projection:

```csharp
// Types: PolyCfd.Gpu.Diagnostics; PolyCfd.Core.LiveViewer.
using var probe = new DeviceSliceCapture(backend);
var planes = new Dictionary<StepStage, SlicePlane>();
session.StageObserver = context => {
    if (context.Stage is StepStage.BeforeProjection or StepStage.AfterProjection)
        planes.Add(context.Stage, probe.Capture(context.State, Field.U, Axis.Z, 0));
};
StepReport report;
try { report = session.Advance(); }
finally { session.StageObserver = null; } // Detach before the probe's lifetime ends.
if (!report.StateUsable || !report.SolversConverged)
    throw new InvalidOperationException("Diagnostic step failed numerical acceptance");
// planes owns two host slices; their difference is the projection's velocity change.
```

`DeviceSliceCapture` reuses its device staging and returns owned host planes; reuse it across
sampled steps. Requested fields stay staggered. For pressure-gradient checks, capture P at
`AfterProjection` and use adjacent cell centers at the corresponding MAC face. Avoid mixing
cell-center, point-interpolated and face-centered values. `DeviceMemoryAudit.Enter(Output)`
can attribute callback copies separately from solver work. Enabled readbacks perturb timing;
disable stage observation for performance comparisons. A `ScenarioRunObserver` can attach
through `context.Session` during `Started`/`BeforeStep` and detach in `Stopped`; no Validation
or Web dependency was added to the runtime and no automatic trace files are produced.

The explicit-CUDA [stage tests](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/test/PolyCfd.Gpu.Tests/Diagnostics/StepStageDiagnosticsTests.cs)
include the pressure-driven channel diagnostic, field equivalence, logical transfer auditing,
moving geometry, adaptive time/source ordering, cancellation and failed/unconverged steps.
They validate the diagnostic API and expose the current splitting error; they do not establish
enclosure accuracy or require future methods to retain that numerical error.

## Acceptance and future work

The completed architecture refactor's [measured evidence](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/ARCHITECTURE_IMPLEMENTATION_EVIDENCE.md) records 666 passing .NET tests / three existing skips, 31 UI tests, repeated numerical comparisons and explicit transfer audits. Those counts describe that refactor, not a rerun of the current tree. Current scenario/measurement checks and recorded test exclusions are on the [work board](/docs/WORKBOARD). All architecture plan items have a recorded final disposition; actual headset validation is explicitly deferred and unperformed. Coarse pressure solve/smoothing remains a profiling-led optimization candidate; async staging, retained builder destinations, weighted reduction completion, the rest of component batching and planar MG each require the separate contract/numerical/timing evidence recorded in the [conditional decisions](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/ARCHITECTURE_IMPLEMENTATION_EVIDENCE.md#ownership-map-and-conditional-decisions).

## Constraints on future architecture changes

Keep fields and repeated numerical processing on the GPU under [GPU-01](/docs/plans/gpu-resident-processing), with explicit measured setup/control/layout boundaries; request only completed output products at each consumer's actual cadence. Preserve the Core/GPU runtime boundary, specialized device SDF kernels, physical boundary semantics and existing viewer tool model. Bind capabilities during setup rather than introducing per-cell virtual dispatch, implicit host staging or a general plugin framework. Serializable resolved settings contain stable identities, not executable factories or validation callbacks; resolve defaults and hash assets outside stepping.

Ownership refactors must preserve the staggered MAC layout, half-precision geometry, float fields and double weighted reductions. Preserve per-connected-component pressure compatibility, anchored-component handling and gauges as topology changes; a global mean is not equivalent. Keep the aperture-weighted rigid-rotation wall-flux path and the general volume-difference fallback, velocity/pressure boundary and halo semantics, and distinct sampled geometry at each multigrid level. GPU-01 moves numerical processing to the device; preserving these semantics does not require retaining host computation. Coarse geometry cannot be replaced by an unvalidated average.

Preserve useful HUD/demo workflows through explicit output capture; CPU numerical
execution is not a preservation requirement or an accepted fallback for new work.
Current CPU accelerator availability remains an implementation fact pending migration.
Public adapters such as `BoundaryKernels` and `ToGpu` can be replaced or removed with their
maintained callers updated together under the [pre-release cleanup policy](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/AGENTS.md#cleanup-and-compatibility).
Keep pool/stream completion barriers until resource completion and safe reuse are proven.

Separate ownership changes from numerical formula/tolerance changes. Preserve scientific acceptance, force windows, output cadence, full-field nonfinite detection and explicit suite membership; do not shorten runs or replace references to accept a refactor. The [physics guide](/docs/PHYSICS_GUIDE), [stationary fan](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/STATIONARY_FAN_VALIDATION.md), [moving fan](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/MOVING_FAN_VALIDATION.md), [cylinder forces](https://github.com/hankbeasley/polycfd/blob/2515fae75250ee310a783c96057bc4b0a4fa9ee6/docs/CYLINDER_FORCES.md) and [backward step](/docs/BACKWARD_STEP_VALIDATION) define their respective coverage. Focused tests do not replace the full reference simulations, and the short moving-fan regression is not ten-revolution validation. Moving immersed viscous-wall coefficients and general moving-body force accuracy remain separate physics work.
