From 8442ca6c7393906bd16dace52825315f2a4a1416 Mon Sep 17 00:00:00 2001 From: Adam Clemens Date: Sat, 29 Aug 2026 21:28:09 +0100 Subject: [PATCH] Close Stage 5: exit audit, eight overstated verdicts, and PyFlow 0.1.0 A separate auditor-stance pass over the thirteen-criterion verdict table TASK-034's own session wrote. Eight of the thirteen did not survive, and one of the eight was a defect in shipped behaviour rather than only in a verdict. Per-criterion record: docs/planning/roadmap.md's new Stage 5 status section, in the place all four prior stages put theirs (the generated status report had been flagging its absence on its own). Seven failures share one shape: a later clause of a criterion whose first clause was genuinely met. Criterion 13 names two ADR-003 substitution checks and only the PressureCoupling one existed -- register_linear_solver was never called outside assembly.py, so a PISO constructing its own solver would have passed everything (confirmed by mutation). 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's initial-divergence clause -- the one that stops the two assertions after it being vacuous -- was true and asserted nowhere. The eighth was real: simulation.velocity_solved meant two different things. With a scalar_pattern, bootstrap transported velocity's components like any other scalar and never pressure-corrected them, so a config saying "solved" produced a velocity that was not incompressible (max divergence 9.16 -> 8.24 -> 6.95 over 1/10/40 frames, against 2.30 -> 0.47 -> 0.057 corrected). Both tasks that touched it wrote the gap down -- in two CLAUDE.md files, and against no criterion, which is how it survived Criterion 12 being marked met. Fixed rather than recorded, at the maintainer's direction: both live paths call navier_stokes_step; a periodic boundary may no longer prescribe anything (the fourth rule of _validate_boundary_conditions_ jointly, scoped to non-default values); the Ghia cavity runs at a non-trivial origin with a stated, measured error bound at its finest resolution; and docs/planning/releases.md gains a real release process, cutting PyFlow 0.1.0 as the MVP release now that reaching the MVP has fired its own long-recorded trigger. Two checks were not doing their job. README's Current Phase had gone a full stage stale for the second time (the Stage 2 audit found the first), so it is now gated: generate_status_report.py fails make ci when the stage README names is not the roadmap's own frontier. And the Gherkin-scenario drift rule had been silently inert since a line wrap split its pattern -- the roadmap sat claiming 79 scenarios against a live 94, behind a green gate. All three claim patterns are now whitespace-tolerant, with a regression test. Also fixes six stale documentation claims in files no Stage 5 task opened, adds the Navier-Stokes timestep sequence to sequences.md (whose only job is runtime sequences and which had no mention of pressure at all), and records three new rules in docs/practices.md. Co-Authored-By: Claude Opus 5 --- README.md | 85 +++++---- adr/ADR-003-modular-numerical-strategies.md | 18 +- docs/CHANGELOG-DESIGN.md | 130 +++++++++++++ docs/architecture/icds.md | 14 ++ docs/architecture/rendering.md | 29 ++- docs/architecture/sequences.md | 124 +++++++++++-- docs/engineering-principles.md | 12 +- docs/glossary.md | 4 +- docs/implementation/config-template.yaml | 27 ++- docs/implementation/golden-demos.md | 27 ++- docs/implementation/mvp.md | 36 ++++ docs/planning/CLAUDE.md | 13 +- docs/planning/backlog.md | 34 ++++ docs/planning/releases.md | 175 ++++++++++++------ docs/planning/roadmap.md | 165 +++++++++++++---- docs/planning/status.md | 9 +- docs/practices.md | 114 ++++++++++++ docs/repository-inventory.md | 4 +- docs/repository-manifest.md | 24 ++- planning/data/CLAUDE.md | 15 +- planning/data/releases.yaml | 25 +++ planning/model/entities.yaml | 17 +- pyproject.toml | 2 +- src/pyflow/__init__.py | 2 +- src/pyflow/bootstrap.py | 96 ++++++---- src/pyflow/configuration/CLAUDE.md | 50 ++++- src/pyflow/configuration/schema.py | 86 +++++++-- src/pyflow/engine/CLAUDE.md | 13 +- src/pyflow/engine/numerics/CLAUDE.md | 6 +- tests/features/lid_driven_cavity.feature | 15 ++ tests/features/navier_stokes_timestep.feature | 41 ++++ .../features/pressure_correction_loop.feature | 12 +- tests/unit/CLAUDE.md | 40 +++- tests/unit/test_bootstrap.py | 80 ++++++++ tests/unit/test_configuration.py | 66 +++++++ tests/unit/test_generate_status_report.py | 97 ++++++++++ tests/unit/test_navier_stokes_timestep.py | 142 +++++++++++++- tests/unit/test_pressure_correction_loop.py | 21 +++ tools/generators/CLAUDE.md | 18 ++ tools/generators/generate_config_template.py | 25 ++- tools/generators/generate_status_report.py | 84 ++++++++- uv.lock | 2 +- 42 files changed, 1708 insertions(+), 291 deletions(-) diff --git a/README.md b/README.md index ca1534a..8bd97f7 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ ## Project Status -**Current Version:** 0.0.1 — no release has been made. +**Current Version:** 0.1.0 — the 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 @@ -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. @@ -124,10 +126,14 @@ need to find it. ## Current Phase -Stage 5 — First Fluid Solver -- not yet started (Stage 4 closed -2026-08-28, PR #38). +Stage 6 — Additional 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, @@ -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 ``` --- diff --git a/adr/ADR-003-modular-numerical-strategies.md b/adr/ADR-003-modular-numerical-strategies.md index 66aa1fb..cd86518 100644 --- a/adr/ADR-003-modular-numerical-strategies.md +++ b/adr/ADR-003-modular-numerical-strategies.md @@ -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 @@ -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 diff --git a/docs/CHANGELOG-DESIGN.md b/docs/CHANGELOG-DESIGN.md index 48a3210..7f1dedb 100644 --- a/docs/CHANGELOG-DESIGN.md +++ b/docs/CHANGELOG-DESIGN.md @@ -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. diff --git a/docs/architecture/icds.md b/docs/architecture/icds.md index 4ff5d6b..bba1951 100644 --- a/docs/architecture/icds.md +++ b/docs/architecture/icds.md @@ -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. diff --git a/docs/architecture/rendering.md b/docs/architecture/rendering.md index 334ca49..3584335 100644 --- a/docs/architecture/rendering.md +++ b/docs/architecture/rendering.md @@ -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 diff --git a/docs/architecture/sequences.md b/docs/architecture/sequences.md index 816efd4..ebed145 100644 --- a/docs/architecture/sequences.md +++ b/docs/architecture/sequences.md @@ -184,6 +184,74 @@ state a rendered frame came from, the same "`bootstrap()` populates it, `RenderWindow` itself holds no simulation content" shape `assembled_numerics` already established (Section 3, below). +### Built today: one incompressible Navier-Stokes timestep + +**Built 2026-08-29, TASK-034 -- Stage 5's own assembled timestep, and +the sequence the two above are no longer the whole of.** Everything +before this subsection describes `step()`, which advances transported +fields through a velocity it treats as external input. A run that +*solves* for velocity (`simulation.velocity_solved: true`) does not call +`step()` from `on_frame` at all -- it calls +`simulation.navier_stokes_step`, which calls `step()` as its own +momentum predictor and then corrects the result. + +**Added by the Stage 5 exit audit, not by TASK-034 itself.** This +document's only stated job is "in what order do things actually happen +when PyFlow runs", and for a full day after the coupled solve landed it +contained no mention of pressure, predictor or corrector -- the exact +drift its own Maintenance section below asks a reader to grep for. + +```mermaid +sequenceDiagram + participant Caller + participant NS as simulation.navier_stokes_step() + participant Step as simulation.step() + participant PC as numerics.pressure_coupling + participant LS as numerics.linear_solver + + Caller->>NS: navier_stokes_step(fields, velocity_field_name, numerics, dt) + NS->>NS: assemble u/v components into a VectorField + Note over NS,Step: Predictor -- momentum advanced with no pressure term + NS->>Step: step(fields, current_velocity, numerics, dt) + Step-->>NS: predicted fields (velocity components and any scalars alike) + NS->>NS: reassemble the predicted components: provisional velocity + Note over NS,LS: Corrector -- a loop, not a single pass (TASK-033) + NS->>PC: correct(provisional_velocity, dt) + loop until max divergence <= tolerance, else raise + PC->>PC: Rhie-Chow-corrected divergence, recorded in last_divergence_history + PC->>LS: solve(poisson_matrix, -divergence / dt) + LS-->>PC: pressure correction + PC->>PC: pressure += correction; velocity -= dt * grad(correction) + end + PC-->>NS: (corrected_velocity, pressure) + NS-->>Caller: NavierStokesStepResult(fields, provisional, corrected, pressure) +``` + +**Three things this sequence makes visible that prose does not.** +`step()` is reused unchanged as the momentum predictor -- velocity's own +components go through the same `AdvectionScheme`/`DiffusionScheme`/ +`TimeIntegrator` path a transported scalar does, which is Stage 5 +Completion Criterion 1's whole claim. The corrector is a *loop* whose +per-pass divergence is recorded, not a single correction (`PISO`, +genuinely multi-pass since TASK-033). And `numerics.linear_solver` +reaches the timestep only through `numerics.pressure_coupling` -- never +called directly by `navier_stokes_step`, which is why proving the +*configured* solver is the one that runs needed its own substitution +scenario (`tests/features/navier_stokes_timestep.feature`, added by that +stage's exit audit). + +Pressure is never a member of `fields`: `step()` raises +`PressureFieldTransportError` if a `PressureField` appears there, and +`navier_stokes_step` returns the solved pressure alongside the fields +rather than among them. That is Criterion 2's "solved from the +constraint, not transported", expressed structurally. + +The live-run wiring is `bootstrap.py`'s `_add_solved_velocity_rendering` +-- the same `on_frame` seam `_add_passive_scalar_transport` attaches to +above, calling `navier_stokes_step` once per rendered frame and redrawing +the corrected velocity as arrows (`examples/golden-demos/ +lid_driven_cavity.yaml`). + --- ## 3. Data Flow: Where State Lives @@ -235,9 +303,24 @@ 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. Update this subsection with the real sequence once **TASK-034** -lands -- a note on that task's own roadmap entry asks for the same thing -in the same change. +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. --- @@ -305,14 +388,27 @@ 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. -Two subsections are deliberately marked **Planned** rather than omitted or -stated as fact: Section 2's live-loop wiring and Section 3's checkpointing. -Both are anchored to the specific roadmap task that will build them -(TASK-030, TASK-034) rather than left as an open-ended "future work," and -both of those tasks' own `docs/planning/roadmap.md` entries carry a note -asking for this document to be updated in the same change that lands them --- so the update is findable from the roadmap, not only from this -document's own memory. If either lands and this file wasn't updated in -the same change, that is exactly the kind of drift `docs/practices.md`'s -Blast Radius rule exists to catch: grep this file's own TASK-030/TASK-034 -mentions the next time either task is touched. +**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. + +**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 +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. diff --git a/docs/engineering-principles.md b/docs/engineering-principles.md index 1f57b10..552f7f9 100644 --- a/docs/engineering-principles.md +++ b/docs/engineering-principles.md @@ -29,8 +29,16 @@ Working software is more valuable than partially completed architecture. Every stage after Stage 0 must contain a working demonstration. (Reworded 2026-08-15 from "every release after Release 0". The intent is -unchanged; "release" was never the unit the project plans in, and PyFlow -has no release process. See `docs/glossary.md`.) +unchanged: "release" was never the unit the project plans in. That +rewording said "and PyFlow has no release process", which stopped being +true on 2026-08-29 when reaching the MVP triggered +`docs/planning/releases.md` -- corrected by the Stage 5 exit audit. +**The principle is unaffected, and the two now line up rather than +compete**: a release is cut when a stage closes and its exit audit +completes, so "every stage after Stage 0 must contain a working +demonstration" is also, in practice, what every release carries. Stage +remains the unit; release is downstream of it. See `docs/glossary.md` +and `docs/planning/releases.md`.) --- diff --git a/docs/glossary.md b/docs/glossary.md index a54df5d..290430e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -472,9 +472,9 @@ Levels run 0-10 and answer "what can PyFlow do once this is finished," where Sta A published increment of PyFlow, versioned in `pyproject.toml`. -Releases are currently the least developed of the project's three progression concepts: no release process is defined and the knowledge architecture has no entry specifying one. The project is at version 0.0.1 and has made no release. +Releases were the least developed of the project's three progression concepts until 2026-08-29, when reaching the MVP fired one of the three triggers `docs/planning/releases.md` had recorded for defining a process. **The project is at version 0.1.0, the MVP release, cut when Stage 5 closed.** The knowledge architecture still has no entry specifying this document, which is deliberate — its content is set by `docs/planning/backlog.md` E7, not by a KA content requirement. -`docs/planning/releases.md` records why that is a deliberate deferral rather than an oversight, and the concrete conditions that would trigger defining a process. (This entry said that file "is empty" until 2026-08-18; it was written on 2026-08-17 and the description was left stale — the two documents reference each other, so a change to either needs checking against the other.) +Here, a release is an annotated git tag on `main` at a commit whose CI is green on both platforms, cut when a stage closes *and its exit audit is complete*; `MINOR` carries stage completion and `MAJOR` stays 0 until PyFlow is willing to keep its public API stable. Nothing is published anywhere yet, which is itself a recorded decision rather than an omission. `docs/planning/releases.md` carries the process, the release history, and the two triggers that remain unmet. (This entry said that file "is empty" until 2026-08-18; it was written on 2026-08-17 and the description was left stale — the two documents reference each other, so a change to either needs checking against the other, which is also how this paragraph came to be a stage behind on 2026-08-29.) The recurring project rule about working demonstrations is stated in terms of Stages, not Releases (`docs/engineering-principles.md` P-004). Do not infer a release cadence from it; there is not one yet. diff --git a/docs/implementation/config-template.yaml b/docs/implementation/config-template.yaml index 729a25b..2e4e9d9 100644 --- a/docs/implementation/config-template.yaml +++ b/docs/implementation/config-template.yaml @@ -192,30 +192,39 @@ numerics: # boundary-*normal* component only, positive = outward. A face may # prescribe velocity or pressure, never both (see .pressure below); if # every one of the four faces prescribes a velocity, they must sum to - # zero net flux, weighted by each face's physical length. Invalid: - # prescribing both velocity and pressure on one face, or a nonzero net - # flux across all four. + # zero net flux, weighted by each face's physical length. On a + # periodic face, only null or 0.0 -- a periodic boundary wraps to its + # pair and reads no prescribed value. Invalid: prescribing both + # velocity and pressure on one face, a nonzero net flux across all + # four, or a nonzero velocity on a periodic face. velocity: 0.0 # Valid: null (not prescribed here) or a number. Mutually exclusive - # with .velocity above on the same face. + # with .velocity above on the same face, and must be null on a + # periodic face. Invalid: any number on a periodic face. pressure: null # Valid: any finite number -- the Dirichlet value a transported scalar # field is given at this face. Only read when type is "dirichlet"; - # harmless but unused otherwise. + # harmless but unused when "neumann", and must stay at its 0.0 default + # when "periodic", which reads no prescribed value at all. Invalid: a + # nonzero value on a periodic face. scalar_value: 0.0 # Valid: any finite number -- the Neumann gradient a transported # scalar field is given at this face. Only read when type is - # "neumann"; harmless but unused otherwise. + # "neumann"; harmless but unused when "dirichlet", and must stay at + # its 0.0 default when "periodic", which reads no prescribed value at + # all. Invalid: a nonzero value on a periodic face. scalar_gradient: 0.0 # Valid: a mapping of field name to a finite number -- a per-field # override of scalar_value above, e.g. {u: 1.0, v: 0.0} for a moving # lid's two velocity components. A field name absent from this mapping - # falls back to scalar_value. Only read when type is "dirichlet". - # Invalid: a non-finite value. + # falls back to scalar_value. Only read when type is "dirichlet", and + # must be empty when "periodic". Invalid: a non-finite value, or any + # entry on a periodic face. field_values: {} # Valid: a mapping of field name to a finite number -- field_values' # own Neumann counterpart, overriding scalar_gradient per field name. - # Only read when type is "neumann". Invalid: a non-finite value. + # Only read when type is "neumann", and must be empty when "periodic". + # Invalid: a non-finite value, or any entry on a periodic face. field_gradients: {} south: type: dirichlet diff --git a/docs/implementation/golden-demos.md b/docs/implementation/golden-demos.md index d5747b6..2cce986 100644 --- a/docs/implementation/golden-demos.md +++ b/docs/implementation/golden-demos.md @@ -79,10 +79,21 @@ functionality, since there isn't any yet. `glfw`) for a human actually watching it. Not the same demo `mvp.md`'s "golden demo exists" criterion refers to -- -that's the Initial Golden Demo below (Capability Level 1). Empty Window +that's Lid-Driven Cavity below (Capability Level 2), built by TASK-034 +and the reason `mvp.md` now records the MVP as reached. Empty Window exists purely to prove Stage 0's infrastructure works, before there is any simulation to demonstrate. +*(This sentence read "the Initial Golden Demo below (Capability Level +1)" until the Stage 5 exit audit, 2026-08-29 -- wrong in both halves +once TASK-034 landed: the section it points at was renamed to the demo +it actually built, and the lid-driven cavity is `implementation-plan.md` +Level 2, never Level 1. `planning/data/demos.yaml`'s own +`demo-lid-driven-cavity -> capability-level-2` edge had said so all +along, which is exactly the kind of disagreement between a graph edge +and a hand-restated fact that `adr/ADR-006-knowledge-graph-scope.md` +moved relationships into the graph to avoid.)* + ## Empty Mesh TASK-013's own golden demo (`docs/planning/roadmap.md`, "display an @@ -251,11 +262,15 @@ say so explicitly." - it runs headlessly via `--backend offscreen`, same as every other demo. -**The velocity field is prescribed, not solved.** Stage 5 is what -eventually solves Navier-Stokes for real (`PressureCoupling`, real -momentum equations); this demo's velocity is a fixed, uniform vector -from configuration, transporting the scalar the same way a wind field -transports smoke without itself being computed from the smoke. +**The velocity field is prescribed, not solved** -- for this demo, and +deliberately so. Stage 5 solved Navier-Stokes for real (TASK-034, +2026-08-29; see the Lid-Driven Cavity entry below for the demo that +renders a *solved* field), but this one's velocity stays a fixed, +uniform vector from configuration, transporting the scalar the same way +a wind field transports smoke without itself being computed from the +smoke. That is the whole point of it: it isolates transport from the +coupled solve, which is what makes it still worth running after the +coupled solve exists. ## Lid-Driven Cavity diff --git a/docs/implementation/mvp.md b/docs/implementation/mvp.md index 3c91000..0ba28fd 100644 --- a/docs/implementation/mvp.md +++ b/docs/implementation/mvp.md @@ -3,6 +3,42 @@ Per `docs/planning/knowledge-architecture.md` KA-031. Extracted from `docs/planning/implementation-plan.md` on 2026-08-15 into its own artifact. +## Status: reached 2026-08-29 + +**The MVP is built.** Stage 5 (`docs/planning/roadmap.md`) is the stage +that defines it, and its exit is this document's exit -- every item of +the Definition of Done below is discharged, item by item, in that +stage's own Completion Criterion 11 table, and every component and +validation case in the two sections above it is either shipped or +recorded there with the divergence stated. + +Concretely: `pyflow run --config examples/golden-demos/ +lid_driven_cavity.yaml` solves incompressible Navier-Stokes and renders +the *solved* velocity field live, one real `navier_stokes_step` per +frame. It is validated against Couette flow's exact linear profile, +against Ghia, Ghia & Shin (1982)'s tabulated Re = 100 centreline +profiles under mesh refinement, and against Taylor-Green vortex decay +with a negative control -- all as executable scenarios +(`adr/ADR-007-executable-acceptance-criteria.md`), not as prose. + +**Recorded 2026-08-29 by the Stage 5 exit audit, not by TASK-034.** This +document said nothing about being reached until then, which mattered +more than it might sound: `docs/planning/releases.md` names "Reaching +the MVP" as one of three concrete triggers for defining a release +process, and a reader checking that trigger against this document would +have found no answer here. That process now exists, and PyFlow 0.1.0 is +what it names. + +**What the MVP is not.** It is "correctness, understandability, and +architectural validation -- not maximum numerical accuracy" (below), and +the numbers bear that out: first-order upwind's numerical diffusion is +the dominant error term at MVP mesh resolutions, which is why Stage 5's +own Ghia criterion gates on *convergence under refinement* rather than a +fixed percentage. `docs/implementation/upgrade-paths.md` is where a less +diffusive scheme lands. + +--- + ## Intent The MVP of PyFlow is defined as the smallest implementation that validates diff --git a/docs/planning/CLAUDE.md b/docs/planning/CLAUDE.md index c825165..009115e 100644 --- a/docs/planning/CLAUDE.md +++ b/docs/planning/CLAUDE.md @@ -5,10 +5,15 @@ Planning artefacts: `backlog.md`, `roadmap.md`, `implementation-plan.md`, see below), and `knowledge-architecture.md` (the knowledge architecture spec). -`releases.md` (written 2026-08-17, E7) records that PyFlow has no -release process yet as a deliberate deferral -- concrete trigger -conditions, not an open-ended "eventually" -- not that the file is -empty; keep that distinction if this line is edited again. +`releases.md` (written 2026-08-17 as a recorded deferral, E7; rewritten +2026-08-29 with a real process) carries PyFlow's versioning scheme, what +a release is here, and the release history. **The deferral it used to +record ended when reaching the MVP fired one of its own three trigger +conditions**, which happened a day before anything noticed -- see that +file's own Current State section, and `docs/practices.md`'s "A checkable +trigger still needs somebody to check it". Its standing obligation is +now attached to a scheduled event: **update it whenever a stage closes**, +not when somebody thinks to re-evaluate a condition. The glossary is **not** here -- it is `docs/glossary.md`, per KA-005. diff --git a/docs/planning/backlog.md b/docs/planning/backlog.md index 6a82109..e1e9343 100644 --- a/docs/planning/backlog.md +++ b/docs/planning/backlog.md @@ -1176,6 +1176,20 @@ cite, immediately after they were written. (no longer describes the file as empty) updated in the same change. + **The deferral ended 2026-08-29** (Stage 5 exit audit, maintainer's + call): the second of those three triggers -- reaching the MVP -- + fired when TASK-034 landed, and `docs/planning/releases.md` was + rewritten with a real process (semantic versioning with `MINOR` + carrying stage completion, a release cut only after a stage's exit + audit, an annotated tag on a two-platform-green commit, nothing + published anywhere yet) plus a release history whose first row is + PyFlow 0.1.0. Worth recording against this item specifically: the + trigger fired a day before anything noticed, because a condition + written to be checkable still needs somebody to check it. The + three version-carrying files (`pyproject.toml`, + `src/pyflow/__init__.py`, `README.md`) and `docs/glossary.md`'s + "Release" entry moved in the same change. + ### E9 — Agent guidance (TASK-009, KA-038) - [x] **E9. Fill the placeholder `CLAUDE.md` files** (closed 2026-08-19, @@ -1975,6 +1989,26 @@ here.): The boundary-condition precondition named above is separately closed (the next bullet); what remains open here is the loop's own convergence, and it is scheduled rather than unassigned. + + **Closed 2026-08-29, TASK-033 and TASK-034 together.** `PISO` is + genuinely multi-pass, and the strong claim this bullet stated -- + the corrected velocity field is divergence-free to within solver + tolerance after every correction step -- is checked as a + *recorded per-iteration sequence*, not an end-state assertion + (`tests/features/pressure_correction_loop.feature`): the + sequence is non-increasing at every element, starts orders of + magnitude above the configured tolerance, ends at or below it, + and exhausting the iteration limit raises + `DivergenceDidNotConvergeError` rather than returning a + best-effort field. TASK-034 adds the velocity-side conservation + check this bullet's own sibling bullets have for advection and + diffusion: no single step increases total kinetic energy for an + inviscid, unforced, closed-domain flow, measured step by step. + **Closed by the Stage 5 exit audit, not by either task** -- both + landed leaving this bullet open, which is exactly the gap the + boundary-conditions bullet immediately below already names + (`docs/practices.md`'s "grep for a task's own identifier when it + closes"), recurring one stage later in the same list item. - **Boundary conditions** -- **done, not Stage 4 -- closed early by TASK-019 (Stage 3, 2026-08-23)**, found while drafting Stage 4's own Completion Criteria (2026-08-25): the global mass- diff --git a/docs/planning/releases.md b/docs/planning/releases.md index f3d479c..8a47e08 100644 --- a/docs/planning/releases.md +++ b/docs/planning/releases.md @@ -8,64 +8,123 @@ requirement. ## Current State -PyFlow has made **no release**. It is at version 0.0.1 -(`pyproject.toml`), and no release process -- what triggers one, how it -is versioned in practice, what gets published where -- is defined -anywhere in the repository. - -This is a **deliberate deferral, not an oversight**, for the same reason -`docs/planning/backlog.md` Part II gives for deferring -`CONTRIBUTING.md`/`CODE_OF_CONDUCT.md`/`SECURITY.md`: PyFlow is a -single-developer project with no external consumers yet, and a release -process exists to serve exactly the concerns (external consumers' -expectations of stability, a distribution channel, a support/versioning -contract) that do not yet apply. - -## Why Not Now - -`docs/glossary.md`'s "Release" entry already states the core reason -plainly: releases are "the least developed of the project's three -progression concepts" (alongside Stage and Capability Level), and the -project's actual working rhythm is Stage-based, not release-based -- -`docs/engineering-principles.md` P-004 was deliberately reworded from -"every release after Release 0" to "every stage after Stage 0" on -2026-08-15 specifically because "release" was never the unit PyFlow -plans or works in. Defining a release process now, ahead of any reason -to actually cut one, would be exactly the kind of premature structure -`docs/engineering-principles.md` P-016 (prefer reversible decisions -until understanding justifies commitment) and P-018 (implement the -simplest valid version of each layer) both argue against. - -## What Would Trigger Defining One - -Not an open-ended "eventually" -- concrete conditions, so this section -is checkable rather than aspirational: - -- **A first external consumer** -- anyone depending on PyFlow who isn't - actively developing it needs a stable, versioned thing to depend on, - which is exactly what a release process exists to provide. This is - also `docs/practices.md`'s Python-version-policy trigger for moving - from "periodic review" to "deliberate stability" -- the same event - changes both policies for the same underlying reason. -- **Reaching the MVP** (`docs/implementation/mvp.md`) -- the first point - at which PyFlow is a genuinely usable simulation someone outside active - development might want to run, rather than an in-progress engine. -- **A maintainer decision to publish** -- independent of either - condition above, the maintainer may simply decide a release is wanted - (e.g. to mark a milestone) before either triggers. - -When any of these happens, this document should be rewritten with an -actual process (versioning scheme, what artifact gets published, where, -and what "released" means for a Python package specifically -- most -likely PyPI, given the project's BSD-3-Clause licence and -scientific-Python-ecosystem alignment, `LICENSE`) -- not just its -trigger condition restated. Until then, this file's job is to make the -deferral explicit and checkable, per A3's requirement that no tracked -file stay empty. +**PyFlow 0.1.0, the MVP release.** Cut 2026-08-29, when Stage 5 closed +and `docs/implementation/mvp.md`'s Definition of Done was discharged +item by item (`docs/planning/roadmap.md`, Stage 5 Completion Criterion +11). + +Before that, this document recorded a deliberate deferral: no release, +no process, and three concrete conditions that would trigger writing +one. The second of them -- "Reaching the MVP... the first point at +which PyFlow is a genuinely usable simulation someone outside active +development might want to run" -- fired when TASK-034 landed. + +**It fired a day before this document noticed**, which is worth keeping +rather than smoothing over. The Maintenance section below said "update +this document... the moment any trigger condition above is met", and +nothing did; the Stage 5 exit audit found it, along with +`docs/implementation/mvp.md` not recording that the MVP was reached +either. A trigger written as a checkable condition still needs somebody +to check it, and neither of the two documents that owned the MVP concept +was in the blast radius anybody greped. `docs/practices.md`'s +Blast Radius rule now names both by name. + +## The Process + +Deliberately small. PyFlow is a single-developer project with no +external consumers, so this describes what a release *is* here, not a +publication pipeline nobody needs yet. + +**Versioning: `MAJOR.MINOR.PATCH`, semantic versioning +(), with `MINOR` carrying stage completion while +`MAJOR` stays 0.** + +- **`0.MINOR.0` is cut when a stage closes and its exit audit is + complete** -- not when its last task merges. The audit is what makes + the stage's own claims true, and four stage audits in a row have + changed a verdict (Stages 2, 3, 4 and 5 all did), so a release cut + before one would be a release of unverified claims. +- **`0.MINOR.PATCH` with `PATCH > 0`** is for a correction to an already + released stage: a real defect fixed, not a documentation pass. +- **`MAJOR` stays 0 until the public API is something PyFlow is willing + to keep stable.** Today it is not: `PyFlowConfig`'s schema took its + first deliberate breaking change in Stage 5 (`diffusion_coefficient` + migrating into a new `fluid:` section, TASK-041), and the six + `adr/ADR-003` interfaces are still expected to widen as stages add + physics. `1.0.0` is a decision to stop doing that, and nothing has + asked for it. +- **A breaking configuration change is allowed within `0.x` but is never + silent** -- TASK-041's own precedent: the retired field's name is + rejected at load time with a named error saying where it moved, not + defaulted. That rule is stronger than semantic versioning requires of + a `0.x` project, and it is the one this project actually cares about. + +**What a release is, concretely.** An annotated git tag `vMAJOR.MINOR.PATCH` +on `main`, at the commit whose CI run is green on both platforms, with a +tag message naming the stage it closes and linking that stage's own exit +audit. That is the whole artifact. + +**Where it is published: nowhere, yet, and that is a decision.** PyPI +is the obvious eventual home (BSD-3-Clause, scientific-Python ecosystem +alignment, `LICENSE`), and `pyproject.toml` is already shaped for it -- +but publishing creates an obligation to keep working what someone +installed, which is exactly the obligation `MAJOR = 0` above says PyFlow +is not ready to take on. Publish when a real external consumer exists, +which is the first trigger below and remains unmet. + +**Three places carry the version number and must move together**: +`pyproject.toml`'s `[project].version`, `src/pyflow/__init__.py`'s +`__version__` (whose own comment already says so), and `README.md`'s +"Current Version" line. `docs/glossary.md`'s "Release" entry and this +document's Current State section carry it in prose too. + +## Release History + +| Version | Date | Stage closed | Notes | +|---------|------|--------------|-------| +| 0.1.0 | 2026-08-29 | Stage 5 — First Fluid Solver | The MVP. Incompressible Navier-Stokes end to end: velocity transported as component fields, pressure solved from the incompressibility constraint, a genuinely multi-pass `PISO`, assembled by `navier_stokes_step`. Validated against Couette flow, Ghia, Ghia & Shin (1982) at Re = 100 under mesh refinement, and Taylor-Green vortex decay with a negative control. Golden demos: Lid-Driven Cavity, Heat Diffusion. | + +Stages 0 through 4 predate this process and are deliberately not +retro-tagged: a tag is a claim that a released artifact was verified +against a published process, and no such process existed when they +closed. Their exit audits are the record instead +(`docs/planning/roadmap.md`). + +## What Would Trigger Changing This Process + +The same shape as before -- concrete conditions, so this section stays +checkable rather than aspirational. One of the original three has fired; +the other two have not. + +- **A first external consumer** -- *not yet met*. Anyone depending on + PyFlow who isn't actively developing it needs a published, installable + artifact, which is what the "publish nowhere" decision above defers. + This is also `docs/practices.md`'s Python-version-policy trigger for + moving from "periodic review" to "deliberate stability" -- the same + event changes both policies for the same underlying reason. +- **Reaching the MVP** -- *met 2026-08-29*, and what this rewrite + discharges. +- **A maintainer decision to publish** -- *not yet met*, and + independent of either condition above: the maintainer may simply + decide a release should be published (to PyPI, or anywhere) before an + external consumer exists. + +When either open condition fires, rewrite the "Where it is published" +paragraph above with the actual publication mechanism -- not just its +trigger condition restated. ## Maintenance -Written 2026-08-17 (`docs/planning/backlog.md` E7). Update this -document, not just `docs/glossary.md`'s "Release" entry, the moment any -trigger condition above is met -- the glossary defines the term: this -document is where the actual process, once one exists, belongs. +Written 2026-08-17 (`docs/planning/backlog.md` E7) as a recorded +deferral; rewritten 2026-08-29 with a real process when the MVP trigger +fired, at the maintainer's direction during the Stage 5 exit audit. + +**Update this document, not just `docs/glossary.md`'s "Release" entry, +whenever a stage closes** -- the Release History table above is the one +place a reader can see what has actually been cut, and it goes stale the +same way every other restated fact does. The lesson from the one time +this failed (Current State, above) is that a trigger phrased as a +condition is only as good as whoever remembers to evaluate it, so the +obligation is now attached to something that happens on a schedule -- +every stage exit -- rather than to a condition somebody has to think to +check. diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index 8134585..8d92bc7 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -189,8 +189,18 @@ 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): **672 tests at 99% as of 2026-08-29 (Stage 5 complete)**, up -from 653 after TASK-033 -- TASK-034's own ten new Gherkin scenarios +(C1a/C1b): **688 tests at 99% as of 2026-08-29 (Stage 5 complete, exit +audit included)**, up from 672 as TASK-034 closed and 653 after +TASK-033. The Stage 5 exit audit added sixteen: six rejection tests plus +one acceptance test for the periodic-prescription rule it built +(Criterion 6), six for `generate_status_report.py`'s new README +Current-Phase drift check plus one regression test for the +Gherkin-scenario claim pattern it found silently inert, one new Gherkin +scenario each in `navier_stokes_timestep.feature` (Criterion 13's +`LinearSolver` substitution check) and `pressure_correction_loop.feature` +(Criterion 3's initial-divergence clause), and one in +`tests/unit/test_bootstrap.py` for the `velocity_solved` defect it found +in shipped behaviour (Criterion 12). Before that, TASK-034's own ten new Gherkin scenarios in `tests/unit/test_navier_stokes_timestep.py`, two new plain (non-BDD) unit tests in `tests/unit/test_piso_pressure_coupling.py` proving the new `_poisson_matrix` cache, one new periodic-aware test each in @@ -428,7 +438,7 @@ properties `PISO` (TASK-027, Stage 4) already computed but Stage 4's own criteria never had cause to check -- constant pressure for a divergence-free provisional field, the null-space remedy actually holding, `step` rejecting a `PressureField` -- against the real `PISO` -class throughout, no new pressure-solving mechanism. **79 of those 653 +class throughout, no new pressure-solving mechanism. **95 of those 688 are Gherkin scenarios rather than pytest functions** (`adr/ADR-007-executable-acceptance-criteria.md`; up from fourteen with `field_display.feature` gaining scenarios and `numerics_assembly.feature` @@ -6800,6 +6810,101 @@ what the task must not merely *nominally* satisfy (`docs/practices.md`, "The intent lives in the qualifier"). The Completion Criteria above were drafted against these on 2026-08-28, not in place of them. +### Status as of 2026-08-29: Stage 5 complete, thirteen of thirteen criteria met + +**Eight of these thirteen verdicts were overstated when first written, +and this stage's exit audit found all eight.** The first table was written on +2026-08-29 as TASK-034 closed, in the session that wrote TASK-034; this +one is the corrected table, produced by a separate pass run under +`prompts/common/AUDITOR.md`'s stance. The eight amended rows say what +was claimed, what was actually true, and what was done about it, rather +than being silently rewritten (root `CLAUDE.md`'s Integrity section, and the +precedent Stage 3's Criterion 8 and Stage 4's own three amended rows +both set). **Stage 5 closes at thirteen of thirteen, but not the +thirteen it started with.** Criterion 5 gained the stated, defended +absolute tolerance its own text always asked for; Criterion 6's second +named rejection surface was built rather than assumed inherited; +Criterion 7's degenerate-fixture rule gained the one exception Ghia's +own reference frame forces, recorded rather than quietly taken; +Criterion 9 was discharged against a real two-platform run rather than a +local pass; Criterion 10's six documentation defects are fixed; +Criterion 11's own MVP -- the thing this stage exists to deliver -- is +now recorded as reached in the two documents that own that concept, +rather than only inside this table; Criterion 12's `velocity_solved` now +means the same thing on both live paths, instead of solving on one and +self-advecting on the other; +Criterion 13's *second* substitution check -- the one its own text names +and nobody built -- now exists and is mutation-verified. Nothing here +reopened a task. + +**One of the eight was a defect in shipped behaviour, not only in a +verdict**, and it is the one worth reading first: Criterion 12's, where +a configuration field named `velocity_solved` produced a +pressure-corrected velocity or a merely self-advected one depending on +whether a `scalar_pattern` happened to be set. It had been honestly +recorded by both TASK-031 and TASK-034 -- in two `CLAUDE.md` files, and +against no criterion. **A gap recorded in a `CLAUDE.md` is a gap +somebody chose not to fix; a gap recorded against a criterion is one the +stage cannot close over.** That distinction is what let this one through +a criterion whose own text is "not reachable only from a test fixture". + +**The distinction worth keeping is the same one Stage 4's audit found, +recurring:** the first table's thirteen was arrived at by reading the +first half of most of these criteria and stopping -- and, in Criterion +12's case, by reading a known gap's own honest write-up without asking +which criterion it fell under. Criterion 13 says "The same +substitution check for `LinearSolver`" in its second sentence; +Criterion 6 lists five surfaces and the audit found four; +Criterion 5's own tolerance clause is the last line of its Ghia bullet. +Every one of them was a sentence the criterion had already written down. + +**Criterion 10 is the one this audit would flag as a standing pattern +rather than a one-off.** Six stale claims, in six files no Stage 5 task +opened -- and one of them, `README.md`'s own "Current Phase" section, is +the *second* time that exact section has gone a full stage stale (the +Stage 2 exit audit found it claiming the project "is beginning Stage +2"). A rule that has now failed twice is not a rule; this audit +therefore made it mechanical instead, per `docs/practices.md`'s "Let a +checked artifact carry status, not a tense": +`tools/generators/generate_status_report.py`'s drift check now reads +README's Current Phase section and fails `make ci` when the stage it +names is not the roadmap's own first stage not marked complete. + +**A second check turned out to be inert rather than absent, which is +worse.** `generate_status_report.py`'s Gherkin-scenario drift rule +required a literal space before "are Gherkin scenarios"; a later edit +hard-wrapped this document's line between the count and that phrase, and +a pattern that stops matching reports *nothing to check* -- which reads +exactly like a clean pass. The roadmap sat claiming 79 scenarios against +a live 94, behind a green gate, 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 quoting the real wrapped sentence. + +**Evidence.** Criterion 9 is discharged by two real runs, both green on +`ubuntu-latest` and `windows-latest`, read from `gh run view`'s own +per-job output: `33269489214` (this stage's last PR, #47) and +`33270312866` (the merge of that PR to `main`). This audit's own branch +additionally passes `make ci` locally, and its own CI run is what the +Merge Gate requires before *it* merges -- stated rather than assumed, +since a local pass is not that evidence. + +| Criterion | Verdict | +|-----------|---------| +| 1. Velocity transported by the same mechanism as every other field | **Met.** TASK-031's own subtasks a/c/d. The structural no-special-casing check is `tests/unit/test_velocity_field_support.py`'s own `_then_no_special_casing_in_orchestrator`, bound to `velocity_field_support.feature`'s last scenario -- it inspects `inspect.getsource(simulation)`, the whole module, so `navier_stokes_step` is covered by it too. *(The first table named `test_navier_stokes_timestep.py` as the home of this check; it is not, and never was. Corrected rather than quietly moved.)* | +| 2. Pressure solved from the constraint, not transported | **Met.** TASK-032. The null-space bullet's "removed explicitly, and which remedy was chosen is visible and tested" is genuinely discharged: `ConjugateGradientSolver`'s own *gated* mean-subtracting projection, documented in `icds.md` and `engine.md` and checked by `pressure_field.feature`'s own constant-shift scenario -- re-verified against the source in this audit rather than taken from the first table. | +| 3. Divergence decreases monotonically to the configured tolerance | **Met as amended 2026-08-29; one clause was unasserted as first written.** The loop is genuinely multi-pass (TASK-033) and the iteration-limit and partial-correction scenarios both have real teeth. **But the criterion's own "the fixture's initial maximum divergence is stated and is orders of magnitude above the configured tolerance" was stated nowhere and asserted nowhere** -- and that clause is load-bearing, since it is exactly what stops "non-increasing at every element" and "last element at or below tolerance" from passing for a corrector that does nothing. Measured in this audit: the fixture starts at 1.85 against a configured 1e-4, four orders of magnitude, so the claim was true all along and unchecked. **Fixed in the audit's own change:** `pressure_correction_loop.feature`'s first scenario gains "the recorded divergence sequence starts orders of magnitude above the configured tolerance", with the measured figure written into the feature file and the module's own bound set an order of magnitude below it. | +| 4. One timestep solves momentum and continuity together | **Met.** `navier_stokes_step`; predictor/corrector/corrected sequence, both null tests, determinism -- all real-engine scenarios, all passing. Checkpointing is correctly absent (this criterion excludes it); `docs/architecture/sequences.md`'s placeholder was re-anchored in this audit, since "update once TASK-034 lands" stopped being actionable the moment TASK-034 landed without building it. | +| 5. Physical correctness against a known answer, per case | **Met as amended 2026-08-29; the Ghia bullet's last clause was undischarged.** Couette at solver tolerance; Taylor-Green matched/mismatched pair; kinetic energy never increasing; Ghia convergence across three real resolutions plus the finest's own vortex structure -- all real and all passing. **But the criterion's own "the absolute tolerance is stated and defended in the feature file against the mesh actually used" had no counterpart in the scenario**: only monotonic decrease was asserted, which errors of 10, 5 and 2 would satisfy exactly as well as the real ones do. **Fixed in the audit's own change** -- the finest resolution's own error against Ghia's profiles is now bounded by a stated, measured figure, defended in the feature file against the mesh it was measured on. | +| 6. Rejection paths exercised against real bad input | **Met as amended 2026-08-29; one of the five named surfaces was never built.** Four were: the velocity component-count check, the component-set-size and cross-mesh reassembly checks, and the corrector loop's own iteration-limit exhaustion. **"A configuration that names a boundary treatment velocity has no meaning for" was not**, and no task recorded dropping it -- a periodic boundary carrying a prescribed velocity, pressure, scalar value or per-field override loaded cleanly and was then ignored outright by `assemble_numerics`, which skips the boundary-condition registry entirely for `type: periodic`. **Fixed in the audit's own change** (maintainer's call: build it rather than record it as dropped): `_validate_boundary_conditions_jointly` gains a fourth rule, scoped to non-default values so every periodic configuration this repository already ships stays valid, with six rejection tests and one acceptance test in `tests/unit/test_configuration.py` beside its two sibling rules. | +| 7. Executable Gherkin criteria, `make check-scenarios` gates | **Met as amended 2026-08-29; one exception was taken and not recorded.** The gating half was always real. **The degenerate-fixture half was not, for the cavity**: this criterion requires every scenario's fixture to use "a non-square mesh, non-trivial origin... and a lid velocity that isn't 1", and the Ghia cavity fixture is square, at origin `(0, 0)`, with a lid velocity of exactly 1.0. Two of those three are forced -- Ghia's tabulated Re = 100 profiles are nondimensionalised on a unit square with a unit lid speed, so a non-square cavity or a different lid speed would not be comparable to the reference at all. **The origin was not forced**, and the maintainer's call was to fix what could be fixed rather than only record it: the cavity fixture now sits at a non-trivial origin, converting to unit-cavity coordinates explicitly where the comparison needs them. The two genuinely-forced exceptions are recorded in the feature file where a reader meets them. | +| 8. Demonstrations: Lid-Driven Cavity and Heat Diffusion | **Met.** Both built, both with a CLI-subprocess regression test and a quantitative physical check; neither demo test asserts absolute field values, which is what keeps Criterion 4's cross-platform clause honest. | +| 9. `make ci` green on a real runner | **Met.** Two runs, both `success` on `ubuntu-latest` and `windows-latest`, read from `gh run view`'s own per-job output rather than inferred from a merged PR: `33269489214` (PR #47) and `33270312866` (its merge to `main`). *(The first table recorded this as the audit's one honest gap, correctly: it was written before either run's result was checked.)* | +| 10. Documentation matches the tree, capability map included | **Not met on 2026-08-29 as first claimed; met after this audit.** Six stale claims, in six files no Stage 5 task opened -- the exact failure mode `docs/practices.md`'s "A stage's documentation sweep is a grep, not a diff review" names, and which Stage 4's own audit produced that rule after finding seven of. **`README.md`'s "Current Phase" section said "Stage 5 -- First Fluid Solver -- not yet started", on the day Stage 5 closed**, alongside "Stage 5 will solve incompressible flow" and a "most recent demonstration" that was two demos out of date. **`docs/architecture/sequences.md`** -- the document whose only stated job is "in what order do things actually happen when PyFlow runs" -- contained no mention of pressure, predictor, corrector or `navier_stokes_step` at all, and still asked to be updated "once TASK-034 lands". **`docs/architecture/rendering.md`** claimed the field-to-GPU conversion happens "not per frame and not per timestep... Stage 4 onward, not this", stale since TASK-030 and now doubly so -- the third stale claim in that one file about a stage that had already closed. **`src/pyflow/configuration/schema.py`**'s `NumericsConfig` docstring said "the other four still resolve to their own reference implementation", flatly contradicted by `assembly.py`'s own "zero `_Null*` classes remain". **`adr/ADR-003`** still described `PISO` in the present tense as a single correction pass. And **`docs/implementation/golden-demos.md`** pointed `mvp.md`'s "golden demo exists" criterion at "the Initial Golden Demo below (Capability Level 1)" -- wrong in both halves once TASK-034 landed, and contradicted by `planning/data/demos.yaml`'s own `demo-lid-driven-cavity -> capability-level-2` edge, which had been right all along. All six fixed in the audit's own change, and README's own half made mechanical rather than remembered (above). Capability map: verified directly -- `planning/data/demos.yaml`/`capabilities.yaml` already carried both demos with `validates -> capability-level-2` edges, and `docs/planning/capability-map.md` is deliberately status-free, so nothing was owed there. | +| 11. `mvp.md`'s Definition of Done discharged item by item | **Met as amended 2026-08-29.** Every row of its own table holds, re-read against the tree rather than against the first table. **But neither `docs/implementation/mvp.md` nor `docs/planning/releases.md` said the MVP had been reached** -- and `releases.md` names "Reaching the MVP" as one of exactly three concrete triggers for defining a release process, with a Maintenance section instructing that it be updated "the moment any trigger condition above is met". The trigger fired when TASK-034 landed. Fixed in the audit's own change, at the maintainer's direction: `mvp.md` records the MVP as reached, and `releases.md` is rewritten with a real release process rather than a restated trigger. | +| 12. Everything this stage adds is configuration-driven, validated, documented | **Not met on 2026-08-29 as first claimed; met after this audit.** `simulation.velocity_solved` had two live paths and meant two different things. With no `scalar_pattern`, `bootstrap.py`'s `_add_solved_velocity_rendering` called `navier_stokes_step` and produced a genuinely incompressible velocity. **With a `scalar_pattern`, `_add_passive_scalar_transport` transported velocity's components like any other scalar and never pressure-corrected them** -- so a configuration saying "solved" produced a velocity that was not, chosen by whether a scalar happened to be configured, with no error and nothing rendered differently. Measured, not argued: maximum divergence sat at 9.16 -> 8.24 -> 6.95 over 1, 10 and 40 frames uncorrected, against 2.30 -> 0.47 -> 0.057 corrected. **TASK-031 and TASK-034 both knew** -- it is recorded in `src/pyflow/configuration/CLAUDE.md` and in `bootstrap.py`'s own docstrings as a "real, pre-existing gap this task did not close" -- **and it was recorded against no criterion, which is exactly how it survived this row being marked met the first time.** A gap written down in a `CLAUDE.md` is a gap somebody chose not to fix; a gap written down against a criterion is a gap the stage cannot close over. **Fixed in the audit's own change** (maintainer's call: route it through, rather than reject the combination or record it): both live paths now call `navier_stokes_step`, with a regression test asserting the scalar-plus-solved-velocity path's own divergence collapses, measured against both behaviours before its bound was chosen. Otherwise met, with one deliberate exception recorded rather than silently narrowed: run-length/steadiness stayed a validation-scenario constant, not a config field (TASK-034's own Discharges explain why neither the demos nor the direct-engine Ghia scenario need one). `fluid:`, the corrector-loop tunables, solved-vs-prescribed velocity, and per-field wall values (superseding `velocity_tangential`) are all real, validated, documented config surface. `simulation.stable_timestep` is engine code no live run reaches -- noted rather than filed as a violation, since the *capability* it serves (choosing a timestep) is configured, and the helper is a stated, documented derivation rather than a hidden one. | +| 13. The solver runs through ADR-003's seams, checked by substitution | **Not met on 2026-08-29 as first claimed; met after this audit.** This criterion names **two** substitution checks. The `PressureCoupling` one was built and is real. **The `LinearSolver` one -- "which reaches the timestep only through the coupling and has never been exercised end-to-end either" -- was not**: `register_linear_solver` was never called anywhere outside `assembly.py`'s own built-in registration, so a `PISO` that constructed its own `ConjugateGradientSolver` instead of using the resolved one would have passed every scenario in this repository. Confirmed by mutation, not argued: making `PISO.__init__` discard its injected solver leaves the whole suite green. **Fixed in the audit's own change** -- `navier_stokes_timestep.feature` gains a scenario registering a recording `LinearSolver` under its own name, selected through `NumericsConfig`, and asserting the timestep's own pressure solve asked it; verified to fail under exactly that mutation and to pass without it. This is the criterion its own text called "the one an otherwise-passing Stage 5 is most likely to fail silently", and it was half-failing silently. | + ## TASK-041 Fluid Configuration Section @@ -7813,10 +7918,24 @@ feature file, are the criteria. Written to cover, at minimum: ### Discharges -Criteria 4, 8, 9, 10, 11 and 13, entirely. Criterion 5, all bullets, -including the Couette one entirely -- TASK-033 supplies the corrector -loop it depends on but does not itself scenario-test it (see that -task's own Discharges). Criterion 12, its tangential-boundary share -- +**Amended 2026-08-29 by the Stage 5 exit audit; this section claimed +more than the task delivered.** As written it read "Criteria 4, 8, 9, +10, 11 and 13, entirely" plus "Criterion 5, all bullets". Four of those +were short, and the stage's own status section above records each: +Criterion 13's `LinearSolver` substitution check was never built (only +the `PressureCoupling` one); Criterion 5's stated-and-defended absolute +tolerance was never written; Criterion 10 left five stale claims in +files this task did not open; Criterion 9 was recorded against a local +`make ci` rather than a real two-platform run. Criterion 6's second +named rejection surface was owed by no task and built by none. All are +now discharged, by the audit rather than by this task, which is the +distinction this amendment exists to preserve. + +Criteria 4, 8 and 11, entirely. Criteria 5, 9, 10 and 13, entirely **as +completed by that audit**. Criterion 5's bullets are otherwise all this +task's, including the Couette one entirely -- TASK-033 supplies the +corrector loop it depends on but does not itself scenario-test it (see +that task's own Discharges). Criterion 12, its tangential-boundary share -- **discharged by the `field_values` finding above, not by building `velocity_tangential`** -- and its run-length/steadiness share: **deliberately not a new config field.** The Ghia cavity scenario's own @@ -7834,38 +7953,6 @@ Lid-driven cavity. This defines the MVP of PyFlow. -### Stage 5 Completion Criteria — Exit Audit - -Written 2026-08-29, TASK-034 done, the same "read every criterion back -against what actually landed" discipline Stage 3/4's own exit audits -used. Each row points at the task's own Discharges section (or, for -Criterion 5, this task's own Acceptance Criteria bullets above) for the -full record rather than re-narrating it. - -| Criterion | Verdict | -|-----------|---------| -| 1. Velocity transported by the same mechanism as every other field | **Met.** TASK-031's own subtasks a/c/d; `test_navier_stokes_timestep.py`'s own no-special-casing check still passes with `navier_stokes_step` added. | -| 2. Pressure solved from the constraint, not transported | **Met.** TASK-032. | -| 3. Divergence decreases monotonically to the configured tolerance | **Met.** TASK-033, `PISO` genuinely multi-pass. | -| 4. One timestep solves momentum and continuity together | **Met.** `navier_stokes_step`; predictor/corrector/corrected sequence, both null tests, determinism -- all real-engine scenarios, all passing. | -| 5. Physical correctness against a known answer, per case | **Met.** Couette at solver tolerance; Ghia cavity, monotonic convergence across three real resolutions plus the finest's own vortex structure (confirmed on a real 11m24s run); Taylor-Green matched/mismatched pair; kinetic energy never increasing. | -| 6. Rejection paths exercised against real bad input | **Met.** `UnconfiguredBoundaryFaceError`'s own periodic case now genuinely reachable and tested (this task's own periodic-support fix); every other rejection path already covered by TASK-041/031-033. | -| 7. Executable Gherkin criteria, `make check-scenarios` gates | **Met.** 94 scenarios across 21 feature files, `make check-scenarios` passing. | -| 8. Demonstrations: Lid-Driven Cavity and Heat Diffusion | **Met.** Both built, both with a CLI-subprocess regression test and a quantitative physical check. | -| 9. `make ci` green on a real runner | **Pending this branch's own CI run** -- green locally (`make ci`, this session), not yet checked against a real `ubuntu-latest`/`windows-latest` run, per this project's own standard of evidence (a merged PR's own `gh run` output, not a local pass alone). | -| 10. Documentation matches the tree, capability map included | **Met.** `icds.md`/`engine.md` (Pressure-Velocity Coupling), `golden-demos.md` (two new sections, "Initial Golden Demo" retired), every touched `CLAUDE.md`, both inventories, `tests/fixtures/` recorded as a new convention. Capability map: `planning/data/demos.yaml`/`capabilities.yaml` already named `demo-heat-diffusion`/`demo-lid-driven-cavity` with `validates -> capability-level-2` edges before this task landed either -- nothing to add, verified directly rather than assumed. | -| 11. `mvp.md`'s Definition of Done discharged item by item | **Met**, reading its own table back: simulation runs end-to-end (Criteria 4, 8); physical fields evolve (1, 2); boundary conditions operate (5's Couette/cavity bullets); pressure/velocity coupling works (3, in the strong sense); numerical solution is measurable (5); visualisation shows the result (8's cavity bullet, a solved field rendered live for the first time); golden demo exists (8); documentation describes the implemented functionality (10); tests verify the core behaviour (7); capability map is updated (10's own share, already current). | -| 12. Everything this stage adds is configuration-driven, validated, documented | **Met**, with one deliberate exception recorded rather than silently narrowed: run-length/steadiness stayed a validation-scenario constant, not a config field (this task's own Discharges above explain why neither the demos nor the direct-engine Ghia scenario need one). `fluid:`, the corrector-loop tunables, solved-vs-prescribed velocity, and per-field wall values (superseding `velocity_tangential`) are all real, validated, documented config surface. | -| 13. The solver runs through ADR-003's seams, checked by substitution | **Met.** `navier_stokes_step`'s own substitution scenario: a `PressureCoupling` test double registered under its own name and selected by configuration is demonstrably what gets called. | - -**Criterion 9's own caveat is the one honest gap this audit found**: -this session's own `make ci` is green, but per this project's own -Merge Gate (root `CLAUDE.md`), "mechanically green" means a real run on -both platforms, checked from the actual `gh run` output once this -branch's PR exists -- not inferred from a local pass. Recorded here -rather than silently assumed, per the Merge Gate's own fourth -requirement ("said honestly"). - --- # Stage 6 — Additional Physical Fields diff --git a/docs/planning/status.md b/docs/planning/status.md index 2043516..cebb41a 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -32,16 +32,17 @@ pie showData - **Stage 2 -- Representing Fields** complete (2026-08-22) - **Stage 3 -- Numerical Engine** complete (2026-08-23) - **Stage 4 -- First Numerical Methods** complete (2026-08-28) +- **Stage 5 -- First Fluid Solver** complete (2026-08-29) ### Up next -**Stage 5 -- First Fluid Solver** has no pending tasks recorded, but isn't marked complete -- likely awaiting its exit audit. +**Stage 6 -- Additional Physical Fields** is next, starting with TASK-035 (Temperature), 3 more not yet started in this stage. ## Live repository facts - **46** `CLAUDE.md` files -- **672** tests collected -- **94** Gherkin scenarios (`tests/features/*.feature`) +- **688** tests collected +- **95** Gherkin scenarios (`tests/features/*.feature`) ## Stages @@ -115,7 +116,7 @@ pie showData ### Stage 5 -- First Fluid Solver -**no status recorded** -- `██████████` 5/5 tasks; 13 criteria defined, no status line yet +**complete, as of 2026-08-29** -- `██████████` 5/5 tasks; 13/13 criteria met | Task | Status | Date | Artifact | |------|--------|------|----------| diff --git a/docs/practices.md b/docs/practices.md index 156d6a7..6132a3e 100644 --- a/docs/practices.md +++ b/docs/practices.md @@ -1348,6 +1348,120 @@ file-specific, so Stage 4 reproduced it in three new files. **Fix the class, then, not the file** -- which is what this rule is, and why it is phrased as three greps rather than as a list of documents to re-read. +**Amended 2026-08-29 by the Stage 5 exit audit, which found five more -- +and one of them in `README.md`, for the second time.** The three greps +work, and found all five. What they cannot do is run themselves, and +`README.md`'s "Current Phase" section is the one place where that has +now failed twice: the Stage 2 audit found it claiming the project "is +beginning Stage 2", and the Stage 5 audit found it claiming Stage 5 was +"not yet started" on the day Stage 5 closed, with a "most recent +demonstration" two demos out of date. Two failures of the same sentence +is not a reminder problem. **So that one is now mechanical**: +`tools/generators/generate_status_report.py`'s drift check reads +README's Current Phase section and fails `make ci` when the stage it +names is not the roadmap's own first stage not marked complete +("Let a checked artifact carry status, not a tense", above -- and note +which document is authoritative: the roadmap decides, README is checked +against it). + +**Add a fourth grep, for the documents a stage *should* have gained a +section in.** Stage 5's worst finding was not a stale sentence but an +absent one: `docs/architecture/sequences.md`, whose only stated job is +"in what order do things actually happen when PyFlow runs", had no +sequence for `navier_stokes_step` at all -- the single most important +runtime sequence the stage added. Nothing in the three greps above +looks for a *missing* section, because a document that never mentions +the new thing contains no false sentence to find. So: for each capability +a stage adds, name the document that would have to describe it if it had +existed from the start, and check that it does. + +## An exit audit reads each criterion to its last sentence + +**Standing rule, 2026-08-29, from the Stage 5 exit audit.** Six of that +stage's thirteen verdicts were overstated when first written, and the +six failures share one shape: the criterion said more than the verdict +answered, and the extra was always in a *later* clause of a criterion +whose first clause was genuinely met. + +- Criterion 13 names two substitution checks in consecutive sentences. + One was built. The verdict read "Met." +- Criterion 6 enumerates five rejection surfaces. Four existed. +- Criterion 5's Ghia bullet ends "the absolute tolerance is stated and + defended in the feature file against the mesh actually used". Nothing + stated one. +- Criterion 3 requires the fixture's initial divergence to be "stated and + orders of magnitude above the configured tolerance" -- the clause that + stops the two assertions after it from being vacuous. It was true, and + asserted nowhere. + +None of these needed judgement to find. Each is a sentence the criterion +had already written down, in a criterion the auditor had already opened. +**So: work the criterion clause by clause, and where it enumerates, count +the enumeration against what exists.** A criterion that names N things is +not met at N-1, however good the N-1 are. + +This is the same failure the Stage 4 audit described as "arrived at by +not reading the second half of three sentences" -- restated as a rule +rather than as a stage's own observation, because describing it once did +not stop it recurring at a higher count one stage later. + +## A gap recorded in a `CLAUDE.md` is not recorded against a criterion + +**Standing rule, 2026-08-29, from the Stage 5 exit audit.** That stage +shipped a configuration field, `simulation.velocity_solved`, which meant +two different things depending on whether an unrelated field was also +set: with a `scalar_pattern` the velocity was transported like any other +scalar and never pressure-corrected; without one it went through the +real corrector loop. A configuration saying "solved" produced a velocity +that was not incompressible, with no error and nothing rendered +differently -- the plausible-looking wrong answer this document names +repeatedly. + +**It was not hidden.** Both tasks that touched it 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". That +is exactly the honesty the Blast Radius rule asks for, and it was not +enough: the stage's 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 the +`CLAUDE.md` note and never asked which criterion it fell under. + +**A note in a `CLAUDE.md` records that somebody chose not to fix +something. A note against a criterion records something the stage cannot +close over.** They are different instruments and only one of them gates. +So, when a task closes by writing down what it did not do: + +1. Name the criterion the gap falls under, in the criterion's own + verdict, not only in the module's `CLAUDE.md`. If no criterion covers + it, say that too -- an uncovered gap is a finding about the criteria. +2. Say whether the gap is *reachable from configuration*. A limitation + nobody can trip over is a note; one a config author can select by + accident is a defect with a note attached. + +## A checkable trigger still needs somebody to check it + +**Standing rule, 2026-08-29, from the Stage 5 exit audit.** +`docs/planning/releases.md` named three concrete conditions that would +trigger defining a release process, deliberately phrased to be checkable +rather than open-ended, and instructed that the document be updated "the +moment any trigger condition above is met". One of them -- reaching the +MVP -- fired when TASK-034 landed, and neither that document nor +`docs/implementation/mvp.md` noticed, because a trigger is not attached +to anything that happens; it waits for a reader to think of it. + +**Attach an obligation to an event that occurs on a schedule, not to a +condition someone has to remember to evaluate.** `releases.md`'s +obligation is now "update this whenever a stage closes", which is a thing +that visibly happens, rather than "update this when the MVP is reached", +which is a thing somebody has to notice. The same applies to a +documentation placeholder anchored to a task: `docs/architecture/ +sequences.md` asked to be updated "once TASK-034 lands", and TASK-034 +landed *without building the thing the placeholder describes*, a case +the anchor did not cover -- so the anchor sat pointing at a closed task, +and the same pass left the document with no sequence for what TASK-034 +did build. When a task carrying a note in a document closes, re-read that +document whether or not the task built what the note names. + --- # Design Rules diff --git a/docs/repository-inventory.md b/docs/repository-inventory.md index 7c91e3a..27b2486 100644 --- a/docs/repository-inventory.md +++ b/docs/repository-inventory.md @@ -17,7 +17,7 @@ not here either -- those come from running the suite, not from listing files. **289 tracked files** across 46 directories; -4 are empty. +3 are empty. ## (root) @@ -198,7 +198,7 @@ listing files. - `demos.yaml` - `features.yaml` -- empty - `references.yaml` -- empty -- `releases.yaml` -- empty +- `releases.yaml` ## planning/model diff --git a/docs/repository-manifest.md b/docs/repository-manifest.md index 670c921..73621c3 100644 --- a/docs/repository-manifest.md +++ b/docs/repository-manifest.md @@ -134,7 +134,7 @@ Not present, deferred consciously rather than overlooked: | dependency-tree.md | 🟩 | **Generated** engine subsystem dependency order, from `planning/data/components.yaml` (`tools/generators/generate_dependency_tree.py`, 2026-08-21); regenerate with `make dependency-tree`, never hand-edit | | status.md | 🟩 | **Generated** visual project status report -- task/stage tables plus a Mermaid chart, from `roadmap.md`'s own status prose and live repository counts (`tools/generators/generate_status_report.py`, 2026-08-26); regenerate with `make status-report`, never hand-edit. `make check-status` refuses to regenerate it at all while roadmap.md's claimed counts disagree with reality, not just when this file is stale relative to them -- see `docs/planning/CLAUDE.md` | | dreams.md | 🟨 | Speculative future ideas, explicitly not commitments (KA-036) | -| releases.md | 🟨 | No release process yet -- deliberate deferral, not an oversight, with concrete trigger conditions recorded (E7, 2026-08-17) | +| releases.md | 🟨 | Versioning scheme, what a release is here, and the release history (E7, 2026-08-17 as a recorded deferral; rewritten 2026-08-29 with a real process when reaching the MVP fired one of its own three triggers). First release: PyFlow 0.1.0 | --- @@ -350,9 +350,10 @@ features,references,releases}.yaml`. 🟨 — **partially populated.** All four `model/` files hold content, plus `data/components.yaml` (the engine layers), `data/capabilities.yaml` -(the capability levels) and `data/demos.yaml` (the golden demos); the -rest are deliberately empty, each with a stated trigger in -`model/entities.yaml`. Which files those are is not restated here -- +(the capability levels), `data/demos.yaml` (the golden demos) and +`data/releases.yaml` (populated 2026-08-29, when reaching the MVP fired +its own stated trigger); the rest are deliberately empty, each with a +stated trigger in `model/entities.yaml`. Which files those are is not restated here -- `docs/repository-inventory.md` is generated and marks every empty file, so a count in this sentence would be a second, unchecked copy of a fact `make check-inventory` already keeps true. Validated by @@ -360,13 +361,16 @@ so a count in this sentence would be a second, unchecked copy of a fact `docs/planning/dependency-tree.md` is generated from `data/components.yaml`. -The remaining four `data/` files are empty **on purpose, each with a +The remaining `data/` files are empty **on purpose, each with a stated trigger in `model/entities.yaml`** -- not deferred-and-forgotten, -which is what this row used to describe. `releases.yaml` in particular -should be expected to stay empty indefinitely: -`docs/planning/releases.md` is a sustained argument that PyFlow should -not have a release process yet, and a file matching a documented -deliberate absence is correct rather than incomplete. +which is what this row used to describe. **`releases.yaml` is no longer +among them** (2026-08-29): this paragraph used to single it out as the +one that "should be expected to stay empty indefinitely", on the grounds +that `docs/planning/releases.md` was a sustained argument against having +a release process yet. Reaching the MVP fired one of that document's own +three trigger conditions, it was rewritten with a real process, and the +graph file was populated with its first release -- the trigger mechanism +working exactly as designed, one document behind. Scope is `adr/ADR-006-knowledge-graph-scope.md`, which narrowed `adr/ADR-001-knowledge-graph.md` after the 2026-08-21 audit found it diff --git a/planning/data/CLAUDE.md b/planning/data/CLAUDE.md index 215ddc1..c0d7c89 100644 --- a/planning/data/CLAUDE.md +++ b/planning/data/CLAUDE.md @@ -20,10 +20,21 @@ These hold content: - `demos.yaml` -- the golden demos, each with a `validates` edge to the level whose own **Golden Demo** section names it. -The other four are empty, each with a stated trigger in +- `releases.yaml` -- released versions, one entity per release. Empty + from creation until 2026-08-29, correctly so; its stated trigger + ("populate only if PyFlow gains a release process") fired when + reaching the MVP closed Stage 5. + +The other three are empty, each with a stated trigger in `../model/entities.yaml`. Populate a file when its content exists *and* something consumes it, not to make the directory look complete -(`adr/ADR-006-knowledge-graph-scope.md` rule 6). +(`adr/ADR-006-knowledge-graph-scope.md` rule 6). **`releases.yaml` is +the first file here to make that transition, and it is worth reading as +a worked example of the trigger mechanism**: the trigger was written +down, the condition genuinely fired, and nothing noticed for a day -- +the Stage 5 exit audit did. A stated trigger is a good record and a poor +alarm (`docs/practices.md`, "A checkable trigger still needs somebody to +check it"). **A missing edge must be declared, not merely absent.** An entity that would normally have an edge and does not carries an `unresolved:` field diff --git a/planning/data/releases.yaml b/planning/data/releases.yaml index e69de29..02201eb 100644 --- a/planning/data/releases.yaml +++ b/planning/data/releases.yaml @@ -0,0 +1,25 @@ +# Released versions of PyFlow. +# +# Empty from this file's creation until 2026-08-29, correctly so: +# `docs/planning/releases.md` was a sustained argument that PyFlow should +# not have a release process yet, and `../model/entities.yaml` recorded +# "Populate only if that changes" as this category's own trigger. It +# changed -- reaching the MVP (Stage 5, TASK-034) fired one of that +# document's three trigger conditions, and it was rewritten with a real +# process and a release history. +# +# No edges: `../model/relationships.yaml` declares no relationship type +# with `releases` at either end, so a release entity having none is the +# normal shape rather than a declared gap. What a release relates to -- +# the stage it closes -- lives in prose (`docs/planning/releases.md`'s +# own Release History table), because "which stage closed" is execution +# status, which `adr/ADR-006-knowledge-graph-scope.md` rule 2 keeps in +# `docs/planning/roadmap.md` rather than in the graph. + +category: releases + +entities: + - id: release-0-1-0 + name: PyFlow 0.1.0 + description: The MVP release, cut 2026-08-29 when Stage 5 closed and its exit audit completed. + documented_in: docs/planning/releases.md diff --git a/planning/model/entities.yaml b/planning/model/entities.yaml index 29aaafd..1135a8b 100644 --- a/planning/model/entities.yaml +++ b/planning/model/entities.yaml @@ -87,11 +87,14 @@ entities: description: >- A released version of PyFlow. source_of_truth: docs/planning/releases.md - populated: false + populated: true trigger: >- - None expected for a long time, and this file being empty is - correct rather than incomplete (ADR-006 rule 6). - `docs/planning/releases.md` is a sustained argument that PyFlow - should *not* have a release process yet -- single developer, no - external consumers, Stage-based rather than release-based rhythm. - Populate only if that changes. + Populated 2026-08-29. This read "None expected for a long time... + Populate only if that changes" and described + `docs/planning/releases.md` as a sustained argument that PyFlow + should *not* have a release process yet. It changed: reaching the + MVP (Stage 5, TASK-034) fired one of that document's own three + trigger conditions, and it was rewritten with a real process. One + release exists, PyFlow 0.1.0. Add an entity per release cut; the + stage a release closes stays in prose, not as an edge (ADR-006 + rule 2 keeps execution status in `docs/planning/roadmap.md`). diff --git a/pyproject.toml b/pyproject.toml index c5181ae..03fa8b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyflow" -version = "0.0.1" +version = "0.1.0" description = "A modular, field-centric computational fluid dynamics engine." readme = "README.md" license = "BSD-3-Clause" diff --git a/src/pyflow/__init__.py b/src/pyflow/__init__.py index 78cab29..7a9aa9b 100644 --- a/src/pyflow/__init__.py +++ b/src/pyflow/__init__.py @@ -3,4 +3,4 @@ See docs/planning/roadmap.md for current implementation status. """ -__version__ = "0.0.1" # must match [project].version in pyproject.toml +__version__ = "0.1.0" # must match [project].version in pyproject.toml diff --git a/src/pyflow/bootstrap.py b/src/pyflow/bootstrap.py index c083dcb..9605547 100644 --- a/src/pyflow/bootstrap.py +++ b/src/pyflow/bootstrap.py @@ -14,6 +14,15 @@ `scalar_pattern` -- the Lid-Driven Cavity demo's own shape. Every other configuration still renders without stepping anything. +**`config.simulation.velocity_solved` now means the same thing on both +live paths** (Stage 5 exit audit, 2026-08-29): with a `scalar_pattern` +alongside it, `_add_passive_scalar_transport` calls +`navier_stokes_step` too. It did not until then -- it transported +velocity's components like any other scalar and never pressure-corrected +them -- so which of the two behaviours a configuration got was decided by +whether a scalar happened to be configured. See that function's own +docstring for the measured before/after. + 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 imported `simulation_step` twenty lines below -- the same stale @@ -180,23 +189,34 @@ def _add_passive_scalar_transport( (TASK-030's own Design decision). **`config.simulation.velocity_solved` (TASK-031, added 2026-08-29)**: - when true, velocity's own two components join `state` (decomposed - via `VectorField.decompose`) and are advanced by the same `step` - call as the scalar -- self-advected by the transporting `velocity` - itself, reassembled (`VectorField.assemble`) from the just-advanced - components after every frame so the *next* frame transports against - the current velocity, not the initial one. `simulation.py` itself - needs no change for this (Stage 5 Completion Criterion 1's own - structural clause): decompose-before/reassemble-after lives entirely - here, and `step` just sees more entries in `fields`. **Still requires - a scalar (`scalar_pattern`)** -- a velocity-only live run has nothing - this function knows how to render yet (no vector-arrow-per-frame - path exists), so `velocity_solved` set without `scalar_pattern` is - validated but has no visible effect through `bootstrap.py` today; the - mechanism itself is proven directly against `simulation.step()` - (`tests/features/velocity_field_support.feature`), not only through - this live path. Revisit when a demo genuinely needs velocity-only - live rendering (TASK-034's own Lid-Driven Cavity is the likely first). + when true, velocity's own two components join `state` (decomposed via + `VectorField.decompose`) alongside the scalar, and the whole state is + advanced by `navier_stokes_step` rather than plain `step` -- so the + velocity carrying the scalar is genuinely pressure-corrected, frame + by frame, and the *next* frame transports against a corrected + velocity rather than the initial one. `simulation.py` needs no change + for this (Stage 5 Completion Criterion 1's own structural clause): + decompose-before/reassemble-after lives entirely here. + + **This path used plain `step` until the Stage 5 exit audit + (2026-08-29), and that was a real defect, not a scoping choice.** + TASK-031 built it before any corrector loop existed to call, and + TASK-034 -- which built one, and used it in + `_add_solved_velocity_rendering` -- left this path alone and recorded + the gap in two `CLAUDE.md` files. The result was a configuration + field named `velocity_solved` that solved on one live path and merely + self-advected on the other, chosen by whether a `scalar_pattern` + happened to be set, with no error and nothing rendered differently: + a plausible-looking wrong answer reachable from configuration alone. + Measured before and after, on the fixture + `tests/unit/test_bootstrap.py` now uses: maximum divergence sat at + 9.16 -> 8.24 -> 6.95 over 1, 10 and 40 frames uncorrected, and falls + 2.30 -> 0.47 -> 0.057 corrected. + + **Velocity's own initial condition still comes from + `velocity_pattern`/`velocity` either way** -- "solved" decides what + happens to it after frame zero, not what it starts as + (`src/pyflow/configuration/CLAUDE.md`). """ assert window.assembled_numerics is not None numerics = window.assembled_numerics @@ -229,16 +249,18 @@ def _add_passive_scalar_transport( window.scene.add(rendered_object) def _advance() -> None: - nonlocal state, rendered_object, velocity_field - state = simulation_step(state, velocity_field, numerics, config.numerics.timestep) - window.simulation_fields = state + nonlocal state, rendered_object if solved: - u_name = VectorField.component_name("velocity", 0) - v_name = VectorField.component_name("velocity", 1) - u, v = state[u_name], state[v_name] - assert isinstance(u, ScalarField) - assert isinstance(v, ScalarField) - velocity_field = VectorField.assemble([u, v], "velocity") + # `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 tracer = state["tracer"] assert isinstance(tracer, ScalarField) colors = scalar_field_colors( @@ -273,14 +295,20 @@ def _add_solved_velocity_rendering( `gfx.Line`, build a new one" shape `_add_passive_scalar_transport` already uses for its own scalar mesh. - **Uses `navier_stokes_step`, not plain `step`** -- the real - difference from `_add_passive_scalar_transport`'s own `velocity_ - solved` path, which only ever transports velocity's components like - an ordinary scalar and never pressure-corrects them (a genuine, - pre-existing gap in that path, out of this task's own scope to - close: nothing before TASK-034 had a corrector loop to call). A - demo using this function is genuinely incompressible, frame by - frame, not merely self-advected. + **Uses `navier_stokes_step`, not plain `step`**, so a demo using this + function is genuinely incompressible frame by frame, not merely + self-advected. + + This paragraph used to go on to name that as "the real difference + from `_add_passive_scalar_transport`'s own `velocity_solved` path, + which only ever transports velocity's components like an ordinary + scalar and never pressure-corrects them (a genuine, pre-existing gap + in that path, out of this task's own scope to close)". **The Stage 5 + exit audit closed that gap on 2026-08-29** rather than leaving a + configuration field that solved on one path and did not on the other: + 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. """ assert window.assembled_numerics is not None numerics = window.assembled_numerics diff --git a/src/pyflow/configuration/CLAUDE.md b/src/pyflow/configuration/CLAUDE.md index de263cd..cd1c593 100644 --- a/src/pyflow/configuration/CLAUDE.md +++ b/src/pyflow/configuration/CLAUDE.md @@ -130,10 +130,30 @@ derived from the mesh's own bounds in `bootstrap.py` instead -- the same "derived from mesh bounds, not a config field" precedent `_scalar_display_initializer`'s own `center` already set for `FieldDisplayConfig`'s "radial_gradient" pattern. `velocity` is a -prescribed (not solved) constant vector, `_number_pair`-normalised the -same way `RenderingConfig.pan`/`MeshConfig.origin` are -- Stage 5 is what -eventually solves Navier-Stokes for real, so a prescribed field is the -only kind of "velocity" any Stage 4 demo can legitimately have. +prescribed (not solved) constant vector *by default*, +`_number_pair`-normalised the same way +`RenderingConfig.pan`/`MeshConfig.origin` are -- a prescribed field is +the only kind of "velocity" any Stage 4 demo can legitimately have, and +Stage 5's `velocity_solved` (below) is what a later run uses to ask for +the other kind. + +**`_validate_boundary_conditions_jointly` grew a fourth rule 2026-08-29 +(Stage 5 exit audit): a periodic boundary may not prescribe anything.** +Periodic bypasses the boundary-condition registry entirely inside +`assembly.py`, so `velocity`, `pressure`, `scalar_value`, +`scalar_gradient`, `field_values` and `field_gradients` are all read by +nobody on such a face -- a configuration setting one loaded cleanly and +was silently ignored, which is the "plausible-looking wrong answer" +failure mode this project keeps naming. **Scoped to *non-default* values, +and that scoping is the whole design decision**: `velocity` defaults to +`0.0` rather than `None`, and `scalar_value`/`scalar_gradient` to `0.0`, +so a rule phrased as "is set at all" would have rejected every periodic +configuration this repository already ships. `velocity: null` is accepted +alongside `0.0` because +`examples/golden-demos/passive_scalar_transport.yaml` predates the rule +using exactly that form, and it is the most honest way to write +"prescribes nothing". Discharges Stage 5 Completion Criterion 6's second +named rejection surface, which no Stage 5 task had built. **`velocity_solved: bool` (TASK-031, added 2026-08-29) is the solved-vs-prescribed control Stage 5 adds -- a separate field, not a @@ -158,11 +178,23 @@ path yet (`docs/planning/roadmap.md` TASK-031's own Status note). velocity-only path this note anticipated, selected when `velocity_solved` is `True` and `scalar_pattern` is `None` -- the Lid-Driven Cavity demo's own shape. Uses `navier_stokes_step`, not plain `step`, so this path is -genuinely pressure-corrected every frame; `_add_passive_scalar_transport`'s -own `velocity_solved` path (a scalar *and* a solved velocity together) -is unaffected and still uses plain `step`, a real, pre-existing gap this -task did not close, since nothing before TASK-034 had a corrector loop -to call there either. +genuinely pressure-corrected every frame. + +**Both live paths now mean the same thing by `velocity_solved`, closed +by the Stage 5 exit audit (2026-08-29).** Until then this entry recorded +that `_add_passive_scalar_transport`'s own `velocity_solved` path (a +scalar *and* a solved velocity together) "still uses plain `step`, a +real, pre-existing gap this task did not close" -- honestly recorded, and +still a defect: a configuration field named `velocity_solved` produced a +pressure-corrected velocity or a merely self-advected one depending on +whether a `scalar_pattern` happened to be set, with no error and nothing +rendered differently. **A recorded gap in a `CLAUDE.md` is not the same +as a recorded gap against a criterion**, which is how this survived +Stage 5's own Criterion 12 being marked met, and is worth remembering the +next time a task closes by writing down what it did not do. Measured +before and after on the fixture `tests/unit/test_bootstrap.py` now uses: +maximum divergence 9.16 -> 8.24 -> 6.95 over 1, 10 and 40 frames +uncorrected, 2.30 -> 0.47 -> 0.057 corrected. **`ScalarTransportPattern` gained a second value, `"sinusoidal_mode"` (TASK-034, added 2026-08-29)** -- the Heat Diffusion demo's own initial diff --git a/src/pyflow/configuration/schema.py b/src/pyflow/configuration/schema.py index 1090564..2dfd297 100644 --- a/src/pyflow/configuration/schema.py +++ b/src/pyflow/configuration/schema.py @@ -359,9 +359,11 @@ class SimulationConfig: section small the same way `FieldDisplayConfig` stays small. `velocity` is a prescribed (not solved) constant vector by default -- `velocity_solved` (TASK-031, added 2026-08-29) is what lets a run ask - for the other kind. Stage 5 is what eventually solves Navier-Stokes - for real; Stage 4 demos, and this section's own default, can only - have the prescribed kind. + for the other kind, and Stage 5's own `navier_stokes_step` (TASK-034, + 2026-08-29) is what the solved kind runs through. The default stays + prescribed: every Stage 4 demo needs that kind, and a solved run is a + deliberate opt-in rather than what an unconfigured `simulation:` + section silently becomes. **`velocity_solved: bool` is a separate field from `velocity_pattern`, deliberately -- not a widened `velocity_pattern` value.** A pattern @@ -584,14 +586,55 @@ def validate(self) -> None: getattr(self, name).validate(name) +def _periodic_prescriptions(face_config: BoundaryFaceConfig) -> list[tuple[str, object]]: + """Every field on a *periodic* `face_config` set to something other + than its own "prescribes nothing" value, as `(field_name, value)`. + + **Scoped to non-default values deliberately** (Stage 5 exit audit, + 2026-08-29): `velocity` defaults to `0.0` and `scalar_value`/ + `scalar_gradient` to `0.0`, so a rule phrased as "is set at all" + would reject every periodic configuration this repository already + ships. `velocity: null` is accepted alongside `0.0` -- it is the most + honest way to write "this face prescribes nothing", and + `examples/golden-demos/passive_scalar_transport.yaml` predates this + rule using exactly that form. + """ + findings: list[tuple[str, object]] = [] + if face_config.velocity not in (None, 0.0): + findings.append(("velocity", face_config.velocity)) + if face_config.pressure is not None: + findings.append(("pressure", face_config.pressure)) + if face_config.scalar_value != 0.0: + findings.append(("scalar_value", face_config.scalar_value)) + if face_config.scalar_gradient != 0.0: + findings.append(("scalar_gradient", face_config.scalar_gradient)) + if face_config.field_values: + findings.append(("field_values", face_config.field_values)) + if face_config.field_gradients: + findings.append(("field_gradients", face_config.field_gradients)) + return findings + + def _validate_boundary_conditions_jointly( mesh: MeshConfig, boundary_conditions: BoundaryConditionsConfig ) -> None: - """The three whole-configuration constraints `icds.md` records -- - periodic pairing, no dual prescription, and (only when every - boundary prescribes velocity) zero net flux -- checked together - because each is a relation between boundaries, not a property of - one (`docs/planning/roadmap.md` TASK-019's design decision). + """The four whole-configuration constraints `docs/architecture/ + icds.md`'s Boundary Condition entry records -- periodic pairing, no + prescription on a periodic boundary, no dual prescription, and (only + when every boundary prescribes velocity) zero net flux -- checked + together because each is a relation between boundaries, or between a + boundary's own type and what it prescribes, not a property of one + field (`docs/planning/roadmap.md` TASK-019's design decision). + + **The periodic-prescription rule is Stage 5 Completion Criterion 6's + second named rejection surface** ("a configuration that names a + boundary treatment velocity has no meaning for"), built by that + stage's exit audit on 2026-08-29 -- the one surface of the five that + criterion names which no Stage 5 task had discharged. Before it, a + periodic face carrying a prescribed velocity, pressure, scalar value + or per-field override loaded cleanly and was then ignored outright by + `assembly.py`'s `assemble_numerics`, which skips the + boundary-condition registry entirely for `type: periodic`. """ faces = {name: getattr(boundary_conditions, name) for name in _BOUNDARY_NAMES} @@ -602,6 +645,16 @@ def _validate_boundary_conditions_jointly( f"{paired!r} is {faces[paired].type!r}, not periodic" ) + for name, face_config in faces.items(): + if face_config.type != "periodic": + continue + for field_name, prescribed in _periodic_prescriptions(face_config): + raise ValueError( + f"numerics.boundary_conditions.{name} is periodic but prescribes " + f"{field_name}={prescribed!r}; a periodic boundary wraps to its paired " + "edge and reads no prescribed value, so this would be silently ignored" + ) + for name, face_config in faces.items(): if face_config.velocity is not None and face_config.pressure is not None: raise ValueError( @@ -641,13 +694,16 @@ class NumericsConfig: sole named MVP choice for each. No real numerical scheme shipped under `src/` through Stage 3 (Stage 3 Completion Criterion 1), so a validated name resolved only to `engine/numerics/assembly.py`'s - trivial, non-physical reference implementation -- Stage 4 replaces - each in turn, `advection` first (TASK-023, 2026-08-27): a validated - `"first_order_upwind"` now resolves to a real scheme - (`FirstOrderUpwindAdvection`); the other four still resolve to their - own reference implementation until their own task lands. See - `assembly.py`'s own docstring for why a reference implementation is - there at all, and for which name(s) still resolve to one. + trivial, non-physical reference implementation. **Stage 4 replaced + all six in turn and closed on 2026-08-28; every validated name now + resolves to a real scheme, and `assembly.py` holds zero `_Null*` + reference implementations.** This paragraph read "`advection` first + (TASK-023)... the other four still resolve to their own reference + implementation until their own task lands" until the Stage 5 exit + audit (2026-08-29) found it -- flatly contradicted by `assembly.py`'s + own module docstring three files away, which had said "zero `_Null*` + classes remain" since the day Stage 4 closed. See that docstring for + why a reference implementation was there at all. `timestep`/`linear_solver_tolerance`/`linear_solver_max_iterations` are plain positive numbers, not closed sets of names -- diff --git a/src/pyflow/engine/CLAUDE.md b/src/pyflow/engine/CLAUDE.md index 211f356..15a45d0 100644 --- a/src/pyflow/engine/CLAUDE.md +++ b/src/pyflow/engine/CLAUDE.md @@ -1428,9 +1428,16 @@ multi-stage `TimeIntegrator` (`RK4Integrator`) can ask for the derivative again at an intermediate state it constructs -- the calling-side half of the interface widening `time_integrator.py`'s own entry, above, records. `velocity` stays fixed across every evaluation within one `step` call: -`step` only ever advances `fields`, treating `velocity` as external input -(Stage 5's pressure coupling is what will eventually advance it), so -nothing about RK4's own sub-stages needed to change that. The +`step` only ever advances `fields`, treating `velocity` as external +input, so nothing about RK4's own sub-stages needed to change that. +**That is still true of `step` itself, and is what let `step` be reused +unchanged as `navier_stokes_step`'s own momentum predictor** (TASK-034, +Stage 5, 2026-08-29): the caller hands `step` the velocity assembled +from this timestep's own components, reads the advanced components back +out of the result, and corrects *those* -- so velocity is advanced +across a timestep without `step` ever having to advance it within one. +(This parenthetical read "Stage 5's pressure coupling is what will +eventually advance it" until the Stage 5 exit audit, 2026-08-29.) The `MismatchedMeshError` check stays exactly where it was, run once against the original `fields`/`velocity` before the closure is built -- an intermediate state `RK4Integrator` builds is always derived from `fields` diff --git a/src/pyflow/engine/numerics/CLAUDE.md b/src/pyflow/engine/numerics/CLAUDE.md index 81b4bb5..5083861 100644 --- a/src/pyflow/engine/numerics/CLAUDE.md +++ b/src/pyflow/engine/numerics/CLAUDE.md @@ -59,8 +59,10 @@ one of `adr/ADR-003`'s six configuration-selected components. **A real interface change again, the second since `time_integrator.py`'s**: `PressureCoupling.correct` gained a second parameter, `dt`, recorded as `adr/ADR-009-pressure-coupling-dt.md`. See `src/pyflow/engine/CLAUDE.md`'s -own entries for the real content: `PISO` performs a single, real -correction pass rather than the full multi-pass Issa algorithm, an +own entries for the real content: as this task shipped it, `PISO` +performed a single, real correction pass rather than the full +multi-pass Issa algorithm (**resolved by TASK-033, below** -- it is +genuinely multi-pass since 2026-08-29), an honestly-scoped limitation found and resolved by numerical investigation before any implementation code was written -- PyFlow's collocated mesh needs Rhie-Chow interpolation (and momentum-equation coefficients this diff --git a/tests/features/lid_driven_cavity.feature b/tests/features/lid_driven_cavity.feature index 8084955..5911fab 100644 --- a/tests/features/lid_driven_cavity.feature +++ b/tests/features/lid_driven_cavity.feature @@ -5,6 +5,21 @@ # `tests/features/navier_stokes_timestep.feature`'s own scenario, run # directly against the engine, not repeated here -- this file is the # reproducible, visible demonstration the public-API rule requires. +# +# **This demo's own fixture is the canonical benchmark geometry -- a unit +# square at the origin, unit lid speed -- and deliberately does not +# follow Criterion 7's degenerate-fixture rule** (recorded 2026-08-29 by +# the Stage 5 exit audit, which found the exception taken and unstated). +# The rule exists so a wrong implementation cannot agree with a right one +# by coincidence, and it is discharged where that risk actually lives: +# the validation scenario in `navier_stokes_timestep.feature`, which runs +# at a non-trivial origin against real reference data. Nothing here +# compares against a reference at all -- these three scenarios check that +# the documented command runs, that the rendered field is genuinely +# solved rather than the zero it started from, and that two runs agree +# bit for bit -- so a distinctive geometry would buy nothing and would +# cost this config file its recognisability as *the* lid-driven cavity a +# reader arrives expecting. Feature: Lid-Driven Cavity A square cavity, no-slip on every wall, the top wall moving diff --git a/tests/features/navier_stokes_timestep.feature b/tests/features/navier_stokes_timestep.feature index 492c403..1c8a9a6 100644 --- a/tests/features/navier_stokes_timestep.feature +++ b/tests/features/navier_stokes_timestep.feature @@ -48,6 +48,20 @@ Feature: Navier-Stokes Timestep When one Navier-Stokes timestep is taken Then the test double's own distinctive pressure value appears in the result, not a real solve's + # Criterion 13 names *two* substitution checks, not one -- LinearSolver + # "reaches the timestep only through the coupling and has never been + # exercised end-to-end either". Added by the Stage 5 exit audit + # (2026-08-29), which found the criterion had been recorded as met on + # the PressureCoupling half alone: `register_linear_solver` was never + # called anywhere outside `assembly.py`'s own built-in registration, so + # a `PISO` that constructed its own `ConjugateGradientSolver` instead of + # using the resolved one would have passed every scenario in this file. + + Scenario: The timestep's own pressure solve calls the configured LinearSolver, not one the coupling made for itself + Given a LinearSolver test double registered under its own name and selected by configuration + When one Navier-Stokes timestep is taken + Then the test double records that the timestep's own pressure solve asked it to solve + # -- Criterion 5: Couette flow, at solver tolerance rather than a loose one Scenario: Couette flow reaches the exact linear steady velocity profile @@ -61,11 +75,38 @@ Feature: Navier-Stokes Timestep # percentage at one; this is this project's most computationally # expensive scenario (three real runs to a measured steady state), # deliberately, per this task's own Design decision. + # + # **The absolute bound the third Then below asserts is 0.08, on the + # 17x17 mesh, and it is defended rather than asserted**: Criterion 5 + # requires it stated "in the feature file against the mesh actually + # used", and until the Stage 5 exit audit (2026-08-29) added it, this + # scenario made no absolute accuracy claim at all -- errors of 10, 5 + # and 2 would have satisfied monotonic decrease exactly as well as the + # real ones do. Measured on real runs at this exact origin, spacing and + # steadiness criterion: 0.1433 at 9x9, 0.0874 at 13x13, 0.0578 at + # 17x17, so the bound keeps roughly 38% margin at the finest while + # sitting well below what the coarsest scores. A velocity field of + # zeros -- the cheapest "solved nothing" failure -- scores 0.3366 + # against these same 34 tabulated points, nearly six times the bound. + # The convergence claim above it is still the gating one; this is what + # stops the trend being a trend towards nothing in particular. + # + # **This fixture takes two deliberate exceptions to Criterion 7's + # degenerate-fixture rule, and they are forced by the reference, not + # chosen.** That rule asks every fixture for a non-square mesh, a + # non-trivial origin, and a lid velocity that isn't 1. Ghia's Re = 100 + # profiles are nondimensionalised on a unit square driven at unit lid + # speed, so a non-square cavity or a different lid speed would not be + # comparable to the reference at all. The origin was never forced and + # is non-trivial ((0.35, -0.2), `_CAVITY_ORIGIN`), which is what makes + # the unit-cavity conversion in the vortex-centre check a real step + # rather than an identity. Scenario: The Ghia comparison error decreases monotonically across three mesh resolutions, and the finest shows the right vortex structure Given three lid-driven cavity meshes at increasing resolution, at Reynolds number 100 When each is run to a measured steady state Then the error against Ghia's centreline profiles decreases monotonically across the three resolutions + And the finest resolution's own error is below the stated absolute bound for that mesh And the finest resolution's primary vortex centre is within a stated distance of Ghia's own And the finest resolution shows both downstream secondary corner vortices, rotating opposite the primary diff --git a/tests/features/pressure_correction_loop.feature b/tests/features/pressure_correction_loop.feature index 1748a96..b94ff2d 100644 --- a/tests/features/pressure_correction_loop.feature +++ b/tests/features/pressure_correction_loop.feature @@ -26,10 +26,20 @@ Feature: Pressure Correction Loop Given a small, non-square, non-trivially-origined mesh And a provisional velocity field with real interior divergence, not aligned with either mesh axis + # The first Then below is Stage 5 Completion Criterion 3's own + # "the fixture's initial maximum divergence is stated and is orders of + # magnitude above the configured tolerance", added by that stage's exit + # audit (2026-08-29) -- true of this fixture all along (measured: 1.85 + # against a configured tolerance of 1e-4, four orders of magnitude) but + # asserted nowhere, which left the two Thens below it exactly as + # vacuous as the criterion warns: a corrector that did nothing at all + # would produce a *constant* sequence, which is non-increasing, and on + # a near-divergence-free fixture its last element would pass too. Scenario: A corrector loop against a real divergent field converges, with a non-increasing recorded divergence sequence Given a real linear solver When the field is corrected by PISO's own corrector loop - Then the recorded divergence sequence is non-increasing at every element + Then the recorded divergence sequence starts orders of magnitude above the configured tolerance + And the recorded divergence sequence is non-increasing at every element And its last element is at or below the configured tolerance Scenario: A corrector loop that only partially corrects divergence each pass still takes multiple genuine passes to converge diff --git a/tests/unit/CLAUDE.md b/tests/unit/CLAUDE.md index 669d941..f777bc6 100644 --- a/tests/unit/CLAUDE.md +++ b/tests/unit/CLAUDE.md @@ -287,11 +287,20 @@ thirteenth, and Stage 5's fourth and last module in this lineage** -- Stage 5's fifth task, binding `tests/features/ navier_stokes_timestep.feature`'s eleven scenarios: `simulation. navier_stokes_step`'s own predictor/corrector/corrected-state sequence, -both null tests, determinism, the ADR-003 substitution check, Couette -flow, the Ghia cavity comparison, the Taylor-Green emergent-phenomenon -pair, and kinetic-energy conservation. Same shape as every module before +both null tests, determinism, **both** ADR-003 substitution checks, +Couette flow, the Ghia cavity comparison, the Taylor-Green +emergent-phenomenon pair, and kinetic-energy conservation. *(This +enumeration read "the ADR-003 substitution check", singular, and claimed +eleven scenarios against a real ten -- both corrected 2026-08-29 by the +Stage 5 exit audit, which found Criterion 13's second substitution check +missing and built it. The count is eleven now because the eleventh +scenario exists, not because the claim was right; a hand-written count +of a thing that is countable is exactly what +`tools/generators/generate_status_report.py` gates repository-wide and +nothing gates per file.)* Same shape as every module before it: its own `_Context` dataclass, its own local doubles -(`_MarkerPressureCoupling`, the substitution check's own test double), +(`_MarkerPressureCoupling` and `_RecordingLinearSolver`, the two +substitution checks' own test doubles), no golden-demo config file or CLI run for this file -- the two golden demos this task also builds (Lid-Driven Cavity, Heat Diffusion) are bound separately, in `tests/golden/`, per that directory's own @@ -303,12 +312,33 @@ earlier module in this list uses for its own fixture data -- committed, cited reference data is not a test double, and belongs where any other module needing the same table could import it too. +**Three of this module's scenarios were amended 2026-08-29 by the Stage +5 exit audit**, which found the criteria they discharge each had a +clause nothing checked. A `_RecordingLinearSolver` double joins +`_MarkerPressureCoupling` (Criterion 13 names *two* substitution checks; +only the `PressureCoupling` one existed, and `register_linear_solver` +had never been called outside `assembly.py` -- verified to have teeth by +mutation: making `PISO.__init__` discard its injected solver leaves the +rest of the suite green and fails only this). The Ghia scenario gained a +stated absolute error bound at its finest resolution (Criterion 5 asks +for one "in the feature file against the mesh actually used"; monotonic +decrease alone would be satisfied by errors of 10, 5 and 2). And the +cavity mesh moved to a non-trivial origin, which turns the unit-cavity +conversion in the vortex-centre check from an identity into a real step +-- the one clause of Criterion 7's degenerate-fixture rule this fixture +could honour, the other two (square mesh, unit lid speed) being forced +by Ghia's own nondimensionalisation and recorded in the feature file +rather than quietly taken. + **The Ghia cavity scenario is this project's most computationally expensive test, deliberately** -- three real runs (resolutions 9, 13, 17) to a measured steady state, not a fixed step count. Chosen odd so the vertical/horizontal centreline always lands exactly on a column/row of cell centres, no interpolation needed against Ghia's own tabulated -points. **A real, measured performance fix was needed to keep this +points. The profile comparison indexes cells rather than coordinates, so +the mesh origin does not affect it at all -- measured, not assumed: a +full three-resolution run at the shifted origin scores 0.1433, 0.0874, +0.0578, the same figures the run at the origin produced. **A real, measured performance fix was needed to keep this runtime tractable at all**: `PISO._poisson_matrix` used to rebuild an `O(num_cells * num_faces)` matrix every single timestep even though nothing about it changes between timesteps on a fixed mesh -- caching it diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py index ea5ede1..34304a9 100644 --- a/tests/unit/test_bootstrap.py +++ b/tests/unit/test_bootstrap.py @@ -13,6 +13,7 @@ import torch from pyflow.bootstrap import bootstrap +from pyflow.engine.numerics.divergence import GreenGaussDivergence from pyflow.engine.scalar_field import ScalarField from pyflow.engine.vector_field import VectorField @@ -168,6 +169,85 @@ def test_bootstrap_with_velocity_solved_advances_velocitys_own_components( assert not torch.equal(early_values, later_u.values) +_SOLVED_WITH_SCALAR_CONFIG = ( + "rendering:\n backend: offscreen\n" + "mesh:\n origin: [0.4, -0.3]\n extent: [8, 6]\n spacing: [0.2, 0.25]\n" + "numerics:\n timestep: 0.005\n" + "fluid:\n viscosity: 0.05\n" + "simulation:\n" + " scalar_pattern: gaussian_blob\n" + " velocity_pattern: uniform\n" + " velocity: [1.3, -0.7]\n" + " velocity_solved: true\n" +) +"""A uniform interior velocity inside a closed no-slip box -- the walls +immediately generate real divergence, which is what makes the assertion +below discriminating. Distinct factors throughout (non-square mesh, +non-trivial origin, unequal spacing, a velocity aligned with neither +axis), per `docs/practices.md`. +""" + +_SOLVED_DIVERGENCE_BOUND = 0.5 +"""Measured on real runs of both behaviours before being chosen, not +guessed (Stage 5 exit audit, 2026-08-29). Pressure-corrected, this +fixture's own maximum divergence falls 2.30 -> 0.47 -> 0.057 over 1, 10 +and 40 frames; transported by plain `step` and never corrected, it sits +at 9.16 -> 8.24 -> 6.95 across the same frames. The bound has roughly +nine times' margin below the corrected value at 40 frames and is more +than an order of magnitude under the uncorrected one, so it separates the +two behaviours rather than merely recording one of them. + +**Not driven to solver tolerance, and that is expected**: +`GreenGaussDivergence`'s naive face-averaged divergence is deliberately +not the Rhie-Chow-consistent measure `PISO`'s corrector loop drives to +its own tolerance -- see `tests/golden/test_lid_driven_cavity.py`'s own +module docstring, which declines to assert an absolute bound for exactly +this reason. What this test claims is the weaker, sufficient thing: the +divergence collapses instead of persisting. +""" + + +def test_bootstrap_with_velocity_solved_and_a_scalar_pressure_corrects_the_velocity( + tmp_path: Path, +) -> None: + """A solved velocity carrying a scalar alongside it is genuinely + incompressible, not merely self-advected. + + **This was a real defect until the Stage 5 exit audit (2026-08-29) + found it**, and a configuration-reachable one: + `simulation.velocity_solved` had two live paths, and only the + velocity-*only* one (`_add_solved_velocity_rendering`, TASK-034) + called `navier_stokes_step`. Adding a `scalar_pattern` silently + switched a run to `_add_passive_scalar_transport`, which transported + velocity's components like any other scalar and never + pressure-corrected them -- so a configuration saying "solved" + produced a velocity that was not incompressible, with no error and + nothing rendered differently. Recorded as a known gap in two + `CLAUDE.md` files at the time and against no completion criterion, + which is why it survived Stage 5's own Criterion 12 being marked met. + """ + config_file = tmp_path / "config.yaml" + config_file.write_text(_SOLVED_WITH_SCALAR_CONFIG) + + window = bootstrap(config_file, max_frames=40) + + assert window.simulation_fields is not None + assert window.assembled_numerics is not None + u = window.simulation_fields[VectorField.component_name("velocity", 0)] + v = window.simulation_fields[VectorField.component_name("velocity", 1)] + assert isinstance(u, ScalarField) + assert isinstance(v, ScalarField) + velocity = VectorField.assemble([u, v], "velocity") + divergence = GreenGaussDivergence(window.assembled_numerics.boundary_conditions, {}).divergence( + velocity + ) + + assert float(divergence.abs().max()) < _SOLVED_DIVERGENCE_BOUND, ( + "the solved velocity's own divergence did not collapse; this path is transporting " + "velocity without pressure-correcting it" + ) + + def test_bootstrap_backend_override(tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" config_file.write_text( diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 1e8ac70..bff6384 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -928,6 +928,72 @@ def test_load_config_rejects_periodic_without_its_paired_boundary( load_config(config_file) +# Stage 5 Completion Criterion 6's second named rejection surface -- "a +# configuration that names a boundary treatment velocity has no meaning +# for" -- built by that stage's exit audit (2026-08-29), which found it +# was the one surface of the five no task had discharged. A periodic +# boundary wraps to its opposite edge and reads none of these fields +# (`assembly.py`'s own `assemble_numerics` skips the boundary-condition +# registry entirely for `type: periodic`), so a configuration setting one +# was silently ignored rather than rejected -- exactly the +# "plausible-looking wrong answer" shape `docs/practices.md` names. Lives +# here, not in a `.feature` file, because it is a third rule of +# `_validate_boundary_conditions_jointly` and its two siblings (periodic +# pairing above, dual prescription below) are both tested here. +@pytest.mark.parametrize( + ("prescription", "expected"), + [ + (" velocity: 1.5\n", "velocity"), + (" pressure: 0.0\n", "pressure"), + (" scalar_value: 2.5\n", "scalar_value"), + (" scalar_gradient: 2.5\n", "scalar_gradient"), + (" field_values:\n tracer: 1.0\n", "field_values"), + (" field_gradients:\n tracer: 1.0\n", "field_gradients"), + ], +) +def test_load_config_rejects_a_prescription_on_a_periodic_boundary( + prescription: str, expected: str, tmp_path: Path +) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text( + "numerics:\n" + " boundary_conditions:\n" + " east:\n" + " type: periodic\n" + prescription + " west:\n type: periodic\n" + ) + + with pytest.raises(ValueError, match=f"numerics.boundary_conditions.east.*{expected}"): + load_config(config_file) + + +def test_load_config_accepts_a_periodic_boundary_carrying_only_default_prescriptions( + tmp_path: Path, +) -> None: + # The complement of the rejection above, and the reason it is scoped to + # *non-default* values: `velocity` defaults to `0.0` and `scalar_value`/ + # `scalar_gradient` to `0.0`, so rejecting "is set at all" would reject + # every periodic configuration this repository already ships + # (`examples/golden-demos/heat_diffusion.yaml` sets all four faces + # periodic). `velocity: null` is accepted too -- it is the most + # honest way to write "this face prescribes nothing". + config_file = tmp_path / "config.yaml" + config_file.write_text( + "numerics:\n" + " boundary_conditions:\n" + " east:\n" + " type: periodic\n" + " velocity: null\n" + " scalar_value: 0.0\n" + " west:\n" + " type: periodic\n" + " velocity: 0.0\n" + ) + + config = load_config(config_file) + + assert config.numerics.boundary_conditions.east.type == "periodic" + + def test_load_config_rejects_velocity_and_pressure_both_prescribed_on_one_boundary( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_generate_status_report.py b/tests/unit/test_generate_status_report.py index cd88ac2..1eb9f5e 100644 --- a/tests/unit/test_generate_status_report.py +++ b/tests/unit/test_generate_status_report.py @@ -272,6 +272,103 @@ def test_matching_scenario_claim_produces_no_finding() -> None: assert find_drift([], live, roadmap_text) == [] +def test_a_scenario_claim_broken_across_a_line_is_still_matched() -> None: + """The real claim in `docs/planning/roadmap.md` wraps: "**79 of those + 653\\nare Gherkin scenarios rather than pytest functions**". + + **This is a regression test for the check being silently inert**, + found by the Stage 5 exit audit (2026-08-29). The pattern used a + literal space before "are", so a hard line break in that position + made `search` return `None` -- and a `None` here means "no claim to + check", not "claim is wrong", so the whole rule quietly stopped + applying at some point after it first ran. The roadmap's count was + 79 against a live 94 by the time this was found: fifteen scenarios of + undetected drift, behind a gate that reported success. Exactly the + failure mode `make check-scenarios` exists to prevent for feature + files, reproduced in the checker itself. + """ + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=94) + roadmap_text = "**79 of those 653\nare Gherkin scenarios rather than pytest functions**" + findings = find_drift([], live, roadmap_text) + assert any("79" in f and "94" in f for f in findings) + + +# --- find_drift: README's Current Phase ------------------------------------ +# +# Added by the Stage 5 exit audit (2026-08-29). README.md's "Current +# Phase" section has now gone a full stage stale twice -- the Stage 2 exit +# audit found it claiming the project "is beginning Stage 2", and this one +# found it claiming Stage 5 was "not yet started" on the day Stage 5 +# closed. Both passed `make ci` cleanly, because nothing read that +# sentence. This makes the front door's own status a checked artifact +# rather than a tense (`docs/practices.md`, "Let a checked artifact carry +# status, not a tense"), gated through `make check-status`, which is +# already in `make ci` -- no new target, and no second place that decides +# which stage is current. + + +def _stages_through(complete_through: int, total: int = 6) -> list[StageStatus]: + return [ + StageStatus( + number=n, + name=f"Stage {n}", + criteria_claimed_total=None, + criteria_claimed_met=None, + criteria_actual_total=0, + complete_claimed=n <= complete_through, + status_date="2026-08-29" if n <= complete_through else None, + ) + for n in range(total) + ] + + +def _readme(phase_body: str) -> str: + return f"## Where to Start\n\nread things\n\n## Current Phase\n\n{phase_body}\n\n## Roadmap\n" + + +def test_readme_naming_the_current_stage_produces_no_finding() -> None: + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + readme = _readme("Stage 5 -- Additional Physical Fields -- not yet started.") + assert find_drift(_stages_through(4), live, "", readme_text=readme) == [] + + +def test_readme_naming_a_stage_that_has_already_closed_is_reported() -> None: + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + readme = _readme("Stage 4 -- First Numerical Methods -- not yet started.") + findings = find_drift(_stages_through(4), live, "", readme_text=readme) + assert any("Current Phase" in f and "Stage 4" in f and "Stage 5" in f for f in findings) + + +def test_readme_with_no_current_phase_section_is_reported() -> None: + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + findings = find_drift(_stages_through(4), live, "", readme_text="## Roadmap\n\nnothing\n") + assert any("Current Phase" in f for f in findings) + + +def test_readme_current_phase_naming_no_stage_at_all_is_reported() -> None: + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + readme = _readme("Work is proceeding nicely, thank you for asking.") + findings = find_drift(_stages_through(4), live, "", readme_text=readme) + assert any("Current Phase" in f for f in findings) + + +def test_readme_is_not_checked_when_no_text_is_supplied() -> None: + """Every other `find_drift` caller in this module passes three + positional arguments; `readme_text=None` keeps that the "not checked" + case rather than a silent pass on empty text.""" + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + assert find_drift(_stages_through(4), live, "") == [] + + +def test_readme_is_not_checked_once_every_stage_is_complete() -> None: + """`_current_stage` is `None` when nothing is pending -- there is no + current stage for README to disagree with, so this reports nothing + rather than crashing.""" + live = LiveFacts(claude_md_count=0, test_count=0, scenario_count=0) + readme = _readme("Stage 2 -- whatever.") + assert find_drift(_stages_through(5, total=6), live, "", readme_text=readme) == [] + + # --- render_status_md ----------------------------------------------------- diff --git a/tests/unit/test_navier_stokes_timestep.py b/tests/unit/test_navier_stokes_timestep.py index 2706363..800e0a5 100644 --- a/tests/unit/test_navier_stokes_timestep.py +++ b/tests/unit/test_navier_stokes_timestep.py @@ -57,12 +57,17 @@ from pyflow.engine.numerics.assembly import ( AssembledNumerics, assemble_numerics, + register_linear_solver, register_pressure_coupling, ) from pyflow.engine.numerics.boundary_condition import BoundaryCondition, DirichletBoundaryCondition from pyflow.engine.numerics.diffusion import CentralDifferenceDiffusion from pyflow.engine.numerics.divergence import GreenGaussDivergence -from pyflow.engine.numerics.linear_solver import ConjugateGradientSolver +from pyflow.engine.numerics.linear_solver import ( + ConjugateGradientSolver, + LinearSolver, + LinearSolverResult, +) from pyflow.engine.numerics.pressure_coupling import PISO, PressureCoupling from pyflow.engine.numerics.time_integrator import RK4Integrator from pyflow.engine.scalar_field import PressureField, ScalarField @@ -122,6 +127,35 @@ def _real_numerics( ) +class _RecordingLinearSolver(LinearSolver): + """A real Conjugate Gradient solve that records having been asked -- + exists only to prove the timestep's own pressure solve runs through + whichever `LinearSolver` `assemble_numerics` resolved, not one the + `PressureCoupling` constructed for itself (Stage 5 Completion + Criterion 13's *second* substitution check, added by that stage's + exit audit on 2026-08-29). + + Delegates rather than returning a marker value, deliberately: the + claim under test is "the configured object is the one that runs", not + "a wrong answer propagates", so the physics stays real and the + scenario cannot pass or fail for any reason other than the call + itself. `_MarkerPressureCoupling` above takes the opposite approach + for the opposite reason -- a `PressureCoupling`'s own return value + *is* observable in `NavierStokesStepResult`, so a marker is the + directer evidence there; a `LinearSolver`'s is not, since it reaches + the result only through a pressure field a real solve would produce + too. + """ + + def __init__(self, tolerance: float, max_iterations: int) -> None: + self._inner = ConjugateGradientSolver(tolerance, max_iterations) + self.solve_calls = 0 + + def solve(self, matrix: torch.Tensor, rhs: torch.Tensor) -> LinearSolverResult: + self.solve_calls += 1 + return self._inner.solve(matrix, rhs) + + @dataclass class _Context: mesh: StructuredCartesianMesh @@ -141,6 +175,7 @@ class _Context: cavity_finest_fields: dict[str, Field] = field(default_factory=dict) cavity_finest_mesh: StructuredCartesianMesh | None = None cavity_finest_extent: tuple[int, int] = (0, 0) + recording_solver: _RecordingLinearSolver | None = None # -- Given ------------------------------------------------------------- @@ -231,6 +266,38 @@ def _given_marker_pressure_coupling() -> _Context: return ctx +@given( + "a LinearSolver test double registered under its own name and selected by configuration", + target_fixture="ctx", +) +def _given_recording_linear_solver() -> _Context: + mesh = _no_slip_mesh() + name = "test_only_recording_linear_solver" + recorded: list[_RecordingLinearSolver] = [] + + def factory(tolerance: float, max_iterations: int) -> LinearSolver: + solver = _RecordingLinearSolver(tolerance, max_iterations) + recorded.append(solver) + return solver + + register_linear_solver(name, factory) + config = NumericsConfig( + linear_solver=name, # type: ignore[arg-type] + boundary_conditions=BoundaryConditionsConfig( + north=BoundaryFaceConfig(type="dirichlet", velocity=None, scalar_value=0.0), + south=BoundaryFaceConfig(type="dirichlet", velocity=None, scalar_value=0.0), + east=BoundaryFaceConfig(type="dirichlet", velocity=None, scalar_value=0.0), + west=BoundaryFaceConfig(type="dirichlet", velocity=None, scalar_value=0.0), + ), + ) + numerics = assemble_numerics(config) + assert len(recorded) == 1, "assemble_numerics did not construct the registered solver" + ctx = _Context(mesh=mesh, numerics=numerics, recording_solver=recorded[0]) + velocity = _divergent_velocity(mesh) + ctx.fields = {c.name: c for c in velocity.decompose()} + return ctx + + _COUETTE_LID_SPEED = 1.7 _COUETTE_VISCOSITY = 0.8 _COUETTE_EXTENT = (3, 8) @@ -451,6 +518,15 @@ def _then_marker_pressure_present(ctx: _Context) -> None: ) +@then("the test double records that the timestep's own pressure solve asked it to solve") +def _then_recording_solver_was_called(ctx: _Context) -> None: + assert ctx.recording_solver is not None + assert ctx.recording_solver.solve_calls > 0, ( + "the configured LinearSolver was never asked to solve; the timestep's pressure " + "coupling must be using a solver it constructed for itself" + ) + + @then( "the steady streamwise velocity profile matches the exact linear Couette solution at " "solver tolerance" @@ -617,6 +693,48 @@ def _then_taylor_green_mismatches(tg_result: _TaylorGreenContext) -> None: _CAVITY_VISCOSITY = _CAVITY_LID_SPEED / _CAVITY_REYNOLDS_NUMBER # Re = U*L/nu, L = 1 _CAVITY_STEADY_RESIDUAL_TOLERANCE = 1e-6 _CAVITY_MAX_STEPS = 6000 +_CAVITY_ORIGIN = (0.35, -0.2) +"""A deliberately non-trivial mesh origin, added 2026-08-29 by the Stage +5 exit audit (Completion Criterion 7's degenerate-fixture rule). + +**Two of that rule's three cavity-relevant clauses cannot be honoured +here, and one can.** Ghia, Ghia & Shin (1982)'s Re = 100 profiles are +nondimensionalised on a *unit square* driven at a *unit* lid speed, so a +non-square cavity or a lid velocity other than 1.0 would not be +comparable to the reference at all -- those two exceptions are forced by +the reference frame, not chosen, and are recorded in this scenario's own +feature file where a reader meets them. The origin was never forced: it +only ever has to be subtracted back out where a comparison is made in +unit-cavity coordinates, which is exactly one place +(`_then_primary_vortex_near_ghia`). Shifting it makes that conversion a +real step rather than an identity, so a vortex detector that quietly +assumed the mesh starts at the origin now fails. + +The Ghia *profile* comparison is untouched by this: it indexes cells +(`mesh.cell_id`), never coordinates, so its errors are identical at any +origin -- verified directly by a full three-resolution run at this +origin (0.14328, 0.08741, 0.05775) before the change was committed. +""" +_CAVITY_FINEST_ERROR_TOLERANCE = 0.08 +"""Stage 5 Completion Criterion 5's own "the absolute tolerance is stated +and defended in the feature file against the mesh actually used" -- +undischarged until the Stage 5 exit audit (2026-08-29) added it, which +left monotonic decrease as the only accuracy claim this scenario made, +and errors of 10, 5 and 2 would satisfy that exactly as well as the real +ones do. + +**Defended against three measured numbers, not chosen for comfort.** On +the finest mesh here (17x17) the real run scores 0.0578, so this bound +keeps roughly 38% margin. The coarsest (9x9) scores 0.1433, so this is +genuinely a claim about the finest mesh rather than one any resolution +would pass. And a velocity field of zeros everywhere -- the cheapest +possible "solved nothing" failure -- scores 0.3366 against these same +34 tabulated points, so the bound sits nearly six times tighter than +doing nothing at all. It is not tight enough to call first-order upwind +accurate at this resolution, and is not meant to be: Criterion 5's +gating claim is the convergence one, and `docs/implementation/ +upgrade-paths.md` is where a less diffusive scheme lands. +""" # Ghia's own primary-vortex distance bound: measured directly on a real # (coarser, n=14) run before being trusted -- the detected centre landed # 0.019 away from Ghia's own (0.6172, 0.7344) in unit-cavity coordinates. @@ -641,7 +759,7 @@ class _CavityRun: def _run_cavity(n: int) -> _CavityRun: - mesh = StructuredCartesianMesh(origin=(0.0, 0.0), spacing=(1.0 / n, 1.0 / n), extent=(n, n)) + mesh = StructuredCartesianMesh(origin=_CAVITY_ORIGIN, spacing=(1.0 / n, 1.0 / n), extent=(n, n)) lid = DirichletBoundaryCondition(0.0, {_U_NAME: _CAVITY_LID_SPEED, _V_NAME: 0.0}) wall = DirichletBoundaryCondition(0.0) bcs: dict[str, BoundaryCondition] = {"north": lid, "south": wall, "east": wall, "west": wall} @@ -716,6 +834,15 @@ def _then_cavity_error_decreases(cavity_runs: list[_CavityRun]) -> None: assert current < previous, f"error did not decrease monotonically: {errors}" +@then("the finest resolution's own error is below the stated absolute bound for that mesh") +def _then_finest_error_within_absolute_bound(cavity_runs: list[_CavityRun]) -> None: + run = _finest_run(cavity_runs) + assert run.error < _CAVITY_FINEST_ERROR_TOLERANCE, ( + f"resolution {run.resolution} scored {run.error} against Ghia's centreline profiles, " + f"above this mesh's own stated bound of {_CAVITY_FINEST_ERROR_TOLERANCE}" + ) + + def _finest_run(cavity_runs: list[_CavityRun]) -> _CavityRun: return max(cavity_runs, key=lambda run: run.resolution) @@ -755,11 +882,18 @@ def _then_primary_vortex_near_ghia(cavity_runs: list[_CavityRun]) -> None: best = (magnitude, i, j) assert best is not None _magnitude, i, j = best - x, y = run.mesh.cell_centroid(run.mesh.cell_id(i, j)) + mesh_x, mesh_y = run.mesh.cell_centroid(run.mesh.cell_id(i, j)) + # Ghia's coordinates are unit-cavity ones, measured from the cavity's + # own bottom-left corner; this mesh does not start there + # (`_CAVITY_ORIGIN`), so the conversion is a real step, not an + # identity. The cavity is one unit across by construction (spacing + # 1/n, extent n), so subtracting the origin is the whole conversion. + x, y = mesh_x - _CAVITY_ORIGIN[0], mesh_y - _CAVITY_ORIGIN[1] ghia_x, ghia_y = PRIMARY_VORTEX_CENTER distance = math.hypot(x - ghia_x, y - ghia_y) assert distance < _CAVITY_VORTEX_DISTANCE_TOLERANCE, ( - f"detected primary vortex at ({x}, {y}), {distance} from Ghia's own {PRIMARY_VORTEX_CENTER}" + f"detected primary vortex at ({x}, {y}) in unit-cavity coordinates, {distance} " + f"from Ghia's own {PRIMARY_VORTEX_CENTER}" ) diff --git a/tests/unit/test_pressure_correction_loop.py b/tests/unit/test_pressure_correction_loop.py index fea4423..0b5cb1b 100644 --- a/tests/unit/test_pressure_correction_loop.py +++ b/tests/unit/test_pressure_correction_loop.py @@ -181,6 +181,27 @@ def _when_corrected(ctx: _Context) -> None: # -- Then ------------------------------------------------------------------ +_INITIAL_DIVERGENCE_MARGIN = 1_000.0 +"""How far above `_TOLERANCE` this fixture's own pre-correction divergence +must sit for the two assertions after it to mean anything -- "orders of +magnitude", made a number (Stage 5 Completion Criterion 3). Measured +directly on this exact fixture before being chosen: the recorded sequence +starts at 1.85 against a tolerance of 1e-4, so roughly four orders of +magnitude, and this bound keeps a full order of margin below that rather +than being set at the measured value. +""" + + +@then("the recorded divergence sequence starts orders of magnitude above the configured tolerance") +def _then_starts_far_above_tolerance(ctx: _Context) -> None: + assert ctx.history is not None + assert ctx.history[0] > _INITIAL_DIVERGENCE_MARGIN * _TOLERANCE, ( + f"fixture's initial divergence {ctx.history[0]} is not orders of magnitude above the " + f"configured tolerance {_TOLERANCE}; the non-increasing and reaches-tolerance " + "assertions below it would pass for a corrector that did nothing" + ) + + @then("the recorded divergence sequence is non-increasing at every element") def _then_non_increasing(ctx: _Context) -> None: assert ctx.history is not None diff --git a/tools/generators/CLAUDE.md b/tools/generators/CLAUDE.md index 8ffef9c..dd4954c 100644 --- a/tools/generators/CLAUDE.md +++ b/tools/generators/CLAUDE.md @@ -83,6 +83,24 @@ drift the first time it ran: `docs/planning/roadmap.md`'s own test/ scenario-count paragraph was off by 136 tests and 5 scenarios, fixed in the same change that added the check. +**It gained one non-roadmap check 2026-08-29 (Stage 5 exit audit): +`README.md`'s own "Current Phase" section.** The drift check now fails +when the stage README names there is not the roadmap's first stage not +marked complete (`_frontier_stage`). That section had gone a full stage +stale twice -- the Stage 2 audit found it claiming the project "is +beginning Stage 2"; the Stage 5 audit found it claiming Stage 5 was "not +yet started" on the day Stage 5 closed -- and both passed `make ci`, +because nothing read the sentence. **It adds no second source of +truth**: the roadmap still decides which stage is current, and README is +checked against it, never consulted for it. This is the same structural- +fact discipline as every other rule here (a stage number parsed out of a +named section, compared to a stage number parsed out of the roadmap), not +a judgement about whether README's prose is otherwise accurate. It lives +here rather than in a new `tools/validators/` script for the reason the +module docstring gives: the fact it needs -- which stage is current -- +is already computed here, and a second script would have to recompute +it. + **Deliberately does not verify everything the roadmap claims.** Which criteria within a stage are *met* (as opposed to how many total exist) is exactly the kind of per-item reading `check_claims.py` diff --git a/tools/generators/generate_config_template.py b/tools/generators/generate_config_template.py index 551fd9e..3af300d 100644 --- a/tools/generators/generate_config_template.py +++ b/tools/generators/generate_config_template.py @@ -266,35 +266,46 @@ "prescribe velocity or pressure, never both (see .pressure " "below); if every one of the four faces prescribes a velocity, " "they must sum to zero net flux, weighted by each face's " - "physical length. Invalid: prescribing both velocity and " - "pressure on one face, or a nonzero net flux across all four." + "physical length. On a periodic face, only null or 0.0 -- a " + "periodic boundary wraps to its pair and reads no prescribed " + "value. Invalid: prescribing both velocity and pressure on one " + "face, a nonzero net flux across all four, or a nonzero " + "velocity on a periodic face." ), "numerics.boundary_conditions..pressure": ( "Valid: null (not prescribed here) or a number. Mutually " - "exclusive with .velocity above on the same face." + "exclusive with .velocity above on the same face, and must be " + "null on a periodic face. Invalid: any number on a periodic " + "face." ), "numerics.boundary_conditions..scalar_value": ( "Valid: any finite number -- the Dirichlet value a transported " "scalar field is given at this face. Only read when type is " - '"dirichlet"; harmless but unused otherwise.' + '"dirichlet"; harmless but unused when "neumann", and must stay ' + 'at its 0.0 default when "periodic", which reads no prescribed ' + "value at all. Invalid: a nonzero value on a periodic face." ), "numerics.boundary_conditions..scalar_gradient": ( "Valid: any finite number -- the Neumann gradient a transported " "scalar field is given at this face. Only read when type is " - '"neumann"; harmless but unused otherwise.' + '"neumann"; harmless but unused when "dirichlet", and must stay ' + 'at its 0.0 default when "periodic", which reads no prescribed ' + "value at all. Invalid: a nonzero value on a periodic face." ), "numerics.boundary_conditions..field_values": ( "Valid: a mapping of field name to a finite number -- a " "per-field override of scalar_value above, e.g. {u: 1.0, v: " "0.0} for a moving lid's two velocity components. A field name " "absent from this mapping falls back to scalar_value. Only read " - 'when type is "dirichlet". Invalid: a non-finite value.' + 'when type is "dirichlet", and must be empty when "periodic". ' + "Invalid: a non-finite value, or any entry on a periodic face." ), "numerics.boundary_conditions..field_gradients": ( "Valid: a mapping of field name to a finite number -- " "field_values' own Neumann counterpart, overriding " "scalar_gradient per field name. Only read when type is " - '"neumann". Invalid: a non-finite value.' + '"neumann", and must be empty when "periodic". Invalid: a ' + "non-finite value, or any entry on a periodic face." ), } diff --git a/tools/generators/generate_status_report.py b/tools/generators/generate_status_report.py index 9f5a1b7..a424568 100644 --- a/tools/generators/generate_status_report.py +++ b/tools/generators/generate_status_report.py @@ -75,6 +75,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ROADMAP_PATH = Path("docs") / "planning" / "roadmap.md" +README_PATH = Path("README.md") STATUS_MD_PATH = REPO_ROOT / "docs" / "planning" / "status.md" STATUS_HTML_PATH = REPO_ROOT / "build" / "status.html" @@ -325,16 +326,84 @@ def gather_live_facts(root: Path = REPO_ROOT) -> LiveFacts: # --- Drift detection ----------------------------------------------------- -CLAUDE_MD_CLAIM = re.compile(r"(\d+) files exist as of \d{4}-\d{2}-\d{2}") -TEST_COUNT_CLAIM = re.compile(r"(\d+) tests at \d+% as of (\d{4}-\d{2}-\d{2})") -SCENARIO_CLAIM = re.compile(r"(\w+) of (?:those\s+)?\d+ are Gherkin scenarios") +# Every literal space in these three patterns is `\s+`, not " ". +# `docs/planning/roadmap.md` is hard-wrapped prose, so any of these +# phrases can acquire a line break in the middle at any edit -- and a +# pattern that stops matching does not report drift, it reports *nothing +# to check*, which reads identically to a clean pass. That is exactly +# what happened to `SCENARIO_CLAIM`: it matched when this script was +# first written, a later edit wrapped the line between "653" and "are", +# and the rule was silently inert until the Stage 5 exit audit +# (2026-08-29) found the roadmap claiming 79 scenarios against a live 94. +CLAUDE_MD_CLAIM = re.compile(r"(\d+)\s+files\s+exist\s+as\s+of\s+\d{4}-\d{2}-\d{2}") +TEST_COUNT_CLAIM = re.compile(r"(\d+)\s+tests\s+at\s+\d+%\s+as\s+of\s+(\d{4}-\d{2}-\d{2})") +SCENARIO_CLAIM = re.compile(r"(\w+)\s+of\s+(?:those\s+)?\d+\s+are\s+Gherkin\s+scenarios") + + +README_CURRENT_PHASE = re.compile( + r"^##\s+Current Phase\s*$(?P.*?)(?=^##\s|\Z)", re.MULTILINE | re.DOTALL +) +README_PHASE_STAGE = re.compile(r"\bStage\s+(\d+)\b") + + +def _readme_phase_findings(readme_text: str, stages: list[StageStatus]) -> list[str]: + """`README.md`'s "Current Phase" section against the roadmap's own + first stage not marked complete. + + **Why this is checked at all**: that section has gone a full stage + stale twice -- the Stage 2 exit audit found it saying the project "is + beginning Stage 2", and the Stage 5 exit audit (2026-08-29) found it + saying Stage 5 was "not yet started" on the day Stage 5 closed. Both + passed `make ci`. Reads the *first* `Stage N` in the section, which is + the phase sentence's own subject; the per-stage recap below it names + every earlier stage too, and is deliberately not what this matches + against. + + This adds no second source of truth: which stage is current still + comes from `docs/planning/roadmap.md`'s own status headings, via + `_frontier_stage`. README is checked *against* it, never consulted for + it. + """ + section = README_CURRENT_PHASE.search(readme_text) + if section is None: + return [ + "README.md has no `## Current Phase` section, so nothing states which stage " + "the project is on where a reader arrives first." + ] + current = _frontier_stage(stages) + if current is None: + return [] + named = README_PHASE_STAGE.search(section.group("body")) + if named is None: + return [ + "README.md's Current Phase section names no stage at all; the roadmap's own " + f"first stage not marked complete is Stage {current.number} ({current.name})." + ] + if int(named.group(1)) != current.number: + return [ + f"README.md's Current Phase section names Stage {named.group(1)}, but the " + f"roadmap's own first stage not marked complete is Stage {current.number} " + f"({current.name})." + ] + return [] -def find_drift(stages: list[StageStatus], live: LiveFacts, roadmap_text: str) -> list[str]: +def find_drift( + stages: list[StageStatus], + live: LiveFacts, + roadmap_text: str, + readme_text: str | None = None, +) -> list[str]: """Every disagreement between `docs/planning/roadmap.md` and the live repository, as human-readable strings. Empty means clean. + + `readme_text` is optional and `None` means "not checked" -- the shape + every unit test in `tests/unit/test_generate_status_report.py` that + predates it relies on. `main()` always passes the real file. """ findings: list[str] = [] + if readme_text is not None: + findings.extend(_readme_phase_findings(readme_text, stages)) for stage in stages: if stage.criteria_claimed_total is None: @@ -671,12 +740,15 @@ def main() -> int: check_only = "--check" in sys.argv[1:] roadmap_text = (REPO_ROOT / ROADMAP_PATH).read_text(encoding="utf-8") + readme_text = (REPO_ROOT / README_PATH).read_text(encoding="utf-8") stages = parse_roadmap(roadmap_text) live = gather_live_facts() - drift = find_drift(stages, live, roadmap_text) + drift = find_drift(stages, live, roadmap_text, readme_text=readme_text) if drift: - print("Status report refused: roadmap.md disagrees with the live repository.\n") + print( + "Status report refused: roadmap.md or README.md disagrees with the live repository.\n" + ) for finding in drift: print(f"- {finding}") print( diff --git a/uv.lock b/uv.lock index fcb9ab7..9292894 100644 --- a/uv.lock +++ b/uv.lock @@ -870,7 +870,7 @@ wheels = [ [[package]] name = "pyflow" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "glfw" },