TASK-052: stop advection transporting through solid walls - #92
Merged
Conversation
`FirstOrderUpwindAdvection` took a genuine boundary face's transporting velocity from the cell inside it rather than from what the configuration prescribed there, so material crossed solid walls at up to 42% of the flow's own peak speed. `GreenGaussDivergence` meanwhile consulted the boundary condition and treated the same walls as solid, so the two operators disagreed about the domain they were solving in. Measured on the shipped smoke_transport geometry, diffusion zeroed so only advection remains: a sealed box lost 14.27% of a purely advected tracer over 400 steps. It now conserves it to +0.000000000000%. This falsified a use case Stage 4 wrote down for itself -- "prescribe a wall's value or its gradient, or make a boundary periodic, and have the interior schemes honour it". The fix is reuse, not new mechanism. `boundary_normal_velocity` is extracted from what divergence already did, and advection now calls it too -- three files, no interface change, no registry change, no arity change anywhere. A first implementation threaded a per-edge mapping from `BoundaryFaceConfig.velocity` through `assemble_numerics` and widened two registries; it was built, measured working, and abandoned when the divergence contract suite's linear-field exactness test turned out to be inexpressible through a per-edge scalar. That is recorded in the task entry rather than presented as the first idea. A second defect fell out of writing the tests: `DirichletBoundaryCondition` fell through to `BoundaryFaceConfig.scalar_value` for a vector field with no override -- a transported scalar's boundary value, returned as a velocity. Every shipped config leaves it at 0.0, so walls were impermeable by coincidence of the defaults rather than by anything the configuration said. A vector field with no override now resolves to 0.0. Evidence the change is physics, not a re-fitted tolerance: the error against Ghia, Ghia & Shin (1982) fell at every resolution and stayed monotonic -- 9x9 0.1433 -> 0.1292, 13x13 0.0874 -> 0.0766, 17x17 0.0578 -> 0.0524. Both baselines are recorded, per Criterion 7. Couette, Taylor-Green (both halves), kinetic-energy monotonicity and Stage 6's periodic domain integrals and decay rates were confirmed unmoved. One check did move, and it belonged to Stage 6. Its Rayleigh-Benard onset scenario compared the two orientations' final vertical-velocity RMS and required a factor of two; it passed at 6.35 and now measures 1.28. That verdict was produced by this defect -- heat and momentum were crossing that fixture's own solid walls, with the Dirichlet condition re-injecting boundary-temperature fluid, a spurious heat pump. Re-derived as a growth comparison (maintainer's call), which is what that fixture's own docstring said it always meant to check and which separates the two by roughly sixty to one. Mutation-verified against an inverted buoyancy sign. Recorded in Stage 6's own status table as a verdict a later stage changed, not rewritten as though it had always read that way. Criterion 3 is discharged only partially and says so: the config-field sweep is not built, and `BoundaryFaceConfig.velocity` still reaches no scheme. Stage 9's Golden Demo (Sealed Box) lands with it, and TASK-053/054 are drafted. `make ci` green: 1206 passed, 156 scenarios, all 15 structural checks. Every demo re-run headlessly and the record/resume/play pipeline re-verified by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the Merge Gate's criterion 2 run as a grep rather than a diff review, after the task's own documentation sweep had already been done and the PR was green on both platforms. All three describe the defect as though it were the design: - `src/pyflow/engine/CLAUDE.md`'s `FirstOrderUpwindAdvection` entry: "the face-normal velocity ... or the owner's own velocity alone for a boundary face". Accurate until 2026-09-12, and the sentence the fix exists to falsify. Replaced, with the old wording kept as the historical note it now is. - `advection.py`'s own inline comment on `v_avg`, which read the placeholder-neighbour average as the answer at a boundary face rather than as the input to one. - `FirstOrderUpwindAdvection`'s class docstring, which described the periodic and boundary-value treatments and said nothing about the transporting velocity at all. This is the shape `docs/practices.md`'s "A stage's documentation sweep is a grep, not a diff review" names: the sweep that ran with the task added new prose next to the contradicting old prose and did not grep for the claim it had just made false. `make ci` green: 1206 passed, 156 scenarios, all 15 structural checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from
docs/insert-stage-9-solver-run-integrity
to
main
September 12, 2026 22:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #91 (Stage 9's insertion) — merge that first.
FirstOrderUpwindAdvectiontook a genuine boundary face's transporting velocity from the cell inside it rather than from what the configuration prescribed there.GreenGaussDivergencemeanwhile consulted the boundary condition and treated the same walls as solid — so the two operators disagreed about the domain they were solving in, and material crossed solid walls at up to 42% of the flow's own peak speed.This falsified a use case Stage 4 wrote down for itself:
The measurement
Shipped
smoke_transport.yamlgeometry, tracer diffusion zeroed so only advection remains. Pure advection in a closed domain must conserve exactly:Evidence this is physics, not a re-fitted tolerance
The error against Ghia, Ghia & Shin (1982) fell at every resolution and stayed monotonic:
Both baselines are recorded rather than the old ones overwritten (Criterion 7). The 0.08 bound is deliberately unchanged — tightening it to each improvement would stop it being an independent check. Couette, Taylor-Green (both halves), kinetic-energy monotonicity and Stage 6's periodic domain integrals and decay rates were all confirmed unmoved rather than assumed so.
The fix is reuse, not new mechanism
boundary_normal_velocityis extracted from what divergence already did; advection now calls it too. Three files, no interface change, no registry change, no arity change anywhere.A first implementation threaded a per-edge mapping from
BoundaryFaceConfig.velocitythroughassemble_numericsand widened two registries. It was built, measured working, and abandoned whentest_divergence_contract.py's linear-field exactness test turned out to be inexpressible through a per-edge scalar — a linear field's normal component varies along an edge, and a future parabolic inlet would have the same problem. That is recorded in the task entry rather than presented as the first idea.A second defect, found by a test rather than by reading
DirichletBoundaryCondition.evaluatefell through toBoundaryFaceConfig.scalar_valuefor a vector field with no override — a transported scalar's boundary value, returned as a velocity. Every shipped config leaves it at0.0, so walls were impermeable by coincidence of the defaults rather than by anything the configuration said: the same shape of accident the pre-fix divergence operator had. A new scenario failed against a fixture whose scalar boundary value was3.0, because the wall then resolved to a normal velocity of 3.0.One check moved, and it belonged to another stage
Stage 6's Rayleigh–Bénard onset scenario compared final vertical-velocity RMS and required a factor of two. It passed at 6.35 and now measures 1.28 — because that verdict was produced by this defect: heat and momentum were crossing that fixture's own solid walls with the Dirichlet condition re-injecting boundary-temperature fluid, a spurious heat pump.
Re-derived as a growth comparison (maintainer's call) — which is what that fixture's own docstring says it always meant to check, and which the 2026-08-31 audit found had never been implemented:
Roughly sixty to one, against bars of 25% and 5%. Mutation-verified: inverting the buoyancy sign drops "below" to 1.012 and fails it. Recorded in Stage 6's own status table as a verdict a later stage changed, not rewritten as though it had always read that way.
Honest about what is not done
Criterion 3 is discharged only partially, and the Discharges section says so. The sweep over
BoundaryFaceConfig's own fields is not built, andBoundaryFaceConfig.velocitystill reaches no scheme — it is validated for mutual exclusivity and zero net flux, then read by nothing. What this task closes is the half that was actually wrong.TASK-053 (a failed frame failing the run) and TASK-054 (the timestep stability warning) are drafted, not built — both carry
Status: Not started, so Stage 9 reads asopenedrather thancomplete.Verification
make cigreen in full: 1206 passed, 20 skipped, 156 scenarios, all 15 structural checks. All 11 golden demos re-run headlessly;sealed_boxverified throughpyflow run --demos sealed_boxand present in the demo listing as #12; the documentedrecord→resume→playpipeline re-run by hand.🤖 Generated with Claude Code