From 341a570e78b44c511592f817afa16434ece900d2 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:10:01 +1030 Subject: [PATCH 01/28] docs(spec): plan stage S5 of 005 and record its review decisions --- .../005-coordinate-frame-correctness/plan.md | 986 +++++++++++++++++- specs/README.md | 2 +- 2 files changed, 963 insertions(+), 25 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/plan.md b/specs/005-coordinate-frame-correctness/plan.md index 41b59561..e1db6776 100644 --- a/specs/005-coordinate-frame-correctness/plan.md +++ b/specs/005-coordinate-frame-correctness/plan.md @@ -7,18 +7,20 @@ The constitution (.specify/memory/constitution.md) has priority. --> ## Open points -- **OP1**: This plan gives the exact design only for stages S1 to S4 (S1: +- **OP1**: This plan gives the exact design only for stages S1 to S5 (S1: shared transform, reference-constraint boundary and fault frame; S2: fold, P2, directional-regularisation and constant-norm cells; S3: saved frames, copies and transform changes; S4: display, data export, fault-centre geometry and - intrusion classification). The spec-planner limit is 10 code files, and the + intrusion classification; S5: resize paths, cell centres, non-mutating + diagnostics, residual and exclusion report). The spec-planner limit is 10 code files, and the maintainer selected a staged plan on 2026-11-04. S2 has its exact design since 2026-11-04 ("Stage S2 design"). S3 has its exact design since 2026-11-05 ("Stage S3 design"), and S4 since 2026-11-05 ("Stage S4 design"). - Stages S5 and S6 have a stage map, a file list from a grep survey and a - requirement allocation, but no exact signatures. Owner: the spec-planner - skill, one run for each stage. Blocks: the tasks of S5 and S6. The spec-tasks - skill makes the S4 tasks next (S1 is merged; S2 is in pull request #396; S3 + S5 has its exact design since 2026-11-05 ("Stage S5 design"). Stage S6 has + a stage map, a file list from a grep survey and a requirement allocation, but + no exact signatures. Owner: the spec-planner + skill, one run for each stage. Blocks: the tasks of S6. The spec-tasks + skill makes the S5 tasks next (S1 is merged; S2 is in pull request #396; S3 is built in pull request #397). - **OP2**: Run time of the mandatory R2 and R5 matrices. R2 needs 4 anchors x 3 rotations x 2 centre modes x 2 engines = 48 solves (FDI with 20000 @@ -97,6 +99,40 @@ The constitution (.specify/memory/constitution.md) has priority. --> defect. Owner: the maintainer. Decision needed: file an issue, as for the C-005 and C-006 defects. Default until an answer: S4 keeps the expression and tests only the branch without `value_map`. Blocks: nothing. +- **OP12**: The spec (FR-015) says that cell-centre sampling can place a centre + beyond its cell. By reading, `BoundingBox.cell_centres` is correct for a + rotated box. The spacing equals `node_step_vector`, and the four callers + convert with `reproject`. The planner did not run the code. Owner: the S5 + cell-centre test task. Decision needed: if all 60 cells of the new tests pass, + record "not reproduced" and keep the tests as guards. Default until an answer: + no code change in `_bounding_box.py`. If a cell fails, use the closed form of + "S5 decisions" item 7. Blocks: nothing. +- **OP13** (closed 2026-11-05, maintainer answer Q3, see "Answers"): The misfit + methods must not change state and must not rebuild a stale model. Rule: on a + stale builder, fault builder or interpolator, the methods write one + `UserWarning` and return NaN ("S5 decisions" item 11). +- **OP14** (closed 2026-11-05, maintainer answer Q1, see "Answers"): The 2D + `maximum` keeps its legacy value `origin + nsteps * step`. It counts nodes as + cells. S5 keeps it for FR-028. The four existing assertions pass with no change. + S5 only derives `nsteps_cells`, `n_cell_x` and `n_cell_y` from `nsteps`. +- **OP15** (closed 2026-11-05, maintainer answer Q2, see "Answers"): The old misfit + methods keep their names and public shape. New names give the 1-D result. The + gradient value changes meaning. `COMPAT.md` and `ROADMAP.md` record it. +- **OP16**: The old `evaluate_gradient_misfit` for two families (gradient and + normal) of different length. Before S5 it raised `ValueError` on current NumPy. + Equal lengths gave shape `(2, N)`. Owner: the maintainer. Decision needed: keep + `(2, N)` for equal lengths and return one row `(1, N1 + N2)` for different + lengths, or always return one row. Default until an answer: the first choice, + because it keeps the shape that worked. `COMPAT.md` records it. Blocks: + nothing. +- **OP17**: The setter fix and the extent from `set_nelements` can change numeric + tests, including `packages/loop_interpolation/tests/golden` (the parity oracle + for the Rust path, with its own `--update-golden` option). Owner: the S5 survey + task. Decision needed: whether to regenerate such a file. Default until an + answer: record each change in "S5 golden review (C-004)"; regenerate a file only + when the cause is the intended extent change, and name the file; leave the + gates red and report if the cause is not clear. Blocks: the merge of S5 when a + parity file changes. ## Answers @@ -185,6 +221,36 @@ The constitution (.specify/memory/constitution.md) has priority. --> fail before T032 and pass after it. T030 and T031 also need T032. The PLI control meets SC-002 (value error 1.8e-12, gradient error 1.3e-10). +- 2026-11-05 (S5 plan): OP1 is closed for S5. After an independent review of + the S5 design, the maintainer answered five questions on 2026-11-05. The S5 + design was revised to match. +- 2026-11-05 (Q1, 2D `maximum`, maintainer): do not change the value of the 2D + `maximum`. Derive `nsteps_cells`, `n_cell_x` and `n_cell_y` from `nsteps`, and + keep `maximum = origin + nsteps * step` (legacy; it counts nodes as cells). This + is kept for FR-028. The four existing assertions in + `packages/loop_common/tests/test_2d_discrete_support.py` (lines 14 and 22) and + `tests/unit/interpolator/test_2d_discrete_support.py` (lines 16 and 24) stay + unchanged and must pass. OP14 is closed. +- 2026-11-05 (Q2, misfit result, maintainer): keep the old public names and the + old public shape of `evaluate_value_misfit` and `evaluate_gradient_misfit`. The + new non-mutating, world-correct 1-D result has new names + (`evaluate_gradient_cosines`, `evaluate_value_residuals`). The old methods call + the same corrected code. The gradient value changes meaning (a cosine). This is + a behaviour change, recorded in `COMPAT.md` (migration notice) and in a + `ROADMAP.md` decision entry. OP15 is closed. +- 2026-11-05 (Q3, stale model, maintainer): the misfit methods change no state + and do not rebuild a stale model. S5 chose one rule: warn once with + `UserWarning` and return NaN. OP13 is closed. +- 2026-11-05 (Q4, `set_nelements`, maintainer): accept the exact extent after + `set_nelements`. S5 adds a numeric-test survey, a new section "S5 golden review + (C-004)", and a regeneration of the golden data files after review (C-004, + SC-015). The earlier sentence that the 3D golden test passes with no change is + withdrawn. `set_nelements` clamps `nsteps` to at least 2; the setter rejects + non-whole values; the 2D `nsteps` assignment coerces to an int array. +- 2026-11-05 (Q5, value misfit, maintainer): the public value misfit keeps its + normalisation by the field range. The physical residual uses an unnormalised + private helper, so its unit "field" is true. + ## Summary Observations, fault geometry, masks and public queries stay in world @@ -207,8 +273,9 @@ S1 repairs the shared boundary and the fault frame: S2 repairs the fold paths. S3 makes saved interpolators, copies and transform changes keep the frame ("Stage S3 design"). S4 removes two extra conversions: the display and export of observation data, and the intrusion classification. It also -deprecates `GeologicalModel.rescale` ("Stage S4 design"). S5 and S6 repair grid and diagnostics, and the auxiliary -utilities and validation matrix (see "Stages and rollback"). +deprecates `GeologicalModel.rescale` ("Stage S4 design"). S5 repairs the resize paths and the misfit diagnostics and adds the residual and +exclusion report ("Stage S5 design"). S6 covers the auxiliary utilities and the +validation matrix (see "Stages and rollback"). ## Technical context @@ -274,7 +341,7 @@ utilities and validation matrix (see "Stages and rollback"). ## Design -S1 to S4 have the exact design below. S5 and S6 have scope only (OP1). +S1 to S5 have the exact design below. S6 has scope only (OP1). ### Types and data @@ -1686,6 +1753,862 @@ two codes give the same `get_data()` output; (d) on the S3 code `model.rescale(p and `model.scale(p)` give no warning, on the S4 code each gives one, and the values are equal. +### Stage S5 design + +S5 has four aims. First, S5 makes the remaining resize paths keep the node counts +and cell counts consistent (FR-014). Second, S5 checks that cell-centre sampling +agrees with the grid cells (FR-015). Third, S5 makes the feature misfit +diagnostics leave their inputs unchanged and compare quantities in one basis +(FR-023). Fourth, S5 adds a residual and exclusion report that keeps physical +residuals and assembled residuals apart (FR-024). + +Base: branch `feat/005-s4-world-locations` (S4, pull request #401, not merged). If +S4 changes before it merges, run this planner again on the merged result. + +The plan was revised on 2026-11-05 after an independent review and five +maintainer answers (see "Answers"). The text below is the revised design. + +#### S5 facts from the code (read on 2026-11-05) + +- Resize, 3D. S1 repaired the `origin` and `maximum` setters of + `StructuredGrid3DGeometry` with `_cells_for_length`. Two setters of the same + class were not repaired (`_structured_grid_3d.py`, `nsteps` setter at line 71, + `step_vector` setter at line 131). The `nsteps` setter counts nodes. It does + `step_vector /= nsteps_new / nsteps_old`. This keeps the product + `nsteps * step` constant. It does not keep the extent `(nsteps - 1) * step`. + Example: the node count 11 to 21 with step 1 gives step `0.524` and a new + extent `10.48` instead of `10`. +- The `step_vector` setter sets the node count to `ceil(nsteps / factor)`. It + treats the node count as a cell count. So the extent also drifts. It can lose + a cell. +- `set_nelements` (`_structured_grid_3d.py:50-57`) ends with `self.nsteps = + nsteps`. It computes `nsteps = ceil((maximum - origin) / step)` with a cubic + step from the requested element count. So it uses the `nsteps` setter, and the + setter fix changes what `set_nelements` leaves behind. Callers: + `GeologicalFeatureBuilder.build` calls `interpolator.set_nelements` (line 575 + of `_geological_feature_builder.py`) and the support method is reached through + `DiscreteInterpolator.set_nelements` (line 104). `set_interpolation_geometry` + runs `support.step_vector = support.step_vector * 0.9` in a loop while + `interpolator.dof < 100` (line 525 of the same file). So both setters run in + ordinary model builds, and numeric tests that depend on a built support can + change. +- The golden test `packages/loop_common/tests/test_structured_support_reference.py` + runs `set_nelements(200)`, then `nsteps + 1`, then `step_vector * 2`, then + `origin - 1`, then `maximum + 3`, in sequence on one grid (lines 171 to 180). + Each case records the state that the previous setters left. So the cases + `after_set_nelements`, `after_nsteps`, `after_step_vector`, `after_origin` and + `after_maximum` all change when the two setters change. The earlier text of + this plan said that `after_origin` and `after_maximum` stay: that was wrong. +- `StructuredGrid2DGeometry` (`_structured_grid_2d.py`) has plain attributes + `nsteps`, `maximum`, `nsteps_cells`, `n_cell_x` and `n_cell_y`, set once in + `__init__` (lines 23 to 30). `maximum = origin + nsteps * step_vector` counts + nodes as cells. `nodes` uses `origin + nsteps_cells * step_vector`. The two + disagree by one step. The three count attributes and `maximum` go stale if + `nsteps` is assigned later. The 3D class computes `maximum` as + `origin + nsteps_cells * step_vector`. The 2D class `set_nelements` raises + `NotImplementedError` (`supports/_2d_structured_grid.py:50`). Four existing + assertions read the 2D `maximum`: `packages/loop_common/tests/test_2d_discrete_support.py` + lines 14 and 22, and `tests/unit/interpolator/test_2d_discrete_support.py` + lines 16 and 24. They expect `origin + nsteps * step` (the legacy value). +- `RectilinearGrid` (`supports/_3d_rectilinear_grid.py`) holds node arrays + `_xnodes`, `_ynodes`, `_znodes`. It inherits the `origin`, `nsteps` and + `step_vector` setters of the 3D geometry. A call changes the stored counts and + the stored origin, but not the node arrays. So the geometry and the nodes + disagree. `maximum` is read from the node arrays and has no setter. The + constructors of the geometry classes write the private attributes (`_origin`, + `_nsteps`, `_step_vector`), not the setters. +- Cell centres. `BoundingBox.cell_centres` (`_bounding_box.py:647`) builds + `nsteps - 1` centres with `linspace(origin + h, maximum - h, nsteps - 1)` and + `h = node_step_vector / 2`. Now `maximum - origin = (nsteps - 1) * step`. So + the spacing is exactly `node_step_vector`. Every centre is the middle of a cell + of `regular_grid(local=False)`. The method returns local points. All four + callers (`_base_geological_feature.py:391`, `420`, `442` and + `_model_exporter.py:77`) call `reproject` on the result. The existing tests + (`test_bounding_box_structured_grid.py`, `test_scalar_field_grid.py`) use + unrotated boxes only. By reading, the default path has no defect for a rotated + box (OP12). `structured_grid(local_coordinates=True)` builds a different grid. + Its cells are the enclosure of the projected corners. That grid works in the + local frame, so `grid.cell_index_at` takes local points. Its `cell_centres` are + the centres of those cells. They are not `box.cell_centres()`. The supports' + `cell_centres(global_index)` (3D, 2D, rectilinear) use their own local frame and + do not apply `rotation_xy`, as their docstrings state. +- Misfit methods (`_geological_feature.py`: `evaluate_gradient_misfit` at line 240 + and `evaluate_value_misfit` at line 272 on this branch). `get_gradient_constraints()` + and `get_norm_constraints()` return the stored arrays by reference + (`_geological_interpolator.py`: the definitions are at lines 633 and 652, and + the returns are at lines 640 and 660). So `grad /= norm` (line 255) changes + `interpolator.data["gradient"]` in place. The norm is taken over all columns + (position, vector and weight). So the position and the weight columns change + too. `grad[:, :3:6]` (line 260) is the slice with start 0, stop 3 and step 6. + That is column `0` only. The vector columns are `3:6`. The stored vectors are + in the local basis. The interpolator converts them once when it stores them. + `evaluate_gradient` returns world vectors. So a rotated frame compares two + bases. +- Evaluation path. `GeologicalFeature.evaluate_value` (line 117) and + `evaluate_gradient` call `self.builder.up_to_date()` (lines 141 and 191). + `BaseBuilder.up_to_date` (`_base_builder.py:81`) first calls itself on every + fault builder. Then, if the builder or the interpolator is stale, it calls + `update()`, which builds again and solves. So a misfit method that calls + `self.evaluate_value` on a stale model rebuilds it. This also holds for + `self.max()` and `self.min()` (`_base_geological_feature.py:280` and `294`), + because they call `evaluate_value`. The earlier text of this plan marked this + path as not read. It is now read. No separate cache-validity flag exists in + `GeologicalFeature`, the builder or the interpolator (grep for `cache` on + 2026-11-05). The state is `interpolator.up_to_date`, `builder._up_to_date` + (a protected attribute; no public property exists), `interpolator.c` and + `interpolator.latest_diagnostics_report`. +- Result shape. With one family, `np.array([list])` gives the shape `(1, N)`. + With two families of equal length it gives `(2, N)`. With two families of + different length, `np.array([list, ndarray])` raises `ValueError` on current + NumPy. The value misfit divides by `self.max() - self.min()` (line 288): zero + range gives a division by zero. It does not change its inputs. No code in + `LoopStructural` or `packages` calls the two methods (`git grep -n -w` on + 2026-11-05). `ROADMAP.md` line 832 names them in a history note only. +- Diagnostics report. `_diagnostics.py` holds three frozen dataclasses and + `ConstraintDiagnosticsReport` with `to_dict` and `summary`. It has + `from __future__ import annotations`. The two builders + (`GeologicalInterpolator._build_constraint_diagnostics_report`, line 731, and the + `DiscreteInterpolator` override, line 221) only read state. + `get_constraint_diagnostics_report(refresh=False)` stores the result in + `latest_diagnostics_report`. +- `DiscreteInterpolator.calculate_residual_for_constraints()` (line 497) gives + `A x - b` for each assembled constraint, as a dict by constraint name. The names + are the constraint names: FDI uses `value` and `gradient`, PLI uses `value` and + `norm`. It does not test `self.c`. The constructor sets `self.c` to zeros + (lines 64 to 68), so `c` is not `None` after construction. Line 1355 can + assign another value. +- Dropped rows. The `DiscreteInterpolator` builder sets `dropped_rows` as + `source_points - rows` for `value`, `normal`, `tangent` and `inequality_value` + (line 265), and as `2 * source_points - rows` for `gradient` (line 267). Other + families (interface, inequality pairs, regularisation) get `None`. So + `dropped_rows` is in matrix rows. `outside_model_point_count` is in points. The + base builder (used by `SurfeRBFInterpolator`, which does not override it) sets + `dropped_rows = outside` and `row_count = point count`, so for Surfe both are in + points. +- Builder exclusions. `GeologicalFeatureBuilder.excluded_rows` + (`dict[str, dict[str, int]]`, S1, line 88) uses constraint names as keys: + `_orthogonal` (line 343) and `_equality` (lines 387 + and 393). The reasons are `invalid_reference_value`, + `invalid_reference_direction` and `evaluation_error`. The S1 restricted-support + case uses the feature name `restricted`, so the keys are `restricted_equality` + and `restricted_orthogonal`. These keys are not family names. No report reads + them. +- Python version. `pyproject.toml` and `packages/loop_common/pyproject.toml` set + `requires-python >=3.9`. `_structured_grid_3d.py` has no `from __future__ + import annotations`. The syntax `np.ndarray | None` fails there on Python 3.9. +- Documents. `git grep` finds no mention of `ConstraintDiagnosticsReport`, + `summarise_residuals`, `UNKNOWN_EXPLANATION` or the misfit methods in + `docs/source` or `API.md` (checked 2026-11-05). + +#### S5 decisions + +1. **Resize contract.** For every structured grid, each assignment of `origin`, + `maximum`, `nsteps` or `step_vector` keeps the extent that the caller asked + for, or encloses it by less than one cell. `nsteps` counts nodes in the + geometry classes. A repeated assignment of the same value changes nothing. + Assignments of different values can add up. The sequence A, B, A can enlarge + the extent by less than one cell of B plus less than one cell of A. A test + measures this (`test_step_vector_a_b_a_extent_drift`). +2. **3D `nsteps` setter.** Keep origin and extent. The new step is + `extent / (nsteps_new - 1)`. The extent is `nsteps_cells * step_vector`, taken + before the change. A value below 2 on any axis raises `ValueError`. A value + that is not a whole number raises `ValueError`. The setter does not round, + because rounding hides a caller error. A float array of whole numbers is + accepted and stored as `int`. A scalar is broadcast, as before. +3. **`set_nelements` (Q4, answered 2026-11-05).** S5 accepts the exact extent + after `set_nelements`: `maximum` is the same before and after the call. The old + code changed it. `set_nelements` does `nsteps = np.maximum(np.ceil(...).astype(int), 2)`. + The clamp keeps behaviour for valid callers (a value of 2 or more is unchanged) + and avoids a new `ValueError` when the requested element count is very small. + The old code gave one node (zero cells) for such a request, which no caller + can use. The setter accepts only `nsteps >= 2`, because one node has no + extent to keep. +4. **3D `step_vector` setter.** Keep origin and the requested extent. The cell + count is `_cells_for_length(extent, step_vector=new_step)` and the node count + is that plus one. The stored step is a copy of the value that the caller gave. + The grid can end up to one cell larger than the extent, never smaller. The loop + in `set_interpolation_geometry` ends as before, because the cell count grows + when the step gets smaller. +5. **2D grid (Q1, answered 2026-11-05).** `nsteps` becomes a property. Its setter + coerces the value with `np.ceil(np.asarray(v)).astype(int)`, as `__init__` does + today. `nsteps_cells`, `n_cell_x` and `n_cell_y` become read-only properties + derived from `nsteps`. `maximum` becomes a read-only property with the legacy + value `origin + nsteps * step_vector`. This value counts nodes as cells. S5 does + not change it, because FR-028 asks to keep it. The four existing assertions + of `maximum` must pass with no change. After an `nsteps` assignment, the three + counts and `maximum` follow the new `nsteps` (before, they stayed stale). +6. **Rectilinear grid.** The `origin`, `nsteps` and `step_vector` setters raise + `NotImplementedError` after construction. Resize needs new node arrays, and + the grid cannot guess them. `maximum` already has no setter. +7. **Cell centres (FR-015).** No change to `BoundingBox.cell_centres` unless a new + test fails (OP12). S5 adds tests for rotated boxes and for the cell + association of the returned grid. If a test fails, the fix is the closed form + `origin + (arange(nsteps - 1) + 0.5) * node_step_vector` for each axis, in + place of `linspace`. The task records the failure first. +8. **Misfit methods (Q2, answered 2026-11-05).** The old public methods keep their + names and their public result shape. Two new methods return the corrected + result as a 1-D array: `evaluate_gradient_cosines` and `evaluate_value_residuals` + (both names are free; `git grep -n -w` on 2026-11-05 gives no match). The old + methods and the new methods call the same private code (one code path for each + quantity). That code works on copies, converts the stored local vectors with + `interpolator.local_to_world_vectors`, converts the stored local positions with + `bounding_box.reproject`, selects the columns `[:, :3]` (position) and + `[:, 3:6]` (vector), and evaluates through the feature. The in-place change, the + `grad[:, :3:6]` column error and the local-versus-world comparison are removed + from the old methods. +9. **Old result shape.** `evaluate_gradient_misfit` returns one row for each + non-empty family, as before: shape `(1, N)` for one family, and `(2, N)` for two + families of equal length. For two families of different length it returns one + row `(1, N1 + N2)`; before, this case raised `ValueError` (OP16). With no rows it + returns `np.array([])` of shape `(0,)`, as before. `evaluate_value_misfit` + returns shape `(N,)`, as before. +10. **Gradient value changes meaning.** The old value was the dot product of an + unnormalised model gradient and a column slice. The new value is the cosine + between the world data direction and the model gradient. This is a change of + behaviour of a public method. S5 records it in `COMPAT.md` as a migration + notice, and in `ROADMAP.md` as a decision entry (the constitution row for the + public API asks for it). The new name `evaluate_gradient_cosines` gives the + same numbers in one 1-D array. +11. **Stale model (Q3, answered 2026-11-05).** The misfit methods and + `verification_report` must not change state and must not rebuild. First they + ask `_stale_reason()`. It returns a text if `builder._up_to_date` is `False`, if + any fault builder of the builder is stale, or if `interpolator.up_to_date` is + `False`. It returns `None` otherwise (and when `builder` is `None`, only the + interpolator counts). If the text is not `None`, the method does not evaluate. + It writes one `warnings.warn(text, UserWarning, stacklevel=2)`. The text names + the stale object and says that the model must be built first. The new methods + then return `np.full(n, np.nan)`. The old methods return their usual shape + filled with NaN. Reasons: (a) the call changes no state (SC-009); (b) no + exception stops a loop over features; (c) NaN is the marker for an unusable row + in this design; (d) a number from old coefficients looks valid and is not; + (e) a `UserWarning` shows by default, a log line does not. When the model is up + to date, the methods evaluate with `self.evaluate_value` and + `self.evaluate_gradient`. Their call to `builder.up_to_date()` then does + nothing, because `_stale_reason()` has checked the same state (faults + included). The number `n` is the row count of the stored array. Reading a shape + changes nothing. +12. **Gradient misfit is a cosine.** The pure function `direction_cosines` returns + the cosine between the data direction and the model gradient for each row. A + row with a zero, not finite or missing vector gives `NaN`. +13. **Value misfit (Q5, answered 2026-11-05).** The public `evaluate_value_misfit` + keeps its old meaning: `|value - field|` divided by the field range + `self.max() - self.min()`. It divides only when the range is finite and greater + than zero. Otherwise it returns the unnormalised absolute difference. The + physical residual uses a private helper `_value_residuals_fresh()` that gives the + unnormalised `|value - field|` in field units. `evaluate_value_residuals`, + `evaluate_value_misfit` and the physical report entry `value` all call it. So + the unit `"field"` is true. +14. **Report.** `ConstraintDiagnosticsReport` gets three new fields with defaults, + appended after the existing ones. `to_dict` and `summary` show them only when + they are not empty. So a report that was built before S5 gives the same + output. A new method `GeologicalFeature.verification_report()` fills them. It + builds a new report. It does not store it in `latest_diagnostics_report`. Its + private call is `_build_constraint_diagnostics_report`. +15. **Separate residuals.** `physical_residuals` holds quantities in data units + (value: field units; direction: degrees). `assembled_residuals` holds the + `A x - b` rows of each constraint, in the assembler's normalisation. The two + dicts keep the plain names as keys. Assembled keys are constraint names (FDI: + `value`, `gradient`; PLI: `value`, `norm`). Physical keys are `value`, `gradient` + and `normal`. The two sets can share a key. S5 does not prefix the keys, + because the assembled keys must stay equal to the names that + `calculate_residual_for_constraints` uses. Each entry has a `kind` field + (`"physical"` or `"assembled"`). The two dicts are separate objects and never + merge in `to_dict`. The tests check the `kind` field and the object identity, + not a claim that the keys differ. +16. **Unsolved model.** `verification_report` fills the two residual blocks only if + `_stale_reason()` is `None` and `interpolator.c` is not `None` and the + interpolator has `calculate_residual_for_constraints`. If not, both blocks stay + empty (`{}`), one warning is written (stale case), and the exclusions are still + reported, because they read state only. `calculate_residual_for_constraints` + itself does not change in S5. +17. **Exclusions.** Each exclusion is an `ExclusionRecord` with a family, a reason, + a count and a plain explanation. Sources, in this order: + (a) The builder's `excluded_rows`. A builder record keeps its own constraint key + as `family` (for example `restricted_orthogonal`). It is not a data family. S5 + does not subtract it from any family count. + (b) The outside-model count of each family, reason `outside_model`, in points. + (c) For each family whose `dropped_rows` is not `None`: the rows that + `outside_model` does not explain, reason `unattributed`, in rows. The unit of + `dropped_rows` is the matrix row, so the outside count is first multiplied by + `rows_per_point` (2 for `gradient` when the interpolator is a + `DiscreteInterpolator`, 1 otherwise). The record exists when the difference is + positive. A family with `dropped_rows` equal to `None` (interface, + regularisation, other) gets no `unattributed` record, because it has no basis. + For Surfe, `dropped_rows = outside` and so the difference is zero. + A reason with no registered explanation keeps its count and gets + `UNKNOWN_EXPLANATION`. No row is dropped from the count. +18. **Out of S5.** Residual reports for fold cells and P2 cells (the cells that the + spec table links to FR-024 with SC-003, SC-007 and SC-008) are not in S5. S2 + covers the fold and P2 data paths. S6 covers the backend matrix. The summary + code does not depend on the engine, so S5 adds no cell for them. +19. **Python 3.9.** `_structured_grid_3d.py` uses `Optional[np.ndarray]` from + `typing`. It gets no `from __future__ import annotations` (the file stays + unchanged in style). `_diagnostics.py` already has the future import and uses + `float | None` in annotations. +20. **Exports.** `loop_interpolation.__init__` exports `ResidualSummary`, + `ExclusionRecord`, `direction_cosines` and `build_exclusion_records`. + `summarise_residuals`, `UNKNOWN_EXPLANATION` and `EXCLUSION_EXPLANATIONS` are + not exported. The feature code and the tests import them from + `loop_interpolation._diagnostics`. + +#### S5 contracts + +| Boundary | Input | Output | Owner | +|---|---|---|---| +| `StructuredGrid3DGeometry.nsteps` and `.step_vector` setters, `set_nelements` | node count, step, or element count | origin and extent kept (extent enclosed by less than one cell for the step setter) | `_structured_grid_3d.py` | +| `StructuredGrid2DGeometry.nsteps`, `maximum` and cell counts | `origin`, `nsteps`, `step_vector` | `nsteps` is an int array; counts derived, never stale; `maximum` legacy value `origin + nsteps * step_vector` | `_structured_grid_2d.py` | +| `RectilinearGrid` resize setters | any value | `NotImplementedError`, no state change | `_3d_rectilinear_grid.py` | +| `BoundingBox.cell_centres` | the box | local centres, one for each cell of `regular_grid(local=False)` (unchanged) | `_bounding_box.py` | +| `GeologicalFeature.evaluate_gradient_misfit` and `evaluate_value_misfit` | stored local constraint arrays | old public shape and names; world-correct; stored arrays, weights, coefficients and both up-to-date flags unchanged; stale model gives a warning and NaN | `_geological_feature.py` | +| `GeologicalFeature.evaluate_gradient_cosines` and `evaluate_value_residuals` | the same | 1-D arrays; same rules | `_geological_feature.py` | +| `direction_cosines` | two `(N, d)` arrays in one basis | `(N,)` cosines | `_diagnostics.py` | +| `GeologicalFeature.verification_report` | the feature | a new `ConstraintDiagnosticsReport` with residuals and exclusions; no state change | `_geological_feature.py` | + +#### S5 functions and signatures + +`StructuredGrid3DGeometry` (`_structured_grid_3d.py`; add `from typing import +Optional` at the top): + +```python +def _cells_for_length( + self, length: np.ndarray, step_vector: Optional[np.ndarray] = None +) -> np.ndarray: + """Number of cells per axis that cover ``length``. + + ``step_vector`` defaults to the current step. The rule of S1 is unchanged: + ``ceil(length / step - 1e-9)``; 0 cells become 2; a negative count raises + ``ValueError("Cannot resize the grid.")``. + """ +``` + +`set_nelements`: the line `nsteps = np.ceil(...).astype(int)` becomes + +```python +nsteps = np.maximum( + np.ceil((self.maximum - self.origin) / step_vector).astype(int), 2 +) +``` + +`nsteps` setter: + +```python +@nsteps.setter +def nsteps(self, nsteps): + nsteps = np.broadcast_to(np.asarray(nsteps), self._nsteps.shape) + if not np.all(np.isfinite(nsteps)) or np.any(nsteps != np.round(nsteps)): + raise ValueError("Cannot resize the grid. nsteps must be whole numbers.") + nsteps = nsteps.astype(int) + if np.any(nsteps < 2): + raise ValueError("Cannot resize the grid. nsteps must be at least 2.") + extent = self.nsteps_cells * self._step_vector + self._step_vector = extent / (nsteps - 1) + self._nsteps = np.array(nsteps) + self._on_geometry_change() +``` + +`step_vector` setter: + +```python +@step_vector.setter +def step_vector(self, step_vector): + step_vector = np.array(step_vector, dtype=float) + extent = self.nsteps_cells * self._step_vector + cells = self._cells_for_length(extent, step_vector=step_vector) + self._nsteps = cells + 1 + self._step_vector = step_vector + self._on_geometry_change() +``` + +`StructuredGrid2DGeometry` (`_structured_grid_2d.py`): in `__init__`, replace the +assignments of `self.nsteps`, `self.maximum`, `self.nsteps_cells`, `self.n_cell_x` +and `self.n_cell_y` (lines 23, 26, 28 to 30) with `self.nsteps = nsteps` (the setter +coerces) and keep the other lines. Add: + +```python +@property +def nsteps(self) -> np.ndarray: + return self._nsteps + +@nsteps.setter +def nsteps(self, nsteps): + self._nsteps = np.ceil(np.asarray(nsteps)).astype(int) + +@property +def nsteps_cells(self) -> np.ndarray: + return self.nsteps - 1 + +@property +def n_cell_x(self): + return self.nsteps[0] - 1 + +@property +def n_cell_y(self): + return self.nsteps[1] - 1 + +@property +def maximum(self) -> np.ndarray: + # Legacy value (FR-028): it counts nodes as cells, so it is one step + # beyond the last node. ``nodes`` uses ``nsteps_cells``. Do not change it. + return self.origin + self.nsteps * self.step_vector +``` + +The properties `n_cell_x` and `n_cell_y` return the same NumPy integer type as +the old attributes. The task greps for readers and writers of the five names +before the edit (`git grep -n "n_cell_x\|n_cell_y\|nsteps_cells\|\.maximum ="`). A +writer outside `__init__` must move to the derived form. The `__init__` order +matters: `nsteps` is set before `origin` is read. + +`RectilinearGrid` (`_3d_rectilinear_grid.py`): override the three setters. + +```python +_RESIZE_MESSAGE = ( + "A rectilinear grid has fixed node arrays and cannot be resized. " + "Build a new RectilinearGrid." +) + +@property +def origin(self): return self._origin +@origin.setter +def origin(self, origin): raise NotImplementedError(_RESIZE_MESSAGE) +# the same pattern for nsteps and step_vector +``` + +`_diagnostics.py` (new code; existing classes and fields keep their order): + +```python +@dataclass(frozen=True) +class ResidualSummary: + name: str # constraint or data family + kind: str # "physical" or "assembled" + unit: str # "field", "degree" or "assembled" + count: int # rows with a finite residual + nan_count: int # rows with no finite residual (not in the statistics) + rms: float | None # None when count == 0 + max_abs: float | None + mean_abs: float | None + +@dataclass(frozen=True) +class ExclusionRecord: + family: str # a data family, or the constraint key of a builder record + reason: str + count: int # points for outside_model, rows for unattributed + explanation: str + +EXCLUSION_EXPLANATIONS: dict[str, str] = { + "invalid_reference_value": "The reference value is NaN or not finite at the sampled point.", + "invalid_reference_direction": "The reference direction has zero length or is not finite.", + "evaluation_error": "The reference feature raised an error when it was evaluated.", + "outside_model": "The point lies outside the model bounding box. The count is in points.", + "unattributed": "The assembly has fewer rows than expected and no reason was recorded. The count is in matrix rows.", +} +UNKNOWN_EXPLANATION = "No explanation is registered for this reason." + +def summarise_residuals(name: str, kind: str, unit: str, residuals: np.ndarray) -> ResidualSummary: ... +def direction_cosines(model_vectors: np.ndarray, data_vectors: np.ndarray) -> np.ndarray: ... +def build_exclusion_records( + excluded_rows: dict[str, dict[str, int]], + families: dict[str, ConstraintFamilyDiagnostics], + rows_per_point: dict[str, int] | None = None, +) -> tuple[ExclusionRecord, ...]: ... +``` + +`summarise_residuals` flattens the array, counts non-finite values in `nan_count` +and computes `rms`, `max_abs`, `mean_abs` from the finite values. It does not +change its input. `direction_cosines` raises `ValueError` if the two shapes +differ. For each row it divides the dot product by the product of the two norms. +A row whose norm is zero or not finite gives `NaN`. Both inputs stay unchanged. + +`build_exclusion_records` returns the records sorted by `(family, reason)`. The +rules are: + +- One record for each reason in `excluded_rows`. The family is the key of + `excluded_rows`. The count is not subtracted from any family. +- One `outside_model` record for each family with `outside_model_point_count > 0`. +- One `unattributed` record for each family with `dropped_rows` not `None` and + `dropped_rows - outside_model_point_count * k > 0`. Here `k` is + `rows_per_point.get(family, 1)` (`rows_per_point` `None` means an empty dict). + The count of the record is the difference. +- A count of zero makes no record. + +The report class gets three fields after `outside_model_points`: + +```python +physical_residuals: dict[str, ResidualSummary] = field(default_factory=dict) +assembled_residuals: dict[str, ResidualSummary] = field(default_factory=dict) +exclusions: tuple[ExclusionRecord, ...] = () +``` + +`to_dict` adds the keys `"physical_residuals"`, `"assembled_residuals"` (a dict of +the fields of each summary) and `"exclusions"` (a list of dicts) only when the +field is not empty. `summary()` adds one line for each non-empty block, after the +existing lines. The properties `total_rows` and `active_families` do not change. + +`GeologicalFeature` (`_geological_feature.py`; the module has the future +import): + +```python +def evaluate_gradient_misfit(self) -> np.ndarray: + """Cosine between each stored direction and the model gradient (1 = equal). + + One row for each non-empty family (gradient, then normal): shape (1, N) for + one family, (2, N) for two of equal length, (1, N1 + N2) for two of + different length. Shape (0,) with no rows. The model gradient and the data + direction are compared in world coordinates. The method changes no stored + array, weight, coefficient or flag. A stale model gives a UserWarning and + NaN. The meaning of the value changed in S5 (see COMPAT.md). + """ + +def evaluate_value_misfit(self) -> np.ndarray: + """|value - field| at each stored value row, relative to the field range. + + The result is divided by the field range only when the range is finite and + greater than zero. Otherwise it is the absolute difference in field units. + """ + +def evaluate_gradient_cosines(self) -> np.ndarray: + """1-D cosines: gradient rows first, then normal rows. Shape (N,).""" + +def evaluate_value_residuals(self) -> np.ndarray: + """1-D |value - field| in field units, not normalised. Shape (N,).""" + +def verification_report(self) -> ConstraintDiagnosticsReport: + """Report with physical residuals, assembled residuals and exclusions.""" + +def _stale_reason(self) -> Optional[str]: ... +def _gradient_cosines_fresh(self) -> list: ... # one array for each non-empty family +def _value_residuals_fresh(self) -> np.ndarray: ... +def _physical_residual_summaries(self) -> dict[str, ResidualSummary]: ... +``` + +`_stale_reason` checks `self.builder._up_to_date`, then the same attribute of +every builder in `self.builder.faults`, then `self.interpolator.up_to_date`. It +returns a text for the first stale object, or `None`. + +Method body of `_gradient_cosines_fresh`, in order: (1) for each of the arrays +`get_gradient_constraints()` and `get_norm_constraints()` that has rows, `rows = +np.array(array, dtype=float, copy=True)`; (2) `positions = rows[:, :3]`, and +`reproject` them if `bounding_box` is not `None`; (3) `data = rows[:, 3:6]`, and +apply `self.interpolator.local_to_world_vectors(data)` when `bounding_box` is +not `None`; (4) `model = self.evaluate_gradient(positions)`; (5) append +`direction_cosines(model, data)`. The method returns the list (one array for each +non-empty family). `evaluate_gradient_cosines` calls `_stale_reason`; on a stale +model it warns and returns `np.full(n, np.nan)`; otherwise it returns +`np.concatenate` of the list (`np.zeros(0)` if the list is empty). The old +`evaluate_gradient_misfit` calls the same code and then builds the shape of +decision 9. The call `self.builder.up_to_date()` is removed from all of them. + +`_value_residuals_fresh`: copy `get_value_constraints()`, reproject the positions, +return `np.abs(rows[:, 3] - self.evaluate_value(positions))`. +`evaluate_value_misfit` returns the stale NaN array, or divides that result by +`self.max() - self.min()` when the range is finite and positive. + +`verification_report` body: `base = self.interpolator._build_constraint_diagnostics_report()` +(a new object; it does not set `latest_diagnostics_report`). If +`_stale_reason()` is not `None`, write the warning and skip the two residual +blocks. If `interpolator.c` is `None`, or the interpolator has no +`calculate_residual_for_constraints`, skip the assembled block. Otherwise +`assembled` = for each `(name, residual)` in +`calculate_residual_for_constraints().items()`, `summarise_residuals(name, +"assembled", "assembled", residual)`. Then `physical = +self._physical_residual_summaries()`; `excluded = getattr(self.builder, +"excluded_rows", {})`; `rows_per_point = {"gradient": 2}` if the interpolator is +a `DiscreteInterpolator`, else `None`. Return `dataclasses.replace(base, +physical_residuals=physical, assembled_residuals=assembled, +exclusions=build_exclusion_records(excluded, base.families, rows_per_point))`. +The physical summaries are `value` (from `_value_residuals_fresh`, unit +`"field"`), and `gradient` and `normal` (`degrees(arccos(clip(cosine, -1, 1)))` +of the list from `_gradient_cosines_fresh`, unit `"degree"`). So there is one +code path for each quantity. + +The names `ResidualSummary`, `ExclusionRecord`, `EXCLUSION_EXPLANATIONS`, +`UNKNOWN_EXPLANATION`, `summarise_residuals`, `direction_cosines`, +`build_exclusion_records`, `verification_report`, `evaluate_gradient_cosines`, +`evaluate_value_residuals`, `_stale_reason`, `_gradient_cosines_fresh`, +`_value_residuals_fresh` and `_physical_residual_summaries` are free (`git grep +-n -w` on 2026-11-05; `_build_constraint_diagnostics_report` exists and is used). + +#### S5 tests + +`packages/loop_common/tests/test_resize_paths.py` (new). Grids: `G1` origin +`(0,0,0)`, `nsteps (11,6,4)`, step `(1,2,3)`; `G2` origin `(100,-20,5)`, +`nsteps (5,5,5)`, step `(0.5,0.5,0.5)`; `G3` origin `(-1e4,2e3,-30)`, `nsteps +(21,3,8)`, step `(10,100,7.5)`. The 2D grids are `H1` origin `(0,0)`, `nsteps +(5,5)`, step `(1,1)`, and `H2` origin `(10,-5)`, `nsteps (8,4)`, step `(0.5,2)`. + +| Test function | Cells | Checks | +|---|---|---| +| `test_reapplying_same_value_10_times_changes_nothing_3d` | 3 grids x 4 setters (origin, maximum, nsteps, step_vector) = 12 | 10 repeats of each assignment leave `origin`, `maximum`, `step_vector` and `nsteps` equal to the start: counts exactly, lengths within `1e-12 * max(1, L)` (SC-011) | +| `test_nsteps_setter_keeps_extent` | 3 | `nsteps` doubled then halved: `maximum` equal to the start within `1e-12 * L`; node count equals the request | +| `test_step_vector_setter_encloses_extent` | 3 | step x 1.5: new `maximum` is at least the old one and less than one new step above it | +| `test_step_vector_a_b_a_extent_drift` | 3 | step A, then B = 1.5 A, then A again: the final `maximum` minus the start is at least 0 and less than `A + B` on each axis; record the measured value | +| `test_nsteps_below_two_is_rejected_without_change` | 1 | `ValueError`; state unchanged | +| `test_nsteps_not_whole_is_rejected_without_change` | 1 | `nsteps = (5.5, 5, 5)` and `NaN` raise `ValueError`; state unchanged; `(5.0, 5.0, 5.0)` is accepted and stored as `int` | +| `test_set_nelements_keeps_extent` | 3 | after `set_nelements(200)` `maximum` equals the start within `1e-12 * L`; the returned value equals `n_elements`; `nsteps` equals `max(ceil(extent / h), 2)` with `h = (volume / 200) ** (1 / 3)` computed in the test | +| `test_set_nelements_small_request_is_clamped` | 1 | `G1.set_nelements(1)` raises nothing; every `nsteps` is at least 2; the extent is kept | +| `test_2d_counts_are_derived` | 2 grids x 3 checks = 6 | after `nsteps` assignment, `nsteps_cells`, `n_cell_x`, `n_cell_y` and `n_elements` agree with the new `nsteps`; `nsteps` is an int array when the input is a float list; `maximum == origin + nsteps * step_vector` (legacy value, one step beyond `nodes.max(axis=0)`) | +| `test_rectilinear_resize_is_rejected` | 3 setters | `NotImplementedError`; nodes, origin and counts unchanged | + +`packages/loop_common/tests/test_cell_centre_sampling.py` (new). Boxes: `unit` +and `offset_anisotropic` from `test_bounding_box_structured_grid.py`. Frames: +rotations 0, 30 and 90 degrees about z; local origin `(0,0,0)` and `(7,-3,11)`. + +| Test function | Cells | Checks | +|---|---|---| +| `test_each_cell_has_exactly_one_centre` | 12 | `grid.cell_index_at(box.project(world_centres))` is `arange(n_cells)` with `grid = box.structured_grid(local_coordinates=True)`: no `-1`, no repeat, `np.bincount` all `1`. This is the main evidence for FR-015 and SC-011 | +| `test_local_coordinates_grid_associates_own_centres` | 12 | every `box.project(world_centres)` falls in a cell of `box.structured_grid(local_coordinates=True)`: `cell_index_at(...) != -1` for all points. The world centres are `reproject(box.cell_centres(order="F"))` | +| `test_centres_are_cell_midpoints_in_world` | 12 | `reproject(box.cell_centres(order="F"))` equals the mean of the 8 corner nodes of each cell of `box.structured_grid()`, within `Tp`. Not separate evidence for SC-011 (it restates the construction of `cell_centres`) | +| `test_centres_match_exported_cells` | 12 | world centres equal `grid.cell_centres` within `Tp`. Not separate evidence for SC-011 | +| `test_centres_inside_box` | 12 | each world centre is inside `[origin, maximum]` within `Tp`. Not separate evidence for SC-011 | + +The rotation is set with `box.set_local_transform(local_origin=..., local_rotation=...)`. +The task reads the S1 test `test_rigid_transform_contract.py` for the call and for +`Tp`. Rotation 0 with origin 0 is the control. Expected result by reading: all +pass on the current tree ("coverage, no defect", OP12). Only the first two tests +count as evidence for SC-011. + +`packages/loop_interpolation/tests/test_residual_exclusion_report.py` (new). It +tests the pure functions and the report class, with no model. + +| Test function | Cells | Checks | +|---|---|---| +| `test_direction_cosines_pure_function` | 6 | equal, opposite and orthogonal vectors; a zero row and a NaN row give `NaN`; shape mismatch raises `ValueError`; both inputs unchanged; scale invariance | +| `test_report_without_verification_is_unchanged` | 1 | `to_dict().keys()` equals the pre-S5 key set and the constructor still takes the old fields in order (rollback check) | +| `test_builder_records_keep_their_own_key` | 1 | `excluded_rows = {"restricted_orthogonal": {"invalid_reference_direction": 3}}` gives a record with `family == "restricted_orthogonal"` and count 3; no data-family count changes; no `unattributed` record appears | +| `test_unknown_reason_keeps_its_count` | 1 | a reason `"x"` in `excluded_rows` gives a record with `UNKNOWN_EXPLANATION` and the same count | +| `test_unattributed_rows_are_reported` | 1 | a `value` family with `dropped_rows` 5 and 2 outside points gives `unattributed` 3 | +| `test_gradient_outside_points_give_no_false_unattributed` | 1 | a `gradient` family with 10 source points, 2 outside points and `dropped_rows` 4 (`2 * 10 - 16` rows) and `rows_per_point={"gradient": 2}` gives no `unattributed` record; with `dropped_rows` 6 it gives 2 | +| `test_dropped_rows_none_gives_no_unattributed` | 1 | an `interface` family with `dropped_rows=None` and no outside points gives no record | +| `test_surfe_style_dropped_equals_outside` | 1 | `rows_per_point=None`, `dropped_rows` equal to the outside count: no `unattributed` record | + +`tests/unit/modelling/test_diagnostics_frames.py` (new). It uses +`world_foliation_model` (S4) and solves with the pattern of +`test_reference_constraint_frames.py`. + +| Test function | Cells | Checks | +|---|---|---| +| `test_misfit_leaves_state_unchanged` | 4 anchors x 3 rotations x 2 engines (FDI, PLI) = 24 | after a solve, snapshot (deep copies) of every `interpolator.data[...]` array, `c`, `interpolator.up_to_date`, `builder._up_to_date`, the weights of `constraints`, and `id(latest_diagnostics_report)`; call the two old misfit methods; every snapshot is equal (`np.array_equal`) (SC-009). It uses no new name | +| `test_new_misfit_methods_leave_state_unchanged` | 24 | the same snapshot; call `evaluate_gradient_cosines`, `evaluate_value_residuals` and `verification_report()` | +| `test_gradient_cosine_matches_analytic_direction_exact_field` | 24 | `monkeypatch` replaces `feature.evaluate_gradient` with a stub that returns the analytic world direction of the R1 foliation at the given points. R1 gradient and normal rows: `abs(1 - cos) <= 1e-10` for both the old method (one row) and `evaluate_gradient_cosines`. This tests the basis and column logic, not the solver (SC-009) | +| `test_gradient_cosine_matches_analytic_direction_solved_model` | 24 | the real solved model: `abs(1 - cos) <= tol_solved`. The value `tol_solved` is measured on the solved models. The task records the maximum and sets the bound with a margin of 2. It is not `1e-10` | +| `test_value_residuals_are_absolute_difference` | 4 anchors x 3 rotations = 12 | `evaluate_value_residuals()` equals `abs(value - evaluate_value(world_points))` at the 27 value rows; no NaN; unit field | +| `test_value_misfit_is_normalised_by_field_range` | 12 | `evaluate_value_misfit()` equals `evaluate_value_residuals() / (feature.max() - feature.min())` within `1e-12` | +| `test_value_misfit_with_zero_range_does_not_divide` | 2 | the model has value rows with one value (a near-constant field). The solver gives noise near `1e-12`, so the test forces the range with `monkeypatch`: `feature.max` and `feature.min` return the same number (cell 1) or `NaN` (cell 2). No `RuntimeWarning` (`warnings.simplefilter("error")`); the result equals the unnormalised absolute difference | +| `test_misfit_with_no_rows_is_empty` | 2 | no gradient rows: `shape == (0,)`; no value rows: `shape == (0,)` | +| `test_old_misfit_shapes_are_kept` | 4 | one family: `(1, N)`; two families of equal length: `(2, N)`; two of different length: `(1, N1 + N2)` (OP16); the values equal `evaluate_gradient_cosines()` | +| `test_stale_misfit_is_not_rebuilt` | 2 engines x 2 kinds (interpolator stale, builder stale) = 4 | uses the old methods only. Snapshot `interpolator.up_to_date`, `builder._up_to_date`, `c` and the data arrays. After a call, every snapshot is equal. On the current tree the call rebuilds the model, so `interpolator.up_to_date` changes from `False` to `True`: the test fails there (FR-031) | +| `test_stale_misfit_warns_once_and_returns_nan` | 4 | old and new methods each write exactly one `UserWarning` that names the stale object; the new methods return `np.full(n, np.nan)`; the old methods return the old shape filled with NaN | +| `test_stale_verification_report_does_not_rebuild` | 4 | `verification_report()` on a stale model: the state snapshot is equal; the two residual blocks are empty; the exclusions are present; one warning | +| `test_report_blocks_are_separate` | 2 engines = 2 | the two dicts are different objects; every physical entry has `kind == "physical"` and every assembled entry has `kind == "assembled"`; the physical `value` entry has unit `"field"` and its `rms` equals the RMS of `evaluate_value_residuals()` within `1e-12`; the assembled entries equal `summarise_residuals` of `calculate_residual_for_constraints()`; `to_dict` has two separate blocks | +| `test_unsolved_model_gives_no_residual_blocks` | 2 | a model with `interpolator.c = None`: the two blocks are empty and the exclusions are present; no exception | +| `test_exclusions_are_counted_and_explained` | 2 engines = 2 | the restricted-support reference case of S1 (`test_reference_constraint_frames.py`): the builder records appear under their constraint keys (`restricted_equality`, `restricted_orthogonal`) with the analytic count; the explanation is not empty and not `UNKNOWN_EXPLANATION` | +| `test_gradient_outside_points_give_no_false_unattributed_model` | 2 engines = 2 | a feature with gradient data and 3 points outside the model box: the report has an `outside_model` record of 3 and no `unattributed` record for `gradient` | + +Pre-fix evidence (FR-031). The state test with the old methods, the exact-field +cosine test and the stale-state test fail on the current tree. In the first, a +different array is stored in `data["gradient"]` after the call. In the second, +the cosine uses one column and local vectors. In the third, the call rebuilds the +model. The value-residual test records "not reproduced" if it passes on the +current tree (the value path does not change the stored positions). The resize +tests for `nsteps`, `step_vector`, `set_nelements` and the 2D counts fail on the +current tree (record the observed extent drift and the stale counts). The 2D +`maximum` is unchanged, so no 2D `maximum` test fails. The cell-centre tests are +coverage (OP12). The tests of the new names fail because the names do not exist. +The pre-fix part uses no new name. + +Existing tests that must pass with no change: +`packages/loop_common/tests/test_2d_discrete_support.py` and +`tests/unit/interpolator/test_2d_discrete_support.py` (the four `maximum` +assertions; S5 does not edit these two files), `test_rectilinear_grid.py`, +`test_bounding_box_structured_grid.py`, `tests/unit/modelling/test_scalar_field_grid.py`, +the constraint diagnostics tests (`git grep -ln "get_constraint_diagnostics_report" +tests packages`), and the S1 to S4 test files. + +Tests that change by design (Q4). The 3D golden test +`packages/loop_common/tests/test_structured_support_reference.py` and its data +file `packages/loop_common/tests/data/structured_support_reference.npz` change, +because the setters now keep the extent. The earlier sentence that this golden +test must pass with no change is withdrawn. The 2D golden test +`test_structured_grid_2d_reference.py` and `data/structured_grid_2d_reference.npz` +should not change (S5 keeps the 2D `maximum`). The task runs it, and if a key +differs, it records the key. The golden update follows this order: (1) run the +test and list each failing key; (2) review each key against the extent rule of +decision 1; (3) record each key, with the old and the new value, in the section +"S5 golden review (C-004)" of `verification.md` (SC-015: 0 tolerance changes); (4) +only then run `python packages/loop_common/tests/test_structured_support_reference.py +--update` (and the 2D script with `--update` only if a key differs); (5) run the +test again. + +Numeric-test survey (Q4). The tests that build a model run `set_nelements` and +the `step_vector` loop. The task runs `pytest tests/unit/modelling` and `pytest +packages/loop_interpolation/tests` on the fixed code, and `packages/loop_common/tests`. +For each test that fails or changes a number, it records the test, the old +value, the new value and the cause in "S5 golden review (C-004)". The directory +`packages/loop_interpolation/tests/golden` has its own option `--update-golden` +and is the parity oracle for the Rust path. If a file there changes, the task +records it and does not regenerate it before the review (OP17). + +#### S5 backend and capability cells + +| Cell | Result in S5 | +|---|---| +| FDI and PLI misfit, residual and exclusion report | Mandatory (the cells above). | +| P2 | Not in S5. The summary code does not depend on the engine. | +| Residual reports for fold and P2 cells (FR-024 with SC-003, SC-007, SC-008) | Not in S5. S2 covers the fold and P2 data paths, and S6 covers the matrix. | +| Native and Surfe | Not in S5. The misfit methods read the Python interpolator state only. Backend matrix: S6. | +| Resize of structured, 2D and rectilinear grids | Mandatory (resize tests above). Unstructured meshes cannot resize (OP4, S3). | + +#### S5 risks + +- The old misfit methods keep their public shape. The gradient value changes + meaning (decision 10). A caller outside the repository (the QGIS plugin, user + scripts) can depend on the old number. `COMPAT.md` and `ROADMAP.md` record it. + No caller in this repository uses the methods. +- `evaluate_gradient_misfit` used to call `builder.up_to_date()`, so it rebuilt a + stale model. Now it warns and returns NaN. A caller that relied on the rebuild + gets NaN and one warning, and must build the model first (OP13, closed). +- The extent change from `set_nelements` is a new risk. The old `nsteps` setter + kept `nsteps * step`. The new one keeps the extent. A model build runs + `set_nelements` and the `step_vector` loop, so grid steps, node coordinates and + solved values can change slightly in tests and in user models. The golden review + and the numeric-test survey record each change. The risk grows if an + interpolation golden file changes (OP17). +- Repeated `step_vector` assignments can add up to more than one cell of + enlargement (decision 1). A test measures the bound. +- The 2D `maximum` keeps the legacy value, so it still counts nodes as cells. A + reader that needs the true last node must use `nodes`. The value now follows + later `nsteps` assignments (it was stale before). +- `RectilinearGrid` now raises on resize. A caller that assigned `origin` or + `step_vector` got a wrong grid before. Now it gets a clear error. +- `evaluate_gradient` and `evaluate_value` of a feature call `builder.up_to_date()`. + The misfit methods call them only after `_stale_reason()` returned `None`. The + stale tests check this for FDI and PLI. +- The first S5 planner run read 14 code files, by targeted line ranges. The limit + of the spec-planner skill is 10 code files. The fix run read more. The design + uses only those reads. The risk is that an unread part of those files has other + readers. Each task greps for readers and writers before it edits. S5 changes 10 + code files at most (see "S5 files"). No task group changes more than 10. +- `build_exclusion_records` uses `rows_per_point` of 2 for the gradient family of + a `DiscreteInterpolator`. If an interpolator adds a different number of rows for + a gradient point, the `unattributed` count is wrong for that engine. The + gradient end-to-end test covers FDI and PLI only. + +#### S5 constitution check + +| Project rule | Result | How S5 agrees | +|---|---|---| +| Work stays within the existing packages | Pass | Three `loop_common` files, two `loop_interpolation` files (`_diagnostics.py` and `__init__.py`) and one `LoopStructural` file change. No new module. | +| Gates `pytest` and `ruff check LoopStructural packages` | Pass | The S5 row of "Stages and rollback" names both. | +| numpy docstrings, type hints, py39 | Pass | New functions and dataclasses have numpy docstrings and hints. `_diagnostics.py` has `from __future__ import annotations`. `_structured_grid_3d.py` uses `Optional`. | +| The public `GeologicalModel` API stays compatible; a break needs a ROADMAP.md decision and a major-version note | Pass | No `GeologicalModel` signature changes. The report only gets new defaulted fields. The misfit methods keep their names and public shape. The gradient value changes meaning: this is recorded in `COMPAT.md` (migration notice) and in a `ROADMAP.md` decision entry (Q2). The 2D `maximum` does not change. | +| Stages can be tested and reversed independently | Pass | S5 reverts to the S4 merge. | +| `ROADMAP.md` owns decisions; update it when one changes | Pass | S5 adds one decision entry for the misfit methods: "Misfit diagnostics of GeologicalFeature (spec 005, S5)". No other decision changes. | +| Feature documents stay under `specs/NNN-name/` | Pass | Only `plan.md`, `tasks.md` and `verification.md` change there. | + +#### S5 files + +At most 10 code files (.py) change in S5: 6 source files and 4 new test files. No +task group changes more than 10 code files. Data files and documents are not +counted. + +| File | Change | +|---|---| +| `packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py` | `_cells_for_length` gets `step_vector`; new `nsteps` and `step_vector` setters; `set_nelements` clamp; `from typing import Optional` | +| `packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py` | `nsteps` property with int coercion; `nsteps_cells`, `n_cell_x`, `n_cell_y` and `maximum` become properties (`maximum` keeps the legacy value, with a comment) | +| `packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py` | `origin`, `nsteps`, `step_vector` setters raise `NotImplementedError` | +| `packages/loop_interpolation/src/loop_interpolation/_diagnostics.py` | `ResidualSummary`, `ExclusionRecord`, explanation table, three functions, three report fields, `to_dict` and `summary` | +| `packages/loop_interpolation/src/loop_interpolation/__init__.py` | Export four new names (`ResidualSummary`, `ExclusionRecord`, `direction_cosines`, `build_exclusion_records`). `summarise_residuals` and `UNKNOWN_EXPLANATION` are imported from `loop_interpolation._diagnostics` | +| `LoopStructural/modelling/features/_geological_feature.py` | Rewrite both misfit methods on shared private code; add `evaluate_gradient_cosines`, `evaluate_value_residuals`, `verification_report`, `_stale_reason` and the private helpers | +| `packages/loop_common/tests/test_resize_paths.py` | New | +| `packages/loop_common/tests/test_cell_centre_sampling.py` | New | +| `packages/loop_interpolation/tests/test_residual_exclusion_report.py` | New | +| `tests/unit/modelling/test_diagnostics_frames.py` | New | +| `packages/loop_common/tests/test_structured_support_reference.py` | Not edited. It must pass after the regeneration of its data file | +| `packages/loop_common/tests/data/structured_support_reference.npz` | Regenerated with `--update` after review (C-004, SC-015) | +| `packages/loop_common/tests/data/structured_grid_2d_reference.npz` | Regenerated only if the 2D reference test changes (not expected) | +| `packages/loop_common/tests/test_2d_discrete_support.py` and `tests/unit/interpolator/test_2d_discrete_support.py` | Not edited. They must pass with no change (Q1) | +| `COMPAT.md` | S5 notice: misfit gradient value (migration notice), stale model gives NaN and a warning, extent kept by the setters and `set_nelements`, rectilinear resize error, new report fields | +| `ROADMAP.md` | Decision "Misfit diagnostics of GeologicalFeature (spec 005, S5)" under "Decisions" | +| `specs/005-coordinate-frame-correctness/verification.md` | S5 pre-fix, post-fix, golden review (C-004), numeric-test survey, backend matrix, rollback and run-time sections | + +Not changed (the survey in "Later stages" listed them): `_bounding_box.py` +(`cell_centres`; conditional, OP12), `_structured_grid.py`, +`supports/_3d_structured_grid.py`, `_base_geological_feature.py`, +`_model_exporter.py`, `_discrete_interpolator.py` and `API.md`. `docs/source` and +`API.md` do not mention the changed names (`git grep`, 2026-11-05), so they need +no edit. + +The `COMPAT.md` notice says: `GeologicalFeature.evaluate_gradient_misfit()` +changed the stored constraint arrays in place, compared a local vector with a +world vector, and read one column. It now keeps its name and its public shape. It +changes nothing. It returns the cosine between the world data direction and the +model gradient for each row. The old value was a dot product with an unnormalised +model gradient. The new value differs. Use `evaluate_gradient_cosines()` for a +1-D array. The method no longer rebuilds a stale model: it writes a `UserWarning` +and returns NaN. `evaluate_value_misfit()` keeps its normalisation by the field +range. It no longer divides by zero. Assigning `nsteps` or `step_vector` on a 3D +grid, and `set_nelements`, keep the extent. A rectilinear grid refuses to resize. +`ConstraintDiagnosticsReport` has three new optional fields. With an identity +frame and no resize, the value misfit is the same as before. + +The `ROADMAP.md` entry gives the decision date `2026-11-05` (spec 005, S5, Q2). +It says that the misfit methods keep their names and public shape, that the +gradient value is now a cosine in world coordinates, that a stale model gives a +warning and NaN, and that the new names `evaluate_gradient_cosines` and +`evaluate_value_residuals` give the 1-D result. It lists the compatibility notice +in `COMPAT.md`. + +#### S5 task order (for the spec-tasks skill) + +No two tasks in one wave write `verification.md`. Two tasks never write the same +file in one wave. Each task group has at most 10 code files. + +1. The two `loop_common` test files (resize, cell centres), in two tasks that can + run in parallel. They run on the current tree. +2. The pre-fix evidence for step 1 in `verification.md` (one task, own wave). +3. The 3D setter fix with the `set_nelements` clamp, the 2D properties and the + rectilinear guard (three tasks, separate files, parallel). The 2D task greps + the readers and writers of the five 2D names first (`git grep -n + "n_cell_x\|n_cell_y\|nsteps_cells\|\.maximum ="`). +4. The numeric-test survey and golden review (one task, after step 3). It runs + `tests/unit/modelling`, `packages/loop_interpolation/tests` and + `packages/loop_common/tests`, records each change in "S5 golden review + (C-004)", and regenerates the golden `.npz` file after review. All later tasks + that build models depend on this step. It is the only `verification.md` writer + of its wave. +5. The post-fix evidence for the resize and cell-centre tests (one task, own wave, + after step 4). +6. `_diagnostics.py` (dataclasses, functions, report fields), with the pure-function + tests in `test_residual_exclusion_report.py`. Then the `__init__` export. This + step does not depend on steps 3 to 5 and can run in parallel with them, but its + evidence goes to `verification.md` in a later wave. +7. The feature tests in `test_diagnostics_frames.py` (state, cosine, value, empty, + shapes, stale, residual, exclusion), written before the misfit change. This task + owns that file alone. The pre-fix evidence goes to `verification.md` in its own + wave. It depends on step 4, because the solved-model tolerance and the values + depend on the built support. +8. The misfit rewrite and `verification_report` (one file; one task). It depends on + steps 6 and 7. +9. `COMPAT.md`, `ROADMAP.md`, the rollback check, and last the gates (the + orchestrator runs the full suites when memory is tight). `verification.md` is + written in the last wave only by this step. + +S5 rollback check. Revert to the S4 merge. Then run these checks. + +- (a) The field names and order of `ConstraintFamilyDiagnostics`, + `RegionCoverageDiagnostics` and the first five fields of + `ConstraintDiagnosticsReport` are equal before and after (`dataclasses.fields`). +- (b) For a report built with no verification, `to_dict()` is equal before and + after. +- (c) `from loop_interpolation import *` on the S4 code gives a subset of the S5 + names. +- (d) The signatures of `GeologicalFeature.evaluate_gradient_misfit` and + `evaluate_value_misfit` are equal. The result shape for one family is equal. +- (e) With an unchanged grid, the 3D `origin`, `maximum`, `nsteps` and + `step_vector` of a structured support are equal on both trees. +- (f) The four 2D `maximum` assertions pass on both trees. + +The result of each check goes to `verification.md`. + ### Stages and rollback | Stage | Files and behaviour | Acceptance check | Rollback target and compatibility check | @@ -1694,7 +2617,7 @@ are equal. | S2 | Fold geometry, directional regularisation, quadrature, masks, sign alignment for FDI-fold, PLI discrete fold, P2 and P2-fold; constant-norm variants (FR-008; R3 for P2; R4) ("Stage S2 design") | New S2 tests (SC-002 for constant norm; SC-007, SC-008 and SC-013 for every mandatory fold and P2 cell); the existing fold package tests pass with no change; `pytest`; `ruff check LoopStructural packages`; package tests | Revert to the S1 merge. Check: the S1 tests still pass. No saved format changed. | | S3 | Persistence format version, transform and coordinate-space metadata ("Stage S3 design"); no second projection at load; copies keep the frame; legacy load requires a transform or identity declaration; transform change policy and restored-point cache invalidation (FR-016 to FR-020); ROADMAP.md decision for FR-019; `COMPAT.md` migration note | SC-006, SC-012, SC-016, SC-017 | Revert to the S2 merge. Check: old-format R1 files load through their documented path; a new-format file fails with 1 actionable error and 0 state changes on the old reader (SC-017). | | S4 | Display, export of observation geometry, fault-centre geometry and intrusion classification use stored world locations (FR-022; "Stage S4 design") | World-location comparison within `Tp` (U3.4, U3.5) | Revert to the S3 merge. Check: viewer import paths unchanged. | -| S5 | Cell-centre sampling, remaining resize paths, non-mutating diagnostics, residual and exclusion report (FR-014 remainder, FR-015, FR-023, FR-024) | SC-009, SC-011 | Revert to the S4 merge. Check: diagnostics report fields unchanged for callers. | +| S5 | Remaining resize paths (3D `nsteps` and `step_vector`, `set_nelements`, 2D derived counts, rectilinear guard), golden review, cell-centre tests, non-mutating misfit diagnostics, residual and exclusion report (FR-014 remainder, FR-015, FR-023, FR-024; "Stage S5 design") | SC-009, SC-011; the new S5 tests; `pytest`; `ruff check LoopStructural packages`; package tests | Revert to the S4 merge. Check: diagnostics report fields unchanged for callers (`dataclasses.fields`, `to_dict` keys, exported names; "S5 rollback check"). | | S6 | Auxiliary point transformations and duplicate entry points (FR-025); displacement validation R2 (FR-026, FR-027); backend matrix incl. native and Surfe (FR-030); golden review (FR-029); 005 completion line in ROADMAP.md | SC-004, SC-010, SC-014, SC-015, SC-018 | Revert to the S5 merge. Check: `LoopStructural.utils` and `loop_common.math` import paths unchanged. | ## Test strategy @@ -1858,14 +2781,12 @@ grep survey on 2026-11-04 and are not final. `_3d_viewer.py`, `intrusion_builder.py`, `_fault_segment.py` and `exporters.py`; the code shows that S4 does not change them. S4 changes `_geological_feature.py` and `intrusion_frame_builder.py`. -- **S5**: `packages/loop_common/src/loop_common/geometry/_bounding_box.py` - (`cell_centres`), `_structured_grid.py`, `_structured_grid_2d.py`, - `packages/loop_common/src/loop_common/supports/_3d_structured_grid.py`, - `_3d_rectilinear_grid.py`, - `packages/loop_interpolation/src/loop_interpolation/_diagnostics.py`, and - `LoopStructural/modelling/features/_geological_feature.py` (found in the S4 - survey: the misfit methods change the stored arrays in place with - `grad /= norm`, and `grad[:, :3:6]` selects the position columns). +- **S5**: planned. See "Stage S5 design" and "S5 files". The survey listed + `_bounding_box.py`, `_structured_grid.py`, `supports/_3d_structured_grid.py` + and `_3d_rectilinear_grid.py`; the code shows that S5 changes only the + rectilinear grid among them (plus `_structured_grid_3d.py` and + `_structured_grid_2d.py`, which the survey did not list). `_diagnostics.py` and + `_geological_feature.py` are confirmed. - **S6**: `packages/loop_common/src/loop_common/math/_transformation.py`, `LoopStructural/utils/_transformation.py`, `packages/loop_interpolation/src/loop_interpolation/_native.py`, @@ -1889,8 +2810,8 @@ grep survey on 2026-11-04 and are not final. | FR-011 | Slip orthogonalisation; extent-direction row | SC-003, SC-013 | None | | FR-012 | Stored `_fault_buffer`, `_force_mesh_geometry`; factory override removed | SC-005; U1.5 test | None | | FR-013 | `local_enclosure`; `set_interpolation_geometry` | SC-005; enclosure unit test | None | -| FR-014 | `_cells_for_length`; setters | Resize-10-times test | Other supports S5, OP1 | -| FR-015 | S5 | S5 | OP1 | +| FR-014 | `_cells_for_length`; S1 setters; S5: 3D `nsteps` and `step_vector` setters, `set_nelements` clamp, 2D derived counts (legacy `maximum` kept), rectilinear guard | `test_resize_paths.py` (resize 10 times; extent kept; A, B, A drift; `set_nelements`); golden review (C-004) | Extent after `set_nelements` changes by design (Q4); OP17 | +| FR-015 | S5: tests for rotated boxes and cell association; `cell_centres` unchanged unless a test fails | `test_cell_centre_sampling.py` (two tests are evidence; three are coverage) | OP12 (defect not seen by reading) | | FR-016 | `frame_payload`; `to_dict` frame keys; format version 2 ("S3 saved format") | `test_frame_persistence.py::test_payload_declares_frame` | None | | FR-017 | `_restore_local_constraints` stores local arrays with no conversion | `test_local_arrays_unchanged_after_two_cycles` | None | | FR-018 | `_inherit_frame` in the three `copy()` methods | `test_frame_copy_and_lifecycle.py::test_copy_keeps_frame` | PLI has no `copy` (documented, not added); OP6 | @@ -1898,8 +2819,8 @@ grep survey on 2026-11-04 and are not final. | FR-020 | `frame_signature`, `_check_frame`, `StaleFrameError` (reject at use) | `test_stale_frame_rejected_without_state_change`, `test_fault_restoration_uses_current_frame` | Policy choice OP4; no restoration cache exists | | FR-021 | `validate_rigid_rotation` in `set_local_transform` and `rotation_xy`; saved metadata checked in `resolve_frame` | `test_rigid_transform_contract.py`; `test_malformed_metadata` | None | | FR-022 | Remove the extra `rescale` in `GeologicalFeature.get_data` and in the intrusion classification (`_network_model_values`); the fault centre is a world point since S1 and its consumers are unchanged | `test_world_location_consumers.py` (display, export, classification, 2D ellipse); S1 `test_fault_ellipsoid_centre_is_world` | OP8 to OP10 | -| FR-023 | S5 | S5 | OP1 | -| FR-024 | `excluded_rows` (S1 part) | Restricted-support exclusion count | Report integration S5, OP1 | +| FR-023 | S5: both misfit methods keep their names and shape, work on copies, compare in world coordinates, never rebuild (warn and NaN when stale); new `evaluate_gradient_cosines` and `evaluate_value_residuals`; `direction_cosines` | `test_diagnostics_frames.py`; `test_residual_exclusion_report.py` | OP16; the gradient value changes meaning (`COMPAT.md`, `ROADMAP.md`) | +| FR-024 | `excluded_rows` (S1); S5: `ResidualSummary`, `ExclusionRecord`, `build_exclusion_records`, report fields, `verification_report` | `test_exclusions_are_counted_and_explained`, `test_physical_and_assembled_residuals_are_separate` | Interpolator-side drops with no recorded reason are reported as `unattributed` (in rows). Residual reports for fold and P2 cells (SC-003, SC-007, SC-008) are out of S5: S2 and S6 | | FR-025 | S6 | S6 | OP1 | | FR-026 | S1: values, gradients, residuals (SC-003, SC-008); displacement S6 | `test_fault_frame_analytic.py` | Displacement OP1 | | FR-027 | S6 | S6 | OP1 | @@ -1916,9 +2837,9 @@ grep survey on 2026-11-04 and are not final. | SC-006 | Format version 2; local arrays restored with no conversion | `test_round_trip_world_values`, `test_local_arrays_unchanged_after_two_cycles` | None | | SC-007 | Reference sampling; S2 fold sampling and P2 | `test_reference_constraint_frames.py`; `test_fold_constraint_frames.py` | None | | SC-008 | Reference sampling; S2 fold rows and P2 | `test_reference_constraint_frames.py` (FDI, PLI, P2); `test_fold_constraint_frames.py` | None | -| SC-009 | S5 | S5 | OP1 | +| SC-009 | S5: misfit methods; `direction_cosines` | `test_misfit_leaves_state_unchanged`, `test_stale_misfit_is_not_rebuilt`, `test_gradient_cosine_matches_analytic_direction_exact_field`, `test_gradient_cosine_matches_analytic_direction_solved_model` (FDI and PLI; P2 not in S5) | None | | SC-010 | S6 | S6 | OP1 | -| SC-011 | `_cells_for_length` (resize part) | Resize test | Cell centres S5, OP1 | +| SC-011 | `_cells_for_length` and the S5 setters (resize part); S5 cell-centre tests | `test_resize_paths.py`, `test_cell_centre_sampling.py` (first two tests) | OP12, OP17 | | SC-012 | Rotation part in S1; metadata and legacy part in S3 | `test_rigid_transform_contract.py`; `test_legacy_*`, `test_malformed_metadata` | None | | SC-013 | Extent-direction row; S2 fold R4 directions | `test_fault_frame_analytic.py`; `test_fold_constraint_frames.py` | None | | SC-014 | Merge gates in every stage | `pytest`, ruff, package tests | None | @@ -1972,6 +2893,23 @@ grep survey on 2026-11-04 and are not final. display data through `get_data` and get world locations. - **Exclusion record**: `GeologicalFeatureBuilder.excluded_rows: dict[str, dict[str, int]]`. S5 connects it to the diagnostics report. +- **S5 diagnostics (owner: `loop_interpolation`)**: `ResidualSummary`, + `ExclusionRecord`, `direction_cosines(model_vectors, data_vectors)` and + `build_exclusion_records(excluded_rows, families)` are exported. + `ConstraintDiagnosticsReport` has the optional fields `physical_residuals`, + `assembled_residuals` and `exclusions`; `to_dict` shows them only when they + are not empty. `GeologicalFeature.verification_report()` fills them, makes + the private call `_build_constraint_diagnostics_report`, and does not store the + report. `summarise_residuals` and `UNKNOWN_EXPLANATION` are imported from + `loop_interpolation._diagnostics` and are not exported. The feature misfit + methods `evaluate_gradient_misfit` and `evaluate_value_misfit` keep their names + and public shape, work on copies and never rebuild. A stale model gives one + `UserWarning` and NaN. The gradient value is now a cosine in world coordinates. + The new methods `evaluate_gradient_cosines` and `evaluate_value_residuals` give + 1-D arrays. Spec 004 can report its directional rows through + `ResidualSummary(kind="assembled")`. Structured 3D grids keep origin and extent + on every setter and on `set_nelements`. The 2D `maximum` keeps its legacy value. + A rectilinear grid refuses to resize. - **Fault geometry**: an explicit `fault_center` is a world point; the generated centre extent row is `coord=2` with `strike_unit / major_axis`. Spec 003 golden fault models can change only under C-006/C-004 with a diff --git a/specs/README.md b/specs/README.md index b8d31424..797f8682 100644 --- a/specs/README.md +++ b/specs/README.md @@ -9,7 +9,7 @@ not certify that an implementation or a numerical acceptance gate passed. | [002-rbf-interpolator](002-rbf-interpolator/spec.md) | Draft, not reviewed | Continue its existing review and clarification workflow. This cascade did not change it. | | [003-fault-evaluation-performance](003-fault-evaluation-performance/spec.md) | Implementation in progress; 005 contract cascade applied | Its active plan and tasks remain in the existing LoopStructural-native worktree. OP3 and OP4 remain open; completed tasks, including concurrent T043, and measured failures are preserved. | | [004-matrix-free-directional-constraints](004-matrix-free-directional-constraints/spec.md) | Draft; 005 contract cascade applied; task graph checked | Review the updated plan and tasks. OP1 still requires the quadrature measurements before a default is chosen. | -| [005-coordinate-frame-correctness](005-coordinate-frame-correctness/spec.md) | S1 implemented 2026-11-04 on `feat/005-coordinate-frame-correctness` (17 of 17 tasks; evidence in [verification.md](005-coordinate-frame-correctness/verification.md)); S5 and S6 not planned | Review and merge the S1 branch. S2 planned and tasked 2026-11-04 (T018 to T031, waves 8 to 13); run spec-orchestrator for S2. S3 planned and tasked 2026-11-05 ("Stage S3 design" in plan.md; OP4 and OP5 closed; T033 to T050, waves 14 to 22; build after S2 merges). S3 is built on `feat/005-s3-frame-persistence` (pull request #397, stacked on #396; 18 of 18 tasks, evidence in verification.md). S3 merged (#397); a follow-up keeps the legacy load backward compatible (pull request #400). S4 planned and tasked 2026-11-05 ("Stage S4 design" in plan.md; OP8 to OP10 have defaults and block nothing; T051 to T068, waves 23 to 31); run spec-orchestrator for S4. OP1: plan S5 and S6 in later planner runs. OP3: constant-norm accuracy check. OP2 closed 2026-11-04: the R2/R5 matrix stays in the default pytest run. 7 minor review findings are open (run-state.json). | +| [005-coordinate-frame-correctness](005-coordinate-frame-correctness/spec.md) | S1 implemented 2026-11-04 on `feat/005-coordinate-frame-correctness` (17 of 17 tasks; evidence in [verification.md](005-coordinate-frame-correctness/verification.md)); S5 planned 2026-11-05 and revised after review; S6 not planned | Review and merge the S1 branch. S2 planned and tasked 2026-11-04 (T018 to T031, waves 8 to 13); run spec-orchestrator for S2. S3 planned and tasked 2026-11-05 ("Stage S3 design" in plan.md; OP4 and OP5 closed; T033 to T050, waves 14 to 22; build after S2 merges). S3 is built on `feat/005-s3-frame-persistence` (pull request #397, stacked on #396; 18 of 18 tasks, evidence in verification.md). S3 merged (#397); a follow-up keeps the legacy load backward compatible (pull request #400). S4 planned and tasked 2026-11-05 ("Stage S4 design" in plan.md; OP8 to OP10 have defaults and block nothing; T051 to T068, waves 23 to 31); run spec-orchestrator for S4. S5 planned 2026-11-05 and revised the same day after review and five maintainer answers ("Stage S5 design" in plan.md; OP13 to OP15 closed; OP12, OP16 and OP17 have defaults; OP17 blocks the S5 merge only if a parity file changes); run spec-tasks for S5 after S4 merges. OP1: plan S6 in a later planner run. OP3: constant-norm accuracy check. OP2 closed 2026-11-04: the R2/R5 matrix stays in the default pytest run. 7 minor review findings are open (run-state.json). | ## Cascade of 005 From 4a65a6a702672bfad3d8f568e19f43f53834b065 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:14:50 +1030 Subject: [PATCH 02/28] docs(spec): add tasks for 005 stage S5 (T069 to T088) --- .../005-coordinate-frame-correctness/tasks.md | 242 +++++++++++++++++- specs/README.md | 2 +- 2 files changed, 237 insertions(+), 7 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 7b85698e..20498827 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1,4 +1,4 @@ -# Tasks: Coordinate and fault-frame correctness (stages S1 to S4) +# Tasks: Coordinate and fault-frame correctness (stages S1 to S5) **Feature**: `005-coordinate-frame-correctness` | **Plan**: [plan.md](plan.md) @@ -10,15 +10,15 @@ Rules: - [P] = can be done at the same time as the other [P] tasks of the same wave. - Written in ASD-STE100 Simplified Technical English. - This file covers stages S1 (T001 to T017, done), S2 (T018 to T032), S3 - (T033 to T050, done) and S4 (T051 to T068) of plan.md. Stages S5 and S6 wait - for OP1 (a later spec-planner run for each stage, then a new spec-tasks run). + (T033 to T050, done) and S4 (T051 to T068, done) and S5 (T069 to T088) of plan.md. Stage S6 waits + for OP1 (a later spec-planner run, then a new spec-tasks run). --> ## Orchestrator notes - Run this file with the spec-orchestrator skill. It has the rules for the worktree, the sub agents, the checks and the commits. -- Waves: 31 (S1: 1 to 7, done; S2: 8 to 13; S3: 14 to 22, done; S4: 23 to 31). Tiers: [S] = simple model, [M] = standard model. +- Waves: 42 (S1: 1 to 7, done; S2: 8 to 13; S3: 14 to 22, done; S4: 23 to 31, done; S5: 32 to 42). Tiers: [S] = simple model, [M] = standard model. - Do not start a task with "Blocked by". ## Phase 1: Setup @@ -1166,6 +1166,221 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal Add the S4 gate counts to the post-fix section. Add to the status log of `ROADMAP.md` a line dated today: "Spec 005 stage S4 implemented: display, export and intrusion classification use stored world locations; rescale and scale deprecated." Report any library warning with the text "is deprecated" in the pytest summary. Write in ASD-STE100 Simplified Technical English. +## Phase 18: Stage S5 tests (FR-014, FR-015, FR-023, FR-024) + +**Goal**: Tests for the remaining resize paths, the cell-centre sampling and the residual and exclusion report are written before the code that they test. +**Independent test**: `pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py` and `pytest packages/loop_interpolation/tests/test_residual_exclusion_report.py`. + +- [ ] T069 [US4] [M] Test: the resize paths keep the extent and the counts (U4.2; FR-014; SC-011). + - Files: `packages/loop_common/tests/test_resize_paths.py` + - Needs: T068 + - Wave: 32 + - Done when: `pytest packages/loop_common/tests/test_resize_paths.py` fails before T075, T076 and T077 (record the failing ids and the first error of each) and passes after T077. + - Context: New file. Classes: `StructuredGrid3DGeometry` (`loop_common.geometry._structured_grid_3d`), `StructuredGrid2DGeometry` (`_structured_grid_2d`), `RectilinearGrid` (`loop_common.supports._3d_rectilinear_grid`). Read the golden test `test_structured_support_reference.py` for the constructor calls. + 3D grids: `G1` origin `(0,0,0)`, nsteps `(11,6,4)`, step `(1,2,3)`; `G2` origin `(100,-20,5)`, nsteps `(5,5,5)`, step `(0.5,0.5,0.5)`; `G3` origin `(-1e4,2e3,-30)`, nsteps `(21,3,8)`, step `(10,100,7.5)`. 2D: `H1` origin `(0,0)`, nsteps `(5,5)`, step `(1,1)`; `H2` origin `(10,-5)`, nsteps `(8,4)`, step `(0.5,2)`. + `nsteps` counts nodes. Extent `L = nsteps_cells * step_vector`. Length tolerance `1e-12 * max(1, L)`; counts must be exactly equal. + `test_reapplying_same_value_10_times_changes_nothing_3d`: 3 grids x 4 setters (origin, maximum, nsteps, step_vector) = 12 cells; after 10 repeats `origin`, `maximum`, `step_vector`, `nsteps` equal the start (SC-011). + `test_nsteps_setter_keeps_extent`: 3 cells; nsteps doubled then halved; `maximum` equals the start; node count equals the request. `test_step_vector_setter_encloses_extent`: 3 cells; step x 1.5; new `maximum` >= old and < old + one new step. + `test_step_vector_a_b_a_extent_drift`: 3 cells; step A, then B = 1.5 A, then A; final `maximum` minus start is >= 0 and < `A + B` on each axis; print the measured value. `test_nsteps_below_two_is_rejected_without_change`: 1 cell; `ValueError`; state equal. + `test_nsteps_not_whole_is_rejected_without_change`: 1 cell; `(5.5, 5, 5)` and NaN raise `ValueError`, state equal; `(5.0, 5.0, 5.0)` is accepted and stored as `int`. + `test_set_nelements_keeps_extent`: 3 cells; after `set_nelements(200)` `maximum` equals the start; the return value equals `n_elements`; `nsteps == max(ceil(L / h), 2)` with `h = (volume / 200) ** (1 / 3)` computed in the test. `test_set_nelements_small_request_is_clamped`: `G1.set_nelements(1)` raises nothing; every nsteps >= 2; extent kept. + `test_2d_counts_are_derived`: 2 grids x 3 checks = 6 cells; after `nsteps` assignment (a float list), `nsteps_cells`, `n_cell_x`, `n_cell_y` and `n_elements` agree with the new `nsteps`; `nsteps` is an int array; `maximum == origin + nsteps * step_vector` (legacy value). `test_rectilinear_resize_is_rejected`: 3 setters; `NotImplementedError`; nodes, origin and counts unchanged. + Rule: tests must show a wrong answer (principle V). Write in ASD-STE100 Simplified Technical English. + +- [ ] T070 [P] [US4] [M] Test: cell-centre sampling agrees with the grid cells for rotated boxes (U4.3; FR-015; SC-011). + - Files: `packages/loop_common/tests/test_cell_centre_sampling.py` + - Needs: T068 + - Wave: 32 + - Done when: `pytest packages/loop_common/tests/test_cell_centre_sampling.py` passes on the current tree (60 cells, "coverage, no defect", OP12). If a cell fails, record the failing ids in the report and keep the test. + - Context: New file. Boxes: `unit` and `offset_anisotropic` from `test_bounding_box_structured_grid.py` (read it for the construction). Frames: rotation 0, 30 and 90 degrees about z; local origin `(0,0,0)` and `(7,-3,11)` (6 frames; with 2 boxes = 12 cells for each test). + Set the frame with `box.set_local_transform(local_origin=..., local_rotation=...)`. Read `test_rigid_transform_contract.py` for the call and for the tolerance `Tp`. Rotation 0 with origin 0 is the control. + `test_each_cell_has_exactly_one_centre` (12 cells; the main evidence for FR-015 and SC-011): `grid = box.structured_grid(local_coordinates=True)`; `world = box.reproject(box.cell_centres(order="F"))`; `grid.cell_index_at(box.project(world))` equals `arange(n_cells)`: no `-1`, no repeat; `np.bincount` is all 1. + `test_local_coordinates_grid_associates_own_centres` (12 cells): every `box.project(world)` falls in a cell of that grid (`cell_index_at(...) != -1`). `test_centres_are_cell_midpoints_in_world` (12): `world` equals the mean of the 8 corner nodes of each cell of `box.structured_grid()` within `Tp`. + `test_centres_match_exported_cells` (12): world centres equal `grid.cell_centres` within `Tp`. `test_centres_inside_box` (12): each world centre is inside `[origin, maximum]` within `Tp`. + Only the first two tests are evidence for SC-011. Say so in their docstrings. Do not change `_bounding_box.py`. Write in ASD-STE100 Simplified Technical English. + +- [ ] T071 [P] [US4] [M] Test: the pure residual and exclusion functions and the report class (U4.4, U4.5; FR-023, FR-024). + - Files: `packages/loop_interpolation/tests/test_residual_exclusion_report.py` + - Needs: T068 + - Wave: 32 + - Done when: `pytest packages/loop_interpolation/tests/test_residual_exclusion_report.py` fails before T073 with `ImportError` (the names do not exist yet), then passes after T073. + - Context: New file. No model. Import from `loop_interpolation._diagnostics`: `ConstraintDiagnosticsReport`, `ConstraintFamilyDiagnostics`, `RegionCoverageDiagnostics` (read the file for their fields and for how to build a family), and the new `direction_cosines`, `build_exclusion_records`, `summarise_residuals`, `ExclusionRecord`, `ResidualSummary`, `UNKNOWN_EXPLANATION`. + Signatures: `direction_cosines(model_vectors, data_vectors) -> ndarray (N,)`; `build_exclusion_records(excluded_rows: dict[str, dict[str, int]], families: dict[str, ConstraintFamilyDiagnostics], rows_per_point: dict[str, int] | None = None) -> tuple[ExclusionRecord, ...]`. + `test_direction_cosines_pure_function` (6 cells): equal, opposite and orthogonal vectors give 1, -1 and 0; a zero row and a NaN row give NaN; shape mismatch raises `ValueError`; both inputs unchanged; scale invariance. + `test_report_without_verification_is_unchanged` (1): `to_dict().keys()` equals the pre-S5 key set (copy it from the existing tests of the report) and the old fields keep their order (`dataclasses.fields`). + `test_builder_records_keep_their_own_key` (1): `{"restricted_orthogonal": {"invalid_reference_direction": 3}}` gives a record with `family == "restricted_orthogonal"`, count 3, no `unattributed` record, no family count changed. `test_unknown_reason_keeps_its_count` (1): reason `"x"` keeps its count and gets `UNKNOWN_EXPLANATION`. + `test_unattributed_rows_are_reported` (1): `value` family, `dropped_rows` 5, outside points 2: `unattributed` count 3. `test_gradient_outside_points_give_no_false_unattributed` (1): `gradient` family, 10 source points, 2 outside, `dropped_rows` 4, `rows_per_point={"gradient": 2}`: no record; with `dropped_rows` 6: `unattributed` 2. + `test_dropped_rows_none_gives_no_unattributed` (1): `interface` family, `dropped_rows=None`, no outside points: no record. `test_surfe_style_dropped_equals_outside` (1): `rows_per_point=None`, `dropped_rows` equal to the outside count: no record. Write in ASD-STE100 Simplified Technical English. + +- [ ] T072 [US4] [M] Record the S5 pre-fix evidence for the resize and cell-centre tests in `verification.md`. + - Files: `specs/005-coordinate-frame-correctness/verification.md` + - Needs: T069, T070 + - Wave: 33 + - Done when: `verification.md` has the heading `## S5 pre-fix failures` with the failing test ids, the first error of each, and the measured values; no S5 fix commit exists yet. + - Context: Run the two `loop_common` test files on the unfixed code, in one call: `pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py` (PowerShell; the worktree on `PYTHONPATH`; the venv python is `C:\Users\lachl\dev\LoopStructural\.venv\Scripts\python.exe`). + Record: the failing resize tests and the first error of each; the measured extent drift of the `nsteps` setter (example: nodes 11 to 21 with step 1 gives `maximum` 10.48 instead of 10); the extent drift of the `step_vector` setter; the stale 2D counts. Record the cell-centre tests as "coverage, no defect" if they pass, or "reproduced" with ids if not (OP12). + The 2D `maximum` is unchanged, so no 2D `maximum` test must fail. Use the layout of `## S4 pre-fix failures` in the same file. FR-031. Write in ASD-STE100 Simplified Technical English. + +## Phase 19: Stage S5 diagnostics types and resize code + +- [ ] T073 [P] [US4] [M] Add the residual and exclusion types and functions to `_diagnostics.py`. + - Files: `packages/loop_interpolation/src/loop_interpolation/_diagnostics.py` + - Needs: T071 + - Wave: 33 + - Done when: `pytest packages/loop_interpolation/tests/test_residual_exclusion_report.py` passes, and `pytest packages/loop_interpolation/tests -k diagnostics` still passes. + - Context: The file has `from __future__ import annotations`; keep the existing classes and field order. Add `ResidualSummary` (frozen dataclass: `name: str`, `kind: str` ("physical" or "assembled"), `unit: str` ("field", "degree" or "assembled"), `count: int` (finite rows), `nan_count: int`, `rms`, `max_abs`, `mean_abs`: `float | None`, `None` when `count == 0`). + Add `ExclusionRecord` (frozen: `family: str`, `reason: str`, `count: int`, `explanation: str`). Add `EXCLUSION_EXPLANATIONS: dict[str, str]` with the keys `invalid_reference_value`, `invalid_reference_direction`, `evaluation_error`, `outside_model`, `unattributed` (plain one-sentence texts; `outside_model` says the count is in points, `unattributed` says it is in matrix rows) and `UNKNOWN_EXPLANATION = "No explanation is registered for this reason."`. + `summarise_residuals(name, kind, unit, residuals) -> ResidualSummary`: flatten; count non-finite values in `nan_count`; statistics from finite values only; do not change the input. + `direction_cosines(model_vectors, data_vectors) -> np.ndarray`: shape mismatch raises `ValueError`; per row dot / (norm * norm); a row with a zero or not finite norm gives NaN; inputs unchanged. + `build_exclusion_records(excluded_rows, families, rows_per_point=None) -> tuple[ExclusionRecord, ...]`, sorted by `(family, reason)`: one record per reason in `excluded_rows` (family = its key; subtract nothing); one `outside_model` record per family with `outside_model_point_count > 0`; one `unattributed` record per family with `dropped_rows` not `None` and `dropped_rows - outside_model_point_count * k > 0`, with `k = rows_per_point.get(family, 1)`; the count is the difference. Count 0 makes no record. An unknown reason gets `UNKNOWN_EXPLANATION`. + Report class: append after `outside_model_points`: `physical_residuals: dict[str, ResidualSummary] = field(default_factory=dict)`, `assembled_residuals: dict[str, ResidualSummary] = field(default_factory=dict)`, `exclusions: tuple[ExclusionRecord, ...] = ()`. `to_dict` adds `"physical_residuals"`, `"assembled_residuals"` (dicts of the fields of each summary) and `"exclusions"` (list of dicts) only when not empty. `summary()` adds one line per non-empty block after the existing lines. `total_rows` and `active_families` do not change. numpy docstrings and type hints (ruff `D`, `ANN`); py39. + +- [ ] T074 [P] [US4] [S] Export four new names from `loop_interpolation`. + - Files: `packages/loop_interpolation/src/loop_interpolation/__init__.py` + - Needs: T073 + - Wave: 34 + - Done when: `python -c "from loop_interpolation import ResidualSummary, ExclusionRecord, direction_cosines, build_exclusion_records"` prints no error. + - Context: Read the file: it already imports from `._diagnostics` and has an `__all__`. Add `ResidualSummary`, `ExclusionRecord`, `direction_cosines` and `build_exclusion_records` to the same import and to `__all__`, in the style of the neighbours. Do NOT export `summarise_residuals`, `UNKNOWN_EXPLANATION` or `EXCLUSION_EXPLANATIONS`. Change no other line. + +- [ ] T075 [P] [US4] [M] Keep the extent in the 3D `nsteps` and `step_vector` setters and in `set_nelements`. + - Files: `packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py` + - Needs: T069, T072 + - Wave: 34 + - Done when: `pytest packages/loop_common/tests/test_resize_paths.py -k "3d or nsteps or step_vector or set_nelements or a_b_a"` passes. + - Context: Add `from typing import Optional` (no `from __future__ import annotations`; py39). `_cells_for_length(self, length, step_vector: Optional[np.ndarray] = None)`: `step_vector` defaults to the current step; the S1 rule is unchanged (`ceil(length / step - 1e-9)`; 0 cells become 2; a negative count raises `ValueError("Cannot resize the grid.")`). + `set_nelements`: `nsteps = np.maximum(np.ceil((self.maximum - self.origin) / step_vector).astype(int), 2)`. `nsteps` setter: `nsteps = np.broadcast_to(np.asarray(nsteps), self._nsteps.shape)`; if not all finite or any `nsteps != np.round(nsteps)` raise `ValueError("Cannot resize the grid. nsteps must be whole numbers.")`; `nsteps = nsteps.astype(int)`; if any `< 2` raise `ValueError("Cannot resize the grid. nsteps must be at least 2.")`; + `extent = self.nsteps_cells * self._step_vector`; `self._step_vector = extent / (nsteps - 1)`; `self._nsteps = np.array(nsteps)`; `self._on_geometry_change()`. No state changes before a raise. + `step_vector` setter: `step_vector = np.array(step_vector, dtype=float)`; `extent = self.nsteps_cells * self._step_vector`; `cells = self._cells_for_length(extent, step_vector=step_vector)`; `self._nsteps = cells + 1`; `self._step_vector = step_vector`; `self._on_geometry_change()`. + Before the edit run `git grep -n "\.nsteps = \|\.step_vector = \|set_nelements"` and read each writer; report one that depends on the old product rule. Keep the `origin` and `maximum` setters of S1. Principle: change no public name. + +- [ ] T076 [P] [US4] [M] Derive the 2D counts from `nsteps` and keep the legacy `maximum`. + - Files: `packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py` + - Needs: T069, T072 + - Wave: 34 + - Done when: `pytest packages/loop_common/tests/test_resize_paths.py -k 2d_counts` passes, and `pytest packages/loop_common/tests/test_2d_discrete_support.py` and `pytest tests/unit/interpolator/test_2d_discrete_support.py` still pass with no change (the four `maximum` assertions; separate calls). + - Context: First run `git grep -n "n_cell_x\|n_cell_y\|nsteps_cells\|\.maximum ="` and read each writer outside `__init__`; a writer must move to the derived form (report it). In `__init__` (lines 23 to 30) replace the assignments of `self.nsteps`, `self.maximum`, `self.nsteps_cells`, `self.n_cell_x` and `self.n_cell_y` with `self.nsteps = nsteps` (the setter coerces); keep the other lines. `nsteps` is set before `origin` is read. + Add: `nsteps` property returning `self._nsteps`; setter `self._nsteps = np.ceil(np.asarray(nsteps)).astype(int)`; read-only properties `nsteps_cells` (`self.nsteps - 1`), `n_cell_x` (`self.nsteps[0] - 1`), `n_cell_y` (`self.nsteps[1] - 1`); read-only `maximum` returning `self.origin + self.nsteps * self.step_vector`. + Keep a comment on `maximum`: legacy value (FR-028); it counts nodes as cells, so it is one step beyond the last node; `nodes` uses `nsteps_cells`; do not change it. Keep the NumPy integer type of the old attributes. Use numpy docstrings and type hints. Rule: the four existing `maximum` assertions pass with no change (decision Q1). + +- [ ] T077 [P] [US4] [S] Make the resize setters of `RectilinearGrid` raise. + - Files: `packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py` + - Needs: T069, T072 + - Wave: 34 + - Done when: `pytest packages/loop_common/tests/test_resize_paths.py -k rectilinear` passes, and `pytest packages/loop_common/tests/test_rectilinear_grid.py` still passes. + - Context: `RectilinearGrid` inherits the `origin`, `nsteps` and `step_vector` setters of the 3D geometry; a call changes the counts but not the node arrays. Add a module constant `_RESIZE_MESSAGE = "A rectilinear grid has fixed node arrays and cannot be resized. Build a new RectilinearGrid."`. + Override the three properties: each getter returns the private attribute (`self._origin`, `self._nsteps`, `self._step_vector`); each setter does `raise NotImplementedError(_RESIZE_MESSAGE)` and changes no state. Pattern: `@property def origin(self): return self._origin` then `@origin.setter def origin(self, origin): raise NotImplementedError(_RESIZE_MESSAGE)`. The constructors write the private attributes, not the setters, so construction is unchanged. `maximum` has no setter: leave it. Use numpy docstrings and type hints. + +## Phase 20: Stage S5 golden review and feature tests + +- [ ] T078 [US4] [M] Survey the numeric tests, review the golden changes and regenerate the 3D golden data (C-004). + - Files: `specs/005-coordinate-frame-correctness/verification.md`, `packages/loop_common/tests/data/structured_support_reference.npz`, `packages/loop_common/tests/data/structured_grid_2d_reference.npz` + - Needs: T075, T076, T077 + - Wave: 35 + - Done when: `pytest packages/loop_common/tests/test_structured_support_reference.py packages/loop_common/tests/test_structured_grid_2d_reference.py` passes, and `verification.md` has the heading `## S5 golden review (C-004)` with one entry for each changed key or test (old value, new value, cause). + - Context: Order (C-004, SC-015: 0 tolerance changes). (1) Run the 3D golden test and list each failing key. (2) Check each key against the rule: each setter keeps the extent or encloses it by less than one cell. (3) Write each key with its old and new value in the section. (4) Only then run `python packages/loop_common/tests/test_structured_support_reference.py --update`. (5) Run the test again. + Run the 2D golden test. It must not change (the 2D `maximum` stays). Run `--update` for it only if a key differs, and record the key. Numeric survey, in separate calls: `pytest tests/unit/modelling`; `pytest packages/loop_interpolation/tests`; `pytest packages/loop_common/tests` (PowerShell; worktree on `PYTHONPATH`; `packages/loopstructural_visualisation/src` also on `PYTHONPATH`; `pyevtk` is only in a scratch folder: add it to `PYTHONPATH` or report the skip). + For each test that fails or changes a number, record the test, the old value, the new value and the cause. Do not edit a test file. `packages/loop_interpolation/tests/golden` is the parity oracle (OP17): record each change there and do NOT regenerate it. If the cause is the intended extent change, name the file for the maintainer; if the cause is not clear, report it. Write in ASD-STE100 Simplified Technical English. + +- [ ] T079 [P] [US4] [M] Record the S5 post-fix evidence for the resize and cell-centre tests. + - Files: `specs/005-coordinate-frame-correctness/verification.md` + - Needs: T078 + - Wave: 36 + - Done when: `verification.md` has the heading `## S5 resize and cell-centre results` and `pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py` passes. + - Context: Run the two files in one call. Record the commands, the counts (resize: 12 + 3 + 3 + 3 + 1 + 1 + 3 + 1 + 6 + 3 = 36 cells; cell centres: 5 tests x 12 = 60 cells), the times, and the measured extent drift of the A, B, A test for each grid (the bound is `A + B`). Compare with `## S5 pre-fix failures`: for each id that failed, write that it now passes. + Write that `test_each_cell_has_exactly_one_centre` and `test_local_coordinates_grid_associates_own_centres` are the evidence for FR-015 and SC-011, and the other three are coverage. Record OP12: "not reproduced" if all 60 cells passed before the fix, and no change to `_bounding_box.py`. Record the `ruff check LoopStructural packages` result for the three `loop_common` source files. Write in ASD-STE100 Simplified Technical English. + +- [ ] T080 [US4] [M] Test: misfit methods keep state, compare in world coordinates, and keep their shapes (U4.4; FR-023; SC-009). + - Files: `tests/unit/modelling/test_diagnostics_frames.py` + - Needs: T073, T078 + - Wave: 36 + - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py -k "leaves_state_unchanged or exact_field or value_ or empty or old_misfit_shapes"` fails before T083 (record the failing ids and the first error of each) and passes after T084. + - Context: New file. Use `world_foliation_model`, `ANCHORS`, `ROTATIONS_DEG` from `tests.fixtures.coordinate_frames`; solve with the pattern of `tests/unit/modelling/test_reference_constraint_frames.py` (read both). Engines: FDI and PLI. Cell grid: 4 anchors x 3 rotations x 2 engines = 24 (value tests: 12 cells). + `test_misfit_leaves_state_unchanged` (24): after a solve, take deep copies of every `interpolator.data[...]` array, `c`, `interpolator.up_to_date`, `builder._up_to_date`, the weights of `constraints` and `id(latest_diagnostics_report)`; call the two OLD methods `evaluate_gradient_misfit`, `evaluate_value_misfit`; every snapshot is equal (`np.array_equal`). It uses no new name. `test_new_misfit_methods_leave_state_unchanged` (24): the same, with `evaluate_gradient_cosines`, `evaluate_value_residuals` and `verification_report()`. + `test_gradient_cosine_matches_analytic_direction_exact_field` (24): `monkeypatch` replaces `feature.evaluate_gradient` with a stub that returns the analytic world direction of the R1 foliation at the given points; `abs(1 - cos) <= 1e-10` for the old method (one row) and for `evaluate_gradient_cosines`. `..._solved_model` (24): the real solved model; `tol_solved` is measured: record the maximum and set the bound with a margin of 2 (it is not `1e-10`). + `test_value_residuals_are_absolute_difference` (12): equals `abs(value - evaluate_value(world_points))` at the 27 value rows; no NaN. `test_value_misfit_is_normalised_by_field_range` (12): equals `evaluate_value_residuals() / (feature.max() - feature.min())` within `1e-12`. `test_value_misfit_with_zero_range_does_not_divide` (2): `monkeypatch` makes `feature.max` and `feature.min` return the same number (cell 1) or NaN (cell 2); `warnings.simplefilter("error")`; the result equals the unnormalised absolute difference. + `test_misfit_with_no_rows_is_empty` (2): no gradient rows gives shape `(0,)`; no value rows gives shape `(0,)`. `test_old_misfit_shapes_are_kept` (4): one family `(1, N)`; two families of equal length `(2, N)`; two of different length `(1, N1 + N2)` (OP16, default); values equal `evaluate_gradient_cosines()`. + +- [ ] T081 [US4] [M] Test: a stale model is not rebuilt, and the report keeps residuals and exclusions apart (U4.4, U4.5; FR-023, FR-024; SC-009). + - Files: `tests/unit/modelling/test_diagnostics_frames.py` + - Needs: T080 + - Wave: 37 + - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py -k "stale or report_blocks or unsolved or exclusions or outside_points"` fails before T083 and T084 (record the failing ids; `test_stale_misfit_is_not_rebuilt` must fail on the current tree because the call rebuilds the model) and passes after T084. + - Context: Append to the file of T080 (same fixtures; engines FDI and PLI). `test_stale_misfit_is_not_rebuilt` (2 engines x 2 kinds, interpolator stale or builder stale = 4): uses the OLD methods only. Snapshot `interpolator.up_to_date`, `builder._up_to_date`, `c` and the data arrays; after the call every snapshot is equal (on the current tree `up_to_date` changes from `False` to `True`: the test fails; FR-031). + `test_stale_misfit_warns_once_and_returns_nan` (4): the old and the new methods each write exactly one `UserWarning` that names the stale object; the new methods return `np.full(n, np.nan)`; the old methods return the old shape filled with NaN. `test_stale_verification_report_does_not_rebuild` (4): snapshot equal; both residual blocks empty; exclusions present; one warning. + `test_report_blocks_are_separate` (2): the two dicts are different objects; physical entries have `kind == "physical"`, assembled entries `kind == "assembled"`; the physical `value` entry has unit `"field"` and its `rms` equals the RMS of `evaluate_value_residuals()` within `1e-12`; the assembled entries equal `summarise_residuals(name, "assembled", "assembled", r)` for `calculate_residual_for_constraints()`; `to_dict` has two separate blocks. Import `summarise_residuals` and `UNKNOWN_EXPLANATION` from `loop_interpolation._diagnostics`. + `test_unsolved_model_gives_no_residual_blocks` (2): `interpolator.c = None`; both blocks `{}`; exclusions present; no exception. `test_exclusions_are_counted_and_explained` (2): the restricted-support reference case of `test_reference_constraint_frames.py`: builder records under `restricted_equality` and `restricted_orthogonal` with the analytic count; the explanation is not empty and not `UNKNOWN_EXPLANATION`. + `test_gradient_outside_points_give_no_false_unattributed_model` (2): gradient data with 3 points outside the box: an `outside_model` record of 3 and no `unattributed` record for `gradient`. + +- [ ] T082 [US4] [M] Record the S5 pre-fix evidence for the diagnostics tests in `verification.md`. + - Files: `specs/005-coordinate-frame-correctness/verification.md` + - Needs: T081 + - Wave: 38 + - Done when: `verification.md` has the heading `## S5 diagnostics pre-fix failures` with the failing ids, the first error of each, and the measured values; no misfit fix commit exists yet. + - Context: Run `pytest tests/unit/modelling/test_diagnostics_frames.py` on the code before T083 (root suite only; one call). Record: the state test with the old methods (a different array in `data["gradient"]` after the call); the exact-field cosine test (one column, local vectors); the stale-state test (the call rebuilds the model: `up_to_date` goes from `False` to `True`); the tests of the new names (they fail because the names do not exist). + Record `test_value_residuals_are_absolute_difference` as "not reproduced" if it passes (the value path does not change the stored positions). Add one behavioural measure with the production path: for `ANCHORS[1]` and rotation 30, the largest absolute change of `interpolator.data["gradient"]` after one call of `evaluate_gradient_misfit`. Use the layout of `## S4 pre-fix failures`. FR-031. Write in ASD-STE100 Simplified Technical English. + +## Phase 21: Stage S5 misfit code + +- [ ] T083 [US4] [M] Rewrite the misfit methods on shared non-mutating code and add the new methods. + - Files: `LoopStructural/modelling/features/_geological_feature.py` + - Needs: T073, T082 + - Wave: 39 + - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py -k "not report_blocks and not unsolved and not exclusions and not outside_points and not verification_report and not new_misfit_methods"` passes. + - Context: The module has `from __future__ import annotations`. Add (import `direction_cosines` from `loop_interpolation._diagnostics`; `warnings`): `_stale_reason(self) -> Optional[str]`: text for the first stale object among `self.builder._up_to_date` is False, each builder in `self.builder.faults`, `self.interpolator.up_to_date` is False; `None` otherwise (builder `None`: only the interpolator). The text names the object and says to build the model first. + `_gradient_cosines_fresh(self) -> list`: for each of `get_gradient_constraints()` and `get_norm_constraints()` with rows: `rows = np.array(array, dtype=float, copy=True)`; `positions = rows[:, :3]`, reproject with `self.bounding_box.reproject` when `bounding_box` is not `None`; `data = rows[:, 3:6]`, convert with `self.interpolator.local_to_world_vectors(data)` when `bounding_box` is not `None`; `model = self.evaluate_gradient(positions)`; append `direction_cosines(model, data)`. + `_value_residuals_fresh(self) -> np.ndarray`: copy `get_value_constraints()`, reproject positions, return `np.abs(rows[:, 3] - self.evaluate_value(positions))`. + `evaluate_gradient_cosines() -> ndarray (N,)`: on a stale model `warnings.warn(text, UserWarning, stacklevel=2)` and return `np.full(n, np.nan)` (`n` = row count of the stored arrays); else `np.concatenate` of the list (`np.zeros(0)` if empty). `evaluate_value_residuals() -> ndarray (N,)`: same rule with `_value_residuals_fresh`. + `evaluate_gradient_misfit()` (old name, new body): same code; one row per non-empty family: `(1, N)` for one family, `(2, N)` for two of equal length, `(1, N1 + N2)` for two of different length, `(0,)` with no rows; stale gives that shape filled with NaN. `evaluate_value_misfit()`: stale gives NaN `(N,)`; else the residuals divided by `self.max() - self.min()` only when the range is finite and > 0, else unnormalised. + Remove every `self.builder.up_to_date()` call and the in-place `grad /= norm` from these methods. Never write to a stored array, weight, coefficient or flag. numpy docstrings (state that the gradient value is a cosine in world coordinates; see COMPAT.md) and type hints. Read the whole old methods first (about lines 240 to 300). + +- [ ] T084 [US4] [M] Add `GeologicalFeature.verification_report` with physical and assembled residuals and exclusions. + - Files: `LoopStructural/modelling/features/_geological_feature.py` + - Needs: T083 + - Wave: 40 + - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py` passes (all cells), and `pytest tests/unit/modelling/test_scalar_field_grid.py` still passes. + - Context: Add `_physical_residual_summaries(self) -> dict[str, ResidualSummary]`: `value` from `_value_residuals_fresh()` (kind `"physical"`, unit `"field"`); `gradient` and `normal` from `_gradient_cosines_fresh()`: `np.degrees(np.arccos(np.clip(cosine, -1, 1)))`, unit `"degree"`; use `summarise_residuals(name, "physical", unit, values)`. Add the public `verification_report(self) -> ConstraintDiagnosticsReport`. + Body: `base = self.interpolator._build_constraint_diagnostics_report()` (a new object; do NOT set `latest_diagnostics_report`). `physical = {}`, `assembled = {}`. If `_stale_reason()` is not `None`: `warnings.warn(text, UserWarning, stacklevel=2)` and skip both blocks. Else `physical = self._physical_residual_summaries()`; and only if `interpolator.c` is not `None` and the interpolator has `calculate_residual_for_constraints`: for `(name, residual)` in `calculate_residual_for_constraints().items()` set `assembled[name] = summarise_residuals(name, "assembled", "assembled", residual)`. + Exclusions are always built (they read state only): `excluded = getattr(self.builder, "excluded_rows", {})`; `rows_per_point = {"gradient": 2}` if the interpolator is a `DiscreteInterpolator`, else `None`. Return `dataclasses.replace(base, physical_residuals=physical, assembled_residuals=assembled, exclusions=build_exclusion_records(excluded, base.families, rows_per_point))`. + An unsolved model (`c` is `None`) leaves the assembled block empty with no warning. Do not change `calculate_residual_for_constraints`. numpy docstring and type hints. Import `ConstraintDiagnosticsReport`, `ResidualSummary`, `summarise_residuals`, `build_exclusion_records` from `loop_interpolation._diagnostics`. + +## Phase 22: Stage S5 finish + +- [ ] T085 [P] [Finish] [M] Record the S5 notices in `COMPAT.md`. + - Files: `COMPAT.md` + - Needs: T084 + - Wave: 41 + - Done when: `grep -c "evaluate_gradient_cosines" COMPAT.md` prints at least 1 and `grep -c "RectilinearGrid" COMPAT.md` prints at least 1. + - Context: Read `COMPAT.md` first, the S1 to S4 notices, and copy their layout. Add migration notices. (1) `GeologicalFeature.evaluate_gradient_misfit()` changed the stored constraint arrays in place, compared a local vector with a world vector, and read one column. It keeps its name and its public shape and changes nothing. It returns the cosine between the world data direction and the model gradient for each row. The old value was a dot product with an unnormalised model gradient, so the new value differs. Use `evaluate_gradient_cosines()` for a 1-D array. + Two families of different length now give one row `(1, N1 + N2)`; before, this raised `ValueError` (OP16). (2) Both misfit methods no longer rebuild a stale model: they write one `UserWarning` and return NaN; build the model first. (3) `evaluate_value_misfit()` keeps its normalisation by the field range and no longer divides by zero (zero or not finite range gives the absolute difference). `evaluate_value_residuals()` gives the unnormalised 1-D result. + (4) Assigning `nsteps` or `step_vector` on a 3D structured grid, and `set_nelements`, keep the extent (a new step, not a product rule); `nsteps` must be whole and at least 2. `set_nelements` keeps `maximum`. Grid steps and solved values can change slightly. (5) A `RectilinearGrid` raises `NotImplementedError` on assignment of `origin`, `nsteps` or `step_vector`. (6) The 2D grid derives `nsteps_cells`, `n_cell_x`, `n_cell_y` from `nsteps`; `maximum` keeps its legacy value. (7) `ConstraintDiagnosticsReport` has three new optional fields (`physical_residuals`, `assembled_residuals`, `exclusions`); with an identity frame and no resize the value misfit is the same as before. + Do not change other text. Write in ASD-STE100 Simplified Technical English. + +- [ ] T086 [P] [Finish] [S] Record the S5 misfit decision in `ROADMAP.md`. + - Files: `ROADMAP.md` + - Needs: T084 + - Wave: 41 + - Done when: `grep -c "Misfit diagnostics of GeologicalFeature (spec 005, S5)" ROADMAP.md` prints 1. + - Context: Add a new `###` subsection as the last one of `## Decisions`, directly before `## Stage sequence`. Heading: `### Misfit diagnostics of GeologicalFeature (spec 005, S5)`. Body (ASD-STE100): decision date 2026-11-05 (spec 005, S5, Q2); the misfit methods `evaluate_gradient_misfit` and `evaluate_value_misfit` keep their names and public shape; the gradient value is now a cosine in world coordinates; a stale model gives a `UserWarning` and NaN (no rebuild); + the new names `evaluate_gradient_cosines` and `evaluate_value_residuals` give the 1-D result; the notice is in `COMPAT.md`. Keep the style of the neighbouring subsections. Do not change other text. + +- [ ] T087 [Finish] [M] Record the S5 rollback check in `verification.md`. + - Files: `specs/005-coordinate-frame-correctness/verification.md` + - Needs: T074, T084 + - Wave: 41 + - Done when: `verification.md` has the heading `## S5 rollback checks` with the results of the 6 checks below, and `git worktree list` shows no throw-away worktree. + - Context: Use a throw-away worktree at the S4 end commit (the commit before the first S5 code commit): `git worktree add --detach ../LoopStructural-005-s5-rollback `, with its own `PYTHONPATH` (check `LoopStructural.__file__` and `loop_interpolation.__file__`). Checks: (a) `dataclasses.fields` of `ConstraintFamilyDiagnostics`, `RegionCoverageDiagnostics` and the first five fields of `ConstraintDiagnosticsReport` are equal on both trees; + (b) `to_dict()` of a report built with no verification is equal on both trees; (c) `from loop_interpolation import *` on the S4 tree gives a subset of the S5 names; (d) the signatures of `GeologicalFeature.evaluate_gradient_misfit` and `evaluate_value_misfit` are equal, and the result shape for one family is `(1, N)` on both; + (e) with an unchanged grid, `origin`, `maximum`, `nsteps` and `step_vector` of a structured support are equal on both trees; (f) the four 2D `maximum` assertions pass on both trees. Then `git worktree remove --force ` and `git worktree prune`. Write in ASD-STE100 Simplified Technical English. + +- [ ] T088 [Finish] [M] Run the S5 gates and complete `verification.md` and the `ROADMAP.md` status log. + - Files: `specs/005-coordinate-frame-correctness/verification.md`, `ROADMAP.md` + - Needs: T079, T085, T086, T087 + - Wave: 42 + - Done when: `ruff check LoopStructural packages && pytest` and `pytest packages/loop_common/tests packages/loop_interpolation/tests` pass, `verification.md` has the S5 sections named below, and `ROADMAP.md` has one new status-log line for S5. + - Context: Run the root `pytest` and the package tests in separate calls (the conftest plugins clash) with the worktree `PYTHONPATH`, plus `packages/loopstructural_visualisation/src`; `pyevtk` is only in a scratch folder (add it or report the skip). Add to `verification.md`: `## S5 post-fix results` (commands, counts, times, the cells of `test_diagnostics_frames.py` and `test_residual_exclusion_report.py`, the measured `tol_solved`), + `## S5 backend matrix` (FDI and PLI misfit, residual and exclusion report: mandatory; resize of structured, 2D and rectilinear grids: mandatory; P2, native, Surfe, residual reports for fold and P2 cells: not in S5; S2 and S6 cover them), `## S5 test run time (OP2)`, and a line for each open point: OP12 (cell-centre result), OP16 (default shape kept), OP17 (parity files changed or not, from T078). + Add to the status log of `ROADMAP.md` a line dated today: "Spec 005 stage S5 implemented: resize paths keep the extent, cell-centre guard tests, non-mutating world-correct misfit diagnostics, residual and exclusion report." Report any failing gate and any file under `packages/loop_interpolation/tests/golden` that changed. Write in ASD-STE100 Simplified Technical English. + ## Wave table | Wave | Tasks | Tier [S] | Tier [M] | @@ -1201,10 +1416,21 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal | 29 | T063, T064, T065, T066 | 2 | 2 | | 30 | T067 | 0 | 1 | | 31 | T068 | 0 | 1 | +| 32 | T069, T070, T071 | 0 | 3 | +| 33 | T072, T073 | 0 | 2 | +| 34 | T074, T075, T076, T077 | 2 | 2 | +| 35 | T078 | 0 | 1 | +| 36 | T079, T080 | 0 | 2 | +| 37 | T081 | 0 | 1 | +| 38 | T082 | 0 | 1 | +| 39 | T083 | 0 | 1 | +| 40 | T084 | 0 | 1 | +| 41 | T085, T086, T087 | 1 | 2 | +| 42 | T088 | 0 | 1 | ## Critical path -T003 -> T004 -> T005 -> T010 -> T013 -> T015 -> T017 -> T018 -> T020 -> T023 -> T025 -> T030 -> T031 -> T033 -> T035 -> T036 -> T039 -> T040 -> T041 -> T043 -> T047 -> T050 -> T051 -> T052 -> T053 -> T054 -> T055 -> T057 -> T063 -> T067 -> T068 +T003 -> T004 -> T005 -> T010 -> T013 -> T015 -> T017 -> T018 -> T020 -> T023 -> T025 -> T030 -> T031 -> T033 -> T035 -> T036 -> T039 -> T040 -> T041 -> T043 -> T047 -> T050 -> T051 -> T052 -> T053 -> T054 -> T055 -> T057 -> T063 -> T067 -> T068 -> T069 -> T072 -> T075 -> T078 -> T080 -> T081 -> T082 -> T083 -> T084 -> T085 -> T088 ## Dependencies @@ -1239,4 +1465,8 @@ T003 -> T004 -> T005 -> T010 -> T013 -> T015 -> T017 -> T018 -> T020 -> T023 -> (`rescale` and `scale` warn), T060 and T061 (exporters), T062 (example). - Finish: T063 (golden review) after the code; T064 to T066 (`COMPAT.md`, `ROADMAP.md`, `API.md`); T067 (rollback check); T068 (gates) last. -- Stages S5 and S6 have no tasks yet (OP1 in plan.md). +- S5 starts after T068 (S4 is its base). Tests, wave 32: T069 (resize), T070 (cell centres), T071 (residual and exclusion functions). T072 (pre-fix evidence) after T069 and T070. +- S5 code: T073 (`_diagnostics.py`) -> T074 (export); T075 (3D setters), T076 (2D counts) and T077 (rectilinear guard) after T072. T078 (numeric survey, golden review, regenerate golden data) after the three; T079 (post-fix evidence) after T078. +- S5 feature tests, one file in a chain: T080 -> T081 (after T073 and T078); T082 (pre-fix evidence). Misfit code: T083 -> T084 (one file; after T073 and T082). +- Finish: T085 (`COMPAT.md`), T086 (`ROADMAP.md`) and T087 (rollback check) after T084; T088 (gates) last. +- No open point blocks a task. OP12, OP16 and OP17 have defaults (OP17 can block the S5 merge if a parity file changes: T078 reports it). Stage S6 has no tasks yet (OP1 in plan.md). diff --git a/specs/README.md b/specs/README.md index 797f8682..2052f714 100644 --- a/specs/README.md +++ b/specs/README.md @@ -9,7 +9,7 @@ not certify that an implementation or a numerical acceptance gate passed. | [002-rbf-interpolator](002-rbf-interpolator/spec.md) | Draft, not reviewed | Continue its existing review and clarification workflow. This cascade did not change it. | | [003-fault-evaluation-performance](003-fault-evaluation-performance/spec.md) | Implementation in progress; 005 contract cascade applied | Its active plan and tasks remain in the existing LoopStructural-native worktree. OP3 and OP4 remain open; completed tasks, including concurrent T043, and measured failures are preserved. | | [004-matrix-free-directional-constraints](004-matrix-free-directional-constraints/spec.md) | Draft; 005 contract cascade applied; task graph checked | Review the updated plan and tasks. OP1 still requires the quadrature measurements before a default is chosen. | -| [005-coordinate-frame-correctness](005-coordinate-frame-correctness/spec.md) | S1 implemented 2026-11-04 on `feat/005-coordinate-frame-correctness` (17 of 17 tasks; evidence in [verification.md](005-coordinate-frame-correctness/verification.md)); S5 planned 2026-11-05 and revised after review; S6 not planned | Review and merge the S1 branch. S2 planned and tasked 2026-11-04 (T018 to T031, waves 8 to 13); run spec-orchestrator for S2. S3 planned and tasked 2026-11-05 ("Stage S3 design" in plan.md; OP4 and OP5 closed; T033 to T050, waves 14 to 22; build after S2 merges). S3 is built on `feat/005-s3-frame-persistence` (pull request #397, stacked on #396; 18 of 18 tasks, evidence in verification.md). S3 merged (#397); a follow-up keeps the legacy load backward compatible (pull request #400). S4 planned and tasked 2026-11-05 ("Stage S4 design" in plan.md; OP8 to OP10 have defaults and block nothing; T051 to T068, waves 23 to 31); run spec-orchestrator for S4. S5 planned 2026-11-05 and revised the same day after review and five maintainer answers ("Stage S5 design" in plan.md; OP13 to OP15 closed; OP12, OP16 and OP17 have defaults; OP17 blocks the S5 merge only if a parity file changes); run spec-tasks for S5 after S4 merges. OP1: plan S6 in a later planner run. OP3: constant-norm accuracy check. OP2 closed 2026-11-04: the R2/R5 matrix stays in the default pytest run. 7 minor review findings are open (run-state.json). | +| [005-coordinate-frame-correctness](005-coordinate-frame-correctness/spec.md) | S1 implemented 2026-11-04 on `feat/005-coordinate-frame-correctness` (17 of 17 tasks; evidence in [verification.md](005-coordinate-frame-correctness/verification.md)); S5 planned 2026-11-05 and revised after review; S6 not planned | Review and merge the S1 branch. S2 planned and tasked 2026-11-04 (T018 to T031, waves 8 to 13); run spec-orchestrator for S2. S3 planned and tasked 2026-11-05 ("Stage S3 design" in plan.md; OP4 and OP5 closed; T033 to T050, waves 14 to 22; build after S2 merges). S3 is built on `feat/005-s3-frame-persistence` (pull request #397, stacked on #396; 18 of 18 tasks, evidence in verification.md). S3 merged (#397); a follow-up keeps the legacy load backward compatible (pull request #400). S4 planned and tasked 2026-11-05 ("Stage S4 design" in plan.md; OP8 to OP10 have defaults and block nothing; T051 to T068, waves 23 to 31); run spec-orchestrator for S4. S5 planned 2026-11-05 and revised the same day after review and five maintainer answers ("Stage S5 design" in plan.md; OP13 to OP15 closed; OP12, OP16 and OP17 have defaults; OP17 blocks the S5 merge only if a parity file changes); S5 tasked 2026-11-05 (T069 to T088, waves 32 to 42; no open point blocks a task; build after S4 merges). OP1: plan S6 in a later planner run. OP3: constant-norm accuracy check. OP2 closed 2026-11-04: the R2/R5 matrix stays in the default pytest run. 7 minor review findings are open (run-state.json). | ## Cascade of 005 From c00f9d2cf3c8df57f25ce602c3be90613b789a04 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:17:41 +1030 Subject: [PATCH 03/28] docs(spec): start S5 run state and settle five gaps in the task texts --- .../005-coordinate-frame-correctness/plan.md | 30 + .../run-state.json | 529 +++++++++--------- .../005-coordinate-frame-correctness/tasks.md | 8 +- 3 files changed, 313 insertions(+), 254 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/plan.md b/specs/005-coordinate-frame-correctness/plan.md index e1db6776..44776f1f 100644 --- a/specs/005-coordinate-frame-correctness/plan.md +++ b/specs/005-coordinate-frame-correctness/plan.md @@ -250,6 +250,36 @@ The constitution (.specify/memory/constitution.md) has priority. --> - 2026-11-05 (Q5, value misfit, maintainer): the public value misfit keeps its normalisation by the field range. The physical residual uses an unnormalised private helper, so its unit "field" is true. +- 2026-11-05 (S5 build start, orchestrator, five gaps of the task texts, settled + by reading the code): (1) `GeologicalFeature` and its base class have no + `bounding_box` attribute. The misfit code uses `self.interpolator.bounding_box` + (an attribute of `GeologicalInterpolator`, `None` when no frame is set), as the + old code does. `self.model.bounding_box` is not used: the model can be `None`. + (2) A stale model for T081: for a stale interpolator, assign + `interpolator.up_to_date = False` after the solve (a plain attribute). For a + stale builder, assign `builder._up_to_date = False` after the solve. Do not use + `builder.set_not_up_to_date`: the `GeologicalFeatureBuilder` override also sets + the interpolator stale. The test asserts the flag value before the call. + (3) `n_elements` exists on `StructuredGrid2DGeometry` (`nsteps_cells[0] * + nsteps_cells[1]`, `_structured_grid_2d.py:45`) and on the 3D geometry (line + 166). (4) No existing test checks the keys of `ConstraintDiagnosticsReport.to_dict`. + T071 writes the pre-S5 key set as a literal from the code: report keys + `interpolator_type`, `total_rows`, `families`, `region_coverage`, + `outside_model_points`; family keys `active`, `row_count`, `dropped_rows`, + `effective_weight_mean`, `effective_weight_min`, `effective_weight_max`, + `source_point_count`, `outside_model_point_count`; region keys + `total_support_nodes`, `active_region_nodes`, `inactive_region_nodes`, + `active_fraction`; report field order `interpolator_type`, `families`, + `region_coverage`, `outside_model_points`. (5) The gradient family adds 2 + rows for each inside point for FDI (strike and dip rows, `name="gradient"`). + PLI is different: `P1Interpolator.add_gradient_constraints` is `pass` + (`_p1interpolator.py:70`), so PLI assembles no `gradient` rows, and the builder + reports `dropped_rows = 2 * source_points` for the `gradient` family of PLI. + So `verification_report` gives an `unattributed` record for PLI gradient data. + That record is true (PLI ignores gradient data) and it is not a defect of S5. + T081 checks the no-false-unattributed case with FDI only, and for PLI it records + the measured `unattributed` count (expected `2 * source_points`) and the + `outside_model` count. The worker reports both numbers. ## Summary diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 568a71c1..dc897c06 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -1,10 +1,10 @@ { "spec": "005-coordinate-frame-correctness", - "stage": "S4", - "worktree": "C:/Users/lachl/dev/LoopStructural-005-s4", - "branch": "feat/005-s4-world-locations", + "stage": "S5", + "worktree": "C:/Users/lachl/dev/LoopStructural-005-s5", + "branch": "feat/005-s5-diagnostics-and-resize", "review_mode": "wave", - "start_commit": "9b3507a9a4d4c7d7a903a08c7d33619a7546bf61", + "start_commit": "1c029d21f92177e8d529d64b334c3cb0ca6ec29e", "runtime": { "runtime": "claude", "max_workers": 5, @@ -14,243 +14,18 @@ "review_spec": "opus", "review_verifier": "opus" }, - "env": "PYTHONPATH=C:/Users/lachl/dev/LoopStructural-005-s4;C:/Users/lachl/dev/LoopStructural-005-s4/packages/loop_common/src;C:/Users/lachl/dev/LoopStructural-005-s4/packages/loop_interpolation/src; python = main checkout .venv (C:/Users/lachl/dev/LoopStructural/.venv/Scripts/python.exe); run the root pytest and the package tests in separate calls, one heavy run at a time (low memory); detached script + foreground poll for long runs; logs with Out-File -Encoding utf8; for the viewer test (T054) add /packages/loopstructural_visualisation/src to PYTHONPATH (else it skips); use PowerShell for pytest (Bash resolves loop_interpolation from the main checkout); pyevtk for test_exporters: pip install --target /pyevtk_pkg pyevtk (system python), add the dir to PYTHONPATH", - "base_note": "Built from docs/005-s4-plan (9b3507a9) on origin/master (S1 to S3 and PR #400 merged). Not pushed; no PR.", - "waves": [ - { - "wave": 23, - "base": "9b3507a9a4d4c7d7a903a08c7d33619a7546bf61", - "tasks": [ - "T051" - ], - "check": "scope pass; ruff pass; payload_declares 36 passed", - "commit": "7b4596046b230fb5f8cd5f55c40e6e277d85f932", - "review": "pass: sonnet, no findings" - }, - { - "wave": 24, - "base": "7b4596046b230fb5f8cd5f55c40e6e277d85f932", - "tasks": [ - "T052" - ], - "check": "scope pass; ruff pass; 18 failed 1 passed pre-fix (shift by local origin; rotation)", - "commit": "448aba2f29d1dfef4d1a9060335a6e94a010768d", - "review": "pass: sonnet, no findings" - }, - { - "wave": 25, - "base": "448aba2f29d1dfef4d1a9060335a6e94a010768d", - "tasks": [ - "T053" - ], - "check": "scope pass; ruff pass; 12 failed AttributeError pre-fix", - "commit": "1b4abbb0020cce326ef56c7452de704af79513ad", - "review": "pass: sonnet, no findings" - }, - { - "wave": 26, - "base": "1b4abbb0020cce326ef56c7452de704af79513ad", - "tasks": [ - "T054" - ], - "check": "scope pass; ruff pass; fault_ellipse 4 passed (coverage); file collects without viewer", - "commit": "121b75c6a62c88188de06f6869cd132b35a712db", - "review": "pass: sonnet, 1 minor open" - }, - { - "wave": 27, - "base": "27cd35bfc8692315a79ad6a06f019de6fc721ff8", - "tasks": [ - "T055", - "T056" - ], - "check": "scope pass; ruff pass; T056 25 failed (24 DID NOT WARN, 1 AttributeError); T052-T054 30 failed 5 passed", - "commit": "769562fe2ab0cb9caf32c3fc5c6653e5718d88cb", - "review": "pass: sonnet, 2 minor open" - }, - { - "wave": 28, - "base": "d8ed7d3eba533567046ca057963c7d98bf65b399", - "tasks": [ - "T057", - "T058", - "T059", - "T060", - "T061", - "T062" - ], - "check": "scope pass except approved T056 test fix; ruff pass; 62 passed (consumers + public api); test_exporters 12 passed 1 pre-existing fail (gocad)", - "commit": "7d5959e7f806ef8ff571406b0045be3c17301000", - "review": "pass: sonnet, no findings" - }, - { - "wave": 29, - "base": "2e84bfcc443c6f60c9d2d55412f508ea0f0d227b", - "tasks": [ - "T063", - "T064", - "T065", - "T066" - ], - "check": "scope pass; greps ok; consumers 60 passed; intrusions 9; api contract 2; exporters 12 + 1 pre-existing fail", - "commit": "75c2cb3517b600036f847fbd7bf9af6e316ba583", - "review": "pass: sonnet, no findings" - }, - { - "wave": 30, - "base": "686322e9927b146c390947e562fe4388ab0549fa", - "tasks": [ - "T067" - ], - "check": "scope pass; (a)-(d) recorded; no rollback worktree left", - "commit": "0871a904d372076bb1e1ff5f62644598c44b8ba6", - "review": "pass: sonnet, no findings" - } - ], - "runs": { - "T051": [ - { - "model": "sonnet", - "result": "pass: world_foliation_model added" - } - ], - "T052": [ - { - "model": "sonnet", - "result": "pass: 19 cells, 18 fail (shift by local origin), identity cell passes" - } - ], - "T053": [ - { - "model": "sonnet", - "result": "pass: 12 cells fail with AttributeError" - }, - { - "model": "sonnet", - "result": "review fix pass: test_intrusion_steps_parameters_evaluate_model_at_world_points covers the production call site; mutation check fails as expected" - } - ], - "T054": [ - { - "model": "sonnet", - "result": "pass: 4 cells coverage" - }, - { - "model": "sonnet", - "result": "fix pass: viewer import moved into the test with importorskip" - } - ], - "T055": [ - { - "model": "sonnet", - "result": "pass: S4 pre-fix section in verification.md" - } - ], - "T056": [ - { - "model": "sonnet", - "result": "pass: 25 failed (24 DID NOT WARN, 1 AttributeError _network_model_values)" - }, - { - "model": "sonnet", - "result": "fix pass: scale inplace assertion records the existing behaviour (copy; caller array unchanged)" - } - ], - "T057": [ - { - "model": "sonnet", - "result": "pass: 19 cells" - } - ], - "T058": [ - { - "model": "haiku", - "result": "pass: 12 cells" - } - ], - "T059": [ - { - "model": "haiku", - "result": "fail: stacklevel=2 attributes the warning to the public_api wrapper; asked a question" - }, - { - "model": "haiku", - "result": "pass after stacklevel=3 (fix); scale inplace test cell failed (test error, see answers)" - } - ], - "T060": [ - { - "model": "haiku", - "result": "pass: 4 rescale calls removed; 3 now-empty if real_coords blocks removed too; test_exporters ran only with pyevtk from a scratch --target dir" - } - ], - "T061": [ - { - "model": "haiku", - "result": "pass" - } - ], - "T062": [ - { - "model": "haiku", - "result": "pass" - } - ], - "T063": [ - { - "model": "sonnet", - "result": "pass" - } - ], - "T064": [ - { - "model": "sonnet", - "result": "pass; Active written in the Removed-in column like the existing rows" - } - ], - "T065": [ - { - "model": "haiku", - "result": "pass" - } - ], - "T066": [ - { - "model": "haiku", - "result": "pass" - } - ], - "T067": [ - { - "model": "sonnet", - "result": "pass; Loop3DView.add_data does not exist: checked Loop2DView.add_data (signature equal) and Loop3DView.add_mesh" - } - ], - "T068": [ - { - "model": "sonnet", - "result": "pass; gates run by the orchestrator (detached script); Done-when root pytest has 1 pre-existing failure (gocad, not S4)" - } - ] - }, + "env": "PYTHONPATH=C:/Users/lachl/dev/LoopStructural-005-s5;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loop_common/src;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loop_interpolation/src;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loopstructural_visualisation/src;C:/Users/lachl/AppData/Local/Temp/claude/C--Users-lachl-dev-dev-tools/f206cbcd-e5d0-45be-a4c8-5f5c89bb26f1/scratchpad/pyevtk_pkg; python C:/Users/lachl/dev/LoopStructural/.venv/Scripts/python.exe; run pytest from PowerShell; root pytest and package tests in separate calls", + "base_note": "Built from docs/005-s5-plan (1c029d21), which sits on feat/005-s4-world-locations (S4, PR #401, not merged). Not pushed; no PR.", + "waves": [], + "runs": {}, "findings": { - "found": 7, + "found": 0, "rejected": 0, "dropped": 0, - "fixed": 1, - "open_minor": [ - "T054 test_2d_viewer_fault_ellipse_centre_is_world: compares the patch centre to fault.fault_centre[:2] only; add a check that fault_centre[:2] is close to anchor[:2] (optional).", - "T056 test_internal_callers_do_not_warn: importorskip(pyevtk) sits after the first checks; move it to the start or split the exporter part.", - "T056 failing ids before the fixes (25: 24 DID NOT WARN, 1 AttributeError _network_model_values) are not in verification.md; record them in T063.", - "T067/T064: the plan names Loop3DView.add_data, which does not exist (Loop2DView.add_data does). COMPAT.md S4 notice repeats the name; correct in a later pass.", - "T054 fault ellipse test: add check against anchor[:2] (end review, minor).", - "T056 test_internal_callers_do_not_warn: importorskip(pyevtk) mid-test hides the checks that ran; move the exporter part to its own test (end review, minor).", - "T068 verification.md S4 deprecated-call grep: line numbers of verification.md hits are stale (wave 31, minor)." - ] + "fixed": 0, + "open_minor": [] }, - "answers": [ - "2026-11-05 (wave 28, orchestrator): (1) T059 stacklevel is 3, not 2, because @public_api adds one frame; the test requires the warning to name the caller file. (2) GeologicalModel.scale has always copied its input (np.array(points).astype(float)), so inplace=True never changed the caller array; FR-033 says keep behaviour, so the T056 scale test now checks the existing behaviour (result equals project(points); caller array unchanged) instead of mutation. (3) pyevtk is not in the venv: tests/unit/io/test_exporters.py and the exporter part of test_internal_callers_do_not_warn skip without it. pyevtk 1.7.0 was installed with pip --target into the scratch dir and put on PYTHONPATH for the checks; the venv is unchanged. (4) test_write_vol_gocad_is_broken_for_real_bounding_box fails before and after S4 (pre-existing: BoundingBox.__getitem__ raises LoopValueError for a slice; the test expects pytest.raises(LoopValueError) but the failure is at the base too) - to check." - ], + "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", "history": { "S1": { "stage": "S1", @@ -1179,19 +954,273 @@ "review": "first review: 1 major (warning location and timing) + 2 minor; fixed with warn_legacy_identity() and 7 new test cells (commit 169ae619); fix checked by the orchestrator, no second reviewer round" } ] + }, + "S4": { + "spec": "005-coordinate-frame-correctness", + "stage": "S4", + "worktree": "C:/Users/lachl/dev/LoopStructural-005-s4", + "branch": "feat/005-s4-world-locations", + "review_mode": "wave", + "start_commit": "9b3507a9a4d4c7d7a903a08c7d33619a7546bf61", + "runtime": { + "runtime": "claude", + "max_workers": 5, + "worker_S": "haiku", + "worker_M": "sonnet", + "review_wave": "sonnet", + "review_spec": "opus", + "review_verifier": "opus" + }, + "env": "PYTHONPATH=C:/Users/lachl/dev/LoopStructural-005-s4;C:/Users/lachl/dev/LoopStructural-005-s4/packages/loop_common/src;C:/Users/lachl/dev/LoopStructural-005-s4/packages/loop_interpolation/src; python = main checkout .venv (C:/Users/lachl/dev/LoopStructural/.venv/Scripts/python.exe); run the root pytest and the package tests in separate calls, one heavy run at a time (low memory); detached script + foreground poll for long runs; logs with Out-File -Encoding utf8; for the viewer test (T054) add /packages/loopstructural_visualisation/src to PYTHONPATH (else it skips); use PowerShell for pytest (Bash resolves loop_interpolation from the main checkout); pyevtk for test_exporters: pip install --target /pyevtk_pkg pyevtk (system python), add the dir to PYTHONPATH", + "base_note": "Built from docs/005-s4-plan (9b3507a9) on origin/master (S1 to S3 and PR #400 merged). Not pushed; no PR.", + "waves": [ + { + "wave": 23, + "base": "9b3507a9a4d4c7d7a903a08c7d33619a7546bf61", + "tasks": [ + "T051" + ], + "check": "scope pass; ruff pass; payload_declares 36 passed", + "commit": "7b4596046b230fb5f8cd5f55c40e6e277d85f932", + "review": "pass: sonnet, no findings" + }, + { + "wave": 24, + "base": "7b4596046b230fb5f8cd5f55c40e6e277d85f932", + "tasks": [ + "T052" + ], + "check": "scope pass; ruff pass; 18 failed 1 passed pre-fix (shift by local origin; rotation)", + "commit": "448aba2f29d1dfef4d1a9060335a6e94a010768d", + "review": "pass: sonnet, no findings" + }, + { + "wave": 25, + "base": "448aba2f29d1dfef4d1a9060335a6e94a010768d", + "tasks": [ + "T053" + ], + "check": "scope pass; ruff pass; 12 failed AttributeError pre-fix", + "commit": "1b4abbb0020cce326ef56c7452de704af79513ad", + "review": "pass: sonnet, no findings" + }, + { + "wave": 26, + "base": "1b4abbb0020cce326ef56c7452de704af79513ad", + "tasks": [ + "T054" + ], + "check": "scope pass; ruff pass; fault_ellipse 4 passed (coverage); file collects without viewer", + "commit": "121b75c6a62c88188de06f6869cd132b35a712db", + "review": "pass: sonnet, 1 minor open" + }, + { + "wave": 27, + "base": "27cd35bfc8692315a79ad6a06f019de6fc721ff8", + "tasks": [ + "T055", + "T056" + ], + "check": "scope pass; ruff pass; T056 25 failed (24 DID NOT WARN, 1 AttributeError); T052-T054 30 failed 5 passed", + "commit": "769562fe2ab0cb9caf32c3fc5c6653e5718d88cb", + "review": "pass: sonnet, 2 minor open" + }, + { + "wave": 28, + "base": "d8ed7d3eba533567046ca057963c7d98bf65b399", + "tasks": [ + "T057", + "T058", + "T059", + "T060", + "T061", + "T062" + ], + "check": "scope pass except approved T056 test fix; ruff pass; 62 passed (consumers + public api); test_exporters 12 passed 1 pre-existing fail (gocad)", + "commit": "7d5959e7f806ef8ff571406b0045be3c17301000", + "review": "pass: sonnet, no findings" + }, + { + "wave": 29, + "base": "2e84bfcc443c6f60c9d2d55412f508ea0f0d227b", + "tasks": [ + "T063", + "T064", + "T065", + "T066" + ], + "check": "scope pass; greps ok; consumers 60 passed; intrusions 9; api contract 2; exporters 12 + 1 pre-existing fail", + "commit": "75c2cb3517b600036f847fbd7bf9af6e316ba583", + "review": "pass: sonnet, no findings" + }, + { + "wave": 30, + "base": "686322e9927b146c390947e562fe4388ab0549fa", + "tasks": [ + "T067" + ], + "check": "scope pass; (a)-(d) recorded; no rollback worktree left", + "commit": "0871a904d372076bb1e1ff5f62644598c44b8ba6", + "review": "pass: sonnet, no findings" + } + ], + "runs": { + "T051": [ + { + "model": "sonnet", + "result": "pass: world_foliation_model added" + } + ], + "T052": [ + { + "model": "sonnet", + "result": "pass: 19 cells, 18 fail (shift by local origin), identity cell passes" + } + ], + "T053": [ + { + "model": "sonnet", + "result": "pass: 12 cells fail with AttributeError" + }, + { + "model": "sonnet", + "result": "review fix pass: test_intrusion_steps_parameters_evaluate_model_at_world_points covers the production call site; mutation check fails as expected" + } + ], + "T054": [ + { + "model": "sonnet", + "result": "pass: 4 cells coverage" + }, + { + "model": "sonnet", + "result": "fix pass: viewer import moved into the test with importorskip" + } + ], + "T055": [ + { + "model": "sonnet", + "result": "pass: S4 pre-fix section in verification.md" + } + ], + "T056": [ + { + "model": "sonnet", + "result": "pass: 25 failed (24 DID NOT WARN, 1 AttributeError _network_model_values)" + }, + { + "model": "sonnet", + "result": "fix pass: scale inplace assertion records the existing behaviour (copy; caller array unchanged)" + } + ], + "T057": [ + { + "model": "sonnet", + "result": "pass: 19 cells" + } + ], + "T058": [ + { + "model": "haiku", + "result": "pass: 12 cells" + } + ], + "T059": [ + { + "model": "haiku", + "result": "fail: stacklevel=2 attributes the warning to the public_api wrapper; asked a question" + }, + { + "model": "haiku", + "result": "pass after stacklevel=3 (fix); scale inplace test cell failed (test error, see answers)" + } + ], + "T060": [ + { + "model": "haiku", + "result": "pass: 4 rescale calls removed; 3 now-empty if real_coords blocks removed too; test_exporters ran only with pyevtk from a scratch --target dir" + } + ], + "T061": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T062": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T063": [ + { + "model": "sonnet", + "result": "pass" + } + ], + "T064": [ + { + "model": "sonnet", + "result": "pass; Active written in the Removed-in column like the existing rows" + } + ], + "T065": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T066": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T067": [ + { + "model": "sonnet", + "result": "pass; Loop3DView.add_data does not exist: checked Loop2DView.add_data (signature equal) and Loop3DView.add_mesh" + } + ], + "T068": [ + { + "model": "sonnet", + "result": "pass; gates run by the orchestrator (detached script); Done-when root pytest has 1 pre-existing failure (gocad, not S4)" + } + ] + }, + "findings": { + "found": 7, + "rejected": 0, + "dropped": 0, + "fixed": 1, + "open_minor": [ + "T054 test_2d_viewer_fault_ellipse_centre_is_world: compares the patch centre to fault.fault_centre[:2] only; add a check that fault_centre[:2] is close to anchor[:2] (optional).", + "T056 test_internal_callers_do_not_warn: importorskip(pyevtk) sits after the first checks; move it to the start or split the exporter part.", + "T056 failing ids before the fixes (25: 24 DID NOT WARN, 1 AttributeError _network_model_values) are not in verification.md; record them in T063.", + "T067/T064: the plan names Loop3DView.add_data, which does not exist (Loop2DView.add_data does). COMPAT.md S4 notice repeats the name; correct in a later pass.", + "T054 fault ellipse test: add check against anchor[:2] (end review, minor).", + "T056 test_internal_callers_do_not_warn: importorskip(pyevtk) mid-test hides the checks that ran; move the exporter part to its own test (end review, minor).", + "T068 verification.md S4 deprecated-call grep: line numbers of verification.md hits are stale (wave 31, minor)." + ] + }, + "answers": [ + "2026-11-05 (wave 28, orchestrator): (1) T059 stacklevel is 3, not 2, because @public_api adds one frame; the test requires the warning to name the caller file. (2) GeologicalModel.scale has always copied its input (np.array(points).astype(float)), so inplace=True never changed the caller array; FR-033 says keep behaviour, so the T056 scale test now checks the existing behaviour (result equals project(points); caller array unchanged) instead of mutation. (3) pyevtk is not in the venv: tests/unit/io/test_exporters.py and the exporter part of test_internal_callers_do_not_warn skip without it. pyevtk 1.7.0 was installed with pip --target into the scratch dir and put on PYTHONPATH for the checks; the venv is unchanged. (4) test_write_vol_gocad_is_broken_for_real_bounding_box fails before and after S4 (pre-existing: BoundingBox.__getitem__ raises LoopValueError for a slice; the test expects pytest.raises(LoopValueError) but the failure is at the base too) - to check." + ], + "final": { + "head_gated": "1ba71f17", + "lint": "ruff check LoopStructural packages: pass", + "test_packages": "912 passed, 47 skipped, 2 xfailed in 150.24 s", + "test_root": "1 failed, 2066 passed, 4 skipped in 805.50 s; the failure test_write_vol_gocad_is_broken_for_real_bounding_box is pre-existing (confirmed with the S4 changes stashed; two LoopValueError classes); it was skipped before because pyevtk is missing from the venv (pyevtk from a scratch dir on PYTHONPATH)", + "format": "black --check: 3 library files dirty before S4 (exporters.py, intrusion_frame_builder.py, geological_model.py); only task hunks changed" + }, + "spec_review": { + "range": "9b3507a9..e0b52cc5", + "reviewer": "opus", + "verdict": "changes needed: 1 major (call site of _network_model_values not covered), 2 minor", + "fix": "1 major fixed with a new test (mutation proved)" + } } - }, - "final": { - "head_gated": "1ba71f17", - "lint": "ruff check LoopStructural packages: pass", - "test_packages": "912 passed, 47 skipped, 2 xfailed in 150.24 s", - "test_root": "1 failed, 2066 passed, 4 skipped in 805.50 s; the failure test_write_vol_gocad_is_broken_for_real_bounding_box is pre-existing (confirmed with the S4 changes stashed; two LoopValueError classes); it was skipped before because pyevtk is missing from the venv (pyevtk from a scratch dir on PYTHONPATH)", - "format": "black --check: 3 library files dirty before S4 (exporters.py, intrusion_frame_builder.py, geological_model.py); only task hunks changed" - }, - "spec_review": { - "range": "9b3507a9..e0b52cc5", - "reviewer": "opus", - "verdict": "changes needed: 1 major (call site of _network_model_values not covered), 2 minor", - "fix": "1 major fixed with a new test (mutation proved)" } } \ No newline at end of file diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 20498827..30611719 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1185,7 +1185,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_nsteps_not_whole_is_rejected_without_change`: 1 cell; `(5.5, 5, 5)` and NaN raise `ValueError`, state equal; `(5.0, 5.0, 5.0)` is accepted and stored as `int`. `test_set_nelements_keeps_extent`: 3 cells; after `set_nelements(200)` `maximum` equals the start; the return value equals `n_elements`; `nsteps == max(ceil(L / h), 2)` with `h = (volume / 200) ** (1 / 3)` computed in the test. `test_set_nelements_small_request_is_clamped`: `G1.set_nelements(1)` raises nothing; every nsteps >= 2; extent kept. `test_2d_counts_are_derived`: 2 grids x 3 checks = 6 cells; after `nsteps` assignment (a float list), `nsteps_cells`, `n_cell_x`, `n_cell_y` and `n_elements` agree with the new `nsteps`; `nsteps` is an int array; `maximum == origin + nsteps * step_vector` (legacy value). `test_rectilinear_resize_is_rejected`: 3 setters; `NotImplementedError`; nodes, origin and counts unchanged. - Rule: tests must show a wrong answer (principle V). Write in ASD-STE100 Simplified Technical English. + `StructuredGrid2DGeometry.n_elements` exists (`nsteps_cells[0] * nsteps_cells[1]`). Rule: tests must show a wrong answer (principle V). Write in ASD-STE100 Simplified Technical English. - [ ] T070 [P] [US4] [M] Test: cell-centre sampling agrees with the grid cells for rotated boxes (U4.3; FR-015; SC-011). - Files: `packages/loop_common/tests/test_cell_centre_sampling.py` @@ -1207,7 +1207,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Context: New file. No model. Import from `loop_interpolation._diagnostics`: `ConstraintDiagnosticsReport`, `ConstraintFamilyDiagnostics`, `RegionCoverageDiagnostics` (read the file for their fields and for how to build a family), and the new `direction_cosines`, `build_exclusion_records`, `summarise_residuals`, `ExclusionRecord`, `ResidualSummary`, `UNKNOWN_EXPLANATION`. Signatures: `direction_cosines(model_vectors, data_vectors) -> ndarray (N,)`; `build_exclusion_records(excluded_rows: dict[str, dict[str, int]], families: dict[str, ConstraintFamilyDiagnostics], rows_per_point: dict[str, int] | None = None) -> tuple[ExclusionRecord, ...]`. `test_direction_cosines_pure_function` (6 cells): equal, opposite and orthogonal vectors give 1, -1 and 0; a zero row and a NaN row give NaN; shape mismatch raises `ValueError`; both inputs unchanged; scale invariance. - `test_report_without_verification_is_unchanged` (1): `to_dict().keys()` equals the pre-S5 key set (copy it from the existing tests of the report) and the old fields keep their order (`dataclasses.fields`). + `test_report_without_verification_is_unchanged` (1): `to_dict().keys()` equals the pre-S5 key set (no existing test has it: write this literal: report keys `interpolator_type`, `total_rows`, `families`, `region_coverage`, `outside_model_points`; family keys `active`, `row_count`, `dropped_rows`, `effective_weight_mean`, `effective_weight_min`, `effective_weight_max`, `source_point_count`, `outside_model_point_count`; region keys `total_support_nodes`, `active_region_nodes`, `inactive_region_nodes`, `active_fraction`; build the report with a family and a region coverage) and the old fields keep their order (`dataclasses.fields`). `test_builder_records_keep_their_own_key` (1): `{"restricted_orthogonal": {"invalid_reference_direction": 3}}` gives a record with `family == "restricted_orthogonal"`, count 3, no `unattributed` record, no family count changed. `test_unknown_reason_keeps_its_count` (1): reason `"x"` keeps its count and gets `UNKNOWN_EXPLANATION`. `test_unattributed_rows_are_reported` (1): `value` family, `dropped_rows` 5, outside points 2: `unattributed` count 3. `test_gradient_outside_points_give_no_false_unattributed` (1): `gradient` family, 10 source points, 2 outside, `dropped_rows` 4, `rows_per_point={"gradient": 2}`: no record; with `dropped_rows` 6: `unattributed` 2. `test_dropped_rows_none_gives_no_unattributed` (1): `interface` family, `dropped_rows=None`, no outside points: no record. `test_surfe_style_dropped_equals_outside` (1): `rows_per_point=None`, `dropped_rows` equal to the outside count: no record. Write in ASD-STE100 Simplified Technical English. @@ -1305,7 +1305,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Needs: T080 - Wave: 37 - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py -k "stale or report_blocks or unsolved or exclusions or outside_points"` fails before T083 and T084 (record the failing ids; `test_stale_misfit_is_not_rebuilt` must fail on the current tree because the call rebuilds the model) and passes after T084. - - Context: Append to the file of T080 (same fixtures; engines FDI and PLI). `test_stale_misfit_is_not_rebuilt` (2 engines x 2 kinds, interpolator stale or builder stale = 4): uses the OLD methods only. Snapshot `interpolator.up_to_date`, `builder._up_to_date`, `c` and the data arrays; after the call every snapshot is equal (on the current tree `up_to_date` changes from `False` to `True`: the test fails; FR-031). + - Context: Append to the file of T080 (same fixtures; engines FDI and PLI). Stale recipe (settled, plan "Answers"): after the solve, for a stale interpolator assign `interpolator.up_to_date = False`; for a stale builder assign `builder._up_to_date = False` directly (not `builder.set_not_up_to_date`: it also stales the interpolator); assert the flag before the call. PLI note (settled): `P1Interpolator.add_gradient_constraints` is `pass`, so PLI assembles no `gradient` rows and the builder reports `dropped_rows = 2 * source_points` for it; `unattributed` for PLI gradient data is true. Run the no-false-unattributed check with FDI only; for PLI record in the test output the measured `unattributed` count and the `outside_model` count (print them) and assert `outside_model == 3`. `test_stale_misfit_is_not_rebuilt` (2 engines x 2 kinds, interpolator stale or builder stale = 4): uses the OLD methods only. Snapshot `interpolator.up_to_date`, `builder._up_to_date`, `c` and the data arrays; after the call every snapshot is equal (on the current tree `up_to_date` changes from `False` to `True`: the test fails; FR-031). `test_stale_misfit_warns_once_and_returns_nan` (4): the old and the new methods each write exactly one `UserWarning` that names the stale object; the new methods return `np.full(n, np.nan)`; the old methods return the old shape filled with NaN. `test_stale_verification_report_does_not_rebuild` (4): snapshot equal; both residual blocks empty; exclusions present; one warning. `test_report_blocks_are_separate` (2): the two dicts are different objects; physical entries have `kind == "physical"`, assembled entries `kind == "assembled"`; the physical `value` entry has unit `"field"` and its `rms` equals the RMS of `evaluate_value_residuals()` within `1e-12`; the assembled entries equal `summarise_residuals(name, "assembled", "assembled", r)` for `calculate_residual_for_constraints()`; `to_dict` has two separate blocks. Import `summarise_residuals` and `UNKNOWN_EXPLANATION` from `loop_interpolation._diagnostics`. `test_unsolved_model_gives_no_residual_blocks` (2): `interpolator.c = None`; both blocks `{}`; exclusions present; no exception. `test_exclusions_are_counted_and_explained` (2): the restricted-support reference case of `test_reference_constraint_frames.py`: builder records under `restricted_equality` and `restricted_orthogonal` with the analytic count; the explanation is not empty and not `UNKNOWN_EXPLANATION`. @@ -1327,7 +1327,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Wave: 39 - Done when: `pytest tests/unit/modelling/test_diagnostics_frames.py -k "not report_blocks and not unsolved and not exclusions and not outside_points and not verification_report and not new_misfit_methods"` passes. - Context: The module has `from __future__ import annotations`. Add (import `direction_cosines` from `loop_interpolation._diagnostics`; `warnings`): `_stale_reason(self) -> Optional[str]`: text for the first stale object among `self.builder._up_to_date` is False, each builder in `self.builder.faults`, `self.interpolator.up_to_date` is False; `None` otherwise (builder `None`: only the interpolator). The text names the object and says to build the model first. - `_gradient_cosines_fresh(self) -> list`: for each of `get_gradient_constraints()` and `get_norm_constraints()` with rows: `rows = np.array(array, dtype=float, copy=True)`; `positions = rows[:, :3]`, reproject with `self.bounding_box.reproject` when `bounding_box` is not `None`; `data = rows[:, 3:6]`, convert with `self.interpolator.local_to_world_vectors(data)` when `bounding_box` is not `None`; `model = self.evaluate_gradient(positions)`; append `direction_cosines(model, data)`. + `_gradient_cosines_fresh(self) -> list`: for each of `get_gradient_constraints()` and `get_norm_constraints()` with rows: `rows = np.array(array, dtype=float, copy=True)`; `positions = rows[:, :3]`, reproject with `self.interpolator.bounding_box.reproject` when `self.interpolator.bounding_box` is not `None` (settled: `GeologicalFeature` has no `bounding_box`; do not use `self.model.bounding_box`); `data = rows[:, 3:6]`, convert with `self.interpolator.local_to_world_vectors(data)` when that bounding box is not `None`; `model = self.evaluate_gradient(positions)`; append `direction_cosines(model, data)`. `_value_residuals_fresh(self) -> np.ndarray`: copy `get_value_constraints()`, reproject positions, return `np.abs(rows[:, 3] - self.evaluate_value(positions))`. `evaluate_gradient_cosines() -> ndarray (N,)`: on a stale model `warnings.warn(text, UserWarning, stacklevel=2)` and return `np.full(n, np.nan)` (`n` = row count of the stored arrays); else `np.concatenate` of the list (`np.zeros(0)` if empty). `evaluate_value_residuals() -> ndarray (N,)`: same rule with `_value_residuals_fresh`. `evaluate_gradient_misfit()` (old name, new body): same code; one row per non-empty family: `(1, N)` for one family, `(2, N)` for two of equal length, `(1, N1 + N2)` for two of different length, `(0,)` with no rows; stale gives that shape filled with NaN. `evaluate_value_misfit()`: stale gives NaN `(N,)`; else the residuals divided by `self.max() - self.min()` only when the range is finite and > 0, else unnormalised. From f92808b392c716955c4136e7ae6f41e87a6f3968 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:24:52 +1030 Subject: [PATCH 04/28] test(005): add S5 tests for resize paths, cell centres and the residual report --- .../tests/test_cell_centre_sampling.py | 117 ++++++++++ .../loop_common/tests/test_resize_paths.py | 183 +++++++++++++++ .../tests/test_residual_exclusion_report.py | 216 ++++++++++++++++++ .../005-coordinate-frame-correctness/tasks.md | 6 +- 4 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 packages/loop_common/tests/test_cell_centre_sampling.py create mode 100644 packages/loop_common/tests/test_resize_paths.py create mode 100644 packages/loop_interpolation/tests/test_residual_exclusion_report.py diff --git a/packages/loop_common/tests/test_cell_centre_sampling.py b/packages/loop_common/tests/test_cell_centre_sampling.py new file mode 100644 index 00000000..e2bd3517 --- /dev/null +++ b/packages/loop_common/tests/test_cell_centre_sampling.py @@ -0,0 +1,117 @@ +"""Cell centres of a BoundingBox agree with the cells of its structured grids, also for rotated boxes.""" + +import itertools + +import numpy as np +import pytest +from loop_common.geometry import BoundingBox + +BOXES = { + "unit": dict(origin=[0.0, 0.0, 0.0], maximum=[1.0, 1.0, 1.0], nsteps=[10, 10, 10]), + "offset_anisotropic": dict( + origin=[100.0, -20.0, 5.0], maximum=[112.0, 4.0, 35.0], nsteps=[7, 5, 4] + ), +} +ANGLES = [0.0, 30.0, 90.0] +LOCAL_ORIGINS = {"origin0": (0.0, 0.0, 0.0), "origin_shifted": (7.0, -3.0, 11.0)} + + +def _rotation_about_z(angle): + radians = np.deg2rad(angle) + rotation = np.eye(3) + rotation[:2, :2] = [[np.cos(radians), -np.sin(radians)], [np.sin(radians), np.cos(radians)]] + return rotation + + +@pytest.fixture( + params=list(itertools.product(BOXES, ANGLES, LOCAL_ORIGINS)), + ids=lambda p: "-".join(map(str, p)), +) +def box(request): + name, angle, origin_name = request.param + box = BoundingBox(**BOXES[name]) + box.set_local_transform( + local_origin=np.array(LOCAL_ORIGINS[origin_name]), rotation_matrix=_rotation_about_z(angle) + ) + return box + + +def _tolerance(box): + """Point tolerance, the same as in test_rigid_transform_contract.""" + length = np.linalg.norm(box.maximum - box.origin) + world_magnitude = max(np.linalg.norm(box.origin), np.linalg.norm(box.maximum)) + return 1e-10 * length + 32 * np.finfo(float).eps * max(world_magnitude, length) + + +def _world_centres(box): + return box.reproject(box.cell_centres(order="F")) + + +def test_each_cell_has_exactly_one_centre(box): + """Each cell of the box local grid contains exactly one cell centre. + + This test is evidence for FR-015 and SC-011. + + The test works in the local frame of the box. The enclosure grid of + structured_grid(local_coordinates=True) has axis-aligned cells. These + cells differ from the cells of a rotated box. A one-to-one count against + that grid does not hold (measured: 8 of 12 cells fail with the first + version of this test). + """ + n_cells = int(np.prod(box.nsteps - 1)) + centres = box.cell_centres(order="F") + # World round trip must give the same local centres. + local = box.project(box.reproject(centres)) + np.testing.assert_allclose(local, centres, atol=_tolerance(box), rtol=0.0) + # The grid nodes are linspace(origin, maximum) in the frame of the box, and + # project() maps them to the local frame. So go back with reproject(), then + # find the cell index on that grid: x changes fastest (order F). + origin = box.regular_grid(order="F", local=False)[0] + step = box.node_step_vector + cell = np.floor((box.reproject(local) - origin) / step).astype(int) + nx, ny, nz = (int(n) - 1 for n in box.nsteps) + assert len(cell) == n_cells + assert np.all(cell >= 0) + assert np.all(cell < np.array([nx, ny, nz])) + index = cell[:, 0] + nx * (cell[:, 1] + ny * cell[:, 2]) + np.testing.assert_array_equal( + np.bincount(index, minlength=n_cells), np.ones(n_cells, dtype=int) + ) + np.testing.assert_array_equal(index, np.arange(n_cells)) + + +def test_local_coordinates_grid_associates_own_centres(box): + """Each cell centre falls in a cell of the local grid. + + This test is evidence for FR-015 and SC-011. + """ + grid = box.structured_grid(local_coordinates=True) + assert np.all(grid.cell_index_at(box.project(_world_centres(box))) != -1) + + +def test_centres_are_cell_midpoints_in_world(box): + """Each world centre is the mean of the 8 corner nodes of its cell.""" + grid = box.structured_grid() + nx, ny, nz = (int(n) for n in box.nsteps) + k, j, i = np.meshgrid(np.arange(nz - 1), np.arange(ny - 1), np.arange(nx - 1), indexing="ij") + i, j, k = i.ravel(), j.ravel(), k.ravel() # x varies fastest, as the flat cell index + corners = np.zeros((len(i), 3)) + for di, dj, dk in itertools.product((0, 1), repeat=3): + corners += grid.nodes[(i + di) + nx * ((j + dj) + ny * (k + dk))] + np.testing.assert_allclose(_world_centres(box), corners / 8.0, atol=_tolerance(box), rtol=0.0) + + +def test_centres_match_exported_cells(box): + """The world centres equal the cell centres of the exported grid.""" + grid = box.structured_grid() + np.testing.assert_allclose( + _world_centres(box), grid.cell_centres, atol=_tolerance(box), rtol=0.0 + ) + + +def test_centres_inside_box(box): + """Each world centre is inside the box limits.""" + world = _world_centres(box) + tolerance = _tolerance(box) + assert np.all(world >= box.origin - tolerance) + assert np.all(world <= box.maximum + tolerance) diff --git a/packages/loop_common/tests/test_resize_paths.py b/packages/loop_common/tests/test_resize_paths.py new file mode 100644 index 00000000..6cb3c118 --- /dev/null +++ b/packages/loop_common/tests/test_resize_paths.py @@ -0,0 +1,183 @@ +"""The resize paths of the structured grids keep the extent and the counts. + +Each resize setter must keep the grid extent, or enclose it, and must keep the node counts whole. +Setting the same value again and again must change nothing. The tests give a wrong answer on a +grid that drifts. +""" + +import numpy as np +import pytest +from loop_common.geometry import StructuredGrid2DGeometry, StructuredGrid3DGeometry +from loop_common.supports import RectilinearGrid + +# (origin, nodes per axis, step vector). nsteps counts nodes. +GRIDS_3D = { + "G1": ((0.0, 0.0, 0.0), (11, 6, 4), (1.0, 2.0, 3.0)), + "G2": ((100.0, -20.0, 5.0), (5, 5, 5), (0.5, 0.5, 0.5)), + "G3": ((-1e4, 2e3, -30.0), (21, 3, 8), (10.0, 100.0, 7.5)), +} +GRIDS_2D = { + "H1": ((0.0, 0.0), (5, 5), (1.0, 1.0)), + "H2": ((10.0, -5.0), (8, 4), (0.5, 2.0)), +} +SETTERS = ("origin", "maximum", "nsteps", "step_vector") + + +def _grid_3d(name): + origin, nsteps, step = GRIDS_3D[name] + return StructuredGrid3DGeometry(origin=origin, nsteps=nsteps, step_vector=step) + + +def _state(grid): + """Return a copy of the values that define the grid.""" + return { + "origin": np.array(grid.origin, dtype=float), + "maximum": np.array(grid.maximum, dtype=float), + "step_vector": np.array(grid.step_vector, dtype=float), + "nsteps": np.array(grid.nsteps), + } + + +def _assert_state_equal(grid, expected): + now = _state(grid) + for key, value in expected.items(): + np.testing.assert_array_equal(now[key], value, err_msg=key) + + +def _tolerance(grid_state): + """Length tolerance: 1e-12 * max(1, L) for each axis.""" + length = grid_state["maximum"] - grid_state["origin"] + return 1e-12 * np.maximum(1.0, length) + + +@pytest.mark.parametrize("setter", SETTERS) +@pytest.mark.parametrize("name", list(GRIDS_3D)) +def test_reapplying_same_value_10_times_changes_nothing_3d(name, setter): + grid = _grid_3d(name) + start = _state(grid) + for _ in range(10): + setattr(grid, setter, np.array(start[setter])) + now = _state(grid) + tol = _tolerance(start) + for key in ("origin", "maximum", "step_vector"): + assert np.all(np.abs(now[key] - start[key]) <= tol), (name, setter, key, now[key]) + np.testing.assert_array_equal(now["nsteps"], start["nsteps"]) + + +@pytest.mark.parametrize("name", list(GRIDS_3D)) +def test_nsteps_setter_keeps_extent(name): + grid = _grid_3d(name) + start = _state(grid) + tol = _tolerance(start) + for request in (np.array(start["nsteps"]) * 2, np.array(start["nsteps"])): + grid.nsteps = request + assert np.all(np.abs(grid.maximum - start["maximum"]) <= tol), (name, request, grid.maximum) + assert grid.n_nodes == np.prod(request), (name, request, grid.n_nodes) + + +@pytest.mark.parametrize("name", list(GRIDS_3D)) +def test_step_vector_setter_encloses_extent(name): + grid = _grid_3d(name) + start = _state(grid) + new_step = start["step_vector"] * 1.5 + grid.step_vector = new_step.copy() + tol = _tolerance(start) + assert np.all(grid.maximum >= start["maximum"] - tol), (name, grid.maximum) + assert np.all(grid.maximum < start["maximum"] + new_step), (name, grid.maximum) + + +@pytest.mark.parametrize("name", list(GRIDS_3D)) +def test_step_vector_a_b_a_extent_drift(name): + grid = _grid_3d(name) + start = _state(grid) + step_a = start["step_vector"].copy() + step_b = step_a * 1.5 + grid.step_vector = step_a.copy() + grid.step_vector = step_b.copy() + grid.step_vector = step_a.copy() + drift = grid.maximum - start["maximum"] + print(f"{name}: A-B-A extent drift {drift}, limit {step_a + step_b}") + tol = _tolerance(start) + assert np.all(drift >= -tol), (name, drift) + assert np.all(drift < step_a + step_b), (name, drift) + + +def test_nsteps_below_two_is_rejected_without_change(): + grid = _grid_3d("G1") + start = _state(grid) + with pytest.raises(ValueError): + grid.nsteps = np.array([1, 6, 4]) + _assert_state_equal(grid, start) + + +def test_nsteps_not_whole_is_rejected_without_change(): + grid = _grid_3d("G1") + start = _state(grid) + for bad in ([5.5, 5, 5], [np.nan, 5, 5]): + with pytest.raises(ValueError): + grid.nsteps = np.array(bad) + _assert_state_equal(grid, start) + grid.nsteps = np.array([5.0, 5.0, 5.0]) + assert np.asarray(grid.nsteps).dtype.kind == "i" + np.testing.assert_array_equal(grid.nsteps, [5, 5, 5]) + + +@pytest.mark.parametrize("name", list(GRIDS_3D)) +def test_set_nelements_keeps_extent(name): + grid = _grid_3d(name) + start = _state(grid) + length = start["maximum"] - start["origin"] + h = (np.prod(length) / 200) ** (1 / 3) + result = grid.set_nelements(200) + tol = _tolerance(start) + assert np.all(np.abs(grid.maximum - start["maximum"]) <= tol), (name, grid.maximum) + assert result == grid.n_elements + expected = np.maximum(np.ceil(length / h).astype(int), 2) + np.testing.assert_array_equal(grid.nsteps, expected) + + +def test_set_nelements_small_request_is_clamped(): + grid = _grid_3d("G1") + start = _state(grid) + grid.set_nelements(1) + assert np.all(np.asarray(grid.nsteps) >= 2), grid.nsteps + tol = _tolerance(start) + assert np.all(np.abs(grid.maximum - start["maximum"]) <= tol), grid.maximum + + +@pytest.mark.parametrize("check", ("cells", "n_cell", "n_elements", "dtype", "maximum")) +@pytest.mark.parametrize("name", list(GRIDS_2D)) +def test_2d_counts_are_derived(name, check): + origin, nsteps, step = GRIDS_2D[name] + grid = StructuredGrid2DGeometry(origin=origin, nsteps=nsteps, step_vector=step) + grid.nsteps = [nsteps[0] + 3.0, nsteps[1] + 2.0] + new = np.array([nsteps[0] + 3, nsteps[1] + 2]) + if check == "cells": + np.testing.assert_array_equal(grid.nsteps_cells, new - 1) + elif check == "n_cell": + assert (grid.n_cell_x, grid.n_cell_y) == (new[0] - 1, new[1] - 1) + elif check == "n_elements": + assert grid.n_elements == (new[0] - 1) * (new[1] - 1) + elif check == "dtype": + assert np.asarray(grid.nsteps).dtype.kind == "i" + else: + # The 2D maximum keeps its legacy value: origin + nsteps * step_vector. + np.testing.assert_allclose(grid.maximum, np.array(origin) + new * np.array(step)) + + +@pytest.mark.parametrize("setter", ("nsteps", "step_vector", "origin")) +def test_rectilinear_resize_is_rejected(setter): + grid = RectilinearGrid(np.linspace(0, 5, 6), np.linspace(0, 3, 4), np.linspace(0, 2, 3)) + nodes = np.array(grid.nodes) + origin = np.array(grid.origin) + nsteps = np.array(grid.nsteps) + value = { + "nsteps": np.array([7, 5, 4]), + "step_vector": np.array([2.0, 2.0, 2.0]), + "origin": np.array([1.0, 1.0, 1.0]), + }[setter] + with pytest.raises(NotImplementedError): + setattr(grid, setter, value) + np.testing.assert_array_equal(grid.nodes, nodes) + np.testing.assert_array_equal(grid.origin, origin) + np.testing.assert_array_equal(grid.nsteps, nsteps) diff --git a/packages/loop_interpolation/tests/test_residual_exclusion_report.py b/packages/loop_interpolation/tests/test_residual_exclusion_report.py new file mode 100644 index 00000000..041617bb --- /dev/null +++ b/packages/loop_interpolation/tests/test_residual_exclusion_report.py @@ -0,0 +1,216 @@ +import dataclasses + +import numpy as np +import pytest +from loop_interpolation._diagnostics import ( + UNKNOWN_EXPLANATION, + ConstraintDiagnosticsReport, + ConstraintFamilyDiagnostics, + ExclusionRecord, + RegionCoverageDiagnostics, + ResidualSummary, + build_exclusion_records, + direction_cosines, + summarise_residuals, +) + + +def _family( + name, + dropped_rows=0, + source_point_count=0, + outside_model_point_count=0, + row_count=10, +): + return ConstraintFamilyDiagnostics( + name=name, + active=True, + row_count=row_count, + dropped_rows=dropped_rows, + effective_weight_mean=1.0, + effective_weight_min=1.0, + effective_weight_max=1.0, + source_point_count=source_point_count, + outside_model_point_count=outside_model_point_count, + ) + + +def _unattributed(records): + return [r for r in records if r.reason == "unattributed"] + + +def test_direction_cosines_pure_function(): + model = np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + data = np.array( + [ + [2.0, 0.0, 0.0], + [-3.0, 0.0, 0.0], + [0.0, 4.0, 0.0], + [1.0, 0.0, 0.0], + [np.nan, 0.0, 0.0], + ] + ) + model_before = model.copy() + data_before = data.copy() + result = direction_cosines(model, data) + assert result.shape == (5,) + np.testing.assert_allclose(result[:3], [1.0, -1.0, 0.0], atol=1e-12) + assert np.isnan(result[3]) + assert np.isnan(result[4]) + # Inputs are not changed. + np.testing.assert_array_equal(model, model_before) + np.testing.assert_array_equal(data, data_before) + # A shape mismatch is an error. + with pytest.raises(ValueError): + direction_cosines(model[:3], data[:2]) + with pytest.raises(ValueError): + direction_cosines(model, data[:, :2]) + # The result does not depend on the length of the vectors. + a = np.array([[1.0, 2.0, 3.0], [0.0, 1.0, 1.0]]) + b = np.array([[3.0, 1.0, -2.0], [1.0, 1.0, 0.0]]) + np.testing.assert_allclose( + direction_cosines(a, b), direction_cosines(5.0 * a, 0.25 * b), atol=1e-12 + ) + + +def test_report_without_verification_is_unchanged(): + family = _family("value", source_point_count=4) + region = RegionCoverageDiagnostics( + total_support_nodes=10, + active_region_nodes=6, + inactive_region_nodes=4, + active_fraction=0.6, + ) + report = ConstraintDiagnosticsReport( + interpolator_type="FiniteDifferenceInterpolator", + families={"value": family}, + region_coverage=region, + outside_model_points={"value": 1}, + ) + data = report.to_dict() + assert set(data.keys()) == { + "interpolator_type", + "total_rows", + "families", + "region_coverage", + "outside_model_points", + } + assert set(data["families"]["value"].keys()) == { + "active", + "row_count", + "dropped_rows", + "effective_weight_mean", + "effective_weight_min", + "effective_weight_max", + "source_point_count", + "outside_model_point_count", + } + assert set(data["region_coverage"].keys()) == { + "total_support_nodes", + "active_region_nodes", + "inactive_region_nodes", + "active_fraction", + } + # The old fields keep their order. New fields can only come after them. + assert [f.name for f in dataclasses.fields(ConstraintDiagnosticsReport)][:4] == [ + "interpolator_type", + "families", + "region_coverage", + "outside_model_points", + ] + assert [f.name for f in dataclasses.fields(ConstraintFamilyDiagnostics)][:9] == [ + "name", + "active", + "row_count", + "dropped_rows", + "effective_weight_mean", + "effective_weight_min", + "effective_weight_max", + "source_point_count", + "outside_model_point_count", + ] + + +def test_builder_records_keep_their_own_key(): + families = {"restricted_orthogonal": _family("restricted_orthogonal", dropped_rows=3)} + before = dataclasses.replace(families["restricted_orthogonal"]) + records = build_exclusion_records( + {"restricted_orthogonal": {"invalid_reference_direction": 3}}, families + ) + assert len(records) == 1 + record = records[0] + assert isinstance(record, ExclusionRecord) + assert record.family == "restricted_orthogonal" + assert record.reason == "invalid_reference_direction" + assert record.count == 3 + assert _unattributed(records) == [] + assert families["restricted_orthogonal"] == before + + +def test_unknown_reason_keeps_its_count(): + families = {"value": _family("value", dropped_rows=2)} + records = build_exclusion_records({"value": {"x": 2}}, families) + assert len(records) == 1 + assert records[0].reason == "x" + assert records[0].count == 2 + assert records[0].explanation == UNKNOWN_EXPLANATION + + +def test_unattributed_rows_are_reported(): + families = { + "value": _family( + "value", dropped_rows=5, source_point_count=10, outside_model_point_count=2 + ) + } + records = build_exclusion_records({}, families) + unattributed = _unattributed(records) + assert len(unattributed) == 1 + assert unattributed[0].family == "value" + assert unattributed[0].count == 3 + + +def test_gradient_outside_points_give_no_false_unattributed(): + def make(dropped): + return { + "gradient": _family( + "gradient", + dropped_rows=dropped, + source_point_count=10, + outside_model_point_count=2, + ) + } + + records = build_exclusion_records({}, make(4), rows_per_point={"gradient": 2}) + assert records == () + records = build_exclusion_records({}, make(6), rows_per_point={"gradient": 2}) + unattributed = _unattributed(records) + assert len(unattributed) == 1 + assert unattributed[0].count == 2 + + +def test_dropped_rows_none_gives_no_unattributed(): + families = {"interface": _family("interface", dropped_rows=None)} + assert build_exclusion_records({}, families) == () + + +def test_surfe_style_dropped_equals_outside(): + families = { + "value": _family( + "value", dropped_rows=2, source_point_count=10, outside_model_point_count=2 + ) + } + assert build_exclusion_records({}, families, rows_per_point=None) == () + + +def test_residual_summary_is_importable(): + # The name must exist. Its behaviour is tested with the implementation task. + assert ResidualSummary is not None + assert callable(summarise_residuals) diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 30611719..407288a5 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1171,7 +1171,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal **Goal**: Tests for the remaining resize paths, the cell-centre sampling and the residual and exclusion report are written before the code that they test. **Independent test**: `pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py` and `pytest packages/loop_interpolation/tests/test_residual_exclusion_report.py`. -- [ ] T069 [US4] [M] Test: the resize paths keep the extent and the counts (U4.2; FR-014; SC-011). +- [x] T069 [US4] [M] Test: the resize paths keep the extent and the counts (U4.2; FR-014; SC-011). - Files: `packages/loop_common/tests/test_resize_paths.py` - Needs: T068 - Wave: 32 @@ -1187,7 +1187,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_2d_counts_are_derived`: 2 grids x 3 checks = 6 cells; after `nsteps` assignment (a float list), `nsteps_cells`, `n_cell_x`, `n_cell_y` and `n_elements` agree with the new `nsteps`; `nsteps` is an int array; `maximum == origin + nsteps * step_vector` (legacy value). `test_rectilinear_resize_is_rejected`: 3 setters; `NotImplementedError`; nodes, origin and counts unchanged. `StructuredGrid2DGeometry.n_elements` exists (`nsteps_cells[0] * nsteps_cells[1]`). Rule: tests must show a wrong answer (principle V). Write in ASD-STE100 Simplified Technical English. -- [ ] T070 [P] [US4] [M] Test: cell-centre sampling agrees with the grid cells for rotated boxes (U4.3; FR-015; SC-011). +- [x] T070 [P] [US4] [M] Test: cell-centre sampling agrees with the grid cells for rotated boxes (U4.3; FR-015; SC-011). - Files: `packages/loop_common/tests/test_cell_centre_sampling.py` - Needs: T068 - Wave: 32 @@ -1199,7 +1199,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_centres_match_exported_cells` (12): world centres equal `grid.cell_centres` within `Tp`. `test_centres_inside_box` (12): each world centre is inside `[origin, maximum]` within `Tp`. Only the first two tests are evidence for SC-011. Say so in their docstrings. Do not change `_bounding_box.py`. Write in ASD-STE100 Simplified Technical English. -- [ ] T071 [P] [US4] [M] Test: the pure residual and exclusion functions and the report class (U4.4, U4.5; FR-023, FR-024). +- [x] T071 [P] [US4] [M] Test: the pure residual and exclusion functions and the report class (U4.4, U4.5; FR-023, FR-024). - Files: `packages/loop_interpolation/tests/test_residual_exclusion_report.py` - Needs: T068 - Wave: 32 From 188d823f700727a316ba24559cca791f04b66ced Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:31:45 +1030 Subject: [PATCH 05/28] fix(tests): correct review findings of wave 32 --- .../tests/test_cell_centre_sampling.py | 33 +++++++++------ .../tests/test_residual_exclusion_report.py | 1 + .../run-state.json | 40 ++++++++++++++++++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/loop_common/tests/test_cell_centre_sampling.py b/packages/loop_common/tests/test_cell_centre_sampling.py index e2bd3517..87dac7e7 100644 --- a/packages/loop_common/tests/test_cell_centre_sampling.py +++ b/packages/loop_common/tests/test_cell_centre_sampling.py @@ -1,5 +1,7 @@ """Cell centres of a BoundingBox agree with the cells of its structured grids, also for rotated boxes.""" +# The task premise for a one-to-one test against the enclosure grid was wrong. +# The evidence for FR-015 and SC-011 is in test_centres_are_cell_midpoints_in_world. import itertools import numpy as np @@ -47,16 +49,12 @@ def _world_centres(box): return box.reproject(box.cell_centres(order="F")) -def test_each_cell_has_exactly_one_centre(box): - """Each cell of the box local grid contains exactly one cell centre. +def test_centres_round_trip_and_index_own_cells(box): + """The centres survive a world round trip and index the cells of the box own grid. - This test is evidence for FR-015 and SC-011. - - The test works in the local frame of the box. The enclosure grid of - structured_grid(local_coordinates=True) has axis-aligned cells. These - cells differ from the cells of a rotated box. A one-to-one count against - that grid does not hold (measured: 8 of 12 cells fail with the first - version of this test). + This is a consistency check on the cell grid of the box itself. The grid + is built from the same linspace as the centres. This test is not evidence + for FR-015 or SC-011. """ n_cells = int(np.prod(box.nsteps - 1)) centres = box.cell_centres(order="F") @@ -81,16 +79,24 @@ def test_each_cell_has_exactly_one_centre(box): def test_local_coordinates_grid_associates_own_centres(box): - """Each cell centre falls in a cell of the local grid. + """Containment check: each cell centre falls inside the enclosure grid. - This test is evidence for FR-015 and SC-011. + The grid is structured_grid(local_coordinates=True). The check gives no + -1 for any centre. The enclosure grid has axis-aligned cells that differ + from the cells of a rotated box, so a one-to-one count against it does + not hold. """ grid = box.structured_grid(local_coordinates=True) assert np.all(grid.cell_index_at(box.project(_world_centres(box))) != -1) def test_centres_are_cell_midpoints_in_world(box): - """Each world centre is the mean of the 8 corner nodes of its cell.""" + """Each world centre is the mean of the 8 corner nodes of its cell. + + This test is evidence for FR-015 and SC-011. Each cell of the exported + grid has exactly one centre, as the mean of its corners. The centres are + all different. + """ grid = box.structured_grid() nx, ny, nz = (int(n) for n in box.nsteps) k, j, i = np.meshgrid(np.arange(nz - 1), np.arange(ny - 1), np.arange(nx - 1), indexing="ij") @@ -99,6 +105,9 @@ def test_centres_are_cell_midpoints_in_world(box): for di, dj, dk in itertools.product((0, 1), repeat=3): corners += grid.nodes[(i + di) + nx * ((j + dj) + ny * (k + dk))] np.testing.assert_allclose(_world_centres(box), corners / 8.0, atol=_tolerance(box), rtol=0.0) + # No two cells share a centre, so each cell has exactly one centre. + decimals = 6 + assert len(np.unique(np.round(corners / 8.0, decimals), axis=0)) == len(i) def test_centres_match_exported_cells(box): diff --git a/packages/loop_interpolation/tests/test_residual_exclusion_report.py b/packages/loop_interpolation/tests/test_residual_exclusion_report.py index 041617bb..cfc680fe 100644 --- a/packages/loop_interpolation/tests/test_residual_exclusion_report.py +++ b/packages/loop_interpolation/tests/test_residual_exclusion_report.py @@ -103,6 +103,7 @@ def test_report_without_verification_is_unchanged(): "region_coverage", "outside_model_points", } + assert data["total_rows"] == 10 # row_count of the one family assert set(data["families"]["value"].keys()) == { "active", "row_count", diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index dc897c06..59a06cf0 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -16,8 +16,40 @@ }, "env": "PYTHONPATH=C:/Users/lachl/dev/LoopStructural-005-s5;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loop_common/src;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loop_interpolation/src;C:/Users/lachl/dev/LoopStructural-005-s5/packages/loopstructural_visualisation/src;C:/Users/lachl/AppData/Local/Temp/claude/C--Users-lachl-dev-dev-tools/f206cbcd-e5d0-45be-a4c8-5f5c89bb26f1/scratchpad/pyevtk_pkg; python C:/Users/lachl/dev/LoopStructural/.venv/Scripts/python.exe; run pytest from PowerShell; root pytest and package tests in separate calls", "base_note": "Built from docs/005-s5-plan (1c029d21), which sits on feat/005-s4-world-locations (S4, PR #401, not merged). Not pushed; no PR.", - "waves": [], - "runs": {}, + "waves": [ + { + "wave": 32, + "base": "e583f835337c5d23794f41e2fd6597c4064a0832", + "tasks": "T069,T070,T071", + "check": "scope pass; ruff pass; T069 24 failed/76 passed incl T070 (expected pre-fix); T071 ImportError collection (expected)", + "commit": "cc7d5f5ee0795bbec1d5aee6ece628841858fb4b", + "review": "pending" + } + ], + "runs": { + "T069": [ + { + "model": "sonnet", + "result": "pass: 24 failing cells pre-fix" + } + ], + "T070": [ + { + "model": "sonnet", + "result": "fail: test premise wrong (enclosure grid), 8 cells failed" + }, + { + "model": "sonnet", + "result": "pass: premise fixed, 60 cells pass; OP12 not reproduced (centres are correct)" + } + ], + "T071": [ + { + "model": "sonnet", + "result": "pass: ImportError as expected" + } + ] + }, "findings": { "found": 0, "rejected": 0, @@ -1222,5 +1254,9 @@ "fix": "1 major fixed with a new test (mutation proved)" } } + }, + "s5_notes": { + "T070": "The task text premise (one centre per cell of structured_grid(local_coordinates=True)) is wrong for rotated boxes: that grid is the axis-aligned enclosure with a different step. Test rewritten on the box own cell grid. _bounding_box.py not changed. OP12: not reproduced.", + "T070_arg": "set_local_transform takes rotation_matrix=, not local_rotation=." } } \ No newline at end of file From b5d0beb7cd8ef4602edf7d178a5d0fb3de12c020 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:34:52 +1030 Subject: [PATCH 06/28] docs(spec): record wave 32 review --- specs/005-coordinate-frame-correctness/run-state.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 59a06cf0..edb2f64e 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -23,7 +23,7 @@ "tasks": "T069,T070,T071", "check": "scope pass; ruff pass; T069 24 failed/76 passed incl T070 (expected pre-fix); T071 ImportError collection (expected)", "commit": "cc7d5f5ee0795bbec1d5aee6ece628841858fb4b", - "review": "pending" + "review": "fix round then recheck: pass (1 major fixed: T070 test premise/labels; 2 minor fixed); fix commit 04420f44" } ], "runs": { @@ -51,10 +51,10 @@ ] }, "findings": { - "found": 0, + "found": 4, "rejected": 0, "dropped": 0, - "fixed": 0, + "fixed": 3, "open_minor": [] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", From 22e1ffb5d413f1faf7741472591383c81c981d28 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:42:15 +1030 Subject: [PATCH 07/28] feat(loop_interpolation): add residual summary and exclusion record types --- .../src/loop_interpolation/_diagnostics.py | 233 +++++++++++++++++- .../tests/test_residual_exclusion_report.py | 24 +- .../run-state.json | 27 ++ .../005-coordinate-frame-correctness/tasks.md | 4 +- .../verification.md | 37 +++ 5 files changed, 309 insertions(+), 16 deletions(-) diff --git a/packages/loop_interpolation/src/loop_interpolation/_diagnostics.py b/packages/loop_interpolation/src/loop_interpolation/_diagnostics.py index f1f591ce..8481639d 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_diagnostics.py +++ b/packages/loop_interpolation/src/loop_interpolation/_diagnostics.py @@ -1,6 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field + +import numpy as np + +EXCLUSION_EXPLANATIONS: dict[str, str] = { + "invalid_reference_value": "The reference value of the point is not a finite number.", + "invalid_reference_direction": "The reference direction of the point is zero or not finite.", + "evaluation_error": "The model could not be evaluated at the point.", + "outside_model": "The point is outside the model bounds. The count is in points.", + "unattributed": "The rows were dropped for an unknown reason. The count is in matrix rows.", +} +UNKNOWN_EXPLANATION = "No explanation is registered for this reason." @dataclass(frozen=True) @@ -24,12 +35,185 @@ class RegionCoverageDiagnostics: active_fraction: float +@dataclass(frozen=True) +class ResidualSummary: + """Statistics of the residuals of one constraint family. + + Attributes + ---------- + name : str + Name of the residual set. + kind : str + Either ``"physical"`` or ``"assembled"``. + unit : str + One of ``"field"``, ``"degree"`` or ``"assembled"``. + count : int + Number of finite residuals. + nan_count : int + Number of residuals that are not finite. + rms : float or None + Root mean square of the finite residuals. None when ``count`` is 0. + max_abs : float or None + Largest absolute finite residual. None when ``count`` is 0. + mean_abs : float or None + Mean absolute finite residual. None when ``count`` is 0. + """ + + name: str + kind: str + unit: str + count: int + nan_count: int + rms: float | None + max_abs: float | None + mean_abs: float | None + + +@dataclass(frozen=True) +class ExclusionRecord: + """One reason why rows or points were excluded. + + Attributes + ---------- + family : str + Name of the constraint family. + reason : str + Short key of the reason. + count : int + Number of excluded items. + explanation : str + One sentence that explains the reason. + """ + + family: str + reason: str + count: int + explanation: str + + +def summarise_residuals(name: str, kind: str, unit: str, residuals: np.ndarray) -> ResidualSummary: + """Summarise a set of residuals. + + Parameters + ---------- + name : str + Name of the residual set. + kind : str + Either ``"physical"`` or ``"assembled"``. + unit : str + One of ``"field"``, ``"degree"`` or ``"assembled"``. + residuals : numpy.ndarray + The residuals. The array is flattened and is not changed. + + Returns + ------- + ResidualSummary + Statistics of the finite residuals. Values that are not finite + are only counted. + """ + flat = np.asarray(residuals, dtype=float).ravel() + finite = flat[np.isfinite(flat)] + nan_count = int(flat.size - finite.size) + if finite.size == 0: + return ResidualSummary(name, kind, unit, 0, nan_count, None, None, None) + absolute = np.abs(finite) + return ResidualSummary( + name=name, + kind=kind, + unit=unit, + count=int(finite.size), + nan_count=nan_count, + rms=float(np.sqrt(np.mean(finite**2))), + max_abs=float(np.max(absolute)), + mean_abs=float(np.mean(absolute)), + ) + + +def direction_cosines(model_vectors: np.ndarray, data_vectors: np.ndarray) -> np.ndarray: + """Compute the cosine of the angle between paired vectors. + + Parameters + ---------- + model_vectors : numpy.ndarray + Array of shape (n, d). + data_vectors : numpy.ndarray + Array of shape (n, d). + + Returns + ------- + numpy.ndarray + Array of shape (n,). A row with a zero or not finite norm gives NaN. + + Raises + ------ + ValueError + If the two arrays do not have the same shape. + """ + model = np.asarray(model_vectors, dtype=float) + data = np.asarray(data_vectors, dtype=float) + if model.shape != data.shape: + raise ValueError(f"Shape mismatch: model vectors {model.shape}, data vectors {data.shape}.") + model_norm = np.linalg.norm(model, axis=-1) + data_norm = np.linalg.norm(data, axis=-1) + valid = np.isfinite(model_norm) & np.isfinite(data_norm) & (model_norm > 0) & (data_norm > 0) + result = np.full(model_norm.shape, np.nan) + dot = np.sum(model * data, axis=-1) + result[valid] = dot[valid] / (model_norm[valid] * data_norm[valid]) + return result + + +def build_exclusion_records( + excluded_rows: dict[str, dict[str, int]], + families: dict[str, ConstraintFamilyDiagnostics], + rows_per_point: dict[str, int] | None = None, +) -> tuple[ExclusionRecord, ...]: + """Build the records of excluded rows and points. + + Parameters + ---------- + excluded_rows : dict + Maps a family name to a dict of reason and count. + families : dict + Maps a family name to its diagnostics. + rows_per_point : dict, optional + Number of matrix rows that one point makes, for each family. + The default is 1 for each family. + + Returns + ------- + tuple of ExclusionRecord + Records sorted by family and reason. A count of 0 makes no record. + """ + rows_per_point = rows_per_point or {} + records = [] + + def add(family: str, reason: str, count: int) -> None: + """Add one record when the count is more than 0.""" + if count > 0: + explanation = EXCLUSION_EXPLANATIONS.get(reason, UNKNOWN_EXPLANATION) + records.append(ExclusionRecord(family, reason, int(count), explanation)) + + for family, reasons in excluded_rows.items(): + for reason, count in reasons.items(): + add(family, reason, count) + for name, diagnostics in families.items(): + outside = diagnostics.outside_model_point_count + add(name, "outside_model", outside) + if diagnostics.dropped_rows is not None: + k = rows_per_point.get(name, 1) + add(name, "unattributed", diagnostics.dropped_rows - outside * k) + return tuple(sorted(records, key=lambda r: (r.family, r.reason))) + + @dataclass(frozen=True) class ConstraintDiagnosticsReport: interpolator_type: str families: dict[str, ConstraintFamilyDiagnostics] = field(default_factory=dict) region_coverage: RegionCoverageDiagnostics | None = None outside_model_points: dict[str, int] = field(default_factory=dict) + physical_residuals: dict[str, ResidualSummary] = field(default_factory=dict) + assembled_residuals: dict[str, ResidualSummary] = field(default_factory=dict) + exclusions: tuple[ExclusionRecord, ...] = () @property def total_rows(self) -> int: @@ -40,7 +224,7 @@ def active_families(self) -> dict[str, ConstraintFamilyDiagnostics]: return {k: v for k, v in self.families.items() if v.active} def to_dict(self) -> dict: - return { + data = { "interpolator_type": self.interpolator_type, "total_rows": self.total_rows, "families": { @@ -56,16 +240,29 @@ def to_dict(self) -> dict: } for name, family in self.families.items() }, - "region_coverage": None - if self.region_coverage is None - else { - "total_support_nodes": self.region_coverage.total_support_nodes, - "active_region_nodes": self.region_coverage.active_region_nodes, - "inactive_region_nodes": self.region_coverage.inactive_region_nodes, - "active_fraction": self.region_coverage.active_fraction, - }, + "region_coverage": ( + None + if self.region_coverage is None + else { + "total_support_nodes": self.region_coverage.total_support_nodes, + "active_region_nodes": self.region_coverage.active_region_nodes, + "inactive_region_nodes": self.region_coverage.inactive_region_nodes, + "active_fraction": self.region_coverage.active_fraction, + } + ), "outside_model_points": dict(self.outside_model_points), } + if self.physical_residuals: + data["physical_residuals"] = { + key: asdict(value) for key, value in self.physical_residuals.items() + } + if self.assembled_residuals: + data["assembled_residuals"] = { + key: asdict(value) for key, value in self.assembled_residuals.items() + } + if self.exclusions: + data["exclusions"] = [asdict(record) for record in self.exclusions] + return data def summary(self) -> str: lines = [ @@ -99,4 +296,20 @@ def summary(self) -> str: for key, value in sorted(self.outside_model_points.items()): lines.append(f" - {key}: {value}") + for title, block in ( + ("Physical residuals:", self.physical_residuals), + ("Assembled residuals:", self.assembled_residuals), + ): + if block: + lines.append(title) + for key, item in sorted(block.items()): + lines.append( + f" - {key}: count={item.count}, nan={item.nan_count}, " + f"rms={item.rms}, max_abs={item.max_abs} ({item.unit})" + ) + if self.exclusions: + lines.append("Exclusions:") + for record in self.exclusions: + lines.append(f" - {record.family}: {record.reason}={record.count}") + return "\n".join(lines) diff --git a/packages/loop_interpolation/tests/test_residual_exclusion_report.py b/packages/loop_interpolation/tests/test_residual_exclusion_report.py index cfc680fe..0985a117 100644 --- a/packages/loop_interpolation/tests/test_residual_exclusion_report.py +++ b/packages/loop_interpolation/tests/test_residual_exclusion_report.py @@ -141,7 +141,9 @@ def test_report_without_verification_is_unchanged(): def test_builder_records_keep_their_own_key(): - families = {"restricted_orthogonal": _family("restricted_orthogonal", dropped_rows=3)} + # The builder key is not a family that counts it in dropped_rows. + # So dropped_rows is 0 and the rule gives no unattributed record. + families = {"restricted_orthogonal": _family("restricted_orthogonal", dropped_rows=0)} before = dataclasses.replace(families["restricted_orthogonal"]) records = build_exclusion_records( {"restricted_orthogonal": {"invalid_reference_direction": 3}}, families @@ -157,7 +159,8 @@ def test_builder_records_keep_their_own_key(): def test_unknown_reason_keeps_its_count(): - families = {"value": _family("value", dropped_rows=2)} + # dropped_rows is 0, so the rule gives no unattributed record. + families = {"value": _family("value", dropped_rows=0)} records = build_exclusion_records({"value": {"x": 2}}, families) assert len(records) == 1 assert records[0].reason == "x" @@ -190,7 +193,13 @@ def make(dropped): } records = build_exclusion_records({}, make(4), rows_per_point={"gradient": 2}) - assert records == () + # 4 dropped rows = 2 outside points * 2 rows. No unattributed record. + # One outside_model record gives the count in points. + assert _unattributed(records) == [] + assert len(records) == 1 + assert records[0].family == "gradient" + assert records[0].reason == "outside_model" + assert records[0].count == 2 records = build_exclusion_records({}, make(6), rows_per_point={"gradient": 2}) unattributed = _unattributed(records) assert len(unattributed) == 1 @@ -208,7 +217,14 @@ def test_surfe_style_dropped_equals_outside(): "value", dropped_rows=2, source_point_count=10, outside_model_point_count=2 ) } - assert build_exclusion_records({}, families, rows_per_point=None) == () + records = build_exclusion_records({}, families, rows_per_point=None) + # Dropped rows equal outside points. No unattributed record. + # One outside_model record gives the count. + assert _unattributed(records) == [] + assert len(records) == 1 + assert records[0].family == "value" + assert records[0].reason == "outside_model" + assert records[0].count == 2 def test_residual_summary_is_importable(): diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index edb2f64e..3a9f2ffc 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -24,6 +24,14 @@ "check": "scope pass; ruff pass; T069 24 failed/76 passed incl T070 (expected pre-fix); T071 ImportError collection (expected)", "commit": "cc7d5f5ee0795bbec1d5aee6ece628841858fb4b", "review": "fix round then recheck: pass (1 major fixed: T070 test premise/labels; 2 minor fixed); fix commit 04420f44" + }, + { + "wave": 33, + "base": "436d5f3f9d282c6048c7b27c268acdd975f166fa", + "tasks": "T072,T073", + "check": "scope pass; residual report test 9 passed; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -48,6 +56,22 @@ "model": "sonnet", "result": "pass: ImportError as expected" } + ], + "T072": [ + { + "model": "sonnet", + "result": "pass" + } + ], + "T073": [ + { + "model": "sonnet", + "result": "blocked: 4 T071 tests contradicted the task text" + }, + { + "model": "sonnet", + "result": "T071 tests corrected (fix worker); 9 passed" + } ] }, "findings": { @@ -1258,5 +1282,8 @@ "s5_notes": { "T070": "The task text premise (one centre per cell of structured_grid(local_coordinates=True)) is wrong for rotated boxes: that grid is the axis-aligned enclosure with a different step. Test rewritten on the box own cell grid. _bounding_box.py not changed. OP12: not reproduced.", "T070_arg": "set_local_transform takes rotation_matrix=, not local_rotation=." + }, + "s5_notes2": { + "T071_T073": "Four T071 tests asserted () or no record where the T073 rule (outside_model record per family with outside points; builder keys are not families) gives outside_model records or needs family dropped_rows 0. Tests corrected to assert no unattributed record; implementation follows the task text." } } \ No newline at end of file diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 407288a5..bd2cc437 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1212,7 +1212,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_unattributed_rows_are_reported` (1): `value` family, `dropped_rows` 5, outside points 2: `unattributed` count 3. `test_gradient_outside_points_give_no_false_unattributed` (1): `gradient` family, 10 source points, 2 outside, `dropped_rows` 4, `rows_per_point={"gradient": 2}`: no record; with `dropped_rows` 6: `unattributed` 2. `test_dropped_rows_none_gives_no_unattributed` (1): `interface` family, `dropped_rows=None`, no outside points: no record. `test_surfe_style_dropped_equals_outside` (1): `rows_per_point=None`, `dropped_rows` equal to the outside count: no record. Write in ASD-STE100 Simplified Technical English. -- [ ] T072 [US4] [M] Record the S5 pre-fix evidence for the resize and cell-centre tests in `verification.md`. +- [x] T072 [US4] [M] Record the S5 pre-fix evidence for the resize and cell-centre tests in `verification.md`. - Files: `specs/005-coordinate-frame-correctness/verification.md` - Needs: T069, T070 - Wave: 33 @@ -1223,7 +1223,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal ## Phase 19: Stage S5 diagnostics types and resize code -- [ ] T073 [P] [US4] [M] Add the residual and exclusion types and functions to `_diagnostics.py`. +- [x] T073 [P] [US4] [M] Add the residual and exclusion types and functions to `_diagnostics.py`. - Files: `packages/loop_interpolation/src/loop_interpolation/_diagnostics.py` - Needs: T071 - Wave: 33 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index 481d3bfc..cf779b2b 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -697,3 +697,40 @@ of `rescale` or `scale` appears, so no S4 code path calls them. - `tests/unit/io/test_exporters.py::test_write_vol_gocad_is_broken_for_real_bounding_box` failed only because two classes are named `LoopValueError`. `BoundingBox.__getitem__` raises the class of `loop_common.geometry._bounding_box` (a `ValueError`), and the test expected the class of `LoopStructural.utils.exceptions`. The test now accepts `(LoopValueError, ValueError)`. The file passes: 13 passed (run with `pyevtk` on `PYTHONPATH`). The full root suite was not run again after this change; the failure was the only one in the S4 root run. - The task text and the S4 notice in `COMPAT.md` named `Loop3DView.add_data`. The method is `Loop2DView.add_data`. `plan.md`, `tasks.md` and `COMPAT.md` now say so. + +## S5 pre-fix failures + +Method. Both `loop_common` test files ran on the unfixed code, in one call: `python -m pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py -p no:cacheprovider -q -rf` (PowerShell, the worktree `LoopStructural-005-s5` on `PYTHONPATH`). No S5 library change exists at this commit. The result is 24 failed and 76 passed. All 24 failures are in the resize file. All 60 cells of the cell-centre file pass. + +Resize file (`test_resize_paths.py`): 24 failed, 76 passed. + +| Test function | Failed / total | Representative id and first error | +|---|---|---| +| `test_nsteps_setter_keeps_extent` | 3 / 3 | `[G1]`: the `maximum` is [10.5, 11, 10.5] and not [10, 10, 9] after `nsteps` is set to [22, 12, 8]. `[G2]` gives 102.25, -17.75, 7.25 (start 102, -18, 7). `[G3]` gives -9795, 2250, 26.25 (start -9800, 2200, 22.5) | +| `test_step_vector_setter_encloses_extent` | 2 / 3 | `[G1]`: the `maximum` is [10.5, 9, 9] and is below the start [10, 10, 9], so the extent is lost. `[G3]` fails in the same way (the `maximum` is below the start). `[G2]` passes | +| `test_nsteps_below_two_is_rejected_without_change` | 1 / 1 | `DID NOT RAISE ValueError`. The setter accepts nsteps [1, 6, 4] | +| `test_nsteps_not_whole_is_rejected_without_change` | 1 / 1 | `DID NOT RAISE ValueError`. The setter accepts [5.5, 5, 5] and [nan, 5, 5] | +| `test_set_nelements_keeps_extent` | 3 / 3 | `[G1]`: the `maximum` is not the start `maximum`. `set_nelements(200)` gives [9.43, 10.29, 10] and not [10, 10, 9]. `[G2]` gives 102.08, -17.92, 7.08. `[G3]` gives -9811, 2270, 10 | +| `test_set_nelements_small_request_is_clamped` | 1 / 1 | `assert np.all(nsteps >= 2)` fails: `nsteps` is [2, 2, 1], and the `maximum` is [5.5, 6, 0] | +| `test_2d_counts_are_derived` | 10 / 10 | All 5 checks fail for `[H1]` and for `[H2]`: `cells`, `n_cell`, `n_elements`, `dtype`, `maximum`. See the measured values below | +| `test_rectilinear_resize_is_rejected` | 3 / 3 | `[nsteps]`: `DID NOT RAISE NotImplementedError`. `[step_vector]` and `[origin]` fail in the same way | +| `test_reapplying_same_value_10_times_changes_nothing_3d` | 0 / 12 | Passes now (coverage, no defect) | +| `test_step_vector_a_b_a_extent_drift` | 0 / 3 | Passes now (coverage, no defect). See the measured drift below | + +Measured values (a short script, not saved in the repository). + +| Measure | Value | +|---|---| +| `nsteps` setter, nodes 11 to 21, step 1, origin 0 | `maximum` is 10.476 (10.48) in each axis, and the expected value is 10 | +| `step_vector` setter, step x 1.5 on G1 | `maximum` is [10.5, 9, 9], the start is [10, 10, 9]. The `nsteps` become [8, 4, 3] | +| `step_vector` setter, step x 1.5 on G3 | `maximum` is [-9805, 2150, 26.25], the start is [-9800, 2200, 22.5]. The `nsteps` become [14, 2, 6] | +| `step_vector` setter, step x 1.5 on G2 | `maximum` is [102.25, -17.75, 7.25], the start is [102, -18, 7]. The extent is enclosed, so the test passes | +| A-B-A `step_vector` drift (A, then 1.5 A, then A) | G1 [1, 0, 3]. G2 [0.5, 0.5, 0.5]. G3 [0, 0, 7.5]. All are inside the limit (A + B), so the test passes | +| 2D `nsteps` set to [nx + 3.0, ny + 2.0], H1 (5, 5) | `nsteps_cells` stays [4, 4]. `n_cell_x` and `n_cell_y` stay 4. `n_elements` stays 16. The dtype is float64. The `maximum` stays [5, 5] and the expected value is [8, 7] | +| Same set on H2 (8, 4) | `nsteps_cells` stays [7, 3]. `n_elements` stays 21. The dtype is float64. The `maximum` stays [14, 3] and the expected value is [15.5, 7] | + +The 2D `maximum` check fails only because the counts are stale after the set. The legacy formula (origin + nsteps x step) is the expected value in the test, so the fix does not change the formula. + +Cell-centre file (`test_cell_centre_sampling.py`): 60 passed, 0 failed. Result: coverage, no defect. OP12 is not reproduced. + +Note on the one-to-one test. The first version of the test used `structured_grid(local_coordinates=True)` and failed in 8 of 12 cells (rotation 30 and 90). That grid is the axis-aligned enclosure of the projected corners, and it has a different step. The premise in the task text was wrong. The test now uses the midpoints of the exported world grid and the own cell grid of the box. `_bounding_box.py` was not changed. Also, `set_local_transform` takes the argument `rotation_matrix=`. From 4390f0ddcc3cef78ce6bbb14a49be59afdbf5a78 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:45:36 +1030 Subject: [PATCH 08/28] docs(spec): record wave 33 review --- specs/005-coordinate-frame-correctness/run-state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 3a9f2ffc..122ed7cc 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -30,8 +30,8 @@ "base": "436d5f3f9d282c6048c7b27c268acdd975f166fa", "tasks": "T072,T073", "check": "scope pass; residual report test 9 passed; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "0b4eef55d3e0896875495a3e783081b5a0cc6f84", + "review": "pass (no findings; 2 optional edge notes: finite**2 overflow near 1e154, inf*0 warning in direction_cosines)" } ], "runs": { From b65aadafdbaaf669888b7e59cc8041a61d164bb8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:49:34 +1030 Subject: [PATCH 09/28] fix(loop_common): keep the extent on resize of structured grids, derive 2D counts, reject rectilinear resize --- .../geometry/_structured_grid_2d.py | 69 +++++++++++--- .../geometry/_structured_grid_3d.py | 38 +++++--- .../supports/_3d_rectilinear_grid.py | 89 +++++++++++++++++++ .../src/loop_interpolation/__init__.py | 8 ++ .../run-state.json | 36 ++++++++ .../005-coordinate-frame-correctness/tasks.md | 8 +- 6 files changed, 224 insertions(+), 24 deletions(-) diff --git a/packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py b/packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py index 7c48836b..16244673 100644 --- a/packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py +++ b/packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py @@ -1,6 +1,5 @@ """Pure 2D regular grid geometry: origin/nsteps/step_vector indexing.""" - import numpy as np from loop_common.logging import get_logger as getLogger @@ -20,14 +19,55 @@ def __init__(self, origin=None, nsteps=None, step_vector=None): nsteps = np.array([10, 10]) if step_vector is None: step_vector = np.ones(2) - self.nsteps = np.ceil(np.array(nsteps)).astype(int) + self.nsteps = nsteps self.step_vector = np.array(step_vector) self.origin = np.array(origin) - self.maximum = origin + self.nsteps * self.step_vector self.dim = 2 - self.nsteps_cells = self.nsteps - 1 - self.n_cell_x = self.nsteps[0] - 1 - self.n_cell_y = self.nsteps[1] - 1 + + @property + def nsteps(self) -> np.ndarray: + """Number of nodes on each axis. + + Returns + ------- + np.ndarray + Integer array of node counts. + """ + return self._nsteps + + @nsteps.setter + def nsteps(self, nsteps) -> None: + # Round up to whole nodes. All counts come from this value. + self._nsteps = np.ceil(np.asarray(nsteps)).astype(int) + + @property + def nsteps_cells(self) -> np.ndarray: + """Number of cells on each axis (`nsteps - 1`).""" + return self.nsteps - 1 + + @property + def n_cell_x(self): + """Number of cells on the x axis.""" + return self.nsteps[0] - 1 + + @property + def n_cell_y(self): + """Number of cells on the y axis.""" + return self.nsteps[1] - 1 + + @property + def maximum(self) -> np.ndarray: + """Legacy maximum corner (FR-028). + + Returns + ------- + np.ndarray + `origin + nsteps * step_vector`. + """ + # Legacy value (FR-028). It counts nodes as cells, so it is one + # step beyond the last node. `nodes` uses `nsteps_cells`. + # Do not change it. + return self.origin + self.nsteps * self.step_vector @property def nodes(self): @@ -88,7 +128,10 @@ def inside(self, pos: np.ndarray) -> np.ndarray: inside = np.ones(pos.shape[0]).astype(bool) for i in range(self.dim): inside *= pos[:, i] > self.origin[None, i] - inside *= pos[:, i] < self.origin[None, i] + self.step_vector[None, i] * self.nsteps_cells[None, i] + inside *= ( + pos[:, i] + < self.origin[None, i] + self.step_vector[None, i] * self.nsteps_cells[None, i] + ) return inside def check_position(self, pos: np.ndarray) -> np.ndarray: @@ -118,14 +161,20 @@ def neighbour_global_indexes(self, mask=None, **kwargs): if mask is None: mask = np.array([[-1, 0, 1, -1, 0, 1, -1, 0, 1], [1, 1, 1, 0, 0, 0, -1, -1, -1]]) neighbours = indexes[:, None, :] + mask[:, :, None] - return (neighbours[0, :, :] + self.nsteps[0, None, None] * neighbours[1, :, :]).astype(np.int64) + return (neighbours[0, :, :] + self.nsteps[0, None, None] * neighbours[1, :, :]).astype( + np.int64 + ) def cell_corner_indexes(self, cell_indexes: np.ndarray) -> np.ndarray: corner_indexes = np.zeros((cell_indexes.shape[0], 4, 2), dtype=np.int64) xcorner = np.array([0, 1, 0, 1]) ycorner = np.array([0, 0, 1, 1]) - corner_indexes[:, :, 0] = cell_indexes[:, None, 0] + corner_indexes[:, :, 0] + xcorner[None, :] - corner_indexes[:, :, 1] = cell_indexes[:, None, 1] + corner_indexes[:, :, 1] + ycorner[None, :] + corner_indexes[:, :, 0] = ( + cell_indexes[:, None, 0] + corner_indexes[:, :, 0] + xcorner[None, :] + ) + corner_indexes[:, :, 1] = ( + cell_indexes[:, None, 1] + corner_indexes[:, :, 1] + ycorner[None, :] + ) return corner_indexes def global_index_to_cell_index(self, global_index): diff --git a/packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py b/packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py index cf1c3514..30723952 100644 --- a/packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py +++ b/packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py @@ -1,4 +1,6 @@ -"""Pure 3D regular grid geometry: origin/nsteps/step_vector indexing.""" +"""Pure 3D regular grid geometry: origin/nsteps/step_vector indexing.""" + +from typing import Optional import numpy as np @@ -52,7 +54,8 @@ def set_nelements(self, nelements) -> int: ele_vol = box_vol / nelements step_vector = np.zeros(3) step_vector[:] = ele_vol ** (1.0 / 3.0) - nsteps = np.ceil((self.maximum - self.origin) / step_vector).astype(int) + # Keep the extent. Use at least 2 nodes on each axis. + nsteps = np.maximum(np.ceil((self.maximum - self.origin) / step_vector).astype(int), 2) self.nsteps = nsteps return self.n_elements @@ -70,9 +73,16 @@ def nsteps(self): @nsteps.setter def nsteps(self, nsteps): - change_factor = nsteps / self.nsteps - self._step_vector /= change_factor - self._nsteps = nsteps + nsteps = np.broadcast_to(np.asarray(nsteps), self._nsteps.shape) + if not np.all(np.isfinite(nsteps)) or np.any(nsteps != np.round(nsteps)): + raise ValueError("Cannot resize the grid. nsteps must be whole numbers.") + nsteps = nsteps.astype(int) + if np.any(nsteps < 2): + raise ValueError("Cannot resize the grid. nsteps must be at least 2.") + # Keep the extent: the new step is the extent divided by the new cell count. + extent = self.nsteps_cells * self._step_vector + self._step_vector = extent / (nsteps - 1) + self._nsteps = np.array(nsteps) self._on_geometry_change() @property @@ -97,13 +107,17 @@ def rotation_xy(self, rotation_xy): ) self._rotation_xy = validate_rigid_rotation(rotation_xy, 3) - def _cells_for_length(self, length: np.ndarray) -> np.ndarray: + def _cells_for_length( + self, length: np.ndarray, step_vector: Optional[np.ndarray] = None # noqa: FA100 + ) -> np.ndarray: """Return cell counts that cover the requested axis lengths. Parameters ---------- length : np.ndarray Requested support length along each grid axis. + step_vector : Optional[np.ndarray] + Step to use along each axis. The default is the current step. Returns ------- @@ -115,7 +129,9 @@ def _cells_for_length(self, length: np.ndarray) -> np.ndarray: ValueError If any requested cell count is negative. """ - cells = np.ceil(length / self.step_vector - 1e-9).astype(np.int64) + if step_vector is None: + step_vector = self.step_vector + cells = np.ceil(length / step_vector - 1e-9).astype(np.int64) cells[cells == 0] = 2 if np.any(cells < 0): logger.error( @@ -130,9 +146,11 @@ def step_vector(self): @step_vector.setter def step_vector(self, step_vector): - change_factor = step_vector / self._step_vector - newsteps = self._nsteps / change_factor - self._nsteps = np.ceil(newsteps).astype(int) + step_vector = np.array(step_vector, dtype=float) + # Keep the extent: the new cell count covers the old extent with the new step. + extent = self.nsteps_cells * self._step_vector + cells = self._cells_for_length(extent, step_vector=step_vector) + self._nsteps = cells + 1 self._step_vector = step_vector self._on_geometry_change() diff --git a/packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py b/packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py index 52e8abb1..1a251c45 100644 --- a/packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py +++ b/packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py @@ -16,6 +16,10 @@ logger = getLogger(__name__) +_RESIZE_MESSAGE = ( + "A rectilinear grid has fixed node arrays and cannot be resized. Build a new RectilinearGrid." +) + class RectilinearGrid(StructuredGrid): """A rectilinear (non-uniformly spaced) 3-D structured grid. @@ -78,6 +82,91 @@ def ynodes(self) -> np.ndarray: def znodes(self) -> np.ndarray: return self._znodes + # ------------------------------------------------------------------ + # Resize guards: the node arrays are fixed, so the setters refuse + # ------------------------------------------------------------------ + + @property + def origin(self) -> np.ndarray: + """Return the origin of the grid. + + Returns + ------- + np.ndarray + The origin, shape (3,). + """ + return self._origin + + @origin.setter + def origin(self, origin: np.ndarray) -> None: + """Refuse to move the origin. + + Parameters + ---------- + origin : np.ndarray + The requested origin. It is not used. + + Raises + ------ + NotImplementedError + Always. The node arrays are fixed. No state changes. + """ + raise NotImplementedError(_RESIZE_MESSAGE) + + @property + def nsteps(self) -> np.ndarray: + """Return the number of nodes per axis. + + Returns + ------- + np.ndarray + The node counts, shape (3,). + """ + return self._nsteps + + @nsteps.setter + def nsteps(self, nsteps: np.ndarray) -> None: + """Refuse to change the node counts. + + Parameters + ---------- + nsteps : np.ndarray + The requested node counts. It is not used. + + Raises + ------ + NotImplementedError + Always. The node arrays are fixed. No state changes. + """ + raise NotImplementedError(_RESIZE_MESSAGE) + + @property + def step_vector(self) -> np.ndarray: + """Return the step vector of the grid. + + Returns + ------- + np.ndarray + The step vector, shape (3,). + """ + return self._step_vector + + @step_vector.setter + def step_vector(self, step_vector: np.ndarray) -> None: + """Refuse to change the step vector. + + Parameters + ---------- + step_vector : np.ndarray + The requested step vector. It is not used. + + Raises + ------ + NotImplementedError + Always. The node arrays are fixed. No state changes. + """ + raise NotImplementedError(_RESIZE_MESSAGE) + def onGeometryChange(self): if self.interpolator is not None: self.interpolator.reset() diff --git a/packages/loop_interpolation/src/loop_interpolation/__init__.py b/packages/loop_interpolation/src/loop_interpolation/__init__.py index fac883f5..608bef59 100644 --- a/packages/loop_interpolation/src/loop_interpolation/__init__.py +++ b/packages/loop_interpolation/src/loop_interpolation/__init__.py @@ -13,6 +13,7 @@ "DirectionalRegularisation", "DiscreteFoldInterpolator", "DiscreteInterpolator", + "ExclusionRecord", "FDFoldInterpolator", "FiniteDifferenceInterpolator", "FoldEvent", @@ -31,12 +32,15 @@ "PiecewiseLinearInterpolator", "RegionCoverageDiagnostics", "RegularisationConfig", + "ResidualSummary", "StaleFrameError", "StructuredGrid", "StructuredGrid2D", "SurfeRBFInterpolator", "TetMesh", "UnStructuredTetMesh", + "build_exclusion_records", + "direction_cosines", "get_fold_rotation_profile", ] from loop_common.logging import get_logger as getLogger @@ -60,7 +64,11 @@ from ._diagnostics import ( ConstraintDiagnosticsReport, ConstraintFamilyDiagnostics, + ExclusionRecord, RegionCoverageDiagnostics, + ResidualSummary, + build_exclusion_records, + direction_cosines, ) from ._discrete_fold_interpolator import ( DiscreteFoldInterpolator, diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 122ed7cc..80f79725 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -32,6 +32,14 @@ "check": "scope pass; residual report test 9 passed; ruff pass", "commit": "0b4eef55d3e0896875495a3e783081b5a0cc6f84", "review": "pass (no findings; 2 optional edge notes: finite**2 overflow near 1e154, inf*0 warning in direction_cosines)" + }, + { + "wave": 34, + "base": "6c7d80ecf8dabe9871156893783afbb8baacb00e", + "tasks": "T074,T075,T076,T077", + "check": "scope pass; resize+rectilinear+2d tests 78 passed; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -72,6 +80,30 @@ "model": "sonnet", "result": "T071 tests corrected (fix worker); 9 passed" } + ], + "T074": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T075": [ + { + "model": "sonnet", + "result": "pass: 29 passed" + } + ], + "T076": [ + { + "model": "sonnet", + "result": "pass: 10+8+11 passed" + } + ], + "T077": [ + { + "model": "haiku", + "result": "pass" + } ] }, "findings": { @@ -1285,5 +1317,9 @@ }, "s5_notes2": { "T071_T073": "Four T071 tests asserted () or no record where the T073 rule (outside_model record per family with outside points; builder keys are not families) gives outside_model records or needs family dropped_rows 0. Tests corrected to assert no unattributed record; implementation follows the task text." + }, + "s5_notes3": { + "T076": "Builder lines _geological_feature_builder.py 520/544/604 assign support.maximum; 3D supports only (2D supports are not built by the feature builder). 2D maximum is now read-only.", + "T075": "Writers checked: none depends on the old product rule; set_interpolation_geometry loop still terminates." } } \ No newline at end of file diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index bd2cc437..ea1fca4b 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1235,14 +1235,14 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `build_exclusion_records(excluded_rows, families, rows_per_point=None) -> tuple[ExclusionRecord, ...]`, sorted by `(family, reason)`: one record per reason in `excluded_rows` (family = its key; subtract nothing); one `outside_model` record per family with `outside_model_point_count > 0`; one `unattributed` record per family with `dropped_rows` not `None` and `dropped_rows - outside_model_point_count * k > 0`, with `k = rows_per_point.get(family, 1)`; the count is the difference. Count 0 makes no record. An unknown reason gets `UNKNOWN_EXPLANATION`. Report class: append after `outside_model_points`: `physical_residuals: dict[str, ResidualSummary] = field(default_factory=dict)`, `assembled_residuals: dict[str, ResidualSummary] = field(default_factory=dict)`, `exclusions: tuple[ExclusionRecord, ...] = ()`. `to_dict` adds `"physical_residuals"`, `"assembled_residuals"` (dicts of the fields of each summary) and `"exclusions"` (list of dicts) only when not empty. `summary()` adds one line per non-empty block after the existing lines. `total_rows` and `active_families` do not change. numpy docstrings and type hints (ruff `D`, `ANN`); py39. -- [ ] T074 [P] [US4] [S] Export four new names from `loop_interpolation`. +- [x] T074 [P] [US4] [S] Export four new names from `loop_interpolation`. - Files: `packages/loop_interpolation/src/loop_interpolation/__init__.py` - Needs: T073 - Wave: 34 - Done when: `python -c "from loop_interpolation import ResidualSummary, ExclusionRecord, direction_cosines, build_exclusion_records"` prints no error. - Context: Read the file: it already imports from `._diagnostics` and has an `__all__`. Add `ResidualSummary`, `ExclusionRecord`, `direction_cosines` and `build_exclusion_records` to the same import and to `__all__`, in the style of the neighbours. Do NOT export `summarise_residuals`, `UNKNOWN_EXPLANATION` or `EXCLUSION_EXPLANATIONS`. Change no other line. -- [ ] T075 [P] [US4] [M] Keep the extent in the 3D `nsteps` and `step_vector` setters and in `set_nelements`. +- [x] T075 [P] [US4] [M] Keep the extent in the 3D `nsteps` and `step_vector` setters and in `set_nelements`. - Files: `packages/loop_common/src/loop_common/geometry/_structured_grid_3d.py` - Needs: T069, T072 - Wave: 34 @@ -1253,7 +1253,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `step_vector` setter: `step_vector = np.array(step_vector, dtype=float)`; `extent = self.nsteps_cells * self._step_vector`; `cells = self._cells_for_length(extent, step_vector=step_vector)`; `self._nsteps = cells + 1`; `self._step_vector = step_vector`; `self._on_geometry_change()`. Before the edit run `git grep -n "\.nsteps = \|\.step_vector = \|set_nelements"` and read each writer; report one that depends on the old product rule. Keep the `origin` and `maximum` setters of S1. Principle: change no public name. -- [ ] T076 [P] [US4] [M] Derive the 2D counts from `nsteps` and keep the legacy `maximum`. +- [x] T076 [P] [US4] [M] Derive the 2D counts from `nsteps` and keep the legacy `maximum`. - Files: `packages/loop_common/src/loop_common/geometry/_structured_grid_2d.py` - Needs: T069, T072 - Wave: 34 @@ -1262,7 +1262,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal Add: `nsteps` property returning `self._nsteps`; setter `self._nsteps = np.ceil(np.asarray(nsteps)).astype(int)`; read-only properties `nsteps_cells` (`self.nsteps - 1`), `n_cell_x` (`self.nsteps[0] - 1`), `n_cell_y` (`self.nsteps[1] - 1`); read-only `maximum` returning `self.origin + self.nsteps * self.step_vector`. Keep a comment on `maximum`: legacy value (FR-028); it counts nodes as cells, so it is one step beyond the last node; `nodes` uses `nsteps_cells`; do not change it. Keep the NumPy integer type of the old attributes. Use numpy docstrings and type hints. Rule: the four existing `maximum` assertions pass with no change (decision Q1). -- [ ] T077 [P] [US4] [S] Make the resize setters of `RectilinearGrid` raise. +- [x] T077 [P] [US4] [S] Make the resize setters of `RectilinearGrid` raise. - Files: `packages/loop_common/src/loop_common/supports/_3d_rectilinear_grid.py` - Needs: T069, T072 - Wave: 34 From 973bfcf3feab298a9fdb31e4b360bc98b1ba5441 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 19:53:00 +1030 Subject: [PATCH 10/28] docs(spec): record wave 34 review --- .../005-coordinate-frame-correctness/run-state.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 80f79725..5a35819e 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -38,8 +38,8 @@ "base": "6c7d80ecf8dabe9871156893783afbb8baacb00e", "tasks": "T074,T075,T076,T077", "check": "scope pass; resize+rectilinear+2d tests 78 passed; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "71a7948a4de89574ee28dded5b564e75970b4cf4", + "review": "pass (3 minor, open)" } ], "runs": { @@ -107,11 +107,15 @@ ] }, "findings": { - "found": 4, + "found": 7, "rejected": 0, "dropped": 0, "fixed": 3, - "open_minor": [] + "open_minor": [ + "T075 _structured_grid_3d.py has a UTF-8 BOM added at line 1 (remove the 3 bytes).", + "T076 _structured_grid_2d.py has format-only hunks in is_inside, neighbour_global_indexes, cell_corner_indexes (noise).", + "T075 set_nelements passes a cell count to the nsteps setter, which takes nodes: nelements=1000 on a unit cube gives 9^3=729 cells (task text and old behaviour; not a regression)." + ] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", "history": { From f22db8705d412a69c8f2cef4df67eededb444c91 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:18:04 +1030 Subject: [PATCH 11/28] test(005): review and regenerate the 3D structured support golden data for S5 --- .../data/structured_support_reference.npz | Bin 316525 -> 316236 bytes .../run-state.json | 14 ++ .../005-coordinate-frame-correctness/tasks.md | 2 +- .../verification.md | 130 ++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/loop_common/tests/data/structured_support_reference.npz b/packages/loop_common/tests/data/structured_support_reference.npz index f4330e176fb46cdc85d99568da9aebadfc0f8c91..3ecac675c6e92898a17feaea7da2cf837ef9441b 100644 GIT binary patch delta 111106 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

SHJCx$Y+{SaG=t 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 z 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 diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 5a35819e..f99a0829 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -40,6 +40,14 @@ "check": "scope pass; resize+rectilinear+2d tests 78 passed; ruff pass", "commit": "71a7948a4de89574ee28dded5b564e75970b4cf4", "review": "pass (3 minor, open)" + }, + { + "wave": 35, + "base": "b93c067182c78a0429e7934199eef890469e4bbf", + "tasks": "T078", + "check": "scope pass; golden tests 9 passed; survey: modelling 1540 passed, interpolation 615 passed, loop_common 406 passed, tests/unit rest 504 passed; 78 of 611 3D golden keys changed (causes C1 C2 C3); 2D golden unchanged; loop_interpolation golden unchanged", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -104,6 +112,12 @@ "model": "haiku", "result": "pass" } + ], + "T078": [ + { + "model": "sonnet", + "result": "pass" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index ea1fca4b..fb54e2c6 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1272,7 +1272,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal ## Phase 20: Stage S5 golden review and feature tests -- [ ] T078 [US4] [M] Survey the numeric tests, review the golden changes and regenerate the 3D golden data (C-004). +- [x] T078 [US4] [M] Survey the numeric tests, review the golden changes and regenerate the 3D golden data (C-004). - Files: `specs/005-coordinate-frame-correctness/verification.md`, `packages/loop_common/tests/data/structured_support_reference.npz`, `packages/loop_common/tests/data/structured_grid_2d_reference.npz` - Needs: T075, T076, T077 - Wave: 35 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index cf779b2b..bbf88771 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -734,3 +734,133 @@ The 2D `maximum` check fails only because the counts are stale after the set. Th Cell-centre file (`test_cell_centre_sampling.py`): 60 passed, 0 failed. Result: coverage, no defect. OP12 is not reproduced. Note on the one-to-one test. The first version of the test used `structured_grid(local_coordinates=True)` and failed in 8 of 12 cells (rotation 30 and 90). That grid is the axis-aligned enclosure of the projected corners, and it has a different step. The premise in the task text was wrong. The test now uses the midpoints of the exported world grid and the own cell grid of the box. `_bounding_box.py` was not changed. Also, `set_local_transform` takes the argument `rotation_matrix=`. + +## S5 golden review (C-004) + +Order (C-004): (1) run the 3D golden test and list the failing keys; (2) check each key against the rule; (3) write the keys here; (4) run `--update`; (5) run the test again. SC-015: no tolerance was changed (`rtol=1e-9`, `atol=1e-12` stay). + +Step 1. `test_structured_support_reference.py` failed in 3 cells (StructuredGrid, TetMesh, P2TetMesh). A key-by-key diff (script in the scratchpad, not in the repository) found 78 changed keys of 611. The key sets are equal. Each of the 3 classes has the same 26 changed keys: 3 grids (`unit`, `aniso`, `small`) x (`step_vector`, `to_dict.step_vector`, and `after_set_nelements`, `after_nsteps`, `after_step_vector`, `after_origin`, `after_maximum` `.step_vector`) plus `after_maximum.maximum` and `after_maximum.nsteps` where they differ (`unit` has no `nsteps` change, `aniso` and `small` do). The 2D golden (`test_structured_grid_2d_reference.py`) passes with no change (4 passed): the 2D `maximum` is the same, so no 2D key changed and `--update` was not run for it. + +Step 2. Rule: each setter keeps the extent or encloses it by less than one cell. Extent = `(nsteps - 1) x step_vector`. Checked on the `StructuredGrid` keys (the 3 classes give the same numbers): + +| Grid | Stage | Old extent | New extent | Result | +|---|---|---|---|---| +| unit | `set_nelements(200)` | 3.571 | 4.000 (start 4) | kept | +| unit | `nsteps` + 1 | 4.286 | 4.000 | kept | +| unit | `step_vector` x 2 | 4.286 | 4.000 | kept | +| unit | origin - 1 | max 4.714 | max 4.333 (1 cell = 1.333; enclosed by 0.333) | enclosed by less than one cell | +| unit | `maximum` + 3 (request 7.333) | 9.000 | 8.333 (over by 1.0, 1 cell = 1.333) | enclosed by less than one cell | +| aniso | `set_nelements(200)` | [12.1, 5.18, 6.29] | [13, 5, 6.5] (start [13, 5, 6.5]) | kept | +| aniso | `nsteps` + 1, `step_vector` x 2 | [12.8, 6.09, 7.14] | [13, 5, 6.5] | kept | +| aniso | origin - 1 | max [13.2, 6.91, 7.86] | max [13.5, 5.6, 7.0] (cells [1.5, 1.6, 1.5]) | enclosed by less than one cell | +| aniso | `maximum` + 3 (request [16.5, 8.6, 10]) | [17.4, 10.55, 11.29] | [16.5, 8.8, 10] | enclosed by less than one cell | +| small | `set_nelements(200)`, `nsteps` + 1, `step_vector` x 2 | 2.143, 2.571, 2.571 | 2.000 | kept | +| small | origin - 1 | max 3.286 | max 2.333 (1 cell = 0.667) | enclosed by less than one cell | +| small | `maximum` + 3 (request 5.333) | 6.714 | 5.667 (over by 0.333, 1 cell = 0.667) | enclosed by less than one cell | + +All new values obey the rule. The old values did not keep the extent (they drifted), and that is the S5 defect. + +Causes. + +- C1. `unit/step_vector` and `to_dict.step_vector` (and the same keys for `aniso`, `small`). Old value: the step after a later setter (for example 0.7143, not the constructor value 1.0). New value: 1.0, which is the constructor step. The old `nsteps` setter did `self._step_vector /= change_factor`, an in-place change. The golden test stores the array that the property returns, so a later setter changed the stored "start" value. The new setters give a new array. The new value is the correct start value. +- C2. `after_set_nelements`, `after_nsteps`, `after_step_vector`, `after_origin`, `after_maximum` `.step_vector`: the setters keep the extent, so the new step is the extent divided by the new cell count. +- C3. `after_maximum.maximum` and `after_maximum.nsteps`: the grid before this stage has a different step and `nsteps` (C2), so the encloses-the-request result differs. The result is enclosed by less than one cell. + +Step 3. The 78 changed keys (old value, new value, cause). + +| Key | Old value | New value | Cause | +|---|---|---|---| +| `P2TetMesh/aniso/after_maximum.maximum` | [17.4, 10.5455, 11.2857] | [16.5, 8.8, 10] | C3 (maximum request is enclosed by less than one cell) | +| `P2TetMesh/aniso/after_maximum.nsteps` | [7, 9, 7] | [6, 9, 7] | C3 (maximum request is enclosed by less than one cell) | +| `P2TetMesh/aniso/after_maximum.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/aniso/after_nsteps.step_vector` | [0.7, 0.909091, 0.857143] | [0.75, 0.8, 0.75] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/aniso/after_origin.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/aniso/after_set_nelements.step_vector` | [0.7, 0.909091, 0.857143] | [1, 0.888889, 0.9] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/aniso/after_step_vector.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/aniso/step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `P2TetMesh/aniso/to_dict.step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `P2TetMesh/small/after_maximum.maximum` | [6.71429, 6.71429, 6.71429] | [5.66667, 5.66667, 5.66667] | C3 (maximum request is enclosed by less than one cell) | +| `P2TetMesh/small/after_maximum.nsteps` | [10, 10, 10] | [11, 11, 11] | C3 (maximum request is enclosed by less than one cell) | +| `P2TetMesh/small/after_maximum.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/small/after_nsteps.step_vector` | [0.428571, 0.428571, 0.428571] | [0.333333, 0.333333, 0.333333] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/small/after_origin.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/small/after_set_nelements.step_vector` | [0.428571, 0.428571, 0.428571] | [0.4, 0.4, 0.4] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/small/after_step_vector.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/small/step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `P2TetMesh/small/to_dict.step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `P2TetMesh/unit/after_maximum.maximum` | [9, 9, 9] | [8.33333, 8.33333, 8.33333] | C3 (maximum request is enclosed by less than one cell) | +| `P2TetMesh/unit/after_maximum.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/unit/after_nsteps.step_vector` | [0.714286, 0.714286, 0.714286] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/unit/after_origin.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/unit/after_set_nelements.step_vector` | [0.714286, 0.714286, 0.714286] | [0.8, 0.8, 0.8] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/unit/after_step_vector.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `P2TetMesh/unit/step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `P2TetMesh/unit/to_dict.step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/aniso/after_maximum.maximum` | [17.4, 10.5455, 11.2857] | [16.5, 8.8, 10] | C3 (maximum request is enclosed by less than one cell) | +| `StructuredGrid/aniso/after_maximum.nsteps` | [7, 9, 7] | [6, 9, 7] | C3 (maximum request is enclosed by less than one cell) | +| `StructuredGrid/aniso/after_maximum.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/aniso/after_nsteps.step_vector` | [0.7, 0.909091, 0.857143] | [0.75, 0.8, 0.75] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/aniso/after_origin.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/aniso/after_set_nelements.step_vector` | [0.7, 0.909091, 0.857143] | [1, 0.888889, 0.9] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/aniso/after_step_vector.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/aniso/step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/aniso/to_dict.step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/small/after_maximum.maximum` | [6.71429, 6.71429, 6.71429] | [5.66667, 5.66667, 5.66667] | C3 (maximum request is enclosed by less than one cell) | +| `StructuredGrid/small/after_maximum.nsteps` | [10, 10, 10] | [11, 11, 11] | C3 (maximum request is enclosed by less than one cell) | +| `StructuredGrid/small/after_maximum.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/small/after_nsteps.step_vector` | [0.428571, 0.428571, 0.428571] | [0.333333, 0.333333, 0.333333] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/small/after_origin.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/small/after_set_nelements.step_vector` | [0.428571, 0.428571, 0.428571] | [0.4, 0.4, 0.4] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/small/after_step_vector.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/small/step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/small/to_dict.step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/unit/after_maximum.maximum` | [9, 9, 9] | [8.33333, 8.33333, 8.33333] | C3 (maximum request is enclosed by less than one cell) | +| `StructuredGrid/unit/after_maximum.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/unit/after_nsteps.step_vector` | [0.714286, 0.714286, 0.714286] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/unit/after_origin.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/unit/after_set_nelements.step_vector` | [0.714286, 0.714286, 0.714286] | [0.8, 0.8, 0.8] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/unit/after_step_vector.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `StructuredGrid/unit/step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `StructuredGrid/unit/to_dict.step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `TetMesh/aniso/after_maximum.maximum` | [17.4, 10.5455, 11.2857] | [16.5, 8.8, 10] | C3 (maximum request is enclosed by less than one cell) | +| `TetMesh/aniso/after_maximum.nsteps` | [7, 9, 7] | [6, 9, 7] | C3 (maximum request is enclosed by less than one cell) | +| `TetMesh/aniso/after_maximum.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/aniso/after_nsteps.step_vector` | [0.7, 0.909091, 0.857143] | [0.75, 0.8, 0.75] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/aniso/after_origin.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/aniso/after_set_nelements.step_vector` | [0.7, 0.909091, 0.857143] | [1, 0.888889, 0.9] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/aniso/after_step_vector.step_vector` | [1.4, 1.81818, 1.71429] | [1.5, 1.6, 1.5] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/aniso/step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `TetMesh/aniso/to_dict.step_vector` | [0.7, 0.909091, 0.857143] | [0.5, 2, 1.5] | C1 (old array was changed in place by a later setter) | +| `TetMesh/small/after_maximum.maximum` | [6.71429, 6.71429, 6.71429] | [5.66667, 5.66667, 5.66667] | C3 (maximum request is enclosed by less than one cell) | +| `TetMesh/small/after_maximum.nsteps` | [10, 10, 10] | [11, 11, 11] | C3 (maximum request is enclosed by less than one cell) | +| `TetMesh/small/after_maximum.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/small/after_nsteps.step_vector` | [0.428571, 0.428571, 0.428571] | [0.333333, 0.333333, 0.333333] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/small/after_origin.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/small/after_set_nelements.step_vector` | [0.428571, 0.428571, 0.428571] | [0.4, 0.4, 0.4] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/small/after_step_vector.step_vector` | [0.857143, 0.857143, 0.857143] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/small/step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `TetMesh/small/to_dict.step_vector` | [0.428571, 0.428571, 0.428571] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `TetMesh/unit/after_maximum.maximum` | [9, 9, 9] | [8.33333, 8.33333, 8.33333] | C3 (maximum request is enclosed by less than one cell) | +| `TetMesh/unit/after_maximum.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/unit/after_nsteps.step_vector` | [0.714286, 0.714286, 0.714286] | [0.666667, 0.666667, 0.666667] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/unit/after_origin.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/unit/after_set_nelements.step_vector` | [0.714286, 0.714286, 0.714286] | [0.8, 0.8, 0.8] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/unit/after_step_vector.step_vector` | [1.42857, 1.42857, 1.42857] | [1.33333, 1.33333, 1.33333] | C2 (resize keeps the extent: new step = extent / cells) | +| `TetMesh/unit/step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | +| `TetMesh/unit/to_dict.step_vector` | [0.714286, 0.714286, 0.714286] | [1, 1, 1] | C1 (old array was changed in place by a later setter) | + + +Step 4. Golden update and re-run. `python packages/loop_common/tests/test_structured_support_reference.py --update` wrote 611 arrays. Then `pytest packages/loop_common/tests/test_structured_support_reference.py packages/loop_common/tests/test_structured_grid_2d_reference.py` passed (9 passed). The file `structured_grid_2d_reference.npz` did not change. + +### Numeric survey (S5 resize change) + +Environment: the worktree, `loop_common`, `loop_interpolation` and the visualisation sources, and `pyevtk`, on `PYTHONPATH`. Run after the golden update, one pytest process at a time. + +| Command | Result | Time | +|---|---|---| +| `pytest tests/unit/modelling` | 1540 passed, 2 skipped | 457.78 s | +| `pytest packages/loop_interpolation/tests` | 615 passed, 47 skipped, 2 xfailed | 70.95 s | +| `pytest packages/loop_common/tests` | 406 passed | 69.60 s | +| `pytest tests/unit --ignore=tests/unit/modelling` | 504 passed, 2 skipped | 677.84 s | + +No test failed and no test number changed, so no test file needs a change. The only changed numbers are the 78 golden keys above (file `packages/loop_common/tests/data/structured_support_reference.npz`). Nothing under `packages/loop_interpolation/tests/golden` changed (the 615 tests there pass without a regeneration). No failure is left, and so no root failure from the S4 gate was seen in these four runs. From 4467f01821ef3431c4ce2b648e6bb5de6738e24d Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:21:28 +1030 Subject: [PATCH 12/28] docs(spec): record wave 35 review --- specs/005-coordinate-frame-correctness/run-state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index f99a0829..11626965 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -46,8 +46,8 @@ "base": "b93c067182c78a0429e7934199eef890469e4bbf", "tasks": "T078", "check": "scope pass; golden tests 9 passed; survey: modelling 1540 passed, interpolation 615 passed, loop_common 406 passed, tests/unit rest 504 passed; 78 of 611 3D golden keys changed (causes C1 C2 C3); 2D golden unchanged; loop_interpolation golden unchanged", - "commit": "pending", - "review": "pending" + "commit": "09d2e7b6bc82fcef0d813f45020015c171b12d23", + "review": "pass (no findings; reviewer confirmed 78 keys, causes C1 C2 C3)" } ], "runs": { From 0d699ab78e95df7e53791cf14fa6e69b91cfef76 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:31:26 +1030 Subject: [PATCH 13/28] test(005): add S5 misfit diagnostics tests and record resize results --- .../run-state.json | 23 ++ .../005-coordinate-frame-correctness/tasks.md | 4 +- .../verification.md | 42 +++ .../unit/modelling/test_diagnostics_frames.py | 249 ++++++++++++++++++ 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 tests/unit/modelling/test_diagnostics_frames.py diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 11626965..48648b82 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -48,6 +48,14 @@ "check": "scope pass; golden tests 9 passed; survey: modelling 1540 passed, interpolation 615 passed, loop_common 406 passed, tests/unit rest 504 passed; 78 of 611 3D golden keys changed (causes C1 C2 C3); 2D golden unchanged; loop_interpolation golden unchanged", "commit": "09d2e7b6bc82fcef0d813f45020015c171b12d23", "review": "pass (no findings; reviewer confirmed 78 keys, causes C1 C2 C3)" + }, + { + "wave": 36, + "base": "8dff4ec4ce03031efb2e79e5a3b0322d1e8a7e92", + "tasks": "T079,T080", + "check": "scope pass; resize+cell-centre 100 passed; T080 127 failed pre-fix as expected; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -118,6 +126,18 @@ "model": "sonnet", "result": "pass" } + ], + "T079": [ + { + "model": "sonnet", + "result": "pass" + } + ], + "T080": [ + { + "model": "sonnet", + "result": "pass: 127 failing cells pre-fix; tol_solved 9e-4 (measured max 4.414e-4)" + } ] }, "findings": { @@ -1339,5 +1359,8 @@ "s5_notes3": { "T076": "Builder lines _geological_feature_builder.py 520/544/604 assign support.maximum; 3D supports only (2D supports are not built by the feature builder). 2D maximum is now read-only.", "T075": "Writers checked: none depends on the old product rule; set_interpolation_geometry loop still terminates." + }, + "s5_notes4": { + "T080": "Test new_misfit_methods_leaves_state_unchanged name spelled with leaves so the -k filter matches. Contract: evaluate_gradient_cosines is flat (gradient rows then normal rows); old misfit ravel equals it; new method must call self.evaluate_gradient. TOL_SOLVED=9e-4." } } \ No newline at end of file diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index fb54e2c6..e50e7d45 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1281,7 +1281,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal Run the 2D golden test. It must not change (the 2D `maximum` stays). Run `--update` for it only if a key differs, and record the key. Numeric survey, in separate calls: `pytest tests/unit/modelling`; `pytest packages/loop_interpolation/tests`; `pytest packages/loop_common/tests` (PowerShell; worktree on `PYTHONPATH`; `packages/loopstructural_visualisation/src` also on `PYTHONPATH`; `pyevtk` is only in a scratch folder: add it to `PYTHONPATH` or report the skip). For each test that fails or changes a number, record the test, the old value, the new value and the cause. Do not edit a test file. `packages/loop_interpolation/tests/golden` is the parity oracle (OP17): record each change there and do NOT regenerate it. If the cause is the intended extent change, name the file for the maintainer; if the cause is not clear, report it. Write in ASD-STE100 Simplified Technical English. -- [ ] T079 [P] [US4] [M] Record the S5 post-fix evidence for the resize and cell-centre tests. +- [x] T079 [P] [US4] [M] Record the S5 post-fix evidence for the resize and cell-centre tests. - Files: `specs/005-coordinate-frame-correctness/verification.md` - Needs: T078 - Wave: 36 @@ -1289,7 +1289,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Context: Run the two files in one call. Record the commands, the counts (resize: 12 + 3 + 3 + 3 + 1 + 1 + 3 + 1 + 6 + 3 = 36 cells; cell centres: 5 tests x 12 = 60 cells), the times, and the measured extent drift of the A, B, A test for each grid (the bound is `A + B`). Compare with `## S5 pre-fix failures`: for each id that failed, write that it now passes. Write that `test_each_cell_has_exactly_one_centre` and `test_local_coordinates_grid_associates_own_centres` are the evidence for FR-015 and SC-011, and the other three are coverage. Record OP12: "not reproduced" if all 60 cells passed before the fix, and no change to `_bounding_box.py`. Record the `ruff check LoopStructural packages` result for the three `loop_common` source files. Write in ASD-STE100 Simplified Technical English. -- [ ] T080 [US4] [M] Test: misfit methods keep state, compare in world coordinates, and keep their shapes (U4.4; FR-023; SC-009). +- [x] T080 [US4] [M] Test: misfit methods keep state, compare in world coordinates, and keep their shapes (U4.4; FR-023; SC-009). - Files: `tests/unit/modelling/test_diagnostics_frames.py` - Needs: T073, T078 - Wave: 36 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index bbf88771..c86545d9 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -864,3 +864,45 @@ Environment: the worktree, `loop_common`, `loop_interpolation` and the visualisa | `pytest tests/unit --ignore=tests/unit/modelling` | 504 passed, 2 skipped | 677.84 s | No test failed and no test number changed, so no test file needs a change. The only changed numbers are the 78 golden keys above (file `packages/loop_common/tests/data/structured_support_reference.npz`). Nothing under `packages/loop_interpolation/tests/golden` changed (the 615 tests there pass without a regeneration). No failure is left, and so no root failure from the S4 gate was seen in these four runs. + + +## S5 resize and cell-centre results + +Commands (PowerShell, the worktree `LoopStructural-005-s5` on `PYTHONPATH`): + +- `python -m pytest packages/loop_common/tests/test_resize_paths.py packages/loop_common/tests/test_cell_centre_sampling.py -p no:cacheprovider -q --durations=0` -> 100 passed in 0.19 s. The slowest item took 0.01 s (setup). +- `python -m pytest packages/loop_common/tests/test_resize_paths.py -p no:cacheprovider -q -s -k a_b_a` -> 3 passed (shows the measured drift). +- `python -m pytest packages/loop_common/tests/test_cell_centre_sampling.py -p no:cacheprovider -q` -> 60 passed in 0.12 s. +- `python -m ruff check LoopStructural packages` -> All checks passed. This includes the three `loop_common` source files. + +Counts, as pytest reports them. Resize file: 40 cells (12 + 3 + 3 + 3 + 1 + 1 + 3 + 1 + 10 + 3). The task text said 36. The count differs because `test_2d_counts_are_derived` has 10 cells (2 grids x 5 checks), not 6. Cell-centre file: 60 cells (5 tests x 12). Total 100, which equals the 24 failed + 76 passed of `## S5 pre-fix failures`. + +Comparison with `## S5 pre-fix failures`, test by test. All 24 failed cells now pass. + +| Test function | Before | Now | +|---|---|---| +| `test_nsteps_setter_keeps_extent` | 3 / 3 failed | 3 / 3 pass | +| `test_step_vector_setter_encloses_extent` | 2 / 3 failed (`[G1]`, `[G3]`) | 3 / 3 pass, `[G1]` and `[G3]` now pass | +| `test_nsteps_below_two_is_rejected_without_change` | 1 / 1 failed | 1 / 1 pass | +| `test_nsteps_not_whole_is_rejected_without_change` | 1 / 1 failed | 1 / 1 pass | +| `test_set_nelements_keeps_extent` | 3 / 3 failed | 3 / 3 pass | +| `test_set_nelements_small_request_is_clamped` | 1 / 1 failed | 1 / 1 pass | +| `test_2d_counts_are_derived` | 10 / 10 failed | 10 / 10 pass | +| `test_rectilinear_resize_is_rejected` | 3 / 3 failed | 3 / 3 pass | +| `test_reapplying_same_value_10_times_changes_nothing_3d` | 12 / 12 passed | 12 / 12 pass (coverage) | +| `test_step_vector_a_b_a_extent_drift` | 3 / 3 passed | 3 / 3 pass (coverage) | +| Cell-centre file, 5 tests | 60 / 60 passed | 60 / 60 pass | + +Measured extent drift of the A-B-A test (`step_vector` set to A, then B = 1.5 A, then A). The bound is A + B. + +| Grid | Measured drift | Bound (A + B) | +|---|---|---| +| G1 | [1, 2, 0] | [2.5, 5, 7.5] | +| G2 | [0.5, 0.5, 0.5] | [1.25, 1.25, 1.25] | +| G3 | [10, 100, 7.5] | [25, 250, 18.75] | + +All drifts are inside the bound. The pre-fix drift was G1 [1, 0, 3], G2 [0.5, 0.5, 0.5], G3 [0, 0, 7.5], also inside the bound. The pre-fix and post-fix values differ because the setters now keep the extent and enclose it by less than one cell. + +Evidence for FR-015 and SC-011. `test_centres_are_cell_midpoints_in_world` is the evidence. Each cell of the exported grid (`structured_grid()`) has one centre, as the mean of its 8 corner nodes, and the centres are all different. `test_local_coordinates_grid_associates_own_centres` is a containment check: each cell centre falls inside the `structured_grid(local_coordinates=True)` grid (no -1 from `cell_index_at`). That grid is the axis-aligned enclosure, so a one-to-one count against it does not hold, and the test does not claim it. `test_centres_round_trip_and_index_own_cells` is a consistency check on the cell grid of the box itself, built from the same linspace as the centres. It is not evidence for FR-015 or SC-011. `test_centres_match_exported_cells` and `test_centres_inside_box` are coverage. + +OP12: not reproduced. All 60 cell-centre cells passed before the fix and pass now. `_bounding_box.py` was not changed. \ No newline at end of file diff --git a/tests/unit/modelling/test_diagnostics_frames.py b/tests/unit/modelling/test_diagnostics_frames.py new file mode 100644 index 00000000..e718d4db --- /dev/null +++ b/tests/unit/modelling/test_diagnostics_frames.py @@ -0,0 +1,249 @@ +"""Frame checks for the misfit methods of a geological feature (U4.4; FR-023; SC-009). + +The data of the foliation are world coordinates. The interpolator stores them in +its local frame. The misfit methods must compare world vectors with world +vectors, and they must not change any state of the model. +""" + +import copy +import itertools +import warnings + +import numpy as np +import pandas as pd +import pytest + +from tests.fixtures.coordinate_frames import ( + ANCHORS, + R1_GRADIENT, + ROTATIONS_DEG, + model_with_frame, + world_foliation_model, +) + +ENGINES = ("FDI", "PLI") +R1_UNIT = R1_GRADIENT / np.linalg.norm(R1_GRADIENT) +# Bound of |1 - cosine| for the real solved model. The independent measurement +# over the 24 cells gave a maximum of 4.41e-4. The bound is 2 times this value +# (8.8e-4), rounded up to one significant digit. +TOL_SOLVED = 9e-4 + + +def _cell_id(cell): + anchor, rotation, engine = cell + index = int(np.flatnonzero(np.all(ANCHORS == anchor, axis=1))[0]) + return f"a{index}-r{int(rotation)}-{engine}" + + +CELLS = [ + (ANCHORS[i], rotation, engine) + for i, rotation, engine in itertools.product(range(len(ANCHORS)), ROTATIONS_DEG, ENGINES) +] +CELL_IDS = [_cell_id(cell) for cell in CELLS] +VALUE_CELLS = [ + (ANCHORS[i], rotation, "FDI") + for i, rotation in itertools.product(range(len(ANCHORS)), ROTATIONS_DEG) +] +VALUE_IDS = [_cell_id(cell) for cell in VALUE_CELLS] + +# Row ranges of the data frame of the fixture. +VALUE_ROWS = slice(0, 27) +GRADIENT_ROWS = slice(27, 31) +NORMAL_ROWS = slice(31, 35) + + +def _build(anchor, rotation, engine="FDI", select=None): + """Return a solved model, its foliation and the world data frame.""" + model, feature, data = world_foliation_model(anchor, rotation) + if engine != "FDI" or select is not None: + if select is not None: + data = select(data) + model = model_with_frame(anchor, anchor + (8, 10, 12), rotation) + model.data = data + feature = model.create_and_add_foliation("r1", interpolatortype=engine, nelements=1000) + feature.builder.up_to_date() + assert feature.interpolator.up_to_date + return model, feature, data + + +def _snapshot(feature): + """Return deep copies of the state that a misfit method must not change.""" + interpolator = feature.interpolator + return { + "data": copy.deepcopy(interpolator.data), + "c": copy.deepcopy(interpolator.c), + "up_to_date": interpolator.up_to_date, + "builder_up_to_date": feature.builder._up_to_date, + "weights": { + name: copy.deepcopy(constraint["w"]) + for name, constraint in interpolator.constraints.items() + }, + "report_id": id(interpolator.latest_diagnostics_report), + } + + +def _assert_same_state(before, after): + assert before["data"].keys() == after["data"].keys() + for key, array in before["data"].items(): + assert np.array_equal(array, after["data"][key]), f"data[{key!r}] changed" + assert np.array_equal(before["c"], after["c"]), "c changed" + assert before["up_to_date"] == after["up_to_date"] + assert before["builder_up_to_date"] == after["builder_up_to_date"] + assert before["weights"].keys() == after["weights"].keys() + for name, weights in before["weights"].items(): + assert np.array_equal(weights, after["weights"][name]), f"weights of {name} changed" + assert before["report_id"] == after["report_id"] + + +def _independent_cosines(feature): + """Return the world cosines of the stored rows, with no change to the state. + + The function copies the stored rows and uses only the public frame methods. + """ + interpolator = feature.interpolator + cosines = [] + for key in ("gradient", "normal"): + rows = np.array(interpolator.data[key], dtype=float, copy=True) + if rows.shape[0] == 0: + continue + positions = interpolator.local_to_world_points(rows[:, :3]) + vectors = interpolator.local_to_world_vectors(rows[:, 3:6]) + model_vectors = feature.evaluate_gradient(positions) + dots = np.einsum("ij,ij->i", model_vectors, vectors) + dots /= np.linalg.norm(model_vectors, axis=1) * np.linalg.norm(vectors, axis=1) + cosines.append(dots) + return np.concatenate(cosines) + + +def _world_value_rows(feature, data): + """Return the world points and values of the value rows.""" + rows = data.iloc[VALUE_ROWS] + return rows[["X", "Y", "Z"]].to_numpy(float), rows["val"].to_numpy(float) + + +@pytest.mark.parametrize("cell", CELLS, ids=CELL_IDS) +def test_misfit_leaves_state_unchanged(cell): + """The old misfit methods change no array, flag, weight or report.""" + anchor, rotation, engine = cell + _, feature, _ = _build(anchor, rotation, engine) + before = _snapshot(feature) + feature.evaluate_gradient_misfit() + feature.evaluate_value_misfit() + _assert_same_state(before, _snapshot(feature)) + + +@pytest.mark.parametrize("cell", CELLS, ids=CELL_IDS) +def test_new_misfit_methods_leaves_state_unchanged(cell): + """The new misfit methods and the report change no array, flag, weight or report.""" + anchor, rotation, engine = cell + _, feature, _ = _build(anchor, rotation, engine) + before = _snapshot(feature) + feature.evaluate_gradient_cosines() + feature.evaluate_value_residuals() + feature.verification_report() + _assert_same_state(before, _snapshot(feature)) + + +@pytest.mark.parametrize("cell", CELLS, ids=CELL_IDS) +def test_gradient_cosine_matches_analytic_direction_exact_field(cell, monkeypatch): + """With an exact field the world cosine of each stored row is 1.""" + anchor, rotation, engine = cell + _, feature, _ = _build(anchor, rotation, engine) + + def analytic_gradient(points, *args, **kwargs): + return np.tile(R1_UNIT, (len(np.atleast_2d(points)), 1)) + + monkeypatch.setattr(feature, "evaluate_gradient", analytic_gradient) + old = np.asarray(feature.evaluate_gradient_misfit(), dtype=float) + assert old.shape == (2, 4) + assert np.all(np.abs(1.0 - old) <= 1e-10) + new = feature.evaluate_gradient_cosines() + assert new.shape == (8,) + assert np.all(np.abs(1.0 - new) <= 1e-10) + + +@pytest.mark.parametrize("cell", CELLS, ids=CELL_IDS) +def test_gradient_cosine_matches_analytic_direction_exact_field_solved_model(cell): + """The real solved field has a world cosine near 1 at each stored row.""" + anchor, rotation, engine = cell + _, feature, _ = _build(anchor, rotation, engine) + independent = _independent_cosines(feature) + print(f"max |1 - cos| independent {_cell_id(cell)}: {np.max(np.abs(1.0 - independent)):.3e}") + new = feature.evaluate_gradient_cosines() + assert new.shape == (8,) + assert np.max(np.abs(1.0 - new)) <= TOL_SOLVED + + +@pytest.mark.parametrize("cell", VALUE_CELLS, ids=VALUE_IDS) +def test_value_residuals_are_absolute_difference(cell): + """The residual is the absolute difference at the 27 value rows.""" + anchor, rotation, engine = cell + _, feature, data = _build(anchor, rotation, engine) + points, values = _world_value_rows(feature, data) + expected = np.abs(values - feature.evaluate_value(points)) + residuals = feature.evaluate_value_residuals() + assert residuals.shape == (27,) + assert not np.any(np.isnan(residuals)) + np.testing.assert_allclose(residuals, expected, rtol=0, atol=1e-8) + + +@pytest.mark.parametrize("cell", VALUE_CELLS, ids=VALUE_IDS) +def test_value_misfit_is_normalised_by_field_range(cell): + """The old value misfit is the residual divided by the field range.""" + anchor, rotation, engine = cell + _, feature, _ = _build(anchor, rotation, engine) + expected = feature.evaluate_value_residuals() / (feature.max() - feature.min()) + np.testing.assert_allclose(feature.evaluate_value_misfit(), expected, rtol=0, atol=1e-12) + + +@pytest.mark.parametrize("mode", ["equal", "nan"]) +def test_value_misfit_with_zero_range_does_not_divide(mode, monkeypatch): + """A zero or NaN field range gives the unnormalised difference, with no warning.""" + _, feature, data = _build(ANCHORS[1], 30.0, "FDI") + points, values = _world_value_rows(feature, data) + expected = np.abs(values - feature.evaluate_value(points)) + low, high = (2.0, 2.0) if mode == "equal" else (np.nan, np.nan) + monkeypatch.setattr(feature, "min", lambda: low) + monkeypatch.setattr(feature, "max", lambda: high) + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = feature.evaluate_value_misfit() + assert not np.any(np.isnan(result)) + np.testing.assert_allclose(result, expected, rtol=0, atol=1e-8) + + +def test_misfit_with_no_rows_is_empty(): + """A model with no gradient rows or no value rows gives empty arrays.""" + _, feature, _ = _build(ANCHORS[1], 30.0, "FDI", select=lambda d: d.iloc[VALUE_ROWS]) + cosines = feature.evaluate_gradient_cosines() + assert cosines.shape == (0,) + _, feature, _ = _build( + ANCHORS[1], 30.0, "FDI", select=lambda d: d.iloc[27:35].reset_index(drop=True) + ) + residuals = feature.evaluate_value_residuals() + assert residuals.shape == (0,) + + +def _rows(gradient, normal): + """Return a selector for the value rows and the first rows of each family.""" + + def select(data): + parts = [data.iloc[VALUE_ROWS]] + parts.append(data.iloc[GRADIENT_ROWS].iloc[:gradient]) + parts.append(data.iloc[NORMAL_ROWS].iloc[:normal]) + return pd.concat(parts, ignore_index=True) + + return select + + +@pytest.mark.parametrize( + ("gradient", "normal", "shape"), + [(4, 0, (1, 4)), (0, 4, (1, 4)), (4, 4, (2, 4)), (4, 2, (1, 6))], + ids=["gradient-only", "normal-only", "equal-families", "different-families"], +) +def test_old_misfit_shapes_are_kept(gradient, normal, shape): + """The old method keeps its shapes and holds the values of the new method.""" + _, feature, _ = _build(ANCHORS[1], 30.0, "FDI", select=_rows(gradient, normal)) + old = np.asarray(feature.evaluate_gradient_misfit(), dtype=float) + assert old.shape == shape + np.testing.assert_array_equal(old.ravel(), feature.evaluate_gradient_cosines()) From de64d5557930d15f4a592aa08c58ca35b3e2332b Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:35:00 +1030 Subject: [PATCH 14/28] docs(spec): record wave 36 review --- specs/005-coordinate-frame-correctness/run-state.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 48648b82..3b2f43c4 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -54,8 +54,8 @@ "base": "8dff4ec4ce03031efb2e79e5a3b0322d1e8a7e92", "tasks": "T079,T080", "check": "scope pass; resize+cell-centre 100 passed; T080 127 failed pre-fix as expected; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "a69408bb24ad80e377edbc95472ff3a8f6c6a971", + "review": "pass (3 minor, open)" } ], "runs": { @@ -141,14 +141,17 @@ ] }, "findings": { - "found": 7, + "found": 10, "rejected": 0, "dropped": 0, "fixed": 3, "open_minor": [ "T075 _structured_grid_3d.py has a UTF-8 BOM added at line 1 (remove the 3 bytes).", "T076 _structured_grid_2d.py has format-only hunks in is_inside, neighbour_global_indexes, cell_corner_indexes (noise).", - "T075 set_nelements passes a cell count to the nsteps setter, which takes nodes: nelements=1000 on a unit cube gives 9^3=729 cells (task text and old behaviour; not a regression)." + "T075 set_nelements passes a cell count to the nsteps setter, which takes nodes: nelements=1000 on a unit cube gives 9^3=729 cells (task text and old behaviour; not a regression).", + "T079 section of verification.md does not say that test_each_cell_has_exactly_one_centre was replaced, and calls the midpoint test evidence although it is a guard (OP12 not reproduced). T088 adds one sentence.", + "T080 exact-field stub ignores its points argument, so it cannot catch a wrong position frame (other tests catch it).", + "T080 one TOL_SOLVED=9e-4 for both engines (FDI max 1.2e-4 is looser)." ] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", From cb691b14a3af705535a98ba64c52b2819f1a74e7 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:41:28 +1030 Subject: [PATCH 15/28] test(005): add S5 stale model and report block tests --- .../run-state.json | 17 ++ .../005-coordinate-frame-correctness/tasks.md | 2 +- .../unit/modelling/test_diagnostics_frames.py | 227 ++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 3b2f43c4..64f4bac7 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -56,6 +56,14 @@ "check": "scope pass; resize+cell-centre 100 passed; T080 127 failed pre-fix as expected; ruff pass", "commit": "a69408bb24ad80e377edbc95472ff3a8f6c6a971", "review": "pass (3 minor, open)" + }, + { + "wave": 37, + "base": "223c912bb05aab1ec23ca884fe8b19987ff53b3d", + "tasks": "T081", + "check": "scope pass; 20 new cells fail pre-fix as expected; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -138,6 +146,12 @@ "model": "sonnet", "result": "pass: 127 failing cells pre-fix; tol_solved 9e-4 (measured max 4.414e-4)" } + ], + "T081": [ + { + "model": "sonnet", + "result": "pass: 20 failing cells pre-fix; PLI gradient unattributed 8 (k=2), outside_model 3, dropped_rows 14" + } ] }, "findings": { @@ -1365,5 +1379,8 @@ }, "s5_notes4": { "T080": "Test new_misfit_methods_leaves_state_unchanged name spelled with leaves so the -k filter matches. Contract: evaluate_gradient_cosines is flat (gradient rows then normal rows); old misfit ravel equals it; new method must call self.evaluate_gradient. TOL_SOLVED=9e-4." + }, + "s5_notes5": { + "T081": "PLI measured: 7 gradient rows stored (4 inside, 3 outside), builder dropped_rows=14, outside_model=3, unattributed=8 with rows_per_point gradient 2. FDI dropped_rows=6; needs rows_per_point={gradient:2} (else unattributed 3). Warning text must contain the word interpolator or builder." } } \ No newline at end of file diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index e50e7d45..81a8512f 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1300,7 +1300,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_value_residuals_are_absolute_difference` (12): equals `abs(value - evaluate_value(world_points))` at the 27 value rows; no NaN. `test_value_misfit_is_normalised_by_field_range` (12): equals `evaluate_value_residuals() / (feature.max() - feature.min())` within `1e-12`. `test_value_misfit_with_zero_range_does_not_divide` (2): `monkeypatch` makes `feature.max` and `feature.min` return the same number (cell 1) or NaN (cell 2); `warnings.simplefilter("error")`; the result equals the unnormalised absolute difference. `test_misfit_with_no_rows_is_empty` (2): no gradient rows gives shape `(0,)`; no value rows gives shape `(0,)`. `test_old_misfit_shapes_are_kept` (4): one family `(1, N)`; two families of equal length `(2, N)`; two of different length `(1, N1 + N2)` (OP16, default); values equal `evaluate_gradient_cosines()`. -- [ ] T081 [US4] [M] Test: a stale model is not rebuilt, and the report keeps residuals and exclusions apart (U4.4, U4.5; FR-023, FR-024; SC-009). +- [x] T081 [US4] [M] Test: a stale model is not rebuilt, and the report keeps residuals and exclusions apart (U4.4, U4.5; FR-023, FR-024; SC-009). - Files: `tests/unit/modelling/test_diagnostics_frames.py` - Needs: T080 - Wave: 37 diff --git a/tests/unit/modelling/test_diagnostics_frames.py b/tests/unit/modelling/test_diagnostics_frames.py index e718d4db..f939f8ec 100644 --- a/tests/unit/modelling/test_diagnostics_frames.py +++ b/tests/unit/modelling/test_diagnostics_frames.py @@ -12,12 +12,16 @@ import numpy as np import pandas as pd import pytest +from loop_interpolation._diagnostics import UNKNOWN_EXPLANATION, summarise_residuals from tests.fixtures.coordinate_frames import ( ANCHORS, R1_GRADIENT, ROTATIONS_DEG, + AnalyticReference, model_with_frame, + r3_gradient, + r3_value, world_foliation_model, ) @@ -247,3 +251,226 @@ def test_old_misfit_shapes_are_kept(gradient, normal, shape): old = np.asarray(feature.evaluate_gradient_misfit(), dtype=float) assert old.shape == shape np.testing.assert_array_equal(old.ravel(), feature.evaluate_gradient_cosines()) + + +# --------------------------------------------------------------------------- +# Stale model, report blocks and exclusions (U4.4, U4.5; FR-023, FR-024; SC-009) +# --------------------------------------------------------------------------- + +STALE_KINDS = ("interpolator", "builder") +STALE_CELLS = list(itertools.product(ENGINES, STALE_KINDS)) +STALE_IDS = [f"{engine}-{kind}" for engine, kind in STALE_CELLS] +ANCHOR = ANCHORS[1] +ROTATION = 30.0 + + +def _make_stale(feature, kind): + """Make the interpolator or the builder stale, and check the flag. + + The function sets ``builder._up_to_date`` directly. The method + ``set_not_up_to_date`` also makes the interpolator stale. + """ + if kind == "interpolator": + feature.interpolator.up_to_date = False + assert feature.interpolator.up_to_date is False + assert feature.builder._up_to_date is True + else: + feature.builder._up_to_date = False + assert feature.builder._up_to_date is False + assert feature.interpolator.up_to_date is True + + +def _record_exclusion(feature): + """Add one builder record of excluded rows, to make sure that the report has one.""" + feature.builder.excluded_rows["r1_equality"] = {"invalid_reference_value": 2} + + +def _assert_has_recorded_exclusion(report): + """Check that the report holds the record of ``_record_exclusion``.""" + records = [r for r in report.exclusions if r.family == "r1_equality"] + assert len(records) == 1 + assert records[0].reason == "invalid_reference_value" + assert records[0].count == 2 + + +def _stale_feature(engine, kind): + """Return a solved feature that is then made stale.""" + _, feature, _ = _build(ANCHOR, ROTATION, engine) + _record_exclusion(feature) + _make_stale(feature, kind) + return feature + + +def _call_with_warnings(method): + """Call a method and return its result and the UserWarning records.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = method() + return result, [w for w in caught if issubclass(w.category, UserWarning)] + + +@pytest.mark.parametrize(("engine", "kind"), STALE_CELLS, ids=STALE_IDS) +def test_stale_misfit_is_not_rebuilt(engine, kind): + """The old misfit methods do not rebuild a stale model (FR-031).""" + feature = _stale_feature(engine, kind) + before = _snapshot(feature) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + feature.evaluate_gradient_misfit() + feature.evaluate_value_misfit() + after = _snapshot(feature) + assert after["up_to_date"] == before["up_to_date"], "the call rebuilt the interpolator" + assert ( + after["builder_up_to_date"] == before["builder_up_to_date"] + ), "the call rebuilt the builder" + _assert_same_state(before, after) + + +@pytest.mark.parametrize(("engine", "kind"), STALE_CELLS, ids=STALE_IDS) +def test_stale_misfit_warns_once_and_returns_nan(engine, kind): + """A stale model gives one UserWarning for each method and NaN values.""" + feature = _stale_feature(engine, kind) + for method, shape in ( + (feature.evaluate_gradient_misfit, (2, 4)), + (feature.evaluate_value_misfit, (27,)), + ): + result, caught = _call_with_warnings(method) + assert len(caught) == 1, [str(w.message) for w in caught] + assert kind in str(caught[0].message).lower() + result = np.asarray(result, dtype=float) + assert result.shape == shape + assert np.all(np.isnan(result)) + for method, count in ( + (feature.evaluate_gradient_cosines, 8), + (feature.evaluate_value_residuals, 27), + ): + result, caught = _call_with_warnings(method) + assert len(caught) == 1, [str(w.message) for w in caught] + assert kind in str(caught[0].message).lower() + assert result.shape == (count,) + assert np.all(np.isnan(result)) + + +@pytest.mark.parametrize(("engine", "kind"), STALE_CELLS, ids=STALE_IDS) +def test_stale_verification_report_does_not_rebuild(engine, kind): + """A stale model gives empty residual blocks, exclusions and one warning.""" + feature = _stale_feature(engine, kind) + before = _snapshot(feature) + report, caught = _call_with_warnings(feature.verification_report) + _assert_same_state(before, _snapshot(feature)) + assert len(caught) == 1, [str(w.message) for w in caught] + assert kind in str(caught[0].message).lower() + assert report.physical_residuals == {} + assert report.assembled_residuals == {} + _assert_has_recorded_exclusion(report) + + +@pytest.mark.parametrize("engine", ENGINES) +def test_report_blocks_are_separate(engine): + """The physical and the assembled residuals are two separate blocks.""" + _, feature, _ = _build(ANCHOR, ROTATION, engine) + report = feature.verification_report() + assert report.physical_residuals is not report.assembled_residuals + assert len(report.physical_residuals) > 0 + assert len(report.assembled_residuals) > 0 + for name, entry in report.physical_residuals.items(): + assert entry.kind == "physical", name + for name, entry in report.assembled_residuals.items(): + assert entry.kind == "assembled", name + + value = report.physical_residuals["value"] + assert value.unit == "field" + expected_rms = float(np.sqrt(np.mean(feature.evaluate_value_residuals() ** 2))) + assert abs(value.rms - expected_rms) <= 1e-12 + + assembled = feature.interpolator.calculate_residual_for_constraints() + assert set(report.assembled_residuals) == set(assembled) + for name, residuals in assembled.items(): + assert report.assembled_residuals[name] == summarise_residuals( + name, "assembled", "assembled", residuals + ) + + payload = report.to_dict() + assert set(payload["physical_residuals"]) == set(report.physical_residuals) + assert set(payload["assembled_residuals"]) == set(report.assembled_residuals) + assert payload["physical_residuals"] is not payload["assembled_residuals"] + assert all(v["kind"] == "physical" for v in payload["physical_residuals"].values()) + assert all(v["kind"] == "assembled" for v in payload["assembled_residuals"].values()) + + +@pytest.mark.parametrize("engine", ENGINES) +def test_unsolved_model_gives_no_residual_blocks(engine): + """A model with no solution has no residual blocks, but it has exclusions.""" + _, feature, _ = _build(ANCHOR, ROTATION, engine) + _record_exclusion(feature) + feature.interpolator.c = None + report = feature.verification_report() + assert report.physical_residuals == {} + assert report.assembled_residuals == {} + _assert_has_recorded_exclusion(report) + + +@pytest.mark.parametrize("engine", ENGINES) +def test_exclusions_are_counted_and_explained(engine): + """A restricted reference gives counted and explained exclusion records.""" + _, feature, _ = _build(ANCHOR, ROTATION, engine) + builder = feature.builder + model = feature.model + reference = AnalyticReference( + lambda p: r3_value(p, ANCHOR), lambda p: r3_gradient(p, ANCHOR), "restricted" + ) + lower, upper = ANCHOR + (2.1, 2.1, 2.1), ANCHOR + (5.9, 7.9, 9.9) + reference.support_box = (lower, upper) + builder.add_equality_constraints(reference, lambda p: np.ones(len(p), dtype=bool)) + builder.add_orthogonal_feature(reference, B=0.0, sample_rule=None) + builder.up_to_date() + assert feature.interpolator.up_to_date + + support = feature.interpolator.support + bounding_box = model.bounding_box + nodes = bounding_box.reproject(support.nodes) + selected = np.all((nodes >= lower) & (nodes <= upper), axis=1) + samples = bounding_box.reproject(support.barycentre) + valid = np.all((samples >= lower) & (samples <= upper), axis=1) + expected = { + "restricted_equality": ("invalid_reference_value", int((~selected).sum())), + "restricted_orthogonal": ("invalid_reference_direction", int((~valid).sum())), + } + report = feature.verification_report() + for family, (reason, count) in expected.items(): + assert count > 0 + records = [r for r in report.exclusions if (r.family, r.reason) == (family, reason)] + assert len(records) == 1, family + assert records[0].count == count + assert records[0].explanation + assert records[0].explanation != UNKNOWN_EXPLANATION + + +def _with_outside_gradient(data): + """Return the data with 3 more gradient rows far outside the model box.""" + extra = data.iloc[GRADIENT_ROWS].iloc[:3].copy() + extra[["X", "Y", "Z"]] = extra[["X", "Y", "Z"]].to_numpy(float) + 100.0 + return pd.concat([data.iloc[VALUE_ROWS], data.iloc[GRADIENT_ROWS], extra], ignore_index=True) + + +def test_gradient_outside_points_give_no_false_unattributed_model(): + """Three gradient points outside the box give outside_model 3, not unattributed.""" + _, feature, _ = _build(ANCHOR, ROTATION, "FDI", select=_with_outside_gradient) + assert feature.interpolator.data["gradient"].shape[0] == 7 + records = feature.verification_report().exclusions + outside = [r for r in records if (r.family, r.reason) == ("gradient", "outside_model")] + assert len(outside) == 1 + assert outside[0].count == 3 + assert not [r for r in records if (r.family, r.reason) == ("gradient", "unattributed")] + + +def test_gradient_outside_points_with_pli_are_measured(): + """For PLI, record the measured counts. PLI assembles no gradient rows.""" + _, feature, _ = _build(ANCHOR, ROTATION, "PLI", select=_with_outside_gradient) + records = feature.verification_report().exclusions + outside = sum(r.count for r in records if (r.family, r.reason) == ("gradient", "outside_model")) + unattributed = sum( + r.count for r in records if (r.family, r.reason) == ("gradient", "unattributed") + ) + print(f"PLI gradient: unattributed={unattributed} outside_model={outside}") + assert outside == 3 From ac82e948378384eb380f4de72854509eafa01ffc Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:45:00 +1030 Subject: [PATCH 16/28] docs(spec): record wave 37 review and settle T084 unsolved rule --- specs/005-coordinate-frame-correctness/run-state.json | 10 ++++++---- specs/005-coordinate-frame-correctness/tasks.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 64f4bac7..abdcf5ca 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -62,8 +62,8 @@ "base": "223c912bb05aab1ec23ca884fe8b19987ff53b3d", "tasks": "T081", "check": "scope pass; 20 new cells fail pre-fix as expected; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "88a7096f2d1c8f26e2abd86778cb08313d611ad4", + "review": "pass (3 minor, open; T084 context updated: guard both blocks on c is not None)" } ], "runs": { @@ -155,7 +155,7 @@ ] }, "findings": { - "found": 10, + "found": 13, "rejected": 0, "dropped": 0, "fixed": 3, @@ -165,7 +165,9 @@ "T075 set_nelements passes a cell count to the nsteps setter, which takes nodes: nelements=1000 on a unit cube gives 9^3=729 cells (task text and old behaviour; not a regression).", "T079 section of verification.md does not say that test_each_cell_has_exactly_one_centre was replaced, and calls the midpoint test evidence although it is a guard (OP12 not reproduced). T088 adds one sentence.", "T080 exact-field stub ignores its points argument, so it cannot catch a wrong position frame (other tests catch it).", - "T080 one TOL_SOLVED=9e-4 for both engines (FDI max 1.2e-4 is looser)." + "T080 one TOL_SOLVED=9e-4 for both engines (FDI max 1.2e-4 is looser).", + "T081 test_gradient_outside_points_with_pli_are_measured prints but does not assert unattributed == 8.", + "T081 _snapshot compares id(latest_diagnostics_report): hold the object and compare with is." ] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 81a8512f..8673cf02 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1341,7 +1341,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Context: Add `_physical_residual_summaries(self) -> dict[str, ResidualSummary]`: `value` from `_value_residuals_fresh()` (kind `"physical"`, unit `"field"`); `gradient` and `normal` from `_gradient_cosines_fresh()`: `np.degrees(np.arccos(np.clip(cosine, -1, 1)))`, unit `"degree"`; use `summarise_residuals(name, "physical", unit, values)`. Add the public `verification_report(self) -> ConstraintDiagnosticsReport`. Body: `base = self.interpolator._build_constraint_diagnostics_report()` (a new object; do NOT set `latest_diagnostics_report`). `physical = {}`, `assembled = {}`. If `_stale_reason()` is not `None`: `warnings.warn(text, UserWarning, stacklevel=2)` and skip both blocks. Else `physical = self._physical_residual_summaries()`; and only if `interpolator.c` is not `None` and the interpolator has `calculate_residual_for_constraints`: for `(name, residual)` in `calculate_residual_for_constraints().items()` set `assembled[name] = summarise_residuals(name, "assembled", "assembled", residual)`. Exclusions are always built (they read state only): `excluded = getattr(self.builder, "excluded_rows", {})`; `rows_per_point = {"gradient": 2}` if the interpolator is a `DiscreteInterpolator`, else `None`. Return `dataclasses.replace(base, physical_residuals=physical, assembled_residuals=assembled, exclusions=build_exclusion_records(excluded, base.families, rows_per_point))`. - An unsolved model (`c` is `None`) leaves the assembled block empty with no warning. Do not change `calculate_residual_for_constraints`. numpy docstring and type hints. Import `ConstraintDiagnosticsReport`, `ResidualSummary`, `summarise_residuals`, `build_exclusion_records` from `loop_interpolation._diagnostics`. + An unsolved model (`c` is `None`) leaves BOTH residual blocks empty with no warning (plan decision 16; settled after the wave 37 review: guard the physical block on `interpolator.c is not None` too, because `evaluate_value` with `c = None` fails; the test `test_unsolved_model_gives_no_residual_blocks` requires both `{}`). The stale warning text must contain the word `interpolator` or `builder` (the stale object). The `gradient` rows_per_point is 2 for any `DiscreteInterpolator` (FDI gives 2 rows for each point; PLI gives 0 rows, so PLI gradient data gives an `unattributed` record, measured 8). Do not change `calculate_residual_for_constraints`. numpy docstring and type hints. Import `ConstraintDiagnosticsReport`, `ResidualSummary`, `summarise_residuals`, `build_exclusion_records` from `loop_interpolation._diagnostics`. ## Phase 22: Stage S5 finish From 81f72d4bb012b52c8e63f0eb8fc5487f93612f2d Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:51:25 +1030 Subject: [PATCH 17/28] docs(spec): record S5 diagnostics pre-fix failures --- .../run-state.json | 14 ++++++ .../005-coordinate-frame-correctness/tasks.md | 2 +- .../verification.md | 49 ++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index abdcf5ca..7d68cb1e 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -64,6 +64,14 @@ "check": "scope pass; 20 new cells fail pre-fix as expected; ruff pass", "commit": "88a7096f2d1c8f26e2abd86778cb08313d611ad4", "review": "pass (3 minor, open; T084 context updated: guard both blocks on c is not None)" + }, + { + "wave": 38, + "base": "e584bb20eb897241054ea2dda689211fdd48f1b8", + "tasks": "T082", + "check": "scope pass; 147 of 147 cells fail pre-fix; old method changes data[gradient] by up to 8.2254", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -152,6 +160,12 @@ "model": "sonnet", "result": "pass: 20 failing cells pre-fix; PLI gradient unattributed 8 (k=2), outside_model 3, dropped_rows 14" } + ], + "T082": [ + { + "model": "sonnet", + "result": "pass" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 8673cf02..e6912e7e 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1311,7 +1311,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `test_unsolved_model_gives_no_residual_blocks` (2): `interpolator.c = None`; both blocks `{}`; exclusions present; no exception. `test_exclusions_are_counted_and_explained` (2): the restricted-support reference case of `test_reference_constraint_frames.py`: builder records under `restricted_equality` and `restricted_orthogonal` with the analytic count; the explanation is not empty and not `UNKNOWN_EXPLANATION`. `test_gradient_outside_points_give_no_false_unattributed_model` (2): gradient data with 3 points outside the box: an `outside_model` record of 3 and no `unattributed` record for `gradient`. -- [ ] T082 [US4] [M] Record the S5 pre-fix evidence for the diagnostics tests in `verification.md`. +- [x] T082 [US4] [M] Record the S5 pre-fix evidence for the diagnostics tests in `verification.md`. - Files: `specs/005-coordinate-frame-correctness/verification.md` - Needs: T081 - Wave: 38 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index c86545d9..08cefdb5 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -905,4 +905,51 @@ All drifts are inside the bound. The pre-fix drift was G1 [1, 0, 3], G2 [0.5, 0. Evidence for FR-015 and SC-011. `test_centres_are_cell_midpoints_in_world` is the evidence. Each cell of the exported grid (`structured_grid()`) has one centre, as the mean of its 8 corner nodes, and the centres are all different. `test_local_coordinates_grid_associates_own_centres` is a containment check: each cell centre falls inside the `structured_grid(local_coordinates=True)` grid (no -1 from `cell_index_at`). That grid is the axis-aligned enclosure, so a one-to-one count against it does not hold, and the test does not claim it. `test_centres_round_trip_and_index_own_cells` is a consistency check on the cell grid of the box itself, built from the same linspace as the centres. It is not evidence for FR-015 or SC-011. `test_centres_match_exported_cells` and `test_centres_inside_box` are coverage. -OP12: not reproduced. All 60 cell-centre cells passed before the fix and pass now. `_bounding_box.py` was not changed. \ No newline at end of file +OP12: not reproduced. All 60 cell-centre cells passed before the fix and pass now. `_bounding_box.py` was not changed. + +## S5 diagnostics pre-fix failures + +Method. The test file ran on the unfixed code, in one call: `python -m pytest tests/unit/modelling/test_diagnostics_frames.py -p no:cacheprovider -q -rf --no-header` (PowerShell, the worktree `LoopStructural-005-s5`). No misfit fix commit exists at this point. The result is 147 failed and 0 passed in 142.77 s. No test passes. The file has 147 cells. + +Test file (`test_diagnostics_frames.py`): 147 failed, 0 passed. + +| Test function | Failed / total | First error | +|---|---|---| +| `test_misfit_leaves_state_unchanged` | 24 / 24 | `AssertionError: data['gradient'] changed`. The old methods leave a different array in `data["gradient"]` after the call, in all 24 cells (3 rotations x 4 anchors x 2 engines) | +| `test_new_misfit_methods_leaves_state_unchanged` | 24 / 24 | `AttributeError`: `GeologicalFeature` has no attribute `evaluate_gradient_cosines` | +| `test_gradient_cosine_matches_analytic_direction_exact_field` | 24 / 24 | `assert np.all(np.abs(1.0 - old) <= 1e-10)` is False. The old method gives shape (2, 4) from one column and local vectors. See the values below | +| `test_gradient_cosine_matches_analytic_direction_exact_field_solved_model` | 24 / 24 | `AttributeError`: no attribute `evaluate_gradient_cosines` | +| `test_value_residuals_are_absolute_difference` | 12 / 12 | `AttributeError`: no attribute `evaluate_value_residuals` | +| `test_value_misfit_is_normalised_by_field_range` | 12 / 12 | `AttributeError`: no attribute `evaluate_value_residuals` | +| `test_value_misfit_with_zero_range_does_not_divide` | 2 / 2 | `[equal]`: `RuntimeWarning: divide by zero encountered in divide`. `[nan]`: `assert not np.any(np.isnan(result))` fails, the result has NaN | +| `test_misfit_with_no_rows_is_empty` | 1 / 1 | `AttributeError`: no attribute `evaluate_gradient_cosines` | +| `test_old_misfit_shapes_are_kept` | 4 / 4 | `[gradient-only]`, `[normal-only]`, `[equal-families]`: `AttributeError`, no attribute `evaluate_gradient_cosines`. `[different-families]`: `ValueError`, inhomogeneous shape (the old method gives two lists of different length) | +| `test_stale_misfit_is_not_rebuilt` | 4 / 4 | `[FDI-interpolator]`, `[PLI-interpolator]`: `AssertionError: the call rebuilt the interpolator` (`assert True == False`). `[FDI-builder]`, `[PLI-builder]`: `AssertionError: the call rebuilt the builder` | +| `test_stale_misfit_warns_once_and_returns_nan` | 4 / 4 | `AssertionError: []` (`assert 0 == 1`). The old methods give no `UserWarning` | +| `test_stale_verification_report_does_not_rebuild` | 4 / 4 | `AttributeError`: no attribute `verification_report` | +| `test_report_blocks_are_separate` | 2 / 2 | `AttributeError`: no attribute `verification_report` | +| `test_unsolved_model_gives_no_residual_blocks` | 2 / 2 | `AttributeError`: no attribute `verification_report` | +| `test_exclusions_are_counted_and_explained` | 2 / 2 | `AttributeError`: no attribute `verification_report` | +| `test_gradient_outside_points_give_no_false_unattributed_model` | 1 / 1 | `AttributeError`: no attribute `verification_report` | +| `test_gradient_outside_points_with_pli_are_measured` | 1 / 1 | `AttributeError`: no attribute `verification_report` | + +Group counts: 24 + 24 + 24 + 24 + 12 + 12 + 2 + 1 + 4 + 4 + 4 + 4 + 2 + 2 + 2 + 1 + 1 = 147. + +Items of the task text. + +| Item | Result | +|---|---| +| State test with the old methods | Reproduced. `test_misfit_leaves_state_unchanged` fails in 24 of 24 cells. `data["gradient"]` is a different array after `evaluate_gradient_misfit` (the method divides the stored rows in place) | +| Exact-field cosine test | Reproduced. The field is exact (the gradient is `R1_UNIT` in the world frame), so each cosine must be 1. The old method gives shape (2, 4) and not (8,). Both rows are equal, because the slice `grad[:, :3:6]` is the one column 0 and not the 3 vector columns. The vectors are local, not world. Values of the first row, anchor 0, FDI: rotation 0: [0.2001, 0.2632, 0.1671, 0.2877] (largest `|1 - cos|` 0.833). Rotation 30: [0.0232, 0.0700, -0.0781, 0.1532] (1.078). Rotation 90: [-0.3002, -0.3158, -0.4457, -0.1918] (1.446). The bound is 1e-10 | +| Stale-state test | Reproduced. The call rebuilds the model: `up_to_date` goes from `False` to `True`, in 4 of 4 cells (`[FDI-interpolator]`, `[PLI-interpolator]`: the interpolator flag. `[FDI-builder]`, `[PLI-builder]`: the builder flag) | +| Tests of the new names | Reproduced. 88 cells fail only because the name does not exist: `evaluate_gradient_cosines` 52 cells (24 + 24 + 1 + 3), `verification_report` 12 cells (4 + 2 + 2 + 2 + 1 + 1), `evaluate_value_residuals` 24 cells (12 + 12) | +| `test_value_residuals_are_absolute_difference` | Not reproduced as a value defect. It fails with `AttributeError` (the test uses `evaluate_value_residuals`, which does not exist now). This is not a reproduction of a value defect. The old value path is covered by `test_value_misfit_*` | + +Measured value (a short script in the scratchpad, not saved in the repository). It uses `world_foliation_model` of `tests.fixtures.coordinate_frames`, as `_build` does, with `ANCHORS[1]`, rotation 30 and the FDI engine. + +| Measure | Value | +|---|---| +| Largest absolute change of `interpolator.data["gradient"]` after one call of `evaluate_gradient_misfit` | 8.2254 (the array has 4 rows x 7 columns) | +| Largest change in each column (x, y, z, vx, vy, vz, weight) | 2.9211, 7.5854, 8.2254, 0.5485, 0.2712, 0.6789, 0.9139 | + +The change is large in the position columns and in the vector columns, and the weight column also changes. The old method divides the stored rows in place, so the stored data of the interpolator are not the input data after the call. From aecc3e6c7f002279d9ca3a7174e8719604bab1a1 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 20:54:49 +1030 Subject: [PATCH 18/28] docs(spec): record wave 38 review --- specs/005-coordinate-frame-correctness/run-state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 7d68cb1e..fc60e4ae 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -70,8 +70,8 @@ "base": "e584bb20eb897241054ea2dda689211fdd48f1b8", "tasks": "T082", "check": "scope pass; 147 of 147 cells fail pre-fix; old method changes data[gradient] by up to 8.2254", - "commit": "pending", - "review": "pending" + "commit": "6906c8ece48136d3dc16fede52876f01aa435195", + "review": "pass (no findings)" } ], "runs": { From 641f190a20ec652a2c8a5dec53008f9a305f463a Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:03:28 +1030 Subject: [PATCH 19/28] fix(features): make misfit methods non-mutating, world-correct and stale-safe --- .../modelling/features/_geological_feature.py | 209 ++++++++++++++---- .../run-state.json | 14 ++ .../005-coordinate-frame-correctness/tasks.md | 2 +- 3 files changed, 187 insertions(+), 38 deletions(-) diff --git a/LoopStructural/modelling/features/_geological_feature.py b/LoopStructural/modelling/features/_geological_feature.py index 22a17bf0..6b134ff6 100644 --- a/LoopStructural/modelling/features/_geological_feature.py +++ b/LoopStructural/modelling/features/_geological_feature.py @@ -6,7 +6,10 @@ from __future__ import annotations +import warnings + import numpy as np +from loop_interpolation._diagnostics import direction_cosines from LoopStructural.utils.maths import gradient_from_tetrahedron, regular_tetraherdron_for_points @@ -237,55 +240,187 @@ def evaluate_gradient( logger.info(f'Gradient calculated for {self.name}') return v - def evaluate_gradient_misfit(self): - """ + def _stale_reason(self) -> str | None: + """Find the first stale object of the model. + + The method only reads flags. It does not build the model. Returns ------- - misfit : np.array(N,dtype=double) - dot product between interpolated gradient and constraints + str or None + Text that names the first stale object and says to build the model + first, or None if no object is stale. """ - if self.builder is not None: - self.builder.up_to_date() - grad = self.interpolator.get_gradient_constraints() - norm = self.interpolator.get_norm_constraints() - - dot = [] - if grad.shape[0] > 0: - grad /= np.linalg.norm(grad, axis=1)[:, None] - positions = grad[:, :3] - if self.interpolator.bounding_box is not None: - positions = self.interpolator.bounding_box.reproject(positions) - model_grad = self.evaluate_gradient(positions) - dot.append(np.einsum("ij,ij->i", model_grad, grad[:, :3:6]).tolist()) - - if norm.shape[0] > 0: - norm /= np.linalg.norm(norm, axis=1)[:, None] - positions = norm[:, :3] - if self.interpolator.bounding_box is not None: - positions = self.interpolator.bounding_box.reproject(positions) - model_norm = self.evaluate_gradient(positions) - dot.append(np.einsum("ij,ij->i", model_norm, norm[:, :3:6])) - - return np.array(dot) - - def evaluate_value_misfit(self): + builder = self.builder + if builder is not None: + if getattr(builder, "_up_to_date", True) is False: + return f"The builder of {self.name} is stale. Build the model first." + for fault in getattr(builder, "faults", None) or []: + fault_builder = getattr(fault, "builder", fault) + fault_name = getattr(fault, "name", "a fault") + if getattr(fault_builder, "_up_to_date", True) is False: + return f"The builder of the fault {fault_name} is stale. Build the model first." + fault_interpolator = getattr(fault_builder, "interpolator", None) + if getattr(fault_interpolator, "up_to_date", True) is False: + return ( + f"The interpolator of the fault {fault_name} is stale. " + "Build the model first." + ) + if getattr(self.interpolator, "up_to_date", True) is False: + return f"The interpolator of {self.name} is stale. Build the model first." + return None + + def _constraint_row_counts(self) -> list: + """Count the stored rows of the gradient, normal and value constraints. + + Returns + ------- + list of int + Row counts of the gradient, normal and value arrays. """ + return [ + int(np.shape(self.interpolator.get_gradient_constraints())[0]), + int(np.shape(self.interpolator.get_norm_constraints())[0]), + int(np.shape(self.interpolator.get_value_constraints())[0]), + ] + + def _gradient_cosines_fresh(self) -> list: + """Compute the gradient and normal cosines without a stale check. + + The method copies the stored arrays. It does not change them. Returns ------- - misfit : np.array(N,dtype=double) - difference between interpolated scalar field and value constraints + list of numpy.ndarray + One array of cosines for each non-empty family (gradient first, + then normal). """ - if self.builder is not None: - self.builder.up_to_date() + bounding_box = self.interpolator.bounding_box + cosines = [] + for array in ( + self.interpolator.get_gradient_constraints(), + self.interpolator.get_norm_constraints(), + ): + if np.shape(array)[0] == 0: + continue + rows = np.array(array, dtype=float, copy=True) + positions = rows[:, :3] + data = rows[:, 3:6] + if bounding_box is not None: + positions = bounding_box.reproject(positions) + data = self.interpolator.local_to_world_vectors(data) + model = self.evaluate_gradient(positions) + cosines.append(direction_cosines(model, data)) + return cosines + + def _value_residuals_fresh(self) -> np.ndarray: + """Compute the value residuals without a stale check. - locations = self.interpolator.get_value_constraints() - positions = locations[:, :3] + Returns + ------- + numpy.ndarray + Absolute difference in field units, shape (N,). + """ + rows = np.array(self.interpolator.get_value_constraints(), dtype=float, copy=True) + positions = rows[:, :3] if self.interpolator.bounding_box is not None: positions = self.interpolator.bounding_box.reproject(positions) - diff = np.abs(locations[:, 3] - self.evaluate_value(positions)) - diff /= self.max() - self.min() + return np.abs(rows[:, 3] - self.evaluate_value(positions)) + + def evaluate_gradient_cosines(self) -> np.ndarray: + """Compute the cosine between the model gradient and the gradient data. + + The cosine uses world coordinates (see COMPAT.md). The gradient rows come + first and the normal rows come second. The method does not change the + model or the stored constraints. + + Returns + ------- + numpy.ndarray + Cosines, shape (N,). The array is empty if there are no rows. On a + stale model the method warns and returns NaN for each row. + """ + reason = self._stale_reason() + if reason is not None: + warnings.warn(reason, UserWarning, stacklevel=2) + n = sum(self._constraint_row_counts()[:2]) + return np.full(n, np.nan) + cosines = self._gradient_cosines_fresh() + if len(cosines) == 0: + return np.zeros(0) + return np.concatenate(cosines) + + def evaluate_value_residuals(self) -> np.ndarray: + """Compute the residual between the model field and the value data. + + The method does not change the model or the stored constraints. + + Returns + ------- + numpy.ndarray + Absolute difference in field units, shape (N,), in the order of the + value rows. On a stale model the method warns and returns NaN for + each row. + """ + reason = self._stale_reason() + if reason is not None: + warnings.warn(reason, UserWarning, stacklevel=2) + return np.full(self._constraint_row_counts()[2], np.nan) + return self._value_residuals_fresh() + + @staticmethod + def _misfit_shape(counts: list) -> tuple: + """Give the shape of the old gradient misfit array for the row counts.""" + if len(counts) == 0: + return (0,) + if len(counts) == 2 and counts[0] == counts[1]: + return (2, counts[0]) + return (1, sum(counts)) + + def evaluate_gradient_misfit(self) -> np.ndarray: + """Compute the cosine misfit of the gradient and normal constraints. + + The value is a cosine in world coordinates (see COMPAT.md). + + Returns + ------- + numpy.ndarray + Shape (1, N) for one family, (2, N) for two families of equal + length, (1, N1 + N2) for two families of different length and (0,) + with no rows. On a stale model the method warns and returns NaN in + the same shape. + """ + reason = self._stale_reason() + if reason is not None: + warnings.warn(reason, UserWarning, stacklevel=2) + counts = [n for n in self._constraint_row_counts()[:2] if n > 0] + return np.full(self._misfit_shape(counts), np.nan) + cosines = self._gradient_cosines_fresh() + if len(cosines) == 0: + return np.zeros(0) + if len(cosines) == 2 and len(cosines[0]) == len(cosines[1]): + return np.vstack(cosines) + return np.concatenate(cosines)[None, :] + + def evaluate_value_misfit(self) -> np.ndarray: + """Compute the value misfit. + + Returns + ------- + numpy.ndarray + Absolute difference between the model field and the value data, + shape (N,). The difference is divided by the range of the field + only if the range is finite and larger than zero. On a stale model + the method warns and returns NaN for each row. + """ + reason = self._stale_reason() + if reason is not None: + warnings.warn(reason, UserWarning, stacklevel=2) + return np.full(self._constraint_row_counts()[2], np.nan) + diff = self._value_residuals_fresh() + value_range = self.max() - self.min() + if np.isfinite(value_range) and value_range > 0: + diff = diff / value_range return diff def copy(self, name=None): diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index fc60e4ae..8c377934 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -72,6 +72,14 @@ "check": "scope pass; 147 of 147 cells fail pre-fix; old method changes data[gradient] by up to 8.2254", "commit": "6906c8ece48136d3dc16fede52876f01aa435195", "review": "pass (no findings)" + }, + { + "wave": 39, + "base": "00c3e3741eb663e8660ebd44bc24fe987e37e2e1", + "tasks": "T083", + "check": "scope pass; 111 passed, 36 deselected; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -166,6 +174,12 @@ "model": "sonnet", "result": "pass" } + ], + "T083": [ + { + "model": "sonnet", + "result": "pass: 111 passed" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index e6912e7e..210351a1 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1321,7 +1321,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal ## Phase 21: Stage S5 misfit code -- [ ] T083 [US4] [M] Rewrite the misfit methods on shared non-mutating code and add the new methods. +- [x] T083 [US4] [M] Rewrite the misfit methods on shared non-mutating code and add the new methods. - Files: `LoopStructural/modelling/features/_geological_feature.py` - Needs: T073, T082 - Wave: 39 From ed815379d7952725baa964e86ad638b930c78f2f Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:06:48 +1030 Subject: [PATCH 20/28] docs(spec): record wave 39 review --- specs/005-coordinate-frame-correctness/run-state.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 8c377934..397019ae 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -78,8 +78,8 @@ "base": "00c3e3741eb663e8660ebd44bc24fe987e37e2e1", "tasks": "T083", "check": "scope pass; 111 passed, 36 deselected; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "e97859bbb12fdc1d566cf5148e961b0874946c62", + "review": "pass (1 minor, open)" } ], "runs": { @@ -183,7 +183,7 @@ ] }, "findings": { - "found": 13, + "found": 14, "rejected": 0, "dropped": 0, "fixed": 3, @@ -195,7 +195,8 @@ "T080 exact-field stub ignores its points argument, so it cannot catch a wrong position frame (other tests catch it).", "T080 one TOL_SOLVED=9e-4 for both engines (FDI max 1.2e-4 is looser).", "T081 test_gradient_outside_points_with_pli_are_measured prints but does not assert unattributed == 8.", - "T081 _snapshot compares id(latest_diagnostics_report): hold the object and compare with is." + "T081 _snapshot compares id(latest_diagnostics_report): hold the object and compare with is.", + "T083 _stale_reason checks one level of faults; BaseBuilder.up_to_date recurses, so a stale fault nested in a fresh fault builder is still rebuilt on evaluation (rare; plan names one level)." ] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", From 8dcbbfe56b40b59c1d1dd7576b84156b9276f05c Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:18:59 +1030 Subject: [PATCH 21/28] feat(features): add GeologicalFeature.verification_report with residual and exclusion blocks --- .../modelling/features/_geological_feature.py | 70 ++++++++++++++++++- .../run-state.json | 14 ++++ .../005-coordinate-frame-correctness/tasks.md | 2 +- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/LoopStructural/modelling/features/_geological_feature.py b/LoopStructural/modelling/features/_geological_feature.py index 6b134ff6..82eac471 100644 --- a/LoopStructural/modelling/features/_geological_feature.py +++ b/LoopStructural/modelling/features/_geological_feature.py @@ -6,10 +6,18 @@ from __future__ import annotations +import dataclasses import warnings import numpy as np -from loop_interpolation._diagnostics import direction_cosines +from loop_interpolation import DiscreteInterpolator +from loop_interpolation._diagnostics import ( + ConstraintDiagnosticsReport, + ResidualSummary, + build_exclusion_records, + direction_cosines, + summarise_residuals, +) from LoopStructural.utils.maths import gradient_from_tetrahedron, regular_tetraherdron_for_points @@ -327,6 +335,66 @@ def _value_residuals_fresh(self) -> np.ndarray: positions = self.interpolator.bounding_box.reproject(positions) return np.abs(rows[:, 3] - self.evaluate_value(positions)) + def _physical_residual_summaries(self) -> dict[str, ResidualSummary]: + """Summarise the physical residuals of the value, gradient and normal data. + + The method does not check if the model is stale. The caller must check. + + Returns + ------- + dict of str to ResidualSummary + One summary for each non-empty family. The value summary uses field + units. The gradient and normal summaries use degrees. + """ + summaries: dict[str, ResidualSummary] = {} + counts = self._constraint_row_counts() + if counts[2] > 0: + summaries["value"] = summarise_residuals( + "value", "physical", "field", self._value_residuals_fresh() + ) + # The cosine list has one array for each non-empty family, in this order. + names = [name for name, count in zip(("gradient", "normal"), counts[:2]) if count > 0] + for name, cosine in zip(names, self._gradient_cosines_fresh()): + angles = np.degrees(np.arccos(np.clip(cosine, -1, 1))) + summaries[name] = summarise_residuals(name, "physical", "degree", angles) + return summaries + + def verification_report(self) -> ConstraintDiagnosticsReport: + """Build a report of the constraints, residuals and exclusions. + + The method returns a new object. It does not store the report, build the + model or change the state of the model. + + Returns + ------- + ConstraintDiagnosticsReport + The report. The residual blocks are empty if the model is not + solved. On a stale model the method warns, leaves the residual + blocks empty and still gives the exclusions. + """ + base = self.interpolator._build_constraint_diagnostics_report() + physical: dict[str, ResidualSummary] = {} + assembled: dict[str, ResidualSummary] = {} + reason = self._stale_reason() + if reason is not None: + warnings.warn(reason, UserWarning, stacklevel=2) + elif getattr(self.interpolator, "c", None) is not None: + physical = self._physical_residual_summaries() + if hasattr(self.interpolator, "calculate_residual_for_constraints"): + residuals = self.interpolator.calculate_residual_for_constraints() + for name, residual in residuals.items(): + assembled[name] = summarise_residuals(name, "assembled", "assembled", residual) + excluded = getattr(self.builder, "excluded_rows", {}) if self.builder is not None else {} + rows_per_point = ( + {"gradient": 2} if isinstance(self.interpolator, DiscreteInterpolator) else None + ) + return dataclasses.replace( + base, + physical_residuals=physical, + assembled_residuals=assembled, + exclusions=build_exclusion_records(excluded, base.families, rows_per_point), + ) + def evaluate_gradient_cosines(self) -> np.ndarray: """Compute the cosine between the model gradient and the gradient data. diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 397019ae..2ba768a9 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -80,6 +80,14 @@ "check": "scope pass; 111 passed, 36 deselected; ruff pass", "commit": "e97859bbb12fdc1d566cf5148e961b0874946c62", "review": "pass (1 minor, open)" + }, + { + "wave": 40, + "base": "b8230d904fedce4ec53307afeeb788b039bbe43e", + "tasks": "T084", + "check": "scope pass; diagnostics+scalar_field 154 passed; ruff pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -180,6 +188,12 @@ "model": "sonnet", "result": "pass: 111 passed" } + ], + "T084": [ + { + "model": "sonnet", + "result": "pass: 147 + 7 passed" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 210351a1..8594bbe4 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1333,7 +1333,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal `evaluate_gradient_misfit()` (old name, new body): same code; one row per non-empty family: `(1, N)` for one family, `(2, N)` for two of equal length, `(1, N1 + N2)` for two of different length, `(0,)` with no rows; stale gives that shape filled with NaN. `evaluate_value_misfit()`: stale gives NaN `(N,)`; else the residuals divided by `self.max() - self.min()` only when the range is finite and > 0, else unnormalised. Remove every `self.builder.up_to_date()` call and the in-place `grad /= norm` from these methods. Never write to a stored array, weight, coefficient or flag. numpy docstrings (state that the gradient value is a cosine in world coordinates; see COMPAT.md) and type hints. Read the whole old methods first (about lines 240 to 300). -- [ ] T084 [US4] [M] Add `GeologicalFeature.verification_report` with physical and assembled residuals and exclusions. +- [x] T084 [US4] [M] Add `GeologicalFeature.verification_report` with physical and assembled residuals and exclusions. - Files: `LoopStructural/modelling/features/_geological_feature.py` - Needs: T083 - Wave: 40 From a2e18bb3126211c3918a36c75329d7aac75246c7 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:22:24 +1030 Subject: [PATCH 22/28] docs(spec): record wave 40 review --- specs/005-coordinate-frame-correctness/run-state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 2ba768a9..df2d83d8 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -86,8 +86,8 @@ "base": "b8230d904fedce4ec53307afeeb788b039bbe43e", "tasks": "T084", "check": "scope pass; diagnostics+scalar_field 154 passed; ruff pass", - "commit": "pending", - "review": "pending" + "commit": "0ef1d4396743856b803d6e0802b8dfa83504db83", + "review": "pass (no findings)" } ], "runs": { From 18122b1910b39c439bf17c49b1617579fe3bf1f8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:29:18 +1030 Subject: [PATCH 23/28] docs(005): record S5 notices, misfit decision and rollback checks --- COMPAT.md | 7 +++++ ROADMAP.md | 12 +++++++++ .../run-state.json | 26 +++++++++++++++++++ .../005-coordinate-frame-correctness/tasks.md | 6 ++--- .../verification.md | 19 ++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index ae888321..6b1ef1e8 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -54,6 +54,13 @@ unchanged, but the new path is preferred going forward. | Fold `mask_fn`, custom fold providers (`get_deformed_orientation`), `directional_regularisation` direction callables and arrays, and the P2 `steepness_wtfunc` received or returned local (computational-frame) points and directions | They receive world points and give world directions; the interpolator converts to its local frame once. With an identity frame nothing changes (spec 005, stage S2) | 2026-11-04 | | `copy()` returned an interpolator with no frame; a transform change after constraints existed gave a stale result | New payloads carry `format_version: 2`, `constraint_space`, `transform` and `bounding_box`. `copy()` keeps the frame. A transform change after constraints exist raises `StaleFrameError` at the next set, evaluate or save (spec 005, stage S3). Loading a legacy payload with no choice is deprecated: see the deprecation table | 2026-11-05 | | `GeologicalFeature.get_data()` (used by `Loop2DView.add_data` and `ModelExporter.save`) returned locations moved by the local origin and rotated a second time; the intrusion classification evaluated the model at such points | Both use the stored world locations. With an identity frame nothing changes (spec 005, stage S4) | 2026-11-05 | +| `GeologicalFeature.evaluate_gradient_misfit()` changed the stored constraint arrays in place, compared a local vector with a world vector and read one column. Its value was a dot product with an unnormalised model gradient | The name and the public shape stay. The method changes no stored array. It gives the cosine between the world data direction and the model gradient for each row, so the value differs from the old value. Use `evaluate_gradient_cosines()` for a 1-D array. Two families of different length give one row of shape `(1, N1 + N2)`; before, this raised `ValueError` (spec 005, stage S5) | 2026-11-05 | +| `evaluate_gradient_misfit()` and `evaluate_value_misfit()` rebuilt a stale model | Both methods write one `UserWarning` and return NaN. They do not rebuild. Build the model first. The new public method `GeologicalFeature.verification_report()` gives the constraint diagnostics (residuals and exclusions) and does not build the model. A PLI model ignores gradient data, so its report shows an `unattributed` record for the gradient rows (spec 005, stage S5) | 2026-11-05 | +| `evaluate_value_misfit()` divided by the field range, with a division by zero for a zero range | It keeps the normalisation by the field range. A zero or not finite range gives the absolute difference. `evaluate_value_residuals()` gives the unnormalised 1-D result (spec 005, stage S5) | 2026-11-05 | +| Assignment of `nsteps` or `step_vector` on a 3D structured grid, and `set_nelements` | The extent stays the same. Assignment gives a new step, not a product rule. `nsteps` must be whole and at least 2. `set_nelements` keeps `maximum`. Grid steps and solved values can change slightly (spec 005, stage S5) | 2026-11-05 | +| Assignment of `origin`, `nsteps` or `step_vector` on a `RectilinearGrid` | It raises `NotImplementedError`. The node arrays are fixed (spec 005, stage S5) | 2026-11-05 | +| The 2D structured grid: `nsteps_cells`, `n_cell_x` and `n_cell_y` were separate values, and `maximum` could be assigned | The grid derives `nsteps_cells`, `n_cell_x` and `n_cell_y` from `nsteps`. `maximum` is read-only (assignment raises `AttributeError`) and keeps its legacy value (spec 005, stage S5) | 2026-11-05 | +| `ConstraintDiagnosticsReport` had no residual or exclusion fields | It has three new optional fields: `physical_residuals`, `assembled_residuals` and `exclusions`. With an identity frame and no resize, the value misfit is the same as before (spec 005, stage S5) | 2026-11-05 | The following stable methods remain part of the documented public API surface after the package extraction work and are covered by the snapshot-based contract test: add_onlap_unconformity, add_unconformity, create_and_add_domain_fault, create_and_add_fault, create_and_add_fold_frame, create_and_add_folded_fold_frame, create_and_add_folded_foliation, create_and_add_foliation, evaluate_model, evaluate_model_gradient, from_file, get_fault_surfaces, get_feature_by_name, get_stratigraphic_surfaces, rescale, save, scale. diff --git a/ROADMAP.md b/ROADMAP.md index 1a24ccd3..7472e6c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,18 @@ Removal is not before 2 minor releases after the first release that warns. Removal is also not before the known callers (the QGIS plugin) have moved. The library no longer calls either method. +### Misfit diagnostics of GeologicalFeature (spec 005, S5) +Decision date: 2026-11-05 (spec 005, S5, Q2). + +The misfit methods `evaluate_gradient_misfit` and `evaluate_value_misfit` +keep their names and their public shape. The gradient value is now a cosine +in world coordinates. + +A stale model gives a `UserWarning` and NaN. The model is not rebuilt. + +The new methods `evaluate_gradient_cosines` and `evaluate_value_residuals` +give the 1-D result. The notice is in `COMPAT.md`. + ## Stage sequence - [x] **Stage 0 — Planning infra.** This file, release/versioning/compat diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index df2d83d8..5f1b300b 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -88,6 +88,14 @@ "check": "scope pass; diagnostics+scalar_field 154 passed; ruff pass", "commit": "0ef1d4396743856b803d6e0802b8dfa83504db83", "review": "pass (no findings)" + }, + { + "wave": 41, + "base": "0764d785a5bac7e4253d067582309468a77a08f9", + "tasks": "T085,T086,T087", + "check": "scope pass; COMPAT greps ok; ROADMAP grep 1; rollback worktree removed; checks a to f pass", + "commit": "pending", + "review": "pending" } ], "runs": { @@ -194,6 +202,24 @@ "model": "sonnet", "result": "pass: 147 + 7 passed" } + ], + "T085": [ + { + "model": "sonnet", + "result": "pass" + } + ], + "T086": [ + { + "model": "haiku", + "result": "pass" + } + ], + "T087": [ + { + "model": "sonnet", + "result": "pass: rollback commit 1c029d21 (S4 code); report has 4 fields on S4 not 5" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 8594bbe4..1de1e067 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1345,7 +1345,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal ## Phase 22: Stage S5 finish -- [ ] T085 [P] [Finish] [M] Record the S5 notices in `COMPAT.md`. +- [x] T085 [P] [Finish] [M] Record the S5 notices in `COMPAT.md`. - Files: `COMPAT.md` - Needs: T084 - Wave: 41 @@ -1355,7 +1355,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal (4) Assigning `nsteps` or `step_vector` on a 3D structured grid, and `set_nelements`, keep the extent (a new step, not a product rule); `nsteps` must be whole and at least 2. `set_nelements` keeps `maximum`. Grid steps and solved values can change slightly. (5) A `RectilinearGrid` raises `NotImplementedError` on assignment of `origin`, `nsteps` or `step_vector`. (6) The 2D grid derives `nsteps_cells`, `n_cell_x`, `n_cell_y` from `nsteps`; `maximum` keeps its legacy value. (7) `ConstraintDiagnosticsReport` has three new optional fields (`physical_residuals`, `assembled_residuals`, `exclusions`); with an identity frame and no resize the value misfit is the same as before. Do not change other text. Write in ASD-STE100 Simplified Technical English. -- [ ] T086 [P] [Finish] [S] Record the S5 misfit decision in `ROADMAP.md`. +- [x] T086 [P] [Finish] [S] Record the S5 misfit decision in `ROADMAP.md`. - Files: `ROADMAP.md` - Needs: T084 - Wave: 41 @@ -1363,7 +1363,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal - Context: Add a new `###` subsection as the last one of `## Decisions`, directly before `## Stage sequence`. Heading: `### Misfit diagnostics of GeologicalFeature (spec 005, S5)`. Body (ASD-STE100): decision date 2026-11-05 (spec 005, S5, Q2); the misfit methods `evaluate_gradient_misfit` and `evaluate_value_misfit` keep their names and public shape; the gradient value is now a cosine in world coordinates; a stale model gives a `UserWarning` and NaN (no rebuild); the new names `evaluate_gradient_cosines` and `evaluate_value_residuals` give the 1-D result; the notice is in `COMPAT.md`. Keep the style of the neighbouring subsections. Do not change other text. -- [ ] T087 [Finish] [M] Record the S5 rollback check in `verification.md`. +- [x] T087 [Finish] [M] Record the S5 rollback check in `verification.md`. - Files: `specs/005-coordinate-frame-correctness/verification.md` - Needs: T074, T084 - Wave: 41 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index 08cefdb5..2916835b 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -953,3 +953,22 @@ Measured value (a short script in the scratchpad, not saved in the repository). | Largest change in each column (x, y, z, vx, vy, vz, weight) | 2.9211, 7.5854, 8.2254, 0.5485, 0.2712, 0.6789, 0.9139 | The change is large in the position columns and in the vector columns, and the weight column also changes. The old method divides the stored rows in place, so the stored data of the interpolator are not the input data after the call. + +## S5 rollback checks + +Date: 2026-11-05. The check compares the S5 tree with the S4 end code. + +Set-up: + +- The throw-away worktree was at commit 1c029d21 (the docs commit that is the S5 start commit). `git diff --stat 1c029d21 c644c8d7 -- LoopStructural packages` prints nothing. No code differs between 1c029d21 and the S4 end commit c644c8d7, so 1c029d21 holds the S4 end code. +- Each tree had its own `PYTHONPATH`. In each tree, `LoopStructural.__file__` and `loop_interpolation.__file__` pointed into that tree. +- The throw-away worktree was removed with `git worktree remove --force` and `git worktree prune`. `git worktree list` shows no throw-away worktree. + +| Check | Result | Note | +|---|---|---| +| (a) `dataclasses.fields` of the diagnostics classes | pass | `ConstraintFamilyDiagnostics` and `RegionCoverageDiagnostics` are equal. `ConstraintDiagnosticsReport` has only 4 fields on S4 (`interpolator_type`, `families`, `region_coverage`, `outside_model_points`). These 4 are equal on S5. S5 adds `physical_residuals` (the fifth field), `assembled_residuals` and `exclusions`, all with defaults. The task text says "first five", but S4 has only four. | +| (b) `to_dict()` of a report with no verification | pass | One `ConstraintFamilyDiagnostics` and one `RegionCoverageDiagnostics`. The two dicts are equal. | +| (c) `from loop_interpolation import *` | pass | The S4 names are a subset of the S5 names. S5 adds `ExclusionRecord`, `ResidualSummary`, `build_exclusion_records` and `direction_cosines`. No S4 name is missing. | +| (d) `GeologicalFeature.evaluate_gradient_misfit` and `evaluate_value_misfit` | pass | The parameters are equal on both trees (`(self)`). Only the return annotation differs: S5 has `-> 'np.ndarray'`, S4 has none. The result shape for one family (gradient data, normal data dropped, fixture `world_foliation_model`) is `(1, 4)` on both trees. The value misfit has shape `(27,)` on both. No warning on either tree. | +| (e) `StructuredGrid` with an unchanged grid | pass | `origin`, `maximum`, `nsteps` and `step_vector` are equal on both trees (origin [0, 1, 2], nsteps [7, 8, 9], step_vector [1, 2, 0.5], maximum [6, 15, 6]). | +| (f) 2D `maximum` assertions | pass | `pytest packages/loop_common/tests/test_2d_discrete_support.py tests/unit/interpolator/test_2d_discrete_support.py -p no:cacheprovider -q` gives 19 passed on S5 and 19 passed on S4. | From d30b41b051ae425e7be01ad817f9326da41ff26f Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 21:32:46 +1030 Subject: [PATCH 24/28] docs(spec): record wave 41 review --- specs/005-coordinate-frame-correctness/run-state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 5f1b300b..193cd2a7 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -94,8 +94,8 @@ "base": "0764d785a5bac7e4253d067582309468a77a08f9", "tasks": "T085,T086,T087", "check": "scope pass; COMPAT greps ok; ROADMAP grep 1; rollback worktree removed; checks a to f pass", - "commit": "pending", - "review": "pending" + "commit": "3a278aa5d439b60350fc5d350b4eb9a02cb297d5", + "review": "pass (no findings)" } ], "runs": { From 362e53c8e2b3d2c79a6e0595bd825104cec1c3ac Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 22:09:48 +1030 Subject: [PATCH 25/28] docs(005): record S5 gates, results and status log --- ROADMAP.md | 1 + .../run-state.json | 14 ++++ .../005-coordinate-frame-correctness/tasks.md | 2 +- .../verification.md | 80 +++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7472e6c2..335e1fc2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -893,3 +893,4 @@ give the 1-D result. The notice is in `COMPAT.md`. regressions. - **2026-11-05:** Spec 005 stage S3 implemented: saved interpolator frame, legacy-load rule, frame-keeping copies, stale-frame check. Gate result: root pytest 1978 passed, 5 skipped. - **2026-11-05:** Spec 005 stage S4 implemented: display, export and intrusion classification use stored world locations; rescale and scale deprecated. +- **2026-11-05:** Spec 005 stage S5 implemented: resize paths keep the extent, cell-centre guard tests, non-mutating world-correct misfit diagnostics, residual and exclusion report. diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 193cd2a7..4d3fef32 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -96,6 +96,14 @@ "check": "scope pass; COMPAT greps ok; ROADMAP grep 1; rollback worktree removed; checks a to f pass", "commit": "3a278aa5d439b60350fc5d350b4eb9a02cb297d5", "review": "pass (no findings)" + }, + { + "wave": 42, + "base": "6b9e6467ff452970cb1c7fd3a39cc209cb9e2f36", + "tasks": "T088", + "check": "gates: ruff pass; packages 1021 passed 47 skipped 2 xfailed; root 2216 passed 4 skipped (936.67s)", + "commit": "pending", + "review": "covered by the end review" } ], "runs": { @@ -220,6 +228,12 @@ "model": "sonnet", "result": "pass: rollback commit 1c029d21 (S4 code); report has 4 fields on S4 not 5" } + ], + "T088": [ + { + "model": "sonnet", + "result": "pass (gates run by orchestrator)" + } ] }, "findings": { diff --git a/specs/005-coordinate-frame-correctness/tasks.md b/specs/005-coordinate-frame-correctness/tasks.md index 1de1e067..713e2877 100644 --- a/specs/005-coordinate-frame-correctness/tasks.md +++ b/specs/005-coordinate-frame-correctness/tasks.md @@ -1372,7 +1372,7 @@ frame. A legacy file needs an explicit choice. A frame change never gives a stal (b) `to_dict()` of a report built with no verification is equal on both trees; (c) `from loop_interpolation import *` on the S4 tree gives a subset of the S5 names; (d) the signatures of `GeologicalFeature.evaluate_gradient_misfit` and `evaluate_value_misfit` are equal, and the result shape for one family is `(1, N)` on both; (e) with an unchanged grid, `origin`, `maximum`, `nsteps` and `step_vector` of a structured support are equal on both trees; (f) the four 2D `maximum` assertions pass on both trees. Then `git worktree remove --force ` and `git worktree prune`. Write in ASD-STE100 Simplified Technical English. -- [ ] T088 [Finish] [M] Run the S5 gates and complete `verification.md` and the `ROADMAP.md` status log. +- [x] T088 [Finish] [M] Run the S5 gates and complete `verification.md` and the `ROADMAP.md` status log. - Files: `specs/005-coordinate-frame-correctness/verification.md`, `ROADMAP.md` - Needs: T079, T085, T086, T087 - Wave: 42 diff --git a/specs/005-coordinate-frame-correctness/verification.md b/specs/005-coordinate-frame-correctness/verification.md index 2916835b..441bc11f 100644 --- a/specs/005-coordinate-frame-correctness/verification.md +++ b/specs/005-coordinate-frame-correctness/verification.md @@ -907,6 +907,8 @@ Evidence for FR-015 and SC-011. `test_centres_are_cell_midpoints_in_world` is th OP12: not reproduced. All 60 cell-centre cells passed before the fix and pass now. `_bounding_box.py` was not changed. +The named test `test_each_cell_has_exactly_one_centre` of the task text was replaced, because its premise was wrong (see `## S5 pre-fix failures`, note on the one-to-one test). `test_centres_are_cell_midpoints_in_world` is a guard test, because OP12 is not reproduced: it passed before the fix and passes after the fix. + ## S5 diagnostics pre-fix failures Method. The test file ran on the unfixed code, in one call: `python -m pytest tests/unit/modelling/test_diagnostics_frames.py -p no:cacheprovider -q -rf --no-header` (PowerShell, the worktree `LoopStructural-005-s5`). No misfit fix commit exists at this point. The result is 147 failed and 0 passed in 142.77 s. No test passes. The file has 147 cells. @@ -972,3 +974,81 @@ Set-up: | (d) `GeologicalFeature.evaluate_gradient_misfit` and `evaluate_value_misfit` | pass | The parameters are equal on both trees (`(self)`). Only the return annotation differs: S5 has `-> 'np.ndarray'`, S4 has none. The result shape for one family (gradient data, normal data dropped, fixture `world_foliation_model`) is `(1, 4)` on both trees. The value misfit has shape `(27,)` on both. No warning on either tree. | | (e) `StructuredGrid` with an unchanged grid | pass | `origin`, `maximum`, `nsteps` and `step_vector` are equal on both trees (origin [0, 1, 2], nsteps [7, 8, 9], step_vector [1, 2, 0.5], maximum [6, 15, 6]). | | (f) 2D `maximum` assertions | pass | `pytest packages/loop_common/tests/test_2d_discrete_support.py tests/unit/interpolator/test_2d_discrete_support.py -p no:cacheprovider -q` gives 19 passed on S5 and 19 passed on S4. | + +## S5 post-fix results + +Date: 2026-11-05. The orchestrator ran the gates on commit `6b9e6467`. The `PYTHONPATH` held the worktree `LoopStructural-005-s5`, `packages/loop_common/src`, `packages/loop_interpolation/src`, `packages/loopstructural_visualisation/src` and a scratch folder with `pyevtk`. The root run and the package run were separate calls (PowerShell), because the conftest plugins clash. + +| Command | Result | Time | +|---|---|---| +| `ruff check LoopStructural packages` | All checks passed | - | +| `pytest packages/loop_common/tests packages/loop_interpolation/tests` | 1021 passed, 47 skipped, 2 xfailed, 7 warnings | 140.08 s | +| Root `pytest -W default::DeprecationWarning` | 2216 passed, 4 skipped, 12 warnings | 936.67 s (15 min 36 s) | + +No gate failed. The root run has no failure, and so the S4 failure (`test_write_vol_gocad_is_broken_for_real_bounding_box`) is closed. + +The pytest summary has no library warning with the text "is deprecated" that comes from S5. The log tail (`gate_s5.log`) holds only the last 12 lines of the root run, and it has no "is deprecated" line. + +Cells of the new test files. + +`tests/unit/modelling/test_diagnostics_frames.py` has 147 cells: + +| Test function | Cells | +|---|---| +| `test_misfit_leaves_state_unchanged` | 24 | +| `test_new_misfit_methods_leaves_state_unchanged` | 24 | +| `test_gradient_cosine_matches_analytic_direction_exact_field` | 24 | +| `test_gradient_cosine_matches_analytic_direction_exact_field_solved_model` | 24 | +| `test_value_residuals_are_absolute_difference` | 12 | +| `test_value_misfit_is_normalised_by_field_range` | 12 | +| `test_value_misfit_with_zero_range_does_not_divide` | 2 | +| `test_misfit_with_no_rows_is_empty` | 1 | +| `test_old_misfit_shapes_are_kept` | 4 | +| `test_stale_misfit_is_not_rebuilt` | 4 | +| `test_stale_misfit_warns_once_and_returns_nan` | 4 | +| `test_stale_verification_report_does_not_rebuild` | 4 | +| `test_report_blocks_are_separate` | 2 | +| `test_unsolved_model_gives_no_residual_blocks` | 2 | +| `test_exclusions_are_counted_and_explained` | 2 | +| `test_gradient_outside_points_give_no_false_unattributed_model` | 1 | +| `test_gradient_outside_points_with_pli_are_measured` | 1 | + +The sum is 147. All 147 failed before the fix (`## S5 diagnostics pre-fix failures`) and all pass now. + +`packages/loop_interpolation/tests/test_residual_exclusion_report.py` has 9 tests (no parametrised cells): `test_direction_cosines_pure_function`, `test_report_without_verification_is_unchanged`, `test_builder_records_keep_their_own_key`, `test_unknown_reason_keeps_its_count`, `test_unattributed_rows_are_reported`, `test_gradient_outside_points_give_no_false_unattributed`, `test_dropped_rows_none_gives_no_unattributed`, `test_surfe_style_dropped_equals_outside`, `test_residual_summary_is_importable`. All 9 pass. + +Measured `TOL_SOLVED` = 9e-4. The measured maximum `|1 - cos|` is 4.414e-4 on 24 cells. The margin is a factor of 2. + +Resize and cell-centre files: 100 cells, all pass (`## S5 resize and cell-centre results`). The 3D structured golden file changed in 78 of 611 keys (`## S5 golden review (C-004)`). The 2D golden file did not change. The earlier survey of T078 (modelling 1540 passed, interpolation 615, loop_common 406, tests/unit rest 504) is in the golden section. + +## S5 backend matrix + +| Item | Level | Evidence | +|---|---|---| +| FDI misfit (gradient cosines, value residuals) | mandatory | `test_diagnostics_frames.py` (FDI cells) | +| PLI misfit (gradient cosines, value residuals) | mandatory | `test_diagnostics_frames.py` (PLI cells) | +| Residual and exclusion report, FDI and PLI | mandatory | `test_diagnostics_frames.py`, `test_residual_exclusion_report.py` | +| Resize of the structured grid | mandatory | `test_resize_paths.py` | +| Resize of the 2D grid | mandatory | `test_resize_paths.py` (`test_2d_counts_are_derived`) | +| Resize of the rectilinear grid | mandatory | `test_rectilinear_resize_is_rejected` (resize is rejected with `NotImplementedError`) | +| P2 | not in S5 | S2 and S6 cover it | +| Native engines | not in S5 | S2 and S6 cover them | +| Surfe | not in S5 | S2 and S6 cover it | +| Residual reports for fold and P2 cells | not in S5 | S2 and S6 cover them | + +PLI fact. PLI assembles no gradient rows. In the case with 3 outside points, its gradient family reports an `unattributed` record of 8 (`test_gradient_outside_points_with_pli_are_measured`). The FDI case gives no false `unattributed` record (`test_gradient_outside_points_give_no_false_unattributed_model`). + +## S5 test run time (OP2) + +| Item | Time | +|---|---| +| Root suite (orchestrator, commit `6b9e6467`) | 936.67 s (15 min 36 s). The S4 end run took 805.50 s. | +| Package suites (orchestrator) | 140.08 s. The S4 end run took 150.24 s. | + +The root time is 131 s more than the S4 time. The root suite has 150 more passed tests than at the S4 end (2216 and 2066, and the one S4 failure now passes). The new files add 147 cells to the root suite, and the diagnostics cells are slow (142.77 s on the unfixed code). The two runs had different machine load, so the times do not compare exactly. + +## S5 open points + +- OP12 (cell-centre result): not reproduced. All 60 cell-centre cells passed before and after the fix. The premise of the one-to-one test was corrected. No change to `_bounding_box.py`. +- OP16 (default shape): the default shape is kept. Two families of different length now give the shape (1, N1 + N2). +- OP17 (parity files under `packages/loop_interpolation/tests/golden`): not changed. `git diff --stat 1c029d21 HEAD -- packages/loop_interpolation/tests/golden` prints nothing. From 3f20a01410012c1532f63662e4c5977764843410 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 22:29:15 +1030 Subject: [PATCH 26/28] fix(features): read the real stale state of fault builders in misfit checks --- .../modelling/features/_geological_feature.py | 72 ++++++++++++++----- .../unit/modelling/test_diagnostics_frames.py | 59 +++++++++++++++ 2 files changed, 114 insertions(+), 17 deletions(-) diff --git a/LoopStructural/modelling/features/_geological_feature.py b/LoopStructural/modelling/features/_geological_feature.py index 82eac471..5c438ccc 100644 --- a/LoopStructural/modelling/features/_geological_feature.py +++ b/LoopStructural/modelling/features/_geological_feature.py @@ -216,12 +216,12 @@ def evaluate_gradient( while not resolved: resolved = True for f in self.faults: - v = ( + side = ( f[0] .evaluate_value(tetrahedron.reshape(-1, 3), fillnan='nearest') .reshape(tetrahedron.shape[0], 4) ) - flag = np.logical_or(np.all(v > 0, axis=1), np.all(v < 0, axis=1)) + flag = np.logical_or(np.all(side > 0, axis=1), np.all(side < 0, axis=1)) if np.any(~flag): logger.warning( f"Points are too close to fault {f[0].name}. Refining the tetrahedron" @@ -248,6 +248,50 @@ def evaluate_gradient( logger.info(f'Gradient calculated for {self.name}') return v + @staticmethod + def _first_stale(builder, label: str) -> str | None: + """Find the first stale builder or interpolator below a builder. + + The method only reads flags. It follows the rule of + ``BaseBuilder.up_to_date``. A structural frame builder or a fault + builder has no flag of its own, so the method reads its component + builders. It also reads the faults of each builder. + + Parameters + ---------- + builder : object or None + Builder to check. + label : str + Name of the builder in the text of the result. + + Returns + ------- + str or None + Text that names the first stale object, or None if none is stale. + """ + if builder is None: + return None + for fault in getattr(builder, "faults", None) or []: + fault_builder = getattr(fault, "builder", fault) + fault_name = getattr(fault, "name", "a fault") + reason = GeologicalFeature._first_stale(fault_builder, f"the fault {fault_name}") + if reason is not None: + return reason + components = getattr(builder, "builders", None) + if components is not None: + for index, component in enumerate(components): + name = getattr(component, "name", index) + reason = GeologicalFeature._first_stale(component, f"{label} ({name})") + if reason is not None: + return reason + return None + if getattr(builder, "_up_to_date", True) is False: + return f"The builder of {label} is stale. Build the model first." + interpolator = getattr(builder, "_interpolator", None) + if getattr(interpolator, "up_to_date", True) is False: + return f"The interpolator of {label} is stale. Build the model first." + return None + def _stale_reason(self) -> str | None: """Find the first stale object of the model. @@ -259,21 +303,15 @@ def _stale_reason(self) -> str | None: Text that names the first stale object and says to build the model first, or None if no object is stale. """ - builder = self.builder - if builder is not None: - if getattr(builder, "_up_to_date", True) is False: - return f"The builder of {self.name} is stale. Build the model first." - for fault in getattr(builder, "faults", None) or []: - fault_builder = getattr(fault, "builder", fault) - fault_name = getattr(fault, "name", "a fault") - if getattr(fault_builder, "_up_to_date", True) is False: - return f"The builder of the fault {fault_name} is stale. Build the model first." - fault_interpolator = getattr(fault_builder, "interpolator", None) - if getattr(fault_interpolator, "up_to_date", True) is False: - return ( - f"The interpolator of the fault {fault_name} is stale. " - "Build the model first." - ) + reason = self._first_stale(self.builder, self.name) + if reason is not None: + return reason + for fault in getattr(self, "faults", None) or []: + reason = self._first_stale( + getattr(fault, "builder", None), f"the fault {getattr(fault, 'name', 'a fault')}" + ) + if reason is not None: + return reason if getattr(self.interpolator, "up_to_date", True) is False: return f"The interpolator of {self.name} is stale. Build the model first." return None diff --git a/tests/unit/modelling/test_diagnostics_frames.py b/tests/unit/modelling/test_diagnostics_frames.py index f939f8ec..80a644f4 100644 --- a/tests/unit/modelling/test_diagnostics_frames.py +++ b/tests/unit/modelling/test_diagnostics_frames.py @@ -474,3 +474,62 @@ def test_gradient_outside_points_with_pli_are_measured(): ) print(f"PLI gradient: unattributed={unattributed} outside_model={outside}") assert outside == 3 + + +def _faulted_built_model(): + """Build a model with one fault and one foliation, then update it.""" + from LoopStructural import GeologicalModel + + model = GeologicalModel([0, 0, 0], [1, 1, 1]) + rows = [ + [0.5, 0.5, 0.5, 1, 0, 0, 0, "fault", 0], + [0.5, 0.5, 0.5, 0, 0, 1, 1, "fault", 0], + [0.5, 0.5, 0.5, 0, 1, 0, 2, "fault", 0], + ] + for x in (0.2, 0.8): + for y in (0.2, 0.8): + rows.append([x, y, 0.3, np.nan, np.nan, np.nan, np.nan, "strati", 0.0]) + rows.append([x, y, 0.7, np.nan, np.nan, np.nan, np.nan, "strati", 1.0]) + rows.append([0.3, 0.3, 0.5, 0, 0, 1, np.nan, "strati", np.nan]) + model.data = pd.DataFrame( + rows, columns=["X", "Y", "Z", "nx", "ny", "nz", "coord", "feature_name", "val"] + ) + fault = model.create_and_add_fault("fault", 0.1, nelements=1e3) + strati = model.create_and_add_foliation("strati", nelements=1e3) + model.update() + return model, fault, strati + + +def test_built_faulted_feature_is_not_stale(): + """A built feature with a fault gives finite misfits and no warning.""" + _, _, strati = _faulted_built_model() + assert len(strati.builder.faults) == 1 + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cosines = strati.evaluate_gradient_cosines() + residuals = strati.evaluate_value_residuals() + gradient_misfit = strati.evaluate_gradient_misfit() + value_misfit = strati.evaluate_value_misfit() + report = strati.verification_report() + messages = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] + assert messages == [], messages + assert np.all(np.isfinite(cosines)) and np.all(np.isfinite(residuals)) + assert np.all(np.isfinite(gradient_misfit)) and np.all(np.isfinite(value_misfit)) + assert report.physical_residuals and report.assembled_residuals + + +def test_stale_fault_component_is_not_rebuilt(): + """A stale component of a fault gives one warning, NaN, and no rebuild.""" + model, fault, strati = _faulted_built_model() + component = fault.builder.builders[0] + component.set_not_up_to_date(model) + flags = (component._up_to_date, component._interpolator.up_to_date) + assert flags[0] is False + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + residuals = strati.evaluate_value_residuals() + messages = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] + assert len(messages) == 1, messages + assert "builder" in messages[0] or "interpolator" in messages[0] + assert np.all(np.isnan(residuals)) + assert (component._up_to_date, component._interpolator.up_to_date) == flags From 5e4d7c8284ed0b3459a33c9419d75552282e1e4c Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 22:29:24 +1030 Subject: [PATCH 27/28] docs(spec): record spec review findings --- .../run-state.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index 4d3fef32..e1fbcfda 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -237,10 +237,10 @@ ] }, "findings": { - "found": 14, + "found": 19, "rejected": 0, "dropped": 0, - "fixed": 3, + "fixed": 4, "open_minor": [ "T075 _structured_grid_3d.py has a UTF-8 BOM added at line 1 (remove the 3 bytes).", "T076 _structured_grid_2d.py has format-only hunks in is_inside, neighbour_global_indexes, cell_corner_indexes (noise).", @@ -250,7 +250,11 @@ "T080 one TOL_SOLVED=9e-4 for both engines (FDI max 1.2e-4 is looser).", "T081 test_gradient_outside_points_with_pli_are_measured prints but does not assert unattributed == 8.", "T081 _snapshot compares id(latest_diagnostics_report): hold the object and compare with is.", - "T083 _stale_reason checks one level of faults; BaseBuilder.up_to_date recurses, so a stale fault nested in a fresh fault builder is still rebuilt on evaluation (rare; plan names one level)." + "T083 _stale_reason checks one level of faults; BaseBuilder.up_to_date recurses, so a stale fault nested in a fresh fault builder is still rebuilt on evaluation (rare; plan names one level).", + "Spec review: _structured_grid_3d.py BOM (same as wave 34 minor).", + "Spec review: COMPAT.md ConstraintDiagnosticsReport row has the sentence about value misfit with identity frame and no resize; move it to the evaluate_value_misfit row (a built model is always resized).", + "Spec review: verification.md S5 test run time (OP2) count sentence: 150 more passed tests vs 147 cells + 1 S4 failure.", + "MAJOR OPEN (needs maintainer decision): _value_residuals_fresh and _gradient_cosines_fresh evaluate through the feature (plan decision 8), which applies faults a second time to stored positions that were already fault-restored. Physical residuals of a faulted feature compare two frames (measured: 0.080 vs true 0.0004 to 0.0018). Proposed fix: evaluate the interpolator directly at the stored restored positions with no fault step." ] }, "answers": "2026-11-05 (S5 start): five gaps settled in plan.md Answers (bounding box = interpolator.bounding_box; stale recipe = assign up_to_date/_up_to_date False; 2D n_elements exists; report key set is a literal; PLI add_gradient_constraints is pass so PLI gradient family reports unattributed = 2*source_points).", @@ -1467,5 +1471,11 @@ }, "s5_notes5": { "T081": "PLI measured: 7 gradient rows stored (4 inside, 3 outside), builder dropped_rows=14, outside_model=3, unattributed=8 with rows_per_point gradient 2. FDI dropped_rows=6; needs rows_per_point={gradient:2} (else unattributed 3). Warning text must contain the word interpolator or builder." + }, + "spec_review": { + "range": "1c029d21..8ecb66fc", + "reviewer": "opus", + "verdict": "changes needed: 1 blocker (confirmed by opus verifier: _stale_reason treats every fault as stale), 1 major (faulted feature compares frames twice; contradicts plan decision 8, NOT fixed, needs maintainer decision), 3 minor", + "fix": "blocker fixed by sonnet worker plus 2 regression tests; worker also fixed a loop-variable overwrite in evaluate_gradient of faulted features" } } \ No newline at end of file From 181c3eefdac0d62d46008fc8b364152ccd667668 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 5 Nov 2026 22:52:16 +1030 Subject: [PATCH 28/28] docs(spec): record S5 final gates --- specs/005-coordinate-frame-correctness/run-state.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/specs/005-coordinate-frame-correctness/run-state.json b/specs/005-coordinate-frame-correctness/run-state.json index e1fbcfda..bcfee67b 100644 --- a/specs/005-coordinate-frame-correctness/run-state.json +++ b/specs/005-coordinate-frame-correctness/run-state.json @@ -1477,5 +1477,12 @@ "reviewer": "opus", "verdict": "changes needed: 1 blocker (confirmed by opus verifier: _stale_reason treats every fault as stale), 1 major (faulted feature compares frames twice; contradicts plan decision 8, NOT fixed, needs maintainer decision), 3 minor", "fix": "blocker fixed by sonnet worker plus 2 regression tests; worker also fixed a loop-variable overwrite in evaluate_gradient of faulted features" + }, + "final": { + "head_gated": "8eb22671", + "lint": "ruff check LoopStructural packages: pass", + "test_packages": "1021 passed, 47 skipped, 2 xfailed in 98.44 s", + "test_root": "2218 passed, 4 skipped in 864.77 s", + "spec_review_recheck": "pass (opus), no findings" } } \ No newline at end of file