Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ with no seek mechanism at all, and two of the stage's own stated
deferrals (declared-field playback, partial-overlap cache reuse) plus
one gap nobody had named (checkpoint retention) were pulled forward
rather than left indefinitely deferred. TASK-049 closed the retention
gap and TASK-050 the cache-reuse one, both the same day; TASK-048/051
will close the other two, one branch each. Its live status, generated
gap, TASK-050 the cache-reuse one, and TASK-048 the scrub one, all the
same day; TASK-051 closes the last one. 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).
Expand Down Expand Up @@ -188,7 +188,7 @@ golden demo renders a *solved* velocity field live. **Stage 6 is the
proof that the engine underneath it is field-centric**: four named
physical fields, added by configuration.

Stages 0 through 7 are complete, and Stage 8 is reopened (seven of nine
Stages 0 through 7 are complete, and Stage 8 is reopened (eight of nine
criteria met, see below) -- each closed, or in Stage 8's case pending
re-closure, against its own written completion criteria
(`docs/planning/roadmap.md`):
Expand Down Expand Up @@ -254,7 +254,7 @@ re-closure, against its own written completion criteria
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 reopened -- seven of nine criteria
**Stage 8 (Recording & Playback) is reopened -- eight of nine criteria
met.** `pyflow record`/`pyflow resume`/`pyflow play` (TASK-045/046/047,
all 2026-09-07): record a run headlessly, resume it from any
checkpoint, or watch it back in a real window with live pause and speed
Expand All @@ -267,10 +267,12 @@ Status section for the full account). **Reopened 2026-09-09** for four
more criteria an audit found the Goal itself already promised: live
scrub (keyboard and a mouse-draggable bar), combined solved-velocity +
declared-field playback (grounded in Smoke Transport), opt-in checkpoint
retention, and partial-overlap cache reuse. The last two are done
(TASK-049, `--max-checkpoints-retained` on `record`/`resume`; TASK-050,
`pyflow play --cache DIR` now reuses a full-subset request from a wider
cached window with no re-simulation); TASK-048/051 are not yet built.
retention, and partial-overlap cache reuse. Three are done (TASK-049,
`--max-checkpoints-retained` on `record`/`resume`; TASK-050, `pyflow
play --cache DIR` now reuses a full-subset request from a wider cached
window with no re-simulation; TASK-048, Left/Right/Home/End and a
draggable scrub bar, verified against a real window to never pan the
camera underneath a drag); TASK-051 is not yet built.
Try the whole pipeline as it stands today:

```bash
Expand Down Expand Up @@ -312,7 +314,11 @@ the range asked for:
uv run python -m pyflow play --checkpoints-dir checkpoints --to-frame 500
```

Space pauses/resumes; `+`/`-` change playback speed live. Add
Space pauses/resumes; `+`/`-` change playback speed live; Left/Right
step one frame and Home/End jump to the loaded window's own edges;
drag the on-screen scrub bar to seek to any frame in between directly
(TASK-048) -- the drag never pans the camera underneath it, verified
against a real window, not just asserted. Add
`--cache cache` to materialize the window once and reuse it on a later
run without re-simulating -- a later request fully inside an
already-cached range reuses it too, sliced directly, even if its own
Expand Down
286 changes: 286 additions & 0 deletions docs/CHANGELOG-DESIGN.md

Large diffs are not rendered by default.

