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." + ), }