diff --git a/CLAUDE.md b/CLAUDE.md index cd2d91b..c162ecd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,20 @@ easiest to implement -- also `docs/practices.md`. --- +# Branch Discipline + +**All coding work happens on a branch, never directly on `main`.** +Standing rule, 2026-09-08, maintainer's instruction. + +Create a branch before the first edit, not after -- `docs/practices.md` +already names the branch-naming convention (`/`) and the one-branch-per-task granularity; this rule is what +makes both apply every time rather than only when someone remembers to +branch first. `main` only receives commits by merge, once the Merge Gate +above is satisfied. + +--- + # Acceptance Criteria for Simulation Work **From Stage 4 (`docs/planning/roadmap.md` TASK-023) onward, a task's diff --git a/adr/ADR-003-modular-numerical-strategies.md b/adr/ADR-003-modular-numerical-strategies.md index f6fe5f8..ef4dfc3 100644 --- a/adr/ADR-003-modular-numerical-strategies.md +++ b/adr/ADR-003-modular-numerical-strategies.md @@ -61,7 +61,7 @@ anticipated situation actually arose. under the exact same registered name (`"conjugate_gradient"`), again with no interface change and no edit to `test_linear_solver_contract.py`'s existing test bodies. Its own real content was the positive-semi-definite -pressure system `icds.md`'s Linear Solver ICD already named as "a real +pressure system `icds.md`'s Linear Solvers ICD already named as "a real compatibility requirement, not an incidental detail" -- a genuine correctness trap (naive "always project the null space out" silently solves a different problem for a well-conditioned system) caught by a diff --git a/docs/architecture/icds.md b/docs/architecture/icds.md index 5a8ce9f..8cc1436 100644 --- a/docs/architecture/icds.md +++ b/docs/architecture/icds.md @@ -206,7 +206,7 @@ progresses ahead of this one. --- -## Time Integrator +## Time Integration **Represents:** advancing the full simulation state forward by one timestep, given the state and a function that computes its time @@ -267,8 +267,9 @@ TASK-033 -- the outer corrector loop's own tunables, distinct from `numerics.linear_solver_tolerance`/`numerics.linear_solver_max_iterations`, which govern each pass's inner solve). -**Compatibility requirements:** requires a configured Linear Solver to -solve the pressure-correction equation it produces each timestep -- the +**Compatibility requirements:** requires a configured Linear Solvers +instance to solve the pressure-correction equation it produces each +timestep -- the one real cross-layer dependency among the six (every other layer here is independent of the others' choice). @@ -356,7 +357,7 @@ measuring the Lid-Driven Cavity validation's own real runtime. --- -## Linear Solver +## Linear Solvers **Represents:** solving the linear system Pressure–Velocity Coupling (and any other implicit step) produces. @@ -404,7 +405,7 @@ multigrid/preconditioned end of its upgrade path. --- -## Boundary Condition +## Boundary Conditions **Represents:** how a field behaves at domain edges where no neighbouring control volume supplies a flux. @@ -558,10 +559,20 @@ Reviewed 2026-08-18 against the numerical-methods handbook, which was written after this document and in places contradicts what it recorded. Three ICDs changed: Advection's "unconditionally stable" became "unconditionally bounded" (`docs/handbook/numerical-methods/fluxes.md` -explains why the distinction matters); Time Integrator's fourth-order +explains why the distinction matters); Time Integration's fourth-order claim was scoped, since the finished solver's temporal order is capped by -upwind advection and by pressure-coupling splitting; and Linear Solver and -Boundary Condition gained the singular-pressure-system and +upwind advection and by pressure-coupling splitting; and Linear Solvers and +Boundary Conditions gained the singular-pressure-system and global-mass-conservation compatibility requirements, both of which apply to the MVP's own validation cases rather than to a hypothetical future configuration. + +**Checked against `planning/data/components.yaml` as of 2026-09-08:** +each of the six `##` headings above (and each component's own page under +`docs/handbook/numerical-methods/`) is now a `must_appear_in` target for +its matching entity there, so a rename here that isn't mirrored in both +places fails `make check-graph`. Wording must stay byte-identical to the +entity's `name` field -- "Time Integration", "Linear Solvers", "Boundary +Conditions" (plural), matching `engine.md`/`upgrade-paths.md` -- not the +singular forms this document used until that date, found and fixed in +the same change that added the check. diff --git a/docs/handbook/numerical-methods/boundary-conditions.md b/docs/handbook/numerical-methods/boundary-conditions.md index ec2b4ef..ba6640b 100644 --- a/docs/handbook/numerical-methods/boundary-conditions.md +++ b/docs/handbook/numerical-methods/boundary-conditions.md @@ -63,7 +63,7 @@ this is closer to an interior face than either Dirichlet or Neumann: the periodic face pair supplies each other's "neighbour" value directly, the same way two interior cells do, just across a computational rather than geometric gap. A periodic condition requires its paired boundary to also -be periodic (`docs/architecture/icds.md`'s Boundary Condition ICD notes +be periodic (`docs/architecture/icds.md`'s Boundary Conditions ICD notes this explicitly) -- a periodic condition on only one side of a domain has no physical meaning, since there would be nothing for it to be paired with. @@ -133,7 +133,7 @@ independently, rather than a single shared condition). A real domain typically needs different condition types on different edges simultaneously (a fixed-velocity inlet, a zero-gradient outlet, a no-slip wall, and a periodic spanwise pair, for example, on the same -mesh) -- this is why `docs/architecture/icds.md`'s Boundary Condition ICD +mesh) -- this is why `docs/architecture/icds.md`'s Boundary Conditions ICD is deliberately per-boundary-face rather than a single simulation-wide choice, unlike the other five ADR-003-named components. Nothing in FVM's treatment requires uniformity across boundaries; each boundary face's diff --git a/docs/handbook/numerical-methods/linear-solvers.md b/docs/handbook/numerical-methods/linear-solvers.md index 24b6f43..350a954 100644 --- a/docs/handbook/numerical-methods/linear-solvers.md +++ b/docs/handbook/numerical-methods/linear-solvers.md @@ -48,7 +48,7 @@ here, and PyFlow's MVP choice, is iterative. **Conjugate Gradient (CG)** is an iterative method for systems whose matrix is **symmetric positive-definite** (SPD) -- a property the discrete Poisson-type pressure-correction system has on PyFlow's MVP -mesh (`docs/architecture/icds.md`'s Linear Solver ICD notes this +mesh (`docs/architecture/icds.md`'s Linear Solvers ICD notes this explicitly as a real compatibility requirement, not an incidental detail). In exact arithmetic CG reaches the exact solution in at most as many iterations as the system has unknowns; in practice it reaches an diff --git a/docs/handbook/numerical-methods/meshes.md b/docs/handbook/numerical-methods/meshes.md index 51d4f95..af7fd73 100644 --- a/docs/handbook/numerical-methods/meshes.md +++ b/docs/handbook/numerical-methods/meshes.md @@ -120,7 +120,7 @@ special treatment -- a thin wall, a material interface, or a mesh-refinement interface between regions of different resolution. PyFlow's MVP has only external boundaries (`docs/implementation/mvp.md`); internal boundaries are explicitly future work -(`docs/architecture/icds.md`'s Boundary Condition ICD limitations note, +(`docs/architecture/icds.md`'s Boundary Conditions ICD limitations note, and `upgrade-paths.md`'s Boundary Conditions entry). ## Future: Arbitrary Geometry and Adaptive Refinement diff --git a/docs/handbook/numerical-methods/time-integration.md b/docs/handbook/numerical-methods/time-integration.md index 8e7b3b4..6019d3b 100644 --- a/docs/handbook/numerical-methods/time-integration.md +++ b/docs/handbook/numerical-methods/time-integration.md @@ -193,5 +193,5 @@ RK4 and central difference above upwind, so the two paths must not be traversed independently. And RK4's fourth-order claim is now scoped to the ODE system it is handed, with the spatial and operator-splitting caps on the finished solver's observed order stated; -`docs/architecture/icds.md`'s Time Integrator ICD carries the same +`docs/architecture/icds.md`'s Time Integration ICD carries the same caveat. diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index a17d816..2dfcf25 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -306,9 +306,10 @@ 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): **1160 tests as of 2026-09-07**, up from 1154 slightly -earlier the same day (below), then 1153, 1143, 1137, 1131, and 1052 the -day before that. +(C1a/C1b): **1163 tests as of 2026-09-08**, up from 1160 the day before +(three new `check_manifest.py` tests, `ka-name-matches-manifest`), then +1154 slightly earlier that day (below), then 1153, 1143, 1137, 1131, and +1052 the day before that. **The 6 most recent are `pyflow resume`'s own new `--config`/`config_path` alternative** -- a further same-day user request ("do pyflow resume @@ -12354,7 +12355,7 @@ they are not the same amount of work. This is the Unlock whose contribution to *accuracy* is least obvious, and the question should be asked before a task is written rather than discovered by an exit audit looking for the improvement. -`docs/architecture/icds.md`'s Linear Solver ICD records that the +`docs/architecture/icds.md`'s Linear Solvers ICD records that the pressure-correction system PISO produces on the MVP's mesh is symmetric positive-definite, which is precisely the case Conjugate Gradient is already the right solver for; diff --git a/docs/planning/status.md b/docs/planning/status.md index 6ece6c6..38ad29b 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -46,7 +46,7 @@ pie showData ## Live repository facts - **47** `CLAUDE.md` files -- **1160** tests collected +- **1163** tests collected - **144** Gherkin scenarios (`tests/features/*.feature`) ## Stages diff --git a/planning/data/components.yaml b/planning/data/components.yaml index 7458428..e8d36d1 100644 --- a/planning/data/components.yaml +++ b/planning/data/components.yaml @@ -22,6 +22,20 @@ # entry, uses lowercase prose bullets with different wording # ("variable arrangement" for Variables), so it is named here but not # machine-checked -- stated rather than silently dropped. +# +# The six entities `adr/ADR-003-modular-numerical-strategies.md` names as +# independently configuration-selected (advection, diffusion, +# boundary-conditions, time-integration, linear-solvers, +# pressure-velocity-coupling) carry two *additional* `must_appear_in` +# entries beyond the nine-layer pair above: `docs/architecture/icds.md` +# (each restates the name as its own `##` ICD heading) and that +# component's own page under `docs/handbook/numerical-methods/`. This is +# a narrower, ADR-003-specific pattern -- mesh, variables and flux are not +# ADR-003 components and do not get it. Added 2026-09-08, after `icds.md` +# was found spelling three of the six differently ("Time Integrator", +# "Linear Solver", "Boundary Condition") from every other document and +# from these entities' own `name` fields -- fixed in the same change that +# added the check. category: components @@ -61,6 +75,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/advection.md edges: - type: depends_on to: variables @@ -78,6 +94,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/diffusion.md edges: - type: depends_on to: variables @@ -95,6 +113,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/boundary-conditions.md edges: - type: depends_on to: mesh @@ -141,6 +161,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/time-integration.md edges: - type: depends_on to: variables @@ -161,6 +183,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/linear-solvers.md # No edges, and that is the contract rather than an omission: "given # a linear system, produces its solution, independent of the # system's origin". A dependency here would contradict engine.md. @@ -175,6 +199,8 @@ entities: must_appear_in: - docs/architecture/engine.md - docs/implementation/upgrade-paths.md + - docs/architecture/icds.md + - docs/handbook/numerical-methods/pressure-velocity-coupling.md edges: - type: depends_on to: variables diff --git a/src/pyflow/engine/CLAUDE.md b/src/pyflow/engine/CLAUDE.md index 62c9b33..e83c5c1 100644 --- a/src/pyflow/engine/CLAUDE.md +++ b/src/pyflow/engine/CLAUDE.md @@ -929,7 +929,7 @@ module with the interface the same way `FirstOrderUpwindAdvection`/ from `x0 = 0`, with one addition: null-space handling is *gated*, not unconditional. `matrix @ ones` close to zero relative to `matrix`'s own norm signals the constant vector is in the null space (the lid-driven -cavity's own pressure system, `icds.md`'s Linear Solver ICD); when true, +cavity's own pressure system, `icds.md`'s Linear Solvers ICD); when true, the constant mode is projected out of the residual after every update. **Verified before being written, not assumed:** a throwaway numerical prototype confirmed unconditional projection reports `converged=True` diff --git a/tests/unit/test_check_manifest.py b/tests/unit/test_check_manifest.py index 0f61c57..60ba1ad 100644 --- a/tests/unit/test_check_manifest.py +++ b/tests/unit/test_check_manifest.py @@ -177,6 +177,68 @@ def test_a_not_started_row_naming_a_genuinely_empty_file_is_accepted(tmp_path: P assert check_manifest(root) == [] +def test_ka_name_mismatch_between_manifest_and_ka_doc_is_reported(tmp_path: Path) -> None: + """The one correspondence the manifest and the knowledge-architecture + document can agree or disagree about without a reader's judgement: + where both cite the same KA-NNN, do they name the same file? + """ + root = _repo( + tmp_path, + manifest="# Repository Manifest\n\n| File | Status |\n|---|---|\n" + "| bar.md | 🟨 | Something (KA-001) |\n", + files={ + "bar.md": "x\n", + "foo.md": "x\n", + "docs/planning/knowledge-architecture.md": ( + "# Knowledge Architecture\n\n## KA-001 — Foo\n\n**Name:** `foo.md`\n" + ), + }, + ) + + findings = _findings(root) + assert "ka-name-matches-manifest" in findings + assert "KA-001" in findings + + +def test_ka_name_agreement_is_not_reported(tmp_path: Path) -> None: + root = _repo( + tmp_path, + manifest="# Repository Manifest\n\n| File | Status |\n|---|---|\n" + "| foo.md | 🟨 | Something (KA-001) |\n" + "| knowledge-architecture.md | 🟨 | KA source |\n", + files={ + "foo.md": "x\n", + "docs/planning/knowledge-architecture.md": ( + "# Knowledge Architecture\n\n## KA-001 — Foo\n\n**Name:** `foo.md`\n" + ), + }, + ) + + assert check_manifest(root) == [] + + +def test_a_manifest_row_citing_an_unknown_ka_is_not_reported_by_this_rule(tmp_path: Path) -> None: + """Deliberately narrow scope, pinned so it isn't widened without + someone first seeing why it was kept this way: this rule checks + agreement, not completeness. A citation with no matching KA heading + (the document might retire an entry in prose, the same way this + manifest retires paths in prose) is a different rule's job, if it is + anyone's job at all -- not this one's. + """ + root = _repo( + tmp_path, + manifest="# Repository Manifest\n\n| File | Status |\n|---|---|\n" + "| bar.md | 🟨 | Something (KA-999) |\n" + "| knowledge-architecture.md | 🟨 | KA source |\n", + files={ + "bar.md": "x\n", + "docs/planning/knowledge-architecture.md": "# Knowledge Architecture\n", + }, + ) + + assert check_manifest(root) == [] + + def test_the_repositorys_own_manifest_passes() -> None: """A different assertion from every test above: those prove the rules fire, this proves the real manifest satisfies them. Named so a diff --git a/tools/validators/CLAUDE.md b/tools/validators/CLAUDE.md index edc3101..90ab0ee 100644 --- a/tools/validators/CLAUDE.md +++ b/tools/validators/CLAUDE.md @@ -324,3 +324,27 @@ rule. Dropping it was right, and pins the decision so it isn't re-added without someone seeing why it went. **Prefer three rules that always mean something to four where one needs interpreting.** + +**`ka-name-matches-manifest`, added 2026-09-08.** Checks the one +correspondence `docs/planning/knowledge-architecture.md` and +`docs/repository-manifest.md` can agree or disagree about without a +reader's judgement: where a manifest row cites `(KA-NNN)` and that id +exists as a heading in the KA document with a `**Name:**` field, do the +two name the same file (by final path segment)? It says nothing about +completeness in either direction, deliberately -- a naive "every KA-NNN +must be cited somewhere in the manifest" rule, or its reverse, would +reproduce this file's own dropped fourth rule above almost exactly: +`docs/planning/knowledge-architecture.md` retires entries in prose on +purpose (KA-034, "never created and will not be"; KA-038, a category of +files rather than one), the same way this manifest names retired paths +on purpose. Scoping to "both sides already agree the id exists" keeps +every finding a structural fact rather than a completeness judgement, +the same trade `check_graph.py`'s `must_appear_in` already makes for a +different pair of documents. + +Verified against the real repository before landing (not merely +assumed clean): all 43 KA entries have exactly one `**Name:**` field, no +id repeats, every manifest citation resolves to a real KA heading, and +every citing row already agreed with its KA entry's `**Name:**` field -- +the gate landed with zero findings against `docs/repository-manifest.md` +and `docs/planning/knowledge-architecture.md` as they stood that day. diff --git a/tools/validators/check_manifest.py b/tools/validators/check_manifest.py index 3fe8d09..0e2fa4a 100644 --- a/tools/validators/check_manifest.py +++ b/tools/validators/check_manifest.py @@ -25,6 +25,20 @@ - not-started-is-empty: a row marked with the "Not Started" symbol names a file that is absent or genuinely empty, per the manifest's own legend. +- ka-name-matches-manifest: where a manifest row cites `(KA-NNN)` and + that id exists as a heading in `docs/planning/knowledge-architecture.md` + with a `**Name:**` field, the row's own filename cell must name the + same file (by final path segment) as that field. Says nothing about + completeness in either direction -- a KA entry no manifest row cites, + or a citation naming a KA-NNN with no such heading, are not flagged by + this rule. A narrower "every KA cited, every citation resolves" rule + was considered and rejected for the same reason the fourth rule below + was: `docs/planning/knowledge-architecture.md` retires entries in prose + on purpose (KA-034, KA-038 among them), the same way this manifest + retires paths on purpose, so either completeness direction would very + likely reproduce that rule's own false-positive failure. Scoping to + "both sides already agree the id exists" keeps every finding a + structural fact instead. **A fourth rule was built and removed rather than shipped** (2026-08-21): "every path the manifest names exists". It produced 44 findings on the @@ -60,12 +74,37 @@ REPO_ROOT = Path(__file__).resolve().parents[2] MANIFEST_PATH = Path("docs") / "repository-manifest.md" +KA_DOC_PATH = Path("docs") / "planning" / "knowledge-architecture.md" NOT_STARTED = "⬜" COLLECTIVE_BLOCK = re.compile(r"```text collective-coverage\n(.*?)```", re.DOTALL) # A row's leading cell, for `| name | status | ... |` tables. TABLE_ROW = re.compile(r"^\|\s*([^|]+?)\s*\|(.*)$") +# `## KA-NNN — Title`, and that entry's `**Name:** \`path\`` field. The +# `~{0,2}` tolerates a strikethrough (KA-034's retired entry) without +# needing to special-case it -- the backtick pair survives intact either +# way. +KA_HEADING = re.compile(r"^## (KA-\d{3})\b", re.MULTILINE) +KA_NAME = re.compile(r"\*\*Name:\*\*\s*~{0,2}`([^`]+)`") +KA_CITATION = re.compile(r"\(KA-(\d{3})\)") + + +def _ka_names(ka_doc: str) -> dict[str, str]: + """`{KA-NNN: name_path}` for every entry with a `**Name:**` field. + + Scoped per-entry (heading to next heading) rather than document-wide, + so a `**Name:**` field can never be attributed to the wrong id. + """ + headings = list(KA_HEADING.finditer(ka_doc)) + names: dict[str, str] = {} + for index, heading in enumerate(headings): + start = heading.end() + end = headings[index + 1].start() if index + 1 < len(headings) else len(ka_doc) + name_match = KA_NAME.search(ka_doc[start:end]) + if name_match is not None: + names[heading.group(1)] = name_match.group(1) + return names def _tracked_files(root: Path) -> list[str]: @@ -140,6 +179,30 @@ def check_manifest(root: Path = REPO_ROOT) -> list[str]: f"but {path} has content" ) + # -- ka-name-matches-manifest ---------------------------------------- + # Silent, not a finding, if the KA document is absent: this rule only + # has something to say where both documents already agree an id + # exists, and a repository (or test fixture) without the KA document + # has nothing to compare. + ka_doc_file = root / KA_DOC_PATH + if ka_doc_file.is_file(): + ka_names = _ka_names(ka_doc_file.read_text(encoding="utf-8")) + for line in manifest.splitlines(): + row = TABLE_ROW.match(line) + if row is None: + continue + row_name = row.group(1).strip().strip("`") + for number in KA_CITATION.findall(row.group(2)): + ka_id = f"KA-{number}" + name_path = ka_names.get(ka_id) + if name_path is None or Path(row_name).name == Path(name_path).name: + continue + findings.append( + f"ka-name-matches-manifest: {ka_id} names {name_path} in " + f"{KA_DOC_PATH.as_posix()}, but the manifest row citing it " + f"names {row_name}" + ) + return findings