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
85 changes: 49 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

## Project Status

**Current Version:** 0.0.1no release has been made.
**Current Version:** 0.1.0the MVP release, cut 2026-08-29 when Stage 5 closed (`docs/planning/releases.md`).

PyFlow has completed **Stage 4 (First Numerical Methods)** and has not
yet begun Stage 5 (First Fluid Solver). Stage 0 built the engineering
PyFlow has completed **Stage 5 (First Fluid Solver)** -- its MVP -- and
has not yet begun Stage 6 (Additional Physical Fields). Stage 0 built
the engineering
foundations; Stage 1 added the first real engine code -- a
`CoordinateSystem`, a `Mesh` with a structured Cartesian implementation,
and a mesh visualiser you can zoom and pan; Stage 2 added `Field` and
Expand All @@ -23,11 +24,12 @@ non-physical reference implementation; Stage 4 gave each of those six
interfaces its first real, physically meaningful implementation
(`FirstOrderUpwindAdvection`, `CentralDifferenceDiffusion`, `RK4Integrator`,
`ConjugateGradientSolver`, `PISO`, `Dirichlet`/`Neumann`/periodic boundary
conditions) and, with them, PyFlow's first live-stepping simulation --
see the Passive Scalar Transport demo below. Stage 5 is what solves
incompressible flow for real (a coupled velocity/pressure system);
today's numerical schemes are individually real but not yet assembled
into that solve. See `docs/planning/roadmap.md` for the per-task status
conditions) and, with them, PyFlow's first live-stepping simulation;
Stage 5 assembled those schemes into a real coupled velocity/pressure
solve (`navier_stokes_step`, a genuinely multi-pass `PISO`, pressure
solved from the incompressibility constraint) and, with it, PyFlow's
MVP -- see the Lid-Driven Cavity demo below. See
`docs/planning/roadmap.md` for the per-task status
and each stage's exit audit, and `docs/implementation/golden-demos.md`
for what each stage's demonstration proves.

Expand Down Expand Up @@ -124,10 +126,14 @@ need to find it.

## Current Phase