119 changes: 94 additions & 25 deletions docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,15 @@ 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): **1185 tests as of 2026-09-09**, up from 1182 the same day
(C1a/C1b): **1196 tests as of 2026-09-09**, up from 1185 the same day
(TASK-048, Live Scrub: 7 in `tests/unit/test_playback.py` for
`seek_relative`/`seek_to`/`frame_index_from_fraction` and
`PlaybackState.dragging`'s own default, 2 in `tests/unit/
test_rendering.py` for the new `screen_to_world` mapping, and 2 real
glfw-window integration tests in `tests/integration/
test_playback_cli.py` -- genuine injected keyboard seeks, and a genuine
dragged pointer sequence proving both the seek and that the camera
never pans during it), 1185 itself up from 1182 the same day
(TASK-050, Partial-Overlap Cache Reuse: 3 in `tests/unit/
test_replay.py` -- a full-subset request reused without re-simulation,
the right superset picked among several cached windows including a
Expand Down Expand Up @@ -11386,7 +11394,7 @@ them, which had not been drafted yet when these were written.
| 8. Checkpoint retention, opt-in, frame 0 never pruned | TASK-049 |
| 9. Partial-overlap (subset) cache reuse | TASK-050 |

### Status as of 2026-09-09: Stage 8 reopened, seven of nine criteria met
### Status as of 2026-09-09: Stage 8 reopened, eight of nine criteria met

**This stage was audited 2026-09-09, at the maintainer's own request,
against the suspicion that it "never actually went through a
Expand Down Expand Up @@ -11424,13 +11432,13 @@ what shipped rather than against the criteria that were meant to operationalise
| 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 | **Met** -- TASK-045 (record), TASK-046/047 (playback), against Lid-Driven Cavity |
| 6. Live scrub, keyboard and mouse | **Open** -- TASK-048, drafted, not started |
| 6. Live scrub, keyboard and mouse | **Met** -- TASK-048, verified against a real window |
| 7. Combined solved-velocity + declared-field playback | **Open** -- TASK-051, drafted, not started |
| 8. Checkpoint retention, opt-in, frame 0 never pruned | **Met** -- TASK-049, mutation-tested |
| 9. Partial-overlap (subset) cache reuse | **Met** -- TASK-050, mutation-tested |

Seven of nine criteria are met; the stage is **in progress**, not
complete, until TASK-048/051 close the other two. **One real
Eight of nine criteria are met; the stage is **in progress**, not
complete, until TASK-051 closes the last one. **One real
course-correction happened during the original build, recorded rather
than smoothed over**: TASK-045's own original Golden Demo choice (Heat
Diffusion) turned out incompatible with TASK-047's own scope decision
Expand Down Expand Up @@ -12217,8 +12225,7 @@ Completion Criterion 9 in full.

## TASK-048 — Live Scrub

**Status: Not started, drafted 2026-09-09.** Discharges Completion
Criterion 6.
**Status: Done, 2026-09-09.** Discharges Completion Criterion 6.

### Purpose

Expand All @@ -12229,10 +12236,9 @@ wherever `pyflow play` was launched.

### Dependencies

`playback.py` (TASK-047), for `PlaybackState`/`play()`. `window.py`'s
existing pointer-drag pan machinery (`_begin_pan`/`_update_pan`/
`_end_pan`) is the one real open risk this task starts from -- see
Design decisions below.
`playback.py` (TASK-047), for `PlaybackState`/`play()`. `pyflow.
rendering.window` gained one new function (`screen_to_world`); no
change to `RenderWindow` itself was needed (see Design decision 4).

### Design decisions, recorded here

Expand All @@ -12246,21 +12252,84 @@ Settled directly with the maintainer when this stage was reopened:
own start/end.**
3. **Mouse: a draggable scrub bar**, reaching any frame in the window
directly rather than only by repeated stepping.
4. **Open technical risk, to resolve empirically before building the
widget, not by assumption:** `RenderWindow.run()` already wires
generic camera-pan pointer handlers unconditionally on every
interactive run (`window.py`). A scrub-bar drag must not also pan the
camera underneath it. First step of this task: verify whether pygfx/
rendercanvas's event dict supports stopping propagation to a
same-canvas handler registered afterward -- the same "verify sign
conventions and event behaviour before relying on them" discipline
`rendering/CLAUDE.md`'s own pan/zoom entries already establish. If it
does not, the fallback is disabling `RenderWindow`'s built-in pan for
playback windows specifically, the same shape `close_keys=None`
already gives a caller for the close-key default.
4. **The open technical risk resolved in favour of the primary
approach, not the stated fallback.** `RenderWindow.run()` wires its
own camera-pan pointer handlers unconditionally at `rendercanvas`'s
default `order=0`. Read directly rather than assumed:
`rendercanvas.core.events.EventEmitter.emit` dispatches handlers
sorted by `order` then registration order, and stops the moment a
handler sets `event["stop_propagation"]` -- confirmed live (two
handlers at `order=-1`/`order=0` on the same canvas; the second
never ran once the first set it). `playback.py`'s own pointer
handlers register at `order=-1` for exactly this reason, and
`RenderWindow._update_pan` is already a no-op if `_begin_pan` never
ran, so suppressing `pointer_down` alone is enough -- `window.py`'s
own pan/close-key machinery needed no change at all. Full trail:
`docs/CHANGELOG-DESIGN.md`, 2026-09-09.
5. **A second empirical check, found necessary while building the bar
itself, not anticipated when this task was drafted:** placing a
thumb at an absolute world x and hit-testing an absolute pointer
position needs an absolute screen-to-world mapping, which nothing in
this codebase had -- `_update_pan` only ever tracked a *delta*.
`screen_to_world` (`window.py`) was verified against a real rendered
marker at a known world position before being trusted, the same
"verify sign conventions before relying on them" discipline
`rendering/CLAUDE.md`'s pan/zoom entries already establish for
exactly this class of formula. Full measurement:
`docs/CHANGELOG-DESIGN.md`, 2026-09-09.

Artifacts, Acceptance Criteria and Discharges are written when this task
is actually built, the same as every other entry in this file.
### Artifacts Produced

- `src/pyflow/rendering/window.py` -- `screen_to_world(camera,
logical_width, logical_height, screen_x, screen_y)`, the inverse of
what `_update_pan` tracks only as a delta.
- `src/pyflow/playback.py` -- `PlaybackState.dragging`; `seek_relative`,
`seek_to`, `frame_index_from_fraction` (pure, no rendering); `play()`
gained a scrub-bar track (`gfx.Line`) and thumb (`gfx.Points`,
rebuilt on index change the same "remove old, build new" way
`_rebuild_arrows` already is), Left/Right/Home/End in `_on_key`, and
`pointer_down`/`pointer_move`/`pointer_up` handlers registered at
`order=-1`.
- Tests: 8 in `tests/unit/test_playback.py` (`seek_relative`/`seek_to`
clamping at both ends, `frame_index_from_fraction`'s full range and
its own clamping, `PlaybackState.dragging`'s default), 2 in
`tests/unit/test_rendering.py` (`screen_to_world` against the four
corners and centre of a simple case, and against an off-centre,
aspect-expanded case matching the real-marker measurement above), 2
in `tests/integration/test_playback_cli.py` (a real glfw window with
genuine injected Left/Right/Home/End key events, and a real dragged
pointer sequence proving both that position seeks correctly *and*
that the camera does not move at all during the drag).

### Acceptance Criteria

- Left/Right/Home/End move `PlaybackState.position` to the expected
frame regardless of `paused`/`speed`, checked in isolation
(`tests/unit/test_playback.py`) and through the real keyboard wiring
against a genuinely running window
(`test_arrow_and_home_end_keys_seek_playback_live`) -- including the
real, previously-unstated finding that pause freezes wherever
ordinary autoplay already reached, not a reset to frame 0.
- A mouse drag starting on the scrub bar moves `PlaybackState.position`
to the frame its release point corresponds to, checked against a
real injected `pointer_down`/`pointer_move`/`pointer_up` sequence,
not only the pure `frame_index_from_fraction` function in isolation.
- **The same drag does not move `window.camera.local.position` at
all** -- checked directly before and after the drag in the same test,
the literal claim this task exists to prove.
- Verified by hand against the real CLI (root `CLAUDE.md`'s Feature
Verification rule), with an honest limit stated rather than glossed
over: the two live-window integration tests inject genuine
`rendercanvas` events against a real glfw window and observe real
state/pixel effects, the same technique
`test_space_pauses_playback_live` already established -- this is not
the same as a human's own hand on a real mouse, which nothing in this
environment could exercise. `pyflow play`'s own `--help` output is
unchanged (no new CLI flag), confirmed directly.

### Discharges

Completion Criterion 6 in full.

---

Expand Down
14 changes: 7 additions & 7 deletions docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ demand, not part of this file.

## Progress

**50/52 tasks complete (96%)** across 16 planned stages. For the full plan, including
**51/52 tasks complete (98%)** 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" : 50
"Not started" : 2
"Done" : 51
"Not started" : 1
```

### Milestones
Expand All @@ -40,12 +40,12 @@ pie showData

### Up next

**Stage 8 -- Recording & Playback** is next, starting with TASK-048 (Live Scrub), 1 more not yet started in this stage.
**Stage 8 -- Recording & Playback** is next, starting with TASK-051 (Combined Solved-Velocity + Declared-Field Playback).

## Live repository facts

- **49** `CLAUDE.md` files
- **1185** tests collected
- **1196** tests collected
- **144** Gherkin scenarios (`tests/features/*.feature`)

## Stages
Expand Down Expand Up @@ -153,7 +153,7 @@ pie showData

### Stage 8 -- Recording & Playback

**no status recorded** -- `███████░░░` 5/7 tasks; 9 criteria defined, no status line yet
**no status recorded** -- `█████████░` 6/7 tasks; 9 criteria defined, no status line yet

| Task | Status | Date | Artifact |
|------|--------|------|----------|
Expand All @@ -162,7 +162,7 @@ pie showData
| TASK-047 -- Interactive Playback (`pyflow play`) | Done | 2026-09-07 | `replay.py` |
| TASK-049 -- Checkpoint Retention Policy | Done | 2026-09-09 | `checkpoint.py` |
| TASK-050 -- Partial-Overlap Cache Reuse, Subset Only | Done | 2026-09-09 | `replay.py` |
| TASK-048 -- Live Scrub | Not started | | |
| TASK-048 -- Live Scrub | Done | 2026-09-09 | `playback.py` |
| TASK-051 -- Combined Solved-Velocity + Declared-Field Playback | Not started | | |

### Stage 9 -- Better Numerics
Expand Down
43 changes: 43 additions & 0 deletions src/pyflow/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,46 @@ rendered pixels (not only that `PlaybackState.paused` flips in
isolation) needed a way to reach both the window and the playback state
from outside `play()`, which its own `on_frame(window)` parameter and
this attribute together provide.

**Live scrub (TASK-048, Stage 8 reopening, added 2026-09-09): Left/
Right step one frame, Home/End jump to the loaded window's own edges,
and a draggable scrub bar reaches any frame in between directly.**
`PlaybackState.dragging`, `seek_relative`/`seek_to`/
`frame_index_from_fraction` are pure, no-rendering logic, the same
split `advance_playback_position`/`toggle_pause`/`increase_speed`/
`decrease_speed` already establish. The bar itself is a static
`gfx.Line` track plus a `gfx.Points` thumb rebuilt on index change the
same "remove old, build new" way `_rebuild_arrows` already is,
positioned below whatever else is shown using the same fixed-fraction-
of-mesh-height layout every other HUD margin in this codebase already
uses.

**The scrub bar's own pointer handlers register at `order=-1`, one
level above `RenderWindow.run`'s own camera-pan handlers (the default
`order=0`), and set `event["stop_propagation"]` when a drag starts on
the bar** -- so the same drag never also pans the camera underneath
it. Verified live before being relied on, not assumed from
`rendercanvas`'s own documentation: `rendercanvas.core.events.
EventEmitter.emit` sorts handlers by `order` then registration order
and stops dispatching once `stop_propagation` is set, confirmed with
two handlers on one canvas at `order=-1`/`order=0`. `RenderWindow.
_update_pan` was already a no-op if `_begin_pan` never ran, so
suppressing `pointer_down` alone is enough -- no change to `window.py`
needed, unlike the fallback this task was drafted expecting to need.
Placing the thumb and hit-testing a drag both needed an absolute
screen-to-world mapping nothing in this codebase had (`_update_pan`
only ever tracked a delta) -- `rendering.window.screen_to_world`, see
that file's own `CLAUDE.md` entry for how it was verified. Full trail
for both findings: `docs/CHANGELOG-DESIGN.md`, 2026-09-09.

**A real, previously-unstated finding, caught by the two live-glfw-
window integration tests this task added
(`tests/integration/test_playback_cli.py::
test_arrow_and_home_end_keys_seek_playback_live`,
`::test_dragging_the_scrub_bar_seeks_without_panning_the_camera`):
pausing freezes wherever ordinary autoplay (`speed`/frame) already
reached by the time the key lands, not a reset to frame 0.** Obvious in
hindsight, but the first draft of the keyboard test assumed the latter
and failed against real logged frame/position/paused values -- caught
by logging every frame rather than guessing at the right frame number
to assert against.
Loading
Loading