ci(guardrails): style gates — deprecated-pattern scanner, allowlist ratchet, PR checklist (FO-03) - #364
Merged
Merged
Conversation
…lowlist ratchet Scans src/ for the Charter's cheapest-to-check banned patterns: access-context (S7), mesh.data (S7), hedging names maybe_/try_/do_ (S3), and uncommented except/pass swallows (S4). Line-based, no dependencies; comment lines skipped; heuristics and exclusions documented in the module docstring. --include-docs adds a report-only docs/ inventory. The allowlist records the 87 legacy hits at baseline (development@9f5ed9e9) as path:pattern-id lines and may only shrink; the scanner reports stale entries so cleanups delete them. Part of FO-03 (quality campaign 2026-07, Phase 4). Underworld development team with AI support from Claude Code
style-gates.yml runs the deprecated-pattern scanner on every PR to development and main (checkout + python only, no PETSc build, <1 min), failing with the offending lines and a pointer to the Charter and the shrink-only allowlist policy. A second step prints the docs/ inventory report-only. No formatting gate: src/underworld3 is not black-clean (81 of 95 files), so a format check would fail every library PR. The PR template distils the Charter into a nine-line checklist. Part of FO-03 (quality campaign 2026-07, Phase 4). Underworld development team with AI support from Claude Code
…ters Adds guides/style-gates.md (what the gates check, shrink-only allowlist policy, local usage, what to do when a PR trips the gate, why there is no formatting gate) and links it from the developer guides toctree. Charter S10 gains a four-line machine-enforcement pointer; the release process notes that style gates must be green on the release branch. Part of FO-03 (quality campaign 2026-07, Phase 4). Underworld development team with AI support from Claude Code
…ide reference The /check-patterns skill now points at scripts/check_deprecated_patterns.py as the single source of truth for the src/ pattern list, and its reference list is aligned with the Charter authority map (the .qmd guide no longer exists). Underworld development team with AI support from Claude Code
lmoresi
added a commit
that referenced
this pull request
Jul 16, 2026
…tranded branch, closes #365) (#369) * feat(solvers): custom geometric-MG prolongation hook (scalar) Add set_custom_mg() + utilities/custom_mg.py: drive PCMG geometric multigrid with a prolongation we build ourselves (barycentric or RBF) from independent, possibly non-nested coarse meshes. Coarse operators come from Galerkin RAP (P^T A P), so only the prolongation list is supplied. This decouples geometric multigrid from a nested refine() hierarchy. Mechanism: inject the custom P before the first PCSetUp (avoids the MatProductReplaceMats shape error a live matrix swap triggers under Galerkin), and KSPSetDMActive(OPERATOR, False) so PETSc uses our explicit P instead of re-deriving DM-hierarchy interpolation. Finest level reduced to the BC-eliminated global ordering; coarse levels use full DOF coords. Validated (scalar Poisson, refinement=2 box): custom barycentric P from independent coarse meshes reaches FMG iteration counts (1-3 vs FMG 2) and the same solution. On a mesh with NO refine hierarchy (FMG unavailable -> GAMG, 13 iters) custom-P geometric MG converges in ~9 iters. test_1015 (4 tests); test_1014 (11) still pass. Single-field (scalar/vector) only; Stokes velocity-block path is a follow-up. Underworld development team with AI support from Claude Code * feat(custom_mg): Layer-1 generalized FMG hierarchy (BC-per-level) + scoped SBR Phase 1 of hardening the custom-prolongation work into a generalized FMG hierarchy. - CustomMGHierarchy: adapter-agnostic multi-level hierarchy that builds custom-P transfers (barycentric/rbf) with essential BCs applied at EVERY level (transfers map reduced->reduced). This is the load-bearing invariant: on an exactly-nested hierarchy, omitting per-level reduction makes a coarse boundary DOF coincide with a BC-removed fine DOF -> zero column -> singular Galerkin coarse operator. build() guards against zero columns. - set_custom_fmg(): register a hierarchy + per-level solver factory; built and installed at solve() time (build-time injection, DMActive(OPERATOR,False), Galerkin RAP). Scalar / single-field-vector (top-level PC); Stokes velocity block is Phase 2. - sbr_refine / sbr_refine_where: local Skeleton-Based Refinement (no MMG, on-rank, conforming) with SCOPED dm_plex_transform_type (leaking it globally breaks UW's uniform refine() with err73). - Legacy finest-only path kept for back-compat (test_1015). Validated: scalar jump-coefficient Poisson on a 5-level (3 uniform + 2 SBR) hierarchy converges in 3 FMG iters vs GAMG 46, solution matches to 2.4e-8. test_1016 (3 tests); test_1015 (4) + test_1014 (11) still pass. Design: docs/developer/design/GENERALIZED_FMG_HIERARCHY_AND_ADAPT.md Underworld development team with AI support from Claude Code * docs(custom_mg): Layer-1 status + remaining hardening before merge Layer 1 (generalized FMG hierarchy) is an independent, working capability (arbitrary nested/non-nested coarse grids -> geometric FMG, BC-per-level). Current scope experimental: scalar/single-field, serial, factory API. Remaining before merge-ready general feature: Stokes velocity-block, drop the factory (label-based BC reduction), parallel. Underworld development team with AI support from Claude Code * feat(custom_mg): Stokes velocity-block custom-P FMG (Phase 1, Step 1) Integrate the generalized FMG hierarchy into the saddle-point solver's velocity sub-block. set_custom_fmg(..., field_id=0) now drives geometric multigrid on the velocity block with our supplied (barycentric / RBF) prolongations + Galerkin RAP. The velocity sub-PC is unreachable until the monolithic Jacobian is assembled (PCFieldSplit forms A_vv via MatCreateSubMatrix; snes.setUp builds structure only -> err73). So _install_velocity_block_transfers: forces a Jacobian assembly (computeFunction + computeJacobian at the zero guess; throwaway max_it=0 fallback), reaches the velocity sub-PC, reset()s it and rebuilds a FRESH PCMG from our P (mirrors the proven standalone recipe; sidesteps the MatProductReplaceMats live-swap bug), re-attaches the coupled Stokes nullspace. _configure_pcmg now derives the options prefix from pc.getOptionsPrefix() so both the scalar top-level PC and the velocity sub-PC are configured correctly. inject_custom_mg is wired into SNES_Stokes_SaddlePt.solve (guarded no-op unless set_custom_fmg was called), injected after setFromOptions / nullspace, before the real solve. Validated (SolCx eta_B=1e6, 3-level nested hierarchy, in-solver via solver.solve()): velocity block converges in 6 MG iters vs GAMG 198, solution matches the GAMG reference to 1.5e-9. test_1017 (barycentric + rbf). test_1014/1015/1016 unchanged (20 pass total). Underworld development team with AI support from Claude Code * docs(custom_mg): mark Stokes velocity-block integration done (Phase 1, Step 1) Underworld development team with AI support from Claude Code * refactor(custom_mg): drop level_solver_factory (Phase 1, Step 2) Each coarse level's BC-constrained reduced map is now derived directly from the coarse mesh DM via _coarse_reduced_map: clone the DM, copy the (built) finest solver's fields + DS onto it, createDS, read the global section. The DS carries UW's exact essential-BC definitions and is a topology-independent discretisation spec, so it constrains the matching boundary DOFs on ANY coarse mesh that shares the solver's boundary labels (nested or not). This removes the throwaway-solver factory (heavy, leak-prone, and an API wart): set_custom_fmg and CustomMGHierarchy.build no longer take a level_solver_factory. Leak-free — DM ops only, no SNES / JIT. Validated identical to the old factory path (reduced maps byte-equal: 126 + 510 DOFs on the SolCx 3-level hierarchy). test_1014/1015/1016/1017 all green (20). custom-P vs native FMG unchanged (6 vs 5 iters, sol match 3.7e-10). Underworld development team with AI support from Claude Code * docs(custom_mg): mark factory removal done (Phase 1, Step 2) Underworld development team with AI support from Claude Code * feat(custom_mg): loud serial-only guard for np>1 (Phase 1, Step 3 partial) The reduced maps use rank-local DOF indices and the prolongations assemble as serial AIJ, so at np>1 custom-P would silently build wrong P (or hit a cryptic broadcast error). Add _require_serial: set_custom_fmg and inject_custom_mg now raise a clear NotImplementedError on >1 ranks, pointing to preconditioner='fmg'/'gamg'. Serial behaviour unchanged. Parallel test tests/parallel/test_1017_custom_mg_serial_guard_mpi.py (np=2) asserts both the set-time and legacy solve-time guards fire. Full parallel custom-P (nested co-partitioned, rank-local point location + ghosting, MPIAIJ assembly with global-section reduction) remains a designed fast-follow. Underworld development team with AI support from Claude Code * docs(custom_mg): record np>1 serial guard (Phase 1, Step 3) Underworld development team with AI support from Claude Code * feat(custom_mg): parallel (np>1) custom-P FMG, nested co-partitioned (Phase 1, Step 3) The hierarchy path now builds parallel-correct transfers. Each rank builds its block of P rank-locally: ghost-inclusive coarse coords mean every owned fine node lands in a local coarse simplex (verified 0 misses np=2/4), and the reduced global numbering rides the DM global section via a clean local->global-reduced map (_level_dof_layout scatters owned global indices out through globalToLocal; constrained DOFs stay -1, ghosts resolve to the owner's global index). Transfers assemble as MPIAIJ (owned fine rows, global coarse cols incl. off-rank); constrained coarse DOFs drop -> reduced->reduced. Parallel zero-column guard via P^T·1 + allreduce. _coarse_dof_layout reuses the leak-free copyDS trick for coarse levels. CustomMGHierarchy.build branches serial (scipy CSR) vs parallel (MPIAIJ). The guard now blocks only the legacy finest-only path (still serial). Validated np=1/2/4: scalar Poisson custom-P 4 iters and Stokes SolCx velocity block 6 iters, both matching the GAMG reference (and each other across rank counts). New tests/parallel/test_1017_custom_mg_parallel_mpi.py (scalar + Stokes correctness + legacy serial-guard). Serial 20 unchanged. Underworld development team with AI support from Claude Code * docs(custom_mg): record parallel np>1 done (Phase 1, Step 3) Underworld development team with AI support from Claude Code * feat(custom_mg): cover all solver families (SNES_Vector hook; Vector + Constrained tests) Verify custom-P works for every solver family that consumes the mesh, so a mesh-owned adapted hierarchy drives MG uniformly: - SNES_Vector.solve was MISSING the inject_custom_mg hook (only SNES_Scalar and SNES_Stokes_SaddlePt had it) — so Vector_Projection silently stayed on GAMG. Add the hook (mirrors the scalar one). All 8 solver-subclass solve() overrides delegate to a base solve(), so this completes coverage: scalar branch -> Poisson/Darcy/Projection/AdvDiff/Diffusion; vector branch -> Vector_Projection; velocity-block -> Stokes/VE/Constrained/NS. - test_1017: add SNES_Vector (Vector_Projection, top-level vector PC, field_id=None) and Stokes_Constrained (free-slip multipliers, grouped [p,h] split, field_id=0) serial tests — both drive custom-P 'mg', constrained matches analytic SolCx to 4.4e-4. - parallel test: add Vector (passes np=2) and Constrained. The Constrained parallel test is SKIPPED: Stokes_Constrained is not parallel-safe yet — it segfaults at np>1 independently of custom-P (canonical test_1062_constrained_solcx also segfaults at np=2 under plain GAMG). It auto-enables when the constrained solver becomes parallel-ready. Serial: custom_mg 22 + projections + constrained green. Parallel np=2: scalar + Stokes-velocity + vector + legacy-guard pass. Underworld development team with AI support from Claude Code * docs(custom_mg): solver-family coverage matrix + constrained-parallel limitation Underworld development team with AI support from Claude Code * docs(custom_mg): link constrained-parallel segfault to issue #291 Underworld development team with AI support from Claude Code * docs(layer2): SBR adapt-on-top design (mesh-owned custom-P hierarchy) Captures the Layer-2 design: integrate a nested on-rank SBR adapter into mesh.adapt(adapter="sbr", max_levels, node_budget) with the existing isotropic-metric interface; re-adapt (non-cumulative, no node translation — unlike MMPDE) discarding the SBR top and re-marking from the static base each step; mesh-owned hierarchy that ALL solvers consume; Eulerian REMAP field transfer via the existing remesh machinery; no-redistribution as a correctness requirement (custom-P parallel needs the finest co-partitioned with the static coarse tail); static-coarse/transient-fine data model with top-only transfer rebuild + coarse-transfer caching; marker-sidecar checkpoint scheme (designed, deferred). Phased plan + invariants. Underworld development team with AI support from Claude Code * docs(layer2): record adapt-on-top prototype validation (serial + np=2) SBR adapt-on-top + custom-P + REMAP + non-cumulative re-adapt loop proven end-to-end with Layer-1 code only; np=2 confirms the no-redistribute / co-partitioned correctness (refined finest stays co-partitioned with the static base coarse levels). 4 MG iters, matches GAMG. Underworld development team with AI support from Claude Code * fix(custom_mg): wire assembled Jacobian into KSP before velocity-block fieldsplit The Stokes velocity-block injection forces computeJacobian, then reaches the fieldsplit BEFORE the SNES solve. SNESSolve normally wires the freshly-assembled Jacobian into the KSP/outer-PC; doing it ourselves was missing, so for some configurations (e.g. Stokes on an SBR-refined child mesh with a mesh-variable bodyforce) the outer PC carried an UNASSEMBLED operator and PCSetUp failed with "Matrix must be set first" (err73). Fix: snes.getKSP().setOperators(J, Pmat) after forcing assembly, before outer_pc.setUp(). Found via the Layer-2 adapt-on-top prototype (Stokes on a fault-refined child). Diagnosed: ksp.A set but asm=False while J.assembled=True -> operator not wired. New regression test_custom_fmg_stokes_on_sbr_child (Stokes velocity-block custom-P on an SBR child with a mesh-variable bodyforce) — fails pre-fix, passes after. test_1014/1015/1016/1017 green (serial); parallel np2 unchanged. Underworld development team with AI support from Claude Code * feat(layer2): mesh.adapt SBR adapt-on-top returns a refined child Wire the Layer-2 nested adapt-on-top path into the mesh API (de-risked prototype now integrated). The naming settled as: adapt RETURNS a refined child; the old in-place MMG adapt is renamed remesh. mesh.adapt(metric, max_levels=2, node_budget=None, builder, adapter="sbr"): - marks base-finest cells whose size exceeds the metric target, up to max_levels SBR passes (on-rank, distribute=False; node_budget keeps the highest-metric cells first); metric sampled by evaluate (serial) / global_evaluate (np>1); - wraps the refined finest as a child (child.parent = self, _relationship_kind="refinement", registered in _registered_children); the base mesh is untouched (re-adapt is non-cumulative); - the child owns the static coarse tail (_custom_mg_coarse_meshes, cached on the parent) so solvers auto-pickup geometric MG. Solver auto-pickup: custom_mg.maybe_inject_custom_mg (called from the four solve hooks) builds a CustomMGHierarchy from a mesh-owned coarse tail when no solver-set hierarchy is present (field_id 0 for the Stokes velocity block, None for scalar/vector) — every solver on an adapted mesh drives geometric MG with no per-solver set_custom_fmg. Clean no-op for plain (unadapted) solves. copy_into/add_into dispatch on the child kind: refinement children prolongate parent->child (FE-exact barycentric custom-P / global_evaluate REMAP at np>1) and restrict child->parent (nearest-node injection / global_evaluate). Submesh children keep the existing subpoint_is path. mesh.remesh(metric) is the renamed in-place MMG path (byte-identical body); mesh.adapt(adapter="mmg") is a deprecation shim that warns and forwards. Migrated the MMG regression tests (test_0810/0830, ptest_0763) to remesh and hardened test_0810 with a runtime backend probe (skips cleanly where MMG is non-functional). Validation: tests/test_0835_sbr_adapt_on_top.py (7 tests, tier_b; SBR needs no MMG build) + study script validate_mesh_adapt_integration.py (serial + np=2, all OK): child link + finer, solver auto-pickup pc=mg 4 iters == GAMG, copy_into both directions, non-cumulative re-adapt, node_budget localises, shim warns. Submesh + custom-mg regressions (test_0771/0772/1015/1017) green. Docs: LAYER2_SBR_ADAPT_ON_TOP.md "Implementation" section (as-built API); submesh-solver-architecture.md reframed under the parent/child DAG (submesh and refinement are two kinds of child). Underworld development team with AI support from Claude Code * test(layer2): Stokes velocity-block FMG auto-pickup on an adapt() child Canonical SolCx (eta-jump 1e6) Stokes on an SBR-adapted child refined near the viscosity jump (128/512 cells -> child 992, genuinely local). The velocity block auto-picks-up the mesh-owned custom-P FMG (field_id=0, full cycle, fgmres outer) with NO set_custom_fmg, converges in 6 iters == GAMG, matches the GAMG solution to rel 0 and the analytic velocity to GAMG accuracy. Validated serial + np=2 via demo_stokes_on_adapted_child.py. Underworld development team with AI support from Claude Code * feat(layer2): one MG level per SBR refinement step (not a single jump) Previously max_levels>1 collapsed all SBR levels into a single base-finest -> child custom-P transfer, so the whole refinement got just one MG level (the coarse-grid correction made one big jump). Now each SBR pass becomes its own MG level: the child's mesh-owned tail is [base L0 ... base finest] + [SBR level 1 ... SBR level n-1] and the solver appends its own mesh (the finest SBR level = child), so transfers between consecutive levels each span a single refinement. _adapt_sbr keeps every intermediate SBR DM, wraps it as a coarse-level mesh (transient, on the child), and appends them after the static base tail. Effect: MG levels now scale with depth (4/5/6 for max_levels 1/2/3), matching the design doc's intended [base levels] + [SBR levels] hierarchy. The 2-level box-fault Stokes solve improves 4 levels/8 iters -> 5 levels/5 iters; np=2 max_levels=2 Poisson drives 5 levels, converges in 2 iters (intermediate SBR meshes co-partition correctly, no redistribute). test_0835::test_each_sbr_level_is_its_own_mg_level asserts MG levels = base levels + n_sbr and that a 2-level solve drives all of them. 9 tests pass. Underworld development team with AI support from Claude Code * docs+test(layer2): funnel is metric-shape, not node_budget; document honestly Investigation (recorded in the docstrings): a per-level node_budget cannot make the finest SBR level hug a feature, because SBR's conforming closure re-refines the whole connected patch from any seed (measured: 30/60/120/240 seeds -> identical output). The funnel is controlled by the METRIC SHAPE: a wedge (size growing with distance, Surface.refinement_metric(..., profile="linear")) makes each level's band ~half the previous width so the finest level concentrates at the feature with bounded per-level growth; a flat-core Gaussian refines the whole core uniformly at every level (no funnel). - adapt(): new Notes section on controlling the grading/funnel via the metric; node_budget docstring corrected to state it caps SEEDS only (not output DOFs) and points to the metric profile for the funnel. - test_0835::test_wedge_metric_funnels_finest_level_to_feature asserts the wedge metric's per-level marked-cell growth (finest/coarsest) is milder than a flat gaussian's on the same fault (the funnel signature). 10 tests pass. Underworld development team with AI support from Claude Code * docs(layer2): design note for newest-vertex bisection (graded adapt-on-top) Records the SBR/longest-edge grading limitation (uniform-patch refill, measured) and designs the fix: newest-vertex bisection (NVB), whose closure is provably bounded (O(#marked)) and which produces the graded level+1/level+2 staircase keeping simplices (p4est ruled out). Covers: NVB rule (refinement edge / newest-vertex), bounded conforming completion, initial labelling, two implementation routes (Route A = serial numpy on the triangulation + DMPlex-from-cells, proposed first; Route B = native PETSc DMPlexTransform, parallel, deferred), integration seam (engine="nvb" in the existing _adapt_nested; custom-P hierarchy unchanged since transfers are coordinate-built), parallel/checkpoint considerations, a complexity table, a 4-step phasing, and alternatives (red-green, longest-edge, p4est). docs/developer/design/NVB_GRADED_ADAPT.md. Underworld development team with AI support from Claude Code * docs(layer2): NVB note — interior-interface intuition + 3D generalisation Add the conceptual resolution of "why can we build an embedded uniform patch but not refine inside it without draining to the edge": graded meshes are valid and constructible; longest-edge bisection just has no local termination in a uniform bisected patch, so its closure drains to the existing outer interface and cannot carve a new interior interface (measured: 1 centre cell of an r<0.25 patch -> +3592 cells out to the patch edge, both structured and unstructured). NVB's combinatorial marked edge restores local termination -> bounded interior interfaces = grading. Add a 3D section: NVB generalises to tetrahedra (Bansch/Maubach/Stevenson, bounded closure in n-D); longest-edge is worse in 3D; Route A carries over to (N,4) cells; octree stays hex-only (ruled out). Underworld development team with AI support from Claude Code * feat(layer2): NVB serial core prototype — graded AMR proven Phase-1 core of the newest-vertex-bisection design: ~135 lines of numpy (nvb_prototype_2d.py, NVBMesh) implementing the newest-vertex data model and the recursive compatible-bisection with bounded conforming closure. Validated against the SBR longest-edge pathology on the same nested-disk bullseye: - one cell deep in a uniform patch -> +2 cells, LOCAL (r 0.016-0.030), where SBR drained +3592 cells to the patch edge; - the finest band stays confined (gen 6 at r<0.126) where SBR refilled to r=0.337; - conforming at every step (0 hanging nodes, 0 over-shared edges); - 3024 cells vs SBR's 7968 for the same target; - renders as clean concentric generation rings (true grading, not a uniform core). So the hard part (the closure algorithm) works. Remaining Phase-1 = DMPlex wrap + boundary/region label transfer (engineering). Design note updated with the result and the prototype reference. Underworld development team with AI support from Claude Code * feat(layer2): NVB prototype — track per-cell refinement depth Add depth bookkeeping (children inherit parent depth+1) and a depths() accessor, so refinement level can be coloured cleanly on a NON-UNIFORM base (area-derived generation conflates base size variation with refinement). Validated: NVB fault funnel on an irregular Delaunay base grades cleanly by depth (0..7), conforming (0 hanging nodes) and bounded (no cascade) at every step — where longest-edge SBR cascaded across the domain on an unstructured mesh. Underworld development team with AI support from Claude Code * docs(layer2): NVB parallel — decomposition preservation decides Route A vs B Sharpen the parallel section: the binding requirement is preserving the parent's decomposition (co-partitioned finest, no redistribute) so custom-P's rank-local point location holds — not load balance. This DISQUALIFIES Route A (createFromCellList) for parallel: it loses the parent point-SF (serial DM or fresh DMPlexDistribute partition); preserving it would need manual SF rebuild + consistent cross-rank marked-edge bisection + closure exchange. SBR preserves it today via in-place adaptLabel (SF propagated by the transform framework) — the model to match. So Route A = serial stepping stone; Route B (native DMPlexTransform) is the real parallel implementation, justified by decomposition preservation. Adds the np>1 acceptance test (child centroids in locally-owned base cells; coarse-tail partition bit-identical). Underworld development team with AI support from Claude Code * feat(layer2): NVB graded adapt engine — serial Route A in src Port the validated NVB serial core into src as utilities/nvb.py (NVBMesh: newest-vertex data model, recursive bounded compatible-bisection, from_dm / to_dm with boundary-edge + region-cell label transport, conformity / similarity-class diagnostics). createFromCellList needs consistent CCW winding (the (peak,b0,b1) refinement-edge order is geometry-agnostic) — reorient the exported cell list only; labels matched by vertex pair (coordinate, never index). custom_mg.nvb_refine mirrors sbr_refine (single-shot). Mesh._adapt_sbr renamed to _adapt_nested with engine='sbr'|'nvb' (default sbr, behaviour unchanged). The NVB path drives a persistent NVBMesh across 2*max_levels generations (1->2 area halving) so the refinement-edge labelling — hence the similarity-class bound — propagates parent->child; each generation snapshots a DM for the engine-agnostic coordinate-based custom-P FMG tail. NotImplementedError for engine='nvb' at np>1 (cell-list rebuild loses the parent decomposition the custom-P parallel path needs — that is Route B) and for dim!=2. Validated end-to-end serial: graded NVB child (fewer DOFs than the SBR uniform patch), Poisson FMG (5 levels) matches GAMG bit-identically (exact err 3.5e-10), SolCx Stokes (eta jump 1e6) velocity-block FMG matches GAMG iter-for-iter (8) and bit-identically. Existing SBR tests (test_0835) all pass. Underworld development team with AI support from Claude Code * test+docs(layer2): NVB graded adapt tests + design-note status tests/test_0836_nvb_graded_adapt.py (tier_b, 12 tests): engine-level properties (conformity 0 hanging/0 over-shared every generation; bounded closure — single cell local +O(1) and #added ≤ C·#marked; shape-regularity via bounded similarity classes; graded bullseye + fault funnel confine the finest generation near the feature with coarse cells surviving) and the integrated path (graded child link/ engine tag, fewer DOFs than the SBR patch, non-cumulative re-adapt, Poisson FMG matches GAMG, SolCx Stokes η-jump-1e6 velocity-block FMG matches GAMG, 3D guard). Update NVB_GRADED_ADAPT.md: phases 2 (DMPlex wrap) + 3 (Layer-2 wiring) DONE with the resolved gotchas (CCW winding, vertex-pair label match, edge+vertex labels); Route B (parallel native transform) is next and the seam is ready. Underworld development team with AI support from Claude Code * docs(layer2): NVB parallel-readiness review — serial Route A is non-blocking Audit against the 'must not block parallel' requirement: - co-partitioning invariant is structurally preserved by NVB (bisection replaces a cell with same-owner children; closure only triggers same-owner bisections), so any in-place transform keeps the child co-partitioned with the coarse tail — the custom-P parallel transfer's load-bearing assumption. Only Route A's rank-0 cell-list rebuild breaks it. Measured bar: custom-P FMG already solves np=2 on a locally-refined SBR adapt-on-top child (4-level mg, exact err 2e-11). - nothing in the serial code blocks Route B: engine= dispatch seam, engine-agnostic coordinate custom-P tail (parallel path already built+proven), lineage/copy_into/ snapshots all shared; NVBMesh cell-list to_dm + label transfer are simply not on the parallel path. - irreducible hard kernel = the parallel conforming-closure fixpoint; NVB's labelling is cross-rank-consistent for free (geometric longest-edge seed + deterministic midpoint rule). No PETSc transform bisects a labelled edge, so Route B needs a new C transform type (reusing PETSc bisection-closure infra). - options: manual-SF pure-Python (fragile) vs native C transform (robust); recommend prototyping the parallel closure-fixpoint in Python first to de-risk. Underworld development team with AI support from Claude Code * docs(layer2): de-risk the parallel NVB closure-fixpoint (Route B kernel) nvb_parallel_closure_prototype.py: distributed NVB conforming-closure under a faithful discipline (geometric state shared = the point-SF; control flow distributed = owner-only bisection + an explicit cross-rank work queue, no rank touches another's cells). Proves the genuinely novel part of parallel NVB, separately from SF construction: - globally conforming (0 hanging nodes incl. across the partition), every case; - identical to the serial mesh (confluence), every partition (2/3/4-way, diagonal + vertical features straddling boundaries); - converges in <=3 communication rounds, FLAT as the mesh grows n=8->32 (8740 cells) — bounded and mesh-size-independent. What remains for a real parallel engine is only the SF/point construction, which Route B's native DMPlexTransform inherits from PETSc. Design note updated with the result table; this is the milestone that says Route B is worth the C investment. Underworld development team with AI support from Claude Code * docs(layer2): Route B implementation plan — feasibility established Symbol audit (dyld_info on installed libpetsc 3.25) + reading PETSc's SBR transform establish that the native parallel NVB transform is a SELF-CONTAINED UW extension (no PETSc rebuild, no fork) and a small delta from SBR: - the parallel-hard machinery is all EXPORTED: DMPlexTransformRegister, DMPlexPointQueue*, DMLabelPropagate* (the cross-rank closure), generic plex/ label API. Only 5 small 2D geometry/orientation helpers are hidden, all short + locally reimplementable (SBR's triangle-split cone tables are static fns in the .c; barycentre placement ~12 lines). - NVB = SBR with the edge pick changed from longest-edge to newest-vertex; the newest vertex is identified by a per-vertex AGE label (base 0, midpoint=gen), cross-rank-consistent for free. All split geometry/closure/SF reused. - build wiring mirrors the existing _function extension (.pyx + .c vs libpetsc + private headers). Plan of record is validation-gated: infra gate (SBR-clone transform built+parallel) -> NVB edge rule (confluence vs serial) -> _adapt_nested np>1 dispatch -> acceptance (np>1 graded child, Poisson/SolCx FMG, co-partitioning). Underworld development team with AI support from Claude Code * feat(layer2): Route B infra gate — native uwnvb DMPlexTransform registered + parallel Stage 1 of the native parallel NVB transform: prove the whole Route B stack with no PETSc rebuild. New self-contained UW extension underworld3.utilities._nvb_transform (_nvb_transform.pyx registers the type on import + nvb_transform.c implements the transform), wired into setup.py like the existing _function extension (links libpetsc, uses PETSc private transform headers from $PETSC_DIR/include). nvb_transform.c registers 'uwnvb' via the exported DMPlexTransformRegister and sets the transform ops, reimplementing the two short non-exported helpers it needs (SetDimensions ~4 lines, MapCoordinatesBarycenter ~6 lines); celltransform is currently Identity (Stage 2 swaps in the newest-vertex bisection SetUp + cell transform, reusing the exported DMLabelPropagate cross-rank closure). VALIDATED: builds clean (no PETSc rebuild); import registers 'uwnvb'; setType('uwnvb')+setUp()+apply() work serially (8->8 identity); and at np=2 on a DISTRIBUTED DM the apply produces a valid distributed DM with a populated point-SF (9 shared points rank0) + global-vec roundtrip. Confirms registration-from-extension, private-header compile, petsc4py routing, and parallel SF preservation all work — the previously-unknown infrastructure for Route B. Underworld development team with AI support from Claude Code * docs(layer2): settle + prove the Route B build model (headers + binary, no rebuild) Answer the build question definitively: the uwnvb transform extension compiles against PETSc headers (public + private petsc/private/dmplextransformimpl.h from $PETSC_DIR/include) and links the PETSc BINARY libpetsc — no PETSc source compile, no fork. Three tiers: (1) all parallel-critical API exported + linked (DMPlexTransformRegister, DMPlexPointQueue*, DMLabelPropagate* closure); (2) private header for the ops struct (same internal API PETSc's own transforms use, from outside the tree); (3) the few non-exported helpers reimplemented locally (tiny: SetDimensions, MapCoordinatesBarycenter + the regular tri-1to4/seg-1to2 cone tables). DMPolytopeTypeGetArrangement is a static inline in public petscdm.h -> usable by inclusion, not a reimplement. Proven by the Stage-1 gate (compiled, registered, np=2). Caveat recorded: tiers 2/3 couple to PETSc's pinned-3.25 internal ABI -> assert version at build, re-verify on upgrade. Exact source locations to copy/reimplement listed for a turnkey Stage-2 pass. Underworld development team with AI support from Claude Code * feat(layer2/nvb): native uwnvb DMPlexTransform reproduces refine_sbr (Route B Stage 2a) Fills in the Stage-1 "uwnvb" transform (previously an identity gate) with a self-contained clone of PETSc's SBR longest-edge bisection, registered from UW with no PETSc rebuild. It reproduces refine_sbr byte-for-byte — identical triangulation (per-cell vertex geometry, not just counts) for every marking pattern including full uniform refinement — which exercises the entire native transform stack on the pinned PETSc 3.25: registration, the SetUp closure, DMLabelPropagate cross-rank propagation, cell transform, subcell orientation, and coordinate mapping. Depends only on: exported libpetsc symbols (DMPlexPointQueue*, DMLabelPropagate*, the Identity helpers, registration); static-inline header helpers (DMPolytopeTypeGetArrangement, ComposeOrientation, DMPlex_DistD_Internal); and four non-exported PETSc helpers reimplemented verbatim (SetDimensions, MapCoordinatesBarycenter, and the SEGMENT/TRIANGLE cases of the regular cell-refine and subcell-orientation) — all verified byte-identical to the 3.25.0 source. No PETSc rebuild, no fork. This is the SBR-equivalent base; the graded newest-vertex edge choice layers on top (a single-bisection, multi-pass transform — see the design note's Stage 2b finding for why a one-pass double/triple-split scheme cannot grade). - tests/test_0837_nvb_native_transform.py: uwnvb == refine_sbr equivalence (tier_b, skips cleanly without the custom-PETSc extension). - docs/developer/design/NVB_GRADED_ADAPT.md: Stage 2a done; Stage 2b finding and the single-bisection multi-pass redesign; FMG-hierarchy-index label reuse for checkpointing (to investigate next phase). Underworld development team with AI support from Claude Code * feat(layer2/nvb): graded NVB via single-bisection multi-pass native transform (Route B Stage 2b) Adds graded newest-vertex refinement on top of the Stage-2a native transform, and fixes the grading failure of the earlier one-pass approach. A single conforming DMPlexTransform pass is forced into green/blue multi-edge splits that corrupt the refinement-edge structure; instead we refine as repeated CONFORMING single bisections of *compatible* refinement edges, which keeps every child's slot trivially correct and the closure bounded. As built (all in C, so the SF-sensitive parallel logic stays in one place): - each triangle carries a uwnvb_refedge cone-slot label (refedge = cone[slot]; longest-edge seed on the base). No generation label is needed — single bisection makes each child's newest vertex the new midpoint, so its refinement edge is the edge opposite that midpoint (geometric). - uwnvb_bisect: a dumb transform that single-splits exactly the edges named by a label, reusing the Stage-2a cell-transform/orientation/coordinate ops. - UWNVBRefine(dm, want_label): closure (grow the bisect set by LEPP blocking neighbours) + drain (each sub-pass single-splits the compatible refinement edges — an edge that is the refinement edge of all its incident cells, reconciled across ranks by an MPI_LAND PetscSFReduce — then rebuilds the slot + set). Exposed as underworld3.utilities._nvb_transform.refine(dm, want_label). Validated (tests/test_0838, tier_b): refinement edges match the serial NVBMesh reference EXACTLY over four uniform refines (0 mismatch, 64/128/256/512); a mark deep in a 2x-refined patch adds +2 cells (== NVBMesh) versus SBR's +4824; a shrinking bullseye grades to 805 cells versus SBR's 102868 uniform-patch cells; conforming (0 hanging / 0 over-shared) and deterministic. Stage-2a (uwnvb == refine_sbr, test_0837) still passes. Runs conforming at np=1/2/3. Remaining for full parallel confluence: the closure is on-rank only, so a closure crossing a partition under-refines slightly there (still conforming — the SF-reconciled compatibility test prevents cross-partition hanging nodes). The SF-reconciled cross-rank closure (DMLabelPropagate of requested refinement edges, as SBR's SetUp does) is the next step, followed by wiring _adapt_nested(engine=nvb) at np>1. See docs/developer/design/NVB_GRADED_ADAPT.md. Underworld development team with AI support from Claude Code * feat(layer2/nvb): SF-propagated cross-rank closure for parallel NVB Replaces the on-rank-only closure with the SF-reconciled version: each C-cell's refinement edge is marked in a `req` edge label and propagated across the point SF with DMLabelPropagate (the same mechanism SBR's SetUp uses — edges are shared SF points, cells are not), so a cell blocked by an off-rank neighbour marks that neighbour on its owning rank. Each rank derives its own C-cells locally from the requested edges incident to them. This makes the LEPP closure cross-rank complete. Serial behaviour is unchanged (tests 0837 + 0838 still pass); the driver runs at np=1/2/3 producing conforming, graded output (locally 0 over-shared; 0 hanging nodes at np=1; SF-reconciled compatibility prevents one-sided edge bisection). Known limitation (documented in NVB_GRADED_ADAPT.md): the parallel result is valid but not yet bit-confluent — global cell counts differ by a few cells across np (uniform refine 159/352/760 at np=1 vs 157/346/743 at np=2). This is a drain sub-pass sequencing issue, NOT the closure (it affects uniform refine, whose closure is trivial), NOT the longest-edge seed (no exact ties → geometrically deterministic), and NOT the SF-LAND compatibility indexing (verified point-SF ilocal = point numbers). Confluence is a focused follow-up (per-sub-pass serial-vs-parallel comparison), then Stage 2c wires _adapt_nested(engine=nvb) np>1. Underworld development team with AI support from Claude Code * fix(layer2/nvb): parallel confluence — OR-reconcile the per-pass bisection set The nested-distributed-refine SEGFAULT and the parallel non-confluence were the same single bug: the drain marked edges to bisect purely from local C cells, so a shared edge could be split on one rank and kept whole on its neighbour. That makes DMPlexTransformCreateSF map a split-edge child on one rank onto an unsplit vertex on the other — a stratum-crossing point SF. On the next refine, DMLabelPropagate reduces an edge's request onto that vertex (whose support is edges, not cells) and reads out of range → SEGV; and the divergent split sets produced slightly different (each valid) meshes per partition. Fix: uwnvb_sf_lor — an MPI_LOR PetscSFReduce+Bcast of the bisect-mark array over the point SF, so an edge chosen on any rank is split on every rank owning a copy. Since agree[] is already MPI_LAND-reconciled (the edge is the refinement edge of the incident cell on all sharers), splitting it everywhere is exactly the conforming closure — the OR fixes the SF and completes the cross-partition closure in one step. Serial is unchanged (no SF graph → no-op). Now bit-confluent with serial at any communicator size: uniform 159/352/760, graded bullseye 215, deep 5-level nesting 96/180/288/390/472 all equal at np=1/2/3/4, 0 hanging nodes, stratum-consistent output SF. New regression test test_parallel_confluence pins it (passes np=1/2/3). Underworld development team with AI support from Claude Code * test(layer2/nvb): skip serial NVBMesh cross-checks under np>1 The four NVBMesh-reference tests compare per-rank local cells to the serial reference engine, so they only make sense at np=1. Mark them serial_only; test_parallel_confluence (partition-independent global counts) is the parallel pin. np=2/3: 1 passed + 4 skipped; serial: all 9 pass. Underworld development team with AI support from Claude Code * feat(layer2/nvb): Stage 2c — wire native uwnvb transform into mesh.adapt (parallel) mesh.adapt(engine="nvb") now dispatches to the native uwnvb DMPlexTransform when the compiled extension is present, giving graded NVB refinement in parallel (was serial-only via the NVBMesh cell-list engine, which raised at np>1). The transform is in-place — the child stays co-partitioned with the parent, so the mesh-owned custom-P geometric-MG tail is valid at np>1 — and carries the boundary/region labels forward automatically. _adapt_nested: for engine="nvb" with the extension available, mark cells whose current size exceeds the metric target and refine once per pass via _nvb_transform.refine (up to 2*max_levels passes; a bisection halves the area). Collective stop via allreduce(sel.size) so a rank with no local marks still joins the refine when a neighbour's edge forces a closure bisection. metric evaluated with global_evaluate at np>1. NVBMesh remains the serial fallback (raises at np>1 only when the extension is absent). Verified: adapt(engine="nvb") is bit-confluent (child=366 cells at np=1/2/4), boundaries + 4-level MG tail preserved; Poisson on the graded child drives custom-P FMG converging in 3 iters == GAMG at np=1/2/4. New test_0839_nvb_parallel_adapt.py (confluence + FMG acceptance, passes np=1/2/3); existing SBR + serial-NVB adapt suites (22 tests) still green. Underworld development team with AI support from Claude Code * docs(layer2/nvb): mark Stage 2c (parallel adapt wiring) done Underworld development team with AI support from Claude Code * test(layer2/nvb): parallel SolCx Stokes FMG acceptance on the NVB child Adds test_solcx_stokes_velocity_fmg_on_nvb_child to test_0839: SolCx (η jump 1e6) on a graded NVB child, velocity block auto-picks-up the mesh-owned custom-P FMG (geometric mg, FULL/FMG cycle) and matches the GAMG reference. Confluent child (800 cells) and a partition-independent solution. Verified np=1/2/4: FMG vel-iters == GAMG (8/9/7), rel|u| ~0. Underworld development team with AI support from Claude Code * feat(layer2/adapt): accept an analytic metric in _adapt_nested (fixes patchy grading) adapt(metric, engine=...) now accepts any evaluatable metric (sympy / UWexpression), not only a MeshVariable: metric_sym = getattr(metric_field, "sym", metric_field), used in all three refinement branches. Motivation: a baked P1 refinement_metric field stores M = 1/h², which for a typical h_far/h_near ratio swings ~80x across one base cell. P1-interpolating that peaked quantity on the coarse base aliases badly, so a thin finite-width feature gets *patchy* refinement levels along its length (measured: along-fault h_target varied 3.9x even though the distance is ~0 all along the fault). Passing the analytic wedge instead samples the metric exactly at each centroid → uniform along-feature resolution (residual 1.3x is only the P1 distance field's own error; exact distance gives 1.0x). MeshVariable metrics are unchanged (metric_sym == metric_field.sym). New test_adapt_accepts_analytic_metric (confluent, conforming, np1/2). Existing SBR + NVB adapt suites (22) still pass. Underworld development team with AI support from Claude Code * feat(layer2/adapt): callable exact-distance metric, resolved on each refined level The adapt metric is re-evaluated on the centroids of every refined NVB/SBR level, but a distance-driven fault metric supplied as a P1 MeshVariable (or an expression referencing Surface.distance.sym) is interpolated from the *base* mesh — so it aliases: baking M = 1/h² into a P1 field swings ~80x across one base cell (patchy along-fault levels, 3.9x h_target variation), and even an analytic wedge still rides the P1 *distance* field (1.3x). Let mesh.adapt() accept a plain callable metric(centroids) -> M, evaluated directly at each level's centroids, and add Surface.refinement_metric_function(...) that returns one built on the surface's EXACT signed distance. Surface.signed_distance / unsigned_distance expose the exact point-to-polyline (2D) / implicit-distance (3D) primitive at arbitrary query points; _compute_distance_field is refactored to share it (_signed_distance_at) so the P1 field and the callable use one code path. The distance now resolves itself at the new resolution as levels appear (on-fault h_target uniform to ~1e-9, 1.0x). A coordinate-driven callable is also partition-independent, so it needs no global_evaluate/swarm migration and the child stays bit-confluent (206 cells at np=1/2/4 in the test; 1590 in the weak-fault demo). tests/test_0839_nvb_parallel_adapt.py: test_adapt_accepts_callable_metric_exact_surface_distance (np1/np2). Underworld development team with AI support from Claude Code * refactor(layer2/adapt): normalise the metric to one callable; fn.evaluate is the field adapter _adapt_nested had two implicit paths (callable vs field/expr) branched inside a per-call helper. Collapse to a single eval_metric(centroids) -> M callable built once: a user callable is used as-is; a MeshVariable/expression is wrapped in an uw.function.(global_)evaluate adapter. So fn.evaluate is not a separate path — it IS a callable in the framework, just the default one for a field/expression. One code path, evaluated at each refined level's centroids. This also documents the composition the wrapper makes obvious: a user callable may call global_evaluate itself when the metric genuinely depends on a base field (e.g. |grad T| for convection adaptivity) — with the caveat that a field is sampled from the base-mesh interpolant (base-resolution, no self-sharpening, unlike exact geometry) and that np>1 needs global_evaluate, not evaluate. Behaviour unchanged: test_0839 (5) passes np1/np2. Underworld development team with AI support from Claude Code * feat(surfaces): Surface.remap_to(mesh) + director property for adapt-on-top faults remap_to(new_mesh): re-home a Surface onto an adapt() child (drops cached distance fields bound to the old mesh; geometry unchanged; re-registers). Lets ONE fault Surface drive both the geometry-only refinement metric (evaluated before the child exists) and the child-side constitutive model (distance/normal live on the child) — the nested SBR/NVB adapt returns a child and does not auto-notify surfaces the way in-place remesh does. director: unit surface-normal as a symbolic column vector = normalised grad of the signed-distance field. The natural TI weak-plane director; exact for a planar fault. Underworld development team with AI support from Claude Code * fix(custom_mg): don't auto-inject velocity-block FMG onto scalar solvers on adapt children An adapt() child owns a custom-P FMG coarse tail that solve() auto-picks-up via maybe_inject_custom_mg. That tail is built to accelerate the Stokes VELOCITY block, but the hook also fired for scalar solvers (field_id=None). For a semi-Lagrangian advection-diffusion solver the assembled operator is boundary-reduced in a way the coarse DS-copy reduced map does not reproduce, so the per-level BC reductions disagree and the transfers don't chain — PCMG's Galerkin PtAP gets a rectangular A (e.g. 12725x12486) and dies with PETSc error 60. This bit any AdvDiffusionSLCN on an adapted mesh. Fix, in maybe_inject_custom_mg (mesh-owned/opportunistic path only): - skip the auto-pickup when the solver carries a DuDt trace-back operator (semi-Lagrangian advection-diffusion) — its operator structure is incompatible and a scalar AD solve is cheap (GAMG is fine); an explicit set_custom_fmg() still works if wanted; - plus a dimensional guard (finest transfer must match the assembled operator and actually coarsen) as defense-in-depth for other field_id=None cases. Restructured so the solver-set (set_custom_fmg) path is unchanged and errors there still surface. Velocity-block (field_id=0) and P1-scalar-Poisson auto-pickup are unaffected. test_0839: test_advdiff_scalar_on_nvb_child_no_custom_mg_crash (raised PETSc 60 before; passes now). Full NVB suite 11/11. Underworld development team with AI support from Claude Code * refactor(custom_mg): rename maybe_inject_custom_mg -> auto_inject_custom_mg "maybe_" is a hedging, non-descriptive prefix. The function is the solve-time hook that AUTO-injects a custom-P MG hierarchy when one is available (solver-set via set_custom_fmg, or mesh-owned on an adapt() child) and no-ops otherwise — so name it for what it does. Pure rename across the solver pyx (4 call sites) + custom_mg.py + the test comment; behaviour unchanged. NVB suite 11/11. Underworld development team with AI support from Claude Code * fix(custom_mg): operator-faithful finest reduced map on adapt children The custom-P finest reduced map was read from the DM global section via _reduced_map(solver.dm, field_id). On an adapt() child the section can be read before the SNES finalizes it, so it could disagree with the assembled operator the PCMG Galerkins against -> a rectangular finest transfer and a bare PETSc error 60 in the PtAP. auto_inject_custom_mg worked around this by SKIPPING custom-P for any semi-Lagrangian AdvDiffusion (DuDt present), falling back to GAMG. Fix, in CustomMGHierarchy.build: - call solver.snes.setUp() before reading the finest reduced map, so the DM global section is the finalized space the operator lives on; - validate the finest reduced-map size against the assembled operator (_assert_finest_matches_operator) and raise an actionable error instead of a cryptic error 60 if a genuine adapt-child section inconsistency ever arises. The mesh-owned auto-pickup wraps build() in try/except, so this degrades gracefully to the default preconditioner. Remove the vestigial DuDt skip-guard: a semi-Lagrangian AdvDiffusion on an NVB adapt child now installs custom-P geometric MG (PC 'mg') and solves correctly, matching a default-preconditioner solve to iterative tolerance. Tests: test_1016 gains test_finest_map_operator_mismatch_raises (guard fires) and test_advdiff_on_nvb_adapt_child_gets_custom_mg (real auto-pickup path, no skip, correct result). test_0838, test_0839, and the full custom_mg suite (1014/1015/1016/1017, 47 tests) pass. Underworld development team with AI support from Claude Code * feat(custom_mg): cross-partition transfer for non-nested coarse tails at np>1 The parallel custom-P path assembled each rank's prolongation block from that rank's LOCAL coarse coords, which is correct only for CO-PARTITIONED NESTED levels (refine() / on-rank adapt). For a genuinely non-nested coarse mesh (independent gmsh mesh at a different cellSize) at np>1 the coarse and fine partitions differ: a fine leaf on rank r can sit in a coarse cell owned by rank s, so rank-local point location misses it -> zero columns (the guard fired: 23 at np2, 48 at np4) or a wrong nearest-DOF fallback. So set_custom_fmg([independent coarse meshes]) was serial-only. Add a cross-partition transfer (_build_crosspart_transfer / _gather_coarse_cloud): a coarse MG level is small, so all-gather the coarse node cloud (coords + each node/component's GLOBAL reduced column index, -1 for constrained) deduplicated by coordinate; every rank then locates its OWNED fine nodes against the FULL coarse mesh. Columns are coarse global reduced indices (MPIAIJ handles off-rank); constrained coarse DOFs stay as barycentric vertices but drop from the columns (reduced->reduced). Fine rows stay rank-local. Routing via cross_partition on CustomMGHierarchy / set_custom_fmg: - "auto" (default): rank-local co-partitioned build first, rebuild a level cross-partition only if it has zero columns -> validated nested/adapt path stays bit-identical on the fast builder, non-nested tails fixed automatically; - True: force cross-partition; False: force rank-local. Validated: an independent (non-co-partitioned) coarse box tail converges in the same iteration count as serial (6 iters) and matches a GAMG reference to ~1e-8 at np1/np2/np4. New tests/parallel test_parallel_custom_fmg_nonnested (np2, np4). Nested co-partitioned parallel tests (scalar, Stokes velocity block, vector) and the serial suite unchanged. Design note updated. Underworld development team with AI support from Claude Code * style: state the sanctioned failure mode on the custom_mg setUp swallow The style gate (Charter S4 except-pass rule, #364) flags a bare 'except Exception: pass' introduced on the stranded branch pre-Charter. Minimal fix: document the sanctioned failure mode; no behaviour change. Underworld development team with AI support from Claude Code * docs: add the two new adapt design docs to the Design Documents toctree LAYER2_SBR_ADAPT_ON_TOP and NVB_GRADED_ADAPT arrive with the custom-mg landing; wiring them into docs/developer/index.md keeps the docs build at (below) the development warning baseline. Underworld development team with AI support from Claude Code * fix(adapt): guard the legacy in-place mesh.adapt(metric) call shape Post-review hardening for the #365 landing. The branch renamed the in-place MMG remesher adapt() -> remesh() and gave adapt() new child-returning semantics, but a legacy caller got no signal: on a plain mesh a bare adapt(metric) raised a confusing "needs refinement>=1" RuntimeError, and on a refined mesh it silently returned an ignored child, leaving the base unmodified. - mesh.adapt(metric) with no new-API keyword now raises a TypeError directing the caller to mesh.remesh(metric) for in-place adaptation, or to pass an explicit keyword (max_levels=, engine=, ...) to opt in to the child-returning nested path. No silent redirection: the return semantics differ. All existing callers (tests, skills) already pass explicit keywords. - tests/test_0834_adapt_legacy_call_guard.py (level_1, tier_a): the guard fires for bare and verbose-only calls, the message names both paths, and an explicit keyword opts in. - Stale in-place docstring/doc surfaces updated to remesh: docs/advanced/mesh-adaptation.md, adaptivity.py docstrings, the _set_coords guidance message, _replace_from_adapted_mesh and Surface._on_mesh_adapted / refinement-metric docstrings. - nvb.py: TODO(#360) comments on the two coordinate-row == vertex-order assumptions (serial-only reachable; holds for fresh createFromCellList / undistributed base DMs; np>1 uses the native transform). Underworld development team with AI support from Claude Code
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 4 of the 2026-07 quality campaign (FO-03): machine-enforce the cheapest-to-check rules of
docs/developer/UW3_STYLE_CHARTER.mdso the cleaned-up state cannot silently drift back.What each gate checks
scripts/check_deprecated_patterns.py(stdlib only, line-based, heuristics documented in its module docstring) scanssrc/for:access-contextwith mesh.access(...)/with swarm.access(...)mesh-datamesh.datacoordinate readshedging-namedef maybe_*/def try_*/def do_*(incl._-private)except-passexcept ...:+ barepasswith no comment on the adjacent linesComment lines are skipped (commented-out legacy
.access()references in the.pyxfiles do not trip the gate). Documented exclusions:self.datainside Mesh methods (not reliably distinguishable by a line scanner) and general string/docstring mentions..github/workflows/style-gates.ymlruns the scanner on every PR todevelopmentandmain— checkout + setup-python only, no PETSc build, well under a minute — and fails with the offending lines plus pointers to the Charter,docs/developer/guides/style-gates.md, and the allowlist policy. A second step prints a report-onlydocs/inventory (currently 254 hits: 161 access-context, 76 mesh-data, 17 except-pass) without affecting the result.Allowlist inventory (baseline development @ 9f5ed9e)
87 legacy hits across 33 files, recorded in
scripts/deprecated_pattern_allowlist.txtaspath:pattern-idlines. The allowlist may only shrink — the header, the scanner output, and the CI failure message all state this; the scanner reports stale entries so cleanups delete them.access-context(13 hits, 1 file):src/underworld3/tests/test_adaptivity_metrics.py— legacy test written against the old context-manager API.mesh-data(2 hits, 1 file): the deprecated property's own docstring andDeprecationWarningmessage indiscretisation_mesh.py— the shim implementation, not usage.hedging-name(5 hits, 4 files):_maybe_install_snes_update(the Charter §3 example itself) and four_do_move()mover closures. Renaming them is library-code churn outside this PR's scope (Charter §9).except-pass(67 hits, 30 files): uncommented swallows concentrated inunits.py(8),discretisation_mesh.py(8),coordinates.py(7),petsc_maths.pyx(4),smoothing/monge_ampere.py(4). Each needs a one-line sanctioned-failure-mode comment; the intended workflow is to add it in files you already touch and delete the allowlist entry.The shim-warning regression net (
tests/test_0641_wave_c_api_shims.py, already in thelevel_1 + tier_aCI gate) complements these static checks by covering the deprecation warnings the shimmed APIs must keep emitting.Black investigation and recommendation
black --check src/underworld3(black 24.10.0, already present in theamr-devenv — nothing installed): 81 of 95 files would be reformatted. A format gate would therefore fail essentially every PR that touches library code, and a changed-files-only advisory would be permanently red for the same reason — pure noise. Recommendation: no format gate now. If the maintainers want black later, adopt it as one dedicated tree-wide reformat commit (with a.git-blame-ignore-revsentry) plus a gate in the same PR. This is recorded indocs/developer/guides/style-gates.mdso nobody "fixes" it piecemeal. Nothing was reformatted in this PR.Local red/green demonstration
python scripts/check_deprecated_patterns.py→ exit 0,OK: no new deprecated patterns (87 known legacy hit(s) allowlisted).with mesh.access(var):pointed at viapython scripts/check_deprecated_patterns.py <scratch-dir>→ exit 1, hit reported with Charter pointer (file not committed).--no-allowlist) → exit 1 with all 87 hits, confirming the allowlist is load-bearing.Other deliverables
.github/pull_request_template.md: nine-line Charter checklist (no template existed before).docs/developer/guides/release-process.md: style gates must be green on the release branch (thedevelopment → mainPR runs them automatically).docs/developer/guides/style-gates.md: compact guide (checks, shrink-only policy, local usage, tripped-gate procedure), linked from the developer guides toctree. Docs build passes; no new warnings.Left for the maintainer
.claude/commands/check-patterns.mdshould point at the scanner as the single source of truth for thesrc/pattern list (keeping its manual notebook/docs sweeps); the session's permission policy blocks edits under.claude/, so that one-paragraph edit is left to a maintainer.docs/hits stay report-only; promoting docs to a gated root is a separate cleanup.pixi.toml/pixi.lockor library-code changes anywhere in this PR.Underworld development team with AI support from Claude Code