Fault ribbons from the fault's own surface, and the fault-patch multigrid (#629) - #638
Conversation
…h walls The zone outcrop no longer needs an axis-aligned wall. The cap over the bowl is re-triangulated PER COPLANAR BOUNDARY REGION, each piece meshed flat in its region's own plane, so the cap stays exactly on the faceted surface and volume conservation survives. Where the band crosses a crease the two sides segment the line differently (mesh vertices against assembly nodes), so a 1-D overlay merges both node sets by arc coordinate and keeps each elementary sub-segment for the side(s) whose bowl covers it and the band does not; a one-side segment must be a whole mesh edge, because a cavity-shell face matches it. The frame rule (_outcrop_frame_3d) separates the two notions the third dimension forces apart. Where the cavity may OPEN is by SMOOTH WALL — coplanar regions grouped across low-dihedral creases — so a faceted sphere's bowl legitimately spills onto facets beside the band's own while a box's other walls stay refused. What the carve may DELETE is by shape: band-covered vertices, single-region interiors, and interior vertices of straight creases between touched regions; a vertex where three or more regions meet is the domain's shape and is protected. A protected vertex whose whole cell star drops is not stranded: it is a collar node, and the gap fill's tets reference it — that is how a curved boundary keeps its faceting through the surgery. Wall labels are restored per removed face (the 2-D per-segment rule one level up): each new wall triangle takes the labels of the removed face it lies on, so a bowl spanning several walls restores each wall's own. The Euler gate now demands CONSERVATION of the input's Euler number rather than 1 — a spherical shell is S^2 x I, Euler 2, and refused before for its topology, not for any defect. The same assumption in place_sheet and remove_embedded is marked TODO(BUG). _trace_wall_code and its refusals are deleted; the box path runs through the general machinery and the box oracle tests are unchanged. Driven on a rotated box (no wall axis-aligned), a band across the Top/Front box edge (trace on both walls), and a spherical shell outer surface (75 trace facets, 6 vertices removed): volume conserved to 1e-12 in each. Underworld development team with AI support from Claude Code
…n a sphere Three configurations, each the failure mode of a different assumption the box-framed cap made: a rotated box (no wall axis-aligned; the single-region collar), a band across the Top/Front box edge (the crease overlay conforms the two sides' differing segmentations, and each wall's labels are restored on its own side), and a spherical shell outer surface (every facet its own region, every vertex protected faceting — the case the trace design exists for). Each runs its negative control first — the identical census on an interior twin counts no trace — and each ends in the P2 Poisson oracle, exact for a quadratic through the zone. Volume conserved to 1e-12 throughout; the shell's Euler number 2 comes through the conservation gate. The parallel file gains the rotated-box outcrop at np>=2 with the same mesh and patch as the serial test: the info dict is identical on every rank and the trace count matches the serial value (28 facets at np=1, 2 and 4), so the general path is partition-independent. No boundary face is left without a wall label at any rank count. The spherical control sits mid-gap of a 0.75-thick shell: a one-cell chain from a victim corner spans h, so an interior zone needs ~(clearance + 1) * h to spare on both sides — thinner shells refuse interior zones at this resolution by the carve's own clearance gate. Underworld development team with AI support from Claude Code
The collar-vs-bowl consistency check raised on the surgery rank inside the relabel block, which is outside any try — a hang at np>=2. It now sets the block's failure and flows through the allgather like every other refusal there. Underworld development team with AI support from Claude Code
The same gate place_thin_volume already fixed: demanding global Euler number 1 encodes a ball-topology domain, and a spherical shell — S^2 x I, Euler 2 — was refused for its topology rather than for any defect of the surgery. Both gates now compare against the input mesh's own number. Pinned by a regression test: a sheet embeds mid-gap in a spherical shell and its removal clears the label again, both passing their volume and conservation gates. Underworld development team with AI support from Claude Code
The gate catches unmeshed solids — an O(1) relative defect — but its reference, OCC's getMass on the clipped boolean, is only accurate to ~5e-7 relative when the domain tool carries many faces (measured on a 16.6k-facet adapted spherical boundary: the honest mesh volume exceeds the reported CAD mass before any snap runs). At 1e-9 the gate refused correct assemblies; it now allows 1e-6, far above the kernel noise and far below any real missing-solid defect. Underworld development team with AI support from Claude Code
OCC's boolean leaves clipped nodes up to ~4e-7 off the tool's own planes on O(1) geometry against a many-faceted tool; the 1e-9 snap missed them, so a crease-crossing node of the band outline was not recognised as on-crease and the collar meshed a 2-metre sliver beside the crease (measured on a 1000 km megathrust against an adapted spherical boundary; the 2-D fill then refused with moved nodes). The snap tolerance now sits above that noise and below any layer mesh size, and candidates are masked by distance to the boundary FACETS first — the planes are infinite, and at this tolerance every point in space is near some plane of a many-faceted tool. Underworld development team with AI support from Claude Code
Two mechanisms a 1000 km outcrop band forced, plus the diagnostic that found them. _collapse_boundary_imprints_3d is the 2-D imprint collapse one dimension up: where the band outline grazes a boundary vertex (measured: a 2.4 m gap the collar meshed as a sliver and the fill refused as moved nodes), the outline is rerouted THROUGH the vertex — the nearest outline node moves onto it, or the outline edge splits at it with every incident tetrahedron bisected. A move or split must keep every incident cell's volume healthy, else the vertex is skipped and keeps its sliver (the status quo, not a defect); the band still tiles the same faceted surface, so the domain's shape is untouched. An outline edge lying ALONG a crease is no longer refused when the cavity covers both sides: the band reaches the crease there, so the edge bounds the collar piece on the FAR side of the crease, not the owning band triangle's own region. The refusal remains for a band bounded by a crease with no bowl beyond it. A collar piece that fails to mesh now names its pinch — the thinnest node-to-segment gap and the node kinds — which is what separated this sliver class from the OCC placement noise the snap fix covers. Known limit, measured and deliberately not papered over: refining a SPHERICAL boundary projects new facet vertices onto the true sphere, and the creases between the resulting nearly-coplanar sub-facets are laterally fuzzy at (placement noise)/sin(theta) — wider than the band's own feature spacing, which defeats the crease overlay. A widened, fuzz-aware overlay tolerance was tried and withdrawn: it traded the sliver for misclassified chains on the honest-crease tests. Outcrops on adapt-refined spherical boundaries stay refused by the fill's own gates until the crease representation is rethought. Underworld development team with AI support from Claude Code
The sheet path joins the general boundary machinery: one clip, one frame, one collar (maintainer ruling 2026-08-18 — multiple placement paths are themselves the defect). The Sutherland-Hodgman box clip and the wall-code frame are deleted. The discrete clip primitive cuts the authored triangulation against the gathered boundary complex directly: per-component outward orientation (a shell's inner surface signs opposite to its outer), signed-distance classification, sequential cuts by the COPLANAR REGIONS' planes — a box wall cuts as one plane however it is faceted, or the two triangles sharing a cut edge key their cuts by different facets and the sheet tears (measured: a duplicated node on the box top wall). Side cuts are re-derived from the original sheet edge's endpoints and interned by (edge, region), so neighbours whose sides are truncated differently produce the bitwise-identical node (measured: ~1e-17 duplicate pairs on the sphere without it). A crossing that can touch a locally concave crease (an inner boundary) refuses loudly — the polyline cut is not built — and every kept node is gated inside the domain on exit. The carve takes the frame's two masks (open_deletable / open_near) like the volume's carve, with the same protected-collar-node rescue; the outcrop frame accepts a trace CHAIN of edges as the footprint alongside a band of triangles; and the collar embeds the chain through its pieces instead of cutting a hole — chain nodes at crease crossings land ON the crease and enter the 1-D overlay as 'a' nodes, the runs between crossings embed per region (_gmsh_fill_2d now takes several polylines), free ends as gmsh's ordinary free-end embed. A trace edge along a crease, or off the bowl, refuses with the reason. The overlay's local loop variable is renamed crease_chain — it shadowed the new parameter, which made the volume path read a stale trace. The trace chain's edges carry <label>_trace in the result, the wall's labels are restored per removed face (the volume path's rule), and the counts are gated collectively. Box oracle unchanged (test_0854, 8/8; area matches Sutherland-Hodgman bit-for-bit in the spike); the sphere outcrop that could not run at all now embeds with volume conserved and the P2 oracle exact (test_0860); trace counts identical at np=1/2/4 (ptest_0854). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The hybrid-seam study measured the defect this guards against: a split surface terminating against something that cannot slip ends as a free crack tip, slip pins to zero, and the composite is worse than either pure representation (74.6% vs 100/105%). The outcrop is that join one level up, so the acceptance test is kinematic — measured slip > 0 at the trace against a pinned control — not mesh gates alone. The placement half asserts today: every trace edge on the wall AND bounding a labelled fault face, the wall's labels restored beside it. The kinematic half attempts split_along_label_3d through the wall and SKIPS at its daylighting refusal — the body below the gate is the acceptance criterion, ready to run when feature/fault-split-node learns to duplicate the trace chain. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The boundary snap's tolerance was raised to 1e-6 to cover OCC's ~4e-7 boolean placement noise (34ea192), but the test's untouched-control point stayed at 2e-9 — inside the new tolerance, so it snapped and the test failed. The control moves to 2e-5: above the tolerance, below any real feature scale. A branch push does not trigger CI, which is how this shipped red. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
Ruling (2026-08-18): split-node faults do not split through the surface. They stop an element or two below, blind, with a damaged region above carrying the deformation to the surface — the junction lessons applied: a contact tip inside weak material is free, a tip at the weak zone's edge is pinned, and an abutting composite is worse than either pure form. This supersedes the split-through-the-wall acceptance this file first carried; the gated body that waited on a daylighting split is removed. The kinematic acceptance turns the listric handover rule vertical, against a FREE surface, and is validated by running (probe in ~/+Simulations/blind_fault_surface_expression/): a blind frictionless split fault two cells under the surface, solved bare / damage-abutting / damage-enclosing, orders strictly on near-tip slip (0.45 / 0.65 / 0.80) and on surface localization (37% / 44% / 50%). The abutting case is the deliberate negative control — the seam defect the overlap margin exists to avoid. The structural test keeps the placement contract: the trace chain labelled through to the wall, now as the surface LOCATOR for the damage region rather than a split path. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…boundary The ruling's other half: the job is still to figure out the intersection so the fault can stop BEFORE it hits the surface — it just never meshes or splits all the way there. place_sheet(..., setback=d) clips the sheet against the boundary offset INWARD by d (each coplanar region's plane shifted along its inward normal; on a curved boundary the shifted faceted planes, within a sagitta of the true offset): the placed sheet arrives BLIND, its rim strictly interior, so split_along_label_3d takes it as it stands — the daylighting refusal never fires. The would-be intersection with the true boundary is still computed from the unclipped input and returned as info['surface_trace'] — the locator for the damage region above. The clip's output is made split-safe by construction: a cut corner polygon (two side-rim originals plus cut nodes) admits no triangulation without an all-rim face, which the split refuses however fine the sheet (measured: 3 such faces at every density tried). The face's longest interior edge is split at its midpoint — bisecting both sharing faces, rim edges (the trace included) untouched, no child worse-shaped than its parent. A centroid split was tried first and REJECTED: a centroid inside a sliver corner face drove the gap fill to 1e-23 cell volumes and a P2 error of 0.64 on the shell outcrop. test_0861 gains the end-to-end workflow test: a through-running sheet placed with setback=0.25 arrives with no trace on the wall, its shallowest vertex exactly a setback below it (the box's offset plane is exact), surface_trace on the true boundary, and the placed patch splits with the Plus side carrying every placed face. Box oracle and the outcrop suite unchanged (test_0854 8/8, test_0860 4/4, suite green, np=2/4). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…tor marches Physics-first ruling (2026-08-19): choose the fault's blind depth from the physics and adapt the resolution to meet it. Two library changes make that workable on the meshes it needs — locally refined shells whose boundaries snap to the true sphere. The clip's concave refusal becomes a MEASURED gate. A snapped multi-resolution boundary is genuinely non-convex where fine facets meet coarse chords, but the concavity DEPTH there is the sagitta mismatch — metres at Earth scale — while an inner boundary's is the facet size. The sequential plane clip over-cuts by at most that depth, so a crossing is allowed when the deepest concave crease among the near facets stays under 20% of the setback, and refused otherwise; setback zero (an outcrop, where cut nodes must land on the complex exactly) still refuses any concavity at all, as before. The surface-trace locator contours the boundary signed distance over the sheet's own triangulation (marching triangles): every crossing point interpolates on a sheet EDGE from that edge's two vertex distances, so the two triangles sharing it produce the identical point and the polyline chains exactly, with no tolerance welding, on ANY boundary — concave, graded, snapped. A direct triangle-triangle intersection was tried first and REJECTED by measurement: robust- geometry endpoint mismatches fragmented a 300 km trace into 148 components. Locator accuracy is the linear interpolant's, O(spacing^2/R) — metres, which is the locator contract. Verified end to end at Earth scale (probe: ~/+Simulations/spherical_slab_outcrop/megathrust_blind_adapted.py): a 19,768-cell shell adapts to 288,525 cells (proportional grading h <= 0.5*distance — required, or coarse deep-rim tets span to the wall and the carve refuses; edge_split engine — NVB's volume proxy leaves diameters ~3x coarser than the carve's reach rules read), and the megathrust places blind at 15 km = 2.5 local elements with the trace located. Suites unchanged: test_0854/0859/0860/0861 21/21, spike green. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…ulates The ruling (2026-08-19): the authored fault triangulation is DATA, at whatever spacing the source provided — but the embedded fault's resolution must match the mesh it cuts, or the verbatim embed forces sliver cells around every mismatched sheet triangle. place_sheet gains size= — the counterpart of place_thin_volume's size, closing the asymmetry the unification left: the clipped sheet's rim is compressed to its exact corners (a too-fine authored rim coarsens here), each straight run is resampled at the target, and the interior is re-meshed by gmsh in the sheet's own plane. Planar sheets only (a curved surface needs a parametric remesh, refused with the reason), and not for an outcropping sheet — its trace chain must stay on the boundary complex verbatim, so blind (setback) and interior placements only. The all-rim split-safety pass is factored out (_split_safe_triangulation) and applied to the resampled triangulation too: a gmsh planar mesh of a polygon produces corner faces with all three vertices on the rim just as the clip's corner polygons did. test_0861 gains the workflow test: a deliberately coarse 3x3 authored sheet placed blind with size=0.1 refines to gmsh-quality faces (min quality > 0.3 asserted), keeps the blind rim exactly on the offset plane, and splits with the Plus side carrying every face. Suites unchanged (0854/0859/0860/0861 22/22, spike green). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
A reach-aware spatial hash (_reach_query: points binned per octave of their own threshold) and a culled distance (_sheet_distance_within: exact wherever it is below the per-point reach, sentinel above it) replace the whole-domain per-triangle sweeps at the placement's nine hot sites — the gather marks, the carve's victim distances, its straddle sweep and centroid rule, in the sheet and volume paths alike. Per-point reaches matter: the thresholds scale with the local cell size, so a single cutoff cannot cull a graded mesh. Every caller thresholds where it reads the distance, so decisions are identical — verified: the placed mesh is bit-identical on the 452k-cell middle-ground benchmark, and place_sheet drops from 555 s to 67 s (the 446 s _sheet_distance share to under a second; the sewn rebuild is now the placement's largest piece at 30 s). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
Three repeated whole-mesh walks removed from the adapt loop, none changing a single cell of the output (bit-identical child on the 452k-cell benchmark): - cell_diameters(return_tables=True) hands its cell-edge and edge-length tables to bisect_longest_edges, which was re-deriving both in the same pass — the per-cell closure walk is the loop's dominant Python cost and was paid twice per pass (309 sweeps -> 155). - cell_diameters' per-cell max is vectorised (a simplex has a fixed edge count, so the ragged list stacks). - The MG level selection re-measured every retained generation's 5th-percentile diameter at the end — 76 more topology walks — when the marking pass had just computed exactly those numbers; they are cached per-dm and passed as resolution_hint. Adapt on the benchmark: 658 s -> 551 s. The remaining ledger is the engine surgery recorded on #610 — per-pass clone + label writes inside the split call (242 s), MG parent/prolongation maps built per pass (107 s), independent-edge selection (54 s) — the plan-in-numpy / apply-back-to-back and multi-edge-template items. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The subsampler was already discarding every per-pass parent map whose span covered more than one pass — nearly all of them — so ~80 s of the benchmark's 107 s of map building produced maps that were thrown away, while the retained multi-pass levels got None and the any-degree transfer fell back to the geometric builder. The engine loop now records nothing; the subsampler derives parents for the 3-4 RETAINED pairs from the composed vertex transfer (nested_cell_parents is topological through the transfer, and a descendant's referenced coarse vertices are corners of its ancestor at any depth), so the deferral is also an upgrade: multi-pass levels now carry exact parents. Measured on the 452k benchmark, bit-identical child: parent-map cost 86 s (x75) -> 7.8 s (x9); adapt 551 -> 473 s. The bisect interior was also split with instrumentation: of its 242 s, the native transform's setUp+apply is 187 s (~2.5 s per application — the per-pass C cost is honest, the x76 pass count is the waste) and the independence pruning 54 s; clone and label writes are negligible. The remaining #610 levers are therefore pass-count reduction (multi-edge templates, C-side) and the plan-in-numpy replacement of the per-pass Python (~170 s). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
The metric's far clip must sit above the base mesh's measured cell DIAMETERS, not its nominal gmsh cellSize: diameters run 1.2-2.5x the target edge length, edge_split marks on diameter, and a clip below them refines the entire domain once — wasted cells AND a measured de-conditioning of the far field (median shape quality 0.372 -> 0.295), which every later adapt and solve inherits. Found by eye in the viewer (scattered one-level patches across the globe), confirmed by measurement, fixed by h_far >= 1.05 * cell_diameters(base.dm).max(). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_01WDKP73LD3dCFQSNgEXbbw9
…620) The zone walk went through _cells_on_edge, an EDGE walk: correct for a 2-D facet (an edge), one level too low for a 3-D facet (a face), where support-of-support lands on nothing and the zone comes back all-False, silently. The method had only ever seen 2-D meshes. Facets now take getSupport directly — the cells, in any dimension — and the edge walk is kept for labelled edges (a trace chain). Fixes #620. Underworld development team with AI support from Claude Code
The Mesh-level form of place_sheet, mirroring add_conforming_surface: the sheet is cut at the finest level ONLY, and the child inherits this mesh plus everything below it as its coarse multigrid tail — the coarse levels carry neither the cut nor the label, and do not need to (the Galerkin coarse operators are formed from the fine operator; measured in add_conforming_surface's note). The adoption — wrap, bookkeeping, and the "a cut earns a level only if it is genuinely finer" subsample — is extracted from add_conforming_surface into _adopt_cut_child and shared, so the two dimensions cannot drift. The method takes explicit (points, triangles, name): a sheet is DATA — a slab model, an authored parameter-space triangulation — whose connectivity must be embedded verbatim, and FaultSurface re-derives its triangulation so it cannot carry an authored one. setback (blind faults, surface_trace in child._surface_info), size (resample to match the mesh being cut) and clearance pass through to place_sheet. test_0862 mirrors the 2-D contract of test_0844: the tail composition and the replace-not-stack decision, the named boundary and its zone, the untouched base, chaining, the duplicate-name refusal, and a solve with a per-cell contrast across the sheet consuming the cut mesh as it stands. Underworld development team with AI support from Claude Code
_build_3d(mesher="place") placed each sheet at DM level and wrapped the result in a bare Mesh, so the adapt child's multigrid tail died at placement and every solve on the composed mesh fell back to algebraic multigrid — not by design, but because the handoff that add_conforming_surface performs in 2-D was never written here. The route now chains add_conforming_sheet, so each cut child inherits the hierarchy; the split at the end still forfeits it (see add_fault), but the intermediate cut mesh now carries the tail that split-aware multigrid would need. The recorded place-route pathology (solve 3850 s vs 125 s embed, on edge_split children) predates this handoff and wants re-measuring on top of it. Underworld development team with AI support from Claude Code
…rator health (#621) The 3850-vs-125 note said the composed chain's solve was pathological on adapt children. Measured again with the tail handoff in place: 27x the cells embed builds for the same nominal sizes, 41x the time — which is proportionate under 3-D Stokes scaling, with comparable per-cell cost, one nonlinear iteration, machine-zero leak and agreeing slip. The over-build is the route's own sizing (base at cellSize=h_far with refinement=1 puts the far field at h_far/2). Docstring restated; the sizing fix is #621. Companion measurements: #622 (custom-P V-cycles across a cut level cost 7x more than GAMG saves), #623 (fill volume drift on steep gradings). Underworld development team with AI support from Claude Code
inject_custom_mg re-ran build + install on every solve: a duplicate Jacobian assembly (done only to make the fieldsplit reachable), a PCMG reset + Galerkin setup, and a from-scratch geometric transfer build — measured at 55 s of every repeat Stokes solve on an 85k-cell cut child, while the transfers depend only on the meshes and PETSc re-Galerkins changed operator values at PCSetUp by itself. The guard checks the LIVE PC (the managed block exists, is a PCMG, carries the hierarchy's level count) against a marker written at install; any doubt re-installs — the cost of a wrong False is one redundant install, the cost of a wrong True would be a solve on a stale PC. Measured (place_route_health benchmark): repeat-solve overhead 55 s -> 0; a genuine solve (operator values changed) keeps mg:4its on the velocity block against gamg:46 and now comes in ahead on wall time as well (284.6 s vs 307.7 s with the fgmres outer). Underworld development team with AI support from Claude Code
…e sub-blocks but never the top (#624) SNES_Stokes_SaddlePt sets both fieldsplit sub-solves to fgmres but never pushed an outer ksp_type, so every saddle-point solve ran PETSc's default plain gmres around variable-iteration Krylov inner solves — a non-constant preconditioner under which standard GMRES's recurrence does not hold, the same reasoning as the velocity-block FGMRES note (#147) one level up. Measured on a genuine 85k-cell contrast solve: 14,400 inner iterations / 543.9 s under gmres; 307.7 s under fgmres (gamg velocity), 284.6 s (guarded custom-P). Pushed as a managed option in both the constructor and the strategy setter, so an explicit user ksp_type still wins. test_0203's lower-bound test asserted a STRICT undercount on the first solve's velocity count — an artifact of left-preconditioned gmres, which burned one full preconditioner application before its first monitor fired. Right-preconditioned fgmres forms the unpreconditioned residual first, so the bound is now tight; the test asserts the contract (second >= first, plus the honesty flag) instead of the artifact. The remaining genuine-solve bottleneck is the pressure sub-solve at its 200-iteration gasm cap, silently unconverged — #625. Underworld development team with AI support from Claude Code
…), plus a zero-column repair for placed levels The "rbf" transfer builder assembled and solved the GLOBAL dense coarse-cloud kernel matrix and returned nnz/row == n_coarse — dense Galerkin coarse operators, no conditioning path, a rescue that could not scale (#429, agreed long ago and never landed; the interpolation note recorded it as "Not rewritten"). It now delegates to the standard kd-tree local linear-exact interpolator (kdtree.interpolation_matrix, order=1) — the same polyharmonic kernel and reproduction guarantees, sparse support, the interpolator the rest of the code uses. A row-wise kNN builder guarantees nonzeros per ROW, never per COLUMN (#424): on NON-NESTED level pairs — two independently placed meshes (#626), a relaxed child — a coarse DOF outside every fine stencil gives an empty column and a singular PtAP. The serial build loop now repairs such columns by nearest-fine-DOF injection (weight 1, same component), warns with the count, and still refuses if any column stays empty. These are preconditioner transfers, not the discretisation: an injected row costs iterations at worst, never correctness. First measured use: the split-finest-over-unsplit-band ribbon hierarchy (6 of ~50k columns repaired; converged, machine-zero leak, answer unchanged). Suites: test_1015 / test_1016 / test_0753 — 28 passed, 1 pre-existing conditional skip. Underworld development team with AI support from Claude Code
…622) The rotated custom-P path hardcoded coarse="svd" for the velocity PCMG because a free-slip enclosure's Galerkin-coarsened velocity block inherits the rigid-rotation null modes and redundant/LU zero-pivots on them. But nsp being non-None cannot discriminate — it also carries the constant-PRESSURE mode (enclosed domains), which lives outside the velocity block — so a Dirichlet-walled problem with no rotation modes still paid a DENSE SVD factorisation of a 3-D P2 coarse level. _rotated_nullspace now records the count of VERIFIED rotation modes on the solver, and the coarse solve is SVD only when that count is nonzero; the fallback (count unknown) keeps SVD whenever nsp exists, so free-slip shells are untouched. Measured on the split-fault contact benchmark: the SVD was NOT the dominant cost (the per-cycle weight is the level-smoothing footprint of a localized-refinement hierarchy — the background rides at full size on every band level — recorded on #622); the gate is correctness of configuration, not the headline saving. Suites: 0844 / 0845 / 1015 / 1016 / 0203 — 63 passed. Underworld development team with AI support from Claude Code
The multigrid transfer needs more than DOF coordinates. To evaluate a coarse Lagrange basis inside a parent cell it must know WHICH rows of _get_coords_for_basis are that cell's own nodes -- and the section that answers that lives on a coordinate DM _get_coords_for_basis builds and then destroys. _basis_coordinate_dm is extracted from _get_coords_for_basis (second occurrence of the same construction) so both read the same layout, and _cell_node_indices walks that DM's local section over each cell's transitive closure. Degree 3, where an edge carries two DOFs, falls out of the offset/ndof expansion; a discontinuous space hangs every DOF off its own cell and needs no special case. Non-simplex meshes are refused explicitly: a tensor-product Q_k cell carries (k+1)^dim nodes, so the total-degree monomial basis the transfer builds would not be square against them. Cached beside _coord_array and cleared with it -- both describe the same node layout, and it is the DS re-creation, not node motion, that invalidates either. Tested geometrically rather than against itself: every node a cell claims must lie inside that cell, checked with barycentric coordinates built from the cell's vertices, at P1/P2/P3 in 2D and 3D for both continuities. A wrong section offset points at another cell's node, which is somewhere else in the mesh, so it cannot pass. Also asserted: node counts equal C(k+d, d), continuous rows are all covered, discontinuous rows partition cell-block-contiguously (the layout _build_kd_tree_index_DS already assumes), and Q_k is refused. Underworld development team with AI support from Claude Code
…geometric transfers, and FAC patch smoothing (#629) Item 1 of the #629 program, plus the fill mechanism it exposed. A level pair that is a native refine() pair (tagged by _coarse_level_meshes via hierarchy-slot sentinels) now gets the EXACT nested prolongation at any polynomial degree: parent cells recovered topologically from the two DMs, weights by the dual-basis identity W = B M^-1 under the parent's affine pullback (the #425 construction; verified exact to 1e-15 at P1/P2 in 2D and 3D). PETSc's DMCreateInterpolation general path was measured NOT to be the embedding (row sums to 1.375, quadratic reproduction error 1e-2) and is not used. The larger density win turned out to sit beside it: the point-located builders emit ~1e-16 junk weights that are STRUCTURAL nonzeros, and Galerkin RAP fills by structure, compounding level over level. Dropping them (_drop_structural_zeros) collapsed the split-contact benchmark's Galerkin chain from 319/481/265/90 nnz/row to 138/119/173/90 — the item-1 acceptance band — and the warm full-tail contact solve from 97 s to 52 s at bit-identical answers (slip 0.1509, leak 0). Full-tail iterations reproduce the recorded 6-7 under the robust smoother (the 6-its dial row was gmres/4; the script's fast row runs 12). Item 2: FAC/MLAT patch-restricted smoothing. Each level's patch is read off its own transfer (identity row + bit-coincident node = background, per NODE so the rotated path's per-node Q cannot disturb it; slit duplicates count as patch; halo = one coarse-cell layer through the transfer graph), stashed on the hierarchy, and consumed by _configure_pcmg: the level smoother PC becomes ASM with that single subdomain. Three configuration findings are load-bearing: * BASIC, not restricted, ASM: discarding the halo correction stalls the outer KSP at 80-375 iterations where basic runs 6 against a whole-level baseline of 4 (banded Poisson, 4-level tail). * The subdomain solve is SOR, the patch twin of the whole-level smoother: PCASM's default ILU-0 takes a NUMERIC_ZEROPIVOT on the rotated Galerkin patch block (min |diag| 8e-5 near the constraint; PC_FAILED -11 before the first iteration). * The rotated path must force the FAC levels' smoother setup before its options-DB cleanup: PCASM creates the sub-KSP lazily at first apply, after the keys are gone, so sub options silently never applied. A uniform pair (patch = everything) declines to whole-level smoothing. Measured on the split-contact ribbon: correct answers with rotation and contact, but a net cost at THIS toy's proportions — the ribbon band is 66-82% of every refined level's DOFs, so there is no background to save and the ASM gather/scatter is overhead (warm 60 vs 52 s). The FAC win requires production proportions (thin patch in a large domain), which is where the item-3 contrast test goes next. The UW_FAC_* / UW_CUSTOM_MG_* environment knobs are TODO(MEASURE)-marked A/B affordances for that campaign. Tests: test_1017_nested_native_and_fac.py (exactness by degree-k reproduction, tag discipline, split classification with a geometric oracle, end-to-end ASM solve vs GAMG); test_1015/1016/0753/0846/1014/ 1017/1018 suites green. Underworld development team with AI support from Claude Code
…tem 3 diagnosis) The contrast sweep's FMG degradation (velocity 7 -> 10 -> 36 its over 1e0 -> 1e4, where constant V-cycles are the expectation) is DIAGNOSED and FIXED at the smoother: * Level ablation at 1e4: full/no_mid/two tails converge in 20/19/24 its — the coarse corrections contribute nothing under zonal contrast; the weak-band modes are invisible to every coarse space, including the band-carrying mid level. * Doubling the smoother (gmres/8 vs /4) exactly halves the count (20 -> 10): convergence is proportional to total fine-level smoothing work — the signature of smoother-limited band modes. * FAC with a DIRECT subdomain solve (sub_pc lu) on the patch — which contains the weak zone — collapses the count to 2 its at 1e4 and 3 at 1e6: V-cycle constancy across six decades of contrast, below the flat-viscosity count (7). Two supporting changes: factorization sub-solvers get sub_pc_factor_shift_type=nonzero (the rotated Galerkin patch block carries near-zero pivots from the constraint-zeroed transfer rows — unshifted LU takes NUMERIC_ZEROPIVOT even as an exact factorization), and UW_MG_SMOOTH_ITS joins the TODO(MEASURE) campaign knobs. Remaining engineering to make the strong-patch cycle cheap: shrink the subdomain from the whole refined patch to the weak zone + halo, and reuse factors across cycles; the wall-time gate at contrast stays the pressure gasm cap (#625). Underworld development team with AI support from Claude Code
…duplicated nodes, not the refinement patch (#629) The refinement patch conflates the smooth refined bulk (well served by ordinary multigrid) with the fault zone (the only set needing the strong solve). The slit mode keys the subdomain on the split-duplicated nodes with operator-sparsity overlap as the halo — proportional to the fault trace, not the refinement. Validated on the 2-D line rig: constant 6-7 velocity iterations across six decades of viscosity contrast on a 5%% subdomain, against a GAMG control that degrades to its iteration cap. Underworld development team with AI support from Claude Code
The placed ribbon joins the multigrid design in three roles: the bridge level structure between the standard mesh and the patch, the AUTHORED damage-zone identifier (the placement zone label survives the split as cell children — never a distance-mask staircase or a cells_supporting zipper), and one mesh discipline for split, TI, and weak fault representations alike. Junction default: split segments a short distance apart, the intact gap is the linkage; painted cores are opt-in physics. Open engineering: partition weighting for the band and split-pair co-residency in one mechanism. Underworld development team with AI support from Claude Code
…rrection, #629) Louis's correction: split surgery runs in the local frame after distribution, so a pair is born on its parent facet's rank and cannot be separated — no partitioner constraint exists. The real rules: never distribute a pre-split mesh (the slit is a zero-cost graph cut, so a partitioner would preferentially separate the sides while the contact coupling lives outside the graph — gate that pipeline), and keep the star forest consistent where a fault crosses a rank seam along strike (the known np>=3 line-cut item). Ribbon balance reduces to ordinary cell-count weighting. Underworld development team with AI support from Claude Code
The finite-width ribbon is a modelling object in its own right, with three sanctioned physics: a damage zone (when a damage evolution equation is solved — damage is a field, not a paint), a nonlinear plastic/yielding material (so failure patterns EMERGE in the resolved band — the mechanism by which junctions form rather than being authored), and a permeable zone for fluid flow. A hand-painted static weak viscosity is none of these — neither gouge nor a fault. The fault itself is always the split surface in the mesh; ribbon physics complements the slip surface. With none of the three physics present, the ribbon carries background rheology and is purely resolution. All roles compose with the solver design unchanged. Underworld development team with AI support from Claude Code
…ication, #629) A fault may legitimately be REPRESENTED as a weak / TI weak zone carried by the ribbon — then w is a PHYSICAL parameter (the fault-zone width), chosen appropriately and resolved by ~2 elements across. In a split-node model, w is a mesh-bridging convenience with no physical reading, and nothing rheological may be keyed to it. The recurring campaign error was mixing the readings: split nodes plus band-wide weakness with w chosen as a mesh number is neither model. Underworld development team with AI support from Claude Code
… in-repo, composed benchmark test The fault-zone patch key settles into API: set_custom_fmg(..., fac_zone=mask | [masks]), validated against the finest mesh at registration; the solver._fac_zone_cells attribute spelling is retired loudly (setting it raises rather than declining silently — the #629 campaign's sharpest instrumentation lesson). The #589 empty-stratum getIndices() segfault is fixed at source: utilities/dm_labels.py provides label_stratum_indices() gated on getStratumSize (safe on both the null-IS wrapper and values outside the live set), and the dead `is None` guards in nvb/reconnect/fault_split — petsc4py returns a non-None NULL-handle wrapper, so they never fired — are routed through it. mesh.cells_labelled(name, value) is the empty-safe cell-mask accessor for placement labels (the natural fac_zone builder). The transfinite ladder band (#595: three nodes across, rails + exact centreline, mandatory for spine cuts) moves in-repo from the campaign scripts as place_thin_volume(..., mesher="ladder"). tests/test_1022_composed_ribbon_fmg.py (tier B) enshrines the composed 2-D benchmark of record: native level densities, the 2-block finest patch (zone + automatic structural union), iteration bounds, slip/leak invariants. Negative control verified (FAC disabled fails the block assertion). The benchmark reproduces bit-for-bit on the in-repo path; the 3-D pure-contact composition measures 7 velocity iterations vs GAMG 56 with identical physics (design note updated). Underworld development team with AI support from Claude Code
…629) A split-node fault is the efficient fault representation and runs no zone patch: the structural patch is automatic where it matters, and under pure contact the strong patch is redundant outright — measured in 3-D, velocity iterations are 7 with the zone patch, 7 with the structural patch, and 7 with no finest patch at all (the cover gate declines the band-shaped patch of the non-nested placed pair). The fac_zone key is reserved for volumetric representations — weak / TI / damage / gouge rheology, where the zone width is physics — and the ribbon is never the key: it is resolution, not rheology. The design note records the ruling under patch keying, plus the answer to why 2-D and 3-D economics differ: the 2-D ladder's band levels nest by construction (36 -> 72 rungs, an exact 2:1 transfinite pair), so placed pairs behave like native refinement; the 3-D ribbon layers are independent unstructured fills at unrelated sizes and share nothing, which is what fattens the Galerkin chain and prices the V-cycle. The 3-D fix, when wanted, is nesting the band levels, not more patch. test_1022 restructured to the ruling: test 1 enshrines pure contact with the structural patch alone; test 2 covers the fac_zone union machinery and the loud retirement of the _fac_zone_cells spelling. Underworld development team with AI support from Claude Code
…esh (#629) place_thin_volume(mesher="ladder") in 3-D takes the fault surface's own structured discretisation — a (grid, normals) pair — and offsets it ±width/2 into two prism layers split to tets (Dompierre subdivision; quad-diagonal compatibility proven against the analytic skin count). The mid-surface is a real vertex sheet, so the slit is coordinate-set selection with no plane test, and a 2:1 subsampled grid's band shares every vertex with the fine band through placement and split. Exact where exactness is checkable: planar volume to 1e-12; an over-curved extrusion is a refusal, never a reorder. Measured on a curved fault (sinusoidal bulge, campaign rig): physics identical to GAMG, 6-7 velocity iterations against 60-65. The corrected diagnosis, recorded in the design note: vertex nesting alone does not collapse the Galerkin chain — the unstructured FILL SHELL between band skin and coarse background is the node majority (51% of finest P2 nodes, nesting 3%), and vertex nesting is not P2 nesting (10% inside the band). Thinning the shell (clearance floor) brings level 1 to native density. The residual warm-time gap is the rotated path's per-solve transfer rebuild (the un-guarded rotated twin of #622) — the prerequisite fix for wall-clock claims on the contact chain. Geometry tests appended to test_0855 (volume, curvature refusal, nesting, input contract). Underworld development team with AI support from Claude Code
The user supplies the fault surface as their own structured point grid — curved is fine — and one call thickens that grid into the resolved band (extruded prisms, no remesh), embeds it, labels the mid-surface (coordinate selection + rim erosion, now in-repo as _label_mid_surface), splits it into a frictionless-ready fault whose label is a boundary for add_fault_bc, and builds the nested 2:1 unsplit bridge level for the multigrid hierarchy. One parametrisation throughout: normals default to grid-derived and the mid level always subsamples the same field, so the levels nest by construction and the inconsistent-normals footgun cannot be expressed. Defaults follow the measurements (clearance 0.3, the thin fill shell); split=False keeps the labelled unsplit mesh for painted weak/TI models on the same geometry. The 3-D ladder assembly also accepts a bare grid now. The campaign rig, rebuilt on this path, reproduces its record bit-for-bit (21,352 cells, 1,150 slit faces, 675/675 nesting, level densities 103/84/151/93, velocity 6 iterations, slip 0.1523). End-to-end wrapper test added to test_0855. Underworld development team with AI support from Claude Code
… lever is level economics (#629) The design note's claim that the rotated/contact path rebuilds its transfers every solve is retracted: it described the pre-fix state from earlier in the campaign and was repeated without re-measuring. A cProfile of the repeat solve shows the cache working end to end — the geometry tier of _rotated_linear_cache carries the rotation and the custom prolongations, the context carries the KSP/PC, and the operator tier skips assembly and PCSetUp on an unchanged matrix; 46 of 48 seconds are inside the composite Krylov solve, with no build anywhere. The warm cost structure is the Schur loop times the V-cycle price (full factorization = one velocity solve per pressure iteration), and the measured lever is tail depth: on the curved-ladder stack, dropping the near-fine mid level reaches GAMG parity warm (24.0 vs 23.0 s) at 7 velocity iterations against GAMG's 65, with the remaining transfer chain entirely native density — the Galerkin fat was the mid level alone. The mid level's role is production proportions; at rig proportions the tail of choice is [L0, L1] + finest. Physics identical in every arm. Underworld development team with AI support from Claude Code
…chanism) Measured on the parity-tuned [L0, L1] + finest curved-ladder stack, warm repeats, leak machine-zero in every arm: gamma=1 shortens the pressure loop 22 -> 17 with the velocity count unmoved (7), taking the warm solve to 19.1 s against GAMG's 23.0 — FMG ahead outright at flat viscosity. gamma=10 over-stiffens the velocity block (7 -> 12) and reverses the gain. No Schur plumbing was needed: the penalty is viscosity-scaled by construction, so penalty/mu is uniform and the 1/K pressure-mass preconditioner remains spectrally correct. Caveats recorded with the result: under penalty the recovered p is the Lagrange multiplier (p_mech = p - lambda*mu*div_u for any pressure-dependent rheology), and derived quantities drift with gamma (peak slip 1% at gamma=1, 3% at gamma=10 at rig resolution — the same bias family as #633's vertex-sampled dynamic topography). gamma=1 is the sanctioned choice. Underworld development team with AI support from Claude Code
…rapolated, never confiscated (#629) A fault specified from a structural model has its extent as data; the wrapper previously took the grid as the band and labelled the slip surface inset_rings inside it, so the requested fault came out smaller than specified. Inverted: the grid IS the fault, labelled on precisely the supplied points, and the band is built on the sheet continued margin_rings rings outward along its own end tangents (_extend_grid — linear per ring, corners consistent, normals continued the same way and renormalised; no curvature the data never asserted). The 2:1 bridge level subsamples the extended parametrisation, so nesting is unchanged. margin_rings >= 1 is enforced: a split may never reach the band rim, and the margin comes from invented surround. Measured on the curved rig (29x29 fault, margin 2): 1,566 of the 1,568 requested triangles split — only the two corner triangles erode (the splitter's no-interior-vertex refusal, a half-cell nick) — nesting 867/867, chain 95/80/95 nnz/row, velocity 8 iterations. Peak slip 0.1737 against 0.1523 under the old inset: the confiscated rings were biasing the physics by 14%, and the honoured value matches the full-patch embed reference (~0.174). GAMG parity exact (slip 0.1737, leak 2e-17, 70 its vs 8). Underworld development team with AI support from Claude Code
… parametrisation (#629) The 2-D ladder was transfinite and straight-only; the S-fault rig (San Andreas bend + through-line branch, designed with Louis) needs bent traces. _ladder_curved_assembly_2d is the 3-D extrusion one dimension down: the polyline resampled equispaced in arclength, offset ±width/2 along mitred vertex normals (constant width through turns, sharp turns refused), three rails of shared vertices, alternating diagonals, mixed-orientation triangles a refusal. No gmsh, no CAD. Straight polylines keep the gmsh transfinite path, so the recorded composed benchmark stays bit-identical (verified). The nesting contract carries over from 3-D and is enforced by API shape: coarser levels must SUBSAMPLE one fine parametrisation — the assembly accepts precomputed (samples, reach) for exactly that, and independently recomputed reach vectors were measured to break rail coincidence (only the spine nested). With the shared parametrisation: 132/132 mid band vertices coincide on the rig's tanh S trace. Underworld development team with AI support from Claude Code
…#629) The 2-D production path with the full contract set: each supplied polyline IS a fault, its points becoming the band's spine vertices verbatim (the curved ladder's (samples, reach) patches — one parametrisation per trace, extended margin_rings by tangent continuation); traces are placed sequentially as stop-short strands (the junction ruling) and cut+split in ONE add_fault network call — chained calls were measured to drop the earlier fault's pairing records. The (samples, reach) tuple patch is wired through the 2-D ladder hook with the domain gate shared between paths. visualisation/glyphs.py brought over from feature/stress-glyphs (PR #601) for the rig's standard figures: dCFF slip-vs-welded and principal-stress trajectory nets over a fine grey mesh. Validated on the S-fault rig (tanh S + through-line branch, the geometry designed with Louis): 1,858 cells, native chain (24-27 nnz/row), sub-second warm solves, machine-zero leak on both strands, first partitioning number branch/main = 0.41, locked welded state reproducing uniform shear to 2.6e-5. Tests: curved-ladder nesting + refusals, and the two-strand wrapper with honoured trace vertices. Underworld development team with AI support from Claude Code
… — render nodally Louis's catch on the S-fault rig's dCFF panel: resampling a recovered P1 field onto a regular pixel grid via uw.function.evaluate produces artefacts across the elements — grid points that straddle a facet get located into a neighbouring cell with slightly-off reference coordinates. The rule added to the skill: render derived fields NODALLY on the mesh's own triangulation (vertex evaluation is exact for P1 whichever cell the locator picks; VTK interpolates within elements), and on a split mesh never Delaunay the DOF cloud — it re-triangulates across the slit. Underworld development team with AI support from Claude Code
…outcrop-3d-cap # Conflicts: # src/underworld3/cython/petsc_generic_snes_solvers.pyx # src/underworld3/discretisation/discretisation_mesh.py # tests/test_0203_solver_wallclock_guard.py
|
Adversarial review (self-review per project practice; findings verified against the diff, most significant first). 1. The honoured-paint rule is documentation, not API. The campaign's measured lesson — rheology painted on the whole band makes the fault mechanically longer than requested (tip lobes ~2 elements past the mapped tips) — is enforced nowhere: both wrappers return band labels only, and every consumer (our own rig scripts included) reconstructs the fault footprint by hand with geometry-specific code. The mistake we made twice remains one careless mask away. Proposed fix, cheap and general: the wrappers already know each trace's extended parametrisation, so a nearest-sample test (is the centroid's nearest rung a user point or an extension point?) yields a per-strand footprint mask to return in 2. 3. The ladder domain gate is bounding-box only. A structured band inside the domain's bbox but outside a non-convex domain passes 4. 5. Scope notes verified rather than assumed: the Tests after the development merge: touched suites 66 passed; Underworld development team with AI support from Claude Code |
… trace labels are refused The fault-footprint mask is now returned by both wrappers instead of living only in documentation: place_fault_ribbon reports info["footprint"] and place_fault_ribbon_2d reports per-label info["footprints"] — band cells whose nearest extended-parametrisation sample is a USER point, so painted rheology and fac_zone keys can no longer silently extend into the extrapolated tip margin (the mistake measured twice in the campaign: whole-band paint put tip lobes about two elements past the mapped tips). The test is geometry-free — the parametrisation itself carries the user/extension distinction — so it works for any curved strand. place_fault_ribbon_2d also refuses duplicate trace labels at the API boundary rather than failing downstream with an ambiguous-boundary shape inside the add_fault network call. Both fixes carry assertions in test_0855 (footprints strictly inside the band, subset of it, per strand; the duplicate-label refusal). Underworld development team with AI support from Claude Code
|
Review findings 1 and 2 are fixed on the branch: the fault-footprint masks are returned by both wrappers (finding 1 — the honoured-paint rule is now API, with tests asserting the footprint is a strict subset of the band per strand), and duplicate trace labels are refused at the API boundary (finding 2). Finding 3 (bbox-only domain gate) remains a documented limitation with a loud downstream refusal; findings 4 and 5 are state, not defects. Underworld development team with AI support from Claude Code |
…st-local-h headroom in test_0861 test_1021 asserted the pre-#629 blanket rule (the rotated coarse solve is always svd). The campaign gated it: svd only when the rotated problem carries a VERIFIED rigid-rotation null mode, because blanket svd was measured as most of ~0.8 s per V-cycle (#622). The bundle fixtures pin the inner annulus boundary, so no rotation mode survives and redundant/LU is the correct coarse solve there — the assertions now say so. The svd arm keeps its regression protection through a new test: rotated free-slip on BOTH boundaries leaves the genuine rotation mode, the builder must verify and record it, and the coarse solve must be svd (this is exactly where redundant/LU hits its zero pivot, #306). test_0861's two setback placements failed only on CI: with the 0.6 default clearance the carve cavity extends ~(clearance+1)*h_local, and at the worst local h a different gmsh version deals this box that overruns the 0.25 setback margin. clearance=0.3 (the measured production thin-shell choice) keeps the cavity inside the margin at worst-case h; the tests pass locally under both settings. test_0054's failure is the CI-flaky hang-watchdog family (development's own latest CI failed sibling test_0053); no change here — rerun. Underworld development team with AI support from Claude Code
|
CI triage and fixes (f31b9eb), one cause at a time:
Underworld development team with AI support from Claude Code |
…ver dumps sources (#638 CI, test_0054) Root cause, established with native thread samples of two live hangs: faulthandler.dump_traceback_later(repeat=True) walks live frames from its C thread without synchronisation, and fired against a still-importing interpreter that walk loops forever (locally: the C thread pinned in dump_traceback for the whole sample window, the main thread starved mid-import) or reads garbage and dies at SIGSEGV (the CI -11). The env-armed watchdog was arming during `import underworld3.mpi` — inside the very import it then dumped over. The failure is conditional, which made it look flaky: it needs the import slow (cold bytecode caches, as on CI or straight after a build) and piped child output (as the test runs), and this branch's larger import graph pushed the import past CI's 1.0 s trigger where development stays under it. Fixes, each a real hardening: (1) mpi.py preloads traceback/linecache/tokenize so the reporter thread never enters the import system; (2) _stack_dump formats with lookup_lines=False — file names, line numbers and functions carry the hang report, and the source-text reads were reporter-side IO against modules mid-import; (3) report() re-arms its own Timer only — re-issuing dump_traceback_later cancels the C thread and cond-waits on it mid-dump (the sampled deadlock triangle); (4) the structural fix: the environment-armed watchdog arms at the END of `import underworld3`, never during it, trading self-coverage of the import graph for safety everywhere the tool exists for (documented in _watch_from_environment). Validation: the previously first-run-reproducing context (piped children after a fresh build, 0.2 s interval) went from 12/15 frozen to clean in the test's own scenario; the actual test passes 6/6 locally. Known latent issue, pre-existing and out of scope: a natural process exit while a repeat dump is in flight can hang finalisation (the test SIGKILLs and never sees it). Underworld development team with AI support from Claude Code
|
test_0054 root-caused and fixed (4b5ad9c). Native thread samples of two live hangs establish the mechanism: Four hardenings, the last structural: the reporter thread pre-loads everything it touches and formats stacks without source-text reads ( One latent issue noted for the record, pre-existing and untouched: a natural process exit while a repeat dump is in flight can hang finalisation (the atexit disarm waits on the dump). The test SIGKILLs its child and never sees it; an env-armed production run that exits during a dump tick could. Worth its own small issue. CI re-running now. Underworld development team with AI support from Claude Code |
Fault-resolving meshes built from the fault's own surface, the solver machinery that makes them cheap, and the measured record behind both. This is the #629 campaign branch; the issue thread carries every measurement in sequence.
What this delivers
Mesh side — the fault is the input. The user supplies their own discretisation of the fault (a structured grid in 3-D, polylines in 2-D; curved is fine) and one call builds the resolved band by offsetting that discretisation — no remeshing — embeds it, labels and splits the mid-surface, and returns levels that nest by construction:
place_fault_ribbon(base, sheet_grid, width)— 3-D: extruded prism band (always two layers across any width), split mid-surface as a boundary ready foradd_fault_bc, nested 2:1 bridge level.place_fault_ribbon_2d(base, traces, width)— 2-D networks: several strands placed sequentially, stop-short (the junction ruling: the intact gap is the linkage), cut and split in oneadd_faultnetwork call.place_thin_volume(..., mesher="ladder")— the underlying band builder. Straight 2-D traces keep the gmsh transfinite path; curved 2-D and all 3-D bands are pure numpy extrusions with mitred offsets and loud refusals (over-tight bends, inverted extrusions, protruding bands).mesh.cells_labelled(name, value)— empty-safe cell masks from placement labels.Three contracts, each learned from a measured failure: the supplied geometry is honoured exactly (the tip margin is extrapolated surround along end tangents — shrinking the request biased 3-D peak slip by 14%); any painted rheology stops at the fault footprint, never the margin (whole-band paint made the fault mechanically longer than requested); and every coarser level subsamples one parametrisation (independently recomputed normals or reach vectors break vertex nesting — measured in both dimensions).
Solver side — the fault-patch multigrid. The design note (
docs/developer/design/fault-patch-multigrid-2026-08.md) is the specification; in brief: ordinary Galerkin multigrid handles everything smooth, and small strong ASM subdomains keyed on the fault physics restore the multigrid contract where coarse spaces cannot see.set_custom_fmg(..., fac_zone=...)keys the finest level's strong patch on painted cells (volumetric fault representations only — a split-node fault needs no zone patch, by measurement); the structural (cut/split) rows are unioned in automatically, and the oldsolver._fac_zone_cellsspelling raises rather than being silently ignored.refine()pairs get the exact any-degree nested prolongation.utilities/dm_labels.pyfixes the The general 3-D outcrop cap: a zone may meet any boundary #589 empty-stratumgetIndices()segfault at source; the deadis Noneguards innvb,reconnectandfault_split(petsc4py returns a non-None NULL-handle wrapper) are routed through it. This piece is independent and cherry-pickable if the rest is held.Measured record (rig scale; the #629 thread has full context)
test_1022_composed_ribbon_fmg.py): FMG 8–9 iterations against GAMG 82 at native operator density; slip and leak identical; the zone patch takes a 1e4 contrast from 34 iterations to 2.[L0, L1] + finestreaches GAMG wall-clock parity with 8x iteration headroom; the AL penalty at gamma = 1 puts FMG ahead outright.Tests
New or extended:
test_1022(composed benchmark, tier B, negative control verified),test_0855(3-D ladder geometry: exact volume, curvature refusal, nesting; the curved 2-D ladder; the two-strand 2-D wrapper),test_1017_nested_native_and_fac,test_0860–0862(outcrop, daylighting, conforming sheet), parallelptest_0854/0855. Touched suites andlevel_1 and tier_apass locally after the development merge.Known-remaining, deliberately out of scope
UW_FAC_*/UW_CUSTOM_MG_*/UW_MG_SMOOTH_ITSenvironment knobs are still TODO(MEASURE) A/B affordances; retiring them awaits the production-proportion measurements.visualisation/glyphs.pyduplicates PR Visualisation: principal-stress glyphs and stress trajectories #601's file byte-for-byte so the fault figures work from this branch; whichever PR merges second dedupes cleanly.Underworld development team with AI support from Claude Code