From 75c93df93ffa0b61d2a3fd7ea4b300b65109422d Mon Sep 17 00:00:00 2001 From: Adam Clemens Date: Fri, 28 Aug 2026 17:13:47 +0100 Subject: [PATCH 1/3] Fix status.md undercounting progress by 4 tasks docs/planning/roadmap.md's status-line parser expects the literal marker `**Status: Done, DATE.**`, but TASK-028/029/030 had drifted to `**Status:** Done, DATE.` (bold only around "Status:"), and TASK-027 was missing a status marker entirely. The generator's regex silently treated all four as not-done, so docs/planning/status.md reported 29/41 tasks (71%) and Stage 4 as 5/9, even though all four tasks are implemented, tested, and merged. Normalized the four markers to the established format; status.md now correctly reports 33/41 (80%) and Stage 4 as 9/9. Co-Authored-By: Claude Sonnet 5 --- docs/planning/roadmap.md | 8 +++++--- docs/planning/status.md | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index 5cdda78..a7f2255 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -4987,6 +4987,8 @@ restated here as prose. Written to cover, at minimum: PISO Pressure Coupling +**Status: Done, 2026-08-27, Stage 4's sixth task.** + **Intent:** the claim is that a single correction pass **measurably and boundedly reduces the divergence** of a manufactured provisional velocity field, checked cell by cell against a stated tolerance -- not "the @@ -5229,7 +5231,7 @@ restated here as prose. Written to cover, at minimum: Dirichlet Boundary -**Status:** Done, 2026-08-28, Stage 4's seventh task. +**Status: Done, 2026-08-28, Stage 4's seventh task.** **Intent:** the criterion is what the *interior scheme* computes at a boundary face, not what the condition object returns when asked. A @@ -5353,7 +5355,7 @@ exception class of its own). Neumann Boundary -**Status:** Done, 2026-08-28, Stage 4's eighth task. +**Status: Done, 2026-08-28, Stage 4's eighth task.** **Intent:** as TASK-028, for a prescribed **gradient** -- and the zero-gradient case must not be the only one tested, since a @@ -5461,7 +5463,7 @@ contract suite once `NeumannBoundaryCondition` joined it). Periodic Boundary -**Status:** Done, 2026-08-28, Stage 4's ninth and last task. +**Status: Done, 2026-08-28, Stage 4's ninth and last task.** **Intent:** the claim is that a field advected once around a periodic domain returns to its starting distribution -- a round-trip invariant, diff --git a/docs/planning/status.md b/docs/planning/status.md index d3e3f1e..ec0f4e9 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -15,14 +15,14 @@ demand, not part of this file. ## Progress -**29/41 tasks complete (71%)** across 14 planned stages. For the full plan, including +**33/41 tasks complete (80%)** across 14 planned stages. For the full plan, including stages below not yet broken into tasks: [roadmap.md](roadmap.md). ```mermaid pie showData title "Tasks across the roadmap" - "Done" : 29 - "Not started" : 12 + "Done" : 33 + "Not started" : 8 ``` ### Milestones @@ -99,7 +99,7 @@ pie showData ### Stage 4 -- First Numerical Methods -**complete, as of 2026-08-28** -- `██████░░░░` 5/9 tasks; 10/10 criteria met +**complete, as of 2026-08-28** -- `██████████` 9/9 tasks; 10/10 criteria met | Task | Status | Date | Artifact | |------|--------|------|----------| @@ -108,10 +108,10 @@ pie showData | TASK-024 | Done | 2026-08-27 | `src/pyflow/engine/numerics/diffusion.py` | | TASK-025 | Done | 2026-08-27 | `tests/unit/test_rk4_time_integration.py` | | TASK-026 | Done | 2026-08-27 | `tests/unit/test_conjugate_gradient_solver.py` | -| TASK-027 -- PISO Pressure Coupling | Not started | | | -| TASK-028 -- Dirichlet Boundary | Not started | | | -| TASK-029 -- Neumann Boundary | Not started | | | -| TASK-030 -- Periodic Boundary | Not started | | | +| TASK-027 | Done | 2026-08-27 | `docs/practices.md` | +| TASK-028 | Done | 2026-08-28 | `icds.md` | +| TASK-029 | Done | 2026-08-28 | `docs/practices.md` | +| TASK-030 | Done | 2026-08-28 | `docs/practices.md` | ### Stage 5 -- First Fluid Solver From f94dca7fc4da6e3bf2528931b4595d7848f219c1 Mon Sep 17 00:00:00 2001 From: Adam Clemens Date: Fri, 28 Aug 2026 17:19:30 +0100 Subject: [PATCH 2/3] Add generated, annotated config template with valid/invalid comments pyflow generate-config (TASK-039) already produces a loadable default config, but PyYAML's safe_dump cannot emit comments, so it carries no explanation of why a value is accepted or rejected. Requested directly by a user reading schema.py section by section to answer "what can I configure and what values does it accept." Add tools/generators/generate_config_template.py, which walks the live PyFlowConfig dataclass tree and renders docs/implementation/config-template.yaml: every field, with a comment above it stating what counts as a valid value and what does not, using the schema's own defaults so the file stays a real, loadable config rather than a set of placeholder tokens. Wire it into the same generated-and-gated pattern every other repository document already follows: `make config-template` to regenerate, `make check-config-template` (now part of `make ci`) to fail on staleness, and a completeness check (test_every_live_config_field_has_a_comment) that fails a plain `make test` the moment a field exists with no matching comment, independent of whether anyone remembered to regenerate the committed file. The "keep it current" obligation this was explicitly asked for is written into src/pyflow/configuration/CLAUDE.md, alongside the two mechanisms that enforce it. Update every document this touches per the Blast Radius rule: root CLAUDE.md and the Makefile's `make ci` target list, tools/generators/ CLAUDE.md and docs/implementation/CLAUDE.md (the new generator and its output), the repository manifest and inventory, and the roadmap's own test-count paragraph (606 -> 614, from this change's own test suite). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 24 +- Makefile | 29 +- docs/implementation/CLAUDE.md | 12 + docs/implementation/config-template.yaml | 201 +++++++++ docs/planning/roadmap.md | 12 +- docs/planning/status.md | 2 +- docs/repository-inventory.md | 5 +- docs/repository-manifest.md | 6 +- src/pyflow/configuration/CLAUDE.md | 34 ++ tests/unit/test_generate_config_template.py | 121 ++++++ tools/generators/CLAUDE.md | 61 +++ tools/generators/generate_config_template.py | 429 +++++++++++++++++++ 12 files changed, 927 insertions(+), 9 deletions(-) create mode 100644 docs/implementation/config-template.yaml create mode 100644 tests/unit/test_generate_config_template.py create mode 100644 tools/generators/generate_config_template.py diff --git a/CLAUDE.md b/CLAUDE.md index 8e4079e..ea528ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,9 +239,31 @@ prevent (P-011, single authoritative source). - `make check-status` -- fail if `docs/planning/status.md` is stale, or if that drift check finds a disagreement. Added 2026-08-26, part of `make ci`. +- `make config-template` -- regenerate + `docs/implementation/config-template.yaml`: every `PyFlowConfig` field + from `src/pyflow/configuration/schema.py`, with a comment above each + one stating what counts as a valid value and what does not. + `pyflow generate-config` (TASK-039) already produces a loadable + scaffold from the same schema, but `PyYAML`'s `safe_dump` cannot emit + comments, so it carries no explanation -- this generator is that + explanation, kept next to its own source of truth (its + `FIELD_COMMENTS`/`SECTION_COMMENTS`) instead of hand-typed once into a + committed file and left to drift, the same restated-fact failure mode + this section's other generators exist to close. Added 2026-08-28 at a + user's direct request, with the explicit condition that it stay + current as the schema evolves -- see + `src/pyflow/configuration/CLAUDE.md` for the rule that keeps it so. +- `make check-config-template` -- fail if the committed template is + stale relative to the live schema or this generator's own comments. + Part of `make ci`. `tests/unit/test_generate_config_template.py`'s own + `test_every_live_config_field_has_a_comment` is the narrower, + always-on companion: it fails a plain `make test` the moment a field + is added to `schema.py` with no matching comment, not only at + `make ci` time. - `make ci` -- `lint typecheck test check-docs check-docs-index check-graph check-dependency-tree check-inventory check-manifest - check-references check-scenarios check-status` together (this list + check-references check-scenarios check-status check-config-template` + together (this list itself went stale by two targets, `check-references` and `check-scenarios`, before this correction -- restated facts drift even in the document that warns about restated facts); this is what CI diff --git a/Makefile b/Makefile index 2c87284..54198cd 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: install lint format typecheck test check-docs check-docs-index check-graph \ dependency-tree check-dependency-tree inventory check-inventory \ check-manifest check-references check-scenarios check-claims status-report \ - check-status docs demo ci clean + check-status config-template check-config-template docs demo ci clean install: uv sync @@ -118,7 +118,7 @@ check-inventory: check-manifest: uv run python tools/validators/check_manifest.py -ci: lint typecheck test check-docs check-docs-index check-graph check-dependency-tree check-inventory check-manifest check-references check-scenarios check-status +ci: lint typecheck test check-docs check-docs-index check-graph check-dependency-tree check-inventory check-manifest check-references check-scenarios check-status check-config-template # Fails if prose names a repository path that does not exist. Gating: # every rule is a definite structural fact (does this path resolve), @@ -172,6 +172,31 @@ status-report: check-status: uv run python tools/generators/generate_status_report.py --check +# Regenerates docs/implementation/config-template.yaml: every +# PyFlowConfig field, with a comment above each one stating what counts +# as a valid value and what does not. `pyflow generate-config` (TASK-039) +# already produces a loadable scaffold from the same schema, but +# PyYAML's safe_dump cannot emit comments, so it carries no explanation +# -- this generator is that explanation, kept next to its own source of +# truth instead of hand-typed once and left to drift (root CLAUDE.md, +# docs/CLAUDE.md: generate a document that restates a fact the +# repository already knows). See +# tools/generators/generate_config_template.py's own module docstring. +config-template: + uv run python tools/generators/generate_config_template.py + +# Fails if the committed template is stale relative to the live schema +# (a field's default or type changed) or this generator's own +# FIELD_COMMENTS/SECTION_COMMENTS (an explanation changed without +# regenerating). Part of `make ci`. The narrower, always-on companion +# check -- does every field have *a* comment at all, regardless of +# whether the committed file matches -- is +# tests/unit/test_generate_config_template.py::test_every_live_config_field_has_a_comment, +# which fails a plain `make test` the moment a field is added to +# schema.py with no matching entry, not only at `make ci` time. +check-config-template: + uv run python tools/generators/generate_config_template.py --check + # Regenerates docs/index.md, the navigable map of every documentation # page (tools/generators/CLAUDE.md). Not a hand-maintained file -- see # root CLAUDE.md's "Generated documentation must never be edited diff --git a/docs/implementation/CLAUDE.md b/docs/implementation/CLAUDE.md index 2ec661b..6206b48 100644 --- a/docs/implementation/CLAUDE.md +++ b/docs/implementation/CLAUDE.md @@ -17,6 +17,18 @@ This directory holds implementation-level artifacts per This line said "runnable code" until 2026-08-18, describing the shape the rule had already replaced -- see `examples/golden-demos/CLAUDE.md`, which is authoritative for what lives there. +- `config-template.yaml`, added 2026-08-28 at a user's direct request. + **Not itemised in `docs/planning/knowledge-architecture.md`** -- same + as `.claude/` and `tools/`, the KA spec does not enumerate every + directory or file the project ends up wanting; recorded in + `docs/repository-manifest.md` instead. **Generated, never hand-edited** + (`tools/generators/generate_config_template.py`, from + `src/pyflow/configuration/schema.py`): every `PyFlowConfig` field, with + a comment above each one stating what counts as a valid value and what + does not. Regenerate with `make config-template`; `make + check-config-template` (part of `make ci`) fails if it's stale. See + `src/pyflow/configuration/CLAUDE.md` for the rule keeping it current as + the schema evolves. Keep these in sync with `adr/ADR-002-fvm-first.md` and `adr/ADR-003-modular-numerical-strategies.md`, which `mvp.md` and diff --git a/docs/implementation/config-template.yaml b/docs/implementation/config-template.yaml new file mode 100644 index 0000000..aa4925a --- /dev/null +++ b/docs/implementation/config-template.yaml @@ -0,0 +1,201 @@ +# Configuration template -- every PyFlowConfig field, with a comment +# above each one stating what counts as a valid value and what does +# not. +# +# GENERATED by tools/generators/generate_config_template.py from +# src/pyflow/configuration/schema.py. Do not edit by hand -- per root +# CLAUDE.md, generated documentation is never edited manually, and +# `make check-config-template` fails if this file is stale relative to +# the live schema. To change what appears here, change schema.py (the +# value shown) or this script's FIELD_COMMENTS/SECTION_COMMENTS (the +# explanation), then run `make config-template`. +# +# Every value below is PyFlowConfig()'s own default -- this file is a +# real, loadable configuration (see +# tests/unit/test_generate_config_template.py), not a set of +# placeholder tokens that would fail `pyflow run --config` if used +# as-is. Copy it and edit the fields you actually want to change; a +# field left out of your own config file simply keeps its default. +# +# For a plain scaffold with no comments, `uv run python -m pyflow +# generate-config` produces the same defaults directly from the schema. + +# Logging framework settings. +logging: + # Valid: one of DEBUG, INFO, WARNING, ERROR, CRITICAL (case-insensitive). + # Invalid: any other string, e.g. "VERBOSE". + level: INFO + +# Rendering window/canvas settings. +rendering: + # Valid: "glfw" (a real interactive window) or "offscreen" (renders to an + # in-memory array, no window -- what CI and golden-demo regression tests + # use). Invalid: any other string. + backend: glfw + # Valid: a positive integer, in pixels. Invalid: zero, negative, or a + # float. + width: 1280 + # Valid: a positive integer, in pixels. Invalid: zero, negative, or a + # float. + height: 720 + # Valid: any string. The window's title bar text. + title: PyFlow + # Valid: null (leaves the background unset, pygfx's own default) or a + # "#RRGGBB" hex string, e.g. "#1a1a2e". Invalid: a colour name ("red"), a + # short hex ("#fff"), or a string missing the "#". + background_color: null + # Valid: true or false. Invalid: anything else -- not a colour or a + # string. + show_mesh: false + # Valid: a "#RRGGBB" hex string -- unlike background_color, this one has + # no null/off state; show_mesh above is the switch. Invalid: null, or a + # malformed hex string. + grid_color: '#4477aa' + # Valid: a positive number, and within [zoom_min, zoom_max] below. + # Invalid: zero, negative, or outside that range. + zoom: 1.0 + # Valid: a pair of finite numbers [x, y] -- a world-space offset from the + # camera's default centring. Invalid: anything other than exactly two + # numbers. + pan: [0.0, 0.0] + # Valid: a positive number, less than zoom_max. Invalid: zero, negative, + # or >= zoom_max. + zoom_min: 0.1 + # Valid: a number greater than zoom_min. Invalid: <= zoom_min. + zoom_max: 10.0 + +# Structured Cartesian mesh: where the domain starts, how big one cell is, +# and how many cells there are. There is no single "domain size" field -- +# the physical domain is extent * spacing along each axis. +mesh: + # Valid: a pair of finite numbers [x, y] -- the mesh's own lower-left + # corner in world space. Invalid: anything other than exactly two numbers. + origin: [0.0, 0.0] + # Valid: a pair of positive numbers [dx, dy] -- the physical size of one + # cell along each axis. Invalid: zero or negative. + spacing: [1.0, 1.0] + # Valid: a pair of positive whole numbers [nx, ny] -- the *number of + # cells* along each axis, not a physical size (a whole number written as a + # float, e.g. [10.0, 4.0], is fine). Invalid: zero, negative, or + # fractional, e.g. [10.9, 3.99] -- rejected outright rather than rounded, + # since silently building a different mesh than the one asked for is worse + # than refusing. + extent: [10, 10] + +# Static field visualisation for one rendered frame (built-in demo patterns +# only, not a general initial-condition API). +field_display: + # Valid: null (draw no scalar field) or "radial_gradient", the only built- + # in pattern this field currently accepts. Invalid: any other string. + scalar_pattern: null + # Valid: null (draw no vector field) or "rotational", the only built-in + # pattern this field currently accepts. Invalid: any other string. + vector_pattern: null + # Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string. + low_color: '#0000ff' + # Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string. + high_color: '#ff0000' + # Valid: a pair [min, max] with max strictly greater than min. Invalid: + # max <= min. + value_range: [0.0, 1.0] + # Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string. + arrow_color: '#ffffff' + # Valid: a positive number. Invalid: zero or negative. + arrow_scale: 0.3 + # Valid: true or false. + show_legend: true + +# Live, repeatedly-stepped simulation seeding -- distinct from field_display +# above, which renders one static frame. +simulation: + # Valid: null (no live simulation runs) or "gaussian_blob", the only + # built-in pattern this field currently accepts. Invalid: any other + # string. + scalar_pattern: null + # Valid: null or "uniform", the only built-in pattern this field currently + # accepts. Invalid: any other string. + velocity_pattern: null + # Valid: a pair of finite numbers [vx, vy] -- a *prescribed*, not solved, + # constant velocity (Stage 5 is what eventually solves for velocity). + # Invalid: anything other than exactly two numbers. + velocity: [1.0, 0.0] + +# Numerical scheme selection (adr/ADR-003-modular-numerical-strategies.md) +# plus the physical/solver parameters those schemes take. +numerics: + # Valid: "first_order_upwind" -- the only scheme PyFlow currently + # implements for this component. Invalid: any other string. + advection: first_order_upwind + # Valid: "central_difference" -- the only scheme PyFlow currently + # implements for this component. Invalid: any other string. + diffusion: central_difference + # Valid: a positive number (the physical diffusivity, Gamma). Invalid: + # zero or negative. + diffusion_coefficient: 1.0 + # Valid: "rk4" -- the only scheme PyFlow currently implements for this + # component. Invalid: any other string. + time_integration: rk4 + # Valid: a positive number. Fixed for the whole run -- PyFlow applies no + # automatic stability/CFL limit, so an unstable choice is not rejected at + # load time, only at runtime (as diverging values). Invalid: zero or + # negative. + timestep: 0.01 + # Valid: "conjugate_gradient" -- the only solver PyFlow currently + # implements for this component. Invalid: any other string. + linear_solver: conjugate_gradient + # Valid: a positive number -- the convergence tolerance. Invalid: zero or + # negative. + linear_solver_tolerance: 1.0e-06 + # Valid: a positive integer. Invalid: zero, negative, or a float. + linear_solver_max_iterations: 1000 + # Valid: "piso" -- the only scheme PyFlow currently implements for this + # component. Invalid: any other string. + pressure_coupling: piso + # One entry per domain edge. All four faces share the same shape + # (BoundaryFaceConfig) and the same per-field rules, explained once below + # under 'north' and not repeated for the other three. + boundary_conditions: + north: + # Valid: "dirichlet", "neumann", or "periodic". If "periodic", the + # OPPOSITE face (north<->south, east<->west) must also be "periodic" + # -- checked jointly across all four faces, not per-face. Invalid: any + # other string, or a lone periodic face whose pair is not also + # periodic. + type: dirichlet + # Valid: null (not prescribed here) or a number -- the + # 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. + velocity: 0.0 + # Valid: null (not prescribed here) or a number. Mutually exclusive + # with .velocity above on the same 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. + 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. + scalar_gradient: 0.0 + south: + type: dirichlet + velocity: 0.0 + pressure: null + scalar_value: 0.0 + scalar_gradient: 0.0 + east: + type: dirichlet + velocity: 0.0 + pressure: null + scalar_value: 0.0 + scalar_gradient: 0.0 + west: + type: dirichlet + velocity: 0.0 + pressure: null + scalar_value: 0.0 + scalar_gradient: 0.0 diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index a7f2255..444e3a1 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -189,7 +189,7 @@ 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): **606 tests at 99% as of 2026-08-28**, having been 64 when +(C1a/C1b): **614 tests at 99% as of 2026-08-28**, having been 64 when this paragraph was rewritten on 2026-08-19, 202 earlier the same day, 212 after TASK-014, 226 after TASK-015, 250 after TASK-016, 287 after TASK-017, 297 after TASK-039, 315 after the Stage 2 exit audit and 337 @@ -389,8 +389,14 @@ Stage 4 exit audit: one new Gherkin scenario in periodic domain"), added because the existing closed-domain conservation scenario turned out to pass for any flux array whatsoever -- see this Stage's own Completion Criterion 4 row, below, for the mutation evidence -and why the weak scenario was annotated rather than deleted. **54 of -those 606 are Gherkin scenarios rather than pytest functions** +and why the weak scenario was annotated rather than deleted. 614 the +same day: `tools/generators/generate_config_template.py`'s own test +suite (`tests/unit/test_generate_config_template.py`), eight tests, built +at a user's direct request for an annotated, always-current example +config -- again no Stage 4/5 task work involved, the same "count moves +for reasons having nothing to do with the fluid solver" pattern the +paragraph above already records. **54 of those 614 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` joining, TASK-021; to 24 with TASK-040's own diff --git a/docs/planning/status.md b/docs/planning/status.md index ec0f4e9..591663d 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -40,7 +40,7 @@ pie showData ## Live repository facts - **45** `CLAUDE.md` files -- **606** tests collected +- **614** tests collected - **54** Gherkin scenarios (`tests/features/*.feature`) ## Stages diff --git a/docs/repository-inventory.md b/docs/repository-inventory.md index 45c7ee2..6b7e4de 100644 --- a/docs/repository-inventory.md +++ b/docs/repository-inventory.md @@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are not here either -- those come from running the suite, not from listing files. -**267 tracked files** across 45 directories; +**270 tracked files** across 45 directories; 4 are empty. ## (root) @@ -133,6 +133,7 @@ listing files. ## docs/implementation - `CLAUDE.md` +- `config-template.yaml` - `golden-demos.md` - `mvp.md` - `upgrade-paths.md` @@ -364,6 +365,7 @@ listing files. - `test_field_contract.py` - `test_field_visualization.py` - `test_first_order_upwind_advection.py` +- `test_generate_config_template.py` - `test_generate_dependency_tree.py` - `test_generate_docs_index.py` - `test_generate_repository_inventory.py` @@ -406,6 +408,7 @@ listing files. ## tools/generators - `CLAUDE.md` +- `generate_config_template.py` - `generate_dependency_tree.py` - `generate_docs_index.py` - `generate_repository_inventory.py` diff --git a/docs/repository-manifest.md b/docs/repository-manifest.md index 9c6fa51..04ab0e8 100644 --- a/docs/repository-manifest.md +++ b/docs/repository-manifest.md @@ -252,6 +252,7 @@ because only KA-009 specifies one. | mvp.md | 🟨 | **Authoritative** MVP definition (KA-031) | | upgrade-paths.md | 🟨 | How each MVP component can be replaced or extended (KA-032) | | golden-demos.md | 🟩 | What each golden demo must do and how it is verified (KA-035); Empty Window (D5, 2026-08-16) was the first demo actually built, Empty Mesh (TASK-013, 2026-08-20) the second | +| config-template.yaml | 🟩 | **Generated** annotated example config -- every `PyFlowConfig` field with a valid/invalid-value comment, from `src/pyflow/configuration/schema.py` (`tools/generators/generate_config_template.py`, 2026-08-28); regenerate with `make config-template`, never hand-edit. Not itemised in `docs/planning/knowledge-architecture.md` -- see this directory's own `CLAUDE.md` | `docs/implementation/stages/stage-0.md` (KA-034) does not exist and will not be written. KA-034 was marked `superseded` on 2026-08-15: @@ -938,7 +939,10 @@ and dropped, see `tools/validators/CLAUDE.md`) and `check_scenarios.py` (`docs/planning/status.md` and an uncommitted `build/` dashboard from `roadmap.md`'s own status prose plus live repository counts, 2026-08-26 -- also a validator, not just a generator: it refuses to render either -output while those counts disagree with reality), each documented in its +output while those counts disagree with reality) and +`generate_config_template.py` (`docs/implementation/config-template.yaml`, +every `PyFlowConfig` field with a valid/invalid-value comment, from +`src/pyflow/configuration/schema.py`, 2026-08-28), each documented in its own `CLAUDE.md`. `planner/` and `scripts/` -- empty since the first commit, with no document ever stating what either was for -- were retired 2026-08-17 (E10, maintainer's decision) rather than left as speculative diff --git a/src/pyflow/configuration/CLAUDE.md b/src/pyflow/configuration/CLAUDE.md index 0433c67..157a424 100644 --- a/src/pyflow/configuration/CLAUDE.md +++ b/src/pyflow/configuration/CLAUDE.md @@ -356,3 +356,37 @@ the same shape if a future module is the first in `src/pyflow` to lean on a third-party library's stubs precisely: `make lint`'s isolated mypy environment only knows the stub packages listed as `additional_dependencies` for that hook, not the whole `dev` dependency group. + +**`docs/implementation/config-template.yaml` must be kept current with +this file** (rule added 2026-08-28, at a user's direct request for an +annotated example config that stays up to date "as the code evolves"). +It is generated by `tools/generators/generate_config_template.py` from +the live `PyFlowConfig` tree, one comment per field explaining what +counts as a valid value and what does not -- `pyflow generate-config` +(TASK-039) already produces a loadable scaffold from this same schema, +but `PyYAML`'s `safe_dump` cannot emit comments, so it carries no +explanation of *why* a value is accepted or rejected. Hand-writing that +explanation straight into a committed YAML file would only relocate the +restated-fact problem this package already avoids everywhere else (this +file's own repeated "not restated, derived" reasoning); generating it +from a comment map kept next to the schema it describes is the same fix +applied here. + +Concretely: **whenever a field is added, removed, renamed, or has its +valid range/`Literal` options changed in `schema.py`, update +`FIELD_COMMENTS` (or `SECTION_COMMENTS`, for a new top-level section) in +the same change, then run `make config-template`.** This is enforced by +a test, not only remembered, the same shape `src/pyflow/CLAUDE.md`'s CLI +help-text rule uses for the same reason: `tests/unit/ +test_generate_config_template.py::test_every_live_config_field_has_a_comment` +walks the real `PyFlowConfig` dataclass tree and fails a plain `make +test` the moment a field exists with no matching comment, regardless of +whether anyone remembered to regenerate the committed file; `make +check-config-template` (part of `make ci`) additionally fails if the +committed file is stale relative to what the schema and comments +together would currently generate. What neither check can verify is +covered by `docs/practices.md`'s Blast Radius rule instead, the same as +everywhere else in this project: whether a comment's *wording* is still +an accurate description of the field's real constraint is a judgement +call for whoever changes that constraint, not something either test can +see. diff --git a/tests/unit/test_generate_config_template.py b/tests/unit/test_generate_config_template.py new file mode 100644 index 0000000..4d3a849 --- /dev/null +++ b/tests/unit/test_generate_config_template.py @@ -0,0 +1,121 @@ +"""Unit tests for tools/generators/generate_config_template.py. + +Not part of the `pyflow` package (a repo-consistency script, not library +code), so it's imported via `sys.path` -- see tools/generators/CLAUDE.md. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +TOOLS_GENERATORS = Path(__file__).resolve().parents[2] / "tools" / "generators" +if str(TOOLS_GENERATORS) not in sys.path: + sys.path.insert(0, str(TOOLS_GENERATORS)) + +from generate_config_template import OUTPUT_PATH, missing_comment_paths, render # noqa: E402 + +from pyflow.configuration import PyFlowConfig, load_config # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +# --- The "kept up to date" rule itself ------------------------------------- +# +# This is the enforcement `src/pyflow/configuration/CLAUDE.md` promises: +# a field added to schema.py with no matching comment must fail `make +# test`, not rely on someone remembering to update a template by hand. + + +def test_every_live_config_field_has_a_comment() -> None: + """The real regression: if this ever fails, a field was added to (or + renamed in) `PyFlowConfig` without a matching entry in + `FIELD_COMMENTS`/`SECTION_COMMENTS` -- add one in the same change + that adds the field, per `src/pyflow/configuration/CLAUDE.md`. + """ + assert missing_comment_paths(PyFlowConfig) == [] + + +def test_missing_comment_paths_actually_detects_an_uncovered_field() -> None: + """Proves the completeness check has teeth, rather than trivially + passing because it never looks at anything -- a fake dataclass with a + field no comment dict could possibly cover must be reported. + """ + + @dataclass + class _Uncommented: + totally_new_field: int = 0 + + assert missing_comment_paths(_Uncommented) == ["totally_new_field"] + + +def test_a_fully_covered_dataclass_reports_nothing_missing(monkeypatch: pytest.MonkeyPatch) -> None: + import generate_config_template + + @dataclass + class _Covered: + totally_new_field: int = 0 + + monkeypatch.setitem( + generate_config_template.FIELD_COMMENTS, "totally_new_field", "Valid: anything." + ) + + assert missing_comment_paths(_Covered) == [] + + +# --- render() itself -------------------------------------------------------- + + +def test_rendered_output_is_a_real_loadable_config(tmp_path: Path) -> None: + """Every value in the template is `PyFlowConfig()`'s own default, so + the file must round-trip through `load_config` to an equal config -- + this is not a set of placeholder tokens a user would have to edit + before the file even parses. + """ + config_path = tmp_path / "config-template.yaml" + config_path.write_text(render(), encoding="utf-8") + + assert load_config(config_path) == PyFlowConfig() + + +def test_rendered_output_says_it_is_generated() -> None: + output = render() + + assert "GENERATED" in output + assert "do not edit" in output.lower() + assert "make config-template" in output + + +def test_rendered_output_documents_a_valid_and_invalid_case_for_every_field() -> None: + """The user-facing point of this file: not just a value, but what + counts as valid and what doesn't. + """ + output = render() + + assert output.count("Valid:") >= 30 + assert output.count("Invalid:") >= 25 + + +def test_boundary_face_comments_are_explained_once_not_four_times() -> None: + """South/east/west share north's own explanation rather than + repeating it -- the intro comment inside `boundary_conditions:` says + so explicitly, and this pins that the renderer actually behaves that + way rather than just claiming to. + """ + output = render() + + # Exactly one boundary face carries the full per-field explanation. + # (A short, wrap-safe marker -- the full sentence straddles a + # text-wrapped line break, so matching it verbatim would be fragile.) + assert output.count("boundary-*normal*") == 1 + + +def test_the_committed_config_template_is_up_to_date() -> None: + """The same assertion `make check-config-template` makes, run as part + of the ordinary suite so a stale file fails fast rather than only at + the end of `make ci`. + """ + assert OUTPUT_PATH.read_text(encoding="utf-8") == render() diff --git a/tools/generators/CLAUDE.md b/tools/generators/CLAUDE.md index 38ea72a..8ffef9c 100644 --- a/tools/generators/CLAUDE.md +++ b/tools/generators/CLAUDE.md @@ -139,3 +139,64 @@ come from running the suite, not from listing files, and the "42 tests, by this script. Those claims stay prose, now with a date attached, and step 11 of the end-of-session review (`docs/practices.md`) is what covers them. + +**`generate_config_template.py`** (added 2026-08-28, at a user's direct +request) renders `docs/implementation/config-template.yaml`: every +`PyFlowConfig` field (`src/pyflow/configuration/schema.py`), with a +comment above each one stating what counts as a valid value and what +does not. Same shape as the other generators here -- a `--check` mode +wired into `make ci` as `check-config-template`, `newline="\n"`, output +that must never be hand-edited. Different trigger from the other three: +it regenerates when `schema.py`'s fields, defaults, or valid ranges +change, not when a doc page is added or the component graph changes. + +**Why a generator rather than a hand-written example file.** +`pyflow generate-config` (TASK-039, +`src/pyflow/configuration/generator.py`) already turns `PyFlowConfig()` +into loadable YAML, straight from `dataclasses.asdict()` -- but +`PyYAML`'s `safe_dump` cannot emit comments, so that output carries no +explanation of *why* a value is accepted or rejected, only what the +default happens to be. Hand-writing that explanation directly into a +committed YAML file would relocate exactly the restated-fact problem +`generate_dependency_tree.py`/`generate_status_report.py` above were +each built to close, one field at a time instead of one document at a +time: the explanation would drift the moment `schema.py`'s `validate()` +changed and nobody remembered the YAML file existed. This script is that +fix applied to configuration documentation -- `FIELD_COMMENTS`/ +`SECTION_COMMENTS` are the one place a field's valid/invalid explanation +is written, kept in the generator next to the schema it describes. + +**It is also a structural completeness check, the same shape +`generate_status_report.py`'s drift check is, but narrower.** +`missing_comment_paths()` walks the live `PyFlowConfig` dataclass tree +(via `dataclasses.fields()`, `typing.get_type_hints()` to resolve +`from __future__ import annotations` string annotations) and returns +every leaf field with no entry in `FIELD_COMMENTS`, and every top-level +section with none in `SECTION_COMMENTS` -- `render()` refuses to +produce output at all while either list is non-empty, the same +refuse-rather-than-launder-staleness posture `generate_status_report.py` +takes. **Deliberately narrower than that check**: it verifies a comment +*exists* for a field, not that its *wording* is still an accurate +description of that field's real constraint -- the same +judgement-versus-structure line `check_claims.py` +(`tools/validators/CLAUDE.md`) draws, and for the same reason: telling a +stale explanation from a correct one needs a reader, and a check that +needs a reader cannot gate. `docs/practices.md`'s Blast Radius rule +covers that half instead, and `src/pyflow/configuration/CLAUDE.md` +states the concrete obligation ("update `FIELD_COMMENTS` in the same +change that changes the field it describes"). + +**Every value shown is `PyFlowConfig()`'s own default, never a +hand-picked "more illustrative" example.** A value that isn't the +schema's own default would be a second place that default could drift +from -- the same reasoning `generate_config_yaml` (TASK-039) already +uses for the plain scaffold, applied here to the annotated one. The four +boundary faces (`numerics.boundary_conditions.north/south/east/west`) +share one `BoundaryFaceConfig` shape and one comment set per field +(`FIELD_COMMENTS`'s `` placeholder keys); the rendered file +explains each field's rules once, under `north`, and shows the other +three faces' values with no repeated prose -- verified directly by +`tests/unit/test_generate_config_template.py`'s own +`test_boundary_face_comments_are_explained_once_not_four_times`, after +an earlier draft of the renderer repeated the full explanation on all +four faces despite its own banner comment claiming otherwise. diff --git a/tools/generators/generate_config_template.py b/tools/generators/generate_config_template.py new file mode 100644 index 0000000..8453ad2 --- /dev/null +++ b/tools/generators/generate_config_template.py @@ -0,0 +1,429 @@ +"""Generate docs/implementation/config-template.yaml: an annotated, +loadable example of every `PyFlowConfig` field, with a comment above +each one stating what counts as a valid value and what does not. + +Requested directly by a user reading `src/pyflow/configuration/schema.py` +section by section to answer "what can I configure and what values does +it accept" -- exactly the restated-fact shape root `CLAUDE.md` and +`docs/CLAUDE.md` warn about (`pyflow generate-config`, TASK-039, already +produces a loadable scaffold, but `PyYAML.safe_dump` cannot emit +comments, so it carries no explanation of *why* a value would be +accepted or rejected). Hand-writing that explanation directly into a +committed YAML file would only relocate the restatement problem, not +solve it -- the same failure mode `docs/planning/dependency-tree.md` and +`docs/planning/status.md` each hit before being generated. This script +is the fix applied to configuration documentation: `FIELD_COMMENTS` +below is the one place a field's valid/invalid explanation is written, +and `missing_comment_paths()` walks the live `PyFlowConfig` dataclass +tree to fail loudly the moment a field exists there without a matching +comment -- the structural half of "kept up to date"; the semantic half +(is the *comment's content* still accurate) is the same read-the-diff +discipline `docs/practices.md`'s Blast Radius rule asks of any change to +`schema.py`, not something a script can check. + +Two things this script does NOT try to check, on purpose, same reasoning +as `generate_status_report.py`'s own "deliberately does not verify +everything" section: whether a comment's *wording* is still correct +(judgement, not structure -- `check_claims.py`'s territory, and this +script has no equivalent), and whether the example *value* chosen for a +field is the most illustrative one (every value here is `PyFlowConfig()`'s +own default, deliberately -- see `render()`'s docstring for why). + +Run via `make config-template` to write the file, or +`make check-config-template` (part of `make ci`) to fail if the +committed copy is stale relative to the live schema. Per root +`CLAUDE.md`, the output must never be edited by hand -- change +`src/pyflow/configuration/schema.py` and this script's `FIELD_COMMENTS`/ +`SECTION_COMMENTS` instead. +""" + +from __future__ import annotations + +import dataclasses +import sys +import textwrap +import typing +from pathlib import Path +from typing import Any + +import yaml + +from pyflow.configuration.schema import PyFlowConfig + +REPO_ROOT = Path(__file__).resolve().parents[2] +OUTPUT_PATH = REPO_ROOT / "docs" / "implementation" / "config-template.yaml" + +_COMMENT_WIDTH = 76 +_BOUNDARY_FACES = ("north", "south", "east", "west") + +# One entry per top-level `PyFlowConfig` section, in declaration order. +# Checked for completeness the same way FIELD_COMMENTS is. +SECTION_COMMENTS: dict[str, str] = { + "logging": "Logging framework settings.", + "rendering": "Rendering window/canvas settings.", + "mesh": ( + "Structured Cartesian mesh: where the domain starts, how big one " + "cell is, and how many cells there are. There is no single " + '"domain size" field -- the physical domain is ' + "extent * spacing along each axis." + ), + "field_display": ( + "Static field visualisation for one rendered frame (built-in " + "demo patterns only, not a general initial-condition API)." + ), + "simulation": ( + "Live, repeatedly-stepped simulation seeding -- distinct from " + "field_display above, which renders one static frame." + ), + "numerics": ( + "Numerical scheme selection (adr/ADR-003-modular-numerical-" + "strategies.md) plus the physical/solver parameters those " + "schemes take." + ), +} + +# One entry per leaf field, keyed by dotted path from PyFlowConfig. +# `numerics.boundary_conditions..*` covers all four faces +# (north/south/east/west) with one shared comment set, since +# `BoundaryFaceConfig` is structurally identical on every face. +FIELD_COMMENTS: dict[str, str] = { + "logging.level": ( + "Valid: one of DEBUG, INFO, WARNING, ERROR, CRITICAL " + '(case-insensitive). Invalid: any other string, e.g. "VERBOSE".' + ), + "rendering.backend": ( + 'Valid: "glfw" (a real interactive window) or "offscreen" ' + "(renders to an in-memory array, no window -- what CI and golden-" + "demo regression tests use). Invalid: any other string." + ), + "rendering.width": ( + "Valid: a positive integer, in pixels. Invalid: zero, negative, or a float." + ), + "rendering.height": ( + "Valid: a positive integer, in pixels. Invalid: zero, negative, or a float." + ), + "rendering.title": "Valid: any string. The window's title bar text.", + "rendering.background_color": ( + "Valid: null (leaves the background unset, pygfx's own default) " + 'or a "#RRGGBB" hex string, e.g. "#1a1a2e". Invalid: a colour ' + 'name ("red"), a short hex ("#fff"), or a string missing the "#".' + ), + "rendering.show_mesh": ( + "Valid: true or false. Invalid: anything else -- not a colour or a string." + ), + "rendering.grid_color": ( + 'Valid: a "#RRGGBB" hex string -- unlike background_color, this ' + "one has no null/off state; show_mesh above is the switch. " + "Invalid: null, or a malformed hex string." + ), + "rendering.zoom": ( + "Valid: a positive number, and within [zoom_min, zoom_max] " + "below. Invalid: zero, negative, or outside that range." + ), + "rendering.pan": ( + "Valid: a pair of finite numbers [x, y] -- a world-space offset " + "from the camera's default centring. Invalid: anything other " + "than exactly two numbers." + ), + "rendering.zoom_min": ( + "Valid: a positive number, less than zoom_max. Invalid: zero, negative, or >= zoom_max." + ), + "rendering.zoom_max": ("Valid: a number greater than zoom_min. Invalid: <= zoom_min."), + "mesh.origin": ( + "Valid: a pair of finite numbers [x, y] -- the mesh's own " + "lower-left corner in world space. Invalid: anything other than " + "exactly two numbers." + ), + "mesh.spacing": ( + "Valid: a pair of positive numbers [dx, dy] -- the physical " + "size of one cell along each axis. Invalid: zero or negative." + ), + "mesh.extent": ( + "Valid: a pair of positive whole numbers [nx, ny] -- the " + "*number of cells* along each axis, not a physical size (a " + "whole number written as a float, e.g. [10.0, 4.0], is fine). " + "Invalid: zero, negative, or fractional, e.g. [10.9, 3.99] -- " + "rejected outright rather than rounded, since silently building " + "a different mesh than the one asked for is worse than refusing." + ), + "field_display.scalar_pattern": ( + 'Valid: null (draw no scalar field) or "radial_gradient", the ' + "only built-in pattern this field currently accepts. Invalid: " + "any other string." + ), + "field_display.vector_pattern": ( + 'Valid: null (draw no vector field) or "rotational", the only ' + "built-in pattern this field currently accepts. Invalid: any " + "other string." + ), + "field_display.low_color": ( + 'Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string.' + ), + "field_display.high_color": ( + 'Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string.' + ), + "field_display.value_range": ( + "Valid: a pair [min, max] with max strictly greater than min. Invalid: max <= min." + ), + "field_display.arrow_color": ( + 'Valid: a "#RRGGBB" hex string. Invalid: null, or a malformed hex string.' + ), + "field_display.arrow_scale": "Valid: a positive number. Invalid: zero or negative.", + "field_display.show_legend": "Valid: true or false.", + "simulation.scalar_pattern": ( + 'Valid: null (no live simulation runs) or "gaussian_blob", the ' + "only built-in pattern this field currently accepts. Invalid: " + "any other string." + ), + "simulation.velocity_pattern": ( + 'Valid: null or "uniform", the only built-in pattern this field ' + "currently accepts. Invalid: any other string." + ), + "simulation.velocity": ( + "Valid: a pair of finite numbers [vx, vy] -- a *prescribed*, " + "not solved, constant velocity (Stage 5 is what eventually " + "solves for velocity). Invalid: anything other than exactly two " + "numbers." + ), + "numerics.advection": ( + 'Valid: "first_order_upwind" -- the only scheme PyFlow currently ' + "implements for this component. Invalid: any other string." + ), + "numerics.diffusion": ( + 'Valid: "central_difference" -- the only scheme PyFlow currently ' + "implements for this component. Invalid: any other string." + ), + "numerics.diffusion_coefficient": ( + "Valid: a positive number (the physical diffusivity, Gamma). Invalid: zero or negative." + ), + "numerics.time_integration": ( + 'Valid: "rk4" -- the only scheme PyFlow currently implements ' + "for this component. Invalid: any other string." + ), + "numerics.timestep": ( + "Valid: a positive number. Fixed for the whole run -- PyFlow " + "applies no automatic stability/CFL limit, so an unstable " + "choice is not rejected at load time, only at runtime (as " + "diverging values). Invalid: zero or negative." + ), + "numerics.linear_solver": ( + 'Valid: "conjugate_gradient" -- the only solver PyFlow currently ' + "implements for this component. Invalid: any other string." + ), + "numerics.linear_solver_tolerance": ( + "Valid: a positive number -- the convergence tolerance. Invalid: zero or negative." + ), + "numerics.linear_solver_max_iterations": ( + "Valid: a positive integer. Invalid: zero, negative, or a float." + ), + "numerics.pressure_coupling": ( + 'Valid: "piso" -- the only scheme PyFlow currently implements ' + "for this component. Invalid: any other string." + ), + "numerics.boundary_conditions..type": ( + 'Valid: "dirichlet", "neumann", or "periodic". If "periodic", ' + "the OPPOSITE face (north<->south, east<->west) must also be " + '"periodic" -- checked jointly across all four faces, not per-' + "face. Invalid: any other string, or a lone periodic face whose " + "pair is not also periodic." + ), + "numerics.boundary_conditions..velocity": ( + "Valid: null (not prescribed here) or a number -- the 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." + ), + "numerics.boundary_conditions..pressure": ( + "Valid: null (not prescribed here) or a number. Mutually " + "exclusive with .velocity above on the same 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.' + ), + "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.' + ), +} + + +def _resolved_field_type(cls: type, name: str) -> Any: + return typing.get_type_hints(cls)[name] + + +def _leaf_paths(cls: Any, prefix: str = "") -> list[str]: + """Every dotted leaf path `FIELD_COMMENTS` must cover, derived from + the live dataclass tree rather than hand-listed -- so a field added + to `schema.py` without a matching comment shows up here, not just in + a human's memory of what the schema used to look like. + """ + paths: list[str] = [] + for f in dataclasses.fields(cls): + path = f"{prefix}{f.name}" + if f.name == "boundary_conditions": + for leaf in ("type", "velocity", "pressure", "scalar_value", "scalar_gradient"): + paths.append(f"{path}..{leaf}") + continue + resolved = _resolved_field_type(cls, f.name) + if dataclasses.is_dataclass(resolved): + paths.extend(_leaf_paths(resolved, prefix=f"{path}.")) + else: + paths.append(path) + return paths + + +def missing_comment_paths(config_cls: type = PyFlowConfig) -> list[str]: + """Leaf paths (and top-level sections) with no entry in + `FIELD_COMMENTS`/`SECTION_COMMENTS`. Empty means every field the + live schema declares has an explanation -- the completeness half of + "kept up to date"; `tests/unit/test_generate_config_template.py` + asserts this is always empty, so a forgotten comment fails `make + test`, not just `make check-config-template`. + """ + missing: list[str] = [] + if config_cls is PyFlowConfig: + # SECTION_COMMENTS names PyFlowConfig's own top-level sections -- + # a concept that only exists for PyFlowConfig itself, not for an + # arbitrary nested or test-only dataclass passed in below. + missing += [ + name + for name in (f.name for f in dataclasses.fields(config_cls)) + if name not in SECTION_COMMENTS + ] + missing += [path for path in _leaf_paths(config_cls) if path not in FIELD_COMMENTS] + return missing + + +def _format_value(value: object) -> str: + if isinstance(value, tuple): + value = list(value) + dumped = yaml.safe_dump(value, default_flow_style=True).strip() + if dumped.endswith("\n..."): + dumped = dumped[: -len("\n...")] + return dumped + + +def _comment_lines(text: str, indent: str) -> list[str]: + wrapped = textwrap.wrap(text, width=_COMMENT_WIDTH - len(indent) - 2) or [""] + return [f"{indent}# {line}" for line in wrapped] + + +def _render_boundary_conditions(boundary_conditions: Any, indent: str) -> list[str]: + lines = _comment_lines( + "One entry per domain edge. All four faces share the same shape " + "(BoundaryFaceConfig) and the same per-field rules, explained " + "once below under 'north' and not repeated for the other three.", + indent, + ) + lines.append(f"{indent}boundary_conditions:") + for face in _BOUNDARY_FACES: + lines.append(f"{indent} {face}:") + face_config = getattr(boundary_conditions, face) + for f in dataclasses.fields(face_config): + if face == "north": + comment_key = f"numerics.boundary_conditions..{f.name}" + lines.extend(_comment_lines(FIELD_COMMENTS[comment_key], indent + " ")) + lines.append(f"{indent} {f.name}: {_format_value(getattr(face_config, f.name))}") + return lines + + +def _render_fields(instance: Any, prefix: str, indent: str) -> list[str]: + lines: list[str] = [] + for f in dataclasses.fields(instance): + path = f"{prefix}{f.name}" + value = getattr(instance, f.name) + if f.name == "boundary_conditions": + lines.extend(_render_boundary_conditions(value, indent)) + continue + if dataclasses.is_dataclass(value): + lines.append(f"{indent}{f.name}:") + lines.extend(_render_fields(value, prefix=f"{path}.", indent=indent + " ")) + continue + lines.extend(_comment_lines(FIELD_COMMENTS[path], indent)) + lines.append(f"{indent}{f.name}: {_format_value(value)}") + return lines + + +_BANNER = """\ +# Configuration template -- every PyFlowConfig field, with a comment +# above each one stating what counts as a valid value and what does +# not. +# +# GENERATED by tools/generators/generate_config_template.py from +# src/pyflow/configuration/schema.py. Do not edit by hand -- per root +# CLAUDE.md, generated documentation is never edited manually, and +# `make check-config-template` fails if this file is stale relative to +# the live schema. To change what appears here, change schema.py (the +# value shown) or this script's FIELD_COMMENTS/SECTION_COMMENTS (the +# explanation), then run `make config-template`. +# +# Every value below is PyFlowConfig()'s own default -- this file is a +# real, loadable configuration (see +# tests/unit/test_generate_config_template.py), not a set of +# placeholder tokens that would fail `pyflow run --config` if used +# as-is. Copy it and edit the fields you actually want to change; a +# field left out of your own config file simply keeps its default. +# +# For a plain scaffold with no comments, `uv run python -m pyflow +# generate-config` produces the same defaults directly from the schema. +""" + + +def render(config: PyFlowConfig | None = None) -> str: + """The full annotated template, as text. + + `config` defaults to `PyFlowConfig()` -- every field's own declared + default -- rather than a hand-picked "more interesting" example + value, for the same reason `pyflow generate-config` (TASK-039) uses + `PyFlowConfig()`: a value that isn't the schema's own default is a + second place that default could drift from, and `PyFlowConfig()` is + already guaranteed valid (TASK-005's own acceptance criterion). + """ + config = config if config is not None else PyFlowConfig() + missing = missing_comment_paths(type(config)) + if missing: + raise ValueError( + "generate_config_template: no comment for: " + ", ".join(missing) + " -- add one to " + "FIELD_COMMENTS/SECTION_COMMENTS in tools/generators/generate_config_template.py" + ) + + lines = [_BANNER.rstrip("\n")] + for f in dataclasses.fields(config): + lines.append("") + lines.extend(_comment_lines(SECTION_COMMENTS[f.name], indent="")) + lines.append(f"{f.name}:") + lines.extend(_render_fields(getattr(config, f.name), prefix=f"{f.name}.", indent=" ")) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + content = render() + check_only = "--check" in sys.argv[1:] + + if check_only: + current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else None + if current == content: + print("docs/implementation/config-template.yaml is up to date.") + return 0 + print( + "docs/implementation/config-template.yaml is stale -- the config " + "schema or this generator's comments changed without regenerating it.\n" + "Run 'make config-template' and commit the result." + ) + return 1 + + with OUTPUT_PATH.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From b318981b028e815ebdb2218674c6de741fb230d6 Mon Sep 17 00:00:00 2001 From: Adam Clemens Date: Fri, 28 Aug 2026 17:24:01 +0100 Subject: [PATCH 3/3] Fix status.md's artifact column for TASK-027/028/029/030 TASK-023 through TASK-026's Status markers are immediately followed by their own artifact path, so generate_status_report.py's "first backtick after Status" heuristic finds the real implementing file. TASK-027/028/ 029/030's Status lines had no such inline path, so the same heuristic fell through to the nearest unrelated backtick reference inside the Intent/Design prose (docs/practices.md, in every case) -- invisible until the prior commit fixed those four tasks' Status markers enough to even be parsed as Done. Add the same inline artifact reference the earlier tasks already use: pressure_coupling.py (PISO)/gradient.py/divergence.py for TASK-027, boundary_condition.py (Dirichlet/NeumannBoundaryCondition) for TASK-028/ 029, and mesh.py's wrapped_neighbour_cell for TASK-030, which bypasses BoundaryCondition entirely. Co-Authored-By: Claude Sonnet 5 --- docs/planning/roadmap.md | 12 ++++++++++++ docs/planning/status.md | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index 444e3a1..b030765 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -4994,6 +4994,10 @@ restated here as prose. Written to cover, at minimum: PISO Pressure Coupling **Status: Done, 2026-08-27, Stage 4's sixth task.** +`src/pyflow/engine/numerics/pressure_coupling.py` implements `PISO`; +`src/pyflow/engine/numerics/gradient.py`/`divergence.py` implement +`GreenGaussGradient`/`GreenGaussDivergence`, both built by this task -- +see Design decision One below. **Intent:** the claim is that a single correction pass **measurably and boundedly reduces the divergence** of a manufactured provisional velocity @@ -5238,6 +5242,8 @@ restated here as prose. Written to cover, at minimum: Dirichlet Boundary **Status: Done, 2026-08-28, Stage 4's seventh task.** +`src/pyflow/engine/numerics/boundary_condition.py` implements +`DirichletBoundaryCondition`. **Intent:** the criterion is what the *interior scheme* computes at a boundary face, not what the condition object returns when asked. A @@ -5362,6 +5368,8 @@ exception class of its own). Neumann Boundary **Status: Done, 2026-08-28, Stage 4's eighth task.** +`src/pyflow/engine/numerics/boundary_condition.py` implements +`NeumannBoundaryCondition`. **Intent:** as TASK-028, for a prescribed **gradient** -- and the zero-gradient case must not be the only one tested, since a @@ -5470,6 +5478,10 @@ contract suite once `NeumannBoundaryCondition` joined it). Periodic Boundary **Status: Done, 2026-08-28, Stage 4's ninth and last task.** +Periodic bypasses `BoundaryCondition` entirely (see the Design decision +below); the real mechanism is `src/pyflow/engine/mesh.py`'s +`wrapped_neighbour_cell`, read directly by +`src/pyflow/engine/numerics/advection.py`/`diffusion.py`. **Intent:** the claim is that a field advected once around a periodic domain returns to its starting distribution -- a round-trip invariant, diff --git a/docs/planning/status.md b/docs/planning/status.md index 591663d..1147508 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -108,10 +108,10 @@ pie showData | TASK-024 | Done | 2026-08-27 | `src/pyflow/engine/numerics/diffusion.py` | | TASK-025 | Done | 2026-08-27 | `tests/unit/test_rk4_time_integration.py` | | TASK-026 | Done | 2026-08-27 | `tests/unit/test_conjugate_gradient_solver.py` | -| TASK-027 | Done | 2026-08-27 | `docs/practices.md` | -| TASK-028 | Done | 2026-08-28 | `icds.md` | -| TASK-029 | Done | 2026-08-28 | `docs/practices.md` | -| TASK-030 | Done | 2026-08-28 | `docs/practices.md` | +| TASK-027 | Done | 2026-08-27 | `src/pyflow/engine/numerics/pressure_coupling.py` | +| TASK-028 | Done | 2026-08-28 | `src/pyflow/engine/numerics/boundary_condition.py` | +| TASK-029 | Done | 2026-08-28 | `src/pyflow/engine/numerics/boundary_condition.py` | +| TASK-030 | Done | 2026-08-28 | `src/pyflow/engine/mesh.py` | ### Stage 5 -- First Fluid Solver