Stage 5First Fluid Solver -- not yet started (Stage 4 closed
2026-08-28, PR #38).
Stage 6Additional Physical Fields -- not yet started (Stage 5 closed
2026-08-29, PR #47).

Stages 0 through 4 are complete, each closed against its own written
**Stage 5 is the MVP** (`docs/implementation/mvp.md`): PyFlow now solves
incompressible Navier-Stokes end to end, and the Lid-Driven Cavity
golden demo renders a *solved* velocity field live.

Stages 0 through 5 are complete, each closed against its own written
completion criteria (`docs/planning/roadmap.md`):

- Stage 0 — planning system, capability map, repository structure,
Expand All @@ -154,33 +160,40 @@ completion criteria (`docs/planning/roadmap.md`):
`PISO`, and Dirichlet/Neumann/periodic boundary conditions), plus the
simulation-stepping mechanism that drives a live `pyflow run`
(`engine/simulation.py`), demonstrated in the Passive Scalar Transport
golden demo. Individually real numerics, not yet the coupled
velocity/pressure solve -- that's Stage 5.

Stage 5 will solve incompressible flow: a coupled velocity/pressure
system built from Stage 4's now-real numerical schemes. **Its completion
criteria were written on 2026-08-28, before its first task started**
(`docs/planning/status.md` has the live count), per the standing rule
every stage since Stage 2 has followed
-- including the reconciliation against `docs/implementation/mvp.md`'s
own Definition of Done that Stage 5 has owed since 2026-08-22, since
this is the stage that defines the MVP. Seven design questions were
raised while drafting them -- five of the seven gaps in the current
code, verified against it rather than anticipated -- and six were
decided the same day. The seventh (what carries the momentum
coefficients a converging pressure-correction loop needs) is
deliberately left open for TASK-033 to answer with measurements, since
TASK-027 already showed what deciding that one from an armchair costs.
Stage 5 starts with TASK-041 (Fluid Configuration Section), split out
from TASK-031 once those answers made it clear that a task about
velocity transport had quietly acquired the project's first breaking
configuration change.

Try the most recent demonstration -- a scalar blob advected and
diffused across a periodic domain, stepped live:
golden demo. Individually real numerics, not yet assembled into the
coupled velocity/pressure solve -- that was Stage 5's own job.
- Stage 5 — the coupled velocity/pressure solve, and PyFlow's MVP.
Velocity became a transported field like any other (one `ScalarField`
per component), pressure became a field *solved* from the
incompressibility constraint rather than transported, `PISO` became
genuinely multi-pass, and `navier_stokes_step` assembled the three
into one incompressible timestep -- validated against Couette flow's
exact linear profile, Ghia, Ghia & Shin (1982)'s tabulated cavity
profiles at Re = 100 under mesh refinement, and Taylor-Green vortex
decay with its own negative control. Two golden demos: Lid-Driven
Cavity and Heat Diffusion. **Thirteen completion criteria, eight of
which its exit audit found overstated and corrected** -- see that
stage's own status section in `docs/planning/roadmap.md`.

Stage 6 will add four more transported physical fields (temperature,
density, humidity, passive tracers) on the claim Stage 5 exists to make
testable: that nothing in the engine special-cases any particular field.
**Its completion criteria are due when it opens, not now**, per the
standing rule every stage since Stage 2 has followed
(`docs/planning/status.md` is the live view of where things stand). One
criterion is already determined by the stage's own goal and should
survive into them: its
tasks must add no new machinery. "Demonstrate field-centric
architecture" is falsified, not evidenced, by four tasks that each need
engine changes to land -- so a large crop of new step definitions in
Stage 6 is itself evidence against the stage's own claim, and worth
reporting as a finding rather than absorbing quietly.

Try the most recent demonstration -- the lid-driven cavity, solved and
rendered live, one real Navier-Stokes timestep per frame:

```bash
uv run python -m pyflow run --config examples/golden-demos/passive_scalar_transport.yaml
uv run python -m pyflow run --config examples/golden-demos/lid_driven_cavity.yaml
```

---
Expand Down
18 changes: 17 additions & 1 deletion adr/ADR-003-modular-numerical-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ components this ADR names). **A real interface change again, the second
since Time Integration's**: `PressureCoupling.correct` gained a second
parameter, `dt`, recorded as `adr/ADR-009-pressure-coupling-dt.md`.
**A genuine, honestly-scoped limitation, not a hidden one**: `PISO`
performs a single correction pass, verified to measurably and boundedly
performed a single correction pass, verified to measurably and boundedly
reduce a manufactured field's divergence -- not the full multi-pass Issa
algorithm, because PyFlow's collocated mesh needs Rhie-Chow interpolation
(and the momentum-equation coefficients this task's own interface has no
Expand All @@ -93,6 +93,22 @@ session, to belong to Stage 5 TASK-033 instead -- full reasoning:
later task must say so when drafted", the standing rule this finding
produced.

**That limitation is resolved, not merely rescoped** (TASK-033, Stage 5,
2026-08-29): `PISO` is genuinely multi-pass under the same registered
name, again with no edit to `test_pressure_coupling_contract.py`'s
existing test bodies and no widening of `PressureCoupling.correct`'s own
signature -- the corrector loop's two tunables became the strategy's own
constructor arguments, the shape `ConjugateGradientSolver` had already
established. The momentum-equation coefficient Rhie-Chow needs turned
out to be `a_P = V/dt`, PyFlow's explicit RK4 predictor having no other
contribution to it. `docs/architecture/icds.md`'s
Pressure-Velocity Coupling entry carries the full record. **Added
2026-08-29 by the Stage 5 exit audit**, which found this ADR still
describing `PISO` in the present tense as single-pass a day after that
stopped being true -- this document tracks each of the six components'
realisation task by task, so a component gaining a *second* real
implementation belongs here as much as its first did.

**Boundary Condition's Dirichlet half followed the next day** (TASK-028,
Stage 4, 2026-08-28): `DirichletBoundaryCondition` replaced
`assembly.py`'s `_NullValueBoundaryCondition` under the same registered
Expand Down
130 changes: 130 additions & 0 deletions docs/CHANGELOG-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6272,3 +6272,133 @@ re-accumulate the duplication Stage 4's exit audit had to undo.
occurred exactly once before writing anything and printed its own
line-count delta -- the direct lesson from the `str.index` splice
earlier in this session that silently duplicated 3,900 lines.

---

## 29-08-2026

### Stage 5 closed: thirteen-criterion exit audit, eight overstated verdicts

The stage's own criteria were written on 2026-08-28, TASK-041 and
TASK-031..034 landed on 2026-08-28/29, and a first verdict table was
written as TASK-034 closed. **This is the audit that read that table
back**, run in a separate pass under `prompts/common/AUDITOR.md`'s
stance rather than by the session that wrote it. Eight of the thirteen
verdicts did not survive, and one of the eight was a defect in shipped
behaviour rather than only in a verdict. Full per-criterion record:
`docs/planning/roadmap.md`'s own Stage 5 status section -- not restated
here, per P-011.

