diff --git a/.gitignore b/.gitignore
index 7370975..f3cf54a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,10 @@ Thumbs.db
# for isolated subagent work; each is its own nested git repo, not
# content this repository should ever track.
.claude/worktrees/
+
+# `pyflow record`'s own default output (TASK-045, `RecordingConfig.
+# output_dir`, `src/pyflow/configuration/schema.py`) -- a user following
+# README's own documented example from the repository root gets
+# `checkpoints/` at the root with no `--output-dir` given; these are
+# real binary run output, never content this repository should track.
+checkpoints/
diff --git a/README.md b/README.md
index 0d0140d..1162701 100644
--- a/README.md
+++ b/README.md
@@ -131,21 +131,35 @@ need to find it.
## Current Phase
-Stage 8 — Recording & Playback -- not yet started (Stage 7, Rendering
-Annotations, closed 2026-09-03 at its exit audit; Stage 8 was inserted
-ahead of Better Numerics on 2026-09-07, which is why that stage is now
-numbered 9 -- `docs/planning/roadmap.md`'s own "Fourth divergence"
-entry). Its live status, generated from the roadmap rather than
-restated here:
+Stage 8 — Recording & Playback -- in progress: its first task
+(TASK-045, periodic checkpointing via headless `pyflow record`) landed
+2026-09-07; replay and playback (TASK-046/047) are not yet drafted
+(Stage 7, Rendering Annotations, closed 2026-09-03 at its exit audit;
+Stage 8 was inserted ahead of Better Numerics on 2026-09-07, which is
+why that stage is now numbered 9 -- `docs/planning/roadmap.md`'s own
+"Fourth divergence" entry). Its live status, generated from the roadmap
+rather than restated here:
[Stage 8 in the status report](docs/planning/status.md#stage-8----recording--playback).
+**This sentence said "not yet started" for the same reason a fourth
+time here**: TASK-045 landed the same day this stage was inserted, and
+the first draft of this update again left the word stale, exactly the
+pattern the paragraph below already names for Stage 7. `make
+check-status` did not catch it this time either, and for a related but
+distinct reason -- Stage 8's own `Status as of` heading initially used
+free text that satisfied `check_stages.py`'s looser "starts with
+'Status as of'" match but not `generate_status_report.py`'s stricter
+template, so the status line was invisible to the checker rather than
+merely agreeing with a stale prose claim. See
+`docs/planning/roadmap.md`'s own Stage 8 Status section for that fix.
+
**This sentence said "Stage 7 -- not yet started" for three days after
that stage's only task landed**, and `make check-status` did not catch
it: that check compares the stage this section *names* against the
roadmap's first stage not marked complete, and Stage 7 had no status
line at all, so both agreed on the number while the prose was wrong
about what had happened to it. Recorded because this section has now
-gone stale at three consecutive stage boundaries.
+gone stale at four consecutive stage boundaries.
**Stage 5 is the MVP** (`docs/implementation/mvp.md`): PyFlow solves
incompressible Navier-Stokes end to end, and the Lid-Driven Cavity
@@ -217,6 +231,52 @@ completion criteria (`docs/planning/roadmap.md`):
added 93 step definitions, 28% of the repository's whole step
vocabulary, which is evidence against its own claim rather than for
it.
+**Stage 8 (Recording & Playback) is in progress, one of its three
+planned pieces built.** TASK-045 (2026-09-07) adds `pyflow record`/
+`pyflow resume`: `record` steps a simulation forward with no rendering
+window at all, writing a self-contained checkpoint file at frame 0,
+every `recording.checkpoint_interval` frames (100 by default), and at
+the final frame -- a bounded, resumable seek index across the whole run,
+not one file per frame; `resume` continues an existing recording from
+its own last checkpoint, with no `--config` at all (the checkpoint
+carries its own). Deterministic windowed replay and a playback path with
+pause/variable speed (TASK-046/047) are not built yet -- neither command
+renders anything. Try it against the Heat Diffusion demo:
+
+```bash
+uv run python -m pyflow record --config examples/golden-demos/heat_diffusion.yaml --max-frames 200
+# recorded 3 checkpoint(s) to checkpoints, frames [0, 100, 200]
+# wrote 3 checkpoint(s) to checkpoints
+```
+
+`checkpoints/checkpoint_00000200.pt` is a plain `torch.save`d file --
+inspect one directly without any PyFlow-specific tooling:
+
+```bash
+uv run python -c "
+import torch
+c = torch.load('checkpoints/checkpoint_00000200.pt', weights_only=True)
+print(c['frame_count'], list(c['fields']), c['fields']['tracer'].shape)
+"
+# 200 ['tracer'] torch.Size([192])
+```
+
+Now continue that same recording to frame 500, with nothing but the
+checkpoint just written -- no config file, no `--config` flag:
+
+```bash
+uv run python -m pyflow resume --checkpoint checkpoints/checkpoint_00000200.pt --max-frames 500
+# resumed from frame 200, recorded 3 checkpoint(s) to checkpoints, frames [300, 400, 500]
+# wrote 3 checkpoint(s) to checkpoints
+```
+
+`resume` reproduces exactly the trajectory an uninterrupted `record`
+straight to frame 500 would have (`tests/unit/
+test_recording_determinism.py`'s own bit-identical, mutation-tested
+claim) -- the prescribed state it doesn't checkpoint (mesh geometry, any
+constant prescribed velocity) is deterministically re-derived from the
+checkpoint's own embedded config rather than approximated.
+
Stage 9 (Better Numerics) follows Stage 8 (Recording & Playback, added
2026-09-07) -- better advection and diffusion
schemes, and with them the quantitative Rayleigh-Bénard comparison Stage
diff --git a/docs/architecture/CLAUDE.md b/docs/architecture/CLAUDE.md
index f8ff491..36d3682 100644
--- a/docs/architecture/CLAUDE.md
+++ b/docs/architecture/CLAUDE.md
@@ -36,24 +36,30 @@ Grounded directly in `bootstrap.py`, `engine/simulation.py`,
`engine/collocated_field.py` -- read those files, not this note, for
anything beyond orientation.
-**One of its four sections still carries a `Planned` subsection for a
-mechanism that doesn't exist yet** (checkpointing simulation state,
-Section 3), per the maintainer's direction that an unbuilt piece gets a
-placeholder and a backlog anchor, not silence or a fabricated mechanism.
-
-**Its anchor is no longer a task, and how that happened is the useful
-part.** This paragraph used to say the subsection was "anchored to the
-specific roadmap task that will build it (TASK-034)", with that task's
-own roadmap entry carrying a matching note asking for `sequences.md` to
-be updated in the same change. **TASK-034 landed on 2026-08-29 and
-deliberately did not build checkpointing** -- Stage 5 Completion
-Criterion 4 excludes it in as many words -- so the placeholder stayed
-accurate while its anchor pointed at a closed task, and the same pass
-left `sequences.md` with no sequence for `navier_stokes_step`, which is
-what TASK-034 *did* build. A task anchor does not cover "the task landed
-but did not build the thing" (`docs/practices.md`, "A checkable trigger
-still needs somebody to check it"). The subsection now says plainly that
-no task is assigned; whoever writes one re-reads it in the same change.
+**None of its four sections carries a `Planned` subsection any longer**
+(added 2026-09-07, TASK-045, Stage 8 (Recording & Playback)) -- the last
+one, checkpointing simulation state in Section 3, is now a real sequence
+grounded in `src/pyflow/checkpoint.py`/`recording.py`/`simulation_run.py`.
+Deterministic windowed replay and the playback path (Stage 8's own other
+two bullets, TASK-046/047) are still unbuilt, but Section 3 now says so
+in its own closing paragraph rather than under a `Planned` heading, since
+what recording alone built is real and belongs on the page as such.
+
+**Section 3's anchor history is worth keeping, because it is why the
+subsection existed to be finished at all.** It used to say the
+subsection was "anchored to the specific roadmap task that will build it
+(TASK-034)", with that task's own roadmap entry carrying a matching note
+asking for `sequences.md` to be updated in the same change. **TASK-034
+landed on 2026-08-29 and deliberately did not build checkpointing** --
+Stage 5 Completion Criterion 4 excludes it in as many words -- so the
+placeholder stayed accurate while its anchor pointed at a closed task,
+and the same pass left `sequences.md` with no sequence for
+`navier_stokes_step`, which is what TASK-034 *did* build. A task anchor
+does not cover "the task landed but did not build the thing"
+(`docs/practices.md`, "A checkable trigger still needs somebody to check
+it"). Re-anchoring it to "unassigned" rather than deleting the note --
+found by the same audit -- is what let TASK-045 find and close it for
+real, eight days later.
**Re-read `sequences.md` end to end at every stage boundary, not only
when a task it names is touched** (added 2026-09-03, Stage 7 (Rendering
diff --git a/docs/architecture/sequences.md b/docs/architecture/sequences.md
index c385bd5..dfb758c 100644
--- a/docs/architecture/sequences.md
+++ b/docs/architecture/sequences.md
@@ -352,41 +352,129 @@ is none. The only thing PyFlow reads or writes on disk today is YAML
*configuration* (`configuration/loader.py`), which is input, not
simulation output.
-### Planned: checkpointing
-
-Updated-by: unassigned -- this subsection, when a task builds checkpointing
-
-**Not built yet, and not an open design question either.**
-`docs/planning/roadmap.md`'s TASK-034 entry already records the intended
-shape, raised by the maintainer while scoping TASK-013's live zoom/pan and
-deliberately deferred until a real timestepping loop exists to pause:
-
-> checkpoint-based -- periodic full-state snapshots plus deterministic
-> replay between them, not storing every frame, which gets expensive fast
-> for field-rich simulations
-
-This leans on the determinism `docs/implementation/golden-demos.md`'s
-Definition of Done already requires of every demo: replay-from-checkpoint
-is only cheap if re-running the same steps reproduces the same state,
-which is a standing requirement already, not a new one checkpointing would
-add. That requirement is now *checked* rather than only stated, in two
-places: `navier_stokes_timestep.feature`'s own determinism scenario
-(bit-identical corrected velocity and pressure across two runs) and
-`lid_driven_cavity.feature`'s own, through the real demo.
-
-**Re-anchored 2026-08-29 by the Stage 5 exit audit.** This paragraph
-used to end "Update this subsection with the real sequence once
-**TASK-034** lands". TASK-034 landed on 2026-08-29 and **deliberately
-did not build checkpointing** -- Stage 5 Completion Criterion 4 excludes
-it in as many words ("Checkpoint/pause/rewind is explicitly not a
-criterion of this stage", with this placeholder named as what stays
-accurate if it is not built). So nothing is owed on the content, and the
-placeholder above is still true; what was not true any longer was its
-own trigger, which pointed at a task that had already closed. **There is
-no task assigned to build this today.** It reactivates when one is:
-whoever writes it re-reads this subsection in the same change, the same
-obligation TASK-030 and TASK-034 both carried on their own roadmap
-entries.
+### Built today: headless checkpointing (`pyflow record`/`pyflow resume`)
+
+**Built 2026-09-07, TASK-045** -- the sequence below, replacing the
+`Planned` placeholder this subsection carried since TASK-034 (2026-08-29,
+Stage 5 exit audit) deliberately declined to build it. Recording and
+rendering are two disjoint entry points from here on, not one path with
+a flag: `pyflow record` never constructs a `RenderWindow` at all, and
+`pyflow run` never writes a checkpoint. `src/pyflow/simulation_run.py`
+is what makes both possible without duplicating the stepping logic --
+see that module's own docstring, and `src/pyflow/CLAUDE.md`'s entry for
+why it sits at the package root.
+
+```mermaid
+sequenceDiagram
+ participant CLI as pyflow record
+ participant recording as recording.record()
+ participant sim as simulation_run
+ participant Mesh as StructuredCartesianMesh
+ participant checkpoint as checkpoint.write_checkpoint()
+ participant Disk as *.pt files
+
+ CLI->>recording: record(config_path, max_frames=N, ...)
+ recording->>Mesh: StructuredCartesianMesh.from_config(config.mesh)
+ recording->>sim: build_simulation_state(mesh, config)
+ Note over sim: SimulationState(mode, fields, velocity_field) --
no rendering.* config ever read
+ recording->>checkpoint: write_checkpoint(frame_count=0, config, state.fields)
+ checkpoint->>Disk: checkpoint_00000000.pt
+ loop until frame_count == max_frames
+ recording->>sim: advance_simulation_state(state, numerics, dt)
+ alt frame_count % checkpoint_interval == 0, or final frame
+ recording->>checkpoint: write_checkpoint(frame_count, config, state.fields)
+ checkpoint->>Disk: checkpoint_{frame_count:08d}.pt
+ end
+ end
+```
+
+**A checkpoint file is fully self-contained.** `write_checkpoint`
+`torch.save`s one dict per frame -- `schema_version`, `frame_count`, the
+whole `PyFlowConfig` as `dataclasses.asdict(config)` (not a pickled
+instance: `torch.load(weights_only=True)` cannot load one, and
+`config_from_dict` in `configuration/loader.py` already validates a
+plain dict identically to a loaded YAML file), and every field's tensor
+keyed by name. `PressureField` never appears in this dict -- it is a
+`navier_stokes_step` return value, never fed back into the state that
+gets advanced or checkpointed (`engine/CLAUDE.md`'s own `PISO` entry),
+so no field-type tag is needed: every value here is a plain
+`(num_cells,)` tensor. No RNG state and no device metadata either --
+grepping this codebase for `torch.rand`/`random.`/`.cuda(` finds
+nothing, so nothing here is non-deterministic to begin with.
+
+**Resuming needs more than the tensors, and `checkpoint.py` is where
+that gap is closed.** A checkpoint's `fields` dict alone cannot rebuild
+a "passive" mode `SimulationState` -- its prescribed `velocity_field` is
+never checkpointed, since it is constant by construction and would only
+be a second, redundant record of `config.simulation.velocity_pattern`.
+`restore_simulation_state(checkpoint)` calls `build_simulation_state`
+again (from the checkpoint's own embedded config) to reconstruct that
+structure, then overwrites `.fields` with the checkpoint's real evolved
+values -- structure from the config, state from the checkpoint, never
+the other way round. `tests/unit/test_recording_determinism.py` proves
+the round trip is bit-identical to an uninterrupted run to the same
+frame (`rtol=0, atol=0`), confirmed to have teeth by deliberately
+corrupting `restore_simulation_state` and watching the test fail before
+trusting it green.
+
+**`pyflow resume`, added the same task at a user's direct request, is
+`restore_simulation_state` given a CLI a second process can actually
+run** -- until it existed, "how does a second run ingest a checkpoint"
+had no answer past a private Python function.
+
+```mermaid
+sequenceDiagram
+ participant CLI as pyflow resume
+ participant recording as recording.resume()
+ participant checkpoint as checkpoint.py
+ participant sim as simulation_run
+ participant Disk as *.pt files
+
+ CLI->>recording: resume(checkpoint_path, max_frames=M, ...)
+ recording->>checkpoint: read_checkpoint(checkpoint_path)
+ checkpoint->>Disk: torch.load(..., weights_only=True)
+ checkpoint-->>recording: Checkpoint(frame_count=N, config, fields)
+ recording->>checkpoint: restore_simulation_state(checkpoint)
+ checkpoint->>sim: build_simulation_state(mesh, checkpoint.config)
+ Note over checkpoint: overwrites the freshly-built state's own
.fields with checkpoint.fields (real evolved values)
+ checkpoint-->>recording: (mesh, numerics, state at frame N)
+ loop until frame_count == M
+ recording->>sim: advance_simulation_state(state, numerics, dt)
+ alt frame_count % checkpoint_interval == 0, or final frame
+ recording->>checkpoint: write_checkpoint(frame_count, config, state.fields)
+ checkpoint->>Disk: checkpoint_{frame_count:08d}.pt
+ end
+ end
+```
+
+**Shares its checkpoint-writing policy with `record`, not a second
+copy of it.** Both call `recording.py`'s own
+`_advance_and_checkpoint` -- `record` from frame 0 (having already
+written frame 0's own checkpoint itself), `resume` from the checkpoint's
+own `frame_count` (already on disk as the file just read) -- so a
+`record` to frame 6 followed by a `resume` to frame 12 writes exactly
+the files an uninterrupted `record` to frame 12 would have after frame
+6, never re-writing frame 6's own file. Confirmed to genuinely share
+behaviour by a deliberate off-by-one mutation in the shared loop, which
+broke both functions' own tests together, not only one side's.
+
+**No `--config` on `resume` at all** -- the checkpoint is self-contained
+(above), so the only input `resume` needs is the checkpoint's own path;
+`output_dir`, left unset, defaults to that path's own parent directory
+rather than the checkpoint's *embedded* `config.recording.output_dir`
+(the original run's configured default, which may not be where this
+particular file actually lives).
+
+**Deterministic windowed replay and the playback path are still not
+built** -- Stage 8's own second and third bullets (`docs/planning/
+roadmap.md`, Stage 8 preamble), deferred to TASK-046/047 by TASK-045's
+own scope decision. `resume` is not either of those: it produces more of
+the same sparse checkpoint files `record` does, not the dense,
+renderer-ready per-frame data a watched replay window needs, and it
+renders nothing. This subsection covers only what exists: writing
+checkpoints, reading one back into a resumable `SimulationState`, and
+continuing to write more from it. Update it again, in the same change,
+whichever of TASK-046/047 lands next.
---
@@ -477,22 +565,25 @@ Written 2026-08-27, grounded directly in `src/pyflow/bootstrap.py`,
`overview.md`/`rendering.md` and their `CLAUDE.md` companions -- not
re-derived from general engine-design knowledge.
-**One subsection is still marked Planned: Section 3's checkpointing.**
-Section 2's live-loop wiring was too, until TASK-030 landed it on
-2026-08-28 and this file was updated in the same change -- the mechanism
-working exactly as intended.
-
-**The mechanism then failed once, and how it failed is the useful part.**
-Both Planned subsections were anchored to a specific roadmap task rather
-than an open-ended "future work" (TASK-030, TASK-034), with a note on
-each task's own roadmap entry asking for this file to be updated in the
-same change. TASK-034 landed on 2026-08-29 without building
-checkpointing -- which Stage 5 Completion Criterion 4 explicitly allows
--- and *nothing* here was re-read, so the anchor sat pointing at a
-closed task for a day. Worse, the same pass left this document with no
-sequence for `navier_stokes_step` at all, which was TASK-034's actual
-subject; both were found by that stage's exit audit, not by this
-mechanism.
+**No subsection is marked Planned any longer.** Section 2's live-loop
+wiring was the first to go real, on 2026-08-28 (TASK-030); Section 3's
+checkpointing was the second, on 2026-09-07 (TASK-045, Stage 8) -- both
+in the same change that built the mechanism, the anchor working exactly
+as intended that time.
+
+**The mechanism failed once in between, on Section 3's own first anchor,
+and how it failed is the useful part.** Both Planned subsections were
+anchored to a specific roadmap task rather than an open-ended "future
+work" (TASK-030, TASK-034), with a note on each task's own roadmap entry
+asking for this file to be updated in the same change. TASK-034 landed
+on 2026-08-29 without building checkpointing -- which Stage 5 Completion
+Criterion 4 explicitly allows -- and *nothing* here was re-read, so the
+anchor sat pointing at a closed task for a day. Worse, the same pass
+left this document with no sequence for `navier_stokes_step` at all,
+which was TASK-034's actual subject; both were found by that stage's
+exit audit, not by this mechanism. Re-anchoring it to "unassigned" that
+day, rather than deleting the note, is what let TASK-045 find it and
+close it for real eight days later.
**The lesson recorded rather than the fix improvised:** an anchor to a
task is only as good as the reader who greps for it, and "the task
@@ -500,4 +591,8 @@ landed but did not build the thing" is a case a task anchor does not
cover on its own. When a task with a note here closes, re-read this
file whether or not it built what the note names -- what it *did* build
usually belongs here too. Grep this file's own TASK-NNN mentions the
-next time any named task is touched.
+next time any named task is touched. **Section 3's own new note names
+TASK-046/047 as the tasks that will next need this file re-read** --
+deterministic windowed replay and the playback path are still Planned in
+substance, just not under a heading that says so, since neither is built
+yet and this subsection is now describing what recording alone does.
diff --git a/docs/implementation/config-template.yaml b/docs/implementation/config-template.yaml
index 9533910..224e639 100644
--- a/docs/implementation/config-template.yaml
+++ b/docs/implementation/config-template.yaml
@@ -318,3 +318,16 @@ units:
# unit is worth. 1.0 (default) displays the raw simulation number
# unchanged. Invalid: zero or negative.
time_scale: 1.0
+
+# Headless checkpoint recording (Stage 8, Recording & Playback) -- read only
+# by `pyflow record`, never by `pyflow run`. The same config file behaves
+# identically under `pyflow run` whether or not this section is set.
+recording:
+ # Valid: any non-empty string -- where `pyflow record` writes checkpoint
+ # files, relative to the current working directory. Invalid: a non-string
+ # value, or an empty string.
+ output_dir: checkpoints
+ # Valid: a positive integer -- how many frames pass between checkpoints (a
+ # checkpoint is always written at frame 0 and at the run's final frame
+ # too, regardless of this value). Invalid: zero or negative.
+ checkpoint_interval: 100
diff --git a/docs/planning/backlog.md b/docs/planning/backlog.md
index 709b7e3..40da42f 100644
--- a/docs/planning/backlog.md
+++ b/docs/planning/backlog.md
@@ -2369,10 +2369,39 @@ here.):
`docs/planning/roadmap.md` Stage 8 (Recording & Playback), inserted
for exactly this item, no dedicated Capability Level -- see that
document's own "Fourth divergence" entry for the full reasoning.
- Still open: this only opened the Stage; the capability itself is
- unbuilt. *Unblock condition, narrowed:* a task, that re-reads
- `sequences.md`'s checkpointing subsection in the same change per
- that document's own standing obligation.
+
+ **Partially closed 2026-09-07 by TASK-045: half (1), periodic
+ checkpointing, is built.** A config writes nothing on its own --
+ `pyflow record` is a new, structurally headless entry point
+ (`src/pyflow/recording.py` never imports `rendering` at all) that
+ writes periodic, self-contained checkpoint files, exactly the "run
+ is headless by default when it writes to disk" framing this entry
+ asked for. `sequences.md`'s own checkpointing subsection was
+ re-read and replaced with the real sequence in the same change, per
+ its own standing obligation.
+
+ **Half (1) grew a `pyflow resume` command the same day, at a
+ user's direct request, still inside half (1)'s own scope.** A user
+ asked in as many words how a second run would ingest checkpoints
+ to continue the simulation; the honest answer at the time was a
+ private Python function with no CLI, which is not "continue the
+ simulation" for anyone who isn't reading this repository's source.
+ `resume` closes that gap headlessly -- no rendering, no `--config`
+ (the checkpoint carries its own), writing more of the identical
+ sparse checkpoint files `record` already produces. It is not half
+ (2): nothing renders, and no dense per-frame data is materialized
+ for a watched range, which is what half (2) actually asks for.
+
+ **Still open: half (2), the playback path (pause, variable speed,
+ reading snapshots back on their own schedule) -- deferred to
+ TASK-046/047 by TASK-045's own scope decision**, not built here.
+ Deterministic windowed replay (re-simulating forward from a
+ checkpoint to materialize dense per-frame data for a watched range)
+ is the piece that makes "pause and scrub" cheap without storing
+ every frame; nothing reads a checkpoint back into a live render yet.
+ *Unblock condition:* a task building TASK-046 or TASK-047, that
+ re-reads `sequences.md`'s Section 3 in the same change per that
+ document's own standing obligation, the same way TASK-045 just did.
---
diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md
index 83f5ff5..4fcb3ed 100644
--- a/docs/planning/roadmap.md
+++ b/docs/planning/roadmap.md
@@ -306,8 +306,45 @@ This paragraph previously said `make install` and `make test` were still
expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale
since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock`
is committed (B2) and `make test` runs the suite with coverage
-(C1a/C1b): **1052 tests as of 2026-09-06**, up from 763 at Stage 6's
-exit audit. **The last 24 are the benchmarking tool's own tests, across
+(C1a/C1b): **1101 tests as of 2026-09-07**, up from 1052 the day before.
+**16 of those 49 are TASK-045's own `resume` addition** (below); the
+other 33 are TASK-045's original recording scope:
+`tests/unit/test_checkpoint.py` (5, the checkpoint write/read round-trip
+and `UnsupportedCheckpointVersionError`), `test_recording.py` (5, the
+headless recording loop's own checkpoint-frame bookkeeping and
+`NothingToRecordError`), `test_simulation_run.py` (7, including the
+permanent `test_domain_bounds_matches_mesh_bounding_box` regression
+test), `test_recording_determinism.py` (1, the bit-identical
+resume-from-checkpoint claim, mutation-tested), `test_configuration.py`
+(+5: 4 new functions plus one new parametrized case of the existing
+wrong-typed-value test, `config_from_dict`'s own round-trip and
+`RecordingConfig`'s validation), `test_main.py` (+5, `pyflow record`'s
+CLI dispatch), `tests/integration/test_import_order.py` (+3, one
+parametrized case per new root module -- `simulation_run`/`checkpoint`/
+`recording` -- added to its module list), and `tests/integration/
+test_record_cli.py` (2, a real subprocess run of Heat Diffusion through
+`pyflow record`, and the required-argument rejection path); 5 + 5 + 7 +
+1 + 5 + 5 + 3 + 2 = 33. `test_generator.py` and `tests/integration/
+test_cli.py`'s own key-order and help-text assertions were also extended
+for the new `recording:` section, but as edits to existing tests, not
+new ones -- no count from either.
+
+**The 16 `resume` tests, added the same day once a user asked how a
+second run would ingest a checkpoint**: `test_recording_determinism.py`
+(+2 -- the zero-velocity-fixture coverage gap the reasoning behind
+"why the prescribed velocity field isn't checkpointed" turned out to
+have, and the fixture that closes it; see that module's own comments for
+the mutation-testing history), `test_recording.py` (+6 --
+`resume`'s own checkpoint-frame bookkeeping, the record-then-resume
+equivalence invariant, `NothingToResumeError`, and the no-`--config`-
+needed claim), `test_main.py` (+6, `pyflow resume`'s CLI dispatch,
+including that it has no `--config` flag at all), and `tests/
+integration/test_record_cli.py` (+2, a real subprocess record-then-
+resume pipeline and the required-argument rejection path); 2 + 6 + 6 + 2
+= 16. `test_cli.py`'s own help-text assertion was extended for `resume`
+too, again an edit to an existing test rather than a new one. **Before
+those, the previous 24 are
+the benchmarking tool's own tests, across
two modules.** `tests/unit/test_benchmark_demos.py` (16): 5 from
`tools/benchmarks/benchmark_demos.py` built once the seven-fix
vectorization arc below was complete and its own numbers had all come
@@ -10940,22 +10977,35 @@ for Rendering and for Measurements, Diagnostics and Export. It changes
how a simulation's own output is consumed after the fact, rather than
unlocking a new physical or numerical capability.
-Tasks include
+Use cases
-- Periodic full-state checkpointing during a run, config-driven,
- replacing (or running alongside) live rendering
-- Deterministic windowed replay: given a checkpoint and a target frame
- range, re-simulate forward and materialize dense, renderer-ready
- per-frame data for just that range
-- A playback path that reads materialized per-frame data and renders
- it, with pause and variable playback speed
+- Record a long-running simulation headlessly -- no rendering window
+ ever opens -- and get back a bounded set of checkpoint files, not one
+ per frame, whatever `max_frames` is asked for.
+- Resume computation from any written checkpoint and get exactly the
+ trajectory an uninterrupted run would have produced from there,
+ checked bit-for-bit rather than assumed from the mechanism's design.
+- **Not yet built, named here as the stage's own remaining scope rather
+ than left unstated (TASK-046, not yet drafted):** pick any point in a
+ recorded run and watch a dense, renderer-ready replay of just that
+ window, without re-simulating the whole run from the start.
+- **Not yet built (TASK-047, not yet drafted):** pause a replay, scrub
+ to a different point in it, and watch it at a different speed than it
+ was originally computed at.
Golden Demo
-An existing golden demo, run once in record mode and once in playback
-mode, through the same public `pyflow run` CLI every other demo uses --
-which one, and the exact command shape, is decided when this stage's
-first task is scoped.
+**Decided by TASK-045 for its own half: Heat Diffusion, recorded through
+`pyflow record --config examples/golden-demos/heat_diffusion.yaml
+--max-frames N`.** The stage's own Goal names both recording and
+playback; this entry originally read "through the same public `pyflow
+run` CLI every other demo uses", which turned out wrong once TASK-045
+was actually scoped -- `pyflow record` is a new, deliberately separate
+subcommand (`src/pyflow/CLAUDE.md`'s `recording.py` entry: it never
+imports `rendering` at all, so it could not be a mode of `pyflow run`
+without breaking that separation). The playback half -- running the same
+demo's own recording back through a render window -- is still undecided,
+and stays so until TASK-046/047 give it something real to run.
Raised by the maintainer 2026-09-04 (`docs/planning/backlog.md`), not
scheduled until the maintainer's decision on 2026-09-07 to open it --
@@ -10963,6 +11013,448 @@ see this file's own "Stages and Capability Levels" section, Fourth
divergence, for why it is a Stage of its own rather than folded into
Stage 14 (Performance) as the backlog's own first guess had it.
+### Completion Criteria
+
+**Written 2026-09-07, when TASK-045 -- this stage's first task -- was
+drafted, per `docs/planning/stage-specification.md`'s "required from
+opened" rule.** Drafted from the Goal above (record, and play back,
+without the original process staying alive), not from TASK-045's own
+Acceptance Criteria, per that same document's warning against the
+shape that cannot fail if the task that wrote it passed.
+
+**Two of the five criteria below name a task that does not exist yet,
+stated in the criterion itself rather than left for a reader to notice
+later** -- `stage-specification.md`'s own sanctioned shape ("a criterion
+whose strong reading depends on a later task must say so when
+drafted"), the same mechanism TASK-027's own null-space finding
+established this project follows.
+
+1. **Recording never depends on a rendering window, and never opens
+ one.** The Goal's own "without the original run's process needing to
+ still be alive" -- checked at the strongest point available: not
+ just that `pyflow record` defaults to headless, but that the
+ *module* it dispatches through cannot reach `pygfx`/`rendercanvas`
+ at all.
+ - `src/pyflow/recording.py` imports neither `rendering` nor anything
+ that transitively imports it -- checked directly, not assumed from
+ the module's own docstring (`tests/integration/test_import_order.py`
+ exercises the module in a fresh subprocess, though it does not by
+ itself prove the absence of a `rendering` import; the stronger
+ claim was checked by hand at implementation time and is reasserted
+ here as the criterion, not left as a implementation note only).
+2. **A recording's own footprint on disk is bounded, never one file per
+ frame.** The reason checkpointing exists instead of a naive per-frame
+ dump -- `docs/planning/backlog.md`'s own raising of this item names
+ it explicitly.
+ - Checked directly against `checkpoint_interval`: a recording writes
+ exactly frame 0, every multiple of `checkpoint_interval` up to
+ `max_frames`, and `max_frames` itself if it does not already fall
+ on one -- never a checkpoint at any other frame, and never one per
+ frame regardless of how large `max_frames` is.
+ `test_record_always_writes_a_final_checkpoint_even_off_interval`
+ pins the off-interval case specifically (`max_frames=7,
+ checkpoint_interval=5` writes frames `[0, 5, 7]`, not `[0, 5]`).
+3. **Resuming from a checkpoint reproduces the same trajectory a
+ continuous run would have, bit-identically, not merely
+ approximately.** The mechanism the Golden Demo's playback half will
+ need to trust, checked now rather than assumed from
+ `bootstrap()`'s pre-existing determinism.
+ - Checked at `rtol=0, atol=0`, not a numerical tolerance --
+ `tests/unit/test_recording_determinism.py`.
+ - **Confirmed to have real teeth, not just to pass**: the same test
+ was run once against a deliberately corrupted
+ `restore_simulation_state` and observed to fail before being
+ trusted green, this project's own mutation-testing discipline
+ applied here rather than only asserted.
+4. **A checkpoint file is self-contained** -- independently loadable and
+ resumable with no other file present, no separately-tracked run
+ metadata, no config file alongside it.
+ - The whole `PyFlowConfig` a checkpoint was written under travels
+ inside the checkpoint itself (`dataclasses.asdict`), not as a path
+ reference to a config file that might move or change.
+5. **The stage's own Golden Demo runs end to end, both halves, through
+ the same public CLI every other demo uses.** Not yet checkable in
+ full -- the qualifier is the honest half.
+ - **The record half is checkable now, and is**: `tests/integration/
+ test_record_cli.py` runs Heat Diffusion through the real
+ `python -m pyflow record` subprocess and asserts the checkpoint
+ files it names actually appear.
+ - **The playback half cannot be checked until TASK-046/047 build
+ something to check** -- named here as an open half rather than
+ silently dropped from the criterion, per this project's own
+ Integrity section.
+
+### Discharge map
+
+| Criterion | Discharged by |
+|-----------|---------------|
+| 1. Recording never opens a rendering window | TASK-045 |
+| 2. A recording's disk footprint is bounded | TASK-045 |
+| 3. Resuming reproduces the same trajectory, bit-identically | TASK-045 |
+| 4. A checkpoint file is self-contained | TASK-045 |
+| 5. Golden Demo runs end to end (record half) | TASK-045 |
+| 5. Golden Demo runs end to end (playback half) | **TASK-046/047, not yet drafted** |
+
+### Status as of 2026-09-07: Stage 8 in progress, four of five criteria met
+
+**Deliberately "in progress," not "complete," even though every
+`## TASK-NNN` entry under this stage heading is Done** -- that fact is
+what `docs/planning/stage-shape.yaml`'s lifecycle mechanically means by
+"complete" (it governs only which sections this stage's preamble is
+required to carry), and it is a narrower claim than this line makes.
+This stage's own Goal ("recorded... and played back afterward") is half
+built, and saying so here in the exact template
+`tools/generators/generate_status_report.py` reads (`### Status as of
+DATE: Stage N , ...`) is what keeps this stage counted as the
+roadmap's own frontier -- the first stage not complete -- rather than
+silently letting `README.md`'s own "Current Phase" cross-check advance
+past real, undrafted work (TASK-046/047) to Stage 9. **A first draft of
+this heading used prose that satisfied `check_stages.py`'s own looser
+"starts with 'Status as of'" match but not this stricter template**,
+which made the status line invisible to `generate_status_report.py`
+entirely (`complete_claimed` parsed as `None`, not `False`) -- caught by
+querying `parse_roadmap` directly against the real file, not assumed
+from `make check-status` passing, since a line that matches nothing
+reports nothing.
+
+| Criterion | Verdict |
+|-----------|---------|
+| 1. Recording never opens a rendering window | **Met** -- TASK-045 |
+| 2. A recording's disk footprint is bounded | **Met** -- TASK-045 |
+| 3. Resuming reproduces the same trajectory, bit-identically | **Met** -- TASK-045, mutation-tested |
+| 4. A checkpoint file is self-contained | **Met** -- TASK-045 |
+| 5. Golden Demo runs end to end, both halves | **Half met** -- record half built and checked (TASK-045); playback half has no task assigned yet |
+
+Four of five criteria are fully met; the fifth is honestly half met, not
+rounded up. This is the expected shape for a stage opened with only its
+first of three planned pieces of work built -- not a finding requiring
+correction, the way Stage 7's retrospective audit found real defects.
+Revisit this section, in the same change, when TASK-046 or TASK-047
+lands: either it closes Criterion 5 for real, or (if a design question
+surfaces first) this status stays open a while longer and says so.
+
+---
+
+## TASK-045 — Periodic Checkpointing (Headless Recording)
+
+**Status: Done, 2026-09-07, for the scope below.** Replay and playback
+are deliberately not this task's scope -- see Design decisions, Scope.
+**Extended the same day with `pyflow resume`**, once a user asked how a
+second run would ingest a checkpoint the first had written -- still
+recording's own scope, not replay or playback (see Scope's own
+amendment, below).
+
+### Purpose
+
+Stage 8's own Goal, the recording half made concrete: let a simulation's
+state be written to disk as it runs, resumable later without the
+original process staying alive. This is what the checkpointing backlog
+item (`docs/planning/backlog.md`, raised 2026-09-04) asked for as its
+first of two halves, and what `docs/architecture/sequences.md`'s own
+"Planned: checkpointing" placeholder had been anchoring since before a
+task existed to build it.
+
+### Dependencies
+
+None functionally. Builds directly on `bootstrap.py`'s existing
+simulation-state construction and advancement logic (Stage 4-6), and on
+`configuration/loader.py`'s existing YAML-to-`PyFlowConfig` validation,
+extended rather than replaced.
+
+### Design decisions, recorded here
+
+**Scope: recording only, not replay or playback -- a deliberate,
+stated exclusion, not an oversight.** Stage 8's own preamble already
+lists three separable pieces of work; this task builds the first.
+TASK-034 set the precedent for this exact mechanism (it built the
+timestepping loop checkpointing needs and then declined to build
+checkpointing itself, naming the exclusion explicitly in its own entry);
+`stage-specification.md`'s discharge-map mechanism exists precisely for
+a criterion whose strong reading depends on a later task, which is what
+Stage 8's own Completion Criterion 5 (the Golden Demo's playback half)
+does here. Recording alone already touches a real `bootstrap.py`
+refactor, a new config section with its own generator obligations, a new
+CLI subcommand, and a determinism round-trip test with mutation-tested
+teeth -- enough for one reviewable change.
+
+**Amended the same day: `pyflow resume` is in scope, and the line drawn
+above still holds -- the amendment sharpens it rather than moving it.**
+A user asked, in as many words, how a second `pyflow` invocation would
+"ingest those checkpoints to continue the simulation" -- the answer at
+the time was a private Python function
+(`checkpoint.restore_simulation_state`) with no CLI surface at all,
+which is not "continue the simulation" in any sense a user could act on
+without writing a script. `resume` closes exactly that gap: it continues
+a headless *recording*, writing further checkpoint files at the same
+policy `record` already established -- no rendering, no dense per-frame
+materialization, no pause/scrub/speed control. **What makes it
+"recording" and not "replay"** (Stage 8's own second bullet, TASK-046):
+replay's own job is producing dense, renderer-ready per-frame data for a
+*watched* range, which needs a target window and a renderer on the other
+end; `resume` produces more of the identical sparse, renderer-agnostic
+checkpoint files `record` already produces, just starting from frame
+`N` instead of frame `0`. Nothing about Stage 8's own Completion
+Criterion 5 (Golden Demo, playback half) changes -- `resume` still does
+not render anything, so it still does not discharge that half; see this
+task's own amended Artifacts/Acceptance Criteria/Discharges below for
+what it does add.
+
+**Two research findings changed the design from how the backlog item
+first framed it.** `PressureField` never appears in
+`window.simulation_fields` -- pressure is a `navier_stokes_step` return
+value, never fed back into the state that gets advanced (`src/pyflow/
+engine/CLAUDE.md`'s own `PISO` entry) -- so the checkpoint format needs
+no per-field type tag at all: every checkpointed field is a plain
+`(num_cells,)` tensor. And nothing in this codebase uses RNG or a
+non-CPU device anywhere (verified by grepping for `torch.rand`/
+`random.`/`device=`/`.cuda(`), so the checkpoint format needs no
+RNG/device metadata either -- determinism after reload is purely a
+function of mesh + field tensors + config, reproduced exactly.
+
+**Extracted `simulation_run.py` before writing anything new, and
+verified it changed no behaviour before trusting it.** `bootstrap.py`'s
+`_add_declared_field_transport`/`_add_solved_velocity_rendering` each
+fused simulation-state construction and advancement with
+`window.scene.add(...)` calls in one closure -- `RenderWindow` cannot be
+built without paying the real cost of a `wgpu` renderer
+(`RenderWindow.__init__` unconditionally builds one), so a genuinely
+headless recording path needed this logic pulled apart rather than
+`bootstrap()` reused with rendering "turned off." The extraction
+deliberately does not import `rendering.mesh_visualization.
+mesh_bounding_box` (it would transitively pull in `pygfx`, defeating the
+whole point) -- `simulation_run.py`'s own `_domain_bounds` is
+independent, verified numerically identical to it before being trusted,
+and pinned by a permanent regression test
+(`test_domain_bounds_matches_mesh_bounding_box`,
+`tests/unit/test_simulation_run.py`). The refactor itself was verified
+behaviour-preserving by the full pre-existing test suite passing
+unmodified (1052 tests, same count and pass as before) -- not by new
+tests written to justify it, since nothing about its behaviour was
+supposed to change.
+
+**Checkpoint format: one `torch.save`d file per checkpoint, fully
+self-contained, `weights_only=True`-loadable.** The config travels
+inside as `dataclasses.asdict(config)`, not a pickled `PyFlowConfig`
+instance (a pickled instance would force `weights_only=False`, a real
+code-execution surface on load) and not a YAML round-trip through a
+temp file (needless indirection) -- `asdict` is already what
+`generator.py`'s `generate_config_yaml` uses, round-trips tuples
+correctly, and `torch.load(weights_only=True)`'s safe-globals allowlist
+already covers plain dict/list/tuple/str/int/float/bool.
+`loader.py`'s `load_config` was split into `_config_from_raw(raw, *,
+source)` (the read direction any dict-shaped source needs) and a
+thin `load_config` wrapper that reads YAML and calls it; `config_from_dict`
+exposes the same validation to `checkpoint.py`'s `read_checkpoint`
+directly, so a checkpoint's embedded config is validated identically to
+a config file, not through a second, looser parser. Filename
+convention: `checkpoint_{frame_count:08d}.pt`, sortable and scannable by
+name, but `Checkpoint.frame_count` (the value actually stored inside)
+stays authoritative over the filename.
+
+**Headless is structural, not a default.** `recording.py` never imports
+`rendering`/`pygfx`/`rendercanvas` at all -- stronger than defaulting
+`rendering.backend` to `"offscreen"` would have been, since that would
+still let a caller override it back to a live window. `RecordingConfig`
+deliberately has no `enabled: bool` field for the same reason from the
+other direction: `bootstrap()`/`RenderWindow` never read
+`config.recording`, so one config file behaves identically under
+`pyflow run` or `pyflow record` -- which command runs is what turns
+recording on, not a config switch that could silently turn a live
+interactive run into one that also writes checkpoints.
+
+**A real architectural gap found mid-implementation, not anticipated in
+the original design: a checkpoint's raw tensors alone cannot resume a
+"passive"-mode run.** Its prescribed `velocity_field` is never
+checkpointed (constant by construction, so checkpointing it would only
+be a redundant record of `config.simulation.velocity_pattern`), so
+`restore_simulation_state(checkpoint)` calls `build_simulation_state`
+again from the checkpoint's own embedded config for the right structure,
+then overwrites `.fields` with the checkpoint's real evolved values --
+structure from the config, state from the checkpoint, never the other
+way round. Written test-first once the gap was found: the resume test
+was red against the first `checkpoint.py` draft (which had no such
+function) before `restore_simulation_state` was written to make it
+green.
+
+**The determinism test's teeth were confirmed by deliberate mutation,
+not assumed from passing once.** `tests/unit/
+test_recording_determinism.py` runs a small fixture two ways -- a plain
+`advance_simulation_state` loop with no recording at all, as the
+control, and a `record()`-then-`read_checkpoint`-then-
+`restore_simulation_state`-then-advance path -- and asserts bit-identical
+final tensors (`rtol=0, atol=0`). Verified to actually fail under a real
+defect by temporarily corrupting `restore_simulation_state` (multiplying
+the checkpoint's own tensors by `0.0`) and confirming the test failed
+with a reported 20/20 mismatched elements, then reverting and confirming
+green again -- this project's own mutation-testing discipline, applied
+here rather than only asserted.
+
+**No Gherkin `.feature` file, for the same two reasons Stage 7's own
+rendering-plumbing work was exempted, stated explicitly rather than left
+implicit.** This task discharges no Golden Demo criterion on its own --
+Stage 8's own Completion Criterion 5 is only half-discharged by it, the
+playback half deferred to TASK-046/047 -- and its one real physical
+claim ("resuming from a checkpoint reproduces the same trajectory as an
+uninterrupted run") is a serialization-fidelity/mechanism claim, not a
+new physical prediction, the same category `adr/ADR-007-executable-
+acceptance-criteria.md`'s own scope ("real simulation work... where
+physics begins") excludes. Coverage is plain pytest throughout
+(`tests/unit/test_checkpoint.py`, `test_recording.py`,
+`test_simulation_run.py`, `test_recording_determinism.py`,
+`tests/integration/test_record_cli.py`). Recorded as a judgement call to
+revisit if a future reader disagrees, not asserted as beyond question.
+
+**`resume` shares its checkpoint-writing loop with `record`, not a
+second copy of the same policy.** `recording.py`'s new
+`_advance_and_checkpoint(state, numerics, config, *, start_frame,
+max_frames, output_dir, interval)` is the "advance and checkpoint every
+`interval` frames, and at `max_frames`" logic both functions need;
+`record` checkpoints frame 0 itself (the one frame `resume` never has to,
+since it is already on disk as the file being resumed from) and then
+calls the shared helper from `start_frame=0`, `resume` calls it from
+`start_frame=checkpoint.frame_count`. **Confirmed to actually share
+behaviour, not just share code, by a deliberate off-by-one mutation**
+(`start_frame + 1` weakened to `start_frame` in the shared loop): 8 of
+the then-11 recording tests failed, across both `record`'s and
+`resume`'s own test functions, which is what "shared" is supposed to
+mean -- a bug in one path shows up in the other's tests too, not only
+its own.
+
+**`resume` takes no `--config`/`config_path` at all -- the CLI surface
+answers the exact question a user asked** ("how can a second run ingest
+those checkpoints to continue the simulation"), and the answer is that
+the checkpoint alone is enough: it is read through the identical
+`checkpoint.read_checkpoint`/`config_from_dict` a config file's own
+validation goes through, so naming a second, separate config on the CLI
+would only invite one that disagrees with the checkpoint's own embedded
+copy. `output_dir`, unlike `record`'s own default (`config.recording.
+output_dir`), defaults to the checkpoint's own parent directory --
+continuing to write alongside the file just read, not the *original*
+run's configured default, which may not be where this particular
+checkpoint actually lives if that run itself overrode it with its own
+`--output-dir`.
+
+**A second, more specific gap in `test_recording_determinism.py`'s own
+existing fixture was found while checking `resume`'s reasoning aloud
+with a user, not by inspection.** Its `_CONFIG_TEXT` never sets
+`simulation.velocity_pattern`, so the one thing `restore_simulation_
+state` reconstructs from a checkpoint's embedded config rather than
+reads from its tensors -- the prescribed, never-checkpointed velocity
+field -- was always zero in every existing determinism test, which is
+also what a reconstruction bug that silently produced zero regardless of
+config would compute (`docs/practices.md`'s "distinct factors" rule).
+Two new tests on a config with a real, nonzero prescribed velocity close
+this: a full resumed-trajectory comparison, and a narrower direct check
+of the reconstructed field against a hand-computed expected value
+(deliberately not a second `build_simulation_state` call on the same
+config -- a first draft of that narrower test compared two calls that
+share every line of `config_from_dict`, and a mutation dropping
+`simulation.velocity` entirely broke both sides identically, leaving the
+comparison green; comparing against a value computed independently of
+any PyFlow parsing code is what made that mutation visible). Both new
+tests, and the two pre-existing ones, were run under both mutations
+(velocity reconstruction corrupted in `restore_simulation_state`; the
+config parser dropping `velocity`) to confirm exactly which test catches
+which defect, not assumed from either passing.
+
+### Artifacts Produced
+
+- `src/pyflow/simulation_run.py` -- `SimulationState`,
+ `build_simulation_state`, `advance_simulation_state`,
+ `velocity_field_from_state`, `assembled_numerics_for`, `_domain_bounds`.
+- `src/pyflow/checkpoint.py` -- `Checkpoint`, `write_checkpoint`,
+ `read_checkpoint`, `restore_simulation_state`,
+ `UnsupportedCheckpointVersionError`.
+- `src/pyflow/recording.py` -- `record`, `resume`, `RecordingResult`,
+ `NothingToRecordError`, `NothingToResumeError`,
+ `_advance_and_checkpoint` (the checkpoint-writing loop shared by
+ `record`/`resume`).
+- `src/pyflow/bootstrap.py` -- refactored to call the three
+ `simulation_run.py` functions above rather than duplicate their logic
+ inline; not behaviour-changed (verified by the pre-existing suite).
+- `src/pyflow/configuration/schema.py` -- `RecordingConfig`
+ (`PyFlowConfig.recording`): `output_dir: str = "checkpoints"`,
+ `checkpoint_interval: int = 100`.
+- `src/pyflow/configuration/loader.py` -- `_config_from_raw`/
+ `config_from_dict`, the read-direction split described above.
+- `src/pyflow/__main__.py` -- `pyflow record --config
+ --max-frames N [--output-dir DIR] [--checkpoint-interval N]` and
+ `pyflow resume --checkpoint --max-frames N [--output-dir DIR]
+ [--checkpoint-interval N]` subcommands; top-level `description`/
+ `epilog` updated per `src/pyflow/CLAUDE.md`'s CLI-self-description
+ rule.
+- `tools/generators/generate_config_template.py` --
+ `SECTION_COMMENTS`/`FIELD_COMMENTS` for `recording:`;
+ `docs/implementation/config-template.yaml` regenerated.
+- `docs/architecture/sequences.md` -- Section 3's "Planned:
+ checkpointing" replaced with the real, built sequence, including
+ `resume`.
+- `README.md` -- a verified `pyflow record`/`pyflow resume` walkthrough
+ under Stage 8's own entry (added at a user's request, after the
+ original PR shipped with no user-facing usage documentation at all --
+ only internal architecture notes).
+- Tests: `tests/unit/test_checkpoint.py`, `test_recording.py`,
+ `test_simulation_run.py`, `test_recording_determinism.py`,
+ `test_configuration.py` (extended, `config_from_dict` round-trip),
+ `test_main.py` (extended, `record`/`resume` CLI dispatch),
+ `tests/integration/test_record_cli.py` (also covers `resume`, per
+ that module's own broadened docstring), `test_import_order.py`
+ (extended), `test_cli.py` (extended).
+
+### Acceptance Criteria
+
+- `pyflow record --config --max-frames N` runs with no rendering
+ window at any point, and writes a checkpoint at frame 0, every
+ `checkpoint_interval` frames, and at frame `N` (even off-interval).
+- `--config`/`--max-frames` are required; an unbounded or unconfigured
+ headless recording is rejected by `argparse` rather than silently
+ falling back to a default that would run forever or record nothing
+ meaningful.
+- `--output-dir`/`--checkpoint-interval`, given, override
+ `config.recording`'s own fields; omitted, the config's own values
+ apply.
+- A config declaring no `fields` and no `simulation.velocity_solved`
+ raises `NothingToRecordError` rather than writing
+ `checkpoint_interval`-many identical files of a static state.
+- A written checkpoint is independently loadable
+ (`read_checkpoint`/`torch.load(weights_only=True)`) with no other file
+ present, and carries its own `schema_version`,
+ `frame_count`, full config, and every field's tensor by name.
+- `restore_simulation_state` on a read-back checkpoint reconstructs a
+ `SimulationState` that, advanced the remaining frames, produces
+ bit-identical results (`rtol=0, atol=0`) to an uninterrupted run to the
+ same total frame count -- for both "passive" (declared-field) and
+ "solved" (velocity-only) modes, and checked with a genuinely nonzero
+ prescribed velocity, not only the default zero.
+- `src/pyflow/recording.py` imports neither `rendering` nor anything
+ that transitively imports it.
+- Every existing test that exercised `bootstrap.py`'s simulation-state
+ construction/advancement before this task's refactor still passes
+ unmodified.
+- `pyflow resume --checkpoint --max-frames N` takes no `--config`
+ flag at all, reads the checkpoint's own embedded config, and continues
+ stepping headlessly from the checkpoint's own `frame_count`, writing
+ further checkpoints at the same policy `record` uses -- never
+ re-writing the checkpoint it resumed from.
+- `--max-frames` for `resume` must be strictly greater than the
+ checkpoint's own `frame_count`; otherwise `NothingToResumeError`.
+- `record(..., max_frames=N)` followed by `resume(..., max_frames=M)`
+ (`M > N`) writes exactly the checkpoint files a single, uninterrupted
+ `record(..., max_frames=M)` would have written after frame `N`, and
+ the final checkpoint's own field values agree exactly (`rtol=0,
+ atol=0`) with the uninterrupted run's.
+
+### Discharges
+
+Stage 8 Completion Criteria 1, 2, 3, 4, and the record half of 5.
+`resume` does not change this: it is recording's own scope extended, not
+replay or playback, so it discharges nothing beyond what `record` itself
+already did -- Criteria 1-4 apply to it identically (still headless,
+still a bounded footprint, still bit-identical, still self-contained
+checkpoints), and it adds no new criterion of its own. The playback half
+of Criterion 5 is explicitly not discharged by this task -- see this
+stage's own discharge map above.
+
---
# Stage 9 — Better Numerics
diff --git a/docs/planning/status.md b/docs/planning/status.md
index 1593e9c..c4a2dbf 100644
--- a/docs/planning/status.md
+++ b/docs/planning/status.md
@@ -17,13 +17,13 @@ demand, not part of this file.
## Progress
-**45/45 tasks complete (100%)** across 16 planned stages. For the full plan, including
+**46/46 tasks complete (100%)** across 16 planned stages. For the full plan, including
stages below not yet broken into tasks: [roadmap.md](roadmap.md).
```mermaid
pie showData
title "Tasks across the roadmap"
- "Done" : 45
+ "Done" : 46
"Not started" : 0
```
@@ -40,12 +40,12 @@ pie showData
### Up next
-**Stage 8 -- Recording & Playback** is next, and has not been broken into tasks yet.
+**Stage 8 -- Recording & Playback** has no pending tasks recorded, but isn't marked complete -- likely awaiting its exit audit.
## Live repository facts
- **47** `CLAUDE.md` files
-- **1052** tests collected
+- **1101** tests collected
- **144** Gherkin scenarios (`tests/features/*.feature`)
## Stages
@@ -153,7 +153,11 @@ pie showData
### Stage 8 -- Recording & Playback
-**no status recorded** -- not yet broken into tasks; 0 criteria defined, no status line yet
+**in progress, as of 2026-09-07** -- `██████████` 1/1 tasks; 4/5 criteria met
+
+| Task | Status | Date | Artifact |
+|------|--------|------|----------|
+| TASK-045 -- Periodic Checkpointing (Headless Recording) | Done | 2026-09-07 | `docs/planning/backlog.md` |
### Stage 9 -- Better Numerics
diff --git a/docs/repository-inventory.md b/docs/repository-inventory.md
index 74a1890..764acc0 100644
--- a/docs/repository-inventory.md
+++ b/docs/repository-inventory.md
@@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are
not here either -- those come from running the suite, not from
listing files.
-**345 tracked files** across 47 directories;
+**353 tracked files** across 47 directories;
2 are empty.
## (root)
@@ -260,6 +260,9 @@ listing files.
- `__init__.py`
- `__main__.py`
- `bootstrap.py`
+- `checkpoint.py`
+- `recording.py`
+- `simulation_run.py`
## src/pyflow/configuration
@@ -387,6 +390,7 @@ listing files.
- `test_fluid_configuration.py`
- `test_import_order.py`
- `test_interactive_window.py`
+- `test_record_cli.py`
## tests/performance
@@ -412,6 +416,7 @@ listing files.
- `test_check_references.py`
- `test_check_scenarios.py`
- `test_check_stages.py`
+- `test_checkpoint.py`
- `test_collocated_field_contract.py`
- `test_configuration.py`
- `test_conjugate_gradient_solver.py`
@@ -446,10 +451,13 @@ listing files.
- `test_piso_pressure_coupling.py`
- `test_pressure_correction_loop.py`
- `test_pressure_field.py`
+- `test_recording.py`
+- `test_recording_determinism.py`
- `test_rendering.py`
- `test_rk4_time_integration.py`
- `test_scalar_field.py`
- `test_simulation.py`
+- `test_simulation_run.py`
- `test_structured_cartesian_mesh.py`
- `test_temperature_field.py`
- `test_uniform_vertex_coordinate_system.py`
diff --git a/docs/repository-manifest.md b/docs/repository-manifest.md
index 2c05d29..978e10f 100644
--- a/docs/repository-manifest.md
+++ b/docs/repository-manifest.md
@@ -154,7 +154,7 @@ Not present, deferred consciously rather than overlooked:
| overview.md | 🟩 | Top-level system map -- no KA entry; legitimate but unspecified |
| rendering.md | 🟩 | Architecture of the adopted renderer -- no KA entry |
| repository.md | 🟩 | Repository architecture -- no KA entry |
-| sequences.md | 🟨 | Time-ordered runtime sequences (setup, timestep loop, data flow, rendering) as Mermaid diagrams -- no KA entry. Two of its four sections carry a `Planned` subsection (live-loop wiring, checkpointing) anchored to TASK-030/TASK-034, hence 🟨 rather than 🟩. |
+| sequences.md | 🟩 | Time-ordered runtime sequences (setup, timestep loop, data flow, rendering) as Mermaid diagrams -- no KA entry. Its last `Planned` subsection (checkpointing, Section 3) was built 2026-09-07 (TASK-045, Stage 8); Section 2's live-loop wiring went real earlier, 2026-08-28 (TASK-030). Deterministic replay and playback (TASK-046/047) are still unbuilt, but Section 3 now says so in prose rather than under a `Planned` heading. |
| compute-and-rendering-stack.md | 🟨 | Survey and compatibility matrix for array-library × renderer combinations; decision-support for the stack ADRs. Both questions it exists to support are decided: the class (A2b) via `ADR-004`, the instances (A2c, PyTorch + wgpu/pygfx) via `ADR-005`, both 2026-08-15. It remains the record of why, and of the options not taken. (This row read "not yet decided" for A2c until 2026-08-18 -- stale since the day it was written, since `ADR-005` landed the same day.) |
`engine.md` and `icds.md` written 2026-08-17 (`docs/planning/backlog.md`
@@ -596,7 +596,19 @@ and `hud.py` -- title/legend-numeric-label/stats-block `pygfx.Text`
construction, added Stage 7, Rendering Annotations, TASK-044, 2026-08-31,
tested by `tests/unit/test_hud.py`),
plus `bootstrap.py` (calls `assemble_numerics` on every run, TASK-021)
-and `__main__.py` at the package root. `physics/` was a docstring-only
+and `__main__.py` at the package root. **Three more root modules landed
+2026-09-07 (TASK-045, Stage 8, Recording & Playback)**: `simulation_run.py`
+(`SimulationState`, `build_simulation_state`/`advance_simulation_state`/
+`assembled_numerics_for` -- `bootstrap.py`'s own simulation-state
+construction and advancement, extracted so a headless caller can reuse it
+without paying for a `RenderWindow`), `checkpoint.py` (`Checkpoint`,
+`write_checkpoint`/`read_checkpoint`/`restore_simulation_state` --
+`torch.save`d, self-contained checkpoint files with the whole config
+embedded as `dataclasses.asdict`), and `recording.py` (`record`,
+`RecordingResult`, `NothingToRecordError` -- the headless stepping loop
+`pyflow record` dispatches to, which never imports `rendering` at all).
+See `src/pyflow/CLAUDE.md` for why all three sit at the package root
+rather than inside `engine/`. `physics/` was a docstring-only
`__init__.py` through Stage 5, deliberately not `engine/numerics/`'s
home (TASK-018's design decisions: `physics/` is reserved for phenomena,
not numerical machinery) -- **it gained its first real module,
@@ -966,7 +978,12 @@ buoyancy` was imported, in a fresh subprocess -- the same reasoning
rather than an import-order one; found necessary when a first version's
registration call, placed inside `bootstrap()`'s own function body,
turned out to make the name resolvable only after `bootstrap()` had
-actually run once). The repository-tooling tests
+actually run once), and `test_record_cli.py` (TASK-045, 2026-09-07: a
+real subprocess run of `pyflow record` against the Heat Diffusion golden
+demo config, asserting the expected checkpoint files exist and one loads
+back with the right frame count -- not in `tests/golden/`, since
+recording is a new mode of running an existing config, not a new demo).
+The repository-tooling tests
live in `unit/` alongside them: `test_check_docs.py`,
`test_check_claims.py`, `test_check_graph.py` and
`test_generate_docs_index.py`/`test_generate_dependency_tree.py`/
diff --git a/planning/data/features.yaml b/planning/data/features.yaml
index 807c83c..dfb850d 100644
--- a/planning/data/features.yaml
+++ b/planning/data/features.yaml
@@ -615,3 +615,11 @@ entities:
edges:
- type: belongs_to
to: stage-7
+
+ - id: task-045
+ name: "TASK-045 — Periodic Checkpointing (Headless Recording)"
+ documented_in: docs/planning/roadmap.md
+ must_appear_in: docs/planning/roadmap.md
+ edges:
+ - type: belongs_to
+ to: stage-8
diff --git a/src/pyflow/CLAUDE.md b/src/pyflow/CLAUDE.md
index d27fab8..45249ef 100644
--- a/src/pyflow/CLAUDE.md
+++ b/src/pyflow/CLAUDE.md
@@ -2,9 +2,11 @@
Four subpackages, each with its own `CLAUDE.md`: `configuration/`,
`engine/`, `physics/`, `rendering/` -- per `docs/planning/roadmap.md`
-TASK-000. Two top-level modules alongside them: `__main__.py` (the CLI
-entry point, `python -m pyflow`) and `bootstrap.py`. A fifth,
-`engine/numerics/`, landed in Stage 3 -- see below.
+TASK-000. Top-level modules alongside them: `__main__.py` (the CLI entry
+point, `python -m pyflow`), `bootstrap.py`, and -- since TASK-045,
+2026-09-07 -- `simulation_run.py`, `checkpoint.py`, `recording.py` (see
+below). A fifth subpackage, `engine/numerics/`, landed in Stage 3 -- see
+below.
**`bootstrap.py` lives here, at the package root, not inside `engine/`,
deliberately.** It composes `configuration`, `engine` (for logging) and
@@ -111,3 +113,73 @@ around `pyflow.configuration.generator.generate_config_yaml`, so it
lives directly in `__main__.py` rather than needing a root-level module
of its own. See `configuration/CLAUDE.md` for what the generator does
and why it reuses `dataclasses.asdict()`.
+
+**Three more root modules, added 2026-09-07 for TASK-045 (Stage 8,
+Recording & Playback), and why each sits here rather than inside
+`engine/`.**
+
+`simulation_run.py` holds `SimulationState` (a `mode`/`fields`/optional
+`velocity_field` triple) and `build_simulation_state`/
+`advance_simulation_state`/`assembled_numerics_for` -- the simulation-
+state construction and advancement logic `bootstrap.py`'s two rendering
+closures (`_add_declared_field_transport`/
+`_add_solved_velocity_rendering`) used to fuse together with their own
+`window.scene.add(...)` calls. It applies the same standing rule this
+file states above for `bootstrap.py` itself: a module that orchestrates
+`configuration` and `engine` together belongs at the package root, not
+inside whichever subpackage happened to hold the code first. It imports
+neither `rendering` nor pulls in `pygfx` transitively (verified live,
+not assumed, by checking `sys.modules` after importing it alone) --
+that is what lets `recording.py` reuse it for a genuinely headless run.
+`bootstrap.py` itself was refactored to call these functions rather than
+duplicate them; the refactor was verified behaviour-preserving by the
+full existing test suite passing unmodified, not by new tests written to
+justify it.
+
+`checkpoint.py` holds the on-disk checkpoint format:
+`Checkpoint`/`write_checkpoint`/`read_checkpoint`/
+`restore_simulation_state`. One `torch.save`d file per checkpoint,
+`weights_only=True`-loadable (the config is embedded as
+`dataclasses.asdict(config)`, not a pickled instance -- a pickled
+`PyFlowConfig` would force `weights_only=False`, a real code-execution
+surface on load). `restore_simulation_state` is the reason this needs
+its own module rather than living inside `recording.py`: reconstructing
+a resumable `SimulationState` from a checkpoint's raw tensors alone is
+insufficient for "passive" mode, whose prescribed `velocity_field` is
+never checkpointed (constant by construction, so checkpointing it would
+only be a redundant record of `config.simulation.velocity_pattern`) --
+it calls `build_simulation_state` again for the right structure, then
+overwrites `.fields` with the checkpoint's real values.
+
+`recording.py` holds `record`/`resume`/`RecordingResult`/
+`NothingToRecordError`/`NothingToResumeError`, the functions `pyflow
+record`/`pyflow resume` dispatch to. **It never imports `rendering`,
+`pygfx`, or `rendercanvas` at all** -- not merely defaults to an
+offscreen backend -- which is the structural enforcement of "headless by
+default when recording": a `RenderWindow` cannot be constructed without
+paying the real cost of building a `wgpu` renderer (`RenderWindow.
+__init__`), so a genuinely headless path needs to never reach that
+constructor rather than reach it and discard the result.
+`tests/integration/test_import_order.py`'s parametrised module list
+gained all three modules in the same change, per that test's own
+"add to this list whenever a new top-level module or subpackage is
+added" instruction.
+
+**`resume`, added the same day at a user's direct request** ("how can a
+second run ingest those checkpoints to continue the simulation") **--
+still recording's own scope, not replay or playback.** It reads a
+checkpoint (`checkpoint.read_checkpoint`), restores a `SimulationState`
+from it (`checkpoint.restore_simulation_state`), and continues stepping
+headlessly from the checkpoint's own `frame_count`, writing further
+checkpoints at the same policy `record` uses -- shared with it through a
+new `_advance_and_checkpoint` helper rather than a second copy of the
+"every `interval` frames, and at `max_frames`" logic, confirmed to
+genuinely share behaviour (not just source) by a deliberate off-by-one
+mutation that broke both functions' own tests together. Takes no
+`--config` at all: the checkpoint already carries one, validated exactly
+as strictly as a config file (`checkpoint.py`'s own docstring). It is
+not Stage 8's own second or third bullet (deterministic windowed replay,
+TASK-046; a playback path, TASK-047) -- neither renders anything or
+materializes dense per-frame data for a watched range; `resume` only
+ever produces more of the identical sparse checkpoint files `record`
+already produces, starting from a later frame.
diff --git a/src/pyflow/__main__.py b/src/pyflow/__main__.py
index 1e14d98..c9e7014 100644
--- a/src/pyflow/__main__.py
+++ b/src/pyflow/__main__.py
@@ -25,6 +25,39 @@
rejected via `run_parser.error(...)`, the same rejection path
`--backend`'s `choices=` already uses for an invalid backend.
+`pyflow record --config --max-frames N [--output-dir DIR]
+[--checkpoint-interval N]` (TASK-045, Stage 8, Recording & Playback): a
+new subcommand, not a flag on `run` -- it produces checkpoint files, not
+pixels, the same "new capability, own inputs/outputs" shape
+`generate-config` already set, unlike `--demos`, which is only an
+alternate way to say what `run` already does. `--config`/`--max-frames`
+are `required=True` here, unlike `run`'s own optional versions: a
+headless record run with no config just re-records the built-in
+defaults pointlessly, and an unbounded one has no natural stopping
+point, neither of which `run`'s own interactive default has to worry
+about. Dispatches to `pyflow.recording.record`, which never imports
+`rendering` at all -- see that module's own docstring for why this is a
+separate entry point rather than a `bootstrap()` keyword argument.
+
+`pyflow resume --checkpoint --max-frames N [--output-dir DIR]
+[--checkpoint-interval N]` (TASK-045, added the same day as `record`
+once a user asked how a second run would ingest `record`'s own output):
+continues a headless recording from an existing checkpoint rather than
+from frame 0 -- still no rendering window, still writing further
+checkpoint files, not the dense per-frame replay TASK-046/047 still
+owns. **Deliberately no `--config` flag at all** -- a checkpoint carries
+its own, validated exactly as strictly as a config file
+(`pyflow.checkpoint.read_checkpoint`), so naming one here would only
+invite a mismatch between "the config this run resumes under" and
+"the config a user happened to pass." `--checkpoint`/`--max-frames` are
+`required=True`, the same reasoning `record`'s own required flags use;
+`--max-frames` must additionally be past the checkpoint's own frame
+count (`pyflow.recording.NothingToResumeError` otherwise). Dispatches to
+`pyflow.recording.resume`, which shares its checkpoint-writing policy
+with `record` (`recording.py`'s own `_advance_and_checkpoint`) so a
+`record` to frame 6 followed by a `resume` to frame 12 writes the same
+files an uninterrupted `record` to frame 12 would have.
+
The top-level parser's own `description`/`epilog` (below) is the CLI's
self-description, printed both by bare invocation and by `--help`.
**It must be kept current with what the CLI can actually do** -- see
@@ -50,6 +83,7 @@
resolve_golden_demo,
)
from pyflow.configuration.schema import RenderBackend
+from pyflow.recording import record, resume
# Sentinel for `--demos` given with no value ("list the demos"),
# distinguishable from both "not given at all" (`None`, the default) and
@@ -95,6 +129,15 @@ def main(argv: list[str] | None = None) -> None:
" pyflow generate-config --output config.yaml\n"
" Write a valid starting configuration file, ready to "
"edit.\n"
+ " pyflow record --config path/to/config.yaml --max-frames 1000\n"
+ " Headlessly step a simulation forward, writing periodic "
+ "checkpoints\n"
+ " to disk -- no rendering window at all.\n"
+ " pyflow resume --checkpoint checkpoints/checkpoint_00000100.pt "
+ "--max-frames 500\n"
+ " Continue a headless recording from an existing "
+ "checkpoint -- no --config,\n"
+ " the checkpoint carries its own.\n"
"\n"
"Run 'pyflow --help' for a command's own options -- "
"e.g. 'pyflow run --help'\n"
@@ -164,6 +207,82 @@ def main(argv: list[str] | None = None) -> None:
help="Write the generated YAML to this path instead of stdout.",
)
+ record_parser = subparsers.add_parser(
+ "record",
+ help="Headlessly step a simulation forward and write periodic "
+ "checkpoints to disk, with no rendering window at all.",
+ epilog=(
+ "examples:\n"
+ " pyflow record --config examples/golden-demos/heat_diffusion.yaml "
+ "--max-frames 1000\n"
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ record_parser.add_argument(
+ "--config",
+ type=Path,
+ required=True,
+ help="Path to a YAML configuration file.",
+ )
+ record_parser.add_argument(
+ "--max-frames",
+ type=int,
+ required=True,
+ help="Step this many timesteps forward, then stop.",
+ )
+ record_parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=None,
+ help="Where to write checkpoint files (default: config.recording.output_dir).",
+ )
+ record_parser.add_argument(
+ "--checkpoint-interval",
+ type=int,
+ default=None,
+ help="Frames between checkpoints (default: config.recording.checkpoint_interval).",
+ )
+
+ resume_parser = subparsers.add_parser(
+ "resume",
+ help="Read a checkpoint written by `record` (or a previous "
+ "`resume`), and continue stepping headlessly from its own frame, "
+ "writing further checkpoints. No --config -- the checkpoint "
+ "carries its own.",
+ epilog=(
+ "examples:\n"
+ " pyflow resume --checkpoint checkpoints/checkpoint_00000100.pt "
+ "--max-frames 500\n"
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ resume_parser.add_argument(
+ "--checkpoint",
+ type=Path,
+ required=True,
+ help="Path to a checkpoint file written by `pyflow record` or `pyflow resume`.",
+ )
+ resume_parser.add_argument(
+ "--max-frames",
+ type=int,
+ required=True,
+ help="Step forward to this frame, then stop. Must be greater than "
+ "the checkpoint's own frame count.",
+ )
+ resume_parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=None,
+ help="Where to write further checkpoint files (default: the checkpoint's own directory).",
+ )
+ resume_parser.add_argument(
+ "--checkpoint-interval",
+ type=int,
+ default=None,
+ help="Frames between checkpoints (default: the checkpoint's own "
+ "embedded config.recording.checkpoint_interval).",
+ )
+
args = parser.parse_args(argv)
if args.command == "run":
@@ -198,6 +317,26 @@ def main(argv: list[str] | None = None) -> None:
args.output.write_text(yaml_text, encoding="utf-8")
return
+ if args.command == "record":
+ result = record(
+ args.config,
+ max_frames=args.max_frames,
+ output_dir=args.output_dir,
+ checkpoint_interval=args.checkpoint_interval,
+ )
+ print(f"wrote {len(result.checkpoint_frames)} checkpoint(s) to {result.output_dir}")
+ return
+
+ if args.command == "resume":
+ result = resume(
+ args.checkpoint,
+ max_frames=args.max_frames,
+ output_dir=args.output_dir,
+ checkpoint_interval=args.checkpoint_interval,
+ )
+ print(f"wrote {len(result.checkpoint_frames)} checkpoint(s) to {result.output_dir}")
+ return
+
print(f"pyflow {__version__}")
parser.print_help()
diff --git a/src/pyflow/bootstrap.py b/src/pyflow/bootstrap.py
index f1ee6f4..3c0f8b4 100644
--- a/src/pyflow/bootstrap.py
+++ b/src/pyflow/bootstrap.py
@@ -34,22 +34,23 @@
solved-velocity-plus-declared-field combination (Thermal Buoyancy): it
already assembled that combination generically, for TASK-042.
-**This module composes a fourth package as of TASK-035 (Stage 6,
-2026-08-30): `physics`.** It imports `pyflow.physics.buoyancy` for its
-import side effect alone --
-`engine/numerics/assembly.py` cannot (`engine` must stay "independent of
-any specific physics", `src/pyflow/engine/CLAUDE.md`'s own opening
-line), so this is the one place allowed to know about both the registry
-and a concrete phenomenon, the same reason this module already composes
-`configuration`/`engine`/`rendering`. **The registration itself lives in
-`physics/buoyancy.py`, at that module's own import time, not here** --
-a first version called `register_source_term("boussinesq_buoyancy", ...)`
-from inside this module's own `bootstrap()` function, which made the
-name resolvable only after `bootstrap()` had actually run once, unlike
-every one of `adr/ADR-003`'s six components (self-registered the moment
-`assembly.py` is imported). Fixed the same way those six avoid the
-problem: `physics/buoyancy.py` self-registers at its own module scope,
-and this module's own existing import of it is what triggers that.
+**This module composed a fourth package as of TASK-035 (Stage 6,
+2026-08-30): `physics`, via a `pyflow.physics.buoyancy` import for its
+side effect alone (self-registering `"boussinesq_buoyancy"` with
+`engine/numerics/assembly.py`'s registry, which cannot import a concrete
+phenomenon itself -- `engine` must stay "independent of any specific
+physics", `src/pyflow/engine/CLAUDE.md`'s own opening line).** **That
+import moved to `pyflow.simulation_run` (TASK-045, Stage 8, 2026-09-07)**,
+along with the `assemble_numerics` call it makes possible
+(`assembled_numerics_for`, below) -- `recording.py`'s own headless path
+calls that function too and needs the same registration, and this module
+now imports `simulation_run` unconditionally, so the side effect still
+fires exactly once per process regardless of which entry point runs
+first. `physics/buoyancy.py` itself is unchanged: it still self-registers
+at its own module scope (not inside any caller's function body, unlike a
+first version of this mechanism that made the name resolvable only after
+`bootstrap()` had actually run once) -- only *which* module's own
+top-level import triggers that self-registration has moved.
This docstring read "No simulation functionality -- Stage 0's job..."
until the 2026-08-28 Stage 4 exit audit, in a module that by then
@@ -83,13 +84,6 @@
import pygfx as gfx
-# Side-effect import: `physics.buoyancy` self-registers "boussinesq_
-# buoyancy" (`register_source_term`) at its own module scope -- this
-# import is what makes that name resolvable to `assemble_numerics`
-# below, not a reference to anything this module calls directly. See
-# this module's own docstring above for why the registration itself
-# does not live here.
-import pyflow.physics.buoyancy # noqa: F401
from pyflow import __version__
from pyflow.configuration import load_config
from pyflow.configuration.schema import (
@@ -98,13 +92,9 @@
RenderBackend,
UnitsConfig,
)
-from pyflow.engine.field import Field
from pyflow.engine.logging_setup import configure_logging, get_logger
from pyflow.engine.mesh import Mesh, StructuredCartesianMesh
-from pyflow.engine.numerics.assembly import assemble_numerics
from pyflow.engine.scalar_field import ScalarField
-from pyflow.engine.simulation import navier_stokes_step
-from pyflow.engine.simulation import step as simulation_step
from pyflow.engine.vector_field import VectorField
from pyflow.rendering import RenderWindow
from pyflow.rendering.field_visualization import (
@@ -124,6 +114,13 @@
fit_camera_to_bounds,
mesh_bounding_box,
)
+from pyflow.simulation_run import (
+ SimulationState,
+ advance_simulation_state,
+ assembled_numerics_for,
+ build_simulation_state,
+ velocity_field_from_state,
+)
logger = get_logger(__name__)
@@ -201,57 +198,6 @@ def _vector_display_initializer(
raise ValueError(f"unknown vector display pattern: {pattern!r}") # pragma: no cover
-def _simulation_scalar_initializer(
- pattern: str, bounds: _Bounds
-) -> Callable[[float, float], float]:
- """A `Field`-style `(x, y) -> value` callable for `SimulationConfig.
- scalar_pattern` (TASK-030) -- the live-simulation counterpart to
- `_scalar_display_initializer` above, sharing its "derive shape from
- mesh bounds, don't add a config field for it" reasoning.
-
- **`"sinusoidal_mode"` (TASK-034, Stage 5) is the Heat Diffusion
- golden demo's own initial condition** -- a single spatial Fourier
- mode, one full wavelength across the mesh's own x-extent
- (`wavenumber = 2*pi / domain_width`, the same "derived from mesh
- bounds" precedent `"gaussian_blob"`'s own `sigma` already sets), with
- no y-dependence. This is the one initial condition PyFlow's diffusion
- equation has a closed-form solution for at all: a single mode decays
- exponentially at a rate `Gamma * wavenumber**2`, set by the diffusion
- coefficient and the mode's own wavenumber alone -- `tests/features/
- heat_diffusion.feature`'s own criterion measures exactly that rate
- against this closed form.
- """
- if pattern == "gaussian_blob":
- min_x, min_y, max_x, max_y = bounds
- domain_width = max_x - min_x
- center_x = min_x + 0.2 * domain_width
- center_y = (min_y + max_y) / 2
- sigma = 0.08 * domain_width
- return lambda x, y: math.exp(-((x - center_x) ** 2 + (y - center_y) ** 2) / (2 * sigma**2))
- if pattern == "sinusoidal_mode":
- min_x, _min_y, max_x, _max_y = bounds
- domain_width = max_x - min_x
- wavenumber = 2 * math.pi / domain_width
- return lambda x, y: math.sin(wavenumber * (x - min_x))
- raise ValueError(f"unknown simulation scalar pattern: {pattern!r}") # pragma: no cover
-
-
-def _simulation_velocity_initializer(
- pattern: str | None, velocity: tuple[float, float]
-) -> Callable[[float, float], tuple[float, float]]:
- """A `Field`-style `(x, y) -> (vx, vy)` callable for `SimulationConfig.
- velocity_pattern` -- `None` (no pattern configured) prescribes zero
- velocity, independent of whether a scalar pattern is configured, the
- same "each of the two names its own thing, `None` its own absence"
- shape `FieldDisplayConfig.scalar_pattern`/`vector_pattern` already use.
- """
- if pattern is None:
- return lambda x, y: (0.0, 0.0)
- if pattern == "uniform":
- return lambda x, y: velocity
- raise ValueError(f"unknown simulation velocity pattern: {pattern!r}") # pragma: no cover
-
-
def _add_legend(
window: RenderWindow, field_display: FieldDisplayConfig, mesh_bounds: _Bounds
) -> _Bounds | None:
@@ -348,63 +294,54 @@ def _add_declared_field_transport(
`velocity_pattern`/`velocity` either way** -- "solved" decides what
happens to it after frame zero, not what it starts as
(`src/pyflow/configuration/CLAUDE.md`).
+
+ **State construction and per-frame advance moved to `simulation_run.
+ build_simulation_state`/`advance_simulation_state` (TASK-045, Stage 8,
+ 2026-09-07)** -- `recording.py`'s own headless path needs the
+ identical logic with no `pygfx` scene to mutate, so what used to be
+ built inline here (a declared `ScalarField` per `config.fields` entry,
+ velocity's own decomposed components joined in when `solved`, the
+ `if solved: navier_stokes_step(...) else: simulation_step(...)`
+ branch) now lives in a module with no `rendering` import at all. This
+ function keeps only the scene/legend/colour-map half.
"""
assert window.assembled_numerics is not None
numerics = window.assembled_numerics
assert config.fields
bounds = mesh_bounding_box(mesh)
- velocity_initializer = _simulation_velocity_initializer(
- config.simulation.velocity_pattern, config.simulation.velocity
- )
- velocity_field = VectorField(
- mesh, "velocity", num_components=2, initial_value=velocity_initializer
- )
- declared_fields: dict[str, ScalarField] = {
- declared.name: ScalarField(
- mesh,
- declared.name,
- initial_value=_simulation_scalar_initializer(declared.initial_condition, bounds),
- )
- for declared in config.fields
- }
-
- solved = config.simulation.velocity_solved
- state: dict[str, Field] = dict(declared_fields)
- if solved:
- for component in velocity_field.decompose():
- state[component.name] = component
- window.simulation_fields = state
+ built_state = build_simulation_state(mesh, config)
+ assert built_state is not None # config.fields is non-empty, asserted above
+ # Explicitly re-typed as `SimulationState` (not the `| None` union
+ # `build_simulation_state` returns) -- `_advance` below reassigns
+ # `state` as a `nonlocal`, and mypy cannot narrow a closure-captured
+ # variable's type past the `assert` above once it's reassigned inside
+ # a nested function.
+ state: SimulationState = built_state
+ window.simulation_fields = state.fields
render_field_name = config.field_display.render_field
rendered_object: gfx.Mesh | None = None
legend_bounds: _Bounds | None = None
if render_field_name is not None:
+ rendered_field = state.fields[render_field_name]
+ assert isinstance(rendered_field, ScalarField)
colors = scalar_field_colors(
- declared_fields[render_field_name],
+ rendered_field,
config.field_display.low_color,
config.field_display.high_color,
config.field_display.value_range,
)
- rendered_object = build_scalar_field_mesh(declared_fields[render_field_name], colors)
+ rendered_object = build_scalar_field_mesh(rendered_field, colors)
window.scene.add(rendered_object)
legend_bounds = _add_legend(window, config.field_display, bounds)
def _advance() -> None:
nonlocal state, rendered_object
- if solved:
- # `velocity_field` is read only to seed `state` above -- from
- # here on, velocity lives in `state` as its own two
- # components and `navier_stokes_step` reassembles and
- # corrects them itself, so there is nothing left to keep in
- # sync. The prescribed branch below is the opposite case: its
- # velocity never changes at all.
- state = navier_stokes_step(state, "velocity", numerics, config.numerics.timestep).fields
- else:
- state = simulation_step(state, velocity_field, numerics, config.numerics.timestep)
- window.simulation_fields = state
+ state = advance_simulation_state(state, numerics, config.numerics.timestep)
+ window.simulation_fields = state.fields
if render_field_name is not None:
- rendered_field = state[render_field_name]
+ rendered_field = state.fields[render_field_name]
assert isinstance(rendered_field, ScalarField)
colors = scalar_field_colors(
rendered_field,
@@ -463,18 +400,29 @@ def _add_solved_velocity_rendering(
both live paths now call `navier_stokes_step`, and the only real
difference between these two functions is what they render -- arrows
for a velocity alone here, a colour map for the scalar there.
+
+ **State construction and per-frame advance moved to `simulation_run.
+ build_simulation_state`/`advance_simulation_state` (TASK-045, Stage 8,
+ 2026-09-07)**, the same refactor `_add_declared_field_transport`'s own
+ docstring describes -- `SimulationState.fields` only ever stores
+ velocity's own decomposed scalar components, never a live
+ `VectorField`, so this function reassembles one via `simulation_run.
+ velocity_field_from_state` wherever it needs to draw arrows, rather
+ than tracking the reassembled object `navier_stokes_step` used to
+ hand back directly (`result.corrected_velocity`, now discarded in
+ favour of `advance_simulation_state`'s own smaller `SimulationState`
+ return shape).
"""
assert window.assembled_numerics is not None
numerics = window.assembled_numerics
- velocity_initializer = _simulation_velocity_initializer(
- config.simulation.velocity_pattern, config.simulation.velocity
- )
- velocity_field = VectorField(
- mesh, "velocity", num_components=2, initial_value=velocity_initializer
- )
- state: dict[str, Field] = {c.name: c for c in velocity_field.decompose()}
- window.simulation_fields = state
+ built_state = build_simulation_state(mesh, config)
+ assert built_state is not None # velocity_solved is true whenever this function is called
+ # See `_add_declared_field_transport`'s own identical comment for why
+ # this is re-typed rather than used directly.
+ state: SimulationState = built_state
+ window.simulation_fields = state.fields
+ velocity_field = velocity_field_from_state(state)
rendered_object = build_vector_field_arrows(
velocity_field, config.field_display.arrow_color, config.field_display.arrow_scale
@@ -484,11 +432,10 @@ def _add_solved_velocity_rendering(
window.scene.add(rendered_object)
def _advance() -> None:
- nonlocal state, rendered_object, velocity_field
- result = navier_stokes_step(state, "velocity", numerics, config.numerics.timestep)
- state = result.fields
- window.simulation_fields = state
- velocity_field = result.corrected_velocity
+ nonlocal state, rendered_object
+ state = advance_simulation_state(state, numerics, config.numerics.timestep)
+ window.simulation_fields = state.fields
+ velocity_field = velocity_field_from_state(state)
if rendered_object is not None:
window.scene.remove(rendered_object)
rendered_object = build_vector_field_arrows(
@@ -856,57 +803,16 @@ def bootstrap(
# does this, not only ones that need numerics for anything yet, since
# `NumericsConfig` always has a full section (defaulted or not) and
# assembly must not depend on whether a caller happens to care.
- # `config.fluid.diffusion_coefficient` (TASK-041, 2026-08-28) is
- # threaded in explicitly -- it moved out of `NumericsConfig` into its
- # own `fluid:` section, so `assemble_numerics` can no longer read it
- # off `config.numerics` alone. `coefficient_overrides` is now built
- # from `config.fields`' own declarations (TASK-042, Stage 6,
- # 2026-08-30) rather than only from `velocity_solved`: every declared
- # field contributes its own `diffusion_coefficient`, keyed by the
- # field's own `name` -- the mechanism (`CentralDifferenceDiffusion`'s
- # own per-field override map, TASK-031b) is unchanged, only its
- # source is new. When velocity is solved, its own two components
- # (`VectorField.component_name`) are *additionally* diffused with
- # `fluid.viscosity` instead of the scalar default -- this is the one
- # place in the engine that legitimately knows a run's velocity field
- # is conventionally named "velocity", so it is where that mapping is
- # built, not inside `assemble_numerics`/`CentralDifferenceDiffusion`
- # themselves (both stay field-name-agnostic).
- coefficient_overrides = {
- declared.name: declared.diffusion_coefficient for declared in config.fields
- }
- if config.simulation.velocity_solved:
- for i in range(2):
- coefficient_overrides[VectorField.component_name("velocity", i)] = (
- config.fluid.viscosity
- )
-
- # `buoyancy_couplings` (TASK-035, Stage 6, 2026-08-30) is
- # `source_term`'s own per-field mapping, the identical
- # "assemble_numerics stays field-name-agnostic, bootstrap.py builds
- # the map" split `coefficient_overrides` above already establishes.
- # `"boussinesq_buoyancy"` is already registered by the time this runs
- # -- `physics/buoyancy.py` self-registers at its own import time
- # (this module's own top-level `import pyflow.physics.buoyancy`
- # triggers it), not here, so that the name resolves even if
- # `assemble_numerics` is ever called without `bootstrap()` having
- # run first (this module's own docstring has the full history).
- buoyancy_couplings: dict[str, tuple[float, float]] = {}
- for declared in config.fields:
- if declared.has_buoyancy_coupling():
- assert declared.buoyancy_reference_value is not None
- assert declared.buoyancy_coefficient is not None
- buoyancy_couplings[declared.name] = (
- declared.buoyancy_reference_value,
- declared.buoyancy_coefficient,
- )
- window.assembled_numerics = assemble_numerics(
- config.numerics,
- config.fluid.diffusion_coefficient,
- coefficient_overrides,
- config.fluid.gravity,
- buoyancy_couplings,
- )
+ # **Moved to `simulation_run.assembled_numerics_for` (TASK-045, Stage
+ # 8, 2026-09-07)** -- `recording.py`'s own headless path needs the
+ # identical `coefficient_overrides`/`buoyancy_couplings` construction
+ # (per-field diffusion coefficients from `config.fields`, momentum's
+ # own two components diffused with `fluid.viscosity` when solved,
+ # `source_term`'s own per-field buoyancy mapping), and duplicating it
+ # a second time would be the exact restated-fact drift this codebase
+ # avoids elsewhere. See that function's own docstring for why each
+ # piece is built the way it is -- unchanged by the move.
+ window.assembled_numerics = assembled_numerics_for(config)
logger.info("numerics assembled: %s", window.assembled_numerics.names)
if config.fields:
# The same reporting shape the line above uses, for the other
diff --git a/src/pyflow/checkpoint.py b/src/pyflow/checkpoint.py
new file mode 100644
index 0000000..a007f1f
--- /dev/null
+++ b/src/pyflow/checkpoint.py
@@ -0,0 +1,166 @@
+"""One simulation state, written to and read from disk (TASK-045, Stage 8,
+Recording & Playback) -- what `recording.py`'s headless loop writes
+periodically, and what a future windowed-replay path (TASK-046) reads
+back to resume deterministic stepping from.
+
+Orchestrates `configuration` + `engine` only, the same "lives at the
+package root, no `rendering` import" rule `simulation_run.py` and
+`bootstrap.py` both follow (`src/pyflow/CLAUDE.md`).
+
+**One `torch.save`d file per checkpoint, fully self-contained.** Embeds
+its own config (`dataclasses.asdict`, not a pickled `PyFlowConfig`
+instance and not a YAML round-trip through a temp file): a pickled
+instance would force `torch.load(weights_only=False)`, a real code-exec
+surface, and tie the format to Python's pickle protocol version and this
+project's exact class layout; a YAML round-trip is needless indirection
+through a text format when the in-memory dict shape is already exactly
+what `pyflow.configuration.loader`'s own "raw dict -> validated
+`PyFlowConfig`" machinery consumes (`config_from_dict`). `asdict()` is
+already how `generator.py`'s `generate_config_yaml` gets its data
+(`configuration/CLAUDE.md`'s own "reuses `dataclasses.asdict()`"), it
+preserves tuples exactly, and `torch.load`'s default `weights_only=True`
+safe-globals allowlist already covers plain dict/list/tuple/str/int/
+float/bool -- no custom class ever crosses the pickle boundary.
+
+**No per-field type tag, and no RNG/device metadata.** Every entry
+`recording.py` checkpoints comes from `simulation_run.SimulationState.
+fields`, which -- verified directly, not assumed -- only ever holds
+plain single-component scalar tensors: `PressureField` never appears
+there (`navier_stokes_step`'s own pressure output is a separate return
+value, never fed back into the stepped state), and nothing anywhere in
+this codebase uses randomness or a non-CPU device (grepped for
+`torch.rand`/`random.`/`device=`/`.cuda(`, found none). Determinism
+after reload is therefore purely mesh + field tensors + config,
+reproduced exactly -- there is nothing else to capture.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from collections.abc import Mapping
+from dataclasses import dataclass
+from pathlib import Path
+
+import torch
+
+from pyflow.configuration import config_from_dict
+from pyflow.configuration.schema import PyFlowConfig
+from pyflow.engine.collocated_field import CollocatedField
+from pyflow.engine.field import Field
+from pyflow.engine.mesh import Mesh, StructuredCartesianMesh
+from pyflow.engine.numerics.assembly import AssembledNumerics
+from pyflow.engine.scalar_field import ScalarField
+from pyflow.simulation_run import SimulationState, assembled_numerics_for, build_simulation_state
+
+_SCHEMA_VERSION = 1
+
+
+class UnsupportedCheckpointVersionError(ValueError):
+ """Raised by `read_checkpoint` if a file's own `schema_version` isn't
+ the one this module knows how to read -- a named rejection rather
+ than a confusing tensor-shape mismatch several calls deep, and a
+ clean extension point the day this format's own shape ever changes.
+ """
+
+
+@dataclass(frozen=True)
+class Checkpoint:
+ """One `read_checkpoint` result: `frame_count` (0 = the run's own
+ initial state, N = after N advances -- the same convention
+ `RenderWindow.frame_count`/`bootstrap.py`'s `_stats_lines` already
+ use, so `elapsed = frame_count * config.numerics.timestep` agrees
+ with every other place in the codebase that computes it), the
+ reconstructed `config` this checkpoint was recorded under, and
+ `fields` (name -> `(mesh.num_cells,)` float64 tensor, exactly
+ `simulation_run.SimulationState.fields`' own shape once each `Field`
+ is reduced to its raw values).
+ """
+
+ frame_count: int
+ config: PyFlowConfig
+ fields: dict[str, torch.Tensor]
+
+
+def write_checkpoint(
+ path: str | Path,
+ *,
+ frame_count: int,
+ config: PyFlowConfig,
+ fields: Mapping[str, Field],
+) -> None:
+ """Write one checkpoint to `path`. `fields` values are cloned before
+ saving -- a caller (`recording.py`) keeps stepping the same
+ `SimulationState.fields` tensors after this returns, and a checkpoint
+ is a snapshot at this moment, not a live view into state that will
+ keep changing underneath it.
+ """
+ field_tensors: dict[str, torch.Tensor] = {}
+ for name, field_value in fields.items():
+ # `Field` itself declares no `.values` -- only `CollocatedField`
+ # does (`docs/engine/CLAUDE.md`'s own "carries only what's true
+ # regardless of arrangement"). Every entry `recording.py` passes
+ # here is one in practice (this module's own docstring), so this
+ # narrows rather than widens the accepted type.
+ assert isinstance(field_value, CollocatedField)
+ field_tensors[name] = field_value.values.clone()
+
+ payload = {
+ "schema_version": _SCHEMA_VERSION,
+ "frame_count": frame_count,
+ "config": dataclasses.asdict(config),
+ "fields": field_tensors,
+ }
+ torch.save(payload, path)
+
+
+def read_checkpoint(path: str | Path) -> Checkpoint:
+ """Read one checkpoint written by `write_checkpoint`. Raises
+ `UnsupportedCheckpointVersionError` if the file's own `schema_version`
+ doesn't match this module's.
+ """
+ payload = torch.load(path, weights_only=True)
+ schema_version = payload["schema_version"]
+ if schema_version != _SCHEMA_VERSION:
+ raise UnsupportedCheckpointVersionError(
+ f"{path}: checkpoint schema version {schema_version!r} is not supported "
+ f"(this build reads version {_SCHEMA_VERSION!r} only)"
+ )
+ return Checkpoint(
+ frame_count=payload["frame_count"],
+ config=config_from_dict(payload["config"]),
+ fields=payload["fields"],
+ )
+
+
+def restore_simulation_state(
+ checkpoint: Checkpoint,
+) -> tuple[Mesh, AssembledNumerics, SimulationState]:
+ """The mesh, assembled numerics, and resumable `SimulationState` a
+ `record()` run had at `checkpoint.frame_count` -- ready to pass
+ straight to `simulation_run.advance_simulation_state`.
+
+ `checkpoint.fields`' own raw tensors are the state that actually
+ changed; everything else needed to resume (mesh geometry, which mode
+ to advance in, and -- for a `"passive"`-mode run -- the constant,
+ never-checkpointed prescribed velocity field declared fields self-
+ advect against) is deterministically re-derivable from `checkpoint.
+ config` alone, via the exact same `simulation_run.
+ build_simulation_state` a live or headless run already used to build
+ its *own* initial state. Reusing it here gets the structure right
+ (mode, prescribed velocity) but the field *values* wrong (freshly
+ re-initialized from the config's own initial condition, not the
+ checkpoint's evolved state) -- this function's own job is replacing
+ those values with `checkpoint.fields`' real ones.
+ """
+ mesh = StructuredCartesianMesh.from_config(checkpoint.config.mesh)
+ numerics = assembled_numerics_for(checkpoint.config)
+ initial_state = build_simulation_state(mesh, checkpoint.config)
+ # A checkpoint can only have been recorded for a config `recording.
+ # record` accepted, and that function itself raises `NothingToRecord
+ # Error` for exactly the config shape that would make this `None`.
+ assert initial_state is not None
+ initial_state.fields = {
+ name: ScalarField(mesh, name, initial_value=tensor)
+ for name, tensor in checkpoint.fields.items()
+ }
+ return mesh, numerics, initial_state
diff --git a/src/pyflow/configuration/CLAUDE.md b/src/pyflow/configuration/CLAUDE.md
index 6c2cad0..b3513bc 100644
--- a/src/pyflow/configuration/CLAUDE.md
+++ b/src/pyflow/configuration/CLAUDE.md
@@ -757,3 +757,36 @@ everywhere else in this project: whether a comment's *wording* is still
an accurate description of the field's real constraint is a judgement
call for whoever changes that constraint, not something either test can
see.
+
+**`RecordingConfig` (`PyFlowConfig.recording`, TASK-045, added
+2026-09-07, Stage 8, Recording & Playback) is a new top-level
+`recording:` section, following `UnitsConfig`'s own shape** (two plain
+fields, no nesting): `output_dir: str = "checkpoints"` and
+`checkpoint_interval: int = 100` (`validate()` rejects `<= 0`, the same
+plain-positive-number pattern `timestep`/`diffusion_coefficient` already
+established). **Deliberately no `enabled: bool` field** -- neither
+`bootstrap()` nor `RenderWindow` ever reads `config.recording` at all,
+so the identical config file behaves identically whether run through
+`pyflow run` or `pyflow record`; which command is invoked is what turns
+recording on, not a config switch that could silently turn an
+interactive run into one that also writes checkpoints to disk. `pyflow
+record`'s own `--output-dir`/`--checkpoint-interval` CLI flags override
+this section's fields when given, the same override relationship
+`--backend` already has with `rendering.backend`.
+
+**`loader.py` split into `_config_from_raw(raw, *, source)` and a public
+`config_from_dict(raw)`, in the same change, for `checkpoint.py`'s
+benefit, not this section's.** `load_config(path)` used to read YAML and
+build the `PyFlowConfig` in one function body; that body is now
+`_config_from_raw`, and `load_config` is a thin wrapper that reads the
+file and calls it. `config_from_dict` is the same function exposed
+directly for a caller that already has a `dict` in hand and no YAML file
+to read -- concretely, `checkpoint.py`'s `read_checkpoint`, which embeds
+`dataclasses.asdict(config)` straight into a checkpoint's `torch.save`d
+payload (see `src/pyflow/CLAUDE.md`'s `checkpoint.py` entry) and needs
+the identical validation `load_config` gives a YAML file, not a second,
+looser parser that happens to accept the same shape. `__init__.py`
+re-exports `config_from_dict` alongside `load_config`/`PyFlowConfig`, the
+same "callers use the package's public surface, not
+`configuration.loader` directly" rule this file states above for the
+existing two names.
diff --git a/src/pyflow/configuration/__init__.py b/src/pyflow/configuration/__init__.py
index 2d3a38b..1337c46 100644
--- a/src/pyflow/configuration/__init__.py
+++ b/src/pyflow/configuration/__init__.py
@@ -4,7 +4,7 @@
"""
from pyflow.configuration.generator import generate_config_yaml
-from pyflow.configuration.loader import load_config
+from pyflow.configuration.loader import config_from_dict, load_config
from pyflow.configuration.schema import LoggingConfig, MeshConfig, PyFlowConfig, RenderingConfig
__all__ = [
@@ -12,6 +12,7 @@
"MeshConfig",
"PyFlowConfig",
"RenderingConfig",
+ "config_from_dict",
"generate_config_yaml",
"load_config",
]
diff --git a/src/pyflow/configuration/loader.py b/src/pyflow/configuration/loader.py
index be224dd..bcf6b15 100644
--- a/src/pyflow/configuration/loader.py
+++ b/src/pyflow/configuration/loader.py
@@ -24,6 +24,7 @@
MeshConfig,
NumericsConfig,
PyFlowConfig,
+ RecordingConfig,
RenderingConfig,
SimulationConfig,
UnitsConfig,
@@ -83,38 +84,24 @@ def _fields_from_raw(raw: object) -> list[FieldConfig]:
return declared
-def load_config(path: str | Path | None = None) -> PyFlowConfig:
- """Load configuration from `path`, or return all-defaults if `path` is None.
+def _config_from_raw(raw: dict[str, Any], *, source: str) -> PyFlowConfig:
+ """Shared by `load_config` (`raw` from `yaml.safe_load`) and
+ `config_from_dict` (`raw` from `dataclasses.asdict()`, a checkpoint's
+ own embedded config -- `pyflow.checkpoint`, TASK-045) -- one
+ validated construction path for "a nested dict shaped like
+ `PyFlowConfig`", regardless of where the dict came from, so a
+ checkpoint's config is checked exactly as strictly as a config file.
- Raises `FileNotFoundError` if `path` is given but doesn't exist,
- `ValueError` if the file's structure or values are invalid. Every
- such `ValueError` names the file and the offending field -- see the
- `except` clause below for why that needs saying.
+ Extracted from `load_config`'s own body (TASK-045); no behaviour
+ change for that function's own callers.
"""
- if path is None:
- config = PyFlowConfig()
- config.validate()
- return config
-
- path = Path(path)
- if not path.is_file():
- raise FileNotFoundError(f"config file not found: {path}")
-
- with path.open("r", encoding="utf-8") as handle:
- raw = yaml.safe_load(handle)
-
- if raw is None:
- raw = {}
- if not isinstance(raw, dict):
- raise ValueError(f"{path}: top-level YAML must be a mapping, got {type(raw).__name__}")
-
# Derived from `PyFlowConfig`'s own fields, not restated (P-011):
# adding a section to the schema should not also require editing a
# list here for the loader to accept it.
known_sections = {section.name for section in dataclasses.fields(PyFlowConfig)}
unknown = set(raw) - known_sections
if unknown:
- raise ValueError(f"{path}: unknown config section(s): {sorted(unknown)}")
+ raise ValueError(f"{source}: unknown config section(s): {sorted(unknown)}")
# `validate()` is inside this `try`, not after it. It used to sit
# outside, on the assumption that construction was the only step that
@@ -122,9 +109,9 @@ def load_config(path: str | Path | None = None) -> PyFlowConfig:
# construction and blows up in a comparison instead, so
# `width: "wide"` escaped as a raw `TypeError` that this function's
# own docstring said it wouldn't raise (found 2026-08-21). Catching
- # both here also means the file's name is attached to *every*
+ # both here also means the source's name is attached to *every*
# failure, including the hand-written checks in `schema.py`, which
- # name their field but have no idea which file it came from.
+ # name their field but have no idea which file/checkpoint it came from.
try:
config = PyFlowConfig(
logging=LoggingConfig(**raw.get("logging", {})),
@@ -136,9 +123,49 @@ def load_config(path: str | Path | None = None) -> PyFlowConfig:
fluid=FluidConfig(**raw.get("fluid", {})),
numerics=_numerics_config_from_raw(raw.get("numerics", {})),
units=UnitsConfig(**raw.get("units", {})),
+ recording=RecordingConfig(**raw.get("recording", {})),
)
config.validate()
except (TypeError, ValueError) as exc:
- raise ValueError(f"{path}: {exc}") from exc
+ raise ValueError(f"{source}: {exc}") from exc
return config
+
+
+def load_config(path: str | Path | None = None) -> PyFlowConfig:
+ """Load configuration from `path`, or return all-defaults if `path` is None.
+
+ Raises `FileNotFoundError` if `path` is given but doesn't exist,
+ `ValueError` if the file's structure or values are invalid. Every
+ such `ValueError` names the file and the offending field -- see
+ `_config_from_raw`'s own `except` clause for why that needs saying.
+ """
+ if path is None:
+ config = PyFlowConfig()
+ config.validate()
+ return config
+
+ path = Path(path)
+ if not path.is_file():
+ raise FileNotFoundError(f"config file not found: {path}")
+
+ with path.open("r", encoding="utf-8") as handle:
+ raw = yaml.safe_load(handle)
+
+ if raw is None:
+ raw = {}
+ if not isinstance(raw, dict):
+ raise ValueError(f"{path}: top-level YAML must be a mapping, got {type(raw).__name__}")
+
+ return _config_from_raw(raw, source=str(path))
+
+
+def config_from_dict(raw: dict[str, Any]) -> PyFlowConfig:
+ """The read direction of `dataclasses.asdict(config)` -- reconstructs
+ a validated `PyFlowConfig` from the plain-dict shape a checkpoint's
+ own embedded config is stored as (`pyflow.checkpoint`, TASK-045).
+ Not a second, drifting parser: routes through the exact same
+ `_config_from_raw` `load_config` uses, so a checkpoint's config is
+ validated identically to a config file.
+ """
+ return _config_from_raw(raw, source="checkpoint")
diff --git a/src/pyflow/configuration/schema.py b/src/pyflow/configuration/schema.py
index 3ae55d5..6468e7c 100644
--- a/src/pyflow/configuration/schema.py
+++ b/src/pyflow/configuration/schema.py
@@ -756,6 +756,41 @@ def validate(self) -> None:
raise ValueError(f"units.time_scale must be > 0, got {self.time_scale!r}")
+@dataclass
+class RecordingConfig:
+ """Headless checkpoint recording (Stage 8, Recording & Playback,
+ TASK-045) -- a new top-level `recording:` section, read only by
+ `pyflow.recording.record`. `bootstrap()`/`RenderWindow` never read
+ this section at all, which is the structural half of "headless by
+ default when recording" (the capability's own backlog item,
+ `docs/planning/backlog.md`): the same config file behaves identically
+ under `pyflow run` whether or not this section is set, because the
+ live-rendering path and the recording path are two different entry
+ points that share no runtime branch. Recording is enabled by *which
+ command runs*, not a config switch -- deliberately no `enabled: bool`
+ field.
+
+ `output_dir` is where checkpoint files are written (relative to the
+ current working directory, matching every other path-like config
+ value in this schema); `checkpoint_interval` is how many frames pass
+ between checkpoints (a checkpoint is always written at frame 0 and at
+ the run's own final frame too, regardless of this value -- see
+ `recording.py`'s own `record` function).
+ """
+
+ output_dir: str = "checkpoints"
+ checkpoint_interval: int = 100
+
+ def validate(self) -> None:
+ _require_str(self.output_dir, "recording.output_dir")
+ if not self.output_dir:
+ raise ValueError("recording.output_dir must not be empty")
+ if self.checkpoint_interval <= 0:
+ raise ValueError(
+ f"recording.checkpoint_interval must be > 0, got {self.checkpoint_interval!r}"
+ )
+
+
AdvectionSchemeName = Literal["first_order_upwind"]
DiffusionSchemeName = Literal["central_difference"]
TimeIntegrationSchemeName = Literal["rk4"]
@@ -1148,6 +1183,7 @@ class PyFlowConfig:
fluid: FluidConfig = field(default_factory=FluidConfig)
numerics: NumericsConfig = field(default_factory=NumericsConfig)
units: UnitsConfig = field(default_factory=UnitsConfig)
+ recording: RecordingConfig = field(default_factory=RecordingConfig)
def validate(self) -> None:
self.logging.validate()
@@ -1158,6 +1194,7 @@ def validate(self) -> None:
self.fluid.validate()
self.numerics.validate()
self.units.validate()
+ self.recording.validate()
_validate_boundary_conditions_jointly(self.mesh, self.numerics.boundary_conditions)
_validate_field_declarations(self.fields, self.field_display.render_field)
_validate_buoyancy_couplings(
diff --git a/src/pyflow/recording.py b/src/pyflow/recording.py
new file mode 100644
index 0000000..749465d
--- /dev/null
+++ b/src/pyflow/recording.py
@@ -0,0 +1,264 @@
+"""Headless checkpoint recording (TASK-045, Stage 8, Recording & Playback):
+step a simulation forward with no rendering window at all, writing
+periodic checkpoints to disk -- the write half of `docs/planning/
+backlog.md`'s "decouple simulation from rendering" item. `resume`
+(added the same task) is the natural extension of that same half, not
+the read/playback half: it continues a headless recording from an
+existing checkpoint, writing further checkpoints -- still no rendering,
+still not the dense, renderer-ready windowed replay Stage 8's own
+Completion Criterion 5 (playback half) still waits on TASK-046/047 for.
+
+**Never imports `rendering`, `pygfx`, or `rendercanvas` at all.** This is
+the structural enforcement of "headless by default when recording" (the
+backlog item's own stated requirement): `bootstrap()`/`RenderWindow`
+never read `config.recording` (`schema.py`'s own `RecordingConfig`
+docstring), so the same config file behaves identically under `pyflow
+run` whether or not a `recording:` section is set -- recording is
+enabled by *which command runs* (`pyflow record`/`pyflow resume`, this
+module), not a config switch that could accidentally turn a live
+interactive run into one that also writes checkpoints. `RenderWindow.
+__init__` unconditionally builds a real `wgpu` renderer
+(`rendering/window.py`), so this is not merely a convenience: there is
+no way to get a genuinely headless run out of `bootstrap()` itself, even
+with rendering "turned off," which is why this is a separate entry point
+rather than a `bootstrap()` keyword argument.
+
+Orchestrates `configuration` + `engine` only, the same package-root
+placement `bootstrap.py`/`simulation_run.py` both use
+(`src/pyflow/CLAUDE.md`).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from pyflow.checkpoint import read_checkpoint, restore_simulation_state, write_checkpoint
+from pyflow.configuration import load_config
+from pyflow.configuration.schema import PyFlowConfig
+from pyflow.engine.logging_setup import configure_logging, get_logger
+from pyflow.engine.mesh import StructuredCartesianMesh
+from pyflow.engine.numerics.assembly import AssembledNumerics
+from pyflow.simulation_run import (
+ SimulationState,
+ advance_simulation_state,
+ assembled_numerics_for,
+ build_simulation_state,
+)
+
+logger = get_logger(__name__)
+
+
+class NothingToRecordError(ValueError):
+ """Raised when `config` declares no `fields` and no
+ `simulation.velocity_solved` -- there is no changing state to
+ checkpoint, and silently writing `checkpoint_interval`-many identical
+ files of a static initial condition would be a plausible-looking
+ waste, not a useful recording.
+ """
+
+
+class NothingToResumeError(ValueError):
+ """Raised by `resume` when `max_frames` is not strictly greater than
+ the checkpoint's own `frame_count` -- there is nothing to advance to,
+ and silently returning an empty result would look like a successful
+ resume rather than a likely mistake in `--max-frames`.
+ """
+
+
+@dataclass(frozen=True)
+class RecordingResult:
+ """`record`/`resume`'s own return value -- `checkpoint_frames` is
+ every frame number a checkpoint was written *by this call*, in order.
+ For `record`, always starts with `0` and ends with `final_frame_count`
+ (per `record`'s own docstring). For `resume`, never includes the
+ frame resumed from (that checkpoint already exists -- it's the file
+ `resume` read) and ends with `final_frame_count`.
+ """
+
+ output_dir: Path
+ checkpoint_frames: list[int]
+ final_frame_count: int
+
+
+def _advance_and_checkpoint(
+ state: SimulationState,
+ numerics: AssembledNumerics,
+ config: PyFlowConfig,
+ *,
+ start_frame: int,
+ max_frames: int,
+ output_dir: Path,
+ interval: int,
+) -> list[int]:
+ """Advance `state` in place from `start_frame` to `max_frames`,
+ writing a checkpoint every `interval` frames and at `max_frames`
+ (even off interval). Never checkpoints `start_frame` itself -- shared
+ by `record` (`start_frame=0`, checkpointed by its own caller before
+ this runs) and `resume` (`start_frame=checkpoint.frame_count`,
+ already on disk as the file being resumed from), so the two can never
+ drift apart on what "every `interval` frames" means.
+ """
+ checkpoint_frames: list[int] = []
+ for frame_count in range(start_frame + 1, max_frames + 1):
+ state = advance_simulation_state(state, numerics, config.numerics.timestep)
+ if frame_count % interval == 0 or frame_count == max_frames:
+ path = output_dir / f"checkpoint_{frame_count:08d}.pt"
+ write_checkpoint(path, frame_count=frame_count, config=config, fields=state.fields)
+ checkpoint_frames.append(frame_count)
+ return checkpoint_frames
+
+
+def record(
+ config_path: str | Path | None = None,
+ *,
+ max_frames: int,
+ output_dir: str | Path | None = None,
+ checkpoint_interval: int | None = None,
+) -> RecordingResult:
+ """Load `config_path`, step it forward `max_frames` timesteps with no
+ rendering at all, writing a checkpoint at frame 0, every
+ `checkpoint_interval` frames, and at `max_frames` (always, even if
+ `max_frames` doesn't fall on the interval) -- the sparse seek index
+ Stage 8's own Goal describes.
+
+ `output_dir`/`checkpoint_interval`, given, override `config.
+ recording`'s own fields, the same CLI-overrides-config shape
+ `bootstrap()`'s own `backend` parameter already establishes.
+
+ `max_frames` is required, not optional -- unlike `bootstrap()`, there
+ is no window and no user to stop this run any other way; an
+ unbounded headless loop has no natural end.
+
+ Raises `NothingToRecordError` if the loaded config declares nothing
+ that changes frame to frame.
+ """
+ config = load_config(config_path)
+ configure_logging(config.logging)
+
+ resolved_output_dir = Path(
+ output_dir if output_dir is not None else config.recording.output_dir
+ )
+ interval = (
+ checkpoint_interval
+ if checkpoint_interval is not None
+ else config.recording.checkpoint_interval
+ )
+
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ numerics = assembled_numerics_for(config)
+ built_state = build_simulation_state(mesh, config)
+ if built_state is None:
+ raise NothingToRecordError(
+ "config declares no `fields` and no `simulation.velocity_solved` -- "
+ "nothing changes frame to frame, so there is nothing to record"
+ )
+ state: SimulationState = built_state
+
+ resolved_output_dir.mkdir(parents=True, exist_ok=True)
+ write_checkpoint(
+ resolved_output_dir / "checkpoint_00000000.pt",
+ frame_count=0,
+ config=config,
+ fields=state.fields,
+ )
+ rest = _advance_and_checkpoint(
+ state,
+ numerics,
+ config,
+ start_frame=0,
+ max_frames=max_frames,
+ output_dir=resolved_output_dir,
+ interval=interval,
+ )
+ checkpoint_frames = [0, *rest]
+
+ logger.info(
+ "recorded %d checkpoint(s) to %s, frames %s",
+ len(checkpoint_frames),
+ resolved_output_dir,
+ checkpoint_frames,
+ )
+ return RecordingResult(
+ output_dir=resolved_output_dir,
+ checkpoint_frames=checkpoint_frames,
+ final_frame_count=max_frames,
+ )
+
+
+def resume(
+ checkpoint_path: str | Path,
+ *,
+ max_frames: int,
+ output_dir: str | Path | None = None,
+ checkpoint_interval: int | None = None,
+) -> RecordingResult:
+ """Read the checkpoint at `checkpoint_path`, restore the
+ `SimulationState` it holds, and continue stepping headlessly from its
+ own `frame_count` up to `max_frames` -- the same checkpoint policy
+ `record` uses (every `checkpoint_interval` frames, and at
+ `max_frames`), so `record(..., max_frames=6)` followed by
+ `resume(..., max_frames=12)` writes exactly the checkpoint files an
+ uninterrupted `record(..., max_frames=12)` would have written after
+ frame 6.
+
+ No `--config`/`config_path` parameter at all -- a checkpoint is
+ self-contained (`checkpoint.py`'s own docstring) and carries its own
+ validated config, read back through the identical
+ `checkpoint.read_checkpoint` a resumed run's config is checked with.
+
+ `output_dir`, given, overrides where further checkpoints are written;
+ omitted, defaults to `checkpoint_path`'s own parent directory -- not
+ the checkpoint's embedded `config.recording.output_dir`, which is the
+ *original* run's configured default and may not be where this
+ particular file actually lives if that run itself overrode it.
+
+ `checkpoint_interval`, given, overrides the checkpoint's own embedded
+ `config.recording.checkpoint_interval`; omitted, that value applies --
+ the same CLI-overrides-config shape `record` already establishes.
+
+ Raises `NothingToResumeError` if `max_frames` is not strictly greater
+ than the checkpoint's own `frame_count`.
+ """
+ checkpoint = read_checkpoint(checkpoint_path)
+ if max_frames <= checkpoint.frame_count:
+ raise NothingToResumeError(
+ f"checkpoint is already at frame {checkpoint.frame_count}; "
+ f"--max-frames {max_frames} is not past it"
+ )
+
+ configure_logging(checkpoint.config.logging)
+
+ resolved_output_dir = Path(
+ output_dir if output_dir is not None else Path(checkpoint_path).parent
+ )
+ interval = (
+ checkpoint_interval
+ if checkpoint_interval is not None
+ else checkpoint.config.recording.checkpoint_interval
+ )
+
+ _mesh, numerics, state = restore_simulation_state(checkpoint)
+ resolved_output_dir.mkdir(parents=True, exist_ok=True)
+ checkpoint_frames = _advance_and_checkpoint(
+ state,
+ numerics,
+ checkpoint.config,
+ start_frame=checkpoint.frame_count,
+ max_frames=max_frames,
+ output_dir=resolved_output_dir,
+ interval=interval,
+ )
+
+ logger.info(
+ "resumed from frame %d, recorded %d checkpoint(s) to %s, frames %s",
+ checkpoint.frame_count,
+ len(checkpoint_frames),
+ resolved_output_dir,
+ checkpoint_frames,
+ )
+ return RecordingResult(
+ output_dir=resolved_output_dir,
+ checkpoint_frames=checkpoint_frames,
+ final_frame_count=max_frames,
+ )
diff --git a/src/pyflow/simulation_run.py b/src/pyflow/simulation_run.py
new file mode 100644
index 0000000..d97f7aa
--- /dev/null
+++ b/src/pyflow/simulation_run.py
@@ -0,0 +1,273 @@
+"""The pure simulation-state construction/advance logic shared by
+`bootstrap.py`'s two live-rendering paths and, since TASK-045 (Stage 8,
+Recording & Playback), `recording.py`'s headless one.
+
+Extracted from `bootstrap.py`'s `_add_declared_field_transport`/
+`_add_solved_velocity_rendering`, which used to build a field's initial
+condition, call `navier_stokes_step`/`simulation.step`, and mutate a
+`pygfx` scene all inside the same closure -- `recording.py` needs the
+first two without the third at all (`RenderWindow.__init__` unconditionally
+builds a real `wgpu` renderer, so a genuinely headless recording path
+cannot reuse `bootstrap()`/`RenderWindow` even with rendering "turned
+off"; see `docs/architecture/sequences.md` Section 3). This module is
+what makes that possible without duplicating the stepping logic: it
+orchestrates `configuration` + `engine` only, and imports nothing from
+`rendering` -- the same "module that composes two or more subpackages
+lives at the package root" rule `bootstrap.py` itself follows
+(`src/pyflow/CLAUDE.md`), applied to a narrower composition.
+
+**Deliberately does not import `pyflow.rendering.mesh_visualization.
+mesh_bounding_box`, even though it computes the identical value for a
+`StructuredCartesianMesh`.** That module imports `pygfx` at its own top
+level (for `fit_camera_to_bounds`'s own type hint), so importing
+anything from it -- even a function with no `pygfx` dependency in its
+own body -- would transitively pull `pygfx` into `recording.py`'s own
+import chain, defeating the point of a headless path. `_domain_bounds`
+below computes the same bounding box directly from `MeshConfig.origin`/
+`spacing`/`extent`, which is exact for the one concrete `Mesh` this
+project has (`StructuredCartesianMesh`'s own uniform, axis-aligned
+vertex grid) and needs no `Mesh` instance or `numpy` at all. A genuine,
+acknowledged duplication of what `mesh_bounding_box` computes -- not
+its own implementation -- recorded here rather than smoothed over.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Literal
+
+# Side-effect import: `physics.buoyancy` self-registers "boussinesq_
+# buoyancy" (`register_source_term`) at its own module scope -- needed
+# here for the identical reason `bootstrap.py`'s own top-level import of
+# it is needed there (see that module's own docstring): a headless
+# `recording.py` run using `source_term: boussinesq_buoyancy` must be
+# able to resolve the name too, and this is the only other place that
+# calls `assemble_numerics`.
+import pyflow.physics.buoyancy # noqa: F401
+from pyflow.configuration.schema import MeshConfig, PyFlowConfig
+from pyflow.engine.field import Field
+from pyflow.engine.mesh import Mesh
+from pyflow.engine.numerics.assembly import AssembledNumerics, assemble_numerics
+from pyflow.engine.scalar_field import ScalarField
+from pyflow.engine.simulation import navier_stokes_step
+from pyflow.engine.simulation import step as simulation_step
+from pyflow.engine.vector_field import VectorField
+
+_Bounds = tuple[float, float, float, float]
+
+StepMode = Literal["passive", "solved"]
+
+
+def _domain_bounds(mesh_config: MeshConfig) -> _Bounds:
+ """`(min_x, min_y, max_x, max_y)` for a `StructuredCartesianMesh`
+ built from `mesh_config` -- see this module's own docstring for why
+ this doesn't import `rendering.mesh_visualization.mesh_bounding_box`
+ instead, even though the two are exact for this mesh type.
+ """
+ origin_x, origin_y = mesh_config.origin
+ dx, dy = mesh_config.spacing
+ nx, ny = mesh_config.extent
+ return (origin_x, origin_y, origin_x + dx * nx, origin_y + dy * ny)
+
+
+def _simulation_scalar_initializer(
+ pattern: str, bounds: _Bounds
+) -> Callable[[float, float], float]:
+ """A `Field`-style `(x, y) -> value` callable for `SimulationConfig.
+ scalar_pattern` -- moved from `bootstrap.py` unchanged (TASK-045):
+ building a field's initial condition from configuration has no
+ rendering dependency, so it belongs wherever the state that
+ initial condition seeds gets built, not only where it gets rendered.
+
+ **`"sinusoidal_mode"` (TASK-034, Stage 5) is the Heat Diffusion
+ golden demo's own initial condition** -- a single spatial Fourier
+ mode, one full wavelength across the mesh's own x-extent
+ (`wavenumber = 2*pi / domain_width`, the same "derived from mesh
+ bounds" precedent `"gaussian_blob"`'s own `sigma` already sets), with
+ no y-dependence. This is the one initial condition PyFlow's diffusion
+ equation has a closed-form solution for at all: a single mode decays
+ exponentially at a rate `Gamma * wavenumber**2`, set by the diffusion
+ coefficient and the mode's own wavenumber alone -- `tests/features/
+ heat_diffusion.feature`'s own criterion measures exactly that rate
+ against this closed form.
+ """
+ if pattern == "gaussian_blob":
+ min_x, min_y, max_x, max_y = bounds
+ domain_width = max_x - min_x
+ center_x = min_x + 0.2 * domain_width
+ center_y = (min_y + max_y) / 2
+ sigma = 0.08 * domain_width
+ return lambda x, y: math.exp(-((x - center_x) ** 2 + (y - center_y) ** 2) / (2 * sigma**2))
+ if pattern == "sinusoidal_mode":
+ min_x, _min_y, max_x, _max_y = bounds
+ domain_width = max_x - min_x
+ wavenumber = 2 * math.pi / domain_width
+ return lambda x, y: math.sin(wavenumber * (x - min_x))
+ raise ValueError(f"unknown simulation scalar pattern: {pattern!r}") # pragma: no cover
+
+
+def _simulation_velocity_initializer(
+ pattern: str | None, velocity: tuple[float, float]
+) -> Callable[[float, float], tuple[float, float]]:
+ """A `Field`-style `(x, y) -> (vx, vy)` callable for `SimulationConfig.
+ velocity_pattern` -- moved from `bootstrap.py` unchanged (TASK-045),
+ same reasoning as `_simulation_scalar_initializer` above. `None` (no
+ pattern configured) prescribes zero velocity, independent of whether
+ a scalar pattern is configured, the same "each of the two names its
+ own thing, `None` its own absence" shape `FieldDisplayConfig.
+ scalar_pattern`/`vector_pattern` already use.
+ """
+ if pattern is None:
+ return lambda x, y: (0.0, 0.0)
+ if pattern == "uniform":
+ return lambda x, y: velocity
+ raise ValueError(f"unknown simulation velocity pattern: {pattern!r}") # pragma: no cover
+
+
+@dataclass
+class SimulationState:
+ """The state one `record()`/live-render call advances -- `fields` is
+ exactly the `dict[str, Field]` `window.simulation_fields` already
+ carried before TASK-045 (every entry a single-component `ScalarField`,
+ including velocity's own two decomposed components when solved;
+ `PressureField` never appears here -- `navier_stokes_step`'s own
+ pressure output is a separate return value, never fed back in).
+
+ `mode` decides `advance_simulation_state`'s own dispatch: `"solved"`
+ calls `navier_stokes_step` (velocity lives inside `fields` itself,
+ corrected every step); `"passive"` calls plain `simulation.step`
+ against a separately-tracked, never-updated `velocity_field` (the
+ prescribed case -- declared fields self-advect, nothing corrects
+ them). `velocity_field` is therefore only ever set for `"passive"`
+ mode; `"solved"` mode has nothing else to track between steps, since
+ `fields` alone is sufficient to resume from.
+ """
+
+ mode: StepMode
+ fields: dict[str, Field]
+ velocity_field: VectorField | None = None
+
+
+def build_simulation_state(mesh: Mesh, config: PyFlowConfig) -> SimulationState | None:
+ """The initial `SimulationState` for `config`, or `None` if it
+ declares nothing that changes frame to frame (`config.fields` empty
+ and `config.simulation.velocity_solved` false) -- the exact
+ "nothing to run" condition `bootstrap.py`'s own `run_simulation`
+ boolean already computed before TASK-045, now a single check any
+ caller (live-rendering or headless recording) can share.
+
+ Mirrors `bootstrap.py`'s pre-TASK-045 `_add_declared_field_transport`/
+ `_add_solved_velocity_rendering` construction exactly: a declared
+ field per `config.fields` entry, plus velocity's own two decomposed
+ components joined in when `config.simulation.velocity_solved` --
+ `"solved"` mode if declared fields exist (an empty `config.fields`
+ plus solved velocity is the velocity-only case, `"solved"` mode with
+ no other fields).
+ """
+ bounds = _domain_bounds(config.mesh)
+ velocity_initializer = _simulation_velocity_initializer(
+ config.simulation.velocity_pattern, config.simulation.velocity
+ )
+ velocity_field = VectorField(
+ mesh, "velocity", num_components=2, initial_value=velocity_initializer
+ )
+
+ declared_fields: dict[str, ScalarField] = {
+ declared.name: ScalarField(
+ mesh,
+ declared.name,
+ initial_value=_simulation_scalar_initializer(declared.initial_condition, bounds),
+ )
+ for declared in config.fields
+ }
+
+ solved = config.simulation.velocity_solved
+ run_scalar_simulation = bool(config.fields)
+ run_velocity_only_simulation = solved and not config.fields
+ if not (run_scalar_simulation or run_velocity_only_simulation):
+ return None
+
+ if run_scalar_simulation:
+ fields: dict[str, Field] = dict(declared_fields)
+ if solved:
+ for component in velocity_field.decompose():
+ fields[component.name] = component
+ return SimulationState(mode="solved", fields=fields)
+ return SimulationState(mode="passive", fields=fields, velocity_field=velocity_field)
+
+ # Velocity-only, solved (`_add_solved_velocity_rendering`'s own shape).
+ fields = {component.name: component for component in velocity_field.decompose()}
+ return SimulationState(mode="solved", fields=fields)
+
+
+def advance_simulation_state(
+ state: SimulationState, numerics: AssembledNumerics, dt: float
+) -> SimulationState:
+ """One timestep, dispatched on `state.mode` -- exactly the
+ `if solved: navier_stokes_step(...) else: simulation_step(...)`
+ branch `_add_declared_field_transport`'s own `_advance` closure had
+ inline before TASK-045, and exactly what `_add_solved_velocity_
+ rendering`'s own `_advance` always did (it was always `"solved"`
+ mode). Returns a new `SimulationState`; does not mutate `state`.
+ """
+ if state.mode == "solved":
+ fields = navier_stokes_step(state.fields, "velocity", numerics, dt).fields
+ return SimulationState(mode="solved", fields=fields)
+ assert state.velocity_field is not None
+ fields = simulation_step(state.fields, state.velocity_field, numerics, dt)
+ return SimulationState(mode="passive", fields=fields, velocity_field=state.velocity_field)
+
+
+def velocity_field_from_state(state: SimulationState, name: str = "velocity") -> VectorField:
+ """Reassembles the `VectorField` named `name` from `state.fields`'
+ own decomposed components -- the inverse of how `build_simulation_
+ state` put them in. `SimulationState.fields` only ever stores
+ decomposed scalar components, never a live `VectorField` object (the
+ same shape `window.simulation_fields` already had), so a caller that
+ needs one back -- `bootstrap.py`'s own `_add_solved_velocity_
+ rendering`, to draw arrows, and TASK-046's eventual playback path,
+ for the same reason -- has to reassemble it.
+ """
+ components = []
+ for i in range(2):
+ component = state.fields[VectorField.component_name(name, i)]
+ assert isinstance(component, ScalarField)
+ components.append(component)
+ return VectorField.assemble(components, name)
+
+
+def assembled_numerics_for(config: PyFlowConfig) -> AssembledNumerics:
+ """`assemble_numerics` fed from `config` -- the `coefficient_
+ overrides`/`buoyancy_couplings` construction moved here unchanged
+ from `bootstrap()` (TASK-045), so `recording.py` doesn't duplicate
+ it. See `bootstrap.py`'s own inline comments (now here) for why each
+ piece is built the way it is.
+ """
+ coefficient_overrides = {
+ declared.name: declared.diffusion_coefficient for declared in config.fields
+ }
+ if config.simulation.velocity_solved:
+ for i in range(2):
+ coefficient_overrides[VectorField.component_name("velocity", i)] = (
+ config.fluid.viscosity
+ )
+
+ buoyancy_couplings: dict[str, tuple[float, float]] = {}
+ for declared in config.fields:
+ if declared.has_buoyancy_coupling():
+ assert declared.buoyancy_reference_value is not None
+ assert declared.buoyancy_coefficient is not None
+ buoyancy_couplings[declared.name] = (
+ declared.buoyancy_reference_value,
+ declared.buoyancy_coefficient,
+ )
+
+ return assemble_numerics(
+ config.numerics,
+ config.fluid.diffusion_coefficient,
+ coefficient_overrides,
+ config.fluid.gravity,
+ buoyancy_couplings,
+ )
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index 47c4bc5..4272091 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -43,6 +43,8 @@ def test_entry_point_help_mentions_config_flag_and_golden_demos() -> None:
assert "--config" in result.stdout
assert "examples/golden-demos" in result.stdout
assert "--demos" in result.stdout
+ assert "record" in result.stdout
+ assert "resume" in result.stdout
def test_run_demos_bare_lists_available_demos() -> None:
@@ -145,6 +147,7 @@ def test_generate_config_prints_valid_yaml_to_stdout() -> None:
"fluid",
"numerics",
"units",
+ "recording",
]
@@ -181,6 +184,7 @@ def test_generate_config_output_writes_file_and_round_trips_through_run(
"fluid",
"numerics",
"units",
+ "recording",
]
run_result = subprocess.run(
diff --git a/tests/integration/test_import_order.py b/tests/integration/test_import_order.py
index 6521ac6..8c51c8f 100644
--- a/tests/integration/test_import_order.py
+++ b/tests/integration/test_import_order.py
@@ -31,6 +31,9 @@
"pyflow.physics",
"pyflow.physics.buoyancy",
"pyflow.bootstrap",
+ "pyflow.simulation_run",
+ "pyflow.checkpoint",
+ "pyflow.recording",
"pyflow.__main__",
]
diff --git a/tests/integration/test_record_cli.py b/tests/integration/test_record_cli.py
new file mode 100644
index 0000000..dbb42f4
--- /dev/null
+++ b/tests/integration/test_record_cli.py
@@ -0,0 +1,128 @@
+"""`pyflow record` and `pyflow resume` (TASK-045, Stage 8, Recording &
+Playback): real subprocesses, per this project's CLI-testing convention.
+One module for both, not two -- a real `resume` test needs a real
+`record` to resume from, and splitting them would either duplicate that
+setup or force cross-file coordination for no reader's benefit.
+
+Lives here, not under `tests/golden/`, deliberately: recording is a new
+*mode of running an existing config*, not a new demo -- `tests/golden/
+CLAUDE.md`'s own "one test module per demo" obligation attaches to a
+demo identity, and none is being claimed here. The same category
+`test_cli.py`'s own `generate-config` tests already occupy.
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+import torch
+
+
+def test_record_writes_checkpoint_files_for_a_real_golden_demo_config(tmp_path: Path) -> None:
+ output_dir = tmp_path / "checkpoints"
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pyflow",
+ "record",
+ "--config",
+ "examples/golden-demos/heat_diffusion.yaml",
+ "--max-frames",
+ "5",
+ "--output-dir",
+ str(output_dir),
+ "--checkpoint-interval",
+ "5",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "2" in result.stdout # frames 0 and 5
+ assert (output_dir / "checkpoint_00000000.pt").is_file()
+ assert (output_dir / "checkpoint_00000005.pt").is_file()
+
+ # A real, loadable checkpoint -- not just a file that happens to exist.
+ payload = torch.load(output_dir / "checkpoint_00000005.pt", weights_only=True)
+ assert payload["frame_count"] == 5
+ assert set(payload["fields"]) == {"tracer"} # heat_diffusion's own declared field name
+
+
+def test_record_requires_config_and_max_frames() -> None:
+ result = subprocess.run(
+ [sys.executable, "-m", "pyflow", "record"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ assert "--config" in result.stderr
+
+
+def test_resume_continues_a_real_recording_with_no_config_flag(tmp_path: Path) -> None:
+ output_dir = tmp_path / "checkpoints"
+ record_result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pyflow",
+ "record",
+ "--config",
+ "examples/golden-demos/heat_diffusion.yaml",
+ "--max-frames",
+ "5",
+ "--output-dir",
+ str(output_dir),
+ "--checkpoint-interval",
+ "5",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert record_result.returncode == 0, record_result.stderr
+
+ # `resume` gets only the checkpoint path -- no `--config` at all,
+ # the property the checkpoint's own self-containment exists for.
+ resume_result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pyflow",
+ "resume",
+ "--checkpoint",
+ str(output_dir / "checkpoint_00000005.pt"),
+ "--max-frames",
+ "10",
+ "--checkpoint-interval",
+ "5",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert resume_result.returncode == 0, resume_result.stderr
+ assert "1" in resume_result.stdout # one new checkpoint: frame 10
+ assert (output_dir / "checkpoint_00000010.pt").is_file()
+
+ payload = torch.load(output_dir / "checkpoint_00000010.pt", weights_only=True)
+ assert payload["frame_count"] == 10
+ assert set(payload["fields"]) == {"tracer"}
+
+
+def test_resume_requires_checkpoint_and_max_frames() -> None:
+ result = subprocess.run(
+ [sys.executable, "-m", "pyflow", "resume"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ assert "--checkpoint" in result.stderr
diff --git a/tests/unit/test_checkpoint.py b/tests/unit/test_checkpoint.py
new file mode 100644
index 0000000..4145568
--- /dev/null
+++ b/tests/unit/test_checkpoint.py
@@ -0,0 +1,138 @@
+"""Unit tests for pyflow.checkpoint (TASK-045, Stage 8, Recording &
+Playback) -- the write/read round trip a checkpoint's own file format
+must preserve exactly, since `recording.py`'s deterministic replay
+depends on it being lossless.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from pathlib import Path
+
+import pytest
+import torch
+
+from pyflow.checkpoint import (
+ UnsupportedCheckpointVersionError,
+ read_checkpoint,
+ write_checkpoint,
+)
+from pyflow.configuration import PyFlowConfig
+from pyflow.configuration.schema import FieldConfig, MeshConfig
+from pyflow.engine.mesh import StructuredCartesianMesh
+from pyflow.engine.scalar_field import ScalarField
+
+
+def _non_default_config() -> PyFlowConfig:
+ # Tuple-typed fields, a nested list section -- a config a naive
+ # round trip (e.g. losing tuple-ness) would visibly corrupt.
+ config = PyFlowConfig(
+ mesh=MeshConfig(origin=(0.5, -1.0), spacing=(0.2, 0.3), extent=(4, 3)),
+ fields=[FieldConfig(name="smoke", initial_condition="gaussian_blob")],
+ )
+ config.validate()
+ return config
+
+
+def test_write_read_round_trips_config_and_fields(tmp_path: Path) -> None:
+ config = _non_default_config()
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ field_a = ScalarField(mesh, "smoke", initial_value=lambda x, y: x + y)
+ field_b = ScalarField(mesh, "velocity.0", initial_value=lambda x, y: x * 2)
+ path = tmp_path / "checkpoint_00000010.pt"
+
+ write_checkpoint(
+ path, frame_count=10, config=config, fields={"smoke": field_a, "velocity.0": field_b}
+ )
+ loaded = read_checkpoint(path)
+
+ assert loaded.frame_count == 10
+ assert dataclasses.asdict(loaded.config) == dataclasses.asdict(config)
+ torch.testing.assert_close(loaded.fields["smoke"], field_a.values, rtol=0, atol=0)
+ torch.testing.assert_close(loaded.fields["velocity.0"], field_b.values, rtol=0, atol=0)
+
+
+def test_write_checkpoint_stores_a_clone_not_a_reference(tmp_path: Path) -> None:
+ """If a field's own tensor mutates after `write_checkpoint` returns,
+ the checkpoint's own stored data must not change with it -- the
+ whole point of persisting state at a moment in time.
+ """
+ config = _non_default_config()
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ field = ScalarField(mesh, "smoke", initial_value=lambda x, y: 1.0)
+ path = tmp_path / "checkpoint_00000000.pt"
+
+ write_checkpoint(path, frame_count=0, config=config, fields={"smoke": field})
+ field.values[:] = 999.0
+ loaded = read_checkpoint(path)
+
+ assert not torch.allclose(loaded.fields["smoke"], field.values)
+
+
+def test_read_checkpoint_rejects_unknown_schema_version(tmp_path: Path) -> None:
+ path = tmp_path / "bad.pt"
+ torch.save({"schema_version": 999, "frame_count": 0, "config": {}, "fields": {}}, path)
+
+ with pytest.raises(UnsupportedCheckpointVersionError):
+ read_checkpoint(path)
+
+
+def test_restore_simulation_state_reconstructs_a_resumable_state_for_a_passive_config(
+ tmp_path: Path,
+) -> None:
+ """`Checkpoint.fields` alone is not enough to resume from for a
+ `"passive"`-mode run: `SimulationState.velocity_field` (the
+ prescribed, never-checkpointed velocity a declared field self-
+ advects against) has to be re-derived from the checkpoint's own
+ embedded config, not read back from disk -- there is nothing on disk
+ to read it from.
+ """
+ from pyflow.checkpoint import restore_simulation_state
+
+ config = PyFlowConfig(
+ mesh=MeshConfig(extent=(3, 2)),
+ fields=[FieldConfig(name="smoke", initial_condition="gaussian_blob")],
+ )
+ config.validate()
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ field = ScalarField(mesh, "smoke", initial_value=lambda x, y: x + y)
+ path = tmp_path / "checkpoint_00000005.pt"
+ write_checkpoint(path, frame_count=5, config=config, fields={"smoke": field})
+
+ checkpoint = read_checkpoint(path)
+ restored_mesh, restored_numerics, restored_state = restore_simulation_state(checkpoint)
+
+ assert restored_state.mode == "passive"
+ assert restored_state.velocity_field is not None
+ assert restored_state.velocity_field.mesh is restored_mesh
+ restored_smoke = restored_state.fields["smoke"]
+ assert isinstance(restored_smoke, ScalarField)
+ torch.testing.assert_close(restored_smoke.values, field.values, rtol=0, atol=0)
+ assert restored_numerics.names # a real assembled numerics, not a stub
+
+
+def test_restore_simulation_state_reconstructs_solved_mode_with_no_prescribed_velocity(
+ tmp_path: Path,
+) -> None:
+ from pyflow.checkpoint import restore_simulation_state
+ from pyflow.configuration.schema import SimulationConfig
+ from pyflow.engine.vector_field import VectorField
+
+ config = PyFlowConfig(
+ mesh=MeshConfig(extent=(3, 2)), simulation=SimulationConfig(velocity_solved=True)
+ )
+ config.validate()
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ velocity_field = VectorField(
+ mesh, "velocity", num_components=2, initial_value=lambda x, y: (x, y)
+ )
+ components = {c.name: c for c in velocity_field.decompose()}
+ path = tmp_path / "checkpoint_00000003.pt"
+ write_checkpoint(path, frame_count=3, config=config, fields=components)
+
+ checkpoint = read_checkpoint(path)
+ _mesh, _numerics, restored_state = restore_simulation_state(checkpoint)
+
+ assert restored_state.mode == "solved"
+ assert restored_state.velocity_field is None
+ assert set(restored_state.fields) == {"velocity.0", "velocity.1"}
diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py
index 7ca46bb..bc27158 100644
--- a/tests/unit/test_configuration.py
+++ b/tests/unit/test_configuration.py
@@ -57,6 +57,8 @@ def test_defaults_are_valid() -> None:
assert config.units.length_scale == 1.0
assert config.units.time_unit == "s"
assert config.units.time_scale == 1.0
+ assert config.recording.output_dir == "checkpoints"
+ assert config.recording.checkpoint_interval == 100
for boundary_name in ("north", "south", "east", "west"):
face = getattr(config.numerics.boundary_conditions, boundary_name)
assert face.type == "dirichlet"
@@ -527,6 +529,7 @@ def test_load_config_rejects_a_non_numeric_simulation_velocity(tmp_path: Path) -
("units:\n length_scale: not-a-number\n", "units.length_scale"),
("units:\n time_unit: 7\n", "units.time_unit"),
("units:\n time_scale: not-a-number\n", "units.time_scale"),
+ ("recording:\n output_dir: 7\n", "recording.output_dir"),
],
)
def test_load_config_rejects_wrong_typed_values(
@@ -837,6 +840,60 @@ def test_load_config_rejects_non_positive_time_scale(tmp_path: Path) -> None:
load_config(config_file)
+# -- RecordingConfig (Stage 8, Recording & Playback, TASK-045) -----------
+
+
+def test_load_config_reads_recording_section(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("recording:\n output_dir: my_checkpoints\n checkpoint_interval: 10\n")
+
+ config = load_config(config_file)
+
+ assert config.recording.output_dir == "my_checkpoints"
+ assert config.recording.checkpoint_interval == 10
+
+
+def test_load_config_rejects_non_positive_checkpoint_interval(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("recording:\n checkpoint_interval: 0\n")
+
+ with pytest.raises(ValueError, match="recording.checkpoint_interval"):
+ load_config(config_file)
+
+
+def test_load_config_rejects_empty_output_dir(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("recording:\n output_dir: ''\n")
+
+ with pytest.raises(ValueError, match="recording.output_dir"):
+ load_config(config_file)
+
+
+def test_config_from_dict_round_trips_a_non_default_config(tmp_path: Path) -> None:
+ """The read direction of `dataclasses.asdict(config)` -- the shape a
+ checkpoint's own embedded config is stored as (`pyflow.checkpoint`,
+ TASK-045). Built from a real non-default config file (tuple-typed
+ fields, a nested section, a declared field) rather than constructed
+ in code, so this exercises the exact same object `load_config` itself
+ produces.
+ """
+ import dataclasses
+
+ from pyflow.configuration import config_from_dict
+
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(
+ "mesh:\n origin: [0.5, -1.0]\n spacing: [0.2, 0.3]\n extent: [5, 4]\n"
+ "fields:\n - name: smoke\n initial_condition: gaussian_blob\n"
+ "recording:\n output_dir: out\n checkpoint_interval: 25\n"
+ )
+ original = load_config(config_file)
+
+ round_tripped = config_from_dict(dataclasses.asdict(original))
+
+ assert dataclasses.asdict(round_tripped) == dataclasses.asdict(original)
+
+
# -- FieldConfig buoyancy coupling / NumericsConfig.source_term (TASK-035) -
#
# The two higher-level joint claims -- a coupling declared while
diff --git a/tests/unit/test_generator.py b/tests/unit/test_generator.py
index 0a95a3d..1d6a428 100644
--- a/tests/unit/test_generator.py
+++ b/tests/unit/test_generator.py
@@ -140,8 +140,8 @@ def test_top_level_key_order_matches_pyflowconfig_field_order() -> None:
schema's own declared field order -- it only proves the dict
iteration order was preserved. Check the parsed keys directly
against `PyFlowConfig`'s declared order (`logging`, `rendering`,
- `mesh`, `field_display`, `fields`, `simulation`, `fluid`, `numerics`),
- not assumed from the dumper flag.
+ `mesh`, `field_display`, `fields`, `simulation`, `fluid`, `numerics`,
+ `units`, `recording`), not assumed from the dumper flag.
"""
text = generate_config_yaml(PyFlowConfig())
@@ -157,4 +157,5 @@ def test_top_level_key_order_matches_pyflowconfig_field_order() -> None:
"fluid",
"numerics",
"units",
+ "recording",
]
diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py
index 039b8c7..13c2c03 100644
--- a/tests/unit/test_main.py
+++ b/tests/unit/test_main.py
@@ -10,6 +10,7 @@
"""
from pathlib import Path
+from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -45,6 +46,8 @@ def test_top_level_help_describes_current_capabilities(
assert "--config" in captured.out
assert "examples/golden-demos" in captured.out
assert "--demos" in captured.out
+ assert "record" in captured.out
+ assert "resume" in captured.out
def test_run_dispatches_to_bootstrap_with_parsed_args() -> None:
@@ -126,6 +129,143 @@ def test_run_rejects_config_and_demos_together(capsys: pytest.CaptureFixture[str
assert "not allowed with argument" in capsys.readouterr().err
+def test_record_dispatches_to_record_with_parsed_args() -> None:
+ with patch("pyflow.__main__.record") as mock_record:
+ mock_record.return_value = SimpleNamespace(
+ checkpoint_frames=[0, 5, 10], output_dir=Path("out")
+ )
+ main(
+ [
+ "record",
+ "--config",
+ "some-config.yaml",
+ "--max-frames",
+ "10",
+ "--output-dir",
+ "out",
+ "--checkpoint-interval",
+ "5",
+ ]
+ )
+
+ mock_record.assert_called_once_with(
+ Path("some-config.yaml"), max_frames=10, output_dir=Path("out"), checkpoint_interval=5
+ )
+
+
+def test_record_output_dir_and_checkpoint_interval_default_to_none(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ with patch("pyflow.__main__.record") as mock_record:
+ mock_record.return_value = SimpleNamespace(checkpoint_frames=[0, 3], output_dir=Path("c"))
+ main(["record", "--config", "some-config.yaml", "--max-frames", "3"])
+
+ mock_record.assert_called_once_with(
+ Path("some-config.yaml"), max_frames=3, output_dir=None, checkpoint_interval=None
+ )
+
+
+def test_record_requires_config(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit):
+ main(["record", "--max-frames", "5"])
+
+ assert "--config" in capsys.readouterr().err
+
+
+def test_record_requires_max_frames(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit):
+ main(["record", "--config", "some-config.yaml"])
+
+ assert "--max-frames" in capsys.readouterr().err
+
+
+def test_record_prints_a_summary(capsys: pytest.CaptureFixture[str]) -> None:
+ with patch("pyflow.__main__.record") as mock_record:
+ mock_record.return_value = SimpleNamespace(
+ checkpoint_frames=[0, 5, 10], output_dir=Path("checkpoints")
+ )
+ main(["record", "--config", "some-config.yaml", "--max-frames", "10"])
+
+ captured = capsys.readouterr()
+ assert "3" in captured.out
+ assert "checkpoints" in captured.out
+
+
+def test_resume_dispatches_to_resume_with_parsed_args() -> None:
+ with patch("pyflow.__main__.resume") as mock_resume:
+ mock_resume.return_value = SimpleNamespace(
+ checkpoint_frames=[9, 12], output_dir=Path("out")
+ )
+ main(
+ [
+ "resume",
+ "--checkpoint",
+ "checkpoints/checkpoint_00000006.pt",
+ "--max-frames",
+ "12",
+ "--output-dir",
+ "out",
+ "--checkpoint-interval",
+ "3",
+ ]
+ )
+
+ mock_resume.assert_called_once_with(
+ Path("checkpoints/checkpoint_00000006.pt"),
+ max_frames=12,
+ output_dir=Path("out"),
+ checkpoint_interval=3,
+ )
+
+
+def test_resume_output_dir_and_checkpoint_interval_default_to_none() -> None:
+ with patch("pyflow.__main__.resume") as mock_resume:
+ mock_resume.return_value = SimpleNamespace(checkpoint_frames=[9], output_dir=Path("c"))
+ main(["resume", "--checkpoint", "checkpoints/checkpoint_00000006.pt", "--max-frames", "9"])
+
+ mock_resume.assert_called_once_with(
+ Path("checkpoints/checkpoint_00000006.pt"),
+ max_frames=9,
+ output_dir=None,
+ checkpoint_interval=None,
+ )
+
+
+def test_resume_has_no_config_flag_at_all() -> None:
+ """`pyflow resume` never takes `--config` -- a checkpoint carries its
+ own (`recording.resume`'s own docstring); this pins the CLI surface
+ itself rather than only the underlying function's signature.
+ """
+ with pytest.raises(SystemExit):
+ main(["resume", "--config", "some-config.yaml", "--max-frames", "9"])
+
+
+def test_resume_requires_checkpoint(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit):
+ main(["resume", "--max-frames", "9"])
+
+ assert "--checkpoint" in capsys.readouterr().err
+
+
+def test_resume_requires_max_frames(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit):
+ main(["resume", "--checkpoint", "checkpoints/checkpoint_00000006.pt"])
+
+ assert "--max-frames" in capsys.readouterr().err
+
+
+def test_resume_prints_a_summary(capsys: pytest.CaptureFixture[str]) -> None:
+ with patch("pyflow.__main__.resume") as mock_resume:
+ mock_resume.return_value = SimpleNamespace(
+ checkpoint_frames=[9, 12], output_dir=Path("checkpoints")
+ )
+ main(["resume", "--checkpoint", "checkpoints/checkpoint_00000006.pt", "--max-frames", "12"])
+
+ captured = capsys.readouterr()
+ assert "2" in captured.out
+ assert "checkpoints" in captured.out
+
+
def test_generate_config_with_no_output_prints_to_stdout(
capsys: pytest.CaptureFixture[str],
) -> None:
@@ -235,6 +375,10 @@ def test_generate_config_with_no_output_prints_to_stdout(
"time_unit": "s",
"time_scale": 1.0,
},
+ "recording": {
+ "output_dir": "checkpoints",
+ "checkpoint_interval": 100,
+ },
}
@@ -258,5 +402,6 @@ def test_generate_config_with_output_writes_file_and_prints_nothing(
"fluid",
"numerics",
"units",
+ "recording",
]
assert written["mesh"]["extent"] == list(PyFlowConfig().mesh.extent)
diff --git a/tests/unit/test_recording.py b/tests/unit/test_recording.py
new file mode 100644
index 0000000..6895bcf
--- /dev/null
+++ b/tests/unit/test_recording.py
@@ -0,0 +1,240 @@
+"""Unit tests for pyflow.recording (TASK-045, Stage 8, Recording &
+Playback) -- the headless, no-`rendering`-import loop that writes
+periodic checkpoints.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+import torch
+
+from pyflow.checkpoint import read_checkpoint
+from pyflow.recording import NothingToRecordError, NothingToResumeError, record, resume
+
+_DECLARED_FIELD_CONFIG = """\
+mesh:
+ extent: [4, 4]
+ spacing: [0.25, 0.25]
+
+numerics:
+ timestep: 0.01
+ boundary_conditions:
+ north:
+ type: periodic
+ south:
+ type: periodic
+ east:
+ type: periodic
+ west:
+ type: periodic
+
+fields:
+ - name: smoke
+ initial_condition: sinusoidal_mode
+"""
+
+_VELOCITY_ONLY_CONFIG = """\
+mesh:
+ extent: [4, 4]
+ spacing: [0.25, 0.25]
+
+numerics:
+ timestep: 0.01
+ boundary_conditions:
+ north:
+ type: dirichlet
+ field_values:
+ velocity.0: 1.0
+ velocity.1: 0.0
+ south:
+ type: dirichlet
+ east:
+ type: dirichlet
+ west:
+ type: dirichlet
+
+simulation:
+ velocity_solved: true
+
+fluid:
+ viscosity: 0.01
+"""
+
+_STATIC_CONFIG = """\
+mesh:
+ extent: [4, 4]
+ spacing: [0.25, 0.25]
+"""
+
+
+def test_record_writes_checkpoints_at_expected_frames_for_a_declared_field(
+ tmp_path: Path,
+) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+
+ result = record(config_file, max_frames=10, output_dir=output_dir, checkpoint_interval=5)
+
+ assert result.checkpoint_frames == [0, 5, 10]
+ assert result.final_frame_count == 10
+ assert result.output_dir == output_dir
+ checkpoint = read_checkpoint(output_dir / "checkpoint_00000010.pt")
+ assert checkpoint.frame_count == 10
+ assert checkpoint.fields["smoke"].shape == (16,) # 4x4 mesh
+
+
+def test_record_writes_checkpoints_for_a_velocity_only_config(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_VELOCITY_ONLY_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+
+ result = record(config_file, max_frames=6, output_dir=output_dir, checkpoint_interval=3)
+
+ assert result.checkpoint_frames == [0, 3, 6]
+ checkpoint = read_checkpoint(output_dir / "checkpoint_00000000.pt")
+ assert set(checkpoint.fields) == {"velocity.0", "velocity.1"}
+
+
+def test_record_raises_for_a_config_with_nothing_to_step(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_STATIC_CONFIG)
+
+ with pytest.raises(NothingToRecordError):
+ record(config_file, max_frames=5, output_dir=tmp_path / "checkpoints")
+
+
+def test_record_always_writes_a_final_checkpoint_even_off_interval(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+
+ result = record(config_file, max_frames=7, output_dir=output_dir, checkpoint_interval=5)
+
+ assert result.checkpoint_frames == [0, 5, 7]
+
+
+def test_record_falls_back_to_config_recording_section_when_not_overridden(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # `output_dir` isn't overridden here, so it resolves relative to the
+ # current working directory (the same relative-path convention every
+ # other path-like config value in this schema already follows) --
+ # `monkeypatch.chdir` keeps that resolution inside `tmp_path` rather
+ # than writing into the real repository while this test runs.
+ monkeypatch.chdir(tmp_path)
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(
+ _DECLARED_FIELD_CONFIG + "\nrecording:\n output_dir: from_config\n"
+ " checkpoint_interval: 4\n"
+ )
+
+ result = record(config_file, max_frames=8)
+
+ assert result.output_dir == Path("from_config")
+ assert result.checkpoint_frames == [0, 4, 8]
+
+
+# -- resume (extends TASK-045's own recording -- not replay or playback) --
+
+
+def test_resume_continues_from_a_checkpoint_and_writes_only_new_checkpoints(
+ tmp_path: Path,
+) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+ record(config_file, max_frames=6, output_dir=output_dir, checkpoint_interval=3)
+
+ result = resume(output_dir / "checkpoint_00000006.pt", max_frames=12, checkpoint_interval=3)
+
+ # Not [0, 3, 6, 9, 12] -- frames up to and including 6 already exist
+ # on disk from the `record()` call above; resuming must not re-write
+ # them (`checkpoint_frames` is only what *this* call wrote).
+ assert result.checkpoint_frames == [9, 12]
+ assert result.final_frame_count == 12
+ checkpoint = read_checkpoint(output_dir / "checkpoint_00000012.pt")
+ assert checkpoint.frame_count == 12
+
+
+def test_resume_produces_the_same_final_checkpoint_as_an_uninterrupted_record(
+ tmp_path: Path,
+) -> None:
+ """The invariant a checkpoint-then-resume pipeline exists to
+ guarantee: recording straight to frame 12 and recording to frame 6
+ then resuming to frame 12 must agree exactly at frame 12 -- the same
+ claim `tests/unit/test_recording_determinism.py` checks at the
+ `SimulationState` level, pinned here at the level a CLI user actually
+ observes (two checkpoint files).
+ """
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+
+ uninterrupted_dir = tmp_path / "uninterrupted"
+ record(config_file, max_frames=12, output_dir=uninterrupted_dir, checkpoint_interval=12)
+ control = read_checkpoint(uninterrupted_dir / "checkpoint_00000012.pt")
+
+ resumed_dir = tmp_path / "resumed"
+ record(config_file, max_frames=6, output_dir=resumed_dir, checkpoint_interval=6)
+ resume(resumed_dir / "checkpoint_00000006.pt", max_frames=12, checkpoint_interval=6)
+ resumed = read_checkpoint(resumed_dir / "checkpoint_00000012.pt")
+
+ torch.testing.assert_close(resumed.fields["smoke"], control.fields["smoke"], rtol=0, atol=0)
+
+
+def test_resume_defaults_output_dir_to_the_checkpoints_own_directory(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+ record(config_file, max_frames=6, output_dir=output_dir, checkpoint_interval=6)
+
+ result = resume(output_dir / "checkpoint_00000006.pt", max_frames=9, checkpoint_interval=9)
+
+ assert result.output_dir == output_dir
+ assert (output_dir / "checkpoint_00000009.pt").is_file()
+
+
+def test_resume_falls_back_to_the_checkpoints_own_recording_config_for_interval(
+ tmp_path: Path,
+) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG + "\nrecording:\n checkpoint_interval: 4\n")
+ output_dir = tmp_path / "checkpoints"
+ record(config_file, max_frames=4, output_dir=output_dir)
+
+ result = resume(output_dir / "checkpoint_00000004.pt", max_frames=12)
+
+ assert result.checkpoint_frames == [8, 12]
+
+
+def test_resume_rejects_max_frames_not_past_the_checkpoint(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+ record(config_file, max_frames=6, output_dir=output_dir, checkpoint_interval=6)
+
+ with pytest.raises(NothingToResumeError):
+ resume(output_dir / "checkpoint_00000006.pt", max_frames=6)
+
+ with pytest.raises(NothingToResumeError):
+ resume(output_dir / "checkpoint_00000006.pt", max_frames=3)
+
+
+def test_resume_needs_no_config_path_at_all(tmp_path: Path) -> None:
+ """The property `pyflow resume`'s own CLI leans on for having no
+ `--config` flag: a checkpoint is self-contained
+ (`checkpoint.py`'s own docstring), so `resume` never takes one --
+ checked here by calling it with only a checkpoint path and confirming
+ it works, not merely by the function signature lacking the parameter.
+ """
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_DECLARED_FIELD_CONFIG)
+ output_dir = tmp_path / "checkpoints"
+ record(config_file, max_frames=3, output_dir=output_dir, checkpoint_interval=3)
+ config_file.unlink() # the original config is gone; resume must not need it
+
+ result = resume(output_dir / "checkpoint_00000003.pt", max_frames=6, checkpoint_interval=3)
+
+ assert result.checkpoint_frames == [6]
diff --git a/tests/unit/test_recording_determinism.py b/tests/unit/test_recording_determinism.py
new file mode 100644
index 0000000..0c8ce9b
--- /dev/null
+++ b/tests/unit/test_recording_determinism.py
@@ -0,0 +1,196 @@
+"""The one genuinely new physical/numerical claim TASK-045 makes (Stage 8,
+Recording & Playback): resuming from a checkpoint reproduces the same
+trajectory as an uninterrupted run, bit-identically. Mirrors `tests/
+features/navier_stokes_timestep.feature`'s own determinism scenario style
+(`torch.testing.assert_close(..., rtol=0, atol=0)`), extended across a
+real serialize/deserialize/resume round trip -- new ground nothing before
+this task verified (that scenario only proves two in-process,
+never-serialized runs agree).
+
+Deliberately plain pytest, not a `.feature` file -- see `recording.py`'s
+own module docstring and TASK-045's own roadmap entry for why: this is a
+serialization-fidelity/mechanism claim, not a new physical prediction,
+the same category Stage 7's rendering-plumbing work was exempted for.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from pathlib import Path
+
+import torch
+
+from pyflow.checkpoint import read_checkpoint, restore_simulation_state
+from pyflow.configuration import config_from_dict, load_config
+from pyflow.engine.mesh import StructuredCartesianMesh
+from pyflow.engine.scalar_field import ScalarField
+from pyflow.recording import record
+from pyflow.simulation_run import (
+ SimulationState,
+ advance_simulation_state,
+ assembled_numerics_for,
+ build_simulation_state,
+)
+
+_CONFIG_TEXT = """\
+mesh:
+ origin: [0.5, -1.0]
+ extent: [5, 4]
+ spacing: [0.2, 0.3]
+
+numerics:
+ timestep: 0.01
+ boundary_conditions:
+ north:
+ type: periodic
+ south:
+ type: periodic
+ east:
+ type: periodic
+ west:
+ type: periodic
+
+fields:
+ - name: smoke
+ initial_condition: sinusoidal_mode
+"""
+
+# `_CONFIG_TEXT` above never sets `simulation.velocity_pattern`, so its
+# prescribed velocity field -- the one thing `restore_simulation_state`
+# reconstructs from the checkpoint's embedded config rather than reads
+# from the checkpoint's own tensors -- is all zero. A reconstruction bug
+# that always produced zero velocity regardless of config would still
+# pass every test built on `_CONFIG_TEXT` alone, since zero happens to be
+# the correct answer there too (`docs/practices.md`'s "distinct factors"
+# rule: verify a conversion, or here a reconstruction, where its inputs
+# are not degenerate). This fixture prescribes a real, nonzero velocity
+# instead, so a wrong reconstruction changes the outcome rather than
+# accidentally agreeing with it -- see the two tests below that use it.
+_CONFIG_TEXT_WITH_PRESCRIBED_VELOCITY = (
+ _CONFIG_TEXT
+ + """
+simulation:
+ velocity_pattern: uniform
+ velocity: [1.0, 0.5]
+"""
+)
+
+_CHECKPOINT_FRAME = 12
+_FINAL_FRAME = 20
+
+
+def _smoke_values(state: SimulationState) -> torch.Tensor:
+ smoke = state.fields["smoke"]
+ assert isinstance(smoke, ScalarField)
+ return smoke.values.clone()
+
+
+def _control_trajectory(config_file: Path) -> torch.Tensor:
+ """An uninterrupted run to `_FINAL_FRAME`, no recording at all."""
+ config = load_config(config_file)
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ numerics = assembled_numerics_for(config)
+
+ built_state = build_simulation_state(mesh, config)
+ assert built_state is not None
+ state: SimulationState = built_state
+ for _ in range(_FINAL_FRAME):
+ state = advance_simulation_state(state, numerics, config.numerics.timestep)
+ return _smoke_values(state)
+
+
+def _resumed_trajectory(config_file: Path, output_dir: Path) -> torch.Tensor:
+ """A checkpointed run: record to `_CHECKPOINT_FRAME`, reload, resume
+ stepping to `_FINAL_FRAME`.
+ """
+ record(
+ config_file,
+ max_frames=_CHECKPOINT_FRAME,
+ output_dir=output_dir,
+ checkpoint_interval=_CHECKPOINT_FRAME,
+ )
+ checkpoint = read_checkpoint(output_dir / f"checkpoint_{_CHECKPOINT_FRAME:08d}.pt")
+ _mesh, numerics, state = restore_simulation_state(checkpoint)
+ for _ in range(_FINAL_FRAME - _CHECKPOINT_FRAME):
+ state = advance_simulation_state(state, numerics, checkpoint.config.numerics.timestep)
+ return _smoke_values(state)
+
+
+def test_resuming_from_a_checkpoint_reproduces_an_uninterrupted_runs_trajectory(
+ tmp_path: Path,
+) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_CONFIG_TEXT)
+
+ control = _control_trajectory(config_file)
+ resumed = _resumed_trajectory(config_file, tmp_path / "checkpoints")
+
+ torch.testing.assert_close(resumed, control, rtol=0, atol=0)
+
+
+def test_resuming_reproduces_trajectory_with_a_nonzero_prescribed_velocity(
+ tmp_path: Path,
+) -> None:
+ """The same claim as the test above, on
+ `_CONFIG_TEXT_WITH_PRESCRIBED_VELOCITY` -- see that constant's own
+ comment for why a nonzero prescribed velocity is what actually pins
+ `restore_simulation_state`'s reconstruction, rather than only
+ exercising it.
+
+ **Confirmed to have the teeth the test above lacks**: with
+ `restore_simulation_state` mutated to overwrite its reconstructed
+ `velocity_field` with an all-zero one after building it, the test
+ above (zero-velocity fixture) stayed green while this one failed,
+ 20/20 mismatched elements -- reverted once confirmed, per this
+ project's own mutation-testing discipline.
+ """
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_CONFIG_TEXT_WITH_PRESCRIBED_VELOCITY)
+
+ control = _control_trajectory(config_file)
+ resumed = _resumed_trajectory(config_file, tmp_path / "checkpoints")
+
+ torch.testing.assert_close(resumed, control, rtol=0, atol=0)
+
+
+def test_config_round_trip_reconstructs_a_nonzero_prescribed_velocity_field_bit_identically(
+ tmp_path: Path,
+) -> None:
+ """The mechanism the two tests above rely on, isolated: a "passive"
+ mode `SimulationState.velocity_field` is never checkpointed at all
+ (`checkpoint.py`'s own docstring) because it is a pure function of
+ `config.simulation.velocity_pattern`/`velocity` and the mesh, not
+ evolved state -- so round-tripping the config through the exact
+ serialization a checkpoint uses (`dataclasses.asdict`/
+ `config_from_dict`, not the file itself) and rebuilding from it must
+ reproduce the *configured* velocity exactly, not a plausible
+ approximation of it.
+
+ **Checked against a hand-computed expected tensor, not against a
+ second `build_simulation_state` call on the same config** -- a first
+ draft compared the round-tripped reconstruction to a fresh build from
+ `config` itself, and mutation testing found that blind: both calls
+ share every line of `config_from_dict`/`_config_from_raw`
+ (`configuration/loader.py`), so a mutation that made
+ `simulation.velocity` silently fall back to its schema default
+ (`(1.0, 0.0)`) broke both sides identically and the comparison still
+ passed. Comparing against a value computed independently of any
+ PyFlow parsing code is what makes a shared-code bug visible instead
+ of invisible to a differential check.
+ """
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(_CONFIG_TEXT_WITH_PRESCRIBED_VELOCITY)
+ config = load_config(config_file)
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+
+ round_tripped_config = config_from_dict(dataclasses.asdict(config))
+ reconstructed = build_simulation_state(mesh, round_tripped_config)
+ assert reconstructed is not None
+ assert reconstructed.velocity_field is not None
+
+ # `_CONFIG_TEXT_WITH_PRESCRIBED_VELOCITY` sets `velocity: [1.0, 0.5]`
+ # directly -- this is that literal value, not derived from any code
+ # under test, broadcast across every cell (a uniform pattern's own
+ # definition: the same vector everywhere).
+ expected = torch.tensor([1.0, 0.5], dtype=torch.float64).repeat(mesh.num_cells, 1)
+ torch.testing.assert_close(reconstructed.velocity_field.values, expected, rtol=0, atol=0)
diff --git a/tests/unit/test_simulation_run.py b/tests/unit/test_simulation_run.py
new file mode 100644
index 0000000..09d92bd
--- /dev/null
+++ b/tests/unit/test_simulation_run.py
@@ -0,0 +1,114 @@
+"""Unit tests for pyflow.simulation_run (TASK-045, Stage 8, Recording &
+Playback) -- the simulation-state construction/advance logic extracted
+from `bootstrap.py`'s two live-rendering paths so `recording.py`'s
+headless one can share it. `tests/unit/test_bootstrap.py` and every
+golden-demo/feature scenario touching either live path are this
+refactor's own real regression coverage (unmodified, still green); these
+tests cover the extracted functions directly, in isolation.
+"""
+
+from __future__ import annotations
+
+from pyflow.configuration.schema import FieldConfig, MeshConfig, PyFlowConfig, SimulationConfig
+from pyflow.engine.mesh import StructuredCartesianMesh
+from pyflow.engine.numerics.assembly import assemble_numerics
+from pyflow.rendering.mesh_visualization import mesh_bounding_box
+from pyflow.simulation_run import (
+ _domain_bounds,
+ advance_simulation_state,
+ build_simulation_state,
+ velocity_field_from_state,
+)
+
+_MESH_CONFIG = MeshConfig(origin=(0.5, -1.0), spacing=(0.2, 0.3), extent=(5, 4))
+
+
+def test_domain_bounds_matches_mesh_bounding_box() -> None:
+ """`_domain_bounds` deliberately doesn't import `mesh_bounding_box`
+ (it would drag `pygfx` into `recording.py`'s own import chain -- see
+ `simulation_run.py`'s own module docstring), so this is the
+ permanent regression test proving the two stay numerically identical
+ for a `StructuredCartesianMesh` -- not just verified once, ad hoc,
+ while designing this.
+ """
+ mesh = StructuredCartesianMesh.from_config(_MESH_CONFIG)
+ assert _domain_bounds(_MESH_CONFIG) == mesh_bounding_box(mesh)
+
+
+def test_build_simulation_state_returns_none_for_a_static_config() -> None:
+ config = PyFlowConfig(mesh=_MESH_CONFIG)
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+
+ assert build_simulation_state(mesh, config) is None
+
+
+def test_build_simulation_state_is_passive_mode_for_a_declared_unsolved_field() -> None:
+ config = PyFlowConfig(
+ mesh=_MESH_CONFIG, fields=[FieldConfig(name="smoke", initial_condition="gaussian_blob")]
+ )
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+
+ state = build_simulation_state(mesh, config)
+
+ assert state is not None
+ assert state.mode == "passive"
+ assert state.velocity_field is not None
+ assert set(state.fields) == {"smoke"}
+
+
+def test_build_simulation_state_is_solved_mode_for_a_declared_solved_field() -> None:
+ config = PyFlowConfig(
+ mesh=_MESH_CONFIG,
+ fields=[FieldConfig(name="smoke", initial_condition="gaussian_blob")],
+ simulation=SimulationConfig(velocity_solved=True),
+ )
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+
+ state = build_simulation_state(mesh, config)
+
+ assert state is not None
+ assert state.mode == "solved"
+ assert state.velocity_field is None
+ assert set(state.fields) == {"smoke", "velocity.0", "velocity.1"}
+
+
+def test_build_simulation_state_is_solved_mode_for_velocity_only() -> None:
+ config = PyFlowConfig(mesh=_MESH_CONFIG, simulation=SimulationConfig(velocity_solved=True))
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+
+ state = build_simulation_state(mesh, config)
+
+ assert state is not None
+ assert state.mode == "solved"
+ assert set(state.fields) == {"velocity.0", "velocity.1"}
+
+
+def test_advance_simulation_state_passive_mode_advances_declared_field() -> None:
+ config = PyFlowConfig(
+ mesh=_MESH_CONFIG, fields=[FieldConfig(name="smoke", initial_condition="gaussian_blob")]
+ )
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ numerics = assemble_numerics(
+ config.numerics, config.fluid.diffusion_coefficient, {}, (0, 0), {}
+ )
+ state = build_simulation_state(mesh, config)
+ assert state is not None
+ before = state.fields["smoke"].values.clone() # type: ignore[attr-defined]
+
+ advanced = advance_simulation_state(state, numerics, config.numerics.timestep)
+
+ assert advanced.mode == "passive"
+ after = advanced.fields["smoke"].values # type: ignore[attr-defined]
+ assert not before.equal(after)
+
+
+def test_velocity_field_from_state_reassembles_the_named_vector_field() -> None:
+ config = PyFlowConfig(mesh=_MESH_CONFIG, simulation=SimulationConfig(velocity_solved=True))
+ mesh = StructuredCartesianMesh.from_config(config.mesh)
+ state = build_simulation_state(mesh, config)
+ assert state is not None
+
+ velocity = velocity_field_from_state(state)
+
+ assert velocity.name == "velocity"
+ assert velocity.mesh is mesh
diff --git a/tools/generators/generate_config_template.py b/tools/generators/generate_config_template.py
index 431fbff..add73e0 100644
--- a/tools/generators/generate_config_template.py
+++ b/tools/generators/generate_config_template.py
@@ -92,6 +92,12 @@
"time are labelled and scaled on screen. Does not affect the "
"simulation itself, only how its numbers are displayed."
),
+ "recording": (
+ "Headless checkpoint recording (Stage 8, Recording & Playback) -- "
+ "read only by `pyflow record`, never by `pyflow run`. The same "
+ "config file behaves identically under `pyflow run` whether or "
+ "not this section is set."
+ ),
}
# One entry per leaf field, keyed by dotted path from PyFlowConfig.
@@ -388,6 +394,17 @@
"simulation time unit is worth. 1.0 (default) displays the raw "
"simulation number unchanged. Invalid: zero or negative."
),
+ "recording.output_dir": (
+ "Valid: any non-empty string -- where `pyflow record` writes "
+ "checkpoint files, relative to the current working directory. "
+ "Invalid: a non-string value, or an empty string."
+ ),
+ "recording.checkpoint_interval": (
+ "Valid: a positive integer -- how many frames pass between "
+ "checkpoints (a checkpoint is always written at frame 0 and at "
+ "the run's final frame too, regardless of this value). Invalid: "
+ "zero or negative."
+ ),
}