**Seven of the eight failures share one shape, and that is the finding
worth keeping.** Every one of them was a *later clause* of a criterion whose
first clause was genuinely met: Criterion 13 names two substitution
checks and one existed; Criterion 6 enumerates five rejection surfaces
and four existed; Criterion 5's Ghia bullet ends by requiring a stated
absolute tolerance and none was stated; Criterion 3 requires the
fixture's initial divergence to be stated, which is the clause that
stops the two assertions after it being vacuous. None needed judgement
to find. Each is a sentence the criterion had already written down.
`docs/practices.md` gains "An exit audit reads each criterion to its
last sentence" as a standing rule -- the Stage 4 audit described this
same failure as its own observation ("arrived at by not reading the
second half of three sentences"), and describing it once did not stop it
recurring at twice the count one stage later.

### The eighth: a gap recorded in the wrong place

`simulation.velocity_solved` meant two different things depending on
whether an unrelated field was set. With a `scalar_pattern`,
`bootstrap.py`'s `_add_passive_scalar_transport` transported velocity's
components like any other scalar and never pressure-corrected them;
without one, `_add_solved_velocity_rendering` ran the real corrector
loop. A configuration saying "solved" produced a velocity that was not
incompressible, with no error and nothing rendered differently. Measured
rather than argued: maximum divergence 9.16 -> 8.24 -> 6.95 over 1, 10
and 40 frames uncorrected, against 2.30 -> 0.47 -> 0.057 corrected.

**It was never hidden.** TASK-031 and TASK-034 both wrote it down, in
`src/pyflow/configuration/CLAUDE.md` and in `bootstrap.py`'s own
docstrings, as "a real, pre-existing gap this task did not close" --
exactly the honesty the Blast Radius rule asks for, and not enough.
Criterion 12 ("everything this stage adds is configuration-driven,
validated, and documented -- not reachable only from a test fixture")
was marked met by a reader who had seen that note and never asked which
criterion it fell under. `docs/practices.md` gains "A gap recorded in a
`CLAUDE.md` is not recorded against a criterion": a `CLAUDE.md` note
records that somebody chose not to fix something; a note against a
criterion records something the stage cannot close over, and only one of
them gates. Fixed here rather than recorded again -- both live paths now
call `navier_stokes_step`, with a regression test whose bound was chosen
from both measurements above.

### Two checks that read like gates and were not

**`README.md`'s "Current Phase" section had gone a full stage stale for
the second time.** The Stage 2 audit found it claiming the project "is
beginning Stage 2"; this one found it claiming Stage 5 was "not yet
started" on the day Stage 5 closed, offering a demo two demos out of
date. Two failures of one sentence is not a reminder problem, so it is
now mechanical: `tools/generators/generate_status_report.py`'s drift
check reads that section and fails `make ci` when the stage it names is
not the roadmap's own first stage not marked complete. The roadmap still
decides; README is checked against it.

**The Gherkin-scenario drift check had been silently inert.** Its
pattern required a literal space before "are Gherkin scenarios", and a
later edit hard-wrapped `roadmap.md`'s line between the count and that
phrase. A pattern that stops matching reports *nothing to check*, which
reads exactly like a clean pass -- so the roadmap sat claiming 79
scenarios against a live 94, behind a gate reporting success, which is
precisely the failure mode `make check-scenarios` exists to prevent for
feature files, reproduced inside the checker. All three claim patterns
are now whitespace-tolerant, with a regression test naming the real
wrapped sentence.

### Four decisions, taken by the maintainer during the audit

- **The MVP trigger had fired and nothing noticed.**
`docs/planning/releases.md` named "Reaching the MVP" as one of three
concrete conditions for defining a release process, and instructed
that the document be updated the moment one was met. Stage 5 *is* the
MVP. **Decided: define the process and cut it.** PyFlow 0.1.0 --
semantic versioning with `MINOR` carrying stage completion, a release
cut only after a stage's exit audit completes, an annotated tag on a
two-platform-green commit, and nothing published anywhere yet, which
is itself recorded as a decision rather than an omission.
`docs/implementation/mvp.md` now records the MVP as reached;
`planning/data/releases.yaml` is populated for the first time, its own
stated trigger having fired. `docs/practices.md` gains "A checkable
trigger still needs somebody to check it": attach an obligation to an
event that happens on a schedule, not to a condition someone must
remember to evaluate.
- **`velocity_solved`'s two meanings: route it through, not reject or
record it.** Above.
- **Criterion 6's missing rejection surface: build it, not record it.**
A periodic boundary carrying a prescribed velocity, pressure, scalar
value or per-field override loaded cleanly and was then ignored
outright. `_validate_boundary_conditions_jointly` gains a fourth rule,
scoped to *non-default* values so every periodic configuration already
in the repository stays valid.
- **Criterion 7's degenerate-fixture exception: fix what can be fixed,
record what cannot.** Ghia's Re = 100 profiles are nondimensionalised
on a unit square at unit lid speed, so a non-square cavity or a
different lid speed cannot be compared to the reference at all -- two
forced exceptions, now stated in the feature file. The origin was
never forced, and the cavity now sits at a non-trivial one, which
turns the unit-cavity conversion in the vortex-centre check from an
identity into a real step. Verified by a full three-resolution run
before the change was committed: identical errors (0.1433, 0.0874,
0.0578), vortex centre 0.0010 from Ghia's own.
- **README's staleness: add a gating check, not a rule.** Above.

### What this audit did not change

No task was reopened. `docs/planning/capability-map.md` is deliberately
status-free and owed nothing; `planning/data/demos.yaml` and
`capabilities.yaml` already carried both Stage 5 demos with their
`validates` edges, verified directly rather than assumed. Criterion 12's
one deliberate exception -- run-length/steadiness staying a
validation-scenario constant rather than a config field -- was re-read
and stands, since neither golden demo claims to reach steady state and
the Ghia scenario runs against the engine directly, with no live-run
config surface for such a field to occupy.
14 changes: 14 additions & 0 deletions docs/architecture/icds.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,20 @@ pressure equation it produces has no solution at all
whole-configuration constraint, which validation should check across
boundaries rather than per-face.

**A fourth requirement, added 2026-08-29 by the Stage 5 exit audit: a
`periodic` face may prescribe nothing.** It wraps to its pair and
resolves no `BoundaryCondition` instance at all (see the `Choices:` note
above), so `velocity`, `pressure`, `scalar_value`, `scalar_gradient`,
`field_values` and `field_gradients` are read by nobody on such a face.
Before this, setting one loaded cleanly and was then ignored outright --
a silently discarded instruction, which is the failure mode the other
three requirements here exist to prevent. Checked against *non-default*
values only, since `velocity` and both scalar fields default to `0.0`
rather than to a "not prescribed" sentinel, and a rule phrased as "is set
at all" would reject every periodic configuration this repository already
ships. This is Stage 5 Completion Criterion 6's second named rejection
surface, which no Stage 5 task discharged.

**Expected behaviour:** each condition type supplies the face value
(Dirichlet), face gradient (Neumann), or wrapped-neighbour reference
(periodic) the interior advection/diffusion schemes need at that face.
Expand Down
29 changes: 23 additions & 6 deletions docs/architecture/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,29 @@ then, which stopped being true when TASK-017 landed on 2026-08-21).
`scalar_field_colors` (`src/pyflow/rendering/field_visualization.py`)
calls `field.values.numpy()` on a `torch.Tensor` and the resulting
colours reach the GPU through `gfx.Geometry`, which is exactly the
host-memory round trip described above. It is still not a *cost* worth
measuring: the conversion happens once at scene construction, over one
value per mesh cell, not per frame and not per timestep. The claim to
re-examine is the per-frame one, and the first thing that will make it
concrete is a field whose values change while the window is open --
Stage 4 onward, not this.
host-memory round trip described above.

**It is now a per-frame cost, not a one-off at scene construction**
(corrected 2026-08-29 by the Stage 5 exit audit). This paragraph used to
end "the conversion happens once at scene construction... not per frame
and not per timestep. The claim to re-examine is the per-frame one, and
the first thing that will make it concrete is a field whose values
change while the window is open -- Stage 4 onward, not this." Stage 4
*was* that: TASK-030's `_add_passive_scalar_transport` (2026-08-28)
rebuilds the rendered object every frame from freshly converted colours,
and Stage 5's `_add_solved_velocity_rendering` does the same for a
solved velocity field's arrows. **This is the third claim in this one
file to go stale about a stage that had already closed** (see the
locked-step paragraph above, and `docs/practices.md`'s "A stage's
documentation sweep is a grep, not a diff review") -- and the second to
survive an exit audit that had already corrected its neighbour.

Still not a cost worth *measuring* yet, for a different reason than
before: one value per mesh cell, at MVP mesh sizes (16x16 for the cavity
demo, 24x8 for heat diffusion), against a render loop already rebuilding
the whole `gfx.Mesh` each frame -- the conversion is not the expensive
part of that. The claim to re-examine is what happens at a mesh size
where it might be, and nothing has needed one yet (P-016).

## What wgpu/pygfx Does Not Provide

Expand Down
Loading
Loading