Skip to content

Changelog

Unreleased — shell-on-solid conformity (S1a + S1b + S2 + S5) · Phase SSI-2.D stage-bound BCs and recorders · embedded-element pipeline hardening (#329 / #331) · ASDEmbeddedNodeElement option exposure (ADR 0035) · stage-bound constraints + s.initial_stress PUSH (Phase SSI-2.D extension) · Phase SSI-2.E between-stage Domain mutators · topology safety nets (P1/P3) + arc-line wire docs · embedded-host decomposition (ADR 0036) · higher-order line broker split (ADR 0037) · RecorderDeclaration element fan-out fix · orphan-geometry sweep unification + g.model.geometry validation API · split-sweep auto-validation (closed-world / open-world) · raw-PG channel for _user_intentional · g.model.geometry.add_arch (apex-as-vertex two-arc arch) · damping definition ops.damping / s.damping (ADR 0053, D1–D5) · Ladruno J2 plasticity materials (LadrunoJ2 / LadrunoUniaxialJ2 / LadrunoJ2Finite) · Ladruno material wrappers (LogStrain / InitDefGrad / StagedStrain / LadrunoRebarBuckling) · Ladruno live Monitor recorder (ops.recorder.Monitor + read_monitor / tail_monitor) · LadrunoBrick fail-loud on a finite-strain material under geom != "finite" · add_rectangle(plane=…) canonical-plane rectangles · ops.ndf for element-less decoupled nodes + per-node ndf gates G1–G3 (ADR 0049 DOF half) · node-pair ops.element.ZeroLength/CoupledZeroLength/TwoNodeLink(nodes=…) springs to a decoupled ground (ADR 0049) · g.parts.add_plane_wave_box — soil box + ASDAbsorbingBoundary skin (ADR 0054, AB-1a) · ASDAbsorbingBoundary3D bridge element + ops.element.absorbing_boundary (ADR 0054, AB-2) · s.activate_absorbing() staged absorbing-boundary flip (ADR 0054, AB-3) · plane-wave SSI worked example (ADR 0054, AB-4) · g.parts.add_absorbing_shell — bring-your-own-box absorbing skin (ADR 0054, AB-1b) · loads / masses fit the per-node ndf not the model envelope (mixed-ndf from_model silent-drop fix) · layered (stratified) absorbing boxes + per-layer material (ADR 0054, AB-1c layered slice) · absorbing-skin aspect-ratio warning + centred-box mesh fix; rotation documented as unsupported (ADR 0054, AB-1c close-out) · staged-model H5 archival — write + read (ADR 0055 Phase 2, P2.1 + P2.2, schema 2.18.0) · results-viewer event/state Phase 1 — composition gate revived for backend-routed diagrams + outline eye-toggle dispatcher routing + deformed-ghost runtime state · REMOVED — deprecated standalone apeGmshViewer/ app · viewer state-contract V1 — dispatcher-always + owner-fired events + gesture_batch (ADR 0056) · ActiveObjects initial-state seed + qt-marked window tests runnable per-file · viewer state-contract V2 — AST guard test_viewer_state_contract.py (ADR 0056 INV-5) · viewer state-contract V3 — mesh viewer joins the dispatcher (owner-fired VisibilityManager/OverlayVisibilityModel + owned overlay scales + widened guard) · viewer state-contract V4 — model viewer joins (double-render retired; ActiveObjects kept as focus-state owner, OQ3 resolved) · viewer state-contract V5 — projection audit (Session tab rebuilds from owners; never-worked "Load arrows" scale slider fixed); ADR 0056 Accepted (runway V0–V5 complete) · LadrunoQuad fork plane element (ops.element.LadrunoQuad, tag 33007) · LadrunoCST fork plane triangle (ops.element.LadrunoCST, tag 33008) · solution-strategy ladder + established profiles (ADR 0057 Phase A) · partitioned-H5 baseline fixes — capture dedupe + partitions restore + INV-5 fallback round-trip (ADR 0055 Phase 5 / P5.0) · fiber diagrams sit at the beam's TRUE integration stations (FiberSlab.station_natural_coord from MPCO GP_X / .ladruno GP_PARAM / live integrationPoints) · g.constraints.kinematic_coupling now emits the fork LadrunoKinematicCoupling (RBE2, tag 33012) — BREAKING, replaces the equalDOF expansion · g.constraints.distributing_coupling (RBE3) ships — emits the fork LadrunoDistributingCoupling (tag 33011), replacing the NotImplementedError stub · degraded GP world-coordinate reconstructions are loud (WarnGaussCoordsApproximate) · diagram scalar-state consolidation — ScalarColorSupport mixin + base _scoped_results (a set_fmt now survives colormap changes on every diagram) · viewers consume the remaining recorder channels — diagrams orient from .ladruno LOCAL_AXES + plot.energy / plot.node_envelope + dim-based plot facets · static gauss contours (plot.contour(topology="gauss", averaging="averaged"|"discrete")) + plot.fibers dot cloud · local-axes overlay triads resolve recorder-first (parity with the diagram frames) · partitioned-deck getPID shim guards with info commands (every MPI rank built rank 0's submodel) · partitioned emit: shared-node mass / pattern load lines dedup to the node's primary rank (OpenSeesMP sums them — interface nodes carried 2–3× mass) · Ladruno recorder whole-model energy channel (ops.recorder.Ladruno(energy=True)-G energy, emitted last) · deform-follow regression fixed — contour / fiber-section / layer-stack / spring-force diagrams ride the deformed substrate again (dead _sync_layer_grids walk removed) · declarative diagram-kind registry (ADR 0058 S0) — four drifting per-kind tables collapse into @register_diagram_kind; loads/reactions survive session restore + presets; reactions catalog options un-shadowed · geometry→scene resolution seam (ADR 0058 S1) — director.scene_for(geometry) + registry scene_resolver, per-geometry DEFORM pump + scoped fan-out, reference_points moves onto FEMSceneData; copy cost measured (~7 MB / 2 ms at 124k cells → plain copies for S2, no COW) · absorbing-boundary guide (internal_docs/guide_absorbing_boundary.md) · remote HPC job submission (apeGmsh.hpcCluster.submit/Job over SSH + SLURM, ADR 0060) · ops.run_remote one-call remote analysis + Job.wait (ADR 0060 sugar) · coupling control knobs — g.constraints.kinematic_coupling / distributing_coupling accept k / kr / enforce / bipenalty_dtcr / absolute (CouplingControl, neutral schema 2.12.0) · coupling-knob H5 schema completion — sr_cpl_* mirror lane on surface_coupling + dtype/parity test reconciliation (post-#630 main fix) · partitioned staged H5 archival — last staged guard lifted, rank-agnostic stage capture (ADR 0055 Phase 5 / P5.1, schema 2.19.0) · staged domainChange is unconditional — pure-loading stages no longer merge into the previous MODEL_STAGE in the MPCO/Ladruno recorders (+ numeric stage-stamp ordering in the readers + viewer positional stage pairing) · RBE3 tributary-area weighting — distributing_coupling(weighting="area") computes per-independent areas and emits -w · RBE2 partitioned (OpenSeesMP) emit — single-canonical-rank routing for kinematic_coupling (was fail-loud) · docs: guide_constraints.md coupling sections reconciled (fork RBE2/RBE3 emit targets, knobs, area weighting, mortar refusal) · per-rank Tcl deck emission — driver + ranks/rank<K>_<seq>.tcl sourced fragments (apeSees.tcl(per_rank=True), ADR 0061) · ADR 0055 ACCEPTED — compose filtered-audit (compose_inspect['filtered']) + real-staged-archive FILTER verification (Phase 3); staged-H5 runway complete · partitioned staged flat replay + domain-capture gate retired (ADR 0055 Phase 5 / P5.2 + P5.3) · coupling host auto-scalers (k="auto" / k_alpha / host / bipenalty_wcap) · concurrent geometry rendering — per-geometry visible flag (ADR 0058 S2b)

ADDED — ops.uniaxialMaterial.MultiLinear (piecewise-linear backbone)

uniaxialMaterial MultiLinear is now a typed primitive: ops.uniaxialMaterial.MultiLinear(points=((u1, f1), (u2, f2), ...)), taking positive-branch (strain, stress) / (deformation, force) breakpoints and emitting uniaxialMaterial MultiLinear $tag $u1 $f1 $u2 $f2 .... The material is odd-symmetric — the constructor mirrors each breakpoint onto the negative branch (MultiLinear.cpp:109-123) — so __post_init__ refuses any breakpoint strain <= 0, refuses fewer than two breakpoints (the parser itself refuses a single pair), and requires the strains to be strictly increasing. A flat list of alternating values instead of pairs is a loud ValueError rather than an unpacking TypeError. Only the strains are constrained: a descending force branch (post-peak softening) is a legitimate backbone.

Both strain rules guard measured behaviour. A (0, 0) first breakpoint — which the official OpenSees docs example opens with — makes the initial slope s(0)/e(0) a 0.0/0.0 NaN (MultiLinear.cpp:113); the origin is implicit, so the first pair must be the first nonzero breakpoint. A non-monotonic strain list is merely warned about, with a message that misdescribes its own recovery ("Continuing with strain_i+1 = 1.2*strain_1" — the code does no such thing, MultiLinear.cpp:102-107), and construction then proceeds on the raw values, so equal strains divide by zero and decreasing strains yield a negative segment slope (MultiLinear.cpp:121). This is stock OpenSees (MAT_TAG_MultiLinear = 52), not a fork addition, and Tcl and openseespy share the one OPS_MultiLinear factory, so the flat argument list is identical on both targets.

This closes the gap that forced characterised force–deformation devices — a yielding splice / structural fuse whose trilinear law is the specification, carried on a ZeroLength DOF — to reach the deck by text insertion into the emitted .tcl. The near-miss is deliberate: Hysteretic with the same three envelope points is a different OpenSees class with its own unloading, pinching and damage rules. Measured on a monotonic push the two are bit-identical (worst difference 0.0 over a 320-step sweep, and Hysteretic extrapolates on the last slope as well), so a push-only law-reproduction gate cannot detect the substitution at all — the class choice is the protection, not the gate. The docstring says so.

Documented behaviour worth keeping: past the last breakpoint MultiLinear keeps the last slope — it does not cap the force and it does not go flat, because MultiLinear::setTrialStrain clamps its branch search to numSlope - 1 and extrapolates from the last breakpoint on the last segment's slope (MultiLinear.cpp:183-186). A trilinear law rides K3 indefinitely, which is how a model states "there is no bearing backstop at this rung"; a plateau needs an explicit final near-flat segment. A live acceptance test drives a zeroLength (ndm 2 / ndf 3, -dir 2) in compression to 16 mm in 320 steps and gates the backbone reproduction (worst error 2.3e-13 kN against the analytic law), the value at the last breakpoint, the K1 secant, and that the force keeps rising past the last breakpoint rather than capping.

FIXED — Studio habitats bound to $HOME / cwd instead of their own root (ADR 0095 Amendment 12)

resolve_root read only APEGMSH_ROOT, but the habitat template stamps APEGMSH_STUDIO_ROOT into .cursor/mcp.json and scripts/_habitat.py exports that same name — so every habitat produced by init was unbound at the MCP door and fell through to the ancestor walk, then to process cwd. INV-15's chain now reads root= / --rootAPEGMSH_ROOTAPEGMSH_STUDIO_ROOT → nearest .apegmsh/ → cwd (APEGMSH_ROOT wins if both are set; blank is still unset, so an empty APEGMSH_ROOT cannot shadow a real APEGMSH_STUDIO_ROOT). The template and _align_mcp_json now stamp both vars, and a new studio-test fixture clears both so the suite cannot inherit a habitat from the developer's shell.

Accompanying library policy: a user-level ~/.cursor/mcp.json server is bound for every project, so it must carry no root and no cwd; each project stamps apegmsh-studio-habitat with its own Studio root — the overlay folder (e.g. Staged Model/) for a Workbench project with a Studio overlay. CONTRACT_VERSION is unchanged at 1.8.0.

ADDED — typed SANISAND materials + s.update_material_stage (ADR 0101)

ops.nDMaterial.ManzariDafalias and ops.nDMaterial.SAniSandMS are now typed, frozen dataclasses (18 and 19 required doubles respectively, plus an all-or-nothing 5-slot integration tail: int_scheme tan_type jaco_type tol_f tol_r). Landing alongside them is the stage-bound s.update_material_stage(materials=[...], stage=0|1) mutator on the staged context manager — the staged-gravity idiom (build elastic, solve gravity, flip to plastic, push) that neither material was useful without.

SAniSandMS.int_scheme outside {1, 3} raises ValueError: values 0/4/6/7/8/9 hit exit(0) branches in SAniSandMS.cpp:1240-1254 that kill the Python process with no traceback. ManzariDafalias.int_scheme 3 or 5 instead only warns (SanisandIntegrationWarning) — their adaptive-substep code is dead and the yield-drift correction is commented out, so they integrate with no error control (a reported triaxial characterisation came out 31-46% too strong on scheme 3). SAniSandMS.tol_r raises NotImplementedError: the vanilla parser's tail arithmetic (SAniSandMS.cpp:134) never consumes it. SSP_UNSAFE_MATERIAL_CLASSES now warns when either SANISAND material pairs with a LadrunoBrick/LadrunoQuad under formulation="ssp", since both models hard-code their ssp stabilization reference at p = P_atm regardless of real confinement.

s.update_material_stage is validated twice: at the call site (unregistered handle or a non-STAGED_MATERIAL_CLASSES material is a loud BridgeError, not a silent no-op) and at build time by a new V7 pass (_validate_material_stage_targets) that catches a measured OpenSees runtime fact — updateMaterialStage resolves through the Domain's live elements, not the material registry, so a flip aimed at a material with no live element yet silently no-ops at run time. V7 turns that into a build-time error naming the offending stage and material tag.

RO variants (ManzariDafaliasRO, etc.) are intentionally not exposed: they are registered only in the Tcl builder, never in the openseespy command map, so a typed primitive for them would fail on the py/live/run emit targets.

H5 schema 2.20.02.21.0 (additive): each stage group gains an optional (N, 2) int64 update_material_stage dataset, replayed through both the flat and partitioned from_h5 paths.

See ADR 0101 and internal_docs/guide_opensees.md §2.1.

CHANGED — the "constant-memory partitioned emit" claim is corrected, and the session-close contract is written down (ADR 0100, route D0)

ops.tcl(stream=True) makes the deck text O(1); it never made emit constant-memory. The build-side Python object graph still scales with N, and a 51.0 M-hex / 52.6 M-node partitioned deck OOM-killed a 60 GB cluster node mid-emit at ~61.3 GB RSS (~1,200 B/hex). The skill reference's # constant-memory partitioned emit line and the prose above it now say what is true and point at ADR 0100.

The same measurement produced an invariant worth stating outright: close the session before emitting a large model. The emit path reads FEMData only and never calls Gmsh, so a still-open kernel is pure overhead — leaving the with block dropped RSS 31 → 18 GB on that model before build() even started. The partitioning guide, the bridge guide, and the bridge concepts page now say so instead of demonstrating it by accident.

Docs and skill text only: no code, no API change.

CHANGED — ADR 0100 gate G0 is closed; the R-table gains R8 and D5 is re-priced (Amendment 1)

The partitioned-emit residency campaign ran at four mesh sizes and two rank counts. G0a = 0.55-0.61 over the terms ADR 0100 authorised when its decision rule was written, with 13-16% unattributed at every cell.

Three measurements corrected the ADR's pre-measurement guesses. A full per-element reverse tag map, ops_tag_to_fem_eid, was found resident inside _emit_stages_partitioned at 103-228 B/elem across cells (~5-12 GB at the incident's scale) -- larger than several named terms and missing from the original ledger; it is now R8. The _PG_FANOUT_CACHE arrays measure 72 B/elem and stay deliberately unrouted, but are recorded rather than assumed. And R7 (class_chunks) measures 0.00 MB at the resident peak -- the transient is real but lives early in emit, so the ADR's speculation that it "may be the largest single win" does not survive contact with the peak, and D5's class_chunks half is de-priced.

G0b was not computable as originally defined (per-cell RSS/traced ratios spanned 0.68-87.35, including physically impossible sub-1 values). It is redefined as a ratio of regression slopes and measures 0.88 at bench scale -- below the ~1.9 the middle branch assumed, explained by an obmalloc reservoir left by the meshing phase that absorbs emit allocations at these sizes. That precondition is therefore recorded as unverified rather than met.

Docs only: an ADR amendment. No code, no API change.

CHANGED — ADR 0100 Amendment 2: P3 shipped, D5 re-priced UP, the incident is not yet closed

The merged D3+D2+D4+R8 slice (#1077) roughly halves emit-side residency: traced peak growth -48%/-54% and RSS ~-40% at two mesh sizes, for +6-7% emit time (flat in N). RSS is the OOM-relevant statistic and is quoted alongside traced throughout.

It does not close the incident. Extrapolated to the 51.0 M-element incident the traced peak falls from ~48-50 GB to ~23-25 GB -- real progress against a 60 GB node, not a fix.

And it inverts one of Amendment 1's conclusions. That amendment de-priced D5's class_chunks half because R7 measured 0.00 MB at the peak -- but the peak it measured against was the staged residency P3 has now removed. With that gone the peak migrates into infer_node_ndf, where R7 is the binding term at ~370 B/hex, 76-83% of what remains, matching this ADR's original ~344 B/hex estimate. D5's class_chunks half is restored as the largest remaining target. The general lesson is recorded with it: a term's measured share depends on where the peak is, and removing one term moves the peak.

Docs only: an ADR amendment. No code, no API change.

CHANGED — ADR 0100 Amendment 3: G2/G3 measured on esmeralda, the ceiling did not move

The post-P3 51.0 M-hex rung now builds+emits (job 145608, exit 0 -- it OOM-killed pre-P3) but with under 2% RSS improvement over the pre-P3 incident's own re-measured memlog (job 145221), not the bench-scale ~40%. The capacity ladder self-stops at the very next rung, 71.3 M hexes (job 145616, OOM-killed exit 137) -- 100 M and 139 M never ran.

071M's OOM happens before build_rung.py ever prints FEM built, i.e. before get_fem_data() extraction completes and before _emit_partitioned (the code P3 touches) is ever reached -- named as an open instrumentation gap, not resolved here.

A level-ratio cross-check (RSS growth / Amendment 2's traced-peak extrapolation, 1.80-1.97x) sits in the vicinity of ADR 0065's historical ~1.9x allocator/fragmentation multiplier, though it is not the same statistic as Amendment 2's slope-based G0b(0.88) -- read as order-of-magnitude, not a precise validation.

G2's solve (job 145609, the emission-correctness check at scale) did not clear the SLURM queue during this campaign and is recorded as an open item, not a result.

Docs only: an ADR amendment. No code, no API change.

CHANGED — ADR 0100 Amendment 4: the solve-based emission-correctness verdict is PASS

Closes follow-up 2 of Amendment 3's three open items (follow-ups 1, the build_rung.py phase-marker gap, and 3, D5's own cluster check, remain open). Job 145609 (the first solve attempt) died instantly to a harness bug unrelated to ADR 0100: run_p6.sbatch called bare srun. This campaign's jobs were submitted via non-interactive ssh calls whose PATH lacks /opt/slurm/bin -- the reason submit_p6_ladder.sh's own sbatch calls already use the absolute path; run_p6.sbatch's srun call was never given the same treatment. The pre-P3 sweeps that used bare srun successfully were submitted from an interactive shell that already had /opt/slurm/bin on PATH -- a different shell, not the same one. Fixed to the absolute /opt/slurm/bin/srun and resubmitted as job 145623, which completed: 240/240 profile_*.h5 written, all 240 ranks report ANALYZE_MS (min 516,017 / max 571,849 / mean 563,730 ms) and 20/20 steps complete, zero error/exception/traceback/segfault matches in 215 KB of stderr.

Verdict: PASS. The P3-columnar-emitted 51.0 M-hex / 157.8 M-DOF deck assembles, numbers, and steps end-to-end on OpenSeesMP at np=240 (a null-excitation deck -- this proves parse/assembly/step completion, not a loaded response) -- the at-scale confirmation Amendment 3's framing of G2 asked for, complementing the bench-scale byte-identity proof already on record.

Incidentally, collect_sweep.py on the completed rung gives a fresh LadrunoParallelRCM numberer data point (dc.numberDOF 215.96 s at 52.6 M nodes) that extends the pre-P3 sweep's N^0.99 (near-linear) finding one more rung -- the numberer stays exonerated as a large-model cost driver.

Docs only: an ADR amendment. No code, no API change.

FIXED — a node line now carries exactly ndm coordinates (2-D -ndf / -mass were silently dropped)

The broker stores every node as (x, y, z) and the emitters wrote all three, so a 2-D deck emitted node 1 0.0 0.0 0.0 -ndf 2. OpenSees reads ndm coordinates and then scans what follows for optional flags, so the padding desynchronised that scan and the flag was never consumed — measured on Ladruno 25a0647f: the node silently kept the envelope ndf, and -mass produced "incorrect number of nodal mass terms".

The dropped -ndf was the dangerous half. ADR 0032/0033 per-node ndf was inert in every 2-D deck: a gated continuum element (tri6n / LadrunoLST / quad) still parsed, because the builder-ndf bracket satisfies the parser gate, but setDomain then bailed on the wrong node ndf without setting the element's domain pointer and the deck died at analysis with FATAL FE_Element::FE_Element() - element has no domain. That is the second half of what blocked the mixed-ndf lined-tunnel / SSI shape; ADR 0099 was the first.

TclEmitter / PyEmitter / LiveOpsEmitter / RecordingEmitter now trim node coordinates to the ndm they learn from model() (trim_coords_to_ndm in emitter/base.py). 3-D decks are byte-identical — the trim is the identity on a 3-tuple — and H5Emitter still archives the full (x, y, z). The foreign-node-decl recognisers in tests/opensees/_helpers/partition_diff.py accept 1–3 coordinates instead of exactly 3.

FIXED — the builder-ndf bracket destroyed every declaration above it (ADR 0099)

open_builder_ndf_bracket wraps a gated element block (quad / tri6n / LadrunoQuad / LadrunoCST / LadrunoLST / LadrunoUP) in a model BasicBuilder re-issue. That re-issue deletes the Tcl model builder, and its destructor purges the process-global timeSeries / geomTransf / beamIntegration / damping registries (fork TclModelBuilder.cpp:681). apeGmsh emitted all four ABOVE the bracket, so any model combining a gated continuum element, a frame element (which forces the mixed-ndf envelope) and a load pattern emitted a deck that died at pattern Plain — the standard lined-tunnel / SSI shape. damping was worse than the rest: region -damp only warns, so such a deck ran to convergence and reported an undamped answer.

The flat emit path now hoists its gated element blocks above every builder-scoped declaration, so the last model line in the deck precedes all of them (INV-1); gated parsers only ever need an nDMaterial, which survives the re-issue. A new validate_builder_scope_ordering fails loud where that is not yet possible: the split, partitioned and H5-replay paths (INV-4), a stage-activated gated element whose bracket fires mid-deck (INV-4), and quad(damp=...), which is self-wiping (INV-3). Those decks previously emitted and either died late or ran wrong.

repros/repro4_builder_scoped_wipe.py is the executable survival table, measured against the fork binary.

FIXED — Pardiso / Mumps always emit -matrixType (incl. 0)

Pardiso(matrix_type="unsymmetric") mapped to code 0 then if code: treated that as false, so decks showed bare system Pardiso even when the caller asked for unsymmetric storage. Same trap on typed Mumps. Both now always emit -matrixType N as an int (fork OPS_GetIntInput). Unit expectations updated. Skill refs (opensees-bridge / ladruno / gotchas) document the explicit flag and the flat-deck LadrunoContact auto-emit (do not double-declare).

FIXED — the flaky suite segfault: the cyclic GC was finalizing Qt off the UI thread (#1080)

The Linux suite job had been dying with exit 139 and no failing test, hitting main and three PR branches on 2026-08-25/26 including docs-only diffs. The crashing test was tests/sections/test_builder_gui_b6.py::test_panel_matches_headless_build_off_ui_thread — named by a flush-per-test nodeid plugin in 5/6 CI shards, and corroborated by all four production failures stopping at exactly the same -q progress index (7409).

A core dump named the frame: Shiboken::BindingManager::runDeletionInMainThread reached from make_pending_calls, calling through a NULL pointer — PySide6's cross-thread deletion queue draining an entry that no longer described a live object.

August's 47e20ca6 kept the properties worker off the Qt object graph by reachability, making the thread target a module-level _work(...). That was necessary and not sufficient: reachability governs only what the worker's own references can free, while the cyclic collector runs on whichever thread trips the allocation threshold — and this one allocates hard (document parse, FEMData unpickle, the NumPy solve). A gen-2 pass landing there while an earlier builder window's widget tree was unreachable finalized that Qt graph off the GUI thread, and the main thread died draining the queue at whatever test it happened to have reached. Hence: intermittent, load-dependent, and blamed on a test that does nothing wrong.

The worker now runs under a _no_cyclic_gc() pause. Refcount-driven frees are untouched, so nothing leaks — cyclic garbage simply waits for the next collection after the build. The pause is refcounted across overlapping builds and restores whatever it found, so an interpreter that already had the collector off keeps it off. Three regression locks in tests/sections/test_properties.py pin all three properties, and they fail on the unfixed module.

Measured on the harness that pinned the bug — one cold production-identical suite run per CI shard, because looping attempts inside one job warms the page cache and hides the crash entirely: 6/12 shards crashed before, 0/12 after, with the tests still in the suite.

docs/api/constraints.md named Tier 5 — Fork in its taxonomy table and then never defined it: Tiers 1, 2, 2b, 3, 4 and 6 each had a section, Tier 5 had none, so contact was the one constraint verb with no entry on the published API surface and contact_plane appeared nowhere on that page at all. Both now render from their ContactDef / ContactPlaneDef docstrings, and the taxonomy row links to the section instead of dangling.

The skill cheatsheet's coupling source-map footer pointed at ConstraintsComposite.py:1595 / :1878; the live lines are :2121 / :2404. This is the sibling of the contact footer corrected in #1051 — the line numbers were re-derived now rather than carried over from that PR, since the file moved again during the S6 work.

CHANGED — one node-coordinate invariant, one enforcement point (ADR 0099 reconciliation)

A 2-D node line must carry exactly ndm coordinates: a padded third desynchronises OpenSees' optional-argument scan, so the following -ndf K is present and inert and the deck dies later at setDomain with no clue pointing back. That rule was enforced twice — once in the build layer (node_coords_for_ndm) and again, last, at every text/live emitter (trim_coords_to_ndm, ADR 0099). The emitter copy always won, so the build-layer copy changed no production deck while still costing the ndm threading that existed only to feed it.

With one precondition, now made explicit. trim_coords_to_ndm reads the ndm the emitter learned from model(), and a bare emitter that never saw one has ndm = None and trims nothing. Production is safe by construction — emitter.model(ndm=, ndf=) is step 1 of apeSees.emit, unconditional and ahead of every node — but three interface unit rigs built an emitter directly and skipped it, so the build-layer trim was the only thing keeping their goldens two-coordinate. Removing it made them emit node 101 1.0 0.5 0.0 -ndf 2: the padded third coordinate, stranding the -ndf, which is the exact defect ADR 0099 exists to prevent.

The goldens were right and the rigs were wrong, so no expected value was edited — the rigs now issue model() first, as production does, and the goldens pass unchanged. That is the honest fix; rewriting the expectations to match the new output would have buried a real precondition.

node_coords_for_ndm is now node_coords_as_floats and has no ndm parameter: the dimension question belongs to whoever writes the line. That removed 7 function parameters and 24 argument passes across build.py, compose.py and apesees.py.

What did not go is the float() coercion, and that distinction is the whole reason this is a rename rather than a deletion. The broker hands out numpy scalars; TclEmitter renders an unknown numeric via repr; under numpy 2.x repr(np.float64(0.0)) is the literal string np.float64(0.0). A stray numpy coordinate would emit node 1 np.float64(0.0) np.float64(0.0) — and miss the emitter's plain-float fast path on the way. So the build layer still guarantees plain floats; it just no longer has an opinion about how many of them belong on the line.

Verified as a no-op before touching the suite: decks, model.h5 and the H5Emitter sidecar are byte-for-byte identical across a 2-D model at z=0, a 2-D model at z=5 (the only case where the two trims could have disagreed, since the emitter trims for the deck while the archive keeps what it is handed), and a 3-D model. Six new tests pin the split — coercion here, dimension there — including the numpy-repr hazard that keeps the coercion honest.

Noticed, not fixed: validate_adaptive_element_endpoints, resolve_ndf_overlay and validate_record_ndf_consistency carry an ndm parameter that was already dead on main, unrelated to this change.

CHANGED — S4's three -thickness conventions re-measured on a trustworthy build

The adoption-S4 figures were credited to fork build e7555f2c9. That stamp was wrong — the worktree binary that produced them misreported its own hash (it contained ADR-85 F1, which landed after e7555f2c9; see the ladrunoBuild staleness entry). The numbers were internally consistent, so the earlier correction flagged the provenance and left them alone. They are now re-measured against b17e8bd82, whose stamp is trustworthy because rebuild wipes build/ and forces a CMake reconfigure.

All three conventions hold.

Convention Result on b17e8bd82
absent ≡ -thickness 1.0 bit-identical — 5.051658913343455e-07 both
h applied once, not squared penetration ratio h=0.5 : h=1.0 = 2.0
-epsN auto never h-scaled bit-identical at h=1.0 vs 0.53.2823219478178336e-06 both

The middle one is worth a note, because a single deck does not return exactly 2. Sweeping epsN across five decades gives 1.99978 (1e8), 1.99808 (1e9), 1.99392 (1e10), 1.99995 (1e11), 2.000000018 (1e12). The interface force is fixed by equilibrium, so penetration goes as 1/(epsN·h) and the ratio tends to exactly 2 as the interface stiffens; the sub-1 % wobble at intermediate stiffness is elastic coupling in the bodies, not an effect — which would have shown as 4.0, and never does at any stiffness.

Absolute values differ from the originals because the rig differs. What is pinned is the law, and each law reproduces. The S4 tie equilibrium residual (0 to 2.3e-10 on a 1e6 load) was not re-measured and keeps its original provenance caveat.

FIXED — DomainCapture queried a different OpenSees module than the bridge was driving

DomainCapture._lazy_ops() resolved its OpenSees module by import openseespy.opensees, while a live :class:LiveOpsEmitter imports opensees. Those are the same module object in a normally-wired venv (both are the installed opensees.pyd), so the two names were treated as interchangeable. They are not: put a second OpenSees in the interpreter — a locally built fork on PYTHONPATH beside a stock openseespy in site-packages — and they resolve to two different modules with two different domains.

The capture then samples the empty one. Every nodeDisp returns WARNING no response is found and surfaces as opensees.OpenSeesError: See stderr output, while the analysis it is meant to be sampling ran, and converged, in the other module. Nothing in the message points at the module split.

It is a convincing impostor. Found while validating a fork rebuild: test_shell_on_solid.py::test_node_ndf_roundtrips_through_domain_capture failed against the new build and passed against the installed one, which reads as a solver regression and was initially called one. The A/B was confounded — the two runs differed by venv as well as by build, and only the venv mattered.

_lazy_ops now prefers the module the bridge is actually driving (bridge._live_emitter.ops), falling back to the import as before. The resolution stays late rather than moving into __init__: a caller enters ops.domain_capture(...) before analyze() creates the live emitter, so there is nothing to bind to at construction time. An explicit ops= still wins, since that is the seam the capture's own tests mock through.

Verified on the exact scenario that produced the false positive (stock openseespy in site-packages + fork build on PYTHONPATH): the failing test passes, and the live lane against that build goes from 69 passed / 1 spurious failure to 70 passed, leaving only the three raises_friendly_error_on_pre_adr44/46_build tests that are designed to flip once a fork build gains those features.

ADDED — live contact queries: ladruno_contact_force and friends (fork ADR-85, adoption S6 thin)

The last adoption slice, in the shape the fork actually supports. S6 was scoped as "Results reads contact data" — but LadrunoContact is a constraint handler plus a contact domain, not an Element, so it has no element response and therefore no recorder channel. Nothing reaches .ladruno, model.h5 or Results; there is nothing on disk for a reader to read. Contact is live-query-only, so this ships four thin fork-gated bridge wrappers instead of a Results layer:

ops.ladruno_contact_force(node)     # NTS-only normal-force MAGNITUDE
ops.ladruno_contact_info()          # ContactInfo(...) + .total_contacts
ops.ladruno_mortar_penetration()    # mortar ALM measure — a LENGTH
ops.ladruno_mortar_tie_residual()   # mortar-TIE ALM measure

Each needs a prior live analyze() and a fork build; stock openseespy is refused by name rather than dying on a bare AttributeError, and the no-live-analysis error says explicitly that there is no recorded alternative, because that is the first thing anyone asks next.

Four limits are documented on every one of them, because none is visible in the returned number: the force query is NTS-only (a mortar or rigid-plane slave always reads 0.0 — neither lane has a force query at all); it is a magnitude, not a vector (near a corner or the D4 end-cap it equals no single global force component); a released 3-D pair reports its last-active force forever (a known, deferred fork defect — the 2-D lane carries the fix); and 0.0 means both "not in contact" and "no contact engine".

That last one has a trap that verification caught. The obvious disambiguator, n_contacts, is wrong: measured on fork b17e8bd82, a live mortar-only model reports n_contacts=0, n_mortar_contacts=1 — the two counters are disjoint lanes, not a total and a subset, so reading n_contacts == 0 as "no engine" misclassifies every mortar-only model. ContactInfo therefore carries a total_contacts property, and that is what the docs tell you to gate on.

Verified against fork build b17e8bd82, both lanes, apeGmsh driving a live 2-D deck end to end: on NTS the queries reproduce the Tcl oracle exactly (ladruno_contact_force(5) = 19842.895714350583, summing to 100005.02376775819 against an applied 1.0e5) with the mortar queries reading a clean 0.0; on mortar, every slave's force reads 0.0 as documented while ladruno_mortar_penetration() returns 5.0517e-07, matching the same deck's Tcl value. 13 new unit / fork-gate tests run on any build.

FIXED — ladrunoBuild can report a STALE hash, and the S5 contact measurement was mis-stamped

The fork's ladrunoBuild stamp is baked at CMake configure time, not at compile time. Check out a newer commit into an already-configured build tree, rebuild incrementally, and the binary carries the new code while still reporting the hash it was configured at. skills/apegmsh/references/ladruno.md said the stamp was "the git hash the engine binary was compiled from" and told you to fail loud on a mismatch — true as far as it went, but it left the dangerous direction undocumented: a mismatch can mean the binary is newer than its stamp, and a measurement then gets credited to a build that never produced it.

Caught the hard way. A worktree binary reporting e7555f2c9 was used to measure the adoption-S5 contact figures; probing it with a deliberately disjoint 2-D master under -outward winding made it emit ADR-85 F1's own named FATAL verbatim — F1 landed in 52938956b, well after e7555f2c9. So that binary was post-F1 and its stamp was three weeks stale.

The measurement itself stands: re-run on a freshly rebuild-ed binary whose stamp is trustworthy (b17e8bd82, build/ wiped so CMake reconfigured), a flush two-square NTS deck at zero initial gap still diverges on the first Newton step and transmits 0.0, and the same deck with a 1e-4 seeded overlap still converges with the summed normal contact force at 100005.02 against an applied 1.0e5. Only the attribution was wrong. guide_constraints.md §3 Level 5 is re-stamped to b17e8bd82, and ladruno.md gains the staleness caveat plus the way to identify a suspect binary — probe a behaviour only the newer code has, since a named refusal needs no converged run.

Also verified on that build: outward="winding" and outward=(1.0, 0.0) over identical geometry are byte-for-byte identical across every slave DOF and contact force — the first end-to-end execution of apeGmsh's winding emit against a fork binary that implements the keyword.

Note for whoever revisits S4: its -thickness figures carry a e7555f2c9 stamp from the same worktree binary and inherit the same provenance doubt. The numbers were internally consistent and are not re-measured here.

ADDED — the model.h5 neutral zone is a published contract (ADR 0023 amendment)

The neutral zone has always been the part of model.h5 a non-apeGmsh tool can actually use — nodes, elements, physical groups, labels, mesh selections — but it was documented only internally, in opensees/architecture/h5-schema.md, which is written for people changing the emitter rather than people reading its output.

design/model-h5-neutral-zone.md publishes it: every group and dataset with exact dtype, shape, attributes and optionality; the version rule as the code enforces it (semver strings under /meta/neutral_schema_version, the backward-only two-version window, refuse-don't-guess on an unknown version); and the index-base contract that a reader most easily gets wrong — connectivity holds node ids, not row indices, and ids are 1-based and need not be contiguous.

Nothing about the emitter changed. This is publication only: no new fields, no version bump.

The contract is machine-readable and lives in exactly one place, tests/fixtures/neutral_zone/inventory.py. tests/mesh/test_neutral_zone_schema_note.py holds emitter, inventory and page together in both directions — emitter drift and doc drift each fail there — against a committed 41 KB golden, tests/fixtures/neutral_zone/box.h5, reproducible from _generate_fixtures.py. The golden is deliberately built to exercise the awkward cases: element ids that do not start at 1, and a physical group whose element_ids name elements no /elements/{type} group carries.

The first consumer is an apeWorkbench browser reader (h5wasm, no h5py, no apeGmsh import); the golden is the artifact it conforms against.

DOCS — h5-schema.md registry re-sync + doc-sync ratchet

src/apeGmsh/opensees/architecture/h5-schema.md had drifted well behind mesh/_femdata_h5_io.py / opensees/emitter/h5.py: the zone-registry "Current" column was frozen at neutral 2.10.0 / opensees 2.12.0 (live constants: 2.31.0 / 2.20.0), the neutral-zone history stopped at 2.10.0 even though the NEUTRAL_SCHEMA_VERSION docstring — the actual canonical log — runs 21 versions further, /nodes and /elements/{type} omitted the always-written module_label dataset (schema 2.9.0) and the optional ndf (2.7.0) / provenance (2.11.0) columns, the top-level tree still showed the pre-2.26.1 /loads/sp/default single-dataset shape, the "Two zones" bullet list named 8 of the ~18 top-level groups write_neutral_zone actually writes (missing /mesh_selections, /partitions, /parts, /reinforce_ties, /embed_ties, /rebar_elements, /contacts, /contact_planes, /interfaces, /composed_from), and the /meta/lineage note credited the stamp to "the composer" only when write_fem_h5 (the broker-only fem.to_h5() path) stamps fem_hash there too. Every number and dataset name below was re-read from the code, not carried over from the stale doc.

No schema bump — this is a doc-only re-sync; NEUTRAL_SCHEMA_VERSION, SCHEMA_VERSION, and every other schema constant are unchanged.

Two new ratchet tests in tests/opensees/h5/test_h5_schema_compat.py close the gap so this can't silently drift again: test_h5_schema_doc_registry_matches_writer_constants compares the doc's zone-registry "Current" cells against the live NEUTRAL_SCHEMA_VERSION / SCHEMA_VERSION constants, and test_h5_schema_doc_names_every_neutral_zone_group scans write_neutral_zone's own source for every group/dataset it creates and asserts each is named somewhere in the doc — both fail loud (proven by deliberately reverting the doc and re-running before restoring it) instead of the doc quietly going stale again.

ADDED — studio contract 1.8.0: a progress sidecar, run durations, and a version stamp on disk (ADR 0095 Amendment 11)

Three additive gaps closed at once, all found while an out-of-process consumer vendored the published goldens. CONTRACT_VERSION goes 1.7.0 → 1.8.0; nothing gained a required field, so old readers are unaffected (INV-17).

.apegmsh/progress.json — the live solve's step counter. The analyze loop has always emitted APEGMSH_PROGRESS i=.. n=.. t=.., and opensees/_run.py has always parsed it for the verbose console counter — but another process could only learn how far along a solve was by tailing the solver log and reimplementing a private regex. Each parsed sample now also atomic-replaces {schema, contract_version, deck, log, i, n, t, ts, done, warnings}, with a terminal done: true + ok write that fires on a failed exit too. Throttle is the marker cadence (~20 samples/run), not a timer. The sidecar is habitat-only: it writes where .apegmsh/ already exists and nowhere else, so a plain python model.py grows no dot-dir (INV-28), and it swallows its own IO errors — a lost sample must never kill a long solve (INV-29). The writer lives in studio/_progress.py; opensees/_run.py reaches it through a deferred import, so import apeGmsh.opensees gains no eager edge onto the habitat.

Run ledger lines carry started_at + duration_s. A run line had only ts, written at the end, so "how long did that take" meant differencing adjacent lines — wrong when runs are not back-to-back and undefined for the first line. Both _exec_hold_open exits are now timed with a monotonic clock, so a failed replay also reports how long it burned. Untimed lines omit the keys rather than writing null, so a reader that finds duration_s can trust it.

contract_version is now stamped into files, not just payloads. It lived only on the live status dict, so a consumer reading .apegmsh/ directly had an INV-17 major gate it could never engage. names.json and progress.json now carry the stamp, and validate() applies the major check to any payload that has it. Absence means "pre-1.8.0", not "wrong major".

New progress.schema.json + golden; ledger / names schemas gained the optional properties; studio-habitat.md documents the file, its poll contract, and the staleness rule (nothing prunes it — gate on mtime or busy.busy).

ADDED — docs: the contact section, with the 2D lane as its new half (fork ADR-85, adoption S5)

g.constraints.contact / contact_plane were documented nowhereguide_constraints.md reached them only as the redirect target of the deprecated mortar() alias, and the skill cheatsheet carried the signatures with no 2D content at all. S5 writes that section.

internal_docs/guide_constraints.md gains Level 5 — Contact: the two formulations, contact_plane, where records land (fem.elements.contacts / contact_planes, serial-only, live-session-only, fork-run-only), and then the 2D lane — the meshed dim-1 PG, the chained stride-2 pair list made hole-proof by construction, orientation (outward=(ox, oy) vs outward="winding", and the F1 asymmetry: winding is NTS-only, so a flush mortar interface always needs the vector and a curved/closed mortar master stays undeclarable), the three thickness conventions, the 2D rigid plane, and the ndf == ndm / no-partitioning gates. The curved-master facet-sizing warning — which lived only in the internal, history-flagged contact_2d_adoption.md §4 — lands here, under Meshing the interface, alongside the three other mesh-side prerequisites (seed an NTS overlap; restrain the free body transversally; refine a closed loop or it transmits exactly zero); guide_meshing.md §5 gains a pointer to it, since "follow the elastic mesh" is the wrong move exactly there.

skills/apegmsh/references/api-cheatsheet.md (canonical; .claude/skills/ re-derived via scripts/sync_skill.py) gains a 2D contact paragraph and 2D annotations on outward= / contact_plane, and its stale source-map footer is corrected (ConstraintsComposite.py:298 → :624 for contact, :497 → :989 for contact_plane, :1835 → :2865 for mortar, plus _boundary_chain.py). On the published surface, docs/concepts/constraints.md gains Contact in a plane model (and contact_plane, previously unmentioned there), and docs/concepts/backend-capabilities.md names contactPlane and the serial-only / no-parallel-2D scope.

Every snippet was run: the NTS, mortar-with--thickness and rigid-plane declarations emit the decks quoted verbatim in the guide, and the two-square NTS deck was solved on fork build e7555f2c9 — with a 1e-4 seeded overlap it converges and the summed normal contact force closes on the applied 1.0e5 to five significant figures, while the same deck with a zero initial gap diverges to inf at the first step and transmits 0.0. Kernel behaviour is linked to the fork's LadrunoContact2D_guide.md, never restated.

ADDED — 2-D mortar contact: -slave-segments 2, tie, and -thickness (fork ADR-85, adoption S4)

g.constraints.contact(..., formulation="mortar") works in a 2-D model. It was refused by name until now — the fork's 2-D mortar lane shipped in ADR-85 T3, apeGmsh could only reach the NTS one.

The slave surface is the fork's -slave-segments 2, a flat stride-2 pair list chained head-to-tail, and it carries the same hazard the master does: the shorthand 10 11 12 13 is silently legal fork-side and declares a holed surface that converges to a wrong answer. So the slave goes through the same chain walk as the master (_boundary_chain.edge_frames + chain_edges, each segment wound against its own continuum element) and the same ContactRecord stride-2 validator, which already covered slave_faces. The hole is unreachable by construction on both sides. The walk's refusals grew a role= noun so a mis-declared slave curve is no longer reported as a "master" one; the interface lane's text is unchanged.

thickness=h emits the mortar-only -thickness h (the 2-D plane-model out-of-plane thickness; the fork default is 1.0). Three conventions have to stay apart, and apeGmsh scales nothing itself — it emits the token and the fork applies h once, at its 2-D injection site:

  • the element thickness (ops.element.FourNodeQuad(thickness=…)) is baked into element stiffness and contact never re-reads it;
  • thickness=h scales the EXPLICIT eps_n / eps_t / visc / cohesion / tau_max and the tie stiffness;
  • eps_n="auto" is not h-scaled — it already absorbs the element's thickness via getInitialStiff(), so re-scaling would be an h² error.

Measured on fork build e7555f2c9: no -thickness is bit-identical to -thickness 1.0; halving h against an explicit -epsN doubles the interface penetration part exactly (4.013e-06 both times — applied once, not squared); and -epsN auto at h = 1.0 vs h = 0.5 is bit-identical (5.244762212098e-05). A flush 2-D mortar tie deck closes equilibrium at machine precision. thickness= on the NTS lane is refused at declaration (the fork parser refuses -thickness without -mortar) and in a 3-D model at resolve (a 3-D mortar deck's thickness lives in its elements; the fork FATALs at handle()).

outward="winding" stays NTS-only — the F1 asymmetry, carried rather than papered over. The fork shipped declared winding on the NTS lane alone because its 2-D mortar lane has no chain-integrity scan to rest winding's one-connected-chain invariant on, so a flush mortar interface still requires an explicit outward=(ox, oy); the flush refusal now offers a mortar caller the vector alone and says why. Its Lref also widened to the shortest segment of either surface on the mortar lane, matching the fork's own mortar floor (whose interval clip projects the slave endpoints too) — a master-only Lref would be too large and would refuse decks the fork accepts. The wrong-side-master guard now documents itself honestly as apeGmsh's on both lanes: the fork's centroid vote only picks a sign, so a far-side master resolves happily against a boundary the slave never reaches.

Neutral schema 2.31.0 — additive thickness column on contact_payload_dtype, presence-probed on read, NaN ⇒ None ⇒ the fork default 1.0, so a 2.30.x file reads back as the h = 1 it always meant. Parallel/DDM 2-D contact stays refused by name on both lanes (out of scope fork-side); the existing master_nps == 2 emit gate already covers mortar.

Review follow-ups, all in this slice. Both lane-exclusive knobs are now refused at the EMIT layer too, not just at ContactDef: a ContactRecord can reach contact_args without ever passing a def (an h5 decode, a compose rewrite, a hand-built record), and there thickness on the NTS lane was silently dropped while outward="winding" on the mortar lane went straight into a deck the fork refuses at parse — the 3-D winding rule was already gated three deep, the mortar one had a single gate. The 2-D mortar slave's dim-1 rule moved into _refuse_contact_entity_dim (via a new formulation=) instead of sitting beside it as a second gate, which is what that function's docstring means by "the single branch point of the 2D lane". ContactRecord.__post_init__ now refuses a slave stride that disagrees with the master's on dimension (nps 2 vs 3/4) — the h5 encoder and decoder each validated one side in isolation and emit_contacts checks only master_nps against ndm, so a 2-D master paired with tri slave facets reached the fork's own declaration guard. And the whole-domain scratch edge_frames recomputes — one Python-level pass over every domain element with a numpy allocation each — is hoisted into a shared DomainFrames, built once per resolve instead of once per surface (the mortar lane walks two surfaces per contact, on top of one per contact already).

FIXED — partitioned emit hoists its gated element blocks too (ADR 0099 S5)

ADR 0099 S2 fixed the flat path and made every other path fail loud (INV-4). Partitioned emit is now fixed rather than refused, because it is not the hard case it read as: the default partitioned deck is ONE file with if {[getPID] == K} brace guards and the builder-scoped declarations global, outside every guard. A brace is not a file boundary, so the flat hoist replicates directly — one extra rank-guard block per rank that owns a gated element, carrying the nodes those elements need and then the bracketed blocks, placed above the declarations.

The damage this removes was rank-local, which is what made it dangerous: a rank owning no gated element never executes the bracket and ran fine, so the same model passed on 2 ranks and died on 4. Measured on Ladruno 25a0647f with a mixed-ndf 2-rank deck run once per rank — before, rank 0 died at pattern Plain (getTimeSeries … none found with tag: 1) while rank 1 completed; with no pattern, rank 0 instead ran to the end reporting an undamped answer, because region -damp only warns. After, both ranks build every object and match the flat reference to every printed digit.

The hoist is gated on BOTH conditions it needs — at least one rank-OWNED gated element, and at least one builder-scoped declaration to lose — so every other partitioned deck is byte-identical to before (verified on three variants). Element tags are untouched: allocate_element_tags moves above the pre-element pass so the hoisted pass has a plan, and TagAllocator is per-kind.

per_rank=True (ADR 0061) keeps the refusal: it slices each rank guard into its own FILE, which is the split problem — the fragment's source line has to move, not its element lines. It is applied around BuiltModel.emit, so it reaches the INV-4 gate as an emitter attribute (per_rank_fragments) and carries its own path token. Stage-OWNED gated elements are still refused on both paths.

repros/repro4_builder_scoped_wipe.py gains two arms that run decks on the binary rather than only reading them: --partitioned (this slice, once per rank) and --h5 (the S4a replay, owed since S4c).

FIXED — the capture route resolves element_class_name again

DomainCaptureSpec._lookup_class_hint_for_pgs read self._opensees._elem_assignments, an attribute of the legacy g.opensees composite removed in Phase 8. The apeSees bridge carries none, so the guarded getattr yielded {} and the lookup always answered None — every resolved record left element_class_name unset, silently. It now walks the bridge's typed Element primitives (the source the σ_zz capability gate already uses) via the shared cpp_class_name_for_pgs; an explicit element_class_name= still wins, and PGs spanning two element classes still answer None.

That hint is what _identify_layout needs to separate two catalog entries sharing a flat column width — LadrunoLST and BezierTri6 are both 3 GP × 4 components under stress_plane_strain (and both 3 × 3 under stress), with different Gauss orderings — so without it such a record raised Ambiguous catalog match.

Same commit: _resolve_layer_section_metadata dereferenced _sections / _elem_assignments with no guard, so resolving any layers record against a bridge raised AttributeError: 'apeSees' object has no attribute '_sections'. It now answers "no layered-section metadata", matching what it already returned with no bridge attached. Porting that lookup onto the bridge's Section primitives is a layered-shell change and is deliberately left out.

Note the .out / RecorderDeclaration route is unchanged: its element_class_name is carried on the record but no reader consumes it (the H5 declaration bracket does not archive it), and .out reads go through a hand-built ResolvedRecorderSpec with no bridge to resolve from. Auto-populating it there would have changed nothing observable.

ADDED — every recording route promotes stress_zz, not just the deck

The plane-strain σ_zz promotion now covers all three routes that resolve a Gauss response token, so a stress_zz request means the same thing wherever you record from:

  • the bridge's ops.recorder.declare(gauss=…) deck (already shipped);
  • DomainCapture — the live in-process route, which queries ops.eleResponse directly and therefore has its own resolution. It used to drop a requested stress_zz on the floor, which is exactly the silent no-op the feature exists to remove;
  • the .out route — both halves. results/spec/_emit.py writes the promoted token, and the transcoder's read-side token derivation makes the same promotion, because the token names the catalog family the columns were written in: reading a 4-component file under the 3-component stress token finds no layout at that width.

All three go through one shared decision (_recorder_translate._stress_zz_tokens / stress_zz_keyword), so they cannot drift into writing one shape and decoding another. The read site uses the silent variant — the emit side already said its piece.

Capability comes from the same predicate everywhere. DomainCapture resolves it from the bridge's typed element primitives at spec resolution, mirroring the emit-side gate. ResolvedRecorderSpec — which carries no bridge back-reference — takes sigma_zz_capable from the caller, exactly as it already takes element_class_name; unset means unknown and keeps today's token silently.

Known sharp edge: a promoted 12-column file is genuinely ambiguous between LadrunoLST and BezierTri6 (both 3 GP × 4 components), so the .out transcoder needs element_class_name and raises Ambiguous catalog match without it. It does not auto-resolve: the only auto-hint in the tree, DomainCaptureSpec._lookup_class_hint_for_pgs, reads an _elem_assignments attribute the apeSees bridge does not have, so it returns None for every model. DomainCapture itself is unaffected (it reads ops.eleType from the live domain).

FIXED — .ladruno Gauss stress/strain on every Ladruno plane element

LadrunoCST / LadrunoLST / LadrunoQuad recorded with the plain elem_responses=("stress", "strain") answered results.elements.gauss.available_components() == [] — ALL continuum stress and strain was invisible on the .ladruno path.

The fork's plain stress / strain responses on these elements emit no output.tag("ResponseType", …) (only stressPlaneStrain does), so the recorder writes a single element-level block named C1,C2,…,Cn, and the reader — which named columns from the file's COMP_NAMES alone — matched none of them. For exactly those anonymous buckets the names now come from RESPONSE_CATALOG, keyed by the class in the bucket key (stress/33016-LadrunoLST[0:0:0]) plus the ON_ELEMENTS token, with the block width picking the layout — the bracket's rule field is 0 (NoIntegrationRule) here, so it cannot be the catalog key. A width that fits no catalog layout raises GaussLayoutMismatch rather than guessing: a wrong component name is worse than a missing one. Buckets whose COMP_NAMES are real names never reach this path, and MPCO (which already consults the catalog) is untouched.

LadrunoLST joins RESPONSE_CATALOG (tag 33016, 3 GPs Triangle_GL_2, the SixNodeTri anchor order unpermuted).

Recording ("stress", "stressesPlaneStrain") in ONE recorder — the configuration the out-of-plane σ_zz work targets — puts two tokens on the same (element, Gauss point) slots, so the Gauss slab now deduplicates by slot: the bucket whose COMP_NAMES the FILE wrote wins over a catalog-reconstructed one (it is self-describing and a superset, 4 components vs 3). Duplicates that disagree numerically raise instead of being picked arbitrarily. Without this stress_xx came back with twice the columns of stress_zz and von_mises_stress failed to broadcast.

Also fixed alongside: gauss_available / read_gauss_slab discarded every continuum block with MULTIPLICITY > 1 as a fiber expansion. A fiber bucket repeats one scalar per fiber, but a continuum bucket repeats its component set once per GAUSS POINT — the multiplicity test dropped every multi-GP solid. Fiber buckets are now excluded by token.

The 4-component plane-strain response gets its own catalog token, stress_plane_strain, wired from both keyword spellings (stressesPlaneStrain / stressPlaneStrain). The .out transcoder identifies a layout by (token, flat size) alone, so under the plain stress token a promoted 3-GP element's 3 x 4 = 12 columns collided exactly with FourNodeQuad's 4 x 3 = 12 and were decoded as 4 Gauss points of 3 components — every value on the wrong Gauss point AND the wrong component, with no error. Registered for the six classes that implement the fork's stressPlaneStrain branch (FourNodeQuad, Tri31, BezierTri6, LadrunoQuad, LadrunoCST, LadrunoLST; NOT SixNodeTri, NOT LadrunoUP), each with its own stress entry's Gauss count and natural coordinates. Component order is sigma11, sigma22, sigma12, sigma33 — σ_zz LAST, appended so the first three columns stay byte-compatible with stresses (STRESS_PLANE_STRAIN in _vocabulary). A 12-column plane-strain block stays genuinely ambiguous between LadrunoLST and BezierTri6, whose Gauss orders differ; that now raises and asks for a class_hint, exactly as the 9-column stress block these two already share.

ADDED — a plane-strain stress_zz request now records the real σ_zz

ops.recorder.declare(gauss=(..., "stress_zz"), ...) used to be a silent no-op: stress_zz is a valid canonical component, but it routes onto the plain stresses token, which carries only the three in-plane components. The σ_zz you then read back was reconstructed from Poisson's ratio — exact for a linear-elastic material, and wrong by up to ~68% at a plastic Gauss point, which quietly poisons von Mises, the principals and every other invariant built on the 3-D tensor.

Such a record is now promoted, record-level, onto the fork's stressesPlaneStrain element response — [σxx, σyy, σxy, σzz] per Gauss point, a strict superset of stresses, so stress_xx/_yy/_xy declared in the same record ride along unchanged.

Promotion is gated, because the naive version is worse than the bug it fixes. NDMaterial::getStressZZ() returns quiet_NaN unless the material overrides it, and only six element classes even expose the 4-component response. Recording it blindly writes an all-NaN column, and NaN propagates where the ν-estimate at least stayed finite. So the promotion fires only when every element the record targets is one of FourNodeQuad / Tri31 / BezierTri6 / LadrunoQuad / LadrunoCST / LadrunoLST, at plane_type="PlaneStrain", over a material that overrides getStressZZ (ElasticIsotropic, J2Plasticity, DruckerPrager, the PlaneStrain wrapper, and LadrunoJ2 / LadrunoConcrete3D in their plane-strain view). Anything else keeps today's behaviour and warns once, at emit, with StressZZNotRecordedWarning — the only place the user can learn why their σ_zz is an estimate.

A record that does not ask for stress_zz is untouched: same token, same deck, byte for byte.

Read side: a recorded-but-NaN stress_zz column now falls back to ν-recovery with a message that names the material as the cause, distinct from the existing "cannot classify this element" warning. A genuinely recorded, finite σ_zz is used verbatim and raises nothing.

CHANGED — ADR 0098 §11 S6d: docs and skill move onto the session

The last S6 slice. Documentation stops describing the retired Geometry / Composition / Diagram ontology, and — the part the plan did not anticipate — the session finally gets a door.

There was no way in. ResultsSession had no API page, no mkdocs.yml nav entry, and nothing in docs/how-to/**, docs/tutorials/** or examples/** that called results.session(). docs/how-to/index.md still routed "plot a deformed shape or contour" at the old concepts page. Six slices built a model no reader could reach. Now:

  • docs/api/results-session.md — the session as the document: panes, the closed seven-slot catalog, why deform is pose and not a slot, and the legend law. (Named results-session because api/session.md is the builder session — two different things.)
  • docs/how-to/render-a-still.md — the scripted path end to end: results.session(), fill a slot, set the pose, render() a PNG with no Qt in the loop. Every fence was executed before it shipped.

A whole-tree audit, not just the pages ADR 0098 touched — and that was the right call: several BROKEN claims sat outside them, and the worst had nothing to do with this ADR. Those are spun out separately rather than smuggled in here.

Fixed, from ADR 0098's own fallout: adopting <results>.viewer-session.json at S6a left the old name in eight places in shipped code — three in python -m apeGmsh.results.session render --help, two in the Studio MCP tool descriptions, plus studio docstrings and docs/how-to/studio-habitat.md. The MCP text was the worst of it: it told an agent that .viewer-session.json is "the old, refused" file when that is now the live name. The refusal is by payload shape, never by filename — the two are no longer distinguishable by name — and every site now says so.

Also corrected: export_animation's claim that its frames are "pixel-identical to the interactive viewer". True before the flip; false after, because it builds the retired diagram-stack window offscreen while the interactive one is now the session window.

Retired from the skill: the director.geometries section (which carried a warning and then taught viewer.director anyway — an AttributeError since S6a), a session-schema number that was wrong twice over (the retired path is 13, the live snapshot is version 1 with kind: "apegmsh.results.session"), and wv.director.registry.add(...) as an authoring surface — it is show_web-hatch implementation with no compatibility promise, and nothing built through it can enter a snapshot, a pin, or an MCP render. docs/design/results.md gains a "the session is the document" section; it previously described concurrent geometries as current architecture and never mentioned ResultsSession at all.

MeshView.add_clip / remove_clip gained docstrings — public API that the new page needed and the source did not carry. The seven slot properties keep theirs on the property(doc=…) factory; they cannot appear in generated docs because griffe is static and never sees a factory-built property, so the page names them in prose instead.

CHANGED — ADR 0098 §11 S6b: the retirement sweep

The Geometry / Composition / Diagram ontology stops being a tested public surface. 49 test files retire (tests/viewers/ 176 → 128), and an import guard replaces them as the thing that keeps it retired.

S6 is de-publication, not deletion, and this sweep found out how literally that is true: only two source modules are actually dead (viewers/_session_apply.py, viewers/ui/_time_history.py). Everything else is still reachable, because Results.export_animation runs ResultsViewer.show(run_loop=False)_realize_headless, which builds the full Qt window and merely hides the docks. Deleting those two needs surgery inside that hatch-live file for no behaviour change, so it is deferred to the slice that slims the export.

tests/viewers/test_diagrams_import_guard.py is the new gate: a 27-module allowlist, ratcheted both ways — a new importer must be argued for, and an entry that stops importing must be pruned. It resolves relative imports, because inside viewers/ nearly every real hit is from ..diagrams import … and a guard modelled naively on the assess one would have seen almost nothing and passed forever. Its docstring is honest about what it proves: eleven of those entries are alive only because of that fat headless window, so this is a ratchet against new importers, not evidence of a small surface.

Three sweep decisions departed from the plan, each because executing it literally would have deleted live coverage.

test_animation.py was deleted and then restored. Results.export_animation is a public, documented API (0095 INV-11), not an implementation detail, and those 12 tests were its only coverage. The rule that settled the rest of the sweep: a surviving public surface keeps its tests; a surviving implementation detail behind a hatch does not — which is what ADR 0098 already says about the director.

Deleting the director and registry suites left show_web with no coverage at all — and ADR 0098 Amendment 2 rests the survival of the six slotless kinds on precisely that hatch, with no test_web_viewer.py anywhere. tests/viewers/test_diagram_hatch_survival.py is the load-bearing replacement: all six kinds registered by a bare package import, one constructing through the real DiagramRegistry, the §4 catalog still closed at seven, and a near-miss snapshot refusal.

Mixed files are kept whole rather than split. Eleven files span the retired and the surviving; splitting them risks silently dropping a survivor's coverage for a maintenance-only gain, and every test in them still passes. Amendment 2 §A2.4 is corrected accordingly — its "five dedicated test files survive" list missed six more survivor-kind tests living inside per-kind files, which a sweep trusting it would have deleted with their hosts.

Persisted section cuts now boot as view clips. ADR 0098 keeps the h5 auto-load contract while retiring the section_cut picture, so Results.session() reads persisted cuts and attaches them as clips on the booted view (results/session/_cuts.py), preferring a bound OpenSeesModel handle over the file exactly as the retired director did. A ViewClip cuts the whole view, while a SectionCutDef cut only the elements it named — so a cut that names a subset of the model, or carries a bounding polygon, is skipped with a notice rather than silently widened into something that hides more than the original cut did. A cuts zone that cannot be read is a line, never a failed boot.

Three S6a defects fixed. The "Display" dock no longer opens blank — the shell created it unconditionally and the View menu could summon it, but the session window mounted nothing, which is an ADR 0087 INV-2 violation; it now carries one muted hint line. docs/api/viewers.md no longer points at Preferences → Labels as a path from a window that has no Preferences entry. The skill no longer claims Export animation is a button on the viewer GUI.

The disposition of every file and every orphaned behaviour — including the ones deliberately not ported (the File menu, the legend interactor's drag/right-click chrome, the probe overlay, threshold) — is recorded in internal_docs/s6b_honesty_table.md.

CHANGED — ADR 0098 §11 S6a: viewer() opens a ResultsSession

results.viewer() is the same one-liner and opens a different window. It is now sugar for results.session().show() — the document is a ResultsSession of tiled mesh and plot panes with the closed §4 slot catalog, and the window is a client that projects it. The blocking call returns that session, still live after the window closes, so you can query it, render stills off it or snapshot it. blocking=False still returns the Popen and the notebook in-memory fallback still returns the WebViewer: three different things on purpose, because a session in this process is not what a child window is showing.

ResultsViewer is de-published, not deleted — from apeGmsh.viewers and from the root apeGmsh package, which also exported it and is the shorter of the two ways to reach it. The module path keeps working, because Results.export_animation (the 0095 INV-11 hatch) and the show_web hatch still import it there.

viewer(cuts=) is retired with the diagram ontology (§1): a cut plane is clip state on a view. Build defs with apeGmsh.cuts, then results.session()view.add_clip(normal, offset=…).

The restore/save semantics flipped with it, which is what §11 parked in this slice. restore_session and save_session keep their names, tokens and defaults and now drive a session snapshot; <results>.viewer-session.json is the file again, adopted from the old window now that nothing else writes it. python -m apeGmsh.viewers flipped for free — it already called viewer(blocking=True) — and grew --restore-session / --no-save-session so the child honours the policy the caller asked for, which before this it silently did not: viewer(blocking=False, save_session=False) saved anyway.

The window always opens. A session file that cannot be honoured is announced, the default picture boots, and auto-save is switched off for that window. The third part is the one that matters: the save target IS the file that was refused, so booting fresh with auto-save armed would overwrite on close exactly what the refusal was protecting. The v13 session written by the retired window is not restorable — the first upgraded open says so and renames it to .legacy, overwriting neither it nor an aside already there. Upgrade, downgrade, upgrade again and the rename is refused, so you get your window with both files intact and a line naming the one to move.

A restore that merely degrades is not a refusal: a snapshot naming a stage these results no longer have keeps every pane, slot and scope, drops the instant with a notice, and keeps its save target.

ALSO — ADR 0098 Amendment 2 records the six shipped diagram kinds with no slot (fiber_section, layer_stack, the three isochrones, spring_force): they survive as internal implementation of the show_web hatch, the catalog stays closed at seven, and the disposition expires with the hatch. They have no published surface today and lose their authoring UI at this flip; they cannot enter a session snapshot.

Two behaviours of the retired window do NOT exist in the session window and the docs stop claiming them: the draggable / right-click colour-scale chrome (the legend controller carries over, the interactor does not) and File → Open Results…. Both are recorded for the S6b sweep to keep, port or drop deliberately.

ADDED — ADR 0098 §11 S5c: render a saved session

python -m apeGmsh.results.session render <snapshot> <out.png> draws one pane of a saved session out of process, and the Studio MCP render verb grows a session= door onto it. The agent gets the picture a HUMAN arranged, with no Qt in the loop — which is what §11's S5 row asks for.

Its own CLI entry rather than a subcommand of python -m apeGmsh.viewers, because that module flips at S6 and this door outlives it. The broker comes from --results or, failing that, from the results_path the snapshot recorded when it was written, so the common case is two arguments; --model-h5 covers .mpco-backed results, which carry no model zone of their own.

A snapshot's content is the §4 slot catalog, not a view token, so session= skips the closed view check and REFUSES the old-ontology knobs (view / component / step / deform / pack) instead of ignoring them: silently drawing a snapshot's own instant while the caller passed step=3 would hand them a different picture and call it theirs. camera is not refused — it is a property of the still, not of the picture. view / step / camera became None-defaulted so an explicit value is distinguishable from an unset one; the results door still falls back to contour / -1 / iso.

Old sessions are refused and never renamed. The .legacy rename-aside belongs to the human flow (ADR 0098 Consequences); a batch verb or a CLI that moved a user's file would be doing surgery nobody asked for. Checked in the MCP parent before anything spawns, and again in the child with rename_legacy=False.

Restore notices ride out on stderr rather than being swallowed: a still drawn at a different instant than the file asked for says so.

CHANGED — one opener for the CLI doors

open_results(path, model_h5=None) — which reader answers for which extension — moved from viewers/__main__ to apeGmsh.results._open, beside the readers, so the new session door does not build on a module scheduled to retire. viewers/__main__._open_results delegates and keeps its sys.exit(2) contract; studio/_verbs already delegated to it, so both follow. The refusal is now a ValueError rather than a process exit, which a library caller can catch and an error envelope can report. viewers/ui/_open_results.build_results is deliberately untouched — it sniffs file CONTENTS for the Qt open dialog, a different job.

FIXED — the legacy rename-aside now survives a transient Windows denial

atomic_write_text has always retried os.replace because "a concurrent reader may briefly deny replace" on Windows; rename_legacy_aside (ADR 0098 S5a) called the same syscall on the same platform with no such guard. Both now go through one shared replace_with_retry, so the guard is not something the next caller has to remember. Surfaced as a single unreproducible failure during a full-suite run — causation unconfirmed, but the asymmetry was real either way.

ADDED — ADR 0098 §11 S5b: a pin can carry the session snapshot

results_pin(session_snapshot=…) pins the picture a human arranged. The ledger key is session_snapshotsession already means the run's session name in a ledger record — and unlike model_h5 / results, which are recorded by path and hash only, that file is copied into .apegmsh/pins/<pin-id>/session_snapshot.json per the Amendment-5 lifecycle. The reason is not symmetry: the live <results>.session.json is rewritten on every save, so a stamp alone pins content that is already gone. A file that is not a session snapshot is refused here — the retired <results>.viewer-session.json is the near-miss, and pinned blind it becomes an opaque hash that S5c refuses at render time, one step too late and against the wrong artifact.

ADDED — the pin record now HAS a published schema (INV-17)

runs.jsonl interleaves two record kinds, and the published contract described only one of them. A real pin record from the shipped writer was refused by the shipped ledger.schema.json (missing required 'script'), so any out-of-tree consumer validating the ledger line by line rejected every pin line. ledger_pin.schema.json + its golden fixture now describe pin lines: a run line carries no kind and validates as ledger, a pin line carries kind: "pin" and validates as ledger_pin, and a consumer discriminates on kind first. Two schemas rather than one oneOf because the shipped validator is a deliberate draft-07 subset a Workbench-side reimplementation can match without a JSON Schema library — and relaxing the run schema's required list to admit both would stop it catching a run line that lost its script. The new live-writer round-trip test validates a REAL results_pin record against the published schema, which is the check that was missing. contract_version is 1.7.0 (additive: new kind, new field, same major).

FIXED — two pins inside one second no longer destroy each other

Pin ids are second-granular, so two pins in the same UTC second shared an id: two ledger lines claiming to be the same pin, and the second pin's folder written straight over the first's. Survivable while a pin folder held only the assess snapshot (a re-derivable verdict); data loss once it holds the session snapshot, whose live original has usually been overwritten by the time anyone opens the pin. results_pin now takes the first id no ledger line and no existing pin folder has claimed (pin-<stamp>, then pin-<stamp>-2, …). The folder check matters on its own: a pin folder left behind by a run whose ledger append tore (tolerated by design, INV-16) must still not be written over. Proven with a frozen clock — the first version of that test raced the wall clock and skipped, which is the same as not having it.

ADDED — ADR 0098 §11 S5a: the session snapshot + the legacy gate

A ResultsSession now serialises. session.snapshot() is the document as a JSON-safe dict and session.save_snapshot() writes it atomically to <results>.session.json; restore_snapshot / load_snapshot read it back. Panes and their ids, the slots, the pose, the scope, the clips with their plane ids, the legend-hide chrome, the time link and every pane's own instant, and the one §8 selection set. An agent can draw a still of what a human arranged, with no Qt in the loop — which is what S5b's pin (record key session_snapshot) and S5c's MCP render ride.

The schema is frozen at version 1 and identified by kind + version, never by version arithmetic against the old file's schema_version. S4 never widened the time surface — the one-stage-at-a-time scrubber was a widget choice, Instant is (stage, step) either way — so it freezes against exactly what S0 shipped and S5b's contract publishes without a second bump.

Two failure families, deliberately unlike each other. A schema or ontology violation refuses LOUDLY: an unknown result-slot category is the loudest, because the §4 catalog is closed (amended ADR 0094 INV-10) and a category this build does not have means a picture nobody authorised. Restore builds the real frozen records, so S0's laws — the closed catalog, the scope axes, the deform fields, the plot kinds, no negative steps — are the schema's laws, with no weaker second copy. A DATA mismatch degrades instead, with a notice on the returned RestoredSession.notices: an instant naming a stage these results no longer have drops to None and realize's documented fallback (last stage, last step) draws it. A stage rename must not cost the human every pane, slot and scope in the file, and silence is the only forbidden answer. Instants are validated at RESTORE through the same results.stage() lookup realize uses — unvalidated, a dead stage id surfaces as a traceback inside a repaint, for a fault that belongs to a file.

The legacy gate never destroys. Today's window still owns and overwrites <results>.viewer-session.json on close; the new session writes its own path until the S6 flip. A v13-shaped file meeting the new loader earns a notice and a .legacy rename-aside — and an existing .legacy is REFUSED, not replaced, so the second open cannot destroy what the first one moved. The destination is reserved with an exclusive create before the atomic replace, which makes that guarantee atomic rather than advisory. legacy_shape(data) is exported as the bare predicate, and load_snapshot(..., rename_legacy=False) refuses while touching nothing on disk — S5c's contract, ready to use.

Both id counters are re-seeded, not one. ResultsSession._ids numbers mesh-N / plot-N; each MeshView._clip_ids numbers clip-N independently. Restore a session holding mesh-3 and add a view and without both re-seeds you get a second mesh-1 — two panes with one id, and every id-addressed client (render, the outline, the MCP verb) picks whichever comes first.

atomic_write_text moved from studio/_paths.py to the package root as apeGmsh._atomic_io (studio re-exports it, so every studio call site is unchanged): results may not import studio, and a copy-paste would be two implementations of one atomicity guarantee.

The round-trip oracle is at the scene-IR layer — restored-realize == built-realize, structurally, over a RecordingBackend — with a non-empty selection, because an empty one round-trips identically whether or not the field is in the schema at all. It self-checks that two realizes of ONE session agree, and a companion test asserts that a snapshot stripped of its selection block realizes a DIFFERENT scene. 13 mutations of the product all go red. Measured: 1k / 16k / 100k selected nodes snapshot+restore in 1.5 / 36 / 163 ms (1.3 MB of JSON at 100k) — linear, and only because S4-2 made the selection store set-backed.

No window wiring in this slice: nothing save-on-closes or restore-on-opens yet — §11 puts those semantics in the S6 flip.

The window gets its time back. SessionScrubber is the bottom-dock transport over the session's instant: slider, first/step/play/step/last, fps and loop mode, a stage selector, and the §7 link toggle. It is TimeScrubberDock's machinery with the binding swapped — session.time and the change tick in place of director.set_step / subscribe_step — which is all the plan sized it as; the old dock is untouched until S6a. Drag it and every mesh view re-poses and every plot's playhead slides, which is §7's own test ("if the link is on and moving the plot does not move the meshes, the link is a lie") read forwards.

One stage at a time (plan decision 9, settled here). The slider spans the current stage's steps and a selector picks the stage; the director's concatenated multi-stage track is deferred. That way the scrubber's x-axis is the same axis every plot draws — the stage's own recorded time — so the cursor rides the curve literally rather than within-a-segment, and a history plot (resolved inside results.stage(cursor.stage)) swaps curves on an explicit stage change instead of mid-drag. Switching stage lands on step 0 rather than carrying a step that would mean a different time. Not a one-way door: Instant is (stage, step) under either traversal, so the IR and the S5 snapshot are identical.

Unlinked, each pane keeps its own instant (§7). The scrubber then disables itself and says why, because a live slider that changes no picture is a control pretending it can act; the per-pane instant badge SessionPaneFrame has carried built-and-hidden since S4-1 takes over. That badge renders only when it is news — link off, or a mode-posed view, which has no instant at all and is frozen under the link either way (§4/§7), and which without a badge just looks like a pane that stopped working. The inspector gains §9's pane-time row (stage + step + Clear), enabled even while linked because "set it here; the link ignores it" is literal: that value is the instant the pane adopts the moment you unlink.

The badge had to be taught it is chrome. Measured first: an unelided stage id took the pane frame's minimumSizeHint from 207 px to 593 against LAYOUT.pane_min_width = 240, which required_extent() and the Add gate both multiply by — so the gate would have admitted a column the splitter cannot fit, which is criterion 18's whole point. It now carries the title's squeeze policy and elides the stage id, with the full value in the tooltip: back to 209 px.

Playback is the reconciler's first sustained-load test. The discriminating pane is a mode-posed one: it is frozen under the link, so a frame that costs a static pane a realize must cost it nothing. Counting only panes that DO move passes with the criterion-12 signature gate ripped out — which the mutation pass caught, and the test was rewritten around.

ADDED — ADR 0098 §9/§6 (S4-2): outline select-all + the plot pane

Select all. Right-clicking a physical group or an element type in the session outline offers Select all nodes / Select all Gauss — §8's fourth writer, over the same ADR 0045 store a click writes, so the nodes-XOR-Gauss law and the one-gesture undo apply to it unchanged. The subject is the checked names when the clicked row is one of them and that row alone otherwise; each entry carries its count and disables itself at zero. Selecting does not touch scope: scope is what the view draws, selection is what gets plotted. Node membership reads no results at all (pure model topology, resolved by the same _scope resolver a scope checkbox uses); Gauss membership costs one single-step probe slab, addressed through the encoding realize's pick targets share (viewers/session/_gauss_addr.py), so the outline and the highlight cannot disagree about which integration point is selected.

The plot pane. PlotPanePlaceholder is replaced by a real matplotlib-in-Qt chart. SessionReconciler serves mesh views only, so the chart gets its own PlotReconciler restating the same three disciplines: one gesture one redraw; a signature gate that includes the §7 instant (leaving it out is the S4-1 defect one layer up — the playhead would never move); and failures that report rather than vanish. The gate has a cheap half — a cursor move slides the playhead over cached arrays and reads nothing, but only while the STAGE holds: the arrays are a function of cursor.stage, so crossing a stage re-reads rather than painting one stage's record under another's playhead. A plot's chart deliberately does not follow the selection, because §6 copies the membership at creation. matplotlib is an optional dependency; without it the pane renders a named refusal instead of failing to build. Plot panes get a real inspector page too (the series are the occupants: list, clear one, clear all).

The loop closes. "New plot from selection" sits on the outline beside "New plot", labelled with what would be plotted and gated on the Add gate, on there being a selection, and on the curve cap. §8's own test — "if selection does not produce a plot without a Python snippet, selection is still broken" — is now two gestures.

Two S4-2 decisions recorded (plan decisions 10 / 10b). path / xy plot kinds stay refusals and now name the missing surface rather than a slice number: neither has a v1 authoring path and xy has nowhere in PlotSeries(source, quantity) to hold a second axis source. label / physical_group sources resolve as one curve per member, matching what add_plot_from_selection already does, expanded by ONE slab read (pg= is a first-class query filter) rather than one read per member — with a curve cap that refuses before reading, because outline select-all made a 100k-source plot one gesture away.

FIXED — one SelectionState gesture was quadratic in the set size

SelectionState.select_batch and SelectionLog's reducer both deduped with t not in <list>, so a single SET gesture cost O(n²) equality comparisons: measured 0.10 s at 1k targets, 1.66 s at 4k, 26.7 s at 16k — 100k extrapolated to about 17 minutes. It had never been exercised at scale because ResultsViewer never wrote SelectionState; "Select all nodes" on a physical group is the first writer that hands it a whole model. Both dedups are now set-backed and order-preserving (100k targets: 0.16 s), the REMOVE / BOX_REMOVE branch no longer scan-and-shifts, and SelectionState.__len__ answers a count without copying the target list. Pinned by a comparison-count test (complexity, machine-independent) plus a whole-gesture budget that includes the two O(n) costs S4-1 rides on top — the reconciler's exact selection signature and the highlight's membership test.

ADDED — ADR 0098 §8 (S4-1): the selection surface + pane pick

A click or a rubber-band in a mesh pane now writes the session's one selection set. Selection is nodes XOR Gauss points and nothing else (§8): element and fiber pick targets are retired in this window — an element is a membership query, not a hit — so core/results_pick.py (three modes, dim-gate, geometry resolver) is not extended but replaced on the session path by a slim viewers/session/_pick.py. Zero new VTK: the vtkCellPicker ray, the press/move/release machine and the rubber-band overlay are the shared PyVistaPickBackend, reused verbatim (ADR 0047 INV-3). The old file and results_viewer.py are untouched.

The pick targets come out of realize: RealizedPane.targets carries the node ids / (element_id, gp_index) pairs the pane last put on screen, with their posed coordinates, so a hit can only resolve to a point that pane is drawing — right scope, right pose, right instant — and "window … scoped to this view's visible cells" needs no second derivation. GaussSlab carries no gp-index column, so the index WITHIN each element is derived here, matching what the plot resolver expects. Both glyph clouds are emitted pickable (they were not): §8 names them as what a click hits, and a Gauss point — inside its element — has no other prop that can answer for it. A click snaps from the RAY's world point, not from the pixel, which is what keeps a node hidden behind the model unselectable. Plain click/drag replaces the set, Ctrl extends (Ctrl+click toggles), a plain click on nothing clears, and a click with the matching style button off is a non-event — never a silent clear of a set filled from elsewhere.

The set is painted: a <pane>:selection layer marks it over the same posed cloud the glyphs use, in every pane, over only the points that pane draws. Because the highlight is realize output, the S3 reconciler's per-pane signature grows a selection term — without it a selection write ticks the session, the signature compares equal, and the repaint the user just asked for is skipped. The term is the membership (not a count) and is read only when a glyph button is on, so an outline "select all" with glyphs off still costs those panes nothing.

The pane header gains the §8 pick-target radio (Nodes | Gauss), beside the style buttons and separated from them: it aims THIS pane's clicks and windows only — it neither owns nor clears the set, and two panes may aim differently over the one set. The Gauss side disables itself when the stage records no Gauss composite.

SessionSelection gains toggle_node / toggle_gauss (the Ctrl+click writers, XOR law intact), and a pane's pick installation now dies on MeshPane.dispose — the same path that closes its GL context, because its observers live on that interactor.

Taking LEFT for selection means the panes finally need the navigation convention that expects it. ViewerWindow.set_navigation_style styles _qt_interactor, which the session window does not build (A1.1), so every pane applied VTK's stock trackball — where LEFT orbits — and the pick's priority-10 abort would have left the pane with no orbit gesture at all. Panes now apply the mouse_navigation preference themselves (apecad: LEFT selection, MIDDLE orbit, RIGHT pan), and ViewerWindow gains an optional navigation_hook so View → Navigation reaches viewports a window does not own — additive, None for every window that owns its interactor.

Two more from the same review round: the pick-target radio's checked state is a translucent tint + a 2 px accent underline rather than a solid accent chip (the glyph is drawn in the palette's icon colour and nothing re-tints it per state, so a solid fill read 1.07:1 on high_contrast and under 3:1 on 8 of 10 palettes); and the pane title is squeezable with the full name on its tooltip, so the widened header keeps a pane frame's real minimum inside A1.4's 240 px floor — the number required_extent / the Add gate compute with, and which a QSplitter will not shrink a child below.

FIXED — M–κ partial-curve test was a fork-only guarantee; CI now runs the live surface on stock openseespy

tests/sections/test_mc_b7.py::test_partial_curve_when_the_section_goes_singular passed on the Ladruno fork and failed on stock openseespy. The harness' early stop is ops.analyze() != 0 and nothing else, so it inherits the backend's honesty: stock BandGenLinLapackSolver::solve() returns -info+1, which C reads as (-info) + 1, so info == 1 — the zero pivot on the FIRST equation, exactly this section's free axial DOF — returns 0 == success. The fork returns -info. The test is now @pytest.mark.ladruno_fork with the mechanism written down; _mc.py is unchanged, because stock's values here are exact (symmetry holds the residual at zero) — only the reporting diverges.

A companion test runs the same section on every build and checks each returned point against the closed form M = min(EI0·κ, Mp), so marking the first one fork-only does not leave the degenerate section uncovered on stock. It is the half with real teeth there: a build that masks the singularity and corrupts the answer — a failed factorization leaves the load vector in the solution vector, which the caller then spends as a displacement increment — now fails instead of passing quietly.

New live-stock CI lane closes the gap that hid it: no job installed openseespy, so every live-marked test skipped itself and the whole backend-driven surface reported green without executing. The lane installs stock openseespy, hard-asserts the backend actually imported (otherwise its own failure mode is the silent all-skip it exists to prevent), and runs -m "live and not qt and not subprocess and not bench" — 54 tests, ~80 s.

ADDED — ADR 0098 Amendment 1 (S3): the pane host, per-view scope, style buttons

The Results session window's centre becomes N tiled panes. The central widget is a SessionPaneHost: a root QSplitter(Horizontal) of columns, each a QSplitter(Vertical) of rows, depth exactly two, with one QtInteractor per mesh pane. The shape is a pure function of the pane count — T(N): C = ceil(sqrt(N)), row-major fill in session.panes order — so adding a pane never moves the others unless the column count changes. A splitter whose child list changed resets to equal ratios; every other splitter keeps its dragged ones.

On the session path the shell builds no central interactor. The seam is an additive, opt-in central_interactor=False + set_central_widget / set_plotter_provider on ViewerWindow / ResultsWindow that the old window never reaches — results_viewer.py is untouched. Every GL context in the window now belongs to a pane, which is what lets sixteen of the twenty-four acceptance criteria run in the default offscreen lane with injected backends. Toolbar camera presets / fit / screenshot act on the active pane.

Each pane carries a SessionPaneFrame: kind glyph + name, the four INV-MESH-4 style buttons (mesh / outlines / nodes / gauss — pane header only; the inspector does not restate them), a reserved time badge, and a close glyph. The buttons realize: mesh is show_edges of whichever layer IS the one surface, outlines is a feature-edge layer of the same cell set, nodes and gauss are glyph clouds — and the Gauss button stands down when the gauss slot is occupied (one cloud, not two). Three new icon-factory glyphs (outlines, nodes, gauss) + four ADR 0087 Appendix A rows. View clips realize too (the 0083 machinery, view-owned).

The outline grows its second §9 job: per-view scope as checkboxes on one composition axis (physical groups | element types; materials renders disabled — no element→material index is published yet), plus the "New mesh view" / "New plot" rows with the A1.4 Add gate (disabled with the required width in the tooltip when T(N+1) breaches a pane floor; the gate constrains creation only — a script or a snapshot restore is tiled anyway). Zero panes renders the empty-state card. Splitter ratios persist as panes/layout + panes/schema_version under QSettings('apeGmsh','ResultsSession'); _LAYOUT_SCHEMA_VERSION stays 1 (no dock added).

Three S2 defects the amendment named are fixed: inspector pages are now evicted and disposed with their pane (they only ever grew); a reconciler bound to a pane id whose view is gone paints nothing (the first-mesh-view fallback would repaint view 1 into a dying backend); and first-fit camera moves onto the pane (pane 2+ booted unframed). Criterion 12 is gated, not hoped: each reconciler keeps a pane-level signature over (instant, scope, pose, style, overlay, clips, slots, legends), so one slot fill in a four-pane session costs exactly one realize and one render, while a theme change forces all four.

New: tests/viewers/test_pane_host.py (the sixteen [off] criteria + four mutation tests) and tests/viewers/test_pane_host_window_qt.py (criteria 18-20 on real GL, beside the existing probe).

FIXED — doctor no longer ships one developer's venv path

python -m apeGmsh doctor printed a hardcoded home directory on every machine in the world that ran it: D1 compared sys.prefix against a literal office venv and named it even when absent, and D6 probed that same path to compare baseUnits — a package apeGmsh does not depend on. A site convention does not belong in a published wheel.

Both are now opt-in environment configuration, with no literal path anywhere in the module:

  • APEGMSH_REFERENCE_VENV — a venv root D1 compares against. Unset, D1 is a plain identity report (Python 3.12.10 at <exe> (virtualenv)); set and mismatched it warns as before; set and missing it says so. Resolves Scripts/python.exe or bin/python, so it works off Windows.
  • APEGMSH_SHARED_PACKAGES — comma-separated distributions whose versions must agree across this interpreter and the reference venv (_check_baseunits_check_shared_packages, _baseunits_counterparts_counterpart_interpreters). Empty by default; nothing apeGmsh depends on is installed non-editably into several interpreters, so there is no honest default to ship. The Windows-launcher counterpart is now py -3 rather than a pinned 3.12.

To keep the previous behaviour, set both in the shell: APEGMSH_REFERENCE_VENV=<venv root> and APEGMSH_SHARED_PACKAGES=baseUnits.

Two guards in tests/test_doctor.py: the module source may not contain an absolute user path (verified by reintroducing the old literal — the lane fails), and with nothing configured no D1/D6 message may quote a home directory (D2 is exempt; it echoes where apeGmsh actually resides, discovered at runtime). The three D6 lanes were rewritten around apeGmsh itself instead of baseUnits — they previously skipped everywhere the office package was absent, which is to say on CI.

FIXED — habitat template shipped one machine's paths (ADR 0095 S7a)

Every studio init on a machine that was not the template author's produced a broken habitat. Found by using the template from a real habitat (postmortem 2026-08-17_frame-example, F1/F3/F4).

  • template/cursor/mcp.json hardcoded C:\Users\nmb\... for both the launcher script and APEGMSH_PYTHON, and _align_mcp_json only substituted __HABITAT_ROOT__. The .ps1 indirection is gone: the server now runs as <python> -m apeGmsh.studio.mcp, and init substitutes the interpreter (__APEGMSH_PYTHON__) and its apeGmsh source (__APEGMSH_SRC__PYTHONPATH) alongside the root. Quiet env still precedes interpreter startup — MCP applies env before exec.
  • template/scripts/start.ps1 / finish.ps1 probed a single venv spelling (opensees_env), so any other naming fell through to PATH python and failed as No module named 'apeGmsh'. They now probe APEGMSH_PYTHONVIRTUAL_ENV → both office spellings → PATH, and print the interpreter they resolved.
  • template/scripts/_habitat.py::ensure_pythonpath defaulted APEGMSH_SRC to one developer's worktree path, and only ever set the env var — never sys.path, so the documented APEGMSH_SRC override did nothing in-process. It now resolves from the importable apeGmsh first, then APEGMSH_SRC, and guesses no default.
  • New regression test: no file in the packaged template may contain a user home path.

ADDED — habitat template: apeCAD port guard + studio init version gate

  • template/tools/apeCAD/open_interface.py probes its target port and refuses a bound one. apeCAD's ThreadingHTTPServer sets allow_reuse_address, so on Windows a second instance bound port 8765 next to a running one and served that session's sketch — no bind error, detectable only by diffing /api/scene. The matching guard in apeCAD.scratchpad.server.serve() belongs to the apeCAD repo and is not in this change.
  • template/scripts/start_session.py WARNs when apeGmsh.studio._init_habitat is absent — a checkout predating S7a fails studio init with a bare ModuleNotFoundError that reads like a bug rather than a version gap. WARN, never fail: an already-stamped habitat does not need the stamper.

FIXED — [all] now includes the mcp extra

pip install "apeGmsh[all]" installed the apeGmsh.studio package without the MCP SDK it needs, so the Studio door failed at launch and nowhere earlier — the install line the README, the docs and every tutorial hand out quietly did not cover the habitat. mcp>=1.2 joins all; verified by resolving the extra (107 packages, mcp present). It brings a server stack (uvicorn / starlette / sse-starlette) plus pydantic, opentelemetry-api and pyjwt with it — use the narrower extras (apeGmsh[viewer,opensees]) to avoid that.

tests/test_capability_map_drift.py gains an [all]-completeness lane so the next extra cannot slip through the same way: every extra must either be a subset of all or be registered in _ALL_EXCLUSIONS with a reason, and the registry is checked in both directions. Writing it surfaced three more gaps, all deliberate and now documented in pyproject.toml rather than merely absent — partition-pymetis (no PyPI Windows wheel, so folding it in would break [all] on Windows), partition-networkx (inert without git-only nxmetis) and animation (ffmpeg binary payload).

ADDED — docs: the Ladruno backend capability map

docs/concepts/backend-capabilities.md publishes the tier boundary an outside user previously had to discover by running into it: tier 0 (no backend — geometry, meshing, the broker, deck emission, results, viewers, Studio), tier 1 (stock openseespy — the in-process run), and tier 2 (the Ladruno fork). Enumerates the fork-only elements, integrators, constraint/coupling verbs, analysis + solver commands, materials and recorders, and separates the two failure classes: the verbs apeGmsh gates with a curated RuntimeError (the ones stock would otherwise accept and answer wrongly) from those the engine rejects with a bare OpenSeesError. Also documents that [all] omits the mcp extra, and that the fork ships no wheels or releases.

tests/test_capability_map_drift.py machine-checks the element and integrator lists against _FORK_ONLY_ELEMENTS / _FORK_ONLY_INTEGRATORS with a two-way ratchet (undocumented gate token fails; documented non-token fails), in the style of test_skill_docs_drift.py. The prose sections are out of scope — they name commands, not frozenset entries.

Three pages that recommended enforce="equation" without mentioning the build requirement now say so and link the map (concepts/constraints.md, how-to/tie-meshes.md, how-to/compose-modules.md) — the live equation route became fork-gated when the silent 71 %-soft stock path was closed.

ADDED — ADR 0095 Amendment 7 S8a+S8b: oracle-bearing example library

src/apeGmsh/studio/examples/<name>/** ships three curated, package-data examples — arch-pushover (adapted from examples/shoebuckle_arch.py, requires openseespy), step-load-transient (a new generic minimal cantilever carrying the density + step-load + Newmark + Ladruno -G energy transient pattern, requires ladruno), and partitioned-frame (adapted from examples/partition_frame.py, emit-only, mesh-only). Each directory carries the runnable script, a manifest.json (schema 1: tags, teaches, requires, provenance, and named oracle metrics with tolerances), a short README.md, and a verify.py printing one PASS/FAIL line per metric (INV-22 — every oracle number was produced by an actual run, not invented; INV-23 — each manifest's provenance names its dogfood source). New python -m apeGmsh.studio example list [--tag T] | show <name> | copy <name> [--dest DIR] verb (apeGmsh.studio._examples, same importlib.resources + raw-argv __main__ pattern as init, CLI only — no MCP tool). The mesh-smoke lane (tests/studio/test_example_library.py) replays all three packaged copies to the mesh phase gate on CI; full-solve verify.py needs openseespy / the Ladruno classic exe and is not CI-run — see the implementing PR for the local verify transcripts. docs/how-to/studio-habitat.md gains "Use the example library"; the apegmsh skill's Studio paragraph and workflows.md name the door.

ADDED — habitat template: visual-talk convention + apeCAD/apeSketch requisites

apeCAD and apeSketch (spatial-intent CAD scratchpad and hand-ink bridge) are now public and pip-installable, so the habitat template gains a requisite check + protocol for the visual conversation the tools already ship launchers for. scripts/start_session.py import-checks apeCAD and apeSketch after the existing Studio check — missing is a WARN (not session-fatal; a post-processing-only session doesn't need either client) with the pip install git+https://github.com/nmorabowen/... hint, factored into _check_visual_talk_requisite for unit testing. APE/instructions/socratic-geometry.md gains an "Asking for a draft or ink" section defining the ask protocol: what the agent asks for, where artifacts land (tools/apeSketch/human_sketches/ for human ink, agentic_sketches/ for agent references, or models/<id>/ for a lineage), and that the agent consumes Document.to_json()/to_frame() or the apeSketch session export by name, not pixel. ape.project.yaml gains an informational requires: block listing apeGmsh/apeCAD/apeSketch install hints. Mirrored into the steel-connection worked example.

ADDED — ADR 0095 Amendment 6: template improvement-workflow v2

APE/instructions/continuous-improvement.md gains a "Promotion lanes" section naming Lane A (habitat memory, immediate), Lane B (template — edit the habitat's APE/ copy, prove it, then PR into src/apeGmsh/studio/template/**), Lane C (upstream ADR/issue cadence), and a deferred cross-habitat rollup; the existing "promote patterns" bullet now points at Lane B. APE/instructions/session-postmortem.md's procedure gains a step verifying the previous session's promoted/closed backlog items against their declared next-postmortem proof — an unverified proof reopens the item in backlog/open.md (skip ≠ pass). New scripts/template_drift.py (package data, stdlib + _habitat.py only) diffs a habitat's APE/+scripts/ trees against the installed template via importlib.resources, reporting MODIFIED (Lane B candidates) / HABITAT-ONLY / TEMPLATE-ONLY, with memory files called out as expected drift rather than candidates; exits 2 with a clear message if apeGmsh isn't importable. scripts/start_session.py now prints a backlog: N open P0/P1 — … burn-down line parsed tolerantly from postmortem/backlog/open.md, never failing session start over a missing or malformed file.

ADDED — ADR 0095 Amendment 6 S7a+S7b: habitat template ships with studio

apeGmsh.studio.template now ships the APE Studio habitat tree as package data (playbook, memory stubs, skill/library doors + harvest tooling, session lifecycle scripts, soft-contract checker, postmortem / reports skeletons, ape.project.yaml) — lifted byte-identical (INV-21) from the curated ape-studio-template@bab594c extraction. python -m apeGmsh.studio init --name <habitat> --model <model_id> [--root DIR] copies the packaged template into a target directory, substitutes __HABITAT_NAME__ / __MODEL_ID__, aligns .cursor/mcp.json, scaffolds models/<model_id>/{src,cases}, and runs the new habitat's own scripts/check_template.py --strict; refuses (nonzero, no side effects) an already-initialized or conflicting non-empty target. The copier walks the packaged tree via importlib.resources, not __file__, so it works from an installed wheel. The template's own .gitignore and .cursor/mcp.json ship under safe names (dot.gitignore, cursor/mcp.json — package-data globs drop dot-prefixed entries) and are restored to their real dotfile paths by init. INV-20: the author's personal skill suites are named only in APE/skills/README.md's optional-use guidance, never required; a grep gate pins this over the packaged tree. Habitats stay self-contained — init copies the lifecycle scripts and checker in, so an initialized habitat survives apeGmsh version drift. Docs: docs/how-to/studio-habitat.md gains a "Create a habitat" section; the apegmsh skill's Studio paragraph names init. No contract_version bump — init writes project files, not habitat-state files.

ADDED — ADR 0095 Amendment 5: assess snapshot reaches the report

--assess (CLI and MCP, which shells to the CLI) now writes .apegmsh/assess.json — schema, timestamp, target paths, findings, skipped checks, figures, and the agent-facing markdown — via the same temp + os.replace discipline as names.json (INV-16); a torn file degrades to assess=None + a reason in ReportBundle instead of raising. emit_report's Assess section now quotes that snapshot's verdict, findings, skipped list, and target/timestamp instead of always printing "(none — run assess first)". --pin copies the live snapshot into .apegmsh/pins/<id>/assess.json so a later re-emit against that pin keeps the verdict current at pin time even after the live file changes or is deleted; the ledger's reserved pin.assess field stays untouched. Published assess.schema.json + golden fixture; contract_version is 1.6.0.

ADDED — ADR 0095 Amendment 4 S6a: manual host refresh

Toolbar action + F5 re-run the entry script (.apegmsh/project.json, else the script the host opened with) at the current phase through ReplayRunner.run_until in the host process. A live busy.json claim reports busy and does nothing (INV-18, no steal); an unchanged (hash, phase) is a skip-hash no-op ("Up to date"); success rebuilds the ModelViewer scene preserving camera (_rebuild_scene, now exposed as self._rebuild_scene); a failed replay keeps the previous frame (INV-4). Ledger records gain an additive trigger field (open / refresh / watch, S6b will emit the last). contract_version is 1.5.0.

ADDED — ADR 0095 F-status-1: MCP status brief mode

MCP status(mode="brief"|"full") defaults to brief (root, last-run intent, labels/PGs, counts summary, pick summary, CLI-shaped text) so session-start root checks stay under ~2 KB. mode="full" keeps the previous collect_status dump. contract_version is 1.4.0.

FIXED — ADR 0095 S5j: Opus harden (S5g–S5i)

Torn busy.json grace before steal; Windows pid_alive treats ACCESS_DENIED as alive; CLI project.json fallback + root-relative script args; write_project only with a live session; own-PID clear_host / release_busy; OUTSIDE_ROOT for out-of-root project defaults; MCP assess / render / animate path fields via display_path. contract_version is 1.3.1.

ADDED — ADR 0095 S5i: root-relative habitat paths

Ledger script / cwd and MCP path fields under the project root are stored as root-relative posix paths; root stays absolute. contract_version is 1.3.0.

ADDED — ADR 0095 S5h: habitat BUSY lock

run_until / replay acquires .apegmsh/busy.json (O_EXCL); a concurrent claim returns error.code == "BUSY" (CLI exit 3). Stale locks (dead PID) are stolen. status.busy reports lock state; contract_version is 1.2.0.

ADDED — ADR 0095 S5g: status.host + project.json

Qt host claims .apegmsh/host.json for the duration of show(); status.host reports {running, pid, phase, stale} and treats a dead PID as stale. Successful replay writes .apegmsh/project.json; run_until may omit script= and use that entry. Habitat contract_version is 1.1.0.

FIXED — ADR 0095 S5f follow-up (Opus review)

logged test accepts forwarded root=. Ledger reads decode UTF-8 with errors='replace' so a multibyte tear keeps earlier rows. CLI --root is str so blank does not become Path('.'). promote_selection returns UNREADABLE on torn picks; subprocess verbs return BAD_ROOT when the root is not a directory. studio-mcp.ps1 probes opensees_env then opensees_venv and warns when falling back to PATH python.

ADDED — ADR 0095 S5f: ledger degrade + empty-root safety

read_runs / status skip torn runs.jsonl lines and surface ledger_error (parity with names_error). Empty / whitespace root= and APEGMSH_ROOT are treated as unset. Habitat how-to clarifies atomic replace for snapshot JSON vs append-best-effort JSONL.

ADDED — ADR 0095 S5e: studio habitat how-to (spawn / poll)

docs/how-to/studio-habitat.md documents MCP + Qt spawn, INV-15 root, file ownership, poll contract, cold start, and the local run_until trust boundary for out-of-process consumers.

ADDED — ADR 0095 S5d: published schemas + contract_version (INV-17)

apeGmsh.studio.schemas ships JSON Schema + golden fixtures for selection / names / ledger / highlight / status. status includes contract_version (1.0.0). validate_contract refuses unknown majors; additive fields stay compatible.

ADDED — ADR 0095 S5c: MCP error envelope + subprocess timeout

Habitat verbs return {"ok": false, "error": {"code", "message"}} for validation failures (no raise into the adapter). _shell times out via APEGMSH_STUDIO_TIMEOUT (default 600s) → TIMEOUT. viewers render --json and studio --animate --json emit {"ok", "written"}; MCP prefers that over path-line scrape.

ADDED — ADR 0095 S5b: atomic habitat writes + torn-read status (INV-16)

atomic_write_text (temp + os.replace) for selection.json, names.json, and highlight.json. collect_status degrades on unparseable names.json via names_error (parity with selection_error) and never raises for a torn poll.

ADDED — ADR 0095 Amendment 3 / S5a: explicit studio project root (INV-15)

apeGmsh.studio.resolve_root: root= / --rootAPEGMSH_ROOT → nearest ancestor with .apegmsh/ → cwd. Every MCP tool accepts optional root=; run_until passes --root to the CLI so habitat files land under the project root even when script exec chdirs to script.parent. Ledger records may carry additive root / cwd. scripts/studio-mcp.ps1 no longer Set-Locations the library repo.

ADDED — DomainCapture gauss catalog covers LadrunoBrick20 (std)

LadrunoBrick20 (fork H20, tag 33018) was emit/run-ready but missing from RESPONSE_CATALOG, so DomainCapture skipped hex20 continuum stress/strain with an empty dataset. Catalog now registers the std 27-GP Hex_GL_3 layout (same brcshl walk as Twenty_Node_Brick / LadrunoHex20Shape.h GP27[], live-probed identical). formulation="uri" returns a genuine Vector(48) (no slot-0 mirror, unlike LadrunoBrick) — a second non-Custom rule would make _class_int_rule return None and drop all LadrunoBrick20 elements, so uri stays out of the catalog and DomainCapture fails loud with a message that names -formulation uri (use the .ladruno recorder for uri gauss). MPCO still writes rule 0 for Ladruno-band tags (pre-existing; same as LadrunoBrick) — not fixed here.

ADDED — ADR 0096 index harvest: Part / Results / Cluster / fluent select

MCP lookup missed sidecar constructors and g.model.select.in_box / .to_label because the S2 harvester only walked session composites and apeSees. Those symbols now land in _api_index.json (no new MCP verb; Part nested composites are not walked so add_box stays unique). Closes the named S5 src/-grep gap.

FIXED — canvas slug test is platform-absolute

test_cursor_project_slug_matches_cursor_convention was a Windows path resolved against the Linux CI cwd. Assert POSIX slugs on POSIX and the Cursor Windows mapping on nt.

FIXED — sync_skill.py --check stays stdlib before gmsh

lock-tests runs the skill-mirror check before pip install. Live index drift still runs when apeGmsh/gmsh import, and in tests/studio/test_lookup.py.

FIXED — ADR 0096 S5 red/blue closeout

--promote counts MCP lookup kind=miss only (ambiguous is not a miss). Closeout is MCP-lookup-miss eligibility, observe-only — not a closed Budget A loop. Agent-facing text: propose a PR; do not write skills/ or run sync_skill.py in-session.

ADDED — ADR 0096 S5: promotion bar (observe only)

python -m apeGmsh.studio.profile --promote lists lookup-miss classes that hit 3 repeats. Skill errors still owe a PR after one confirmation. Working ImageMage steps: script comments and/or a non-normative Recommendation in workflows.md. Writes nothing; no fix_skill / remember_steps.

ADDED — ADR 0096 S4: sidecar token profiler

python -m apeGmsh.studio.profile reads .apegmsh/mcp_calls.jsonl (tool name + payload bytes, appended by the MCP adapter) and/or a transcript JSONL. Heuristic tokens = chars/4. Defect metric is src_search_rate. --skill-budget gates skill file size. Not an MCP tool.

ADDED — ADR 0096 S3: MCP lookup(symbol)

See-family inspect of the generated API index. Same ~20-line payload as python -m apeGmsh.studio.lookup. Miss points at the skill reference; the adapter does not grep src/ and does not wrap g.model.*. Profiler remains S4.

FIXED — ADR 0096 S2 red/blue closeout

--check and a test now fail when the committed API index drifts from a live harvest. Lookup no longer emits a stub symbol(...). Ambiguous hits print bounded signatures. Remaining skill-freshness stamps point at python -m apeGmsh.studio.lookup, not src/ grep.

ADDED — ADR 0096 S2: generated API index + lookup CLI

python -m apeGmsh.studio.lookup SYMBOL returns ~20 lines (signature, skill pointer, one-line doc) from src/apeGmsh/studio/_api_index.json. Rebuild with --build or scripts/sync_skill.py. src/ grep stays an index miss. MCP lookup is S3, not this slice.

ADDED — ADR 0096 S1: skill router, no grep-src/ lookup

Canonical skills/apegmsh/SKILL.md now routes one task → one reference. Authoring a model script must not grep src/apeGmsh/; that path is an index miss for library maintenance only. Cheatsheet opening matches. Derive remains scripts/sync_skill.py.

ADDED — ADR 0096 (Proposed): agent token budget

Lookup vs judgment. API spelling is a catalog (skill router + later generated index), not grep src/. Engineering criteria stay the spend. Observe open / mutate closed: skill errors and working ImageMage steps promote by reviewed PR or script comments, not by session residue writing SKILL.md or new CAD MCP verbs. 0095 INV-10 stands. S0 is the ADR only (S1–S5 named, not shipped).

ADDED — studio CAD quotations (name + overall size)

Studio ModelViewer opens with part and entity labels on, each showing the AABB size in metres (cotas). Toggle still lives on the View tab. --phase model and --phase mesh remain separate processes so the CAD window can stay up while the mesh generates.

ADDED — Cursor MCP config; quiet the Ladruno stdout banner

S4a leftover: .cursor/mcp.json launches scripts/studio-mcp.ps1python -m apeGmsh.studio.mcp. The office venv .pth prints the Ladruno splash to stdout at interpreter start, which corrupts stdio JSON-RPC; the launcher and config set LADRUNO_OPENSEES_QUIET=1 (and APEGMSH_QUIET=1) before Python runs. Needs apeGmsh[mcp].

ADDED — ADR 0095 S4e: emit_report HTML and canvas skins

Same ReportBundle, no new MCP verb. format=html writes docs/studio-report.html (print/share). format=canvas writes a Cursor .canvas.tsx to the IDE canvases/ directory (output= / CURSOR_CANVAS_DIR / ~/.cursor/projects/<slug>/canvases/) — not git-portable, not the archive. Both skins still write the markdown chapter under docs/. Labels are HTML-escaped. Canvas does not embed visor PNGs. kind=formation stays later.

FIXED — ADR 0095 S4d adversarial closeout

promote_selection snippets match g.model.select(None, dim=).in_box(lo, hi).to_label() / .to_physical(); unnamed picks without a bbox do not author tags. kind=yield is a von Mises contour on stills-style auto-scaled deform (0.12 of the model diagonal), not scale 1.0 and not an iso-clip. highlight writes highlight.json only; the host ignores a leftover file at open and applies names via live resolve + select_batch (INV-3 / INV-7). File poll is the INV-5 S3 adapter, not a Qt viewer.highlight(names) mutator.

ADDED — ADR 0095 Amendment 1 + S4a–S4d studio MCP

S4 restated: MCP wraps habitat verbs, not the apeGmsh public API. One ReportBundle (schema 1); Markdown is the canonical record; HTML and Canvas are later skins. animate(kind=) is a closed catalog. Read-only MCP (S4a) does not wait on highlight (S3).

python -m apeGmsh.studio.mcp is the Cursor stdio adapter. S4a: status, get_selection, run_until. S4b: assess, render, animate(kind=history). S4c: results_pin, emit_report(format=markdown) writes docs/ (INV-12). S4d: animate(kind=yield) is a mesh iso of von_mises_stress (not a π-plane); highlight writes .apegmsh/highlight.json; promote_selection suggests .to_label() / .to_physical() and does not write the .py. Tool bodies read .apegmsh/ JSON or shell to --no-viewer / --assess / --animate / python -m apeGmsh.viewers render (INV-5). Visors under .apegmsh/visors/. Optional extra apeGmsh[mcp]. No g.model.* tools, no setup= on animate.

ADDED — studio --status inspects .apegmsh/ without replay

python -m apeGmsh.studio --status prints the last ledger line, names, counts, and pick from cwd .apegmsh/ — no exec, no Qt. --json dumps the same payload. Empty state exits 2. PoC script: tests/studio/fixtures/box.py.

ADDED — studio run ledger (.apegmsh/runs.jsonl)

Each run_until stop appends one JSONL line (timestamp, script hash, phase, stopped_at, labels / PGs / counts). Skip-remesh does not write a line. A failed exec writes ok: false and the traceback. names.json stays the current snapshot; the ledger is the history. assess and visors are reserved empty keys until those artifacts exist.

ADDED — studio run_until(phase) is a real gate + names.json

python -m apeGmsh.studio script.py defaults to --phase model and stops before g.mesh.generation.generate() (a StopAtPhase sentinel, not a failed exec). --phase mesh allows the mesh and stops before apeSees / Results. A successful stop writes .apegmsh/names.json (labels / physical groups / entity bboxes / element counts) — what exists, not what was clicked. Skip-remesh keys on (source hash, phase) so a model preview is not reused as a mesh preview. Geometry preview of a solving script no longer runs the solve.

FIXED — assess closeout (ADR 0094 Amendment 1)

Planar 2-D winding is degeneracy-only (clockwise slabs are not inverted). Native union-merge NaN fill is skip, not FAIL. RES.ENERGY_ERR is info in %; RES.ENERGY_NONFINITE is the warning. AssessmentReport.skipped is branchable; the Verdict names unevaluated FAIL-reserved checks and last-step scope. Unbound results.render / render_pack raise under APEGMSH_SKIP_VIEWER.

CHANGED — ADR 0094 Amendment 1: catalog honesty

Assess closeout contract after adversarial review: 2-D cells judged for degeneracy only (winding is a convention), RES.NAN union-merge fill is skip not FAIL, RES.ENERGY_ERR is info in %, new RES.ENERGY_NONFINITE warning, AssessmentReport.skipped is branchable. Code in the follow-up PR. Does not reopen S0–S5.

FIXED — Graphics colors G-HEX + model-viewer G-RENDER ratchet

Okabe–Ito preset and swatch contrast no longer carry hex string literals (ADR 0087). Model-viewer G-RENDER budget ratchets 9→8.

CHANGED — skill: agent eyes are visors, not the OS desktop

After a solve the agent looks at .apegmsh/visors/ stills (results.render / labeled results.plot), not a screenshot of the human's monitors or the live Qt window (ADR 0094).

FIXED — studio replay sets an absolute __file__

run_until chdirs into the script directory, then exec'd with a relative __file__. Path(__file__).resolve() then doubled the folder (.apegmsh/.apegmsh/...). The replayed path is now resolved before exec.

ADDED — studio opens MeshViewer when the replayed script has a mesh

python -m apeGmsh.studio gates the host on live Gmsh elements (INV-8): MeshViewer if the script called generate(), otherwise ModelViewer. Picks still write the names-first envelope (phase="mesh" or "model"). MeshViewer accepts on_selection_changed the same way ModelViewer does.

ADDED — ADR 0095 Part 6 / INV-9: host projects the script, does not own it

Studio may show a read-only text view of the replayed .py (syntax + optional name colors matching the viewport) and a later PG→apeSees binding graph. Click highlights; the host does not write the file. Cursor remains the author. Live refresh and these projections stay in the Later slice.

FIXED — dim-filter toggles reset Display-panel surface opacity

The 0/1/2/3 pick-filter rewrote actor opacity from the constructor default (0.35) instead of the live Display slider. Inactive dims still ghost to 0.1; turning a dim back on restores the slider value stored on the registry. The slider re-applies the filter so ghosted dims stay ghosted.

ADDED — View → Theme → Graphics colors… (live, non-modal)

Non-modal Tool window for viewport roles (hover / pick / idle / origin / measure). Edits apply immediately via THEME.update_current so they can be tried while hovering and picking. Includes an Okabe–Ito (protanopia) preset: yellow hover, blue pick, sky-blue overlays. Session-only until Save as theme. Measure probe now reads Palette.measure_color instead of hardcoded yellow.

FIXED — dim-filter keys 0/1/2/3/4 swallowed by VTK QtInteractor

0/1/2/3 (toggle point/curve/surface/volume) and 4 (all) were bound with plotter.add_key_event. The Qt interactor never delivered those digit keys, so the Help → Shortcuts contract was a no-op. They are now QShortcut with ApplicationShortcut — the same law as ResultsViewer Esc — on the model, mesh, and results viewers. Status bar reports the active dim set.

ADDED — ADR 0095 S1+S2: python -m apeGmsh.studio + names-first pick envelope

Basic working studio habitat to test ADR 0095. SelectionEnvelope projects SelectionState to JSON whose identity is labels / physical groups / phase (not dimtags). python -m apeGmsh.studio script.py replays the script with the session held open, opens the Qt model viewer, and writes .apegmsh/selection.json on pick. Failed replay keeps the last good frame (INV-4, unit-tested on the runner). Sidecar: not a composite, not re-exported. No Electron, no MCP, no statement-step.

FIXED — Gmsh from two threads aborts the process (ADR 0080 B6 follow-up)

_session.py grows a process-wide re-entrant runtime lock, held from _gmsh_acquire to the matching _gmsh_release. _GMSH_INIT_LOCK only ever guarded the init refcount — never the gmsh.model.* calls between acquire and release — so two threads could each hold a valid session and interleave their model calls on what is one process-global, non-thread-safe, non-reentrant C++ runtime. The result is a C-level abort(): no Python traceback, no catchable exception, no FAILED line. It was found when a full Windows suite run died inside the ADR 0080 B6 live-properties worker, and it is a product bug — a user toggling Live while a session is open in the same interpreter can lose the app with no diagnostic.

Putting the lock inside the existing acquire/release pair covers every call site (sessions plus the standalone _fem_factory helper) with no new plumbing, and re-entrancy keeps nested sessions — a Part opened inside an apeGmsh session — from self-blocking. The deadlock rule is never wait on another thread's gmsh work while holding it; a session must also begin and end on the same thread, and releasing from another now says so instead of raising a bare "cannot release un-acquired lock". gmsh_runtime_lock(timeout=…) is the bounded-wait form for callers that cannot block forever.

The properties worker does not use the lock — it meshes in a child process (sections/_mesh_proc.py + _mesh_worker.py). Serializing would have been safe but not useful there: a session the user left open holds the lock for its whole lifetime, so the worker would never get a turn and the panel would stay grey until they closed it. Moving the meshing out removes the contention instead of managing it. Only the mesh crosses the boundary — FEMData pickles in ~90 KB — and the analyzer is rebuilt and solved back in the worker thread, which is both the expensive half (0.1–1.0 s vs 330–580 ms of meshing on an SRC composite) and pure NumPy, so it needs no protection. Rebuilding it in the parent also keeps a live SectionProperties for the inspector to drive interactively with user-entered loads, which a "return the numbers" split would have lost. SectionDocument grows the seam: build_fem() (the gmsh half) and analysis_from_fem(fem).

Every child failure arrives as a message, never a hang: a document error carries the child's own diagnosis, a wedged mesh is stopped (MESH_SUBPROCESS_TIMEOUT), and a child that dies without writing its envelope — the abort case — becomes "the mesh process failed without reporting".

tests/test_gmsh_runtime_lock.py asserts the invariant itself — eight threads opening real sessions, an occupancy counter never above 1 — plus re-entrancy, the bounded wait, that the worker builds while another thread holds the runtime, and that it takes zero acquires. The threading assertions run in child interpreters, because the invariant is a property of a process and the shared suite process has sessions of its own open; that is also what makes the positive control automatic: with the lock stubbed out and nothing else changed, the workload kills the interpreter outright. tests/sections/test_mesh_subprocess.py gates the relocation — the child's mesh reproduces an in-process build's area, EIxx_c, GJ and Mp_xx, and the rebuilt analyzer still solves stress on demand. The three B6 worker tests pass unchanged.

CHANGED — ADR 0094 Accepted: skill teaches assess(), Q1–Q3 closed

After-solve check is fem.assess() / results.assess(figures=True) then read_file on report.figures — not inspect as the verdict, not Qt. New skills/apegmsh/references/assess.md (and the helper copy) owns the v1 catalog and code → next action. Happy path / gotcha 7 / results.md §3b / workflows.md §6 point there. Open questions closed: coarse BRep tessellation (Q1), ladder 1+3 only — no hidden-window fallback (Q2), stills.py stays a poster script (Q3). No new finding codes, no g.assess, no OpenSeesModel.assess().

ADDED — python -m apeGmsh.viewers render (ADR 0094 S5)

Off-kernel stills CLI next to the existing interactive python -m apeGmsh.viewers <path>. First token render dispatches to results.render / results.render_pack / fem.render; anything else (including a file named render.h5) still opens the Qt viewer. Skip (APEGMSH_SKIP_VIEWER / no GL) is exit 0 with the existing [skip viewer] notice. Bad flags, missing files, and ValueError (bad view, out-of-range step, deformed with no u) are non-zero on stderr. A standalone model.h5 calls fem.render. No live-session door for g.model.render / g.mesh.render.

ADDED — g.model.render / g.mesh.render live stills (ADR 0094 S4)

Offscreen BRep and undeformed-mesh stills on a live session, same VTK path as fem.render / results.render (pv.Plotter(off_screen=True), theme, camera= / window_size=, .partial.png then replace). g.model.render uses build_brep_scene (ModelViewer's tessellation, including a throwaway coarse 2-D mesh when none exists). g.mesh.render uses build_mesh_scene (MeshViewer's live Gmsh scene), not get_fem_data + build_fem_scene. A from_h5 / closed session raises RuntimeError — no faked BRep still from FEMData. APEGMSH_SKIP_VIEWER=1 or no GL returns None and prints the existing [skip viewer] notice.

ADDED — results.render_pack + assess(figures=True) (ADR 0094 S3)

Canned report pack of Qt-look stills (mesh / last-step primary contour / same deformed at the S1 0.12 auto-scale / static reactions if recorded / optional matplotlib history at the extrema node, labeled [matplotlib]). Public door is results.render_pack(out_dir) -> tuple[Path, ...]. There is no fem.render_pack — a mesh-only pack would be one still, and that is already fem.render. assess(figures=True) (default still False) calls those broker methods; apeGmsh.assess still does not import viewers or gmsh. APEGMSH_SKIP_VIEWER=1 or no GL returns () / empty figures, prints the existing [skip viewer] notice, and the report says "no stills; numbers only."

FIXED — 2-D out-of-plane recovery: LadrunoLST was invisible to it, and the miss was silent

A plane-strain model built from LadrunoLST — the fork's recommended quadratic triangle for near-incompressible plane-strain plasticity, i.e. exactly where σ_zz carries the most weight — rendered wrong von Mises, principal, and mean stresses with no warning. _plane_recovery recognised only LadrunoQuad and LadrunoCST, so plane="auto" failed to classify the model, σ_zz fell back to 0 instead of ν(σ_xx+σ_yy), and the contour path offered no way to say otherwise. Reported from the Cerro Lindo SSI program (ADR-0005 rung M4), whose m4_render.py had to compute plane-strain von Mises by hand.

  • The missing tokens. LadrunoLST joins the flag-style 2-D set. Audit of the rest: tri6n / SixNodeTri and BezierTri6 were already covered by the positional set, and SSPquad has no typed primitive to emit it. One more real gap closed — LadrunoUP, whose 2-D shapes are plane strain by construction; the token spans both dimensions, so the per-axis permeability count (-perm / -permH) is read as its dimensional witness.
  • Silence was the actual defect. An element the recovery cannot classify — or one whose material Poisson's ratio it cannot read — now raises OutOfPlaneRecoveryWarning (exported from apeGmsh.results, so it is filterable) naming the offending type_token and the fix. An element correctly recovered as zero (plane stress for σ, plane strain for ε) is not reported: that zero is exact. Deduped per situation, because the viewer re-reads every frame.
  • Auto-detection is no longer the only word. ContourStyle gains plane= / nu= (honored on topology="gauss", mirroring PrincipalGlyphStyle), and results.plot.contour(...) gains the same pair — both already existed on results.elements.gauss.get. A contour that overrides the recovery bypasses the shared visual store (its float16 cache and global colour range hold default-recovery values) and re-reads per step.

ADDED — labelled g.decouple_node as RBE2/RBE3 master (ADR 0049 OQ2 slice)

g.constraints.kinematic_coupling and g.constraints.distributing_coupling accept a labelled g.decouple_node (string label= or the handle) as master_label / slave_label. Resolve binds the singleton decoupled tag — no post-get_fem_data retarget. Other constraint kinds refuse a decoupled role at declare. ops.ndf(label=/pg=) and broad FEM label registration remain open; OQ4 (partitioned MP master) stays deferred.

ADDED — g.constraints.interface(): a coincident-pair interface that can let go (ADR 0093)

The constraints roster gains its first non-bond. g.constraints.interface( master, slave, *, normal=, tangential=, thickness=, tolerance=1e-6, slave_ndf=None, master_entities=None, slave_entities=None, name=None) emits one zeroLength per coincident node pair between a 2D continuum boundary and a node-for-node coincident wire: unilateral in the normal direction (compression only, separation free) and strength-capped in the tangential one. That is the soil/rock-to-structure bond the Cerro Lindo SSI campaign needs — with a bilateral tie the ground converges and the liner's demand grows without a ceiling, which the field record (metre-class convergence, arches damaged but standing) falsifies. Everything below ships together; the ElasticPP / ElasticPPGap primitives (S2) and the partitioned emit (S8) already have their own entries above.

  • Laws are declarative, translated only at emit. NormalLaw(kind="ent" | "epp_gap" | "elastic", k_per_area, tau_b_n=, gap=) and TangentialLaw(kind="epp" | "elastic", k_per_area, tau_b=) are flat-scalar kernel dataclasses carrying stiffness per unit area; build.py's translation table turns each into a typed uniaxial scaled by that pair's A_trib (ent → ENT(E=k·A), epp → ElasticPP(E=k·A, epsyP=tau_b/k) — the yield force tau_b·A is the emergent E × epsyPepp_gap → ElasticPPGap(E=k·A, Fy=−tau_b_n·A, gap≤0)). The first g.* verb carrying material data, and core/_kernel still import nothing from apeGmsh.opensees.
  • The signs are the library's, and they are the point. Local-x is the master face's outward normal, derived per pair from the master's own boundary edges — a curved master's frame follows the arc instead of collapsing to one average direction, and the sign is fixed against the adjacent domain element's centroid, never the mesh's edge winding. With iNode always the real continuum node, x̂·(u_j − u_i) makes separation a positive elongation, so an ent normal carries zero force open and compression closed; epp_gap gets Fy < 0 and gap ≤ 0 forced by -abs(...) at emit. Flipping either half yields a tension-only interface that converges beautifully and is wrong everywhere — the whole silent-error class this verb exists to kill, gated on a curved master for both normal law kinds.
  • Tributary bookkeeping is not the user's. A_trib = ell_trib × thickness from a 0.5 × edge_length accumulation along the master polyline, with closure on face length × thickness asserted at resolve time and a zero share raised, not skipped. thickness is required with no default (the verb refuses to guess an out-of-plane dimension).
  • Mixed ndf via an explicit slave_ndf=. ZeroLength::setDomain refuses dofNd1 != dofNd2, so slave_ndf=3 (a beam wire) mints a 2-dof phantom at the slave's coordinates plus equalDOF(retained=beam, constrained=phantom, 1 2) and runs the spring master → phantom, leaving the beam's rotation free. Explicit because element classes are assigned at ops.element time, after resolution — apeGmsh cannot know whether a wire becomes a truss or a beam. A mismatch between the declaration and the ndf the deck actually carries is refused at emit, naming both.
  • Scope is loud. 3D models and surface masters raise NotImplementedError at the call and again at resolve (ADR 0093 D2); an interior master edge (material both sides, no outward direction), a reentrant corner, overlapping master/slave node sets, an unmatched slave and an out-of-plane master edge each raise by name.
  • The full record lifecycle, not a half one: InterfaceRecord on fem.elements.interfaces, h5 persistence (neutral schema 2.29.0) with orient rotation and phantom transform under g.compose (INV-2, rotation case gated), stage claim via s.interface(name=) so the unit installs on ground the earlier stages equilibrated (measured born strain-free: install-stage deformation exactly 0.0 against a raw relative displacement of 2.3e−4), and partitioned + staged together — the campaign's actual scenario — at worst relative delta 3.6e−15 serial vs 2-rank OpenSeesMP.
  • Acceptance battery, split deck-level / engine-level across tests/opensees/integration/test_interface_acceptance_battery.py and tests/opensees/subprocess/test_interface_acceptance_engine.py: the bonded limit converges 1/k on equal_dof (max nodal error 1.79e−07 at k_per_area=1e14, 1.79e−09 at 1e16 — ratio 99.9999 over the 100× step, against a bound that includes the spring bed's rotational compliance; an axial-only bound predicts 8e−8 and would have passed a wrong implementation), zero-tension with free separation on the curved master in both signs and both normal kinds, slip saturation at tau_b × L × t, INV-3 closure read back off the emitted material lines, h5 round-trip → emit byte-identity, compose invariance, and per-pair MPCO springs channels (spring_force_0 / _1, spring_deformation_0 / _1) read back against the engine. The ADR's bonded-limit reference was amended from tie to equal_dof during S10: tie takes only dim-2 surfaces / dim-3 volumes while interface() is 2D-line-masters-only, so no mesh exists that both verbs accept — and equal_dof is the stronger reference anyway, being exact where tie's default enforcement is itself a penalty.
  • Docs: docs/api/constraints.md gains a Tier 6 section, docs/concepts/constraints.md places interface springs against tie / contact / embedded, and skills/apegmsh/ carries the verb.

FIXED — partitioned-contact review fixes: displacement-driven decks refuse instead of freeing the ghost DOF, and the cut-master backstop no longer disengages on a partial facet map (ADR 0092 review F1–F8)

The 2026-08-13 adversarial review of the landed partitioned-contact emit lane (PR #927 + follow-ups #944/#945/#948) audited the refusal lattice and found two silent-wrong holes, both closed here, plus smaller diagnostic drift:

  • F1 (HIGH) — a pattern-borne sp on a contact-ghosted node was neither mirrored nor refused: pattern sp lines fan out on a node's NATIVE ranks only, so a displacement-driven contact deck (prescribed motion on the slave surface — ordinary authoring) constrained the DOF on the home rank while the owner rank's ghost copy stayed FREE — the ADR 0027 INV-2 measured singular-matrix class. The contact planner now runs the same plan-time sweep the interface lane already had (_refuse_pattern_sp_on_interface_ghosts, lane="contact"), with a contact-named error. Refusal, not mirroring — mirroring pattern sp onto ghosts correctly (including under staging) is its own project, and the error says so. Load-driven decks (the S5 shape) are unaffected; the same model still emits serially.
  • F2 (MEDIUM) — the INV-4 cut-master + auto-sizing backstop silently disengaged when the facet→element map was unresolvable, and the map was all-or-nothing: ONE ambiguous facet anywhere dropped the WHOLE surface to the node tally, so a genuinely cut master + kn="auto" emitted a deck whose off-rank auto-kn facets the fork silently skips (fork ADR-78 D5.2) — a silently partial interface. master_backing_element_ids is now per-facet; the straddle check runs on the resolved subset; and a partial map + a live auto knob + a multi-rank master (node view, master_node_rank_span) refuses with a named error. A cut master with a fully explicit penalty stays permitted (documented).
  • F3 — a backing element absent from every PartitionRecord now warns loudly (and refuses under an auto knob, same shape as F2) instead of silently degrading the exact owner pick to the node tally.
  • F4 — the undecidable-tie refusal for contact_plane no longer tells the user to disambiguate via master-element ownership (a plane has no master surface and ownership is never consulted for it): the plane lane names the slave tally and the fixes that actually apply.
  • F5soft_family_knobs now mirrors contact_args' edge_edge gate: edge_soft alongside edge_edge=False emits no -edgeSoft token and is no longer refused under partitioning (a live edge_soft still refuses, INV-3).
  • F6 — the suppress_analysis_chain_auto_emit seam's partitioned-only contract is documented at its producer (the flat/split auto-emit sites never consult it; its sole producer requires partitions, so it is unreachable on those lanes today).
  • F7 — the partitioned-vs-serial tag-stream ordering difference is recorded in the ADR as a known, accepted cosmetic divergence (each deck is self-consistent; twins compare content).
  • F8ContactRecord / ContactPlaneRecord docstrings no longer claim "Serial-only" (false since S4).

Full findings table + dispositions: ADR 0092 §Review-findings log. Tests: tests/opensees/integration/test_contact_partitioned_review_fixes.py (11) + per-facet / rank-span / plane-message / edge-gate units in tests/_kernel/resolvers/.

FIXED — a STAGED contact model no longer runs with the contact handler silently overridden (ADR 0092)

The staged partitioned contact refusal (S4) names its own reason: the staged pipeline skips the analysis-chain auto-emit, "so the forced LadrunoContact handler would never be emitted and the interaction would be silently unenforced". That reasoning was never partition-specific — the serial staged path had the same hole, and it hid better, because the global auto-emit does emit constraints LadrunoContact, so the deck looks covered. It isn't: a stage re-declares the entire analysis chain, so its own constraints line lands after the global one and before the stage's analysis, and OpenSees builds the analysis with whatever is current at that moment. Measured on a staged two-block contact deck whose stage declared Transformation:

contact 1 1 2 …             ← the interaction
constraints LadrunoContact  ← global auto-emit
constraints Transformation  ← the stage's handler
analysis Static             ← constructed with Transformation

The contact FE adapters are never injected — the interaction does nothing, and nothing says so. Since s.analysis() requires constraints=, the wrong handler was always reachable; there was no "just don't declare one" escape.

BuiltModel._validate_staged_contact_handlers (the contact twin of the ADR 0068 Open-item-5 EQ guard, called from both staged emit paths) now requires every stage of a contact model to declare s.constraints.LadrunoContact(), else a BridgeError naming the stage, the handler it declared, and the consequence. Non-contact staged models are untouched.

Also fixed the warning that was crying wolf next door: the contact auto-emit warned "a constraint handler was declared … overriding your handler" for any declaration — including LadrunoContact itself, i.e. on every correct staged contact model, and while claiming an override that actually runs the other way. It now fires only on a genuinely conflicting handler, names it, and says that emit ORDER decides which handler the analysis is built with.

ADDED — ADR 0092 S6: the partitioned-contact read-back, and the cross-library filename contract nobody had executed

tests/opensees/subprocess/test_contact_partitioned_results_stitch.py — the S5 model recorded (ops.recorder.Ladruno, unfiltered) and read back through Results. INV-6 predicted "work reduces to a regression test"; writing it found the contract rests on three things, only one pinned:

  • The .part-<K> filename is a cross-library contract, and each side had only tested its own half. apeGmsh emits the recorder line ONCE, globally, with the filename verbatim — there is no part- anywhere in src/ outside the two reader modules. The suffix that discover_partition_files matches is written by the FORK (SRC/recorder/LadrunoRecorder.cpp, gated on send_self_count or an MPI launcher's PMI_*/OMPI_*/SLURM_NTASKS). If the fork's spelling drifted, every partitioned read would silently return ONE rank's slice — a wrong answer shaped like a small model. Now asserted against real 2-rank output.
  • The node stitch dedupes first-write-wins, and the contact ghost is that duplicate. Measured: rank 0 recorded 12 nodes for the 8 it owns — the 4 extra are exactly the slave-face ghosts — while rank 1 recorded its 8. _merge_node_slabs keeps the first copy and drops the rest without comparing them, so the stitch is correct only because ghost == native; the suite asserts that equality survives to the recorder (not just the solver, where ADR-78 P0/S5 measured it) and pins the stitched answer against the serial twin: 16 unique node ids, no NaN, max relative Δ 1.997e−14 (gate 1e−10).
  • The element stitch does NOT dedupe_concat_element_slabs concatenates, assuming rank-disjoint elements, so an INV-7 violation (a ghost carrying elements) would double-count in every partitioned read with no error. Pinned: rank 0 {2}, rank 1 {3}, empty intersection, union == the serial element set.

Plus INV-6 proper on real contact output rather than a synthesized manifest (strip ON_ELEMENTS from one rank → the stitch still answers; from both → loud). The decks deliberately declare no constraint handler, riding the auto-emit, so the suite is also a live consumer of the S5 open-item fix below. Recorded in ADR 0092 for whoever extends this: _per_partition's missing-rank tolerance does not wrap read_layers/read_springs (harmless only while both are always-empty stubs), and opensees_model() reads partition 0 only. Gated subprocess + slow with loud env skips, so CI skips it.

FIXED — split="parts" silently dropped g.constraints.interface() (ADR 0093)

BuiltModel._emit_split ran every other additive side-list pass (emit_mp_constraints, emit_reinforce_ties, emit_embed_ties, emit_contacts, emit_contact_planes, emit_rebar_elements) but never emit_interfaces, so a model carrying g.constraints.interface() exported with split="parts" lost the ENTIRE interface — no zeroLength, no tributary-scaled uniaxials, no mixed-ndf phantom — and the deck still loaded and ran, as a fully bonded model where the user asked for a unilateral one. Found by the ADR 0093 S8 adversarial review (refuter A, finding 5). The pass now runs at the flat path's position (after emit_contact_planes, before emit_rebar_elements), driver-side with the MP constraints: an interface is cross-module by construction, so its unit must land after every fragment has declared its nodes. The constraint-handler auto-emit already saw mixed-ndf interfaces through _fem_has_interface_equal_dofs, so the split deck's handler was never wrong — only its interface was missing. Regression: tests/opensees/unit/test_split_emit.py (the driver carries the block and the fragments do not; the split driver's interface lines equal the single-file deck's, same order, same tags).

FIXED — analysis-chain auto-emits now precede a user-declared analysis directive (ADR 0092 S5 open item)

The bridge's auto-emitted chain components — constraints LadrunoContact (contact) / Transformation (MP constraints), and under partitioning the ADR 0027 INV-5 runtime-conditional numberer ParallelPlain / system Mumps — used to land AFTER a user-declared ops.analysis.Static() line. OpenSees constructs the analysis object AT the analysis command, and constraints / numberer do not retro-propagate into it (the engine back-propagates only system / algorithm), so the auto-emits were silently inert: measured in the ADR 0092 S5 harness, a contact deck relying on the auto-emit ran PlainHandler — the serial twin diverged and the 2-rank twin converged to a plausible-WRONG answer (w_top −5.714e−4 vs the true −5.625e−3) with base reactions still balancing. All three emit paths (_emit_flat, _emit_split, _emit_partitioned) now hoist the auto-emits to immediately before the first user-declared Analysis primitive and skip the original post-topology site; decks with no user analysis primitive keep the original position byte-identically, and the suppress_analysis_chain_auto_emit seam (ADR 0077) is honored at both sites. The live (in-process) lane shares the same pass, so it is fixed too. Regression: deck-shape pins in tests/opensees/integration/test_emit_partitioned_contact.py + the numeric *_auto_chain twins in tests/opensees/subprocess/test_contact_partitioned_numeric_twin.py (the exact pre-fix hazard decks now match the explicit-chain twin to ≤ 1e−10).

ADDED — partitioned (MPI) emit for g.constraints.interface() (ADR 0093 S8)

  • The S5 blanket refusal is lifted: an interface model now emits under partitioned (MPI) emit. Each pair is an ATOMIC unit — mixed-ndf phantom + nested equalDOF + two tributary-scaled uniaxials + one zeroLength — landing inside exactly one rank's block: the rank owning the pair's stamped backing continuum element (INV-5). Element-side ownership is the point: the pair's nodes are co-located, so a cut hugging the interface replicates both onto both ranks and node-tally ownership is undecidable by construction; duplicate emission is the measured ADR 0092 failure class (double stiffness, half penetration, base reactions still balance). Two loud preconditions make the pick exact: the backing element must appear in exactly ONE partition's element set (counted directly — never the first-seen dedup), and the master node must be native to the owner.
  • A foreign slave/beam node ghosts as node + the owner's replayed SP stream (ADR 0027 machinery) with its home ndf explicit (a mixed-ndf beam slave ghosts as -ndf 3 under an ndf=2 envelope) — geometry + SP only, never mass/elements/loads (ADR 0092 INV-7). The nested equalDOF emits owner-only: the phantom exists on exactly one rank.
  • Tag determinism (ADR 0027): every record's material + element tags are pre-allocated in flat side-list order before the rank fan-out; the flat and stage passes consume the same pre-pass (flat decks byte-identical to before). Flat↔partitioned tag identity is conditional — it holds when no other element-minting MP pass coexists (rigid-body / kinematic- coupling / ASDEmbeddedNodeElement tags are minted inside the per-rank loop; measured drift 21–24 vs 25–28 with a coexisting g.constraints.embedded) — recorded as an INV-5 amendment and pinned by a mixed-model regression. Exactly-once emission, global tag uniqueness and cross-rank determinism hold unconditionally.
  • Two refuter-driven refusals (adversarial review, same day): a pattern-borne sp targeting a node the plan would ghost refuses at plan time (pattern sp fans out on native ranks only and is never mirrored onto a ghost — the ADR 0027 INV-2 measured singular-matrix case; use uncuttable_elements=, ops.fix, or serial emit), and an UNCLAIMED interface whose endpoint is a stage-bound node refuses on both the flat and partitioned paths (the base pass would reference a node that only exists inside the stage block; the fix is s.interface(name=)). Stage-claimed interfaces under MPI stay refused, naming ADR 0093 S9.
  • Tests: tests/opensees/unit/test_interface_partitioned_emit.py (owner resolution, INV-5 violations, tag pre-pass) + tests/opensees/integration/test_interface_partitioned_emit.py (1-vs-N byte identity against the flat deck, the all-shared-cut degenerate fixture, foreign-slave ghosting with ndf parity + SP replay + INV-7, the new refusals, determinism).

ADDED — ADR 0092 S5: the partitioned-contact numeric twin actually runs (serial vs 2-rank MPI, ≤ 2e−14)

tests/opensees/subprocess/test_contact_partitioned_numeric_twin.py — the run lane S4 still owed. One model definition (the fork ADR-78 P0 geometry through the real API: two stacked single-hex blocks, 1e-3 initial penetration, g.constraints.contact(kn="auto", outward=(0,0,1)), partition(2)) emits both twins — serial flat=True and the S4 partitioned deck — and both are EXECUTED: OpenSees.exe vs mpiexec -n 2 OpenSeesMP.exe. Measured serial-vs-2-rank relative deltas: w_top 1.8e−14, w_slave 2.0e−14, w_master 2.2e−16, ΣR_base 1.8e−16 (gate ≤ 1e−10); the owner rank's ghost printed bit-identical to the slave rank's native displacement. Three negative controls prove the comparison detects a wrong deck (no-contact diverges; stripped ghost fix replay → Mumps numerically singular; the ADR-78 P0.d duplicated contact verb → the fork's P1 all-rank teardown). Gated on subprocess + loud env-var skips (APEGMSH_OPENSEES_BIN, Intel MPI) so CI is untouched. Also recorded in ADR 0092: an auto-emit ordering defect surfaced by the harness (auto-emitted constraints LadrunoContact / parallel numberer / system land after a user-declared analysis Static, where the engine ignores the handler — silently wrong under contact; workaround: declare the chain explicitly).

ADDED — section-builder extras: catalog picker, moment–curvature, handoff snippet (ADR 0080 B7 + close-out)

  • moment_curvature(doc, *, axis="z", kappa_max, n_steps=40, axial=0.0, tol=1e-8, max_iter=25) (exported from apeGmsh.sections) — the in-process zeroLengthSection M–κ harness that gates G-D/G-E proved, productized for fiber-lane SectionDocuments. Returns a frozen MomentCurvature (curvature / moment tuples starting at (0, 0), EI0, M_max, axial, complete). axis="z" is DOF 6 (elastic slope = EIxx_c), "y" is DOF 5 (EIyy_c); kappa_max is signed; axial follows the OpenSees convention (compression negative), applied first and held with loadConst. A step that fails to converge ends the curve with complete=False rather than raising — a fully-plastified section IS the end of its curve.
  • The section is lowered through exactly the bridge handoff's construction: SectionDocument.to_section's typed-item building was extracted to _document.py::typed_fiber_items, and both paths now call it, then emit through each primitive's own _emit on a LiveOpsEmitter. An M–κ curve and a deck built from one document therefore integrate the same fibers with the same material tags — nothing is re-derived.
  • Two hard contracts, both documented and both deliberate: the harness wipe()s the process-global OpenSees domain (do not call it with a live analysis open), and it runs synchronously on the calling thread. openseespy is a non-reentrant process-global C++ runtime, so putting it on a worker would abort the interpreter below Python's reach; the ADR 0080 B6 properties worker is threaded only because it drives Gmsh. Documents declaring no GJ get an inert placeholder — OpenSees refuses a 3-D fiber section with no torsion at all, and the harness fixes the torsional DOF at both nodes, so the value provably cannot reach the answer.
  • Gate: the elastic slope equals the exact fiber sum ΣE·A·r² on both axes (a points/layer section where the two differ by 4×, so an axis swap cannot pass both), and a rect patch reproduces its own midpoint-rule discretization (b·h³/12)(1 − 1/ny²) — not the continuum value. Keystone: ElasticPP fibers pushed to 20·κ_y plateau at fy·b·h²/4 in both signs, and a constant axial pre-load reduces that plateau by the closed-form (1 − n²) (tests/sections/test_mc_b7.py, 13 tests).
  • handoff_snippet(doc, *, path=None) — the paste-ready bridge lines the builder's new Copy handoff toolbar action copies. Fiber lane: literal ops.uniaxialMaterial.<Type> + ops.section.Fiber(...) construction with template provenance comments. Continuum lane: a SectionDocument.open(path) that lowers lateComputedSection(analysis=doc.build()), or doc.to_section(ops) when a bars overlay exists (the elastic lowering would silently drop it), so numbers are never hand-copied out of a GUI. _script_export.py's fiber renderers were extracted (fiber_material_lines / fiber_template_comments / fiber_collection_lines) and are now shared, so a snippet and an export cannot disagree. Gate: the snippet exec'd on one bridge emits a deck byte-identical to doc.to_section on another (tests/sections/test_b7_extras.py).
  • apeSteel catalog picker (sections/_catalog.py, fail-soft) — an editable designation box in the builder's shape group that prefills the W_face form from any AISC v16 or EN doubly-symmetric I shape. Millimetres (apeSteel's base), and h is mapped from web_clear_height_hw — the clear web height, not the catalog depth d, which is the one-line error the picker exists to prevent. apeSteel absent → the picker is not built at all and the builder is otherwise unchanged. Label enumeration reads a private apeSteel table and is best-effort by contract: if that shape ever changes, catalog_labels() returns () and the box degrades to free text while resolution keeps working.
  • Parity reaches the picker: prefill-then-add writes the same document as add_shape("W_face", **catalog_shape_params(...)), and a prefill alone mutates nothing (it fills GUI fields, so it stays off the undo stack). M–κ controls are fiber-lane only and grey with install guidance when no backend is importable (tests/sections/test_builder_gui_b7.py).
  • Order of operations: moment_curvature resolves the document before it imports a backend, so a material with no uniaxial spec (or an unknown primitive type) reports its own problem whether or not a solver is installed — those are errors the user fixes in their editor. The backend ImportError comes second.
  • The backend probe does not answer for a package. apeGmsh's own tests/opensees/ directory registers as a top-level opensees module whenever pytest imports in importlib mode — the same impostor emitter/live.py::_resolve_ops already rejects after importing it — so a bare find_spec("opensees") claimed a backend that cannot be imported. A real backend is an extension module, never a package; backend_available() now checks that, which is what keeps the builder's M–κ button honest (and the backend-gated tests skipping) inside any pytest process. Those tests also carry the house live marker, so native OpenSees stays out of the curated suite's shared process even where a backend is installed.
  • Close-out: new how-to page Author a section document, a fourth "Sections you author" section in concepts/sections.md, the API page, internal_docs/guide_sections.md, and skill reference §10 (references/section-properties.md) + cheatsheet entry. ADR 0080 is Accepted — B1–B7 shipped as #840–#847 plus this slice.

FIXED — offscreen render: GL skip, step range, deform field (ADR 0094 S1)

results.render / fem.render no longer treat every exception as [skip viewer] no GL context — only RuntimeError / OSError from plotter creation or screenshot. A failed still writes to a temp name and never unlinks a pre-existing file. Out-of-range step= raises (Python negatives still work). view="deformed" with no recorded displacement raises instead of writing an undeformed PNG. Deform vectors come from a shared vectorized reader in _pump_set. CI suite sets APEGMSH_EXPECT_GL=1 so a silent VTK death fails the live-render test.

ADDED — ADR 0095 (Proposed): apeGmsh.studio agent + script + viewer habitat

Sidecar apeGmsh.studio (same administrative shape as hpc / assess: not a session composite, not re-exported from apeGmsh/__init__.py). Cursor stays the IDE; a Python daemon owns refresh / last-good tessellation / assess / render / a names-first SelectionEnvelope; the existing Qt ViewerWindow is the v1 host. JSON-on-disk is the v0 transport; MCP is a later wrap (the consumer ADR 0094 deferred). Electron is a skin, not the architecture. This PR is the ADR + index only (S0); no library yet.

Amends ADR 0094 only in the disposition of “Later: optional MCP wrapping S5”. Assess, stills, and “agents do not drive Qt for diagnosis” stay in force.

FIXED — MESH.INVERTED hex8 sign + 2D/skip/energy assess gaps (ADR 0094 S2)

The signed hex8 6-tet split copied MassResolver's connectivity, including one negatively oriented tet. MassResolver takes abs() so it never saw it; assess used the signed sum and false-failed valid hexes (MESH.INVERTED is FAIL-reserved). That tet is now (0, 2, 7, 6). 2D tri3/quad4 are judged only on a planar mesh; otherwise they are skip-listed (orientation is not inversion). Unbound Results skip-list MESH.*; no-stage skip-lists RES.NAN. A non-finite last-step energy ERR is a finding. Energy ValueError on stage 0 no longer aborts the rest of the stages.

ADDED — fem.assess() / results.assess() finding compiler (ADR 0094 S2)

Standalone sidecar apeGmsh.assess (hpc-shaped: types + pure runners; not a session composite, not re-exported from apeGmsh/__init__.py) compiles a frozen AssessmentReport from the v1 catalog. Public doors are fem.assess() and results.assess(). Findings only — figures=True is S3 and raises. model_diagonal moved to numpy-only results/_geometry.py and is re-exported from plot._arrows. No viewers/gmsh import (INV-1 AST guard). RES.ZERO_U and CAD.* are not in this slice.

ADDED — python -m apeGmsh doctor environment preflight

A one-shot preflight that answers "is this interpreter set up to run apeGmsh?" before a model does. It prints a short markdown report of coded findings and exits 1 if any is error-severity, 0 otherwise (warnings do not fail):

  • D1 interpreter identity — office venv vs a system/other interpreter, the wrong-interpreter ModuleNotFoundError that reads like a bug;
  • D2 import-path drift — the imported tree vs where the editable install maps it, matched exactly (a containment test would call a tree nested under the repo root healthy). A linked git worktree of that same repo is info, not a warning: working in one is the normal way to run a branch. Verified structurally — the tree's .git must be a worktree pointer into the install root's own .git — so a worktree of a different repo, or a vendored clone, still warns;
  • D3 import gmsh;
  • D4 viewer/GL stack, including that QT_QPA_PLATFORM=offscreen on Windows makes ViewerWindow refuse to start (it cannot host the VTK render window there);
  • D5 Ladruno OpenSees fork importable as the live backend;
  • D6 baseUnits version agreement across the office interpreters — baseUnits is installed NON-editably in each, so they can silently disagree on unit conversion factors. Counterpart interpreters that resolve back to the running one are not counted as agreement.

Findings use a frozen DoctorFinding / DoctorReport pair in the style of apeGmsh.cuts._preflight; reconcile with Finding / AssessmentReport when the ADR 0094 apeGmsh.assess package lands.

No Qt, VTK, or GL import anywhere in the module (D4 inspects find_spec + env vars only), so it runs on GL-less CI. Native-backend probes run in subprocesses, so a stale opensees.pyd that crashes on import cannot take the doctor down with it.

src/apeGmsh/doctor.py also runs standalone<any-python> src/apeGmsh/doctor.py, stdlib-only — so a suspect interpreter that cannot import apeGmsh at all still gets a verdict instead of a bare traceback.

Relatedly, apeGmsh/__init__.py now defaults LADRUNO_OPENSEES_QUIET=1 (os.environ.setdefault, so an explicit value still wins), hoisting to package import what the backend resolver and a dozen scripts already did individually. The fork's banner goes to STDOUT, so it corrupts any machine-read stdout. Note this cannot silence the office venv's startup banner: ladruno_opensees.pth runs the fork's _ladruno_opensees_boot before any module here, and only the launching shell's environment pre-empts that. It does take effect wherever the boot is deferred.

ADDED — fem.render / results.render offscreen stills (ADR 0094 S1)

apeGmsh.viewers.render writes one Qt-look PNG from the viewer scene / diagram pipeline (pv.Plotter(off_screen=True) + build_fem_scene + ResultsDirector + a registered diagram through PyVistaQtBackend). Public doors are fem.render(path) and results.render(path, view=..., component=..., step=-1, deform=None, camera="iso"). view= is closed (mesh / contour / deformed / reactions). Deform goes through director.geometries (ADR 0058); there is no setup(plotter, director), no event loop, and no hidden ResultsViewer. APEGMSH_SKIP_VIEWER=1 or no GL returns None with the [skip viewer] notice and writes nothing. render_pack / assess(figures=True) stay S3.

ADDED — ADR 0094 (Proposed): agent assess/report + offscreen viewer render

Sidecar apeGmsh.assess (fem.assess() / results.assess(), inspect stays inventory) and apeGmsh.viewers.render (Qt-look stills from the scene/diagram pipeline, no event loop). Agents do not drive Qt windows. S0 of that ADR (stale blocking=True skill/docs) is the other commit on this PR. Implementation of S2+ is not in this change.

FIXED — skill/docs: results.viewer() default is auto, not blocking=True (ADR 0094 S0)

Results.viewer(blocking=None) already auto-detects — scripts and the CLI still get the in-process Qt window; a Jupyter kernel takes the subprocess path, or show_web() for in-memory Results. The skill and published docs still taught the old "default blocking=True crashes Jupyter" line. Corrected that claim; the after-solve agent check is now fem.inspect / results.inspect.summary() / components() / diagnose() / results.lineage, not a Qt window. sec.viewer / g.model.viewer / g.mesh.viewer still default to blocking=True and were left alone.

REMOVED — dead-code sweep: superseded twins + false-docstring orphans (~3,600 lines)

A full-library audit (vulture + import-graph, four verification agents) removed code that was not just dead but actively misleading — parallel implementations with passing tests and docstrings claiming callers that do not exist. Gone: AddDiagramDialog (+3 test files; the live path is the in-panel Add Diagram card in _diagram_settings_tab.py), LayoutPersistence (+test; both windows use QSettings directly), the never-instantiated DockRegistry class (the module's live helpers — DockSpec, mount_dock_spec, build_view_menu, … — all stay), opensees/_internal/registry.py (never populated, never read), the self-deprecated Mesh._get_raw_fem_data, _rewrite_for_compose + _previous_reservations (docstring claimed "Phase 3B.2b calls into this" — compose() inlines the logic), the unused to_manifest_h5/from_manifest_h5 pair (superseded by emit_recorders/emit_mpco), and ~25 small orphan symbols (_render_tcl/_render_py, _get_nodes_for_entities, _nodes_near, stko_is_available, mpco_fiber_group_aliases, node_vel/node_accel, viewer one-offs, write-only attributes). No public behavior changes: every deletion was verified to have zero callers across src, tests, examples, scripts, and docs before removal. Deliberately KEPT: the unexercised facade surface (import_stl, save_iges, mesh-algorithm enum members, node_to_surface_spring, …) — that is functionality awaiting coverage, not dead code — plus the top-level sections package (used by examples/moment_curvature_fiber_section.ipynb) and the pending wire-vs-delete decisions (bind_vis_mgr, silent _styles.py knobs, picking() vs ADR 0047, results/schema/_native.py constants).

ADDED — skill-docs drift lane: quoted signatures checked against the live code

tests/test_skill_docs_drift.py machine-checks the agent skill docs (skills/apegmsh/SKILL.md + references/*.md) against inspect.signature of the objects they document, and joins the lock-tests CI job next to the existing skill-mirror check. Two guards, both in the source-scanning style of test_viewers_pure_h5_consumer.py / tests/viewers/test_viewer_state_contract.py: D-SIG parses every def name(self|cls, …) quoted in a python fence and compares parameter names, order, kinds, and defaults against the live signature (a trailing ... marks a deliberately abbreviated quote, checked as a subset); D-DEFAULT compares every "default … blocking=X" claim, prose or fence, against the live default. Both registries ratchet two ways — an unregistered quoted signature fails asking to be registered, and a registry entry no longer quoted anywhere fails asking to be pruned — and five positive controls keep the detectors honest.

This closes the hole a 2026-08-12 review found by hand: seven lines across three skill files still claimed results.viewer() defaults to blocking=True long after src/apeGmsh/results/Results.py moved to blocking=None auto-detect (True in scripts, False in a Jupyter kernel). Those lines are corrected here — the crash warning now attaches to forcing blocking=True in a notebook, which is still true, rather than to the default, which is not. The blocking claim is bound per FILE because the sections inspector (SectionProperties.viewer) has not adopted the auto-detect and still legitimately documents blocking=True.

Scope is deliberately narrow — signature quotes and default-value claims. A general prose-claim checker is out of scope.

ADDED — partitioned contact emit: one owner rank, whole interface ghosted (ADR 0092 S4)

g.constraints.contact / .contact_plane now emit under partitioned (MPI) emit — the blanket "serial-only" refusal is gone, replaced by the locality contract. Each interaction's contactSurface pair + contact / contactPlane verb lands inside exactly ONE rank's block (INV-1, the owner — chosen master-side and element-exact where the mesh's connectivity resolves each master facet to its backing solid; the S1 node tally survives only as the fallback, and its undecidable tie still refuses with a named error rather than guessing). Every interface node the owner does not natively own is ghost-declared first: a node line + the owner's replayed SP stream via the ADR 0027 ghost machinery — geometry and SP state ONLY, never mass / elements / loads (INV-7, pinned by test). The emitted deck reproduces the shape the fork's ADR-78 P0 harness measured at 1.4e−14 vs its serial twin, and -kn auto emits byte-identically to the serial deck (INV-3's preserved half, now pinned through the real per-rank fan-out). New named refusals (INV-5): a master surface the partitioner cut while an auto-sizing knob is active (kn/eps_n/eps_t/edge_kn = "auto" — off-rank backing silently skips the auto penalty, fork ADR-78 D5.2; explicit penalties still emit), and staged partitioned contact (the staged pipeline skips the analysis-chain auto-emit, so LadrunoContact would never be forced — deferred, recorded in the ADR log). The S3 soft= refusal is unchanged. No ghost-cost bound exists — the adversarial review withdrew the budget (ADR 0092 §Sign-off Q2). emit_contacts / emit_contact_planes grew a records= override; resolve_contact_ownership a master_element_ranks= input + the new master_backing_element_ids facet→backing-solid helper.

FIXED — main red since 2026-08-11: h5 interface guard broke 70 duck-typed writers; the S2 weighted-cache test never ran where it was written (3 root causes: 71 tests + the mypy ratchet)

Two independent breakages merged through a red gate and stacked (#917 broke 1 test, #920/#921 added 70 more; #922/#923 inherited both):

  • ADR 0093 S3's h5 refuse-guard assumed a full ElementComposite. _refuse_unpersistable read fem.elements.interfaces by attribute access, but the neutral-zone writers are a duck-typed contract — the domain-capture and parallel-modal to_native writers (and the test mocks) pass elements as a plain list, which took 70 tests down with AttributeError: 'list' object has no attribute 'interfaces'. Now getattr(..., None) like the sibling compose guard from the same PR already did: an object without the attribute structurally cannot carry an InterfaceRecord, so there is nothing to drop and nothing to refuse.
  • ADR 0092 S2's test_weighted_cache_survives_the_override passed a dim-3-only tag-keyed dict as weights=, but the API takes a sequence aligned with the stable all-dims element order (its sibling test in the same file does it right). pymetis is not installable on Windows, so the importorskip meant the test never executed where it was written and failed its first real run on CI ("weights length mismatch: expected 2465 ..., got 1433"). Rebuilt the weights the way the sibling does; the length now matches the validator's expectation (probed: 2465 both sides on the same model).

  • ADR 0093 S5's material-translation helpers were typed -> object, so ._emit(...) on their results grew the mypy ratchet 0 → 2 and turned static-gates red on #923's push. Annotated -> "UniaxialMaterial" (the base that declares _emit); ruff and mypy both clean at the CI pins (1.20.0 / 0.15.9).

Process note recorded with the fix: #917/#920/#921 merged with the suite job red, which is how one broken test became five inherited-red merges — hold the gate.

ADDED — partitioned contact refuses soft= by name; -kn auto pinned partition-stable (ADR 0092 S3)

A contact interaction carrying a SOFT-family knob (soft= on g.constraints.contact / contact_plane, edge_soft= on the mortar edge-edge fallback) now fails partitioned (MPI) emit with a named BridgeError that says why: the explicit SOFT penalty sizes k_soft = SOFSCL·4·m_eff/dt² from the ASSEMBLED mass of BOTH surfaces, and the ghosted side of the interface contributes zero assembled mass on the owner rank — no owner rule can fix that (fork ADR-78 D4; the fork engine likewise refuses -soft/-edgeSoft under MPI at handle() time, fork ADR-78 P2 — this refusal fires at deck-generation time instead of at job launch). The check sits in BuiltModel._emit_partitioned ahead of the blanket serial-only contact refusal (which S4 relaxes; this refusal stays), backed by the pure predicate soft_family_knobs alongside the S1 ownership resolver. visc= is not SOFT-family and is not refused; serial emit is untouched. INV-3's preserved half is pinned too: partitioning never rewrites kn="auto" into a literal — a partition-carrying model's contact verb line is byte-identical to its serial twin's, ending on the literal auto token.

ADDED — ElasticPP / ElasticPPGap uniaxial primitives (ADR 0093 S2)

Two typed uniaxialMaterial primitives land in opensees/material/uniaxial.py, plus ops.uniaxialMaterial.ElasticPP / .ElasticPPGap namespace methods: ElasticPP (elastic-perfectly-plastic; epsyP is a yield strain, not a force — the fork derives fyp = E * epsyP internally) and ElasticPPGap (elastic-perfectly-plastic gap, with eta hardening and a damage flag). ElasticPPGap fails loud on sign(Fy) != sign(gap) (gap == 0 exempt) — the fork itself only warns and then silently follows sign(Fy) alone, which is exactly the silent-sign-error class ADR 0093's interface verb exists to avoid. Independent primitives slice ahead of the g.constraints.interface() verb itself.

ADDED — Bernstein-aware consistent load reduction for Ladruno Bézier elements (basis=, ADR 0091)

The field-load verbs (g.loads.line, g.loads.surface.pressure / traction / shear) grow a basis="lagrange"|"bernstein" knob that selects the shape functions reduction="consistent" integrates the field against. The Ladruno fork's BezierTet10 / BezierTri6 DOFs are Bernstein control values, not nodal values: the default Lagrange-consistent vector (tri6 corners ~0, midsides q·A/3) applied to control values represents a strongly oscillatory traction — exact resultant, local spikes — which drove near-surface DruckerPrager Gauss points into apex/tension and diverged a 3775-element strip-footing deck at first yield (TIMs T2, 2026-08-10). basis="bernstein" integrates f_a = ∫ t·B_a dΓ in the same Gauss loop (uniform q → equal q·A/6 face / q·L/3 edge control-point loads; node order verified against the fork's BezierTet10.cpp / BezierTri6.cpp). Fail-loud everywhere it would be a silent no-op: tributary/element-form combos and quad faces raise; the default path is bit-identical. Gravity/volume loads need no knob — their equal split already is the Bernstein-consistent vector for a constant body force (documented). Live gate: a BezierTet10 uniform-compression patch test is exact with the Bernstein vector and visibly oscillates with the Lagrange one (tests/opensees/integration_ladruno/test_bezier_consistent_loads_live.py).

Also FIXED in the same audit: LoadResolver.element_volume fell back to the bounding-box volume for 10/20/27-node solids — gravity / volume loads on quadratic meshes (tet10/hex20, Bézier included) overshot by ~6x on tets. It now mirrors MassResolver's isoparametric ∫|J|dξ path.

Verified against the TIMs acceptance gates on their reference mesh (3775 BezierTet10) before merge: the A/6 weight rule holds to 14 ULPs (46 over the graded reference faces — exact in exact arithmetic, but a Dunavant sum of decimal rule constants is not bit-identical, so the tests assert a ULP-scale bound rather than ==); the resultant is preserved bit-identically between bases (30.000000000000); and the discriminator sweep passes — BezierTet10 std/-bbar × σ_y 5.0/0.2 all converge the full surcharge in ONE step at sum Rz = 300.0000, where the same four legs under the Lagrange vector all diverge. As an independent cross-check this branch's Lagrange weights reproduce the bundle's own trib array to 1.5e-15 on the same faces. The sweep ships as tests/opensees/integration_ladruno/test_tims_strip_footing_gate.py (skips without the bundle; divergence controls marked slow).

Second slice (same ADR): the bridge-side mismatch guard. Consistent reductions now stamp their basis on each NodalLoadRecord.basis (None for basis-insensitive paths; persisted as an additive column, neutral schema 2.28.0, presence-probed so pre-2.28 files decode None), and at build() validate_load_basis_vs_elements warns (WarnLoadBasisMismatch, fail-soft) when an imported case's Lagrange-tagged records land on nodes owned exclusively by Bézier control-value elements (BezierTet10 / BezierTri6 / LadrunoUP on tri6/tet10 Taylor–Hood meshes) — or Bernstein-tagged records on exclusively nodal-value elements. Interface nodes shared by both families are exempt (exclusive-ownership rule), as are basis-less records and cases no pattern imports. This closes the "knob forgotten" gap the authoring-side validation could not see.

FIXED — three Ladruno-fork follow-ups: tet10 eleResponse, silent empty element reads, staged soil materials

Three loose ends from the fork's TIMs-campaign defect report land here.

1. TenNodeTetrahedron eleResponse is fixed engine-side. The response catalog carried a standing NOTE that its 24-value tet10 layout could not be trusted through ops.eleResponse — the engine declared static Vector stresses(6) and wrote 24 floats into it (heap corruption; only 6 values came back). Ladruno builds from 2026-08 (commit 732ab316d) size it 6 * NumGaussPoints, so the catalog layout and the live query finally agree. The note is now version-conditioned rather than open-ended: on older engines the DomainCapture and .out-transcoder paths still see 6 values, and the capture path's size-mismatch error names that engine fix when the shape it got is exactly one Gauss point's worth of a multi-GP element. No workaround was ever coded around the bug, so nothing had to be unwound.

2. A requested element result family that the file does not carry is now loud. A recorder -E token that matches no element makes the engine write a file with no RESULTS/ON_ELEMENTS group at all, and the reader answered every element / gauss / line-station / fiber read with an empty container — a lost field capture looked exactly like a converged run with nothing to plot. LadrunoReader now raises MissingElementResults naming the family, the component, the file and the likely cause (engine builds from 2026-08 also warn at record time). Scope is deliberately narrow: only a missing group is loud. A file that has element results but not the requested component (stress_zz on a 2-D model, basicForce on a quad) keeps its empty slab, and available_components stays quiet so probing still works. Across partitions the read is loud only when every rank is missing the group — a rank owning none of the recorded elements legitimately writes none.

3. Staged soil materials start ELASTIC on new fork engines. Ladruno engines from 2026-08 flip ManzariDafalias's default material stage from elastoplastic to elastic (the static mElastFlag = 1 is gone; constructors set 0), matching the community staged-gravity idiom. apeGmsh does not wrap these soil models — they are declared through raw ops calls — so nothing in the bridge changes, but decks must now call updateMaterialStage explicitly to enter the plastic stage: elastic gravity → ops.updateMaterialStage(material=<tag>, stage=1) → push. A deck that never called it used to get plasticity by accident and will now stay elastic for the whole run. Documented in internal_docs/guide_opensees.md §2.1; a typed s.update_material_stage(...) between-stage mutator is proposed (not implemented) in opensees/architecture/_DEFERRED.md.

ADDED — the live backend reports WHICH engine build it resolved

get_backend_name() has always answered fork-or-stock, never which fork build — and apeGmsh suppresses the fork's splash banner (LADRUNO_OPENSEES_QUIET=1, set by the resolver), so the one string that identified a build was unavailable to a live run by construction. Results could therefore be attributed to a binary nobody had verified; that is exactly how the fork's TIMs T1 incident ran a probe against the wrong engine.

New apeGmsh.opensees.emitter.live.get_backend_build() returns the 40-char git hash the resolved engine was compiled from, forwarding the fork's ladrunoBuild command (fork PR #718), and ops.capabilities().build carries the same stamp. Both return None on stock openseespy or a fork build predating that command, so the addition is inert off the fork.

Use it in any harness whose results are attributed to a specific engine — compare against the expected hash and fail loudly rather than measure a mystery build. It doubles as a stale-opensees.pyd detector: rebuild, and an unchanged hash means the build did not take.

OpenSeesCapabilities grows a defaulted build field (existing constructors unaffected).

FIXED — mesh-viewer explode works on 1D and 2D meshes, not just solids

Explode was written against dim=3: it read its geometry from vol_grids[3], its display style from the dim=3 actor, and returned early when there was no solid mesh. A beam-only or shell-only model — or the 1D/2D half of a mixed model — therefore exploded into nothing.

Grouping and rendering now run across every displayed element dimension, sourcing dim 1/2 from EntityRegistry._full_meshes (falling back to dim_meshes) and dim 3 from vol_grids as before. Each dimension keeps its own render style, while a category shared by several dimensions gets one offset so the pieces of a mixed model stay together as they move. _build_surf_elem_colors remains as a dim=3 wrapper over the new per-dimension lookup, so existing callers are untouched.

Regression coverage for pure-1D, pure-2D and mixed-dimensional scenes, visibility, reset behaviour, and partition grouping — the explode controller's test file goes from 22 tests to 35.

Contributed by @ppalacios92.

FIXED — maintainer source maps pointed at src/apeGmsh/solvers/, a package that no longer exists

The constraint / load / mass machinery moved under src/apeGmsh/_kernel/ in an earlier refactor, but the "For maintainers — source map" blocks in the guides and architecture notes were never updated. The whole solvers/ package is gone, so every path in those blocks was dead — a maintainer following one landed nowhere.

Repointed, each verified to exist:

was now
solvers/_constraint_defs.py _kernel/defs/constraints.py
solvers/_constraint_records.py _kernel/records/_constraints.py
solvers/_constraint_resolver.py _kernel/resolvers/_constraint_resolver/_resolver.py
solvers/_constraint_geom.py _kernel/resolvers/_constraint_resolver/_geom.py
solvers/_kinds.py _kernel/records/_kinds.py
solvers/_opensees_constraints.py opensees/_internal/build.py
solvers/Loads.py _kernel/defs/loads.py + _kernel/records/_loads.py + _kernel/resolvers/_load_resolver.py
solvers/Masses.py _kernel/defs/masses.py + _kernel/records/_masses.py + _kernel/resolvers/_mass_resolver.py
mesh/_record_set.py _kernel/record_sets.py

Touches guide_constraints.md, guide_loads.md, guide_masses.md, architecture/apeGmsh_constraints.md and architecture/apeGmsh_loads.md. The solvers/Constraints.py / Loads.py / Masses.py re-export shim lines are dropped rather than repointed — no such shim exists any more. The constraint map also gains _kernel/resolvers/_mortar.py, which postdates the original map.

FIXED — the mortar tie refuses curved edges and overlapping masters; cross-checked against the fork kernel

The Ladruno fork wrote down the contract its LadrunoMortarKernel holds the apeGmsh numpy port to (fork ADR-78's companion, R1–R8). Running it end to end closed ADR 0086 D2 and turned up two silent defects.

The cross-check passes. On the agreed patch — a 2×2 quad8 slave (21 nodes) on a 3×3 quad4 master (16 nodes) over [0,1]² — the kernel reproduces the fork oracle's ‖P_dual‖_F = 3.927978688773 to 5e-13, and every weight of the reference corner row to the last of its 9 quoted digits. Pinned as a regression test, so the two kernels stay tied together.

Two refusals were missing, and both failed quietly.

  • Curved edges. All the geometry runs on the corner polygon, which is exact only because the serendipity map collapses to the corner map when every midside sits at its edge midpoint. A quad8 midside slid 10 % along its own edge was accepted with a 0.30 linear-patch error and every other guard clean. Now a hard error naming the facet and the edge.
  • Overlapping master facets. The coverage guard sums pair-clip areas, so it counts multiplicity: two coincident masters over half a slave facet and nothing over the other half read as 100 % covered. The uncovered half was then silently extrapolated rather than left free — Σw = 1 held on every row and the linear patch passed at 1.6e-14, because a linear field extrapolates exactly. Nothing downstream could see it, so it is refused before assembly.

Tests gained a linear-patch assertion through each master type (quad4, quad8 and now tri6, which had none) plus a live mutation test that corrupts the tri6 basis and asserts the patch assertion catches it. A permuted tri6 midside ordering — the case the contract says partition-of-unity provably cannot catch — now falls to the curved-edge guard structurally.

Three contract items are met differently on purpose, recorded in the kernel docstring and ADR 0086: this kernel's Duffy 5×5 quadrature is exact wherever the fork's 12-point Dunavant rule is (hence the match), but the two part company at 8e-8 on skewed facets where neither is exact; the conforming-gap L1 measure is subsumed by the stricter flat + coincident v1 scope; and the export-side LadrunoBrick20(lumped=True) requirement for mortar-tied hex20 under LadrunoProjection is exposed on the element but not yet verified as a combination.

FIXED — guide_constraints.md said mortar was unimplemented; it has shipped twice since

The maintainer guide carried two generations of drift. Its Level 4 entry still read "mortarnot implemented; raises NotImplementedError", which was written in June 2026 and was true then, but has been overtaken twice: ADR 0073 turned g.constraints.mortar() into a deprecated alias for the fork's ALM-penalty contact-tie, and ADR 0086 shipped the actual integral mortar as tie(method="mortar") — which the guide never mentioned at all. A maintainer reading it would have concluded the feature did not exist.

  • Level 3 gains method="mortar": what it buys (neither side's interpolation order imposed on the other; master/slave symmetric), where it is computed, that it rides the existing enforce="equation" emission with no new records or emitter verbs, and the four fail-loud contract terms — flat/coincident/convex facets with straight edges, no master facet overlapping another, tri6 slaves refused, translations-only dofs.
  • Level 4's mortar entry now states what the alias actually is and why a contact-tie is the wrong shape for a permanent bond (penalty-enforced so the gap never reaches zero; mandates the LadrunoContact handler, which cannot coexist with an enforce="equation" tie; linear facets only), and points at tie(method="mortar").
  • The broker table no longer lists mortarSurfaceCouplingRecord. That record comes only from resolve_tied_contact; the alias resolves to a ContactRecord on fem.elements.contacts.
  • The guideline "prefer tied_contact over mortar — mortar is more accurate but harder to debug" is replaced by the actual decision rule (reach for mortar on an element-order mismatch).

FIXED — CI: the curated suite ran real-window tests, hung for 6 h, and reported nothing

The suite job stopped completing once the viewer design pass landed its first blocking window test. Nothing is wrong with that test — the same file passes in 5.65 s in the qt lane, which gives it an X server.

The cause was a silent marker override. pyproject.toml sets addopts = [… "-m", "not qt" …], but -m is single-valued, so a CI lane passing its own marker expression replaces that default instead of anding with it, and not qt disappears without a word. The curated lane had been running real-window tests all along; ADR 0089 merely added the first one that blocks rather than degrading — QTimer.singleShot(…, close) then show(), which never returns without a window server (the workflow-wide QT_QPA_PLATFORM=offscreen does not cover a show()/close() round trip).

A stale comment beside addopts is why it stayed invisible: it said CI lacks pytest-qt "so these skip there regardless". They gate on qtpy / pyvistaqt, which the suite lane installs through the viewer extra, so they ran.

  • The suite lane repeats and not qt in its own -m, with the reason written down. No coverage moves: all 26 qt-marked tests live in the 13 files the qt lane discovers by grep, none orphaned.
  • The suite job gains timeout-minutes: 25. An uncapped job drifts toward the 6 h limit, and GitHub serves no logs until a job ends, so the hang was unreadable exactly when you needed to read it. The qt lane already capped each file at 300 s for this reason.
  • The addopts comment now states the override trap and drops the wrong reassurance.

FIXED — the opensees mypy ratchet is back at zero

static-gates had been failing on main, not only on PRs: the ratchet has a baseline of 0 but was measuring 16 errors across 2 files. All of them trace to one omission — the stiffness_resolver= parameter added for stiffness="auto" tie records was threaded through eight functions and a factory without ever being annotated, which also made every call site an untyped call. Adds the StiffnessResolver alias the factory already returned and every consumer already assumed, plus the two Any leaks inside the factory. Annotations only; no behaviour change.

CHANGED — the results viewer got a design pass: three-dock window, one Inspector, drawn icons, designed viewport

A four-phase polish governed by four new ADRs (0087 visual design system + style guards, 0088 window architecture, 0089 viewport model presentation, 0090 scalar-bar design — the last still Proposed / unimplemented). Everything below ships in this entry:

  • Three-dock window. The results viewer boots with Outline, ONE selection-driven Inspector, and a full-width Time scrubber (viewport ≥50 %). The five rotated side tabs are gone — clicking a layer / geometry / stage / plot in the outline switches the Inspector's context; the Color Mapping dock dissolved into the diagram context. Plots auto-appears on the first plot; Output is a full-width console below the scrubber summoned from its status badge; Display and Section planes live in the View menu. Saved layouts from the old dock set are discarded once (schema 7).
  • De-noised chrome. Duplicate inner panel titles removed everywhere; honest empty states (one muted hint, no live-looking 0.000000 fields, no API signatures as furniture); the Output console is themed (it was a white box on dark themes); "Session" dock is now "Display"; "Tied to" is "Field"; the scrubber reads "Step 14 / 14"; menus are File / View / Help with Camera, Orbit axis and Theme submenus and visible shortcuts.
  • One icon language. A 40-glyph self-rendered icon factory replaces every letter button (T/Bo/F/Bk/L/R), emoji and unicode glyph across the three viewers — camera-preset cubes, transport, probe modes, clip controls — all palette-tinted, re-tinting live on theme switch.
  • A designed viewport. Edge hierarchy (2.5 px feature outline + 1 px interior mesh edges), an ambient lift that makes side faces legible (they rendered darker than the edges drawn on them), node cloud OFF by default in the results viewer with one-click toolbar toggles for nodes and mesh edges, per-theme substrate colors, edges demoted to 40 % while a contour is active so the field reads first, and persisted user defaults for outline / mesh / node widths in the Display panel (with reset-to-factory). ContourStyle.cmap no longer defaults to jet. The layer-card double-painted title is fixed. The mesh viewer keeps nodes on; note its 1-D beam tubes thin on fresh installs (shared mesh_line_width default 3.0 → 1.0).
  • Enforcement. test_viewer_style_contract.py guards the design system the way the 0056 guards protect the event contract: no color literals outside theme.py, no ALL-CAPS labels, no dangling QSS selectors, inline-style budget — all two-way ratcheted.

CHANGED — viewer rotation is a real turntable: world-Z yaw, level horizon, clamped pitch, selectable axis

Orbiting the model always felt slightly wrong: yaw spun about the camera's own tilted up-vector (so a pitched view precessed the model instead of spinning it about its vertical), long drags drifted the horizon, pitching past the pole flipped the model and reversed yaw, and the everyday Shift+LMB gesture could only yaw. Rotation now behaves like every structural tool:

  • Shift+LMB drag is the full turntable orbit (yaw + pitch; Shift+MMB remains as a legacy chord). Yaw spins about world +Z no matter how the camera is pitched; the horizon stays level by construction; pitch clamps at ±89° so the model can never flip.
  • The orbit pivots on what you're looking at (the camera focal point — zoom anchors it, pan carries it) instead of a scene centre cached at the first gesture.
  • View → Orbit axis picks the spin axis — Z (default) / X / Y for models whose natural spin is horizontal (a bridge deck about its longitudinal axis); arbitrary axes via NavigationHandle.set_spin_axis.
  • A deform-grown scene no longer risks being near/far-clipped against stale cached bounds while orbiting.

ADDED — the viewer explains itself: first-run starter card + "all elements hidden" feedback + honest outline eyes

Two calm-UX features for the results viewer (R1.3 + R1.4), plus one occlusion bug fix.

First-run empty state. Opening a results file with no saved session used to land on a bare grey mesh. The viewer now floats a small card in the viewport ("No diagrams yet") with one-click starter actions: Add displacement contour (falls back to the first recorded nodal component; hidden when nothing is contourable) and Enable deform ×1 (shown when a displacement field exists). The card disappears the moment any diagram is added — by the buttons, the settings tab, or a session restore — or via its dismiss button. The dead "Probes" placeholder group is gone from the outline tree (Plots stays — it is live).

Visibility feedback. Every hide path (threshold empty range, scope box outside the mesh, dim filter, stage mask, manual hide) funnels through per-geometry ElementVisibility; when the combined layers black out an entire geometry the status bar now says so once — Geometry 'Name': all elements hidden (threshold, scope) — naming the culprit layer(s), and re-arms when the geometry recovers. In the outline, a layer that is switched on but not actually on screen (composition gate, hidden geometry) paints a third, dimmed eye state with an explanatory tooltip instead of a lying filled dot.

FIXED — stale occlusion kept stripping the substrate fill. occludes_substrate consumed intent (is_visible) instead of the effective channel, so a contour parked in an inactive composition still dropped the mesh fill while drawing nothing over it (wireframe-only viewport). Occlusion now requires is_effectively_visible (ADR 0056 INV-2).

CHANGED — Results.viewer() no longer freezes a notebook kernel + FIXED — gizmo teardown on viewer close

results.viewer()'s blocking parameter now defaults to None (auto) instead of True. Outside a notebook nothing changes: scripts and the CLI still get the in-process, blocking Qt window. Inside a Jupyter / IPython ZMQ kernel — where the blocking Qt event loop froze (and often killed) the kernel — the auto default now announces itself with one line and takes a kernel-safe path instead:

  • On-disk Results (from_native / from_mpco): spawns the viewer as a separate process (python -m apeGmsh.viewers <path>), exactly as blocking=False always did.
  • In-memory Results (no path to hand a subprocess): falls back to show_web(), the view-only web viewer.
  • blocking=True / blocking=False keep their exact old meanings — pass blocking=True in a notebook to force the Qt window anyway.

Separately, closing the results viewer now tears the clip / scope gizmo interactors down (uninstall()) and drops the gizmo overlay actors (clear()). Before, the VTK observers and the cursor-arbiter memo outlived the window: an interactor that died mid-hover left a SIZEALL cursor request standing that nothing could ever retract (the scope gizmo's review finding F10 — now also fixed in ClipGizmoInteractor.uninstall(), which previously skipped the cursor withdrawal).

CHANGED — tie/tied_contact/embedded default to stiffness="auto", resolved at emit from the host material (neutral schema 2.27.0)

The silent-failures program, slice B — the real fix behind the slice-3 warning. The penalty default is now the "auto" sentinel: at emit the bridge computes K = α·E_host·L_char (α = 1e3; E_host the largest E among the declared element specs whose PG touches the record's master nodes; L_char the master-node span — the host face/sub-tet size). That lands K a few orders above the host element stiffness, which is all the ASD penalty needs — on the two-block closed-form column the auto route converges to the same K as enforce="equation" and calibrated 1e10–1e12 penalties, where the old 1e18 C++-parity default stalled Newton outright. An auto tie whose master nodes touch no E-carrying material fails loud at emit (BridgeError) instead of guessing; explicit numeric stiffness is untouched, and a numeric 1e18 (old h5 files, explicit passes) still warns.

Plumbing: TieDef/TiedContactDef/EmbeddedDef accept and default to "auto" (validated by the existing auto-or-positive check); InterpolationRecord.stiffness may carry the sentinel (its dataclass default stays 1e18 for old-file decode parity); neutral schema 2.27.0 adds the presence-probed stiffness_auto column and its sr_stiffness_auto surface-coupling mirror; the resolver threads through the flat, staged, and partitioned MP-constraint emit passes and initialises its node→E map lazily (zero cost when no auto records exist).

CHANGED — the tie/embedded penalty default K=1e18 warns at emit; stiffness documented as unit-dependent; Lagrange+NormDispIncr warns

The ASDEmbeddedNodeElement C++-parity default stiffness=1e18 (ADR 0035) is unit-blind: measured on a two-block series column with an exact closed form in N/mm/MPa (E ≈ 2e5), enforce="equation" and penalties of 1e10–1e12 all converge to the same stiffness while the 1e18 default stalls Newton outright (NormDispIncr stuck around 5e-4, Norm deltaR in the hundreds) — a deck that cannot converge in the most common steel unit system, shipped as the default. Until the emit-time stiffness="auto" slice lands, the untouched default now emits a one-time UserWarning on every penalty-route tie/tied_contact/embedded emit, and stiffness — previously absent from all three Parameters blocks — is documented on tie(), tied_contact() and embedded() with the calibration guidance the prose docs already carried (K a few orders above the host element stiffness suffices; K → ∞ is not the goal).

Companion warning: a declared or auto-emitted Lagrange handler combined with an absolute NormDispIncr test now warns — the multiplier DOFs enter the displacement-increment norm with force-like magnitudes, so a tight absolute tolerance can be unreachable on an exactly converged solve (the "fourth defect" of the silent-constraint-failures scoping). Prefer NormUnbalance or a relative test under Lagrange.

FIXED — chain-phase (from_h5/compose) sessions fail loud instead of silently dropping constraints, loads, masses and displacements

The silent-failures program, slice 2. In a from_h5/compose session there is no gmsh and never a re-extraction, so any def the chain-phase router could not apply was stored and silently never applied — the model solved without the weld/load/mass and reported plausible numbers. Every such path is now loud, gated on _fem_from_h5 so LIVE sessions (where the next get_fem_data() re-extraction legitimately resolves deferred names, failing loud there) keep the lenient fall-through:

  • a misspelled bc() / point-load / point-mass target raises the router's KeyError instead of swallowing it;
  • def kinds the router does not cover (g.displacements.surface, g.loads.gravity/surface/line, distributed masses, …) raise ChainPhaseError naming the def type instead of becoming a permanent no-op;
  • the gmsh-resolving verbs g.constraints.contact / contact_plane, g.embed, g.reinforce and g.decouple_node — which bypass the router entirely and can only resolve at a live extraction — raise ChainPhaseError at the verb (raise_if_from_h5_session);
  • a tie / tied_contact whose slave nodes ALL fail the projection tolerance raises ValueError from the shared ConstraintResolver.resolve_tie choke point (build phase and chain phase, both def kinds); a partial projection warns with counts; an interface with zero master faces raises with a dedicated message;
  • the router's blanket except TypeError no longer masks real bugs: only the documented unsupported-target fall-through (_UnroutableTarget) is caught, in both live and chain sessions.

FIXED — displacement case names survive model.h5; from_model fails loud on a zero-match import (neutral schema 2.26.1)

Two halves of the same silent failure. First, the neutral-zone SP writer ignored SPRecord.pattern and flattened every record into /loads/sp/default, so a g.displacements.case("push_gap") came back from any save/reload as pattern='default' — and the assembly's p.from_model("push_gap") imported nothing, silently, leaving the deck with no displacement at all. _write_sp_loads now writes one /loads/sp/{case} dataset per case (the layout the module docstring and the schema-parity whitelist always claimed); the reader has decoded group keys as pattern names since the group existed, so both directions read both layouts and the bump is patch-only (2.26.1). SPSet gains patterns() / by_pattern() mirroring NodalLoadSet. Files written before the fix carry every SP record under default — re-save from the source session to recover the bindings.

Second, the deck side now refuses to import nothing: validate_from_model_cases (wired in BuiltModel.emit) raises BridgeError when a from_model(case) matches zero importable records (no nodal load, no prescribed SP anywhere in the broker), amending the ADR 0051 "silent no-op" clause. A case carrying only homogeneous (hold) records gets a dedicated message (those are model-level by design); a broker with prescribed SPs stranded under default gets the stale-file hint. The check is global (whole-broker), so per-rank-empty partitioned brackets stay legitimate; from_model(case, allow_empty=True) is the per-import escape hatch. interop.etabs_import now registers only load patterns that actually produced defs (an all-zero-magnitude ETABS placeholder pattern no longer registers an unimportable name).

FIXED — viewers: paired mpco/ladruno opens build the scene in fem_eid space (ADR 0043 slice 1.3 viewer side)

The reader-side translator fix (2d159362) made element-level reads on a paired .mpco/.ladruno open return element_index in fem_eid space — but the viewer's scene was still built from the reader's ops-tag-keyed embedded snapshot, because resolve_orientation_source probed only results._path (the results file, which has no /opensees/ zone) and fell back to ViewerData.from_fem(results.fem). The scene's element_id_to_cell join table and the translated reads then spoke different id spaces: unfiltered contours / GP markers / thresholds silently blanked out or (where the ranges overlap) coloured the wrong cells, while selector-filtered reads passed through untranslated — one session mixing both id spaces. Affected every no-fem= entry point: the Qt File→Open dialog, python -m apeGmsh.viewers, the results.viewer(blocking=False) subprocess, and show_web.

  • resolve_orientation_source now prefers results._model_path exactly when element reads actually come back fem_eid-keyed — the new Results._element_reads_fem_keyed(): the attached pairing covers every element id the file records (probed against the reader's embedded snapshot, whose ids ARE the file's ops tags; cached at first ask). Mere translator attachment is not enough — translation is all-or-nothing per read, so a deliberately-unrelated stub model_h5= (the sanctioned test-suite pattern) attaches a pairing that never fires; an attachment-based gate would have rendered the stub's mesh against the real file's untranslated ops-space reads (caught by adversarial review, pinned by test_stub_paired_open_keeps_the_embedded_snapshot_scene). Deliberately NOT gated on has_opensees_orientation: a solid-only model records element_meta but no transforms group, and every consumer degrades gracefully without it (the paired branch only requires the file to still open as HDF5, guarding on-disk corruption after open). Bonus: beam vecxz orientation and section-cut tag mapping (director.tag_map, add_section_cut) now work for mpco/ladruno opens instead of returning None/raising.
  • WarnElementTagPairingMissing now also fires for an attached but non-covering pairing when the fem is externally bound (from_mpco(real.mpco, fem=external, model_h5=unrelated_stub)): Results._element_tag_pairing_missing judges by _element_reads_fem_keyed() instead of mere translator attachment, whose "pairing attached — reads are translated" premise the all-or-nothing contract falsifies. No existing test warns anew (verified with -W error::UserWarning across the stub-paired suites); regression pair in TestAttachedButNonCoveringPairingWarns (mutation-checked: the attachment-based gate fails it).
  • LogRouter gains a fourth capture channel — warnings.showwarning — so WarnElementTagPairingMissing ("your element colours may be wrong") surfaces in the viewer's Output dock instead of a stderr stream nobody watches (for blocking=False, the parent terminal). The uninstall restores the previous hook only when the global is still the router's own shim — with two viewers open, a blind restore would kill the newer viewer's capture on the older one's close, and the last close would reinstate a dead shim that silently discarded every subsequent warning in the process (adversarial-review finding, verified by execution; pinned by test_two_routers_natural_close_order_keeps_capture_and_stderr).
  • Regression tests: tests/viewers/test_viewer_scene_id_space.py (paired offset open → unfiltered gauss element_index ⊆ scene ids; both tests fail on the pre-fix probe), probe-gate units in test_viewer_orientation_from_model_h5.py, warnings-channel units in test_log_router.py. The coverage-based gate keeps the stub-paired AddDiagramDialog fixtures resolving to no model-h5 source, so those tests are untouched.

FIXED — staged × partitioned × MP constraints: a dropped constraint, and ghost BCs that never tracked their owner (ADR 0034, ADR 0027 INV-2)

Two defects in the same intersection, the second only reachable once the first was fixed. That intersection had zero test coverage until 2026-07-27: fourteen tests combined partitioned + constraints, three combined staged + constraints, and exactly one combined all three — which is why three defects surfaced there in two days. Systematic, not bad luck.

1. A stage-claimed MP constraint was silently dropped. The per-rank content gate in _emit_stages_partitioned did not count stage.stage_constraint_records, so every rank hit continue before emit_stage_mp_constraints_partitioned ran. A stage whose only content was a claimed constraint emitted nothing — no warning, no error, just a deck missing the tie the user asked for. Measured on make_two_column_frame_partitioned + a cross-rank equal_dof: the tie alone → 0 equalDOF lines; the tie plus an unrelated s.fix on a different PG → 2 (correct). The claim was always fine; purely the emit gate — and being masked by any other stage content is why the one pre-existing test in this intersection could not see it.

The naive repair (or bool(stage.stage_constraint_records)) re-opens the empty if {[getPID] == K} { } bracket that ADR 0034 exists to prevent, because the emit early-returns on a rank the constraint does not touch. "Does this rank touch it" is _plan_rank_constraints's answer, so the plan now runs before partition_open via plan_stage_mp_constraints_partitioned(...) -> StageConstraintRankPlan | None and is handed to the emit inside the bracket — one participation decision per (stage, rank), gate and emit sharing it.

This suppressed all ten claimable constraint kinds equally, so the regression tests span four: equal_dof, embedded (the plan.embedded_records branch, which no partitioned-staged test reached), node_to_surface (phantom identity across ranks, and the phantom-carries-no-fix exclusion, under staging), and tied_contact. Worth recording: tied_contact is not refused under partitioned emit — the fail-loud guard covers g.constraints.contact / contact_plane, the fork's serial-only contactSurface subsystem, which is a different feature. tied_contact lowers to parallel-safe ASDEmbeddedNodeElement rows and emits.

2. A ghost's SP state never tracked its owner across stage boundaries. ADR 0027 INV-2 (amended 2026-07-27) left one residual open — a ghost declared in stage N missing its owner's stage N−1 BCs. Probing it showed the residual was one third of the hole. Three shapes, all measured 2026-07-28:

shape owner does ghost had consequence
backward s.fix in stage N−1, ghost declared stage N global tier + stage N only free massless DOF ⇒ singular
forward fix s.fix in stage N+1 nothing — the owner-side filter is keyed on rank_owned, which a ghost is never in free massless DOF ⇒ singular
forward remove_sp s.remove_sp in stage N+1 still carries the earlier fix ghost more constrained than its owner — not singular, silently stiffer

The third is the dangerous one — the first two abort, it returns a wrong answer — and it rules out reducing the history to a net fixity vector, since fix is additive per flagged DOF and never releases. So a ghost now replays its owner's ordered SP command stream: sp_ops_so_far ({node: [("fix", flags) | ("remove", dof), …]}, seeded from the global ops.fix tier, extended per stage in the owner's own emit order) is replayed at declaration, and ghosts_held ({rank: {tags}}, seeded from what the global constraint pass declared) drives a per-stage mirror afterwards. The two halves are disjoint by construction — ghosts_held is read before the stage's own declarations fold in, so a ghost declared in stage N takes the replay and not also the mirror (a duplicate fix only warns in OpenSees and would have shipped easily; a duplicate remove sp releases a constraint that no longer exists).

Confirmed under mpiexec -n 2 against the fork OpenSeesMP (stage 1 s.fix(pg="Base"), stage 2 cross-rank tie + tip load): before, two MumpsParallelSolver … Error -10 … Matrix is Singular Numerically and two analyze failed, returned: -3, deck aborted mid-stage-2 with no artifacts; after, zero singular lines, both ranks harvest, and u₂ₓ = 1.6666666666666670e-5 — byte-identical to the single-process oracle and to PL³/3EI.

Known boundary, stated not shipped silently: pattern-scoped sp rows (s.support / ops.support HOLD, p.sp(...) prescribed displacement) are not mirrored onto ghosts. They are SP constraints too, but the gap degrades only to the loud direction — the ghost leaves free a DOF the owner constrains, which is the singular-matrix abort, never the silent over-constraint. Closing it needs the non-homogeneous value replicated, a different question from replicating a fixity flag.

15 regression tests, all mutation-tested: nine distinct defects were re-introduced into the emitter (drop the constraint from the gate, drop the mirror, feed the stage the global-only map, open every bracket, fix the phantoms, mark ghosts held too early, collapse the ordered stream, normalise the fixity vector to all-ones, emit no ghost SP ops at all) and each was caught by the test that claims it. Golden-text churn is again zero — the same tell as the 2026-07-27 fix, and for the same reason: no pre-existing fixture combines a staged partitioned constraint with a BC.

Rebasing this onto the stiffness="auto" work — which reworked the same emit signatures — surfaced a pre-existing coverage hole, closed here with three more tests (18 total). "auto" is the default on tie/tied_contact/embedded and resolves at emit from the host material, so both partitioned entry points must thread a stiffness_resolver; nothing exercised that. This file's other records take InterpolationRecord.stiffness's numeric 1e18 default (the "auto" default lives one layer up, on the Def), test_auto_tie_stiffness.py never partitions, and test_emit_partitioned_embedded.py deliberately passes an explicit stiffness because its fixture declares no materials. The resolver could have been dropped from either call site with every test still green. The new tests pin the resolved K = ALPHA·E·L_char through the flat and the stage-bound emitter, plus the named BridgeError when no host material is found — all three mutation-tested by deleting each stiffness_resolver= argument in turn.

ADDED — modal_deck(solver="arpack"): a second, PARTITIONED distributed-modal backend (ADR 0077 Tier 1B)

Until now the only correct distributed modal path was FEAST, which is replicated — every rank assembles the full (K, M) and only the factorization is distributed. Fork PR #668 (5a522b03b) wired ArpackSOE's parallel collectives under OpenSeesMP, so plain eigen over MumpsParallelSOE on a partitioned deck is now a genuine distributed eigensolve: each rank holds only its slice and the (K−σM) factor+solve is distributed. For the memory-bound case that is the whole reason to go parallel, that strictly dominates FEAST. ADR 0077 listed this route under Rejected alternatives ("REFUTED (F1) … never emitted") — correct against vanilla, false against a #668 build; the ADR is amended accordingly.

  • apeSees.modal_deck(path, solver="feast"|"arpack", num_modes=…)solver is a new axis, orthogonal to the existing target (runtime) seam. ARPACK emits the ordinary partitioned deck (if {[getPID]==K} blocks), a forced Transformation / ParallelPlain / Mumps preamble, and one captured set _lam [eigen $num_modes]. New TclEmitter.eigen_parallel(...) beside eigen_feast_parallel.
  • system Mumps is LOAD-BEARING here — the exact opposite of the FEAST deck, where ADR 0077 INV-4 records that the system line plays no part in the solve. The fork wires the collectives only for MumpsParallelSOE; anything else and they stay dormant with no error. A user-declared non-Mumps system raises rather than being silently overridden. ParallelProfileSPD / MPIDiagonal are the trap — genuinely distributed, own collectives live, so the deck runs while the eigensolver stays rank-local.
  • The FEAST mode-shape harvest could not be reused. Its rank-0 recorder captures the whole field only because that deck is replicated; on a partitioned deck the identical code returns rank 0's slice as if it were the whole mode, with no error. Each rank now writes its own mode_shapes_rank<P>.json + mode_shape_<k>_rank<P>.out, and ParallelModalResult.from_job merges them into the same (n_nodes, ndf) field the replicated path produces — one reader surface, two deck shapes.
  • Shared boundary nodes are recorded on every owning rank on purpose, and cross-checked at harvest. Both ranks solved the same global problem, so the components must agree; disagreement means each ran a private Lanczos — the exact failure a pre-#668 binary produces, and otherwise completely silent. from_job raises with the node tag and both values. Nodal mass, being additive, keeps the opposite discipline: one owning rank per node (the existing primary_owner_map routing), because the M*v merge sums contributions and — with shift = 0 always — K stays exactly right while M goes wrong, yielding a plausible spectrum biased low rather than an error.
  • ops.damping.modal stays refused under MPI, and the guard got more load-bearing. Its original justification (a bare eigen solves each rank's local subdomain) is now obsolete; what is still broken is modalDamping itself, whose modal projection is a rank-local partial sum, so damped response silently changes with rank count. Message and rationale updated so nobody removes the guard on the grounds that the eigen works now.
  • Live-verified against the fork build on the fixed-free 8-mass chain vs an analytic oracle: 1.30e-15 at -n 2, 8.30e-16 at -n 4 (digit-for-digit the fork smoke's own figures), 5.36e-16 for the Tier-0 serial oracle, full 9-node merged field with the boundary node agreeing.
  • Mutation-tested, deliberately. In #668 the original smoke passed a gate-deleted binary at 1.3e-15 — a green test that cannot distinguish is not a test. Every load-bearing assertion here is paired with the defect it claims to catch (test_modal_deck_parallel_arpack_mutations.py), mutating the emitter rather than the deck text: UmfPack-for-Mumps, flat-for- partitioned, rank-0-only harvest, mass on every owner, boundary check removed, boundary tolerance widened. Plus one live mutation — the emitted deck with system UmfPack at -n 2 returns an empty spectrum.
  • Not in scope, recorded: modalProperties stays MPI-blind upstream, so participation factors / effective modal mass remain Tier 0 only; openseespy/PyMP carry the same latent F1 defect, so target="pymp" raises; Tier 0 stays the default for anything that fits on one node.
  • Two traps found live and written down. A partitioned deck run single-process builds only rank 0's submodel — it is not its own serial oracle (unlike the replicated FEAST deck), and on the chain fixture it returns an empty spectrum rather than erroring. And a deck's rank count is baked in at emission: a 2-partition deck at -n 4 leaves two ranks with empty domains and diverges in the ARPACK lockstep guard.

FIXED — a modal deck silently dropped enforce="equation" ties (both backends, incl. the shipped FEAST one)

Found by the ADR 0077 P6 adversarial pass. A modal deck forces constraints Transformation after the model (INV-4 / INV-10 — Penalty pollutes M with penalty mass, Lagrange injects zero-mass DOFs, either fabricates spurious modes). That forced line is emitted last, so it won over the Lagrange upgrade an enforce="equation" tie requires (ADR 0068 INV-4) — and Transformation cannot enforce equationConstraint, it drops it. Measured: a deck carrying six equationConstraint rows under a bare constraints Transformation, running to completion and returning the spectrum of a different structure, with no warning anywhere.

This was not new to the ARPACK backend — modal_deck has had it since the FEAST path shipped (2026-07-16); the new backend inherited it. Both now refuse, via one guard, because only a single constraint handler can be active and no emit satisfies both requirements. Contact is refused for the same reason (it needs LadrunoContact). The ordinary ops.tcl path is untouched and still auto-upgrades to Lagrange — pinned by a control assertion in the regression test.

FIXED — cross-rank MP constraints assembled a SINGULAR matrix in every partitioned run (ADR 0027 INV-2)

Found by the same adversarial pass, and not modal-specific — this hit any partitioned analysis, static or modal, carrying an equalDOF / rigidLink / rigidDiaphragm / surface coupling that straddles a partition.

A foreign-node ("ghost") declaration emitted node <tag> x y z but not the owner's fix. A ghost owns no elements on the declaring rank, so without its BCs its DOFs are free, massless and stiffness-less there while the owning rank has them constrained — under numberer ParallelPlain the two ranks then disagree about whether those DOFs are constrained, the declaring rank contributes a global equation nothing fills, and MUMPS reports Error -10 … Matrix is Singular Numerically. Measured at mpiexec -n 2 on two chains tied tip-to-tip across the partition:

run before after
distributed eigen empty spectrum, silently matches the single-process oracle at 6.07e-16
static analyze singular, returned: -3, empty recorder u₅ = u₁₀ = 0.02 — the analytic value

The ghost now carries the owner's fix immediately after its node line. Phantom nodes are deliberately excluded (bridge-invented, no owner, no user BCs — fixing one would over-constrain the coupling it exists to express), and no de-duplication is needed because _plan_rank_constraints already drops owned tags from foreign_node_tags.

Stage-bound BCs are covered too: a ghost first declared inside a stage block needs the owner's s.fix as well as the global ops.fix. One residual is left open deliberately — a ghost first declared in stage N whose owner fixed it in stage N−1 gets the global + stage-N BCs, not stage N−1's.

Why it survived this long: every pre-existing E2E fixture in test_emit_partitioned_replicate_on_both.py declared no BCs at all, so there was nothing to replicate and the suite passed either way — the fix produced zero golden-text churn, which is the tell. The staged half was worse: it was found only by probing, because nothing in the 12k-test suite exercises partitioned + staged + cross-rank constraint together — the first attempt at it raised NameError at emit time and not one test noticed. Five regression tests were added and mutation-tested: omit the fix, blanket-fix every ghost, fix the phantoms, let owned nodes leak into foreign_node_tags, or feed the stage pass the global-only map — each is caught by its own test. The FEAST backend was never affected (flat deck, no ghost nodes).

FIXED — element results were SILENTLY WRONG for uncomposed solid models (from_mpco / from_ladruno / domain capture)

Correctness fix — silently wrong results. Results.from_mpco and Results.from_ladruno attached the fem_eid↔ops-tag ElementTagTranslator only for composed models, on the premise that an uncomposed model's OpenSees tag equals its fem_eid by allocator construction. That premise is false for the ordinary case: gmsh numbers lower-dimensional elements first, so almost any 3-D solid with surface physical groups has its volume fem_eids offset from the allocator's 1-based ops tags. The consequences, reproduced on a real 393 MB .ladruno (65,798 tet10, uniform tag offset 683):

  • every Gauss-point value was attributed to a different element — correct magnitudes, scrambled spatial arrangement (a bending stress field that must correlate ±1 with the through-thickness coordinate read back as corr ≈ 0);
  • elements whose fem_eid exceeded the max ops tag were silently dropped from pg=-filtered reads (152,308 of 155,040 GPs survived).

The file, the fork recorder, and the elements were all correct — this was purely reader-side. Nodal reads were never affected.

Fixes (ADR 0043 §slice 1.3, updated in place):

  • from_mpco / from_ladruno now attach the translator whenever the bound model records a non-empty element_meta pairing, regardless of compose provenance. Deliberately-unrelated stub model_h5= files (a test-suite pattern) stay safe via the all-or-nothing relabel plus a new per-read consistency rule (ElementTagTranslator.read_translation): one read translates both directions or neither, so an untranslated filter's selected index is never reverse-mapped even when the stub's ops tags happen to cover it.
  • The domain-capture flow (capture/_domain.py) shared the same composed gate and is fixed identically. (from_native reads apeGmsh-written, already-fem-keyed files and from_recorders transcodes nodal records only — neither needed a change.)
  • New WarnElementTagPairingMissing (a UserWarning): element-level reads on an externally-bound fem (fem= / .bind(fem)) with no pairing at all (e.g. from_ladruno without model_h5=) now warn instead of silently joining across id spaces. Nodal reads stay silent; a .ladruno remains self-sufficient.
  • Regression tests: tests/test_results_tag_offset_uncomposed.py (fast, synthetic offset without compose provenance — fails on the old gate) and tests/opensees/integration_ladruno/test_tag_offset_bending_regression.py (live fork cantilever: asserts full PG coverage of the gauss slab and that stress_xx correlates with the through-thickness coordinate as mechanics requires — the spatial assertion a pure count check misses).

ADDED — Pardiso(krylov=…) + FIXED — the live PARDISO probe and H5 archival of optioned solvers

Closing the three gaps between apeGmsh's solver surface and the fork's PARDISO handoff guide (Ladruno_implementation/75c_pardiso_solver_recipe.md), now that the fork is built and installed with Intel MKL.

  • ops.system.Pardiso(krylov=L)-krylov L (fork ADR-75 P1e). Reuses the previous factorization as a CGS preconditioner instead of refactorizing every iteration, stopping at a residual of 10**-L; the fork's recipe recommends 6. Measured a further 1.51× at 51k DOF on top of the PARDISO win — but only under full Newton on a solve-bound model, since full Newton is what refactorizes per iteration, and below ~25k DOF the bookkeeping cancels it. Emitted as an int, like -matrixType.
  • krylov= with matrix_type="symmetric" raises. MKL documents the CGS reuse for mtype 11 / 2 only; on mtype −2 the fork warns and falls back to a direct solve, so the speedup the user asked for silently doesn't happen. Refused at construction instead. The docstring also carries trap 5 — near a limit point the inexact solve can change which post-peak equilibrium branch a LoadControl continuation lands on, so keep it off when the deliverable is the post-peak path — and trap 7: threaded PARDISO is not byte-reproducible run-to-run (~1 ULP across runs at MKL_NUM_THREADS > 1); pin threads to 1 for a byte-identical baseline.
  • FIXED — the live-solver tests probed the wrong build. tests/opensees/live/test_systems_live.py asked stock openseespy whether system Pardiso existed, while the tests themselves run through LiveOpsEmitter, which resolves APEGMSH_OPENSEES_BIN → the fork's opensees → stock. On a box with the fork installed the PARDISO cases therefore skipped on stock's answer (or the whole module skipped when stock was absent) while the run would have used the fork — so the previous entry's claim that "the live tests now run for real against a fork build" was not in fact being exercised. The probe now asks the module LiveOpsEmitter binds. Verified against the installed fork: an unknown system name raises OpenSeesError, Pardiso / PARDISO return cleanly, so the exception-based probe is sound. All ten live solver cases (including two new krylov ones) now run rather than skip.
  • FIXED — ops.h5(...) crashed on any solver with options. A chain arg tuple mixing flags with values — ("-matrixType", 1, "-krylov", 6, "-stats"), and equally integrator Newmark 0.5 0.25 -form D — had no matching dtype in _set_attr and fell through to [float(v) for v in value], raising ValueError: could not convert string to float: '-matrixType'. Mixed tuples are now stored as string tokens (str for ints, repr for floats) and recovered on replay by compose._int_recover, which re-reads a token as int first, then float, else keeps the string — so a flag survives as a flag and -matrixType 1 replays as an int, not 1.0 (the fork parses it with OPS_GetIntInput). Proven by deck-equality with types intact in tests/opensees/h5/test_h5_flagged_chain_args.py. This also un-breaks H5 archival of Newmark(form=…), which was collateral damage of the same hole.
  • New fork-gated test — -krylov on a tangent that actually changes (tests/opensees/integration_ladruno/test_pardiso_krylov_live.py). The existing live cases are linear, so the factorization is built once and CGS reuse never engages; they cannot catch a -krylov that returns a different equilibrium point. The new one loads an elastoplastic LadrunoBrick + LadrunoJ2 block ~57× past first yield under full Newton and pins every PARDISO mode to the UmfPack answer at rel=1e-12. Measured: all modes agree to ≤1 ULP and krylov=6 runs ~1.3× faster than the direct solve, so the reuse demonstrably engages without moving the answer. The test asserts its own premise (that the block yielded) — a mesh-density change had quietly kept it elastic on the first run, which would have made it prove nothing.

ADDED — ops.system.Pardiso() — threaded MKL sparse LU (Ladruno fork ADR-75 P1)

  • New typed LinearSystem primitive Pardiso (analysis/system.py) emitting system Pardiso, exposed as ops.system.Pardiso(). It wraps the fork's PARDISOGenLinSOE / PARDISOGenLinSolver (fork PRs #622 / #623) — Intel MKL PARDISO with a shared-memory threaded factorization and factorization reuse (symbolic once per sparsity pattern, numeric only when the tangent changed).
  • A drop-in for UmfPack: MKL matrix type 11 (real + unsymmetric), the same general storage, so it is legal wherever UmfPack is — including the LadrunoUP unsymmetric-solver gate, where Pardiso now joins the allow-list. The fork measured it bit-identical to UmfPack (rel err 0.0) at every size and thread count.
  • The win compounds with model size — it is not a constant factor. At four threads on a LadrunoBrick + LadrunoJ2 cube: 1.61× at 11.5k DOF, 2.15× at 26k, 3.40× at 51k, because UmfPack scales ~O(N²) while PARDISO scales ~O(N^1.45) (fork ADR-75 P1c, #624). The P1 thread-sweep headline of 1.71× understated it by ~2× for solid-model work.
  • It also raises the ceiling, which matters more than the ratio. UmfPack ran out of memory at 86,490 DOF on the same machine; PARDISO solved that in 30.4 s and a 136,080-DOF model in 68.6 s. Memory, not time, is what stops a large 3-D direct solve — so a model that previously forced the cluster may now fit on a workstation. (RAM-dependent: the ordering of the wall is the durable result, not the DOF count; the true ceiling is untested.)
  • Thread count is deliberately not a parameter. MKL reads it from MKL_NUM_THREADS / OMP_NUM_THREADS, which must be set before the process starts (before import openseespy for a live run, in the job script for a Tcl / HPC run). Four is the recommended desktop value — scaling flattens past that because sparse factorization is memory-bandwidth-bound. Only the factor/solve is threaded; assembly and element state determination stay serial, so the win shows up on solve-bound models and not on element-bound ones.
  • Availability is narrower than every other system: the Ladruno fork built with Intel MKL, serial targets only. Stock OpenSees, a non-MKL fork build, and the MPI targets all reject the token with "unknown system type" — emission works on any build, running does not. Declaring it under len(fem.partitions) > 1 now warns alongside the other serial systems (use Mumps under OpenSeesMP; PARDISO is a serial SOE with no distributed assembly).
  • matrix_type= — symmetric half-storage (fork ADR-75 P1d, #630). "unsymmetric" (default, full CSR), "symmetric" (upper-triangle, LDLᵀ), "spd" (upper-triangle, Cholesky) → -matrixType 0|1|2. Symmetric measured 1.96× UmfPack with 42% less peak memory, bit-identical results — the largest memory lever in ADR-75 so far, and exact, unlike MUMPS BLR. Prefer "symmetric" over "spd": within 0.7% on time, and "spd" fails outright on the indefinite tangent any softening or buckling model eventually has.
  • The default stays "unsymmetric", and apeGmsh now enforces that where it matters. Half-storage reads only the col >= row half of each element matrix — no averaging, no detection — so on a genuinely unsymmetric tangent it silently solves a different system. The LadrunoUP solver gate matched on class name only, so Pardiso(matrix_type="symmetric") would have sailed through the very check that exists to stop a silently-dropped coupling block; it now inspects the mode and refuses, flat and per-stage.
  • matrix_type is emitted as an int, never a string — the fork parses -matrixType with OPS_GetIntInput, and a string value degrades the solver (pre-#630 it silently fell back to ProfileSPD, which cost the fork a 25-minute benchmark that looked merely slow rather than wrong). A regression test pins the emitted type, and an unknown matrix_type raises at construction rather than being quietly downgraded at runtime.
  • stats=-stats: dump PARDISO factor nnz and peak memory once per sparsity pattern.

ADDED — ops.system.Mumps(...) — the full MUMPS option surface

  • Mumps was flag-only (bare system Mumps); it now exposes every option the fork's parser accepts: icntl14-ICNTL14 (working-space growth %, the knob to raise on a workspace/OOM failure), icntl7-ICNTL7 (sequential ordering: 7 auto, 5 METIS, 4 PORD, 2 AMF, 0 AMD), matrix_type-matrixType, blr-BLR, icntl35/cntl7-ICNTL35/-CNTL7, comm_split-commSplit, stats-stats. Two of these are new in ADR-75 P2 / P2b (#625 / #626); the rest were long-standing fork/stock options apeGmsh simply never surfaced.
  • With no arguments it still emits the bare system Mumps, so every existing deck — including the ADR-0027 INV-5 auto-emitted parallel fallback — is unchanged.
  • matrix_type uses the same vocabulary and numbering as Pardiso ("unsymmetric"/"spd"/"symmetric" → 0/1/2), which is deliberate on the fork's side too. The LadrunoUP half-storage gate is written against the attribute rather than the class, so it now covers Mumps automatically.
  • Bad values raise at construction. The fork's MUMPS parser return 0s on a failed parse, leaving theSOE null so OpenSees drops to the ProfileSPD default with only a warning — the run looks merely slow rather than wrong. apeGmsh refuses a negative blr/cntl7, a negative comm_split, an unknown matrix_type, and blr combined with icntl35/cntl7 (blr is sugar for exactly that pair, and the fork's left-to-right parse would silently let the last one win). Int-valued options are emitted as ints, never strings.
  • comm_split carries the deadlock warning in its docstring: MPI_Comm_split is collective over the world communicator, so every rank must pass a colour.
  • BLR is documented as the honest disappointment it measured as. It is an approximate factorization (keep it off on byte-identical / oracle lanes), and at ~32k DOF on 2 ranks the fork found it a win on no axis: 1e-9 ran 1.70× slower with +8.4% peak memory; 1e-4 shrank stored factors 21.8% but moved peak memory only −4.6% while running 3.17× slower — peak is dominated by the active frontal space, not by what is stored. In a nonlinear loop a looser tolerance also returns a less accurate correction, so Newton needs more iterations. The crossover to production-size fronts is untested; stats=True is how you check.
  • The live tests now run for real against a fork build with PARDISO: all three matrix_type modes solve the reference cantilever and agree with UmfPack to 1e-12. They still skip themselves when the bound openseespy has no system Pardiso, so the suite stays green on a stock build.

ADDED — isochrone views in the results viewer: arrival-time map, profile curve family, motion strobe

Three new diagram kinds, all answering a question about time rather than about a response value. Each registers through @register_diagram_kind, so it appears in the Add-Diagram dialog / kind catalog / settings tab and survives a session save-restore with no per-kind table edits (ADR 0058 S0).

  • isochrone_map — "Isochrone map (arrival time)" (IsochroneMapDiagram / IsochroneMapStyle). Colours each node by when its history satisfies an arrival criterion, so the iso-lines of the painted field are the wavefronts: mode="first_crossing" (first time the tracked value reaches threshold) or mode="time_to_peak". The threshold defaults to threshold_fraction × peak so the diagram is usable without knowing the field's units, and the level it actually applied is reported (diagram.threshold_used / describe_criterion() / the settings card) — an auto threshold you can't read back makes the map uninterpretable. interpolate=True (default) places the crossing between the bracketing steps instead of snapping to the later one, which removes the step-quantized banding that otherwise dominates a coarsely sampled front. In first_crossing mode nodes the front never reaches are excluded from the painted submesh rather than given a sentinel colour — leaving a hole (substrate wireframe) is the honest rendering of "not yet arrived" — and attach raises NoDataError naming the level if nothing ever crosses. The whole history is read once at attach and reduced to one time per node; the (T, N) slab is released, and update_to_step is a deliberate no-op (a per-step arrival map would just be a contour). Because that read is inherently (T × N), max_history_samples (default ~50 M ≈ 400 MiB) sizes it up front from a one-node probe and refuses an over-budget request with an actionable message — 1 M nodes × 1 000 steps is 7.5 GiB of float64, which would take the viewer down inside h5py rather than draw anything.
  • isochrone_profile — "Isochrone profile (curve family)" (IsochroneProfileDiagram / IsochroneProfileStyle). The classical consolidation-style plot: response vs position along a path, one curve per instant, coloured along time, with a time colourbar and a heavier highlight tracking the current step. The chart is a new plot-pane side panel (IsochroneProfilePanel, via the existing make_side_panel hook); the 3-D layer is just the sampled path as a polyline, so you can see which nodes feed the curves. Nodes are ordered by one coordinate axis (path_axis="auto" picks the axis of largest extent), ties breaking lexicographically on the other two so the path depends on geometry rather than node numbering. value_axis="auto" draws a z path upright (depth vertical) in the geotechnical convention. Position is the coordinate on that axis, not arc length along a general curve — a selection that doubles back on it reads as a zig-zag, and "All nodes" is refused outright since it has no ordering.
  • isochrone_strobe — "Isochrone strobe (motion trail)" (IsochroneStrobeDiagram / IsochroneStrobeStyle). Superimposes the deformed shape at n_frames instants at once, so the shape of the motion is legible in a still image. This has to be a diagram rather than N geometries because the geometry manager deliberately shares ONE global step cursor (ADR 0058 S3b rejected per-geometry cursors). All frames live in a single wireframe MeshLayer whose points carry their own frame time → one colour scale, one time-reporting scalar bar, and a topology that never changes. scale=None auto-fits off the model diagonal and set_scale re-warps the cached frames in place. A max_points budget makes the per-frame submesh replication fail loud (naming the knobs to turn) instead of silently building a huge layer. Intended for a deform-off geometry — it is the deformation display.

Supporting changes, all small and shared:

  • viewers/diagrams/_isochrone_math.py — the semantics that decide these views (arrival_times / resolve_threshold / pick_step_indices / dominant_axis) as pure-numpy free functions, unit-tested without a scene.
  • ScalarBarSupport._scalar_bar_base_title() — new overridable hook so a diagram whose painted scalar is a time can title its bar accordingly (t_arrival (…) / t_peak (…) / t (… strobe)) instead of mislabelling seconds with the tracked component's name. The geometry-prefix logic in _scalar_bar_title is unchanged and no longer duplicated per kind.
  • ScalarColorSupport._init_lut() now keys the LUT mirror on _color_array_name() rather than blindly on the selector component, so the ColorMapEditor labels the array that is actually on screen. No behaviour change for the existing diagrams (their _color_array_name is the component).
  • Diagram._stage_time_vector(component, probe_id)Results.time is only available on a stage-scoped handle, and a diagram with no explicit spec.stage_id and no geometry stage pin holds the unscoped one. A diagram that needs the time axis itself now derives it from a one-node slab read, guaranteed consistent with its other reads.
  • MeshLayer.line_width (+ the pyvista backend kwarg) — a 1 px strobe wireframe over a wireframe substrate, or a 1 px sampled path, is unreadable. None keeps the backend default, so every existing layer is untouched; the trame backend inherits the translation.
  • The settings tab grows a panel per kind. Their defining knobs are computed at attach (an arrival field, a sampled path, a frame set), so they commit through a new shared _rebuild_with_style that mirrors _on_data_swap — the layer keeps its z-position and composition membership rather than jumping to the top of the stack. Purely visual controls (cmap / clim / opacity / scalar bar / strobe scale) stay live staged setters.
  • Tests: tests/viewers/test_isochrone_math.py (arrival + interpolation + frame picking + axis semantics) and tests/viewers/test_isochrone_diagrams.py (emitted layers, never-arrived exclusion, static-across-steps, time-titled bars, the curve family vs an analytic wavefront, frame warps, the point budget, the deform-follow shift/reset contract for all three, session round-trip, the settings panels, the matplotlib chart, and real-offscreen-VTK integration). The exhaustive _EXPECTED topology map and the dialog's kind-count canary are updated.

FIXED — a short ops.fix(dofs=...) mask was rejected by OpenSees instead of leaving the trailing DOFs free

  • ops.mass and ops.load fit their vectors to the node's ndf (fit_dof_vector); fix never did — _emit_fixes passed rec.dofs through verbatim. On any model where a node carries more DOFs than the mask, ops.fix(pg="Base", dofs=(1, 1)) emitted fix(n, 1, 1) and OpenSees refused every one of them, so the model ran unrestrained (live emit raised; a Tcl deck only warned).
  • The guard in validate_record_ndf_consistency had the rule backwards, and said so in its own docstring: "a short mask fixes only the leading DOFs, which OpenSees accepts". It does not. SP_Constraint.cpp:74 is if (vals.Size()-1 < ndf) { "invalid # of constraint values"; return -1; } followed by a loop bounded at ndf — so a short mask is fatal and a long one is silently truncated, the opposite of the load / mass rule.
  • Emit now pads through the new fit_fix_mask (padding with 0 = DOF left free, which is what "I did not mention DOF k" means), on both the flat and the partitioned (OpenSeesMP) fan-out. The too-long guard is unchanged — it still catches a mask that addresses DOFs the node does not have.
  • This is what made test_terzaghi_q4_column_runs_and_p_decays fail: the drained u-p column fixes (1, 1) on ndf-3 nodes, deliberately leaving the pressure DOF free. Its second failure was independent — the gate asserted monotone consolidation decay on a model with solid inertia, where a suddenly applied top load also rings the column's first axial mode (T = 4h/√(E/ρ) = 0.226 s, ~4 steps at dt = 0.05), so the base pressure oscillated about the Terzaghi curve with a decaying envelope. Terzaghi consolidation is quasi-static, so the gate now runs at rho = 0 and the history is monotone (0.740 → 0.544 over 1 s).

ADDED — the fork's second-order solids reach the bridge: ops.element.LadrunoBrick20 / LadrunoLST

  • apeGmsh could already emit second-order tri6 (SixNodeTri, BezierTri6) and tet10 (TenNodeTetrahedron, BezierTet10, LadrunoUP Taylor-Hood), but two second-order elements the fork ships had no emission path at all: LadrunoBrick20 (H20, tag 33018, ADR 72) and LadrunoLST (T6, tag 33016, ADR 70 P3). The registry carried no gmsh_etypes={17} entry of any kind, so a hex20 mesh had nothing to fan out to. Both now have a typed class, a registry entry and a namespace method; mesh with g.mesh.generation.set_order(2) and pass the physical group as usual.
  • LadrunoBrick20 is the first element whose node order is not Gmsh's. Gmsh's hex20 (etype 17) and the fork's serendipity "Local Node Pattern" agree on the 8 corners and disagree on all 12 mid-edge slots, so _emit permutes through the new GMSH_HEX20_TO_SERENDIPITY — the single source of truth, also stored as the entry's node_reorder (every other entry in the registry is an identity permutation). Getting this wrong is not silent but is misleading: the fork reports a non-positive Jacobian and marks the element DEAD, which reads as mesh distortion rather than node order.
  • Fork-parser constraints are enforced at construction rather than at run, matching how LadrunoBrick handles its own: LadrunoBrick20 accepts only formulation="std"|"uri" and exposes no hourglass knob (a hard fork error by design — the H20 2×2×2 modes are non-communicable, ADR 72 §2.2); LadrunoLST rejects geom="finite" under PlaneStress (the finite plane-stress view omits the thickness stretch, ADR 70). Both are added to the live emitter's fork-only gate so a stock openseespy gives the "requires the Ladruno fork build" message instead of a parser error.
  • Tests: tests/opensees/unit/primitives/test_elements_solid.py gains TestLadrunoBrick20 / TestLadrunoLST — including a check that pins the permutation slot-by-slot against both conventions' documented edge definitions, so the table cannot drift from LadrunoHex20Shape.h. tests/opensees/integration_ladruno/test_second_order_solids_live.py solves both elements on the live fork; a self-weight patch test lands within 2% of b·L²/(2E), loaded through body_force so each element integrates its own consistent load vector rather than comparing two different hand-lumped tractions.
  • Known gap, deliberately not closed here: LadrunoLST(geom="finite") emits correctly but cannot be run from the bridge — the fork demands a FiniteStrainND2DMaterial (its LogStrain2D, ND_TAG 33016) and apeGmsh models no 2-D finite-strain wrapper (the 3-D LogStrain is rejected). That is a materials gap, not an element one. The live test skips that lane with that message and will start exercising it automatically if the material lands.

FIXED — results.plot.* drew an empty figure for every solid / shell read back from a solver file

  • The static-plot facet extractor keyed its solid and surface tables on group.type_name, allowlisting {"tet4", "hex8"} and {"tri3", "quad4"}. But a .ladruno / .mpco-synthesized FEMData names its groups after the OpenSees classmake_type_info(gmsh_name="SSPbrickUP", …) finds no curated alias and falls through to "sspbrickup" — so the solid was silently skipped and plot.contour / plot.deformed / … drew a bare bounding box. Only the 1-D branch worked; it had already been switched to dimension-based dispatch.
  • Which models this hit was decided by nomenclature, not by topology: _auto_alias matches Gmsh shape words, so FourNodeTetrahedron"tet4" and TenNodeTetrahedron"tet10", while stdBrick is recorded by MPCO as class Brick"brick", LadrunoBrick20"ladrunobrick20", FourNodeQuad"fournodequad", ShellMITC4"shellmitc4". Linear tets came through by luck; every brick — the reported symptom — and every shell was dropped.
  • Both branches now key on the topology (dim, npe) — the same GMSH_LINEAR_FALLBACK convention the interactive viewer's fem_scene already used, which is why the viewer rendered these models correctly and only the static plot came up empty.
  • Same pass, same symptom via the other half of the lookup: higher-order elements (tet10 / BezierTet10, hex20, hex27, tri6 / BezierTri6, quad8, quad9) had no table entry at all and were skipped for native Gmsh meshes too. They now render from their corner subset (mid-side nodes dropped), matching the viewer. Wedges and pyramids still have no face table and are still skipped.
  • Regression tests: tests/results/test_plot_facets_solver_named.py (unit) and tests/results/test_plot_facets_mpco_real.py — the latter meshes in apeGmsh, runs the model through openseespy with the MPCO recorder (FourNodeTetrahedron / TenNodeTetrahedron / stdBrick / LadrunoBrick20), reads the file back through read_fem_from_mpco and requires the hull to be identical to the native FEMData's and closed. It also pins the portability argument for the corner subset: permuting the mid-edge columns (the only thing Gmsh, Abaqus and OpenSees disagree about — corners come first in all three) must leave the hull untouched.

FIXED — doc/docstring drift for the FEMData broker accessors (post selection-unification prune)

  • The .select(...) unification removed fem.nodes.get_ids(pg=) / fem.nodes.get(target=) / fem.elements.get_ids(pg=) / fem.elements.resolve(pg=…, element_type=…), but several user-facing examples still showed the removed names (they raise AttributeError when copied). Corrected to the real API — fem.nodes.select(pg=…).ids, fem.nodes.select(target=…), fem.elements.select(pg=…).ids, and fem.elements.select(pg=…).result().resolve(element_type=…) (the GroupResult.resolve terminal) — in docs/how-to/results-mpco.md, docs/guides/nonlinear_concrete_solver.md, docs/design/parts-assembly.md, the OpenSeesModel docstring (model_data.py), and a _fem_factory.py comment. (The docs/api/selection.md migration table already documented the rename and is unchanged; results.nodes.get(pg=, component=) is the separate, valid Results-reader API and was left as-is.)
  • Fixed three docstring examples that showed select("box", dim=1) / select("layer_1", dim=1) "to get edges" — dim= never filtered a label to a lower dimension (the volume/surface came back), and the new select dim-mismatch guard now makes them raise. Rewritten to the working idiom m.model.select(None, dim=1).result().parallel_to(...) (_mesh_structured.py, _selection.py).
  • Not touched (separate concern): the generated docs/api-flows/* (atlas.html / flows.json) still carry stale references — those regenerate from source and shouldn't be hand-edited.

CHANGED — g.model.select(target, dim=) fails loud on a dimension mismatch + chain-level .tags()

  • select("vol", dim=2) on a volume label used to silently return the volume (dim= is not a post-filter — a label / physical group / part resolves to the dimension(s) it occupies, ignoring dim=), so a following .to_physical("faces") registered a volume group — a silent wrong-dimension footgun. Now, when dim= is passed explicitly and the target resolves to no entity at that dimension, select raises a ValueError naming the actual dimension(s) and pointing at .boundary() (to walk a volume down to its faces) or target=None (to select every entity at a dimension). Matching-dim and omitted-dim= calls are unchanged.
  • EntitySelection.tags() — the selection chain grows a .tags() terminal (bare integer tags, drops dim), so g.model.select(...).tags() works directly instead of raising AttributeError and forcing .result().tags().
  • Fixed an unrelated stale test surfaced by the full-suite run: test_phase_3b_2d.py::…returns_none_on_unsupported used KinematicCouplingDef as its "unsupported in chain-phase" example, which the PG-constraints PR (#850) made supported; it now uses NodeToSurfaceDef (still routed through its own bare-tag path).
  • Tests: tests/test_selection_dim_guard.py (mismatch raises, matching / no-dim / target=None unaffected, .boundary() remedy yields the faces, .tags()).

ADDED — Results.from_fem(fem, path) — post-processing for a bare FEMData (no bridge)

  • A model that did not go through the apeSees bridge — e.g. a physical-group model where fem = g.mesh.queries.get_fem_data() drove a hand-written OpenSees deck — now has a one-call route to Results / viewer(): Results.from_fem(fem, path, *, kind="auto", merge_partitions=True, cache_root=None). Previously the only routes were the bridge-bound constructors (from_mpco needs a model_h5= path, from_native / from_recorders need a model= / ResolvedRecorderSpec) or the self-describing from_ladruno. from_fem materialises a neutral-only model.h5 from the fem (cached under <cache_root>/from_fem/, keyed by fem.snapshot_id) and binds it — a real file, not an in-memory model, so the non-blocking / web viewers (which forward --model-h5) work, not just data access. kind="auto" detects .mpco / .ladruno by suffix; a native .h5 must pass kind="native".
  • Composed fems are refused (fail-loud). Element / Gauss results relabel through the fem_eid <-> ops-tag map that only a real bridge run records; a neutral-only model.h5 carries none, but composed_from round-trips, so a composed model would attach an element-less (empty) tag translator and silently mislabel every element/gauss/fiber result. from_fem raises with a pointer to the bridge model.h5 instead.
  • The model_h5= / model= "required" TypeErrors on from_mpco / from_native / from_recorders now name Results.from_fem(fem, path) as the route for a bare snapshot (and fem.to_h5(...) as the manual one).
  • Fixed the non-blocking subprocess viewer for .ladruno: python -m apeGmsh.viewers routed .ladruno into the native branch → NativeReader SchemaVersionError; it now dispatches to from_ladruno.
  • A bare fem has no envelope ndf (MeshInfo carries none), so the cached model's ndf is 0 — harmless for reading / the viewer, relevant only to deck re-emit (not this path's purpose).
  • Tests: tests/results/test_from_fem.py (kind resolution, composed-refusal guard, rewritten error messages, and a native round-trip via NativeWriter with no embedded model — cache write + bind + read-back).

ADDED — constraints accept physical-group / label names (build + chain phase)

  • g.constraints.* no longer require Parts. master_label / slave_label now resolve through the shared label→PG→part resolver (the same one g.loads / g.masses use), so a physical-group model built with add_box + .to_physical(...) can constrain directly — g.constraints.tie("A_top", "B_bot") just works. Previously this raised KeyError: Part label 'A_top' not found in g.parts. Available: [] and the tie / kinematic-coupling family was inaccessible without building Parts. Applies to tie, kinematic_coupling, rigid_body, distributing_coupling, equal_dof (+ mixed), rigid_link, rigid_diaphragm, penalty, and tied_contact. Precedence: a Part registered under the name still wins (the part node/face map is consulted first); otherwise the name resolves as a PG / label. embedded, node_to_surface(_spring), and the fork contact / contact_plane keep their own target schemes (unchanged).
  • Chain-phase routing (from_h5 / compose sessions) extended to the verbs that previously fell through to the bump-counter pattern — a silent no-op (def stored but never applied): tie, kinematic_coupling, rigid_body, distributing_coupling, and penalty now resolve against the FEMData broker via the same pure-numpy resolvers the build path uses. An empty-resolving target is now a hard error rather than silently binding to the global closest node (resolve_kinematic_coupling / resolve_distributing global-fallback hazard). The face_map=None guard is relaxed so a PG session reaches the resolver while a bare direct-resolve() caller still fails loud.
  • Tests: tests/test_constraints_physical_groups_live.py (live gmsh tie / kinematic-coupling by PG name + rewritten error message) and tests/test_constraints_pg_chain_phase.py (chain-phase routing for the five newly-wired verbs + the empty-target fail-loud guard).

ADDED — live properties panel + worker-thread builds (ADR 0080 B6)

  • The section builder GUI grows a Properties dock that shows the analyzer numbers for the current document state. Continuum: the S6 inspector's tabbed geometric/warping/plastic tables + stress preview, embedded (the inspector's display content was extracted into a reusable SectionInspectorPanel, so the builder embeds rather than forks it). Fiber: the exact fiber-sum identities (total area, per-material area, item counts, GJ). A Live checkbox auto-refreshes after each edit; a Refresh button forces a one-shot build. launch_builder() turns Live on by default.
  • No solve on the UI thread (the S6 law): every build+analyze runs on a worker thread via sections/_properties.py::PropertiesController, with the panel greyed until fresh. The controller memoizes by canonical document state (an identical state never rebuilds), coalesces a burst of edits during an in-flight build into a single follow-up build of the latest state (N edits → ≤ N builds, last state wins), and drops stale results (a build for a no-longer-latest state is cached but not shown). The heavy build_document is injectable so the coalescing/memoization tests run with a stub (no Gmsh); one test exercises the real builder and asserts the solve ran off the calling thread and matches a headless build() (tests/sections/test_properties.py, test_builder_gui_b6.py).
  • gmsh.initialize off the main thread: _gmsh_acquire now requests interruptible=False when not on the main thread (the SIGINT handler gmsh.initialize installs can only bind on the main thread — off it the init previously raised signal only works in main thread). Main-thread behaviour (Ctrl-C aborts a long mesh) is unchanged; this is what lets the B6 properties worker build a section on a background thread.

ADDED — section builder GUI shell + drafting aids (ADR 0080 B5)

  • launch_builder(path_or_doc=None, *, blocking=True) (exported from apeGmsh.sections) — a standalone Qt + matplotlib editor for a SectionDocument, in the S6 inspector mold (NOT the ADR 0014/0042/0056 viewer family). Shape palette (the 8 parametric *_face shapes + freehand polygon + RC/fiber items + boolean/bars/mesh forms), a session-free canvas (analytic outlines pre-build for the continuum lane; patch/layer/point glyphs for the fiber lane — no solves in B5), undo/redo as document JSON snapshots, open/save .section.json, and status-bar GRID/SNAP/ ORTHO toggles on F7/F9/F8 via QShortcut in Qt.ApplicationShortcut context (a canvas-focused widget swallows WindowShortcut — the established law). Contract mirrors sec.viewer(): notebooks pass blocking=False; Qt absent → ImportError with guidance; QT_QPA_PLATFORM= offscreen on win32 → RuntimeError from the launcher while the window class stays offscreen-constructible.
  • Parity law is a test: every GUI mutation calls the matching SectionDocument API method, and the offscreen widget tests assert the resulting document dict equals a hand-authored reference — the GUI can do nothing a script cannot (tests/sections/test_builder_gui.py).
  • sections/_drafting.py — the AutoCAD-style drafting aids as Qt-free, pure functions (unit-tested with zero Qt): snap_candidates (endpoints / midpoints / circle centers+quadrants / segment intersections from the document's resolved shape outlines), resolve_snap (object snap beats grid snap, tolerance window, kind-priority tie-break), ortho_project, constrain_segment (the length/angle lock resolver — length → circle, angle → ray, both → determined), and parse_dynamic_input (length<angle / @dx,dy / x,y with a full rejection table). The aids write coordinates into the document and add no document state — the parity law is untouched (tests/sections/test_drafting.py).

ADDED — SectionDocument.export_script() (ADR 0080 B4)

  • One-click export of a section document as a readable, runnable apeGmsh Python script (one-way by design — round-trip editing stays in the JSON). Continuum lane: session → builders/polygon geometry → booleans → mesh → SectionProperties bound to sec, with the bars overlay and bridge handoff as a commented epilogue. Fiber lane: a build_section(ops) function — uniaxial materials from the document specs, template expansions inlined as literals with provenance comments, returning the registered ops.section.Fiber.
  • Contract tested per the review lesson (execution, not just golden text): exports are deterministic; executing an exported continuum script reproduces the document build's analyzer numbers to 1e-9 (incl. polygon + cut); the exported build_section produces a deck byte-identical to doc.to_section (tests/sections/test_script_export.py, 6 tests).

FIXED — B1–B3 hardening from the adversarial review panel (ADR 0080)

  • Loader = mutation API, enforced by shared checkers: SectionDocument's open() path now applies every value/type rule the add_* methods apply (bar-line n>=2 [was ZeroDivisionError at n=1, silent bar loss at n=0], areas > 0, numeric+finite params everywhere, container types, template params validated at load, mesh lc/order, circ-patch radii) — hand-edited JSON now raises SectionDocumentError naming the problem instead of raw KeyError/TypeError deep in build(). Optional keys the validator tolerates (disconnected, GJ, mesh.order) are now read tolerantly in build() too.
  • Version strings must be canonical digits (the "1.-1.0" window hole is closed); repr() works on fiber documents; self-referencing booleans refuse; save() refuses non-finite floats (strict JSON); add_patch_circ now requires ext_rad; template errors wrap as SectionDocumentError on both mutation and load paths.
  • Oracle strengthening (three demonstrated surviving mutations, all now killed and mutation-proven): a coordinated 180° flip of the emitted fiber section dies on two new point-symmetry breakers (signed axial–flexural coupling of a single off-centroid bar with hand values — including the discovered OpenSees FiberSection3d computeCentroid convention: the axial DOF measures strain at the fibers' AREA centroid — and signed odd third moments of an L-section vs composite-rectangle closed forms); the core↔cover role swap dies on distinct confined/unconfined materials with exact per-role areas; the to_section typed-conversion transpose dies on a literal patch rect deck-line assertion.
  • Docs: ComputedSection records that default GJ excludes the bars' own torsional term, that the reference axis stays at the continuum-only elastic centroid, and that bars are not containment-checked.
  • Mechanics lens of the review: zero confirmed defects (ten attacks refuted).

ADDED — bars= rebar overlay on ComputedSection (ADR 0080 B3, gate G-E passed)

  • New Bar value object + bars= on ComputedSection(kind="fiber"): discrete rebar in the analyzer's authoring (x, y) axes, appended to the Gauss fibers through the same axis mapping about the elastic centroid (concrete area not deducted — documented). Bar materials ride dependencies().
  • SectionDocument continuum lane: add_bar / add_bar_line (n bars, endpoints included, stored parametric) and doc.to_section(ops) now works on continuum documents — builds the analyzer, resolves the dual-role materials' uniaxial specs, and registers ComputedSection(kind="fiber", fibers=…, bars=…); "bars" is a 1.0-additive document key (B1 documents load unchanged).
  • Gate G-E passed (tests/sections/test_bars_overlay.py): signed ΣEAyz mirror-catch with an off-axis bar (exact, 1e-12), openseespy M–κ initial slope == the fiber sum with bars (1e-9), and near-zero-concrete ElasticPP bars plateau at ΣA_s·fy·d in both signs.

ADDED — fiber lane + RC templates in SectionDocument (ADR 0080 B2)

  • SectionDocument(kind="fiber"): patches (rect + the new circ), straight layers, bar points, and parametric RC templatesrc_rect_column, rc_circ_column, rc_beam (cover to bar centre, bars-per-face layouts with corner dedup, core_split = exact confined-core/cover partition; the document never computes confinement — you assign the materials). Templates are stored parametric and re-expanded deterministically at build, so editing cover just works.
  • build() on a fiber document returns a FiberRecipe (plain data, material names); doc.to_section(ops) resolves it on the bridge — dual-role material entries gain a uniaxial=("<Type>", {kwargs}) spec constructed via ops.uniaxialMaterial.<Type> (one bridge material per name) and the section registers as ops.section.Fiber(...).
  • New CircPatch primitive (patch circ emission) beside RectPatch; Fiber.patches accepts both. Lane guards: continuum methods raise on fiber documents and vice versa.
  • Gates: closed-form expansion oracles (bar counts/positions, ΣA_bars, patch partitions summing to b·h / πr² exactly, circ ring angles), re-expansion determinism, JSON round-trip, deck golden through a real apeSees bridge (tests/sections/test_fiber_documents.py, 12 tests).

ADDED — SectionDocument continuum lane (ADR 0080 B1)

  • apeGmsh.sections.SectionDocument — the versioned declarative section document (SECTION_DOC_VERSION = "1.0.0", additive-minor window per ADR 0023 with the corrected #836 direction): parametric shapes (the eight *_face builders 1:1) + freehand polygon shapes + booleans (embed = the one-step composite-partition primitive [cut keep-tool + fragment_pair — the double-cover trap is unrepresentable through it], raw cut / fragment_pair) + named materials table + mesh prefs + disconnected policy. build() runs a private session headlessly and returns a SectionProperties; save()/open() round-trip byte-stably.
  • Sacrificial cut tools (consumed by remove_tool=True) get no PG and no material requirement; consumed geometry is swept post-boolean.
  • Gates: SRC document reproduces the hand-authored session's analyzer numbers to 1e-9; L-polygon vs hand integrals; rotated-shape EIxy flow-through; version-window accept/refuse table; fail-loud validation surface (tests/sections/test_section_document.py, 11 tests). Fiber-lane documents are rejected with guidance until B2.

CHANGED — ADR 0080: AutoCAD-style drafting aids added to the builder scope

  • User-requested scope addition to the (still Proposed) section builder: grid snap, object snap (endpoint/midpoint/center/quadrant/intersection with conventional marker glyphs), ortho mode (F8 + Shift-hold), and typed exact input (length<angle / dx,dy / x,y). Status-bar GRID/SNAP/ORTHO toggles (F7/F9/F8 via Qt.ApplicationShortcut). Polar tracking deferred.
  • Design law: aids are GUI-layer input helpers only — they decide which coordinates get written into the SectionDocument and add zero document state, so the GUI↔headless parity law is untouched. Snap/ortho/parse live in a Qt-free sections/_drafting.py (pure functions, unit-testable without a window); plan B5 + risk register updated. Design-only PR.

ADDED — ADR 0080 (Proposed): interactive section builder (SectionDocument + Qt builder GUI)

  • New ADR + implementation plan for the section-authoring gap: a versioned declarative JSON section document (source of truth, full headless API) covering BOTH lanes — continuum (parametric *_face shapes + freehand polygons + booleans + materials → analyzer) and fiber (patches/layers + RC templates: rect/circ column, beam, cover/bar layouts, confined-core split) — plus a standalone Qt builder GUI (S6-inspector mold, outside the viewer family) that edits it, one-click Python script export, and a bars= overlay extension to ComputedSection kind="fiber" (blocking gate G-E).
  • User-ratified scope: palette + polygon tool, both RC lanes, JSON+script persistence, extras = apeSteel catalog picker / live properties panel / moment–curvature preview / bridge-handoff snippet. Slices B1–B7 + close-out in internal_docs/plan_section_builder_adr0080.md. Design-only PR.

FIXED — docs: schema two-version-window direction stated backwards (ADR 0023 table + downstream)

  • The Minor-row sentence in ADR 0023's bump-cadence table ("the previous minor's readers can still open the file") stated the reader window backwardsvalidate_zone_version refuses file.minor > reader.minor (INV-4: no forward tolerance) and always has; the ADR's own Decision section and the contract tests were correct all along. Corrected in place + a dated correction note appended to ADR 0023.
  • Same inverted claim fixed in the schema_version.py module docstring and in the 2.20.0 ledger comment in emitter/h5.py; it also supersedes the window sentence in the 2.20.0 CHANGELOG entry below ("2.19 readers open 2.20 files ignoring the new group" — wrong; a 2.19 reader refuses a 2.20 file loudly). Behavior and tests unchanged — documentation only.

ADDED — /opensees/computed_sections provenance sidecar (ADR 0078 Amendment A1, ratified; opensees schema 2.20.0)

  • Every emitted ComputedSection now leaves a provenance row (tag, analyzer_name, JSON payload) in a new /opensees/computed_sections sidecar (the resolved numbers already persisted through the ordinary section capture). Payload: kind, materials map, disconnected policy, part/element counts, resolved reference moduli (elastic) or GJ + fiber PGs (fiber).
  • Mechanism per the amendment: bridge-side gather at apeSees.h5() (_computed_section_records over bm.primitives, memoized analyses — no re-solve), written in the /opensees/names mold — no Emitter-Protocol widening, group only when non-empty, model_hash-excluded (a ComputedSection deck hashes identically to its hand-typed equivalent).
  • Read-side: OpenSeesModel.computed_sections() + hash-stable to_h5 round-trip; absence-tolerant reader (pre-2.20.0 files ⇒ empty).
  • opensees_schema_version 2.19.0 → 2.20.0 (additive minor, ADR 0023 window: 2.19 readers open 2.20 files ignoring the new group; a 2.18.0 stamp is now refused). Analyzer mesh not persisted; g.compose zone filtering unchanged.

ADDED — kind="fiber" lowering on ComputedSection (ADR 0078 Amendment A2, ratified)

  • ops.section.ComputedSection(analysis=sec, kind="fiber", fibers={pg: UniaxialMaterial}, GJ=None) — auto-generated section Fiber from the analyzer mesh: one fiber per Gauss point (3/tri, 9/quad) about the elastic centroid; area = w·|J| is an exact partition, so fiber sums reproduce EA/EQ/EI to quadrature precision. Materials are user-supplied per analyzer PG (exact cover, never inferred; construct via the bridge — P11); GJ=None defaults from warp.GJ and -GJ is always emitted.
  • Per-kind argument families validated at construction (kind="fiber" forbids E=/G=/ndm=; requires fibers=; geometric-only analyzers rejected); dependencies() surfaces the fiber materials for tag resolution.
  • Gate G-D passed: signed ΣEAyz = EIxy_c on a 30°-rotated rectangle (mirror-catch), openseespy zeroLengthSection moment–curvature slope = EIxx_c/EIyy_c to 1e-9 both axes, ElasticPP plateau = Mp_xx within 1 % both signs (tests/sections/test_fiber_lowering.py). Ratified decisions: fiber origin = elastic centroid (document-only, no knob); -GJ always emitted (inert under ndm=2).

CHANGED — docs: scalar V-share limitation noted on disconnected="sum" stress (post-#820 review)

  • Review follow-up to #820: docstrings (stress(), compute_unit_fields) and the skill reference now state that the Vx/Vy distribution shares are scalar per axis — exact when each part's principal axes align with x/y, approximate for in-plane-rotated parts (the per-part recovered field itself stays fully coupled through its own Ψ/Φ solves). Also restores the missing blank line before the how-to CHANGELOG heading. No behavior change.

ADDED — ADR 0078 Amendment A2 (Proposed): kind="fiber" lowering design

  • Design-only amendment for the reserved kind= axis on ComputedSection: kind="fiber" + user-supplied fibers={pg: UniaxialMaterial} (material mapping is a modeling decision, never inferred; must exactly cover the analyzer PGs) + GJ= defaulting from warp.GJ. Lowering = one FiberPoint per Gauss point of the analyzer mesh (exact area partition — fiber-sum identities are exact tests), reusing the existing Fiber primitive.
  • Blocking gate G-D defined before any implementation: fiber coordinates are the first sign-bearing values crossing the authoring→local axis mapping (G-B's standing handedness caveat) — signed EIxy fiber-sum + moment–curvature keystone + Mp⁺/Mp⁻ plateau checks on a monosymmetric section. Two open questions (fiber origin, -GJ under ndm=2) carried for ratification. No code change in this PR.

ADDED — ADR 0078 Amendment A1 (Proposed): H5 persistence design for ComputedSection

  • Design-only amendment. Discovery: the composed file already carries the resolved elastic numbers of every ComputedSection (the H5Emitter captures the emitted section Elastic line into /opensees/sections/*) — what's missing is provenance. Proposal: a /opensees/computed_sections sidecar in the /opensees/names mold (bridge-side gather at apeSees.h5(), no Emitter-Protocol widening, group written only when non-empty, model_hash-excluded), additive minor bump opensees_schema_version 2.19.0 → 2.20.0 per ADR 0023. Analyzer mesh explicitly NOT persisted; g.compose zone filtering explicitly unchanged. No code change in this PR.

ADDED — stress recovery on disconnected="sum" sections (ADR 0078 follow-up)

  • SectionProperties.stress() no longer raises on multi-part ("sum") sections: actions distribute per the ADR 0078 policy — N/Mxx/Myy via the global plane-sections composite state (common centroid, Steiner terms), Mzz to parts ∝ GJᵢ/ΣGJ, Vx/Vy ∝ the part flexural-rigidity shares (EIyyᵢ/EIxxᵢ, equal curvature); each part recovers τ from its own ω/Ψ/Φ solves.
  • plot_warping(shear_flow=True) now works under "sum" (per-part torque share).
  • Gates: global + per-part equilibrium on dissimilar twins, a node-matched two-rectangle exactness oracle vs a standalone section under the distributed loads, a two-disk closed-form torsion oracle, and a stacked-parts Steiner discriminator (tests/sections/test_stress_disconnected.py).
  • Connected-section recovery is unchanged (single-part path is arithmetically identical). Default disconnected="raise" still fails loud at warping().

ADDED — docs: how-to recipe "Compute section properties for a custom section" (ADR 0078 follow-up)

  • New docs/how-to/section-properties.md: flat-face builder → order-2 mesh → SectionProperties (geometric / warping / plastic / stress) → plot family → ComputedSection bridge handoff, plus the composite SRC recipe using the cut(remove_tool=False) + fragment_pair partition authoring.
  • Wired into the mkdocs nav (How-to ▸ Solve) and the how-to index.

CHANGED — user-level skill copy: junction retired, refresh script added

  • New scripts/refresh_user_skill.py: rebuilds ~/.claude/skills/apegmsh from the origin/main tree object (never the working tree), with --check staleness probe. Run it after merging any skill PR.
  • Rationale: the user-level path was a Windows junction into the main checkout's skills/apegmsh working tree — the served skill silently lagged whenever that checkout sat on an old branch, served uncommitted edits, and writes "to the user skill" mutated the repo tree. First run detaches the legacy junction (link only) and installs a real directory.
  • sync_skill.py docstring cross-references the split: repo-derived mirror = sync_skill.py (CI-gated); user-level copy = refresh_user_skill.py (manual, outside CI's reach).

CHANGED — skill: solution-algorithm & Newmark option notes (PR #786 catch-up)

  • opensees-bridge.md gains the typed ops.algorithm.* / ops.integrator.Newmark option notes shipped in #786 (tangent/factor_once mutual-exclusion rules, the -FactorOnce casing and upstream -intialThenCurrent typo the emitters bake in, hall-factor ordering, Newmark(form=)) — a stranded uncommitted edit from the ADR 0074 checkout, now landed; mirror re-synced.

ADDED — section-analyzer plot family (ADR 0078 addendum)

  • sec.plot_warping(shear_flow=) — Saint-Venant warping-function ω contour (per part under disconnected="sum"); shear_flow=True overlays the unit-torsion shear-stress quiver (connected sections only — raises like stress()). Circle oracle in tests: a disk's ω ≈ 0 (circles don't warp).
  • sec.plot() — one-call overview Figure: the glyphed section view beside the summary() report panel.
  • SectionStress.plot_vector(action=None) — (τ_zx, τ_zy) quiver over a light wireframe; action="mzz"/"vx"/"vy" isolates one per-action term.
  • SectionStress.plot_mohrs_circle(at=(x, y), pg=) — Mohr's circle of the beam stress state (σ_zz, τ) at the mesh node nearest at, principal stresses annotated; pg= picks the exact per-region value at material interfaces.
  • g.sections.plot_faces() — pre-mesh geometry preview: boundary outlines of every dim-2 face + auto-PG name annotations (sanity-check builder placements before meshing).
  • ADR 0078 API contract updated (post-acceptance addendum blocks); skill reference gains a "Plotting surface" table; guide updated. Headless (Agg) tests: circle no-warp oracle, rectangle-warps check, disconnected-policy behavior, pure-axial Mohr oracle (σ/2 centre/radius), per-action vector plots, overview-figure shape, builder-preview annotations.

CHANGED — apeGmsh skill: section-properties.md reference (ADR 0078 lessons)

  • New canonical-skill reference skills/apegmsh/references/section-properties.md distilling the S1–S6 runway: analyzer workflow + constructor gates, the rigidity-form naming law (incl. the reference-free ratio exemption), the composite PARTITION authoring law (cut-then-fragment; shared-lines conformal law), disconnected="raise"|"sum" semantics (lower bound, authored shear transfer via SectionMaterial(G=)), per-analysis lessons (G-weighted torsion, set_order(2), GAs_xy divergence convention, mixed-fy plastic law, stress sign conventions), the OpenSees axis contract (Ixx_c→IzAs_x/A→alphaZ, vecxz responsibility, ndm= form selection, composite reference-moduli rules), flat-face builders + catalog accuracy expectations (fillet-less J 5–15 % under AISC), inspector contract, and testing lessons (analytic oracles first; dev-only PyPI oracle; byte-equality + solve-count assertions).
  • Wired into SKILL.md (reference list + failure-routing line for SectionMeshError/CompositeSectionError/SectionAnalysisError), three section-analyzer traps added to gotchas.md, cheatsheet cross-links; derived mirror re-synced (scripts/sync_skill.py --check green, 12 files).

CHANGED — ADR 0078 Accepted: section-properties analyzer close-out

  • ADR 0078 flipped to Accepted (slices S1–S6 = #802/#803/#804/#805/#808/#810; adversarial gates G-A and G-B passed with 0 confirmed findings; G-C completeness pass clean). Follow-ups carried in the status line: disconnected="sum" stress recovery, H5 persistence of the ComputedSection declaration, kind="fiber" lowering, a docs/how-to analyzer recipe.
  • ADR field-list housekeeping to match as-shipped code: WarpingProperties gains GA / nu_eff / beta_y_± in the contract; GeometricProperties notes the rigidity-form EZ* section moduli; naming-law clarification — reference-free ratio accessors (rx/ry/r11/r22, alpha_x/alpha_y) are exempt from the composite raise (the modulus cancels).
  • New section-oracle extra (pip install -e ".[section-oracle]" → dev-only PyPI sectionproperties>=3.10) and the CI suite lane now installs it, so the analyzer's package-comparison oracle tests actually run in CI (previously importorskip'd everywhere).
  • Docs: internal_docs/guide_sections.md gains the analyzer section (flat-face builders, naming law, composite partition authoring, OpenSees handoff); the canonical skill's API cheatsheet documents SectionProperties / ComputedSection / the *_face builders (mirror re-synced).

ADDED — section-properties analyzer S6: Qt section inspector (ADR 0078)

  • sec.viewer(blocking=True) — standalone Qt + matplotlib inspector panel (sections/_inspector.py), deliberately not part of the ADR 0014/0042/0056 viewer family (no model.h5, no SceneLayer/render seam, no dispatcher). Left: the meshed section with glyph overlays (centroid / shear centre / principal axes / PG colors), switching to stress contours when a component is picked. Right: tabbed read-only property tables (Geometric / Warping / Plastic as available; composite sections gain an e_ref input driving a transformed column) + six load spinboxes (N, Vx, Vy, Mxx, Myy, Mzz) and a component picker that re-blend the precomputed unit stress fields live — no solve ever runs on the UI thread (every analysis runs in the launch path, before window construction).
  • Contract mirrors results.viewer: notebooks must pass blocking=False (%gui qt for a responsive window), Qt-absent → ImportError with install guidance, QT_QPA_PLATFORM=offscreen on Windows → RuntimeError from the launch path (ViewerWindow guard parity). Every capability stays reachable headless (summary(), plot_section(), stress(...).plot()).
  • Stress recovery unavailable (disconnected="sum") → load inputs disabled with guidance; the geometry/property views still work.
  • Tests: import/offscreen guards, blend-equals-stress() identity through the panel path, no-solve-on-UI-thread (patched-solver counter), composite e_ref column values, plastic-tab presence, offscreen screenshot smoke — no blocking event loop in any test.

CHANGED — ADR 0077 parallel modal analysis flipped to Accepted; PyMP backend parked

ADR 0077 moves Proposed → Accepted (2026-07-17): P0–P4 are implemented and live-verified (PRs #800 / #806 / #807 — Tier-0 serial gather, replicated distributed-FEAST modal_deck, eigenvalue + mode-shape harvest, to_native viewer binding). The remaining P5 cluster e2e is recorded as deployment-gated, not a design gate — the chain rides the unchanged deck-agnostic ADR 0060 path (Cluster.submit(binary=…)Job.fetchParallelModalResult.from_job) and waits only on the fork -feast build reaching the cluster. Unlock 2a (the PyMP .py backend, modal_deck(target="pymp")) is parked on demand with its rationale corrected in place: PyMP is itself a fork artifact (the deployed pyd predates FEAST), so the route buys nothing wherever the classic-Tcl exes can be deployed. Docs-only.

ADDED — section-properties analyzer S5: bridge binding + flat-face builders (ADR 0078)

  • ops.section.ComputedSection(analysis=sec, E=, G=, ndm=) — the analyzer IS the declaration: a frozen Section-base primitive holding the SectionProperties reference, resolved at emit through the single shared lowering (sections/_lowering.py) into a section Elastic line byte-identical to a hand-typed ElasticSection. Slots into Lobatto/beamIntegration, Aggregator.base_section, and every element section= field with zero consumer changes; N references to one analyzer = one (memoized) solve.
  • Axis mapping (authoring x ≡ local z, y ≡ local y): Ixx_c→Iz, Iyy_c→Iy, J→J, As_y/A→alphaY, As_x/A→alphaZ. Reference-moduli rules: geometric-only → E/G required; homogeneous → defaulted from the single material; composite → explicit reference moduli required (transformed-section EA/E, EI/E, GJ/G), fail-loud at emit naming the analyzer handle. ndm= selects the 2-D (E A Iz G alphaY) vs 3-D (E A Iz Iy G J alphaY alphaZ) ElasticSection form (default 3).
  • SectionProperties.to_elastic_section(E=, G=, ndm=) — the eager escape hatch: same lowering, returns a plain populated ElasticSection now.
  • Flat-face parametric builders on g.sections: W_face, rect_face, rect_hollow_face, pipe_face, pipe_hollow_face, angle_face, channel_face, tee_face — the solid recipes' cross-section wires minus the extrude; in-plane translate=(dx, dy) + scalar rotate (degrees), auto-PG named after label, returns an Instance.
  • FIXED — g.parts.fragment_pair / fragment_all now reap model._metadata entries for inputs OCC consumed (parity with the Model booleans); previously a builder face consumed by a later fragment tripped validate_pre_mesh with stale keys at mesh time.
  • Verified: deck byte-equality (flat + full Lobatto/forceBeamColumn deck), memoization one-solve count, AISC W14×90 catalog round trip (A/Ix/Iy/J), the SRC encased-W composite end-to-end (cut → fragment → conformal analyzer → deck line), swapped-rectangle axis refutation, 2-D vs 3-D form selection.

ADDED — parallel modal Tier-1 P4 complete: ParallelModalResult.to_native viewer binding (ADR 0077)

ParallelModalResult.to_native(path, fem) writes the harvested distributed-FEAST mode shapes as mode-kind stages in a native results H5 — the exact DomainCapture.capture_modes layout (mode_<k> / kind="mode" / eigenvalue + frequency_hz + period_s + mode_index attrs / displacement_x/y/z + rotation_x/y/z at a single time=[0.0] station) — so the existing surface consumes the distributed run with zero new viewer code: Results.from_native(path)r.modes (metadata + per-mode nodal fields) → r.viewer(). The mode_shapes.json sidecar gains an "ndm" key (emit: modal_deck passes the model ndm through eigen_feast_parallel(shape_ndm=); a sidecar without the key reads as 3-D — the only decks the first P3 rev emitted), so the column→component mapping follows the capture_modes convention exactly: displacement_* = the first min(3, ndm, ndf) shape columns, rotation_x/y/z when the deck recorded ndf >= 6 (a 2-D ndf=3 deck maps in-plane displacements only). Non-positive eigenvalues warn and write frequency_hz = period_s = 0 (same contract as capture_modes); a run dir without the P3 shape harvest fails loud. Verified live against a real serial-FEAST harvest (two-column frame): to_nativeResults.modes round-trips every displacement/rotation component exactly. Locked by 6 cases in tests/test_parallel_modal_to_native.py (ndf=6 round-trip incl. rotations, 2-D in-plane mapping, missing-ndm 3-D default, no-sidecar fail-loud, spurious-mode warn) + the extended deck-text pin. ADR 0077 P4 is complete — remaining phases are P5 (cluster e2e + fork deploy) and the on-demand 2a PyMP backend.

ADDED — section-properties analyzer S4: stress recovery + plots (ADR 0078)

  • SectionProperties.stress(N=, Vx=, Vy=, Mxx=, Myy=, M11=, M22=, Mzz=)SectionStress: linear-elastic σ_zz/τ fields as a blend of eight unit-load nodal fields computed once from the cached geometric + warping solutions — new load vectors never re-solve (the S6 inspector's live-input enabler).
  • Recovery is exact nodal evaluation (shape-function gradients at element nodes, no Gauss→node extrapolation), averaged within material regions only; get(component, pg=) returns exact per-region fields (NaN outside), the flat views take max-|value| at interface nodes. Per-action components kept (sigma_zz_mxx, tau_zy_vy, …) plus tau and von_mises.
  • Documented, equilibrium-tested sign conventions: N tension-positive, Mxx tension at +y, Myy tension at +x, M11/M22 in the principal frame, Mzz CCW.
  • Plotting: SectionStress.plot() tricontour, analyzer plot_mesh() (PG-colored wireframe) and plot_section() (centroid / shear-centre / principal-axes glyphs).
  • Stress on disconnected="sum" sections raises (documented deferral — analyze parts separately). Oracles: uniform N/A, extreme-fibre M·c/I + near-zero NA, circle Mzz·r/J boundary shear, parabolic 1.5V/A at the NA with free-edge zeros, von Mises √3·τ composition, linearity/superposition identities, composite modular-ratio jump with per-region access, headless Agg plot smoke tests.

ADDED — section-properties analyzer S3: plastic analysis (ADR 0078)

  • SectionProperties.plastic() — rigid-plastic analysis on the section mesh: plastic neutral-axis positions (centroidal x/y + principal 11/22), fy-weighted plastic moments Mp_xx/Mp_yy/Mp_11/Mp_22, and first-yield shape factors (sf = Mp/My with My = min over materials of fy·EI/(E·c) — reduces to the classic S/Z for homogeneous sections; ± fibres tracked separately).
  • Neutral axes solved exactly on the discretization as the fy-weighted median of the Gauss-point projections (the limit of the ADR's bisection, with no bracketing failure mode); Mp is second-order accurate in the mesh size.
  • Naming law: Sxx/Syy/S11/S22 divide by the single fy and raise CompositeSectionError on mixed-fy sections (the Mp_* fields ARE the capacities there). Fail-loud fy gate names the offending PGs; documented invalid for strain-softening materials. analyze() includes plastic when every material has fy.
  • Oracles: rectangle S = bh²/4 / shape factor 1.5, circle S = 4r³/3 / sf = 16/(3π), asymmetric T (NA in flange + unequal ± factors), two-fy strip hand calc, rotated-rectangle principal frame, PyPI comparison (skip-if-not-installed).

ADDED — section-properties analyzer S2: Saint-Venant warping / shear analysis (ADR 0078)

  • SectionProperties.warping() — in-process FEM solve (scipy.sparse splu, Lagrange-row regularization of the pure-Neumann system) computing GJ, shear centre (elasticity + Trefftz), warping rigidity EGamma, shear rigidities GAs_x/GAs_y/GAs_xy (+ reference-free alpha_x/alpha_y factors), effective nu_eff, and monosymmetry constants (x/y + principal 11/22, ± fibres).
  • Torsion uses the per-material G-weighted Laplacian (exact for heterogeneous shear modulus — the SectionMaterial(G=) equivalent-shear-strip override is physically meaningful); the shear functions follow the reference package's composite convention (E-weighted + nu_eff), so uniform-nu sections match the PyPI oracle 1:1.
  • Disconnected policy live: default "raise" names the component count (catches the unfragmented-touching-faces bug that would silently corrupt J); explicit disconnected="sum" solves per part (GJ = ΣGJᵢ, GJ-weighted shear centre, per-part results on WarpingProperties.parts).
  • SectionAccuracyWarning on linear elements (tri3/quad4); guidance: set_order(2).
  • Oracles: circle J = πr⁴/2, rectangle J series, As/A = 5/6 at nu = 0, thin-wall channel shear centre, G-override bound tests (strip G→0 → sum, rigid → connected), PyPI sectionproperties comparison (skip-if-not-installed).

ADDED — section-properties analyzer S1: geometric analysis (ADR 0078)

  • SectionProperties(fem, materials=, name=, disconnected=) — new analyzer broker (from apeGmsh import SectionProperties) computing modulus-weighted geometric properties of any meshed 2-D face over the shared apeGmsh.fem kernel: area, perimeter (exterior loops only), mass, elastic centroid, global/centroidal/principal second moments, section moduli, radii of gyration.
  • SectionMaterial(E, nu, G=, fy=, density=) (apeGmsh.sections) — per-PG material spec with an independent G override for equivalent shear media.
  • Rigidity-form naming law: EA/EIxx_c/… always valid; unprefixed accessors (Ixx_c, Zxx_plus, …) divide by the single modulus and raise CompositeSectionError on composites — transformed(e_ref=) is the explicit path.
  • Fail-loud input gates (SectionMeshError): 2-D-only, XY-plane, PG exact-cover. Geometric analysis is connectivity-blind (disconnected parts get common-centroid Steiner terms); the disconnected= policy gates the S2 warping solve.
  • summary() + _repr_html_ notebook affordances. Warping/plastic/stress/bridge binding follow in ADR 0078 S2–S6.

ADDED — parallel modal Tier-1 P3: mode-shape harvest for the distributed-FEAST modal deck (ADR 0077)

apeSees.modal_deck now harvests mode shapes, merge-free (the replicated deck puts ALL nodes on every rank, so an ordinary rank-0 recorder carries the full field). The emit (TclEmitter.eigen_feast_parallel(shape_nodes=, shape_ndf=)) appends a rank-0-guarded block after the captured solve: a mode_shapes.json sidecar pinning the node→column map (sorted mesh node tags × the envelope-ndf dof list — the headerless .out rows become self-describing without the deck), then one recorder Node -file mode_shape_<k>.out … "eigen k" per found mode, a single record trigger (recorders never fire on their own — no analyze step runs in this deck), and remove recorders to close the files. Recorder creation is post-solve by necessity and by design: the band's mode count is dynamic (llength $_lam), recording an unfound mode corrupts the row (NodeRecorder::record skips a node whose eigenvector matrix lacks the column without advancing its write cursor), and the source shows post-solve creation is sound (the eigen dataFlag reads Node::getEigenvectors() only at record time; Domain::addRecorder does not auto-fire); DOFs a node does not carry are cursor-safe 0.0 padding. ParallelModalResult.from_job reads the sidecar + per-mode rows when present (a pre-P3 run dir still harvests eigenvalues; the shape accessors then fail loud), surfacing mode_shape(node, mode) (length-ndf, the EigenResult convention), mode_shape_field(mode) ((n_nodes, ndf)), and shape_nodes. Live-verified on the two-column frame (fork classic-Tcl -feast build; serial OpenSees + hydra mpiexec -n 2 OpenSeesMP, LADRUNO_FEAST_MPI rank-0/1 proof): a degeneracy-broken variant (distinct tip masses 100/120/140/160 → 4 distinct modes 97.46–123.28 Hz, λ rel err 2.3e-8 vs analytic 6e7/m) gives per-mode MAC = 1.0 (9 decimals) distributed-vs-serial and distributed-vs-live-openseespy plain-eigen oracle; the stock frame's exactly-degenerate 4-fold subspace matches with principal-angle cosines all 1.0 (1-to-1 MAC is basis-dependent under exact degeneracy). Deck-text + reader tests extended to 16 (test_modal_deck_parallel_feast.py, test_parallel_modal_result.py).

CHANGED — parallel modal Tier-1 P2 live-verified; modal_deck corrected to a REPLICATED deck (ADR 0077)

The first live distributed run (fork classic-Tcl -feast build, PR nmorabowen/OpenSees#578, mpiexec -n 2 OpenSeesMP.exe) refuted the P1 deck shape: the fork's L3 FEAST requires every rank to assemble the full model (the RCI kernel slices the full 2n block system's triplets across ranks for the distributed dmumps — distribution lives inside the kernel, not in domain decomposition), so the partitioned if {[getPID]==K} deck failed FeastEigenSOE::setSize — vertex not in graph. apeSees.modal_deck now emits the model flat/replicated (partitions on the fem are ignored via the supports_partitions = False seam; RAM trade-off = full model per rank, the documented L3 regime) with a deterministic constraints Transformation / numberer RCM / system UmfPack preamble + the getPID shim; the per_rank= kwarg is gone (no partition spans exist). Also corrected: the deck's system line is NOT part of the FEAST solve path — the earlier "system Mumps is load-bearing" claim was a carry-over from the refuted plain-eigen design; a serial-UmfPack deck produced kernel-verified distributed solves on both ranks. End-to-end validation: apeGmsh-emitted modal_deck(band=(0,200), certify=True)mpiexec -n 2ParallelModalResult.from_job → 4 modes @ 123.280887 Hz, max rel err vs the analytic tip-mass oracle 9.7e-16, with LADRUNO_FEAST_MPI distribution proof on both ranks. Mode-shape harvest (P3) is now merge-free (every rank holds all nodes → rank-0 recorder). Deck-text tests rewritten to pin the flat shape (no partition blocks even for a partition-authored fem). ADR 0077 updated in place (Tier-1 section, INV-1/3/4, P1/P2/P3).

ADDED — parallel modal analysis Tier-1 P4 (partial): ParallelModalResult eigenvalue harvest (ADR 0077)

ParallelModalResult (apeGmsh.opensees.analysis.ParallelModalResult) is the eager result surface for a distributed-FEAST modal run: eigenvalues (a band output — the count is dynamic) + derived omega / freq / periods + n_modes + a certified flag. ParallelModalResult.from_job(job_dir, out="eigenvalues.out") harvests the eigenvalues from a completed modal_deck run dir — the rank-0 write-out format is pinned by TclEmitter.eigen_feast_parallel (a single whitespace-separated line of λ = ω²), so the reader is deterministic and verifiable without a live distributed run; a missing file raises FileNotFoundError. participation_factors(...) / mass_ratios raise NotImplementedError redirecting to the single-process modal_properties (upstream modalProperties is MPI-blind under partitioning; ADR 0077 INV-2), and mode_shape(...) raises pending ADR 0077 P3 (distributed mode-shape harvest — deferred until the fork classic-Tcl -feast build lets the eigenvector-recorder format be verified live). Locked by 6 unit cases (tests/opensees/unit/test_parallel_modal_result.py): whitespace-tolerant parse + derived quantities, certified passthrough, empty-band zero modes, missing-file raise, and the two fail-loud accessors.

ADDED — parallel modal analysis Tier-1 P1: apeSees.modal_deck distributed-FEAST emit (ADR 0077)

apeSees.modal_deck(path, *, band=(f_min, f_max), certify=False, target="tcl", per_rank=False, out="eigenvalues.out") emits a partitioned Tcl deck that runs band-targeted distributed FEAST under OpenSeesMP. The partitioned emit already lays down the eigen preamble (numberer ParallelPlain / system Mumps, each with a single-process fallback; system Mumps is load-bearing — a serial system silently degrades FEAST to a per-rank local solve), so the driver appends only a single captured set _lam [eigen -feast $f_min $f_max -rci [-certify]] plus a rank-0 eigenvalue write-out (via new TclEmitter.eigen_feast_parallel) — never a second [eigen …] call (which would re-run the distributed solve and deadlock on a rank-0-only collective; ADR 0077 INV-5). modalProperties is not emitted (MPI-blind upstream → wrong effective mass; INV-2). Fails loud on target="pymp" (unlock 2a, not implemented), non-partitioned models (use single-process eigen/modal_properties, Tier 0), and staged models; per_rank=True splits per ADR 0061. The band (Hz) defines the mode count — there is no num_modes. This is emit-only: the live distributed run needs the fork classic-Tcl -feast parity build (ADR 0077 unlock 2b); mode-shape harvest + the ParallelModalResult surface are P3/P4 (deferred until the recorder format can be verified live). Locked by 6 deck-text cases (tests/opensees/integration/test_modal_deck_parallel_feast.py): captured solve emitted exactly once, preamble present, write-out block, modalProperties absent, and the three fail-loud guards.

ADDED — parallel modal analysis ADR 0077 (Proposed) + Tier-0 serial-gather stopgap (P0)

ADR 0077 designs modal analysis on the partitioned / HPC path, and ships its first tier. Tier 0 (this PR): on a partition-authored model, apeSees.eigen / modal_properties already build the full, gathered model in one process (the live emitter's supports_partitions = False) and run stock serial ARPACK — so the modes (and, for modal_properties, participation factors / effective modal mass) are exact. It does not scale the eigensolve (whole model on one rank), documented as such in both docstrings. Pinned by a live regression that partitioned-serial eigen is bit-identical to the unpartitioned solve and modal_properties resolves participation on a partitioned model (tests/opensees/live/test_eigen_partitioned_serial_gather.py). Tier 1 (distributed FEAST eigen -feast … -rci, deferred): the ADR records that an originally-proposed "plain eigen over MumpsParallelSOE under OpenSeesMP.exe" path was REFUTED by adversarial review — under _PARALLEL_INTERPRETERS the eigen ArpackSOE gets no setProcessID/setChannels (parallel-eigen wiring is _PARALLEL_PROCESSING-only) so the M*v reduction stays local → per-rank-local garbage modes (the exact SP/MP non-composition the fork's FEAST line was built to resolve, and which apesees.py:4285 already fails loud on for ops.damping.modal). The correct distributed path is fork FEAST (fork ADR 43, L3-only), reachable only once the classic-Tcl -feast parse gap is unlocked (fork commands.cpp parity or a PyMP .py deck); modalProperties stays MPI-blind and is deferred + raised-on in any distributed run. Design + phased plan (P0 done; P1–P5 gated on the unlock) in architecture/decisions/0077-parallel-modal-analysis.md with a full adversarial-review appendix.

ADDED — custom-scalar mag() vector helper + definition persistence (ADR 0076 Slice 4, Accepted)

The mag(<vector>) special form and definition persistence land on the user-defined scalar feature (ADR 0076 now Accepted). results.nodes.define("speed", "mag(velocity)") expands a nodal vector family (velocity / displacement / acceleration / force / rotation / moment / … via new _vocabulary.vector_family) to the Euclidean norm of its recorded components — sqrt(vx**2 + vy**2 + vz**2), clipping to present axes so a 2-D model yields the in-plane speed with no zero-fill. It refuses a component name (mag(velocity_x)), the combined reaction shorthand (forces+moments is not one vector), and a non-bare argument (mag(velocity + 1)). Persistence: results.save_definitions() writes <results>.defs.json; Results.from_native / from_mpco / from_ladruno auto-load it at open (best-effort — a stale operand from a richer run warns and is skipped, never blocks the open; loading is idempotent). This is the same sidecar as the Slice-3 viewer transport (unified — the subprocess launch now routes through save_definitions, so it persists too) and deliberately not an embedded H5 zone, so it works for read-only .mpco as well as native files. Definitions also follow stage / mode derivation — a scalar defined on results is readable on results.stage("grav") (_derive shallow-copies the frozen ExprDef registry). Locked by 12 new cases in tests/test_custom_scalar_expressions.py (mag engine 3-D/2-D + 5 rejections + composite; save→reopen auto-load, idempotency, stale-operand warn-not-fatal, stage propagation, missing-file no-op).

ADDED — user-defined scalar expressions on Results (results.nodes.define / results.elements.gauss.define, ADR 0076)

Users can register a custom scalar as a restricted arithmetic expression over a composite's own available_components() and read it anywhere a component= is accepted — including the viewer picker. results.nodes.define("kinetic_ish", "velocity_x**2 + velocity_y**2 + displacement_x") and results.elements.gauss.define("dcr", "von_mises_stress / 250.0", units="-") become first-class components: get(component=…) computes them on read (sibling of the derived-scalar path, _compute_derived), and they surface in available_components() / definitions / the Add-Diagram picker with zero viewer-side special-casing. Registration is per-composite so the domain is implied — a node expression can't reference a Gauss field. New results/_expr.py parses the expression to a restricted AST over numpy (never eval): operators + - * / // % ** & |, comparisons, numeric literals, the operand names, and a fixed elementwise function table (sqrt abs exp log sin cos tan sign hypot minimum maximum clip where). Adversarial-review hardening folded into ADR 0076: and/or/if-else are rejected, not lowered (they dispatch on array truthiness → ambiguous-truth ValueError; use where(cond,a,b) and & / |); min/max are not exposed (numpy's are field-collapsing reductions, Python's are variadic) — only the 2-arg elementwise minimum/maximum. Fail-loud at define() for parse errors, unknown operands (validated against the union of all stages), name shadowing (stored / derived / already-registered), and zero-operand expressions; the two inherently read-time failures — an operand absent from the specific requested stage, and operand coverage mismatch across independently-recorded fields — raise a legible ExprError at read rather than a raw numpy broadcast error. undefine() refuses to strand a dependent. Definitions reach the subprocess viewer through a dedicated <results>.defs.json sidecar + --defs arg (never the diagram viewer-session.json, which is snapshot-gated and overwritten on close); the in-process viewer() / show_web() share the live registries directly. Scalar-only operands, session-lifetime registration, and no unit/NaN policing are explicit v1 boundaries (mag() vector helper + H5 persistence deferred). Locked by tests/test_custom_scalar_expressions.py (34 cases: engine grammar/rejection/shape-guard, node + Gauss compose-on-derived + custom-on-custom + shadow/undefine guards, payload round-trip + argv --defs + __main__ apply/malformed-survival).

FIXED — ADR 0075 adversarial-review hardening (36-agent gate over the modal-family stack)

A 6-lens find + 3-skeptic-per-finding verify workflow over the full slice 1–5 diff confirmed 9 findings (1 refuted); all are fixed here. Majors: (1) negative damping ratios now refused in _damping_channel_args (damp < 0, any modal_damp entry < 0; ξ = 0 stays legal) — the fork refuses ξ < 0 on four of five family parsers but NOT on responseSpectrumAnalysis, where a single typo'd sign in a CQC modal_damp list makes ρ_ij = √(ξ_i·ξ_j) NaN and the combination kernel's sqrt(s > 0 ? s : 0) silently commits an all-zero design displacement field; (2) wrong-kind object handles are now TypeError-refused at all four excitation resolution sites (base_accel=/series=/load=/input_psd=) — _resolve kind-checks only name-string refs, and per-kind 1-based tag counters meant e.g. a Plain pattern passed as base_accel= emitted a numerically-colliding timeSeries tag and ran a plausible-looking transient with the wrong ground motion; (3) the two modal-history live equality oracles moved from dt=2e-4 to dt=5e-5 (n=1600) — two independent verifier rebuilds of the fork's exact PWL recurrence vs Newmark showed the reference's own O(dt²) error is ~1.15 % at the old dt, above the 1 % assert, so both tests would have failed deterministically against correct code the moment the fork rebuild unskips them. Minors: RSA now accepts a leading T = 0 PGA-anchored spectrum (the fork refuses only negative Tn and clamps T ≤ Tn[0] to Sa[0]; the old message misattributed the stricter check to "the OpenSees -Tn contract" — this supersedes the slice-2 section's "positive strictly-increasing" wording); ModalPropertiesResult documents the unorm=True basis mismatch (participation factors live in the displacement-normalized basis, mode_shape always returns the RAW domain eigenvector — Γ·φ products are scale-consistent only under the default unorm=False); ADR 0075 gains the classic-Tcl -feast parse-gap caveat, scopes the -out passthrough to the three sweep drivers (complex_eigen has none), and corrects the damping contract to at-most-one-of with the RSA exception (optional channel, no rayleigh=). Locked by 7 new unit cases (negative/mixed-sign/zero damping, 3 wrong-kind-handle refusals, negative-period) + a live T=0-anchor acceptance test.

ADDED — complex/state-space modal analysis: apeSees.complex_eigen + ComplexEigenResult (ADR 0075, slice 5 of 5 — family complete)

The fork ADR-46 complexEigen lands as a live-only tier-2 driver — the answer to "what is the damping ratio of the isolation mode?" for non-classically damped models (localized dashpots, bearings, radiation damping). apeSees.complex_eigen(num_modes, *, solver=, tol=None, closed_form=False) builds a fresh live domain, runs the real eigen projection basis, then complexEigen, and parses the fork's flat 7-per-mode return into a frozen ComplexEigenResult (omega0/omega_d/zeta/complex lam/kind 0-under/1-over/2-rigid/resid quality metric + freq_d). Default = the assembled Route B projection (element-by-element getDamp()/getMass() — exactly the C a transient feels: scoped region -rayleigh, betaKinit/betaKcomm, material dampers, -doRayleigh switches all honored); closed_form=True = the fast global-Rayleigh diagonal Route A. Contract traps documented on the driver (fork guide): getDamp()-invisible damping (modalDamping, HHT-α numerical, -doRayleigh-default-OFF Truss/zeroLength families) is invisible; complex mode shapes are recorded via the existing Node-recorder raw=("complexEigenRe<k>",)/Im<k> escape hatch, not carried on the result; a declared constraints Plain triggers a UserWarning (MP-constrained models need a distributing handler for the shape push — guide trap #4). Live gated on the complexEigen attribute with the friendly fork-required error. Locked by from_flat parsing pins + validation cases + the Plain-handler warning pin, and live oracles (tests/opensees/live/test_complex_eigen_live.py): classical-Rayleigh ζ_k = a0/(2ω_k)+a1·ω_k/2 and ω_d = ω₀√(1−ζ²) on both routes at 1e-6, ω₀ echoes the real eigen basis, friendly error pinned on the deployed pre-ADR-46 build. This closes the ADR 0075 five-slice runway.

ADDED — FEAST band-targeted eigen: apeSees.eigen_feast (ADR 0075, slice 4 of 5)

The fork ADR-43 band-targeted FEAST eigensolver joins the Emitter Protocol as a separate method eigen_feast(f_min, f_max, *, certify=False)eigen -feast $fmin $fmax [-certify] returns all modes with natural frequency in [f_min, f_max] Hz, so the mode count is an output (len(result.eigenvalues)), which breaks eigen's num_modes contract and rules out a solver-flag overload. certify=True adds the fork's Sturm/inertia completeness certificate (LDLᵀ inertia at the band edges; the solve refuses on a count mismatch with FEAST). The bridge driver reuses EigenResult unchanged (lazy mode_shape, possibly zero modes on an empty band) and — because the stock ops.eigen symbol exists on every build so a missing-attribute gate can't fire — pre-checks capabilities().has_fork for the friendly fork-required message (a pre-ADR-43 fork build still fails loud with the OpenSees -feast parse error). Deck note: the fork wires -feast into the interpreter/openseespy parser only — classic OpenSees.exe/OpenSeesMP.exe decks do not parse it yet, so the deck target is openseespy decks (documented in the Protocol + ADR 0075). Live oracle (tests/opensees/live/test_eigen_feast_live.py): band solve == -fullGenLapack spectrum filtered to the band, with -certify on; skips on pre-modal-family builds via the modalResponseHistory proxy (ADR-43 ships before ADR-44 on the fork timeline).

ADDED — frequency-domain sweep drivers: apeSees.frequency_response / steady_state_dynamics / random_response (ADR 0075, slice 3 of 5)

The fork ADR-44 sweep commands land as live-only bridge drivers (ADR 0075 tier 2 — the value IS the interpreter return, so no Emitter-Protocol widening; the live emitter gates each on its own fork-only openseespy attribute with the friendly fork-required error). All three share a validated marshaller: f_min/f_max/n_freq band, grid="lin"|"log"|"biased" (biased clusters points around in-band modal peaks), excitation XOR (base_accel_dir= — harmonic base acceleration, no timeSeries, relative response — vs load= nodal-force pattern with the fork-refusal pre-checks), the exactly-one-of damping channel, node/dof/resp("disp"|"vel"|"accel")/modes, and out= ASCII passthrough; each auto-issues eigen + modalProperties on a fresh live domain. frequency_response returns an eager FrequencyResponseResult (Hz grid + complex FRF + magnitude/phase; e^{+iΩt} sign convention); steady_state_dynamics a SteadyStateResult; random_response (PSD→RMS, ADR-44 P3) takes the one-sided G(f)-in-Hz input_psd= timeSeries (defaults grid="biased" per the guide — a linear grid mis-integrates sharp resonances), optional stats=/duration= (Davenport expected peak), and returns a RandomResponseResult normalizing the fork's scalar-vs-list return shapes. Locked by 9 new validation cases + live SDOF closed-form oracles (tests/opensees/live/test_modal_sweeps_live.py): FRF magnitude AND phase-sign vs H(Ω) = −1/(ω²−Ω²+2iξωΩ), load-channel static limit P/k, SSD == |FRF|, white-noise anchor σ_x²=G0/(8ξω³) at 2 % (a √2 miss flags a one-/two-sided mixup), stats/duration shape + ν₀≈f_n, zero-damped in-band refusal, and the friendly fork error pinned on pre-ADR-44 builds (the currently-exercised path; oracles unskip after a fork rebuild).

ADDED — modal-response committing commands: apeSees.modal_response_history + response_spectrum_analysis -combine (ADR 0075, slice 2 of 5)

The two fork ADR-44 commands that commit domain state join the Emitter Protocol (deck-emitting — Tcl/py write the single command line; H5 no-ops; recording captures) and get bridge drivers. apeSees.modal_response_history(dt=, n_steps=, num_modes=, …) runs the fork's exact piecewise-linear modal-superposition transient (modalResponseHistory, Ladruno ADR-44 P1a): auto-issues eigen + modalProperties on a fresh live domain, commits one step per station so ordinary recorders capture the history, and returns a ModalHistoryResult (mode basis + final-station node_disp/vel/accel). apeSees.response_spectrum_analysis(direction, periods=, accels=, combine=, num_modes=, …) runs the list-form RSA with the fork's native combination stage (-combine SRSS|CQC|ABS|TenPercent, ADR-44 P1b) and returns a ResponseSpectrumResult whose node_disp reads the committed combined design displacement field (per-quantity combination caveat documented — never derive element forces from combined displacements). Excitation channels resolve dual-mode handles (base_accel=+direction= ground motion, or load=+series= nodal-force pattern — fork-refused pattern contents (sp constraints, moment tensors) pre-checked at the bridge with a friendly BridgeError); damping is the explicit exactly-one-of channel (damp=/rayleigh=/modal_damp=); CQC requires damping; periods must be positive strictly-increasing; staged models refused. Live gating rides the modalResponseHistory attribute probe for BOTH commands — the upstream responseSpectrumAnalysis parser silently ignores unknown flags, so an ungated -combine on a pre-ADR-44 build would commit per-mode displacements with no combination. Locked by a 15-case bridge validation matrix + emit-text pins + live oracles (tests/opensees/live/test_modal_response_live.py): modal transient == direct Newmark under alphaM-only Rayleigh on both excitation channels, SRSS == numpy Γ·Sa/ω²·φ hand-oracle, and the friendly fork-required error pinned on pre-ADR-44 builds (which is what the currently-deployed 2026-06-25 build exercises; the equality oracles unskip after a fork rebuild).

ADDED — modalProperties surface: apeSees.modal_properties + ModalPropertiesResult (ADR 0075, slice 1 of 5)

First slice of the Ladruno modal-family consumption (ADR 0075; fork ADRs 43/44/45/46). The Emitter Protocol gains modal_properties(*, unorm=False, out=None) — upstream OpenSees modalProperties (Petracca's DomainModalProperties), the prerequisite state for every fork modal-response command: the live emitter passes -return and hands back the properties dict; Tcl/py emit modalProperties [-unorm] [-file $out]; H5 no-ops (runtime retrieval, no schema bump); recording captures. A new bridge driver apeSees.modal_properties(num_modes, *, solver=..., unorm=False) runs eigenmodalProperties -return on a fresh live domain (no analysis chain needed, staged models refused) and returns a frozen ModalPropertiesResult: eigenvalues + derived omega/freq/periods, the raw properties dict, component-keyed accessors (participation_factors("MX"), mass_ratios, cumulative_mass_ratios — percent, components MX/MY/MZ/RMX/RMY/RMZ, 2-D: MX/MY/RMZ), total_mass / center_of_mass, and the lazy mode_shape(node, mode) reader (EigenResult staleness contract). Also lands the shared _damping_channel_args exactly-one-of validator (damp= | rayleigh=(a0,a1) | modal_damp=[ξ…] → verbatim fork flags) for the upcoming ADR-44 drivers, and a pin that a registered standalone timeSeries emits without any pattern referencing it (the -baseAccel/-inputPSD excitation channels rely on this). Locked by tests/opensees/unit/test_apesees_modal_validation.py + emitter emit-text pins + tests/opensees/live/test_modal_properties_live.py (tip-mass cantilever: ~100 % MX mass in mode 1, Γ₁·φ_tip,x = 1 hand identity — runs on stock openseespy, no fork marker).

FIXED — LadrunoUP (ADR 0074) adversarial-review hardening: DOF-aliasing guard, etype legality, solver-gate rescope, replay bracket, silent-capture warning

Ten findings from an adversarial review of the LadrunoUP emission runway, most closing silent-wrong-results paths the count-based gates missed. Rotation-vs-pressure DOF aliasing (new guard) — a 2-D frame element (elasticBeamColumn &c., floor ndm+1) sharing a saturated equal-order LadrunoUP carrier node put its rotation DOF in the pore-pressure slot (ndm+1); both require ndf=ndm+1, so infer_node_ndf and the disjoint-set guard both passed and the fork setDomain checks only the DOF count — the run assembled bending stiffness into the pressure row with rc=0. validate_ladruno_up_pressure_dof now fails loud (pure-translation neighbours like trusses, and TH mid-edge shares, are correctly untouched) with the ADR-0069 separate-node fix. Shape legality is now by Gmsh ETYPE, not node count — an 8-node serendipity quad8 surface in a 3-D model (or a 3-node line3 curve in 2-D) aliased a legal count (8≈H8 / 3≈T3) and emitted a degenerate element that singularized at run; the legality pass reads each element-group's true etype (also vectorized — the per-element straight-side loop became column-slice array ops, ~24× faster at 100k tet10, and equal-order pgs skip per-row boxing). Solver gate (D4) rescoped to what the deck emits-and-solves: a declared symmetric/diagonal system is still refused unconditionally (a model-only Tcl export catches ProfileSPD), but a missing system only raises for solve-bearing emits — so H5 archival, eigen-only, and model-only-skeleton exports are no longer falsely refused; staged decks validate each stage's own system (a system=None stage that analyzes now raises; a stray never-emitted global system no longer false-rejects); a partitioned deck with no system rides the ADR-0027 auto-emitted general Mumps/UmfPack. body= bypassed the double-count guard — LadrunoUP names its always-on self-weight body (accelerations), so validate_body_force_double_count (which grepped body_force) never warned on a from_model gravity overlap; it now reads either attribute (direction-only collinearity, so the unit difference is moot). H5 replay lost the builder bracket — the compose / from_h5 replay re-emitted element lines after one global model(ndm,ndf), so a mixed-envelope equal-order LadrunoUP archive replayed into a deck the fork parser refuses (ndf != ndm+1); replay now brackets gated element runs (coalesced, envelope-restored) in both the flat and staged paths. Gauss capture no longer silently dropsLadrunoUP is has_gauss=True with no RESPONSE_CATALOG layout, so a gauss recorder on a u-p pg captured nothing with zero signal; DomainCapture now emits a consolidated warning (mirroring the line-station path) steering the user to the node / .ladruno pressure channels. Per-TYPE live fork verification — the fork-only-element gate keyed on a single boolean, so a build knowing LadrunoQuad would wave an unknown LadrunoUP through unverified; it now tracks verified types in a set. Single-source shape tables — the shape / mid-edge / builder-ndf tables (previously duplicated across element/solid.py, _internal/build.py, and the registry) collapse into _element_capabilities (mid-edge order cross-referenced to apeGmsh._basis); _BUILDER_NDF_GATED["LadrunoUP"] references the registry's ndf_required instead of a second copy. New coverage: tests/opensees/unit/test_ladruno_up_replay_and_forkgate.py (replay bracket + per-type gate) and tests/results/test_gauss_skip_warning.py, plus DOF-aliasing / etype-legality / rescoped-solver cases in test_ladruno_up_build_gates.py and a model-only-export-allowed case + body-double-count case in the integration suites.

ADDED — LadrunoUP Biot u-p porous element emission (ADR 0074): typed class, per-slot ndf inference, straight-side + solver build gates

The fork's unified saturated-porous continuum (element LadrunoUP, ELE 33017 — honest pore-pressure DOF at slot ndm+1) enters the normal mesh→snapshot→deck pipeline. D1 — one typed class, shape from the mesh: ops.element.LadrunoUP(pg=, material=, Kf=, poro=, rhoF=, perm=|permH=+gammaW=, thick=, alpha=, Ks=, body=, fluidBody=, formulation=, lumped=, stab=, dynSeepage=, geom=) fans tri3→T3 / quad4→Q4 / hexa8→H8 (equal-order) and tri6→Bézier T6 / tet10→Bézier Tet10 (Taylor–Hood; -pOrder linear appended automatically — the user never types it). Kwarg policy is pass-through: None is never emitted, the fork parser's defaults stay the single source of truth; construction mirrors the parser's fatality matrix (perm XOR permH/gammaW pairing, 0<n≤α≤1 storage police, thick-is-2D-only, the -stab grammar incl. ('auto', α0), dynSeepage on/off, geom='linear' only). D2 — heterogeneous intra-element ndf: _ElemSpec gains ndf_floor_per_slot (keyed by fan-out node count) and infer_node_ndf resolves per node-slot — TH vertices carry ndf=ndm+1, mid-edge nodes ndf=ndm, validated STRICTLY ({floor} ok-sets; a beam grabbing a mid-edge node fails loud) — so the hand-written two-step model(ndf=…) dance disappears into inference and the -ndf tokens emit/elide against either envelope choice. Fully additive: every existing element keeps the scalar path. The equal-order builder gate (parser demands builder ndf=ndm+1) rides _BUILDER_NDF_GATED with a new ndm-dependent form (element_builder_ndf(cls, ndm)). D3 — straight-side pre-validation: tri6/tet10 pgs are checked mid-edge-vs-midpoint at the fork's own 1e-6·edge tolerance at build, converting the fork's setDomain deactivate-and-singularize (a cryptic analyze() failure from openseespy) into a BridgeError naming element/node/offset + the Gmsh high-order-optimization hint. D4 — solver build gate: a LadrunoUP deck whose system is missing or symmetric-storage/diagonal ({Band,Profile,SProfile,ParallelProfile}SPD, SparseSYM, (MPI)Diagonal) refuses to build — the honest-p tangent is unsymmetric and the no-system default ProfileSPD silently drops one coupling block (measured p≈1e88 with rc=0, fork guide §2); allow-list UmfPack/SparseGeneral/FullGeneral/BandGeneral/Mumps, checked per stage on staged decks (wipeAnalysis re-defaults each stage), deliberately no escape hatch. Also: stab= on a TH pg and non-provider cells (tet4/prism) fail loud with pg context. D5: LadrunoUP joins _FORK_ONLY_ELEMENTS (deck emission anywhere; live run fails loud on stock builds) and ops.capabilities() gains has_ladruno_up; persistence rides the standard bridge-zone payload (no schema change). D6: fix/sp/mass/load records already validate against the RESOLVED per-node ndf — locked for TH meshes (a 3-slot mask on a 2-dof mid-edge node fails loud; the leading-u mask passes) — and the BC/recorder idioms (drained slot, staged-head Penalty-not-Transformation, NormDispIncr, disp-channel p read-back) are documented on the class. Locked by tests/opensees/unit/primitives/test_elements_ladruno_up.py (construction matrix + kwarg→token mapping + capability surface), tests/opensees/unit/test_ladruno_up_build_gates.py (per-slot inference, legality/straight-side pass, solver gate), and tests/opensees/integration/test_ladruno_up_emission.py (real Gmsh Q4/tri6/tet10 decks — auto -pOrder, -ndf emission/elision both envelopes, builder bracket, curved-disk refusal, G3 mask ergonomics, plus a fork-gated live mini-Terzaghi with monotone base-p decay).

ADDED — scalar-bar orientation + size control (results-viewer color bar): style knobs, live setters, settings-tab controls

The diagram scalar bar (color legend) is now user-controllable end to end. Style: every scalar-bar-bearing diagram style (ContourStyle, VectorGlyphStyle, GaussMarkerStyle, SandStyle, FiberSectionStyle, LayerStackStyle) gains scalar_bar_vertical: Optional[bool] = None (bar orientation; None = the viewer theme's default, horizontal) and scalar_bar_scale: float = 1.0 (on-screen size multiplier). Runtime: ScalarBarSupport gains set_scalar_bar_vertical() / set_scalar_bar_scale() live setters following the set_fmt runtime-override pattern, plus a shared _make_scalar_bar_spec() builder that replaces the seven copy-pasted inline ScalarBarSpec constructions — so orientation/size/fmt overrides now all survive show toggles AND colormap changes on every diagram. IR + backend: ScalarBarSpec gains vertical/size; PyVistaQtBackend derives width/height from the theme's per-orientation base dimensions × size (clamped to the viewport). Widget fix folded in: pyvista's interactive=True vtkScalarBarWidget re-derives its own geometry on enable — vertical bars snapped to the representation's stock 0.17 × 0.8 box regardless of the requested size — so the backend now re-asserts the requested layout on both the actor and the widget representation after creation; the bar stays freely draggable/resizable in the scene afterwards. UI: the settings-tab scalar-bar block (shared by contour / fiber / layer / gauss / vector-glyph / sand cards) gains an Orientation combo (Horizontal/Vertical) and a Size × spinner (0.2–4.0), staged via Apply like Show/Format. Locked by 4 new tests in tests/viewers/test_contour_scalar_bar.py (live orientation flip, size resize + viewport clamp, overrides survive show toggle, style knobs apply at attach).

ADDED — streamed run logging + live progress for ops.tcl(run=True) / ops.py(run=True)

The two subprocess run paths previously called subprocess.run(capture_output=True), which swallowed all OpenSees terminal output on success and, on a non-zero exit, dumped the entire stdout+stderr into one RuntimeError string — no log file, no "it started", no progress. Both now stream through a new apeGmsh.opensees._run.stream_run() helper (Popen, line-by-line tee). The full raw output is always written to a log filelog= overrides the path, otherwise <deck>.log next to the deck (out/model.tclout/model.log); verbatim, flushed live (tail-able), and complete even on a crash. Console output is opt-in via a verbose boolean: False (default) prints three lines only — a begin banner (>> OpenSees | analyze N x dt=… -> deck), the log path, and an [OK] … (exit 0) / [FAIL] exited K … -> see <log> end line; True additionally renders a live step k/N pct% t=… elapsed …s counter plus streamed [warn] lines and a warning tally. On a non-zero exit the raised RuntimeError now carries only the last 15 log lines + the log path, never the whole buffer. The live counter is fed by a throttled APEGMSH_PROGRESS i=.. n=.. t=.. marker (~20 samples over the run, plus the final increment) that the Tcl/Py emitters inject into both the plain and strategy-ladder analyze loops — gated by a new progress=True kwarg (default on; a bare emitter stays marker-free, so decks not driven through run= are unchanged), with flush stdout / flush=True so it streams live through the pipe. The py(run=True) child runs under PYTHONUNBUFFERED=1 so its stdout isn't block-buffered until exit. Console markers are ASCII (>> / [OK] / [FAIL] / [warn]) — Windows cp1252 terminals choke on unicode. Scope: the subprocess paths only; in-process run() / analyze() (which need fd-level redirection) and the already-fetched run_remote() SLURM logs are unchanged. subprocess import dropped from apesees.py (now owned by _run.py). Locked by tests/opensees/unit/test_analyze_progress_markers.py (marker default-off, cadence, shape+flush, strategy branch, tcl+py) and tests/opensees/unit/test_run_streaming.py (log-always on success+failure, minimal vs full console, tail-only exception, helpers) — 15 tests, driven with a throwaway python subprocess so no OpenSees binary is needed in CI; also run-verified end-to-end against real openseespy (live counter on success, tail+log on failure).

ADDED — plastic-strain tensor + derived invariants (plastic_strain_*)

Extends the derived layer to the plastic-strain tensor: a new 6-component Voigt family plastic_strain_xx … plastic_strain_xz (engineering shear, like the total strain) that plasticity materials emit, plus computed invariants on results.elements.gaussequivalent_plastic_strain_current (√(2/3·eᵖ:eᵖ), the current equivalent), volumetric_plastic_strain, j2_plastic_strain, max_shear_plastic_strain, principal_plastic_strain_1/2/3. The plastic_strain shorthand expands DOF-aware (3 comps in 2-D, 6 in 3-D). equivalent_plastic_strain_current is kept distinct from the material-emitted accumulated PEEQ equivalent_plastic_strain (monotonic history) — they are different quantities. Consumed the same compute-on-read way across all three readers: (1) the self-describing native reader surfaces plastic_strain_* for free; (2) MPCO records the whole-mesh per-GP tensor + PEEQ today via the material-response family (-E material.plasticStrain / -E material.plasticStrainEq, one recorder line each) — the material-state read path now recognises the plasticStrain parent token and names its per-GP components positionally by Voigt order (6 → 3-D, 3 → plane), and plasticStrainEq is added as an alias of equivalentPlasticStrain; (3) the .ladruno reader's continuum_canonical maps epsp11/epsp_xx and the ASDPlasticMaterial3D pstrain11/pstrain_xx spellings → plastic_strain_*, plus the scalar PEEQ labels eqpstrain / ebarP / equivalentPlasticStrain / plasticStrainEqequivalent_plastic_strain (the fork still needs an element-side gauss forward for .ladruno — the materials expose the response, no element tags it yet). Also FIXED in this change: continuum_canonical only mapped sigma|eta digit stems, but the Ladruno solids tag total strains eps11..eps13 — gauss strain reads from a LadrunoBrick .ladruno silently returned empty; eps/epsilon digit forms now map to strain_*. The elastic ν out-of-plane recovery is not applied to the plastic-strain tensor (plastic out-of-plane follows a material-dependent rule, e.g. incompressibility for J2) — εᵖ_zz stays as stored (0 in 2-D unless recorded). PLASTIC_STRAIN added to the gauss recorder/capture allow-lists. Locked by analytic tests (uniaxial, engineering-shear halving, volume-preserving equivalent) + a native-file composite test.

ADDED — derived-quantity Phase 5: advanced invariants, ν-aware plane-strain, shell von Mises, principal-direction glyphs

Extends the derived-scalar layer (below) with four features. Advanced invariants (results.elements.gauss.get): j3_stress (third deviatoric invariant det s), lode_angle (degrees, [-30,30], NaN under hydrostatic) and stress_triaxiality (σ_mean/σ_vM, NaN where σ_vM≈0) — for pressure-sensitive (Mohr-Coulomb / Drucker-Prager) constitutive interpretation. ν-aware plane-strain, per-element auto-detect (default plane="auto"): on 2-D data the out-of-plane component is now recovered per element from the model — results/_plane_recovery.py reads each element's plane type (a positional/flag arg on the ElementRecord: quad/tri31/tri6n/BezierTri6args[1], LadrunoQuad/CST-type flag) and its material ν (ElasticIsotropic params[1], K/G materials via ν=(3K−2G)/(2(3K+G)), ASDPlastic via the PoissonsRatio token) and synthesizes a stress_zz/strain_zz column: plane-strain → σ_zz = ν(σ_xx+σ_yy) (ε_zz = 0), plane-stress → σ_zz = 0 (ε_zz = -ν/(1-ν)(ε_xx+ε_yy)). A model mixing both is handled correctly. Falls back to zero (plane stress) where the model can't be parsed (e.g. a synthetic file with no /opensees zone), so existing behaviour is preserved when there's nothing to read. Overrides: plane="strain"/"stress" force the idealization globally (nu= required), plane=None disables recovery entirely. Because the synthesized column is injected into the tensor the derived math already assembles, a file that stores a real stress_zz (a fork recording the material's σ33) is used verbatim and this reconstruction is skipped — exact even for nonlinear plane-strain. Consuming a recorded σ_zz: the two self-describing readers surface it with no catalog change — the native reader keys gauss components by dataset name, and the .ladruno reader's continuum_canonical maps sigma33 (digit) / sigma_zz (axis), now case-insensitively, → stress_zz; since base_components_for reads all six stress_*, a recorded 4th component drops straight into the tensor. The MPCO/STKO reader (catalog-locked) is now width-adaptive too: _maybe_extend_plane_layout extends a 3-component plane stress/strain bucket to a 4-component [σxx, σyy, σxy, σzz] layout when the stored NUM_COLUMNS is exactly one component/GP wider — 3-component .mpco files read unchanged, 4-component ones surface stress_zz in available_components and the invariants. The NaN-aware recovery fills only the Gauss points where a fork emits a NaN sentinel (material couldn't supply σ_zz), keeping recorded finite values. Exactness: plane-stress (σ_zz=0, any material) and elastic plane-strain are exact; nonlinear plane-strain is an elastic estimate unless σ33 is recorded, in which case it's exact. Shell von Mises (von_mises_shell): recovers the extreme-fibre in-plane surface stress from the stored membrane-force / bending-moment resultants (σ = N/t ± 6M/t²) and returns the through-thickness envelope (max of top / bottom surface von Mises) — the worst-case design demand; needs the shell thickness via get(component="von_mises_shell", thickness=t) (explicit, not read from the section). Advertised in available_components() only when the six in-plane resultants are stored. Principal-direction glyphs — a new gauss-topology viewer diagram (kind="principal_glyph", "Principal directions (arrows)"): three arrows per Gauss point along the principal directions of the stress (or strain) tensor, each scaled by its principal magnitude and coloured by the signed principal value on a diverging map (compression ↔ tension), so stress flow (struts / ties, principal orientation around openings) reads at a glance. Reuses GaussSlab.global_coords for world placement and the arrow GlyphLayer for rendering; deform-follows the substrate; per-principal toggles (show_p1/2/3); auto-registers in the Add-Diagram dialog / catalog / session / presets (ADR 0058 S0). PrincipalGlyphStyle carries family (stress/strain), scale, cmap, and the plane/nu out-of-plane knobs. New pure helper results/_derived.principal_frame (values + eigenvectors). Locked by additions to tests/test_derived_scalars.py / tests/test_derived_scalars_composite.py (Lode/triaxiality/J3 analytic states, plane-strain σ_zz recovery + ν-required guard, shell membrane/bending/envelope recovery, principal-frame eigenvector directions) and the new tests/viewers/test_principal_glyph_diagram.py (registration, 3-arrows-per-GP layer, per-principal toggle, signed colouring, per-step update + deform-follow, plane-strain style, no-tensor NoDataError).

ADDED — derived stress/strain scalars (von Mises, principal, Tresca, J2, invariants) computed on read

results.elements.gauss.get(component=...) now serves derived scalars computed from the stored 6-component Voigt tensor instead of only what the recorder wrote — no new storage, and it works retroactively on any existing native / .mpco / .ladruno file (all three funnel through the one gauss composite). Stress: von_mises_stress, tresca_stress, j2_stress, max_shear_stress, mean_stress (I₁/3, tension-positive) and pressure_hydrostatic (−I₁/3, compression-positive) as two explicit sign conventions, plus principal_stress_1/2/3 (σ₁≥σ₂≥σ₃ via batched eigvalsh). Strain: von_mises_strain (equivalent), volumetric_strain, j2_strain, max_shear_strain, principal_strain_1/2/3. The strain tensor is assembled with the engineering→tensor shear halving (OpenSees reports γ_xy=2ε_xy) — without it every principal strain is wrong. The compute layer is a new pure-numpy module results/_derived.py (vectorized over (T, N)); the composite reads the base columns for the same selection and returns a synthesized GaussSlab. Derived names auto-surface in the Add-Diagram picker and plot.contour(topology="gauss") because gauss.available_components() advertises them — but only when a complete tensor is stored (the in-plane trio {xx,yy,xy} at minimum, covering genuine 2-D plane files and full 3-D; a partial tensor is not advertised, since an invariant off it would silently zero-fill). Requesting a derived scalar when the raw tensor was never recorded fails loud. 2-D uses a plane-stress default (out-of-plane σ_zz=0); the ν-aware plane-strain path and principal directions are deferred. Vocabulary split into DERIVED_STRESS_SCALARS / DERIVED_STRAIN_SCALARS (DERIVED_SCALARS kept as a back-compat union); recorder emit routes each derived name to the raw stresses/strains token. Locked by tests/test_derived_scalars.py (analytic uniaxial / pure-shear / hydrostatic states + the engineering-shear correction + the completeness rule) and tests/test_derived_scalars_composite.py (compute-on-read parity vs base columns, principal ordering, missing-tensor guard, 2-D plane default).

ADDED — point-cloud dot-size control (fiber section + sand): style knob, live setter, settings-tab spinner

Dot size on the two point-cloud diagrams is now user-controllable end to end. Style: FiberSectionStyle gains a real point_size: float = 10.0 (screen-space pixels) — its point_size_fraction was a dead knob (documented as "dot radius as a fraction of the model diagonal" but never read anywhere; leftover from a world-sized-sphere design); it stays on the dataclass, documented DEPRECATED, solely so sessions saved before point_size existed still deserialize (style_cls(**data) would otherwise drop the spec). SandStyle.point_size already existed. Runtime: both diagrams gain set_point_size() / current_point_size() following the vector-glyph set_scale runtime-override pattern (frozen style untouched; layer re-emitted live). Backend: the in-place update_layer fast path now pushes layer.point_size onto the actor property — point size lives on the actor, not the dataset, so live size changes on point-cloud layers were silently dropped by the cheap-animation path. UI: a shared point-cloud settings panel (dot-size spinner + the standard color panel) — fiber_section moves from the bare color panel onto it, and sand gets its first settings card (it previously fell through to "No settings UI for kind 'sand' yet"). Locked by 5 new tests (style flow-through, live setter at the layer and at the actor property for both kinds, legacy-session dict without point_size restores).

FIXED — fiber-section dot cloud invisible on GL stacks where sphere billboards draw nothing

FiberSectionDiagram's 3-D dot cloud rendered its vertex-cell MeshLayer with render_points_as_spheres=True. On some GL stacks (verified 2026-07-07 on Windows, both pv.Plotter(off_screen=True) and an on-screen window) that flag draws zero pixels — a 2000-point cloud renders 36k+ pixels as flat points and literally nothing as billboards — so the fiber overlay was completely invisible in the results viewer while the scalar bar still appeared (render-reproduced with the test_fiber_diagram.py fixture: beam line + bar, no dots). Switched to flat GL points (same call already carries point_size=10.0), matching the sand diagram's convention; dots render everywhere now (48/48 fixture fibers visible in the after-shot). The layer-contract test pins render_points_as_spheres is False with the rationale.

ADDED — sand volume plot: field-colored grain cloud inside solid elements (new "sand" diagram kind)

Surface contours only show a solid's field on its skin; the interior is invisible without clipping. The new SandDiagram (kind="sand", "Sand volume plot" in the Add Diagram dialog) fills every 3-D element with small "sand" grains — random interior points colored by the nodal component interpolated at each grain — so a stress bulb, plastic zone, or propagating wave front reads at a glance through the volume. Grains are allocated proportionally to element volume (uniform spatial density regardless of mesh grading; |J|-at-centroid × parent volume via the shared shape-function catalog) and placed by uniform parent-domain sampling (tet4/10 Dirichlet simplex, hex8/20/27 cube, wedge6 tri×line; unsupported 3-D types skip LOUDLY via WarnSandUnsupportedElements). One shape-function weight row per grain drives position, per-step value, and deform-follow consistently — grains ride the deformed substrate and animate with the time scrubber / animation export for free. Rendered as a vertex-cell point-cloud MeshLayer (flat GL points, non-pickable — sphere billboards draw NOTHING on some Windows GL stacks, render-verified) so per-step updates hit the backend's in-place fast path; occludes_substrate=True hides the substrate fill (grains are strictly interior — behind an opaque fill the diagram would be invisible) leaving the wireframe as the volume outline. SandStyle knobs: target_points (grain budget), point_size, cmap/clim (standard ScalarColorSupport LUT + scalar bar), opacity, seed (reproducible clouds), and an optional value-weighted density mode (weight_by_value=True): each grain draws a fixed random threshold at attach and only shows at steps where the normalized |value| at its location exceeds it — dense sand where the field is strong, sparse where weak, flicker-free under animation (density_floor keeps a faint outline). Registry-driven wiring (ADR 0058 S0): the kind appears in the dialog/catalog/session/presets automatically. Locked by tests/viewers/test_sand_diagram.py (13 tests: registration, grain budget + containment + seed determinism, convexity bound, partition-of-unity step shift, deform-follow translate + reset, density masking, shell-only + missing-component NoDataError).

Also FIXED in this change — apply_visibility_mask wrote the wrong ghost byte: the backend's _GHOST_HIDDEN_CELL was 0x01, which is VTK's DUPLICATECELL bit, not HIDDENCELL (0x20). Surface extraction happens to drop duplicate-ghost 1/2/3-D cells, so every existing VisibilityMask consumer rendered correctly — but the mapper's 0-D vertex path only honours the pure HIDDENCELL byte (even 0x21 fails), so any point-cloud layer mask was silently a no-op (render-verified: the sand density mask hid 17k grains in the IR and the dataset while the frame stayed byte-identical). Now 0x20, which render-verifies as hiding both solid and vertex cells. viewers/core/element_visibility.py (the substrate's own writer) is untouched.

FIXED — emit-memory runway review hardening (adversarial panel over #772–#778)

A five-lens adversarial review of the merged runway produced three real findings, all fixed here; everything else was refuted with evidence (aliasing/memo invariants, MassSet base-class coherence, duck-type consumer contracts, order determinism — no changes needed). (1) Duplicate-eid tie-break restored to last-wins: overlapping element PGs legally fan one FEM cell from two specs, producing duplicate fem_eid keys; the old {eid: tag} dict comprehension resolved them LAST-wins, but FemToOpsTagMap._find/translate used first-match searchsorted — silently retargeting recorder/damping/remove_element selections on such models. Both now use side="right" - 1 (stable argsort preserves plan order among equals), reproducing the dict exactly; items() still yields both physical rows. (2) stream_finish() moved inside the abort-guarded region in apeSees.tcl: a failing os.replace mid-promotion (Windows file lock) previously escaped the handler, leaving mixed .tmp/promoted state with no cleanup; it now routes to stream_abort (remaining temps removed, driver-last ordering means no deck entry point can exist half-built), with the partial-promotion + re-run-heals contract documented on stream_finish, and stream_abort now also removes the eagerly-created ranks/ dir when empty. (3) Dead compose-path mass boxing removed: _rewrite_sourced_arrays eagerly materialised tuple(new_mass_set) — one boxed MassRecord per node on every compose() (~GBs at multi-M nodes) — into a _RewrittenBundle.mass_records field that no code consumed; the field is gone, mass_set (columnar) is the only mass channel. New locks: tests/opensees/unit/test_columnar_ownership_maps.py (dict-parity battery for FemToOpsTagMap/SortedIntToInt/NodePartitionOwners incl. the duplicate-eid last-wins contract and scalar-vs-translate agreement) and three streaming tests (fault-injected os.replace mid-promotion → abort + re-run-heals; empty-ranks/ cleanup; byte-identity assertions switched from newline-normalizing read_text to raw read_bytes).

CHANGED — emit-memory runway CLOSED: plan + ADR 0065 status flipped to complete (docs only)

internal_docs/plan_emit_memory_columnar.md Status → COMPLETE with the measured milestone table (emit phase-peak 2,291 → 1,039 B/hex at the 103k-hex / 64-rank / staged reference across #772–#777; extrapolated traced peak @ 11M hexes 23.6–25.3 → 11.4 GB; MassSet 400–700 → 56 B/node), the remaining-ledger note (the pre-existing emit-loop transient, ~715 B/hex partitioned — attribute before spending), and the Route E triggers. ADR 0065 Status → ACCEPTED — fully shipped (Tier 1 2026-06-18; Tier 2 as ops.tcl(stream=True) in #777, with the demand-gate profile having run as plan M0). No code changes.

ADDED — ops.tcl(stream=True) write-through streaming sink + live per-rank routing + atomic writes (ADR 0065 Tier 2 / plan_emit_memory_columnar.md A1–A3)

Retires the emit-side line buffer — the last Route-A ledger term (27.9 MiB / 239k resident line strings at the 103k-hex staged/64-rank reference). apeSees.tcl(path, stream=True) now writes the deck through a live file sink instead of accumulating _LineBuf: the buffer class is dual-mode (list accumulation stays the default, byte-identical to before; with an attached sink, append writes indent + line + "\n" straight through and stores nothing — the partition-indent logic already lived in append, so every emitter call site is captured for free). The banner + any preamble() lines buffered at attach time flush to the sink first; preamble()/insert(0) after streaming has begun fails loud. Per-rank live routing (per_rank=True + stream=True, the production path): partition_open(K) switches the active sink to ranks/rank<K>_<seq>.tcl (writing the ADR 0061 fragment banner, body lines streamed with the partition-level indent stripped live via (indent + line).removeprefix(" ") — the exact post-hoc transform, so intra-block nesting indents and the indent-0 override paths reproduce faithfully); partition_close() closes the fragment and writes the driver's source-guard + blank line. Fragment naming (<seq> = per-rank 0-based block counter) and every byte of driver + fragments are identical to _write_per_rank_tcl's output; PartitionSpan recording retires in stream mode. Atomic writes: everything streams to .tmp siblings promoted via os.replace on clean completion (fragments before the driver); a mid-emit exception aborts and removes every temp — never a half-written final deck. Guards (v1): stream=True + split=TrueValueError; ops.py(..., stream=True)ValueError (the HPC path is Tcl; the py emitter stays list-only); lines() / line_buffer() / write_to() / partition_spans() in stream mode → RuntimeError; run=True composes (the file exists after the replace). Measured (--stream added to tests/benchmarks/emit_throughput_profile.py; box 103k hexes / 64 ranks / staged, --mem): post-emit resident 37.3 → 8.0 MB (the 27.9 MiB line-buffer top-site is gone outright), emit phase-peak 1,321 → 1,039 B/hex (traced peak 137 → 108 MB, RSS 490 → 421 MB, extrapolated @ 11M hexes 14.5 → 11.4 GB). The remaining peak is a pre-existing transient inside the emit loops themselves (identical in list mode; 715 B/hex partitioned non-staged, 613 B/hex flat) — the next ledger term, out of Route-A scope. Locked by tests/opensees/unit/test_emit_streaming_write.py (stream-vs-list byte-identity on flat / partitioned / staged+partitioned; per-rank driver + every fragment vs the post-hoc writer incl. staged <seq> numbering; tracemalloc O(1) emit-peak ceiling; all guards; mid-emit-exception atomicity — 14 new tests). Full tests/opensees green (5049 passed); ruff + package-wide mypy (baseline 0) clean.

FIXED — H5 deck-replay builds a FemToOpsTagMap (mypy ratchet red on main after B2+B3)

The B2+B3 slice (below) missed one caller: _internal/compose.py's deck-replay reconstructs {fem_eid: ops_tag} from replayed element records and passed plain dicts to emit_initial_stress_addtoparameter / emit_activate_absorbing, which now type against FemToOpsTagMap — 3 mypy-ratchet errors on main (runtime-safe: those helpers only call the duck-typed .get(), and the full suite was green). Both replay sites now build the map via a new FemToOpsTagMap.from_pairs((fem_eid, ops_tag), ...) classmethod (pair order = items() order, mirroring the old dict's insertion order). mypy ratchet back to baseline 0.

CHANGED — build-side ownership dicts/sets + tag map go columnar (ADR 0065 v2 / plan_emit_memory_columnar.md B2+B3)

Retires the remaining build-side per-entity Python containers behind the emit path — the terms left after B1+B4 (below). Three compact array-backed classes in opensees/_internal/build.py, each duck-typed to the mapping it replaces (get incl. default, [], in, iteration-over-keys, items/keys/values, == against a plain dict) so consumers are annotation-only changes: FemToOpsTagMap replaces the five {fem_eid: ops_tag} dict comprehensions over the element plan (~160 B/element resident + the boxed triple walk; ~1 GB at LOH.1 scale) — resident form is two int64 arrays straight off the columnar plan, point lookups via searchsorted on a sorted view, plan-order items(), node-pair sentinel rows filtered at construction, and a vectorised translate(eids) -> tags for whole-selection resolution; SortedIntToInt replaces the {fem_eid: rank} / {node_id: rank} ownership dicts (build_element_partition_owner, primary_owner_map) with two sorted int64 arrays + a vectorised translate_ranks used by the per-rank bucketing; NodePartitionOwners replaces the {node_id: set[rank]} map from build_node_partition_owners — the single largest build-side term (one boxed Python set per node, ~315 B/hex at box-64-rank scale) — with a CSR layout (node_ids / offsets / ranks, ~2 int64 per node in the common single-owner case); get() yields a transient frozenset (the MP-constraint replication paths intersect against it), and primary_owner() reduces to the lowest rank vectorised (_ranks[_offsets[:-1]], owner runs are ascending by construction). Measured (--mem, box 103k hexes / 64 ranks / staged): emit phase-peak 1,688 → 1,321 B/hex on top of B1+B4's 2,144 → 1,688, extrapolated traced peak @ 11M hexes 18.6 → 14.5 GB; the dominant remaining term is now the emitter line buffer (Route A, next slice). Decks byte-identical (byte-identity fixtures + full tests/opensees); ruff + mypy clean.

CHANGED — columnar MassSet storage (ADR 0065 v2 / plan_emit_memory_columnar.md C1–C3)

fem.nodes.masses (MassSet) no longer keeps one resident MassRecord dataclass per node — at LOH.1 scale (~7M nodes) that boxed graph cost ~3–5 GB. It now stores masses in three parallel columns (_node_ids: int64[N], _mass: float64[N,6], _names: dict[int,str] sparse) and constructs a transient MassRecord on the fly when iterated / indexed. The resident store drops from ~400–700 B/node to 56 B/node (measured 28.7 MB at 512k nodes). MassRecord (the view/API type) gains slots=True; nothing set ad-hoc attributes on it. The public surface is unchanged (__iter__ / __len__ / __bool__ / __getitem__ / by_kind / by_node / total_mass / summary / _with_record), and membership (rec in fem.nodes.masses) still works by dataclass value-equality. Consumer-inventory finding: every consumer (the OpenSees bridge _emit_masses / _emit_masses_partitioned / bucketed partitioned mass_from_model paths, the mass viewer tab, compose, h5 io) only reads m.node_id / m.mass / m.name — none rely on record identity (is) or in-place mutation, so transient records are safe. Producers: (a) the in-session resolver (MassesComposite.resolve) builds the columnar set directly from the sorted per-node accumulator — never boxing a records list (mass_records becomes a lazy property); (b) the numpy-native _read_masses at FEMData.from_h5 adopts the already-columnar /masses compound dataset's node_id / mass columns with a single copy instead of boxing 7M records (measured from_h5 peak 628 → 468 MB at 512k nodes); _write_masses fills the compound payload straight from the columns. The compose tag-rewrite (tag_rewrite_spec node_id offset) gains a vectorized columnar fast path (_rewrite_mass_set = one node_ids + offset array add; sparse-name namespace-prefix) and the compose merge concatenates host + bundle mass columns in one shot, retiring the O(N²) per-record with_mass append loop. No model.h5 schema change — /masses was already columnar; SchemaVersion untouched, decks byte-identical (float repr preserved bit-for-bit, verified with awkward floats: 0.1, 1e-300, 17-significant-digit values). Verified: full tests/opensees (5035 passed), tests/mesh + compose + mass suites, a new float-identity/round-trip gate (tests/test_mass_columnar_float_identity.py), and a non-collected memory benchmark (tests/benchmarks/mass_columnar_memory_profile.py). ruff + mypy clean on touched files (no new diagnostics). Out of scope (optional C4): NodalLoadSet / SP records share the pattern but are untouched.

CHANGED — columnar element plan + columnar PG fan-out (ADR 0065 v2 / plan_emit_memory_columnar.md B1+B4)

Cuts the dominant emit-time RAM term for large models — the per-element Python object graph (per-element plan tuples + ~54M boxed connectivity ints, ~4–6 GB at the LOH.1 ~6.7M-hex reference). The element fan-out and the pre-allocated element plan now keep their resident form columnar (int64 arrays straight off the FEMData group arrays, which are already numpy) and box a row only transiently at iteration. Two new internal containers in opensees/_internal/build.py: PGElementFanout (.eids: int64[N], .conn: int64[N,k] or object-padded per-row for mixed-npe groups) replaces the memoised list[tuple[int, tuple[int, ...]]] returned by expand_pg_to_elements / expand_spec_to_elements; ElementPlanRows (.eids, .conn, .tag_start) replaces each spec's list[tuple[int, tuple[int, ...], int]] in allocate_element_tags. Both are duck-typed to the old list-of-tuples — iterating yields the exact same (eid, conn) / (eid, conn, tag) tuples in the same order, and __len__ / __getitem__ / bool behave like the old list — so every existing consumer (the flat / split / staged / partitioned emit loops, the fem_eid_to_ops_tag dict comprehensions, the recorder / rayleigh / damping region fan-outs, sweep_asdconcrete_element_size, ModelData.oriented_elements) is unchanged. TagAllocator gains allocate_block(kind, n) — reserves a spec's N element tags in one call with identical per-kind sequential counter semantics (element tags are allocated only in this one pass, so a spec's tags stay the contiguous block [tag_start, tag_start+N); row i's tag is tag_start + i positionally). bucket_pre_allocated_by_rank now yields per-rank ElementPlanRows row-subset views (arrays indexed by the owned-row positions, carrying explicit per-row tags) instead of re-materialising a tuple graph per rank — so partitioned / staged-partitioned emits no longer rebuild the boxed plan per rank. Node-pair (pg=None) specs are a 1-row fan-out carrying the MISSING_FEM_ELEMENT_ID (-1) sentinel, which fits int64 and is filtered out of the tag maps by value exactly as before. Byte-identical emitted Tcl + Py decks for flat, partitioned (multi-rank), staged+partitioned, per-rank fragment files, and split-module emit (the existing byte-identity fixtures + full tests/opensees suite pass). No new public API (both containers are internal). B2 (retiring the fem_eid_to_ops_tag dicts) and B3 remain deferred.

CHANGED — emit-memory runway opened (ADR 0065 v2): M0 --mem attribution + A0 line-buffer copy fixes

First slice of the large-model emit-memory plan (internal_docs/plan_emit_memory_columnar.md — columnar element plan / columnar masses / Tier-2 streaming sink, targeting the ~28 GB OOM on 6.7M-hex partitioned+staged emits):

  • M0tests/benchmarks/emit_throughput_profile.py --mem adds tracemalloc + RSS-peak attribution over the build / emit / write phases (per-phase resident + peak, B/hex, extrapolation to 6.7M/11M hexes, top allocation sites at the post-emit resident point). This is the re-baseline gate ADR 0065 demanded before Tier 2, and the regression harness every subsequent slice must move. Wall-clock from a --mem run is documented as non-throughput.
  • A0TclEmitter/PyEmitter gain line_count() and a read-only line_buffer(); the split-emit module-span recording no longer calls len(emitter.lines()) (which cloned the entire multi-M line list to read a length — four sites), and the per-rank / split deck writers consume line_buffer() instead of the lines() deck-sized copy. Behavior and emitted decks unchanged.

ADDED — OpenSeesModel.build() deck-replay re-emits g.reinforce ties (ADR 0067 P5.1 "A4 full", reinforce leg)

Closes the /opensees deck-replay gap for embedded-reinforcement ties. Previously a reinforced model.h5 loaded via OpenSeesModel.from_h5().build("tcl"/"py"/"live") silently dropped its LadrunoEmbeddedRebar ties — the deck-replay path (_replay_into) re-emitted nothing for them (the H5 emitter no-ops the tie deck record; persistence is the neutral zone's job). Now _replay_into gained a step 8b that re-emits the ties from the neutral-zone fem (fem.elements.reinforce_ties, from the /reinforce_ties group) — which OpenSeesModel.build already passes in and already leans on for element-connectivity rehydration. Tie element tags are freshly allocated past the max replayed element tag (they share the element namespace, so a fresh 1-based counter would collide; ADR 0019 INV-5 already allows tag divergence across round-trip), and a tie's -bond <name> resolves via a name→tag map threaded from the /opensees/names sidecar (OpenSeesModel._names). This is the cleaner re-emit-from-neutral design, superseding the plan's original dedicated-/opensees-deck-record approach: no new deck record, no embedded_rebar write, no opensees-zone schema bump — the deck zone is unchanged. The h5 re-emit caller passes no fem, so it is correctly skipped (the h5 target persists ties via the neutral zone; build('h5') stays byte-stable). The canonical recovery for a reinforced model — FEMData.from_h5 → forward re-emit — is unchanged. Scoped / documented follow-on: _replay_into still replays no other MP-constraint family (equalDOF / rigidLink / rigidDiaphragm / embeddedNode / contact / embed / equation ties); reinforce ties are the first and only family deck-replayed, and each of the rest could later re-emit from the neutral fem the same way. Also records that the deferred s.mortar / s.tied_contact stage-claim item is already resolved (s.tied_contact shipped in ADR 0034; s.mortar intentionally out of scope since the ADR 0073 contact refactor left no claimable record). Locked by tests/opensees/h5/test_reinforce_deck_replay.py (perfect + bond-by-name tcl re-emit, py target, tie-tag non-collision, and build('h5') persisting via the neutral zone). Static gates green (ruff opensees hard gate + mypy 0); full tests/opensees suite 4981 passed.

ADDED — g.reinforce(..., corot=True) co-rotated bar axis for large host rotation (ADR 20 §10.5, R3c)

Exposes the fork LadrunoEmbeddedRebar -corot option on g.reinforce(..., corot=True): the embedded-rebar coupling co-rotates the bar axis each step from the current host geometry, keeping the axial/transverse split frame-objective under large host rotation. False (default) ⇒ the frozen reference -dir (byte-identical to before). The fork needs a second point B along the bar to form d̂_cur = normalize(Σ NshapeB·x − Σ Nshape·x) from current host node positions; apeGmsh emits the host-element-tag-free -shapeB point-B weights so no host-element query is needed (the -shape path's corot sibling). The resolver computes each node's shape_b by stepping a short distance (0.05·host_radius) along the bar axis from the embed point and inverse-mapping that point into the same host element (inverse_map_single) — the magnitude cancels under the fork's normalisation, so a small in-host step robustly captures the host's local deformation gradient along the bar (the ±d̂ step is retried if the first direction leaves the host). The grammar builder (embedded_rebar_args) already carried -corot -shapeB from R3a; this wires the upstream lane — g.reinforceReinforceDefresolve_reinforce (computes shape_b) → ReinforceTieRecordemit_reinforce_ties — and round-trips through model.h5 via additive corot + shape_b columns on reinforce_tie_payload_dtype (neutral schema 2.25.0 → 2.26.0; presence-probed so an in-window 2.25.x file decodes corot=False, shape_b=None; omitted-knob round-trip stays byte-identical). The encode/decode validate shape_b parallel to host_nodes (mirroring weights). Closes the last R3 reinforcement leg (R3a explicit/AL/bipenalty already shipped). Locked by tests/_kernel/resolvers/test_reinforce.py (point-B weights sum to 1, secant along the bar axis, non-corot leaves shape_b=None), tests/opensees/unit/test_reinforce_emit.py (emit -corot -shapeB), and tests/mesh/test_reinforce_tie_h5_roundtrip.py (full corot round-trip + decode presence-probe). Static gates green (ruff opensees hard gate + mypy 0).

ADDED — g.constraints.contact(..., edge_edge=, edge_*=) mortar edge-edge contact fallback (ADR 0073, fork ADR-57 E2–E7)

Exposes the fork LadrunoContact edge-edge (perpendicular segment-to-segment) contact fallback — the cos_t→0 pairs the face-mortar clip degenerates on get a dedicated segment-to-segment penalty. Eleven new knobs on g.constraints.contact(...), all mortar-only: edge_edge (enable the fallback, -edgeedge), edge_kn (-edgeKn auto|<v>, the edge penalty; None ⇒ the mortar penalty), edge_band (-edgeBand, the gap activation band), edge_mu / edge_kt / edge_cohesion / edge_tau_max (-edgeMu/-edgeKt/-edgeCohesion/-edgeTauMax, the edge Coulomb/Tresca friction cone), edge_consistent_tan (-edgeConsistentTan, the non-symmetric Csl tangent), edge_soft (-edgeSoft [SOFSCL], the explicit Courant-stable SOFT penalty — True ⇒ the fork default 0.10, a float ⇒ an explicit SOFSCL), edge_alm (-edgeAlm, the one-scalar commit-cycle ALM), and edge_aug_tol (-edgeAugTol, the ALM tolerance). ContactDef mirrors the fork's two fail-loud gates: edge_edge requires formulation="mortar" (the fork routes the fallback off the mortar lane), and the edge_* params require edge_edge=True (the fork warns-and-IGNORES -edge* without -edgeedge — apeGmsh fails loud rather than silently drop them). Edge penalties/friction are range-validated (edge_kn takes the "auto" sentinel; the friction analogues allow the fork's zero sentinels; edge_band/edge_aug_tol are strictly positive) and an edge_soft SOFSCL > 1 warns (ω·dt = 2√SOFSCL > 2), mirroring the fork. Threaded through the full lane — g.constraints.contactContactDefContactRecordcontact_args (emits -edgeedge then the requested edge knobs, after -cell, before -outward) → emit_contacts — and round-trips through model.h5 via additive edge columns on contact_payload_dtype (neutral schema 2.24.0 → 2.25.0; edge_kn uses an auto/None/numeric tri-state, edge_soft a None/bare/numeric tri-state, everything else NaN-sentinelled; presence-probed so an in-window 2.24.x file decodes the fallback off; omitted-knob round-trip stays byte-identical). Closes the headline remaining ADR 0073 contact leftover — face-to-face contact + friction + contactPlane + the -cell knob were already shipped, leaving edge-edge as the one substantial contact piece. Locked by tests/opensees/unit/test_contact_emit.py (edge emit ordering + both fail-loud gates + range/SOFSCL validation + record→emit) and tests/mesh/test_contact_h5_roundtrip.py (full edge round-trip, bare-edge_soft tri-state, fallback-off defaults, decode presence-probe). Static gates green (ruff opensees hard gate + mypy 0).

ADDED — g.constraints.contact_plane(...) rigid analytical-plane contact (ADR 0073)

Exposes the fork contactPlane command via g.constraints.contact_plane(slave, *, normal=, point=, kn=, visc=None, soft=None, name=) — a meshed slave surface contacting a fixed infinite rigid plane (frictionless, no master mesh), for a rigid floor / wall / foundation where the counter-body needn't be meshed. Emits one contactSurface -slave <nodes> + one contactPlane tag slaveSurfTag nx ny nz px py pz kn [-visc μ] [-soft S], resolved by the same auto-emitted LadrunoContact handler (the _fem_has_contacts gate now triggers on contacts or contact_planes). kn is required numeric (the fork has no "auto" on contactPlane); ContactPlaneDef validates a non-zero normal + strictly-positive kn/visc/soft. Implemented as a full sibling stack of the face-to-face contact generator: ContactPlaneDef / ContactPlaneRecordConstraintsComposite.contact_plane / resolve_contact_planesfem.elements.contact_planes (_fem_factory wiring) → emit_contact_planes (build pass) → the contact_plane Protocol method on all five emitters (tcl/py/live[fork-gated]/h5[deck no-op]/recording). Round-trips through model.h5 via a NEW /contact_planes group (contact_plane_payload_dtype, neutral schema 2.23.0 → 2.24.0; the group is omitted when there are none so a plane-free model stays byte-identical — snapshot_id unchanged). Serial-only like face-to-face contact: a contact_plane under partitioned (MPI) emit fails loud (the partitioned guard now fires on contacts OR contact_planes, so a plane is never silently dropped nor a spurious LadrunoContact handler auto-emitted). Closes the second of the three ADR 0073 deferred contact leftovers; the -epsTie alias is resolved as intentionally not exposed (redundant — it is a pure alias for the -epsN penalty slot that eps_n already emits). Only the edge-edge lane remains deferred. Locked by tests/opensees/unit/test_contact_plane_emit.py (grammar + def validation + emit pass) and tests/mesh/test_contact_plane_h5_roundtrip.py (round-trip incl. soft modes, group-omitted byte-stability, prior-minor window, encode fail-loud). Static gates green (ruff opensees hard gate + mypy 0).

FIXED — viewers/ui/_open_results.py imports OpenSeesModel via the allowed package surface

Follow-up to the File → Open Results… feature (#757): build_results's native path imported OpenSeesModel from the apeGmsh.opensees.opensees_model submodule, which tests/test_viewers_pure_h5_consumer.py::test_viewers_have_no_mesh_or_opensees_imports forbids — viewers/ may only reach it through the package top-level (apeGmsh.opensees). #757's CI suite job flagged this, but the PR auto-merged before the fix landed (the repo has no required status checks, so --auto merged immediately while suite was still running), shipping the violation to main. This one-line change routes the import through from apeGmsh.opensees import OpenSeesModel, restoring the curated suite to green. No behaviour change.

ADDED — g.constraints.contact(..., cell=) broad-phase cell-size knob (ADR 0073)

Exposes the fork LadrunoContact -cell <frac> option — the broad-phase spatial-hash bucket size as a fraction of the median segment diagonal (a performance-tuning knob; a huge value ⇒ one bucket ⇒ brute force). Applies to both formulations (NTS + mortar); omitted ⇒ the fork default. ContactDef requires it strictly positive (mirroring the fork parser's "need a positive frac"). Threads cell through the full lane — g.constraints.contactContactDefContactRecordcontact_args (emits -cell after the extension modifiers, before -outward) → deck emit — and round-trips through model.h5 via an additive cell column on contact_payload_dtype (neutral schema 2.22.0 → 2.23.0, presence-probed so an in-window 2.22.x file decodes cell=None; omitted-knob round-trip stays byte-identical). Closes one of the three ADR 0073 "still deferred" contact leftovers (the -epsTie alias and the contactPlane rigid-plane command remain). Locked by tests/opensees/unit/test_contact_emit.py (emit both lanes + numeric-kn triple padding + strictly-positive validation) and tests/mesh/test_contact_h5_roundtrip.py (cell folded into _eq + the NTS extensions round-trip). Static gates green (ruff opensees hard gate + mypy 0); 137 contact/parity/emission tests pass.

ADDED — results viewer File → Open Results… with a format-aware model follow-up

The post-solve ResultsViewer window now carries a leftmost File menu with an Open Results… action: it pops a QFileDialog for the results file, sniffs the format from the file's contents, and only prompts for a second model.h5 when that format actually needs it. The "is a model needed?" rule follows directly from how each loader sources its broker — .mpcorequired (broker never embedded), native .h5 without an embedded /opensees zone → required, .ladrunooptional (self-sufficient; a model only enriches orientation + lineage), native .h5 with embedded model → nothing asked. The opened file appears in a new viewer window beside the current one. New module viewers/ui/_open_results.py holds the Qt-free helpers sniff_results_format / model_requirement / build_results (content sniffing: Ladruno INFO/GENERATOR, STKO MODEL_STAGE[ groups, native /model+/opensees, with an extension fallback) plus the run_open_dialog Qt driver; ViewerWindow.exec() is split into present() (show/raise/render) + the app.exec_() tail so the second window joins the running event loop instead of nesting it (ResultsViewer.show(enter_loop=False)). Partitioned .ladruno siblings still auto-merge with no prompt; partitioned .mpco discovery is a deferred follow-up. Locked by tests/viewers/test_open_results.py (14: real-fixture ladruno round-trip, native/mpco classification, per-format requirement, build-results error paths). Full viewers suite green (1576).

FIXED — enforce="equation" ties round-trip through model.h5 without a deviation warning (ADR 0068, Open item 4 resolved)

The H5 deck emitter no longer raises H5EquationConstraintDeviationWarning for an enforce="equation" tie (EQ_Constraint). The warning was over-conservative: the equation tie is a resolved InterpolationRecord, and the neutral zone already persists its enforce route and the projection weights (schema 2.14.0) — everything _emit_equation_tie needs. So an equation-tied model.h5 already round-trips via FEMData.from_h5apeSees(fem).tcl()/py()/run() (the forward emit re-runs _emit_one_interpolation_emit_equation_tie), exactly like the g.embed / g.constraints.contact / g.reinforce ties, which all no-op silently in the deck zone. The deck emitter now matches them (silent no-op + _skipped_equation_constraints counter); H5EquationConstraintDeviationWarning is retained in __all__ as a dormant back-compat class (no longer raised). No schema bump — the ADR 0068 premise that this needed an equationConstraint group was stale. With this, no fork-feature carries an H5 deviation warning (reinforce / contact / embed / equation all recover via the neutral zone); the standalone /opensees deck-zone replay stays the shared low-priority follow-on. Locked by tests/test_equation_tie_emission.py (test_h5_deck_emitter_equation_tie_no_deviation_warning + test_equation_tie_reemits_identically_after_h5_record_roundtrip). ADR 0068 Open item 4 + both handoff docs updated.

ADDED — results-viewer animation export (video / GIF) — interactive button + headless Results.export_animation

The results viewer can now export the time history as an MP4 video or animated GIF. The encoding engine (apeGmsh.viewers.animation.export_animation — drive the director step-by-step, capture plotter.screenshot frames, encode via imageio) already existed but was reachable only through a method that required the blocking viewer.show() to have run first (which tears the plotter down on return), so in practice nothing could call it. Two reachable entry points now wire it up: (1) a 🎬 Export button on the Time Scrubber in results.viewer() — opens a save dialog (*.mp4 / *.gif, suffix selects the format), captures every step at the scrubber's current FPS, and runs behind a cancelable QProgressDialog with a wait cursor + status-bar result (cancel via raising out of the new progress callback deletes the partial file and restores the user's step). The frames are exactly what's on screen — deformation, contours, camera, theme. (2) a headless Results.export_animation(path, *, fps=30, step_stride=1, stage=None, deform=None, camera=None, window_size=(1280,720), setup=None) that reuses the full Qt viewer off-screen: it builds the real viewer via the new ResultsViewer.show(run_loop=False) (constructs the window + scene + deform pump, realizes the GL surface for screenshots, but never enters the blocking event loop), applies stage / deform (a scale, or (field, scale)) / camera, runs the optional setup(plotter, director) hook for custom diagrams, exports, and tears down — without closing the caller's Results HDF5 handle (_own_results_close guard on _on_close). MP4 needs the apegmsh[animation] extra (imageio-ffmpeg); GIF is Pillow-only. export_animation gained an optional progress(done, total) callback (1-based, fires per frame; raising cancels). Locked by tests/viewers/test_animation.py (+4: progress-callback invoked 1..total, cancel-via-exception restores the step, and a headless Results.export_animation GIF round-trip that asserts the borrowed Results stays queryable afterward). APEGMSH_SKIP_VIEWER short-circuits the headless path for CI / nbconvert.

FIXED — global ops.damping.rayleigh no longer silently dropped under partitioned (MPI) emit (ADR 0053 × ADR 0027)

A global ops.damping.rayleigh(...) declared outside any stage was emitted in the flat (single-process) deck but silently absent from the partitioned (OpenSeesMP) deck — zero rayleigh lines — so an np>1 run came out undamped (a plane-wave absorbing-boundary model showed a uniform ~14 % / max 45 % seq↔np4 surface discrepancy; the plane-wave handoff's load-bearing finding #1). Root cause: apeSees._emit_partitioned never called the global damping emitters that _emit_flat runs driver-post — stage-bound damping survived (re-emitted per stage by _emit_stages_partitioned), but the bridge's global rayleigh_records / damping_attach_records were dropped. The fix adds _emit_global_damping_partitioned, called once after the per-rank fan-out for both staged and non-staged decks. It mirrors the stage-bound partitioned damping pass: rayleigh (bare global and region-scoped on=) and the Damping-object region -ele … -damp attaches are emitted once outside any partition_open block — correct under OpenSeesMP because MeshRegion::setElements keeps "only those elements in the domain" (foreign -ele tags from other ranks are silently skipped), so each rank binds its locally-owned subset, and a bare rayleigh applies to each rank's local domain. The global pool is therefore not treated more restrictively than the stage pool (an earlier draft fail-louded on the region-scoped/attach forms, which was an arbitrary asymmetry — the stage path already emits the identical global region -ele line). Modal damping (ops.damping.modaleigen + modalDamping) is the one form that fails loud (BridgeError): a bare eigen solves each rank's local subdomain under OpenSeesMP, so the modes — and the modalDamping built from them — would be wrong, not merely unwired (the stage path likewise refuses per-stage modal). Locked by tests/opensees/integration/test_emit_partitioned_global_damping.py (5: bare global rayleigh emits once outside the rank blocks on Tcl + Py; the staged finding-#1 scenario; region-scoped -rayleigh + Damping-object -damp emit as global region lines outside the blocks; modal fails loud). Full tests/opensees suite green (4966 passed); ruff (opensees hard gate) + mypy clean. Emission is unit-verified without MPI; a live OpenSeesMP run is the final confirmation.

CHANGED — g.rebar B1b (beam-element rebar) design resolved (ADR 0067 P5.2)

Records the B1b implementation design (docs only). An investigation pinned every injection point and made the engineering sub-decisions (no further human gate): (1) embedded-only — beam rebar nodes need ndf=6, and conformal bars share the host's ndf=3 solid nodes, so element="beam" + coupling="conformal" will raise; beam auto-emit requires coupling="embedded" (the bar's own nodes go cleanly ndf=6, tied by LadrunoEmbeddedRebar). (2) circular fiber section emitted directly in emit_rebar_elements via section_open("Fiber")patch("circ", …)section_close() (no new Fiber primitive — the dedicated pass already bypasses the registered-Element machinery), giving real bending/dowel stiffness. (3) a Lobatto beamIntegration + a per-segment geomTransf Linear (the round section is symmetric, so vecxz orientation is immaterial — a valid per-segment perpendicular suffices). (4) ndf=6 injection by extending infer_node_ndf (build.py:336) to bump every node of a beam RebarElementRecord to 6 (the dispBeamColumn elements aren't Element specs, so they're invisible to the default inference). (5) dispBeamColumn per line cell. (6) ungate RebarComposite.py:1129. (7) twist folds inLadrunoEmbeddedRebar ties translations only, so a beam rebar node's rotational DOFs are a zero-energy mode (singular tangent); B1b ships WITH the B0.3 stabilization (existing zeroLength + SP to a ghost node, no new C++ class tag). The implementation is a large, core-adjacent change (touches infer_node_ndf / transforms / sections / ghost nodes) and will get an adversarial review. See internal_docs/plan_rebar_p5.md §B1b.

ADDED — auto-emitted rebar elements round-trip through the neutral model.h5 (ADR 0067 P5.2 / B1a.2, neutral schema 2.16.0)

fem.elements.rebar_elements — the cage's place(emit_elements=True) structural elements (one RebarElementRecord per bar) — now persist through FEMData.to_h5 / from_h5, into a new dedicated /rebar_elements group (a symmetric-compound dataset via rebar_element_payload_dtype, modeled on the A1 /reinforce_ties group). Previously B1a left them in-memory only, with to_h5 warning loud that a round-tripped file would be missing them; that warning is now removed. The record carries the bar's resolved line-cell connectivity (flat 2·n_cells int64, extracted from the live mesh at get_fem_data), so the rebar elements re-emit byte-faithfully after a round-trip. The bump is the neutral zone NEUTRAL_SCHEMA_VERSION 2.15.0 → 2.16.0; the two-version reader window tolerates 2.15.x (which simply has no /rebar_elements group). The group is omitted when no bar opted into emit_elements, so a plain cage stays byte-identical and its snapshot_id is unchanged (the hash excludes rebar elements, consistent with constraints / ties). RebarElementRecord is re-exported from apeGmsh._kernel.records and mapped in tests/test_record_schema_parity.py (RECORD_TO_DTYPE); g.compose now preserves the host's rebar elements across a merge (carrying a source Part's rebar elements with PG/material-name prefixing — parallel to the reinforce-tie carry — is a deferred compose teach-in). _encode_rebar_element fails loud on empty/odd connectivity. Locked by tests/mesh/test_rebar_element_h5_roundtrip.py (8: round-trip with connectivity equality, group-omitted + snapshot-stable, no warning, version stamp, encode-rejects-empty, prior-minor window read). The reinforce-tie window tests + the schema fixture are bumped for the 2.16.0 reader. Full mesh + _kernel + rebar + reinforce suites green.

ADDED — g.rebar.place(emit_elements=True) auto-emits the bar's structural element (ADR 0067 P5.2 / B1a)

g.rebar.place(..., emit_elements=True) (default False — no behavior change) now makes the cage auto-emit each bar's own structural element at apeSees build time, so placeget_fem_dataapeSees(fem).tcl()/py()/run() produces a runnable structural model instead of geometry + coupling that you wire by hand. B1a ships the truss path: one CorotTruss per bar line cell, using the bar's uniaxial-material name (resolved at emit, Option B) and area (π·d_b²/4). This is the bar's own axial element — distinct from the LadrunoEmbeddedRebar coupling, which carries no axial stiffness. Implementation: a new RebarElementRecord (_kernel/records/_rebar.py) carries the bar's line-cell connectivity, which g.rebar.resolve() extracts from the live mesh at get_fem_data (the dim-1 cells are dropped from a dim-3 FEMData, so they can't be read back from fem.elements); mesh._fem_factory calls it onto fem.elements.rebar_elements, and a dedicated emit_rebar_elements build pass (mirroring emit_reinforce_ties) emits the elements on both the flat and per-rank paths. Partitioned (MPI) emit and element="beam" bars fail loud (per-rank routing and the beam stack — fiber section + beamIntegration + per-segment geomTransf + ndf=6 + twist — are B1b). Known limit (B1a.2 follow-on): fem.elements.rebar_elements is not yet persisted to the neutral model.h5; FEMData.to_h5 warns loud so a round-tripped file isn't silently missing them (the in-memory emit path is unaffected). Locked by tests/rebar/test_rebar_emit_elements.py (4: off=no CorotTruss; on=one per cell with correct area/material; unregistered material fails loud; beam raises). 4361 mesh+opensees tests green; ruff (opensees hard gate) + mypy clean.

CHANGED — g.rebar Track-B B1 design resolved: cage auto-emits structural rebar elements (ADR 0067 P5.2)

Records the B1 design for element="beam" dowel rebar (docs only). A code re-survey established the load-bearing finding: g.rebar today emits geometry + coupling onlyLadrunoEmbeddedRebar (33005) is a pure coupling element (no axial stiffness; the bar's own stiffness lives on a separate structural element), and RebarMember.element ("truss"/"beam") is stored metadata, never consumed (the structural bar element is user-emitted today, like the reference ladruno_rc.py). Decision (user): the cage will auto-emit the bar's structural element behind an opt-in place(emit_elements=…) flag (default off → no behavior change; on → CorotTruss for truss, dispBeamColumn for beam), wired through a declare → resolve → emit broker channel mirroring g.reinforce (ReinforcementsCompositeresolve_reinforceFEMData.reinforce_tiesemit_reinforce_ties). The beam section is a circular fiber section from db + the bar's uniaxial steel material. Split into B1a (opt-in flag + truss auto-emit) → B1b (fiber section + per-segment vecxz reusing the existing compute_vecxz_for_element fan-out + ndf=6 rebar nodes via the ADR 0048/0049 overlay + gate removal). Twist stabilization (B2) is decided per B0.3: try existing zeroLength+SP first, no new fork C++ class tag unless that fails. Full design + the open B1a kickoff question (new broker record vs reuse the existing element-emission machinery) in internal_docs/plan_rebar_p5.md §B1; handoff Track-B table updated.

CHANGED — g.rebar Track-B B0 decision gate recorded (ADR 0067 P5.2/P5.3)

The B0 human-decision gate for Track B (element="beam" dowel rebar + twist) is resolved and recorded (docs only). All three decisions landed on the lighter-than-feared path after a code re-survey showed the infrastructure largely exists: (1) orientation = serialized Orientation (default AlongBeam) + roll_deg on the bar spec, with the bridge deriving each segment's vecxz at build via the existing compute_vecxz_for_element (the smooth beam-column orientation fan-out already exists; the rebar gap is only per-segment polyline tangents); (2) mixed-ndf = ndf=6 beam-rebar nodes via the existing ADR 0048/0049 per-node ndf overlay, host stays ndf=3, LadrunoEmbeddedRebar couples the 3 translations; (3) twist = try the existing zeroLength+SP first (ADR 20 D6 option 1), no new fork C++ class tag unless that proves insufficient. Recorded in internal_docs/plan_rebar_p5.md §B0 + the recommended sequence and the handoff_rebar_cage.md Track-B table. B1 (element="beam" rebar) is now execution-ready as a separate effort.

FIXED — retired the false H5ReinforceDeviationWarning on reinforced apeSees.h5 decks (ADR 0067 P5.1, A4 minimal)

apeSees(fem).h5(path) no longer warns that "the H5 deck will be missing its embedded reinforcement" — a claim that became false once A1 (#706) made fem.elements.reinforce_ties round-trip through the neutral zone. Because apeSees.h5 writes that neutral zone into the same archive as the /opensees deck zone, a reinforced model.h5 already carries its ties: it round-trips via FEMData.from_h5apeSees(fem).tcl()/py()/run() (the forward path re-runs emit_reinforce_ties). H5Emitter.embedded_rebar is now a silent deck-zone no-op and the H5ReinforceDeviationWarning class (+ __all__ entry + emission) is removed. A dedicated /opensees/constraints/reinforceTie deck record + OpenSeesModel.build() deck-replay (the "A4 full" item) stays deferred — not needed for any cage workflow, and gated behind the broader fact that _replay_into does not replay MP constraints either (documented in internal_docs/plan_rebar_p5.md §"A4 full"). Locked by tests/opensees/unit/test_reinforce_emit.py::test_h5_defers_deck_zone_without_warning (no warning + no deck-zone reinforce record) and tests/test_reinforce_composite.py::test_apesees_h5_deck_roundtrips_ties_via_neutral_zone (a reinforced apeSees.h5read_fem_h5 recovers all ties, no warning). Reinforce + opensees-h5 + rebar suites green (the two failing tests/opensees/h5 cases are the pre-existing openseespy-Windows-DLL ImportError, not this change).

ADDED — cross-partition equationConstraint replication under OpenSeesMP (ADR 0068 P5, Open item 2)

An enforce="equation" tie (g.constraints.tie(..., enforce="equation")) that straddles a partition boundary now emits under partitioned / OpenSeesMP output instead of fail-louding (NotImplementedError). _plan_rank_constraints (opensees/_internal/build.py) replicates the tie on every rank that owns the slave OR any master — the rigidDiaphragm replicate-on-owning-ranks rule, not the single-canonical-host-rank element rule the penalty (ASDEmbeddedNodeElement) tie uses (which, applied to a domain-level EQ_Constraint, would drop the constraint on slave-owning ranks and falsely error on a partition-cut master face — the adversarial finding that motivated the original fail-loud). A new _RankConstraintPlan.equation_records lane collects the per-rank ties; each owning rank ghost-declares the foreign slave/master nodes first (reusing _add_foreign_or_phantom) and emits byte-identical equationConstraint rows via _emit_one_interpolation_emit_equation_tie. Because the equation route allocates no element tag, replicating it across ranks is tag-stream-neutral (penalty-tie tag determinism is unchanged). The cross-rank EQ-capable handler (LadrunoProjection/Lagrange, auto-emitted per Open item 1) resolves the constraint graph across subdomains. Locked by tests/opensees/integration/test_emit_partitioned_replicate_on_both.py (test_cross_rank_equationConstraint_replicates_on_owning_ranks: 3 per-DOF rows on both owning ranks, byte-identical, foreign-node decls precede them; test_equationConstraint_single_owning_rank_no_spurious_replication: a rank-local tie emits only on its owning rank). ruff clean + mypy baseline 0; the partitioned integration sweep stays green. The emission logic is unit-verified without MPI; a live OpenSeesMP run is the final confirmation when a multi-rank fork build is available.

CHANGED — equation-tie handler auto-emit auto-detects implicit vs explicit (ADR 0068 P5, Open item 1)

When an enforce="equation" tie is present and the user declared no constraint handler, BuiltModel._maybe_auto_emit_constraint_handler now picks the EQ-capable handler by the registered integrator instead of always emitting Lagrange: an explicit integrator (CentralDifference/CentralDifferenceLadruno/ExplicitBathe/ExplicitBatheLNVD/ExplicitDifference) auto-emits the fork LadrunoProjection (Δt-neutral, momentum-conserving — a Lagrange multiplier's massless DOF would break the explicit mass solve); implicit / no integrator keeps Lagrange (exact). The classifier is the new shared _is_explicit_integrator, refactored out of apeSees._check_explicit_solver_compat so the two call sites can't drift. Declaring a handler is still the override (respected as before), so no tie_handler= kwarg was needed; INV-4 still fail-louds on Transformation/Auto + an equation tie; and a soft OpenSeesAutoEmitWarning now fires when a user explicitly pairs Lagrange with an explicit integrator + an equation tie (the massless-multiplier hazard). Locked by tests/test_constraint_emission_phase7b.py (TestEquationTieHandlerAutoDetect: explicit→LadrunoProjection, implicit→Lagrange, no-integrator→Lagrange, user-Lagrange+explicit→warns-but-respected). ruff clean + mypy baseline 0 held.

FIXED — harden embedded-reinforcement tie H5 (de)serialization (ADR 0067 P5.1, adversarial review)

A multi-agent adversarial review of the P5.1 work (A1 H5 persistence + A2/A3 compose) confirmed a small cluster of serialization-boundary gaps; the rest of the review verified the design is sound (snapshot_id correctly excludes ties, compose tag-offset/accumulation/cross-Part guard all correct). Fixes: _encode_reinforce_tie now fails loud on a malformed ReinforceTieRecord instead of writing a record that would decode to garbage or emit an invalid LadrunoEmbeddedRebar — it rejects empty host_nodes (a tie must couple ≥ 1 host node), an empty-but-non-None weights array (keeps the None vs [] distinction unambiguous), and a weights length that doesn't match host_nodes (the documented "parallel" invariant). _decode_reinforce_tie mirrors the length check defensively so a corrupted file is refused loudly rather than silently emitting a wrong element. The cross-Part guard (_guard_reinforce_cross_part) gains a comment documenting its "node in no named Part is unconstrained" semantics (fires only on ≥ 2 distinct named Parts — avoids false positives on partial Part maps while still catching the real host-nodes-split-across-Parts case). Locked by tests/mesh/test_reinforce_tie_h5_roundtrip.py (encode rejects empty host / empty-array weights / mismatched weights; a pre-2.15.0 (2.14.0, no-/reinforce_ties-group) file still reads within the two-version window → empty ties). Known open items (documented, not regressions): partitioned-tie dedup, and bond-name re-emit requires the (namespace-prefixed) LadrunoBondSlip material to be declared after compose but before apeSees.build().

ADDED — tie-force recovery for equation-tied interfaces (ADR 0068 P5)

The non-matching equation-tie route (g.constraints.tie(..., enforce="equation")) can now report the interface force it carries — the apeGmsh analogue of LS-DYNA *DATABASE_NCFORC — via the fork OpenSees tie force f = M(a_raw - a_proj) (the LadrunoProjection handler's projection constraint force, ADR-30 P3/P4, already shipped on ladruno). Two routes, both minimal because the plumbing already existed: (1) live queryapeSees.ladruno_projection_tie_force(node, dof) (delegates to a new fork-gated LiveOpsEmitter.ladruno_projection_tie_force, mirroring critical_time_step) returns the tie force at a node/DOF from the last projection step of a prior live analyze(...); fails loud (BridgeError) before any live run, and RuntimeError on a stock build with no ladrunoProjectionTieForce. Works implicit and explicit. (2) recorder readbackops.recorder.Ladruno(nodal_responses=("constraintTieForce",)) already emitted the -N constraintTieForce channel verbatim; the only gap was reading it back, closed by a single _NODAL_RESULT_NAME_MAP entry (CONSTRAINT_TIE_FORCEconstraint_tie_force) so a .ladruno file is consumable via results.nodes.get(component="constraint_tie_force_x") (vec3/node, explicit-only — the channel is scattered each commit by CentralDifferenceLadruno). Locked by tests/test_tie_force_helper.py: the readback mapping + the BridgeError gate run anywhere; the live legs (query == exact F·m₂/(m₁+m₂) tie force, wrong-handler guard, recorder round-trip) need an openseespy built from ladruno ≥ ADR-30 P3/P4 and skip cleanly on older builds. No declarative-vocabulary change (deferred); emission stays the documented Ladruno(nodal_responses=…) passthrough.

CHANGED — g.rebar handoff refreshed for P5 Track-A progress (internal_docs/handoff_rebar_cage.md)

Brought the handoff current with the P5 Track-A work: the status header now reflects bundled bars / the wall generator / mesh-native curved geometry / and the composed-Part keystone (A1 #706 + A2+A3 #707) shipped; the P5 section is restructured into Track A (A1 ✅ neutral-H5 tie persistence, A2+A3 ✅ compose carry + cross-Part guard, A4 ⬜ opensees-deck follow-on) and the B0-gated Track B (beam dowel + twist), pointing at internal_docs/plan_rebar_p5.md; the partitioned-tie dedup open item and the P5 test locations (tests/mesh/test_reinforce_tie_h5_roundtrip.py, test_compose_reinforce_ties.py) are noted. Docs only.

ADDED — g.compose carries embedded-reinforcement ties + cross-Part guard (ADR 0067 P5.1, A2 + A3)

Building on the A1 neutral-H5 tie persistence, g.compose(...) now rewrites and merges fem.elements.reinforce_ties so a composed-Part cage keeps its reinforcement. Each source's ties are offset-rewritten through the existing tag_rewrite_spec (rebar_node scalar + host_nodes array shifted by the module's tag offset; name + bond namespace-prefixed with the module label) and appended to the merged model; the host's own ties are preserved across the merge (previously the rebuilt ElementComposite dropped them). The geometric arrays (weights, direction) are left untouched by the rewrite. Both tracks ship in one PR to close the silent-corruption window the critique flagged: a new cross-Part guard (_guard_reinforce_cross_part) raises ComposeReinforceCrossPartError when a tie's rebar_node + host_nodes span two different source Parts — an embedded tie's bond assumes a co-meshed host, so the offset rewrite would otherwise produce broken conformal topology. (The guard is deliberately not extended to tied-contact SurfaceCouplingRecords, which are designed to bridge surfaces and may legitimately span Parts.) The guard is a no-op for a source with fewer than two Parts or no Part map. Locked by tests/mesh/test_compose_reinforce_ties.py (carry + round-trip, constant tag offset with geometry preserved, host-tie preservation when composing a plain module, and the cross-Part guard: raises across Parts / passes same-Part / no-ops without Parts). Compose + mesh suites green.

ADDED — embedded-reinforcement ties round-trip through the neutral model.h5 (ADR 0067 P5.1, neutral schema 2.15.0)

fem.elements.reinforce_ties — the g.reinforce LadrunoEmbeddedRebar couplings (one ReinforceTieRecord per rebar node) — now persist through FEMData.to_h5 / from_h5, into a new dedicated /reinforce_ties group (a symmetric-compound dataset via reinforce_tie_payload_dtype, modeled on surface_coupling). Previously to_h5 dropped them with a deferral warning, so a reinforced model silently lost its reinforcement on round-trip — this was the keystone blocking composed-Part cage libraries (P5.1 / "do_first" of the P5 plan). The bump is the neutral zone NEUTRAL_SCHEMA_VERSION 2.14.0 → 2.15.0 (the composed-Part library round-trips through the neutral zone; the opensees deck zone 2.19→2.20 is a separable follow-on, A4); the two-version reader window auto-tolerates 2.14.x (which simply has no /reinforce_ties group). The group is omitted entirely when there are no ties, so a tie-free model stays byte-identical and its snapshot_id is unchanged — and snapshot_id deliberately does not hash the tie overlay (consistent with how constraints are already excluded), so even a reinforced model round-trips with an identical snapshot_id. Optional scalars use NaN/"" sentinels with has_* flags for the geometric vlen/fixed fields, so None survives distinct from empty; the bond material is stored by name (Option B; resolved to a tag at re-emit). Locked by tests/mesh/test_reinforce_tie_h5_roundtrip.py (perfect-bond + bond-by-name round-trip with field-by-field equality, tie-free group omission + snapshot stability, reinforced-model snapshot stability, no deferral warning, version stamp); the obsolete test_to_h5_warns_ties_not_persisted is flipped to assert persistence. Build on a real non-matching mesh (no fork build needed).

ADDED — g.rebar P5 implementation plan (internal_docs/plan_rebar_p5.md)

A forward-looking, critique-hardened implementation plan for the three externally-blocked g.rebar P5 items (composed-Part cage libraries via H5 tie persistence; element="beam" dowel-action rebar; bar-axis twist stabilization). Produced by a survey → synthesize → critique multi-agent workflow. Records the verified load-bearing finding — the keystone is the neutral H5 zone (NEUTRAL_SCHEMA_VERSION 2.13.0 → 2.14.0, reinforce_ties persistence modeled on surface_coupling), not the opensees deck zone — and lays out Track A (A1 persist/read → A2+A3 compose teach-in with the cross-Part guard in one PR → A4 deck follow-on) and Track B (B0 human-decision gate → B1 per-segment vecxz + ndf=6 → B2 twist stabilizer → B3 ghost-node persistence), with the open snapshot_id/lineage decision called out as the A1 pre-gate. Docs only — no code.

ADDED — g.rebar mesh-native curved geometry (Path(curve=…) + circular_column(true_arc=True))

The Path L1 spec gains a curve kind — "polyline" (default, straight segments), "arc" (true circular arcs about a new arc_center), or "spline" (one C2 interpolating spline through the points) — so a bar/stirrup can be authored as mesh-native curved geometry instead of a fixed authored polygon. The L2 emitter (_emit_curve, replacing _emit_polyline) welds each kind into gmsh add_arc / add_spline / add_line accordingly. g.rebar.circular_column(true_arc=True) uses it: the discrete hoops become true circular arcs and the spiral a spline, so the mesher seeds nodes on the true curve at the active element size rather than the n_segments polygon (deterministic polygon stays the default). Hand authoring exposes it via g.rebar.bar(..., curve="arc", arc_center=…) / curve="spline" and g.rebar.stirrup(...). Caveat (documented): the realised FE elements are still straight 2-node chords — OpenSees has no curved line element — so true_arc upgrades node placement / curve fidelity, not the element type. Also fixes a latent stale-metadata leak: arc-center construction points are now popped from the apeGmsh registry when occ.removed (previously only the OCC entity was dropped, which tripped the geometry validator at mesh time — this would have hit true-arc hooks under conformal meshing too). Locked by tests/rebar/test_rebar_true_arc.py (8 tests: L1 curve validation + round-trip, hoop-arc / spiral-spline generation, polygon default, embedded place, conformal mesh end-to-end, hand-authored arc/spline bars). 175 rebar + guard tests green.

ADDED — g.rebar.wall — RC wall cages (the 4th standardized member)

g.rebar.wall(length=, thickness=, height=, cover=, vertical_db=, vertical_spacing=, horizontal_db=, horizontal_spacing=, curtains=2, …) builds a reinforced-concrete wall cage (vertical panel, plane x-z, thickness along y) — the fourth standardized member alongside column() / beam() / circular_column(). Vertical bars run along the height (spaced along the length) and horizontal bars run along the length (spaced up the height), in one or two curtains: curtains=2 (default) places a layer cover + db/2 in from each face, curtains=1 a single layer at mid-thickness. Walls are spaced, not counted — vertical_spacing / horizontal_spacing are the max centre-to-centre pitches, rounded to an even division between the end_cover insets (new _positions_by_spacing helper). For a double curtain, crossties=True (default) ties the two curtains together through the thickness on a grid (crosstie_spacing, default twice the coarser bar spacing) with a 135° seismic + 90° hook resolved from the standard (ACI 318 §11.7.4 / §18.10.2.7); a single-curtain wall warns and emits none. Bars carry role="vertical" / "horizontal" / "crosstie"; each is an independent truss/embedded member, so place() (conformal or embedded) and per_member_coupling work unchanged. Vertical and horizontal bars of a curtain are idealised co-planar at the vertical-bar depth (a truss model). Boundary-element confinement is out of scope — model a confined wall end with column() over the boundary zone. Locked by tests/rebar/test_rebar_wall.py (7 tests: double-curtain grid + curtain planes, through-thickness cross-ties, single-curtain mid-thickness, single-curtain crosstie warning, embedded placement as independent members, validation, conformal mesh end-to-end). 167 rebar + guard tests green.

ADDED — g.rebar.beam detailing parity: overlapping-hoop style + full mismatched cross-tie support

Two beam-detailing improvements bringing g.rebar.beam() to column-level parity. (1) confinement_style="overlapping_hoops" — the wide-beam sibling of the column knob: instead of straight vertical cross-tie legs, the cross-section is tiled with closed, overlapping rectangular cell-stirrups (one per adjacent bar column, the bottom edge on the bottom bars and the top edge on the top bars, so neighbours share a vertical edge and every bar sits at a hoop corner), alongside the outer perimeter stirrup. Cell-stirrups are role="tie" (twin-tail closure + seismic spacing apply). It needs equal top/bottom bar counts (a regular grid) and raises otherwise, directing to "crossties". Default stays "crossties". (2) Cross-ties now support every interior bar on a count mismatch_beam_crossties ties each interior bar (top and bottom) to the nearest bar on the opposite face rather than only the index-aligned min(n)−2 interior pairs. When the counts/positions align the legs stay vertical (unchanged); when they differ each interior bar still gets a leg to its nearest opposite bar (slightly inclined), so none is left unsupported, with duplicate bottom↔top pairs coalesced. The count-mismatch warning is retained (now noting the legs may be inclined). Locked by tests/rebar/test_rebar_generators.py (cell tiling count + closed 4-corner y-z rings, equal-count guard, embedded place, style validation, all-interior-supported-on-mismatch); two existing beam tests updated for the more-complete behaviour. 153 rebar tests green.

ADDED — g.rebar bundled longitudinal bars (ACI 318-19 §25.6)

BarLayout(bundle=2|3|4, bundle_pattern="auto"|"line"|"triangle"|"square") replaces each longitudinal position in g.rebar.column() / circular_column() / beam() with a contact bundle of that many parallel bars — the geometry layer realises a bundle as that many individual offset bar lines, each a distinct truss/embedded member (so coupling and detailing are unchanged). The cluster is offset rigidly in the bar's cross-section frame: the outer bars sit on the nominal cover line and the cluster stacks inward toward the section interior (no bar is shallower than the single-bar position along the inward normal; at a true corner a tangentially-spread pair leans toward a face by at most √2/2·d_b — inherent to bundling, so inset for the equivalent diameter √n·d_b if strict corner cover matters). "auto" maps the count → line (2, side-by-side) / triangle (3) / square (4, 2×2); an explicit pattern must match the count. Cross-ties and hoops still engage the outer bar at the nominal position. A new hand-authoring helper g.rebar.bundle(points, *, n, db, material, toward, pattern="auto", spacing=None, ...) returns a tuple of Bar for free-form bundled bars (the cluster leans toward the toward interior point; spacing is the centre-to-centre offset, default the bar diameter — a contact bundle). Validation (ACI §25.6.1.1): 1–4 bars, the pattern matches the count, and a #14/#18 bar is capped at 2 per bundle; the generators also fail-loud when the inward stack would cross the section centre. Locked by tests/rebar/test_rebar_bundles.py (17 tests: L1 validation, column/circular/beam expansion + cover-line geometry + inward stacking + fit guards, hand-authoring, embedded placement as independent members). 148 rebar tests green.

CHANGED — g.rebar handoff refreshed for the shipped detailing arc (internal_docs/handoff_rebar_cage.md)

Brought the handoff doc current with PRs #687–#693: status header now reflects the full ACI detailing arc on main (135 tests), the L2 row + quickstart list circular_column(), limitation #1 notes the confinement_style="overlapping_hoops" alternative, a new item #5 records circular columns (hoops/spiral) shipped + the genuine remaining minor gaps (polygon-approx circles, bundled bars, no beam overlapping-hoops), the test count is 135, and a working note warns to scope ruff --fix to exact files (not a directory). Docs only.

ADDED — g.rebar reinforcement-cage user guide (internal_docs/guide_rebar.md)

A user-facing guide for the now-complete g.rebar cage-authoring API: picking a DetailingStandard (Raw / ACI318 / ACI318_seismic) and the BarCatalog unit knob; the rectangular column() (cross-ties vs confinement_style="overlapping_hoops", ACI §18.7.5 seismic confinement auto-derive), beam() (supplementary legs + §18.6.4 hoop zone), and circular_column() (hoops vs spiral) generators; place() conformal vs embedded coupling + per_member_coupling + twin_tail; hand-authoring bars/stirrups via the fluent builder; and the documented limits. Consolidates the §8/§3 detailing runway (PRs #687–#692) into one reference. Docs only — no code change.

ADDED — g.rebar.circular_column — circular RC column cages (hoops or spiral) (ADR 0067 §8)

g.rebar.circular_column(diameter=, height=, cover=, n_bars=, bar_db=, ties=...) builds a circular column cage — the round sibling of the rectangular column(). n_bars longitudinal bars are evenly spaced on a circle (radius D/2 − cover − tie − db/2), and the transverse reinforcement is either discrete circular hoops (spiral=False, default — one closed ring per tie level on radius D/2 − cover − tie/2, densified in the hinge zones like the rectangular column) or a continuous spiral (spiral=True — one helix end-to-end at pitch ties.spacing). Rings/helix are polygon-approximated with n_segments sides per turn (default 24). Circular confinement laterally supports every bar, so there are no cross-ties. When the standard is ACI318_seismic and ties omits the hinge fields, the §18.7.5 confinement zone is auto-derived (h_x = the bar chord spacing on the circle). The spiral is emitted as a single role="spiral" truss member (a chain of segments along the helix); hoops are role="tie" stirrups (twin-tail closure applies). Bars/hoops are interior to the host, so both couplings mesh; placement reuses the same place() path. Locked by tests/rebar/test_rebar_generators.py (bars on circle + even spacing, closed-ring hoops on the tie radius, single-helix spiral spanning the height, seismic densification, embedded place into a cylinder, validation). 131 rebar tests green.

ADDED — g.rebar.column overlapping cell-hoop confinement style (ADR 0067 §8)

g.rebar.column(..., confinement_style="overlapping_hoops") is an alternative to the default straight cross-ties for laterally supporting the intermediate (n>2 per face) longitudinal bars: instead of straight cross-tie legs, the core is tiled with a grid of closed, overlapping rectangular cell-hoops — one per adjacent 2×2 bar block, corners on the longitudinal bar centerlines — so neighbouring cells share an edge (overlap) and every bar sits at a hoop corner. The outer perimeter hoop is emitted in both styles. This is the wide-section detail where a single perimeter hoop plus cross-ties would otherwise carry many long unsupported legs. The cell-hoops are ordinary role="tie" stirrups (twin-tail closure + the §18.7.5 seismic spacing apply); embedded coupling is robust, conformal forms shared-edge T-junctions needing make_conformal. Default confinement_style="crossties" is unchanged. Locked by tests/rebar/test_rebar_generators.py (cell tiling count + closed 4-corner rings on bar centerlines, embedded place, style validation). 129 rebar tests green.

ADDED — g.rebar twin-tail stirrup/hoop closure (ADR 0067 §3)

A closed stirrup/tie is bent from a single straight bar, so its two free ends both terminate in a hook that overlap at the seam corner. g.rebar.place(...) now emits that twin-tail seam by default — both ends of a closed stirrup carry the closure hook — instead of the previous simplified single closure hook. The second tail mirrors the resolved closure detail (e.g. a seismic 135° hook) onto the start end, anchored at the same seam node with its own outward tangent so the two tails fan into the core. New twin_tail=True knob on place() (set twin_tail=False for the single-hook simplification). A stirrup with an explicit start hook, or one whose closure was dropped because no DetailingStandard is set, is unaffected; cross-ties (already two-ended) and longitudinal bars are untouched. Locked by tests/rebar/test_rebar_hooks.py (6 emitted curves = 4 ring legs + 2 tails with twin-tail; 5 = 4 + 1 with twin_tail=False). 122 rebar tests green.

ADDED — g.rebar.beam auto-derives the ACI 318 §18.6.4 seismic hoop confinement zone (ADR 0067 §8)

The beam sibling of the column confinement auto-derive. When the active standard is ACI318_seismic and the stirrups TieLayout leaves hinge_spacing/hinge_length unset, g.rebar.beam(...) now auto-derives the special-moment-frame hoop zone instead of uniform spacing: the confined length 2h (twice the member depth, §18.6.4.1) and the dense hoop spacing min(d/4, 6·d_b of the smallest longitudinal bar, 6 in) (§18.6.4.4), with d taken to the tension-bar centroid. The user's stirrups.spacing governs outside the hinge zones; an explicit TieLayout(hinge_length=, hinge_spacing=) overrides verbatim; a non-seismic standard (ACI318/Raw) stays uniform (no-op). The ACI numbers live on new ACI318_seismic.beam_confinement_length(...) / beam_confinement_spacing(...) methods (unit-safe via the catalogue), keeping the beam generator free of code constants. A warning reports the derived length/spacing. Locked by tests/rebar/test_detailing.py (2h, governing-term + 6 in cap + unit-safety + guards) and tests/rebar/test_rebar_generators.py (auto-derive densifies, non-seismic stays uniform). 125 rebar tests green.

ADDED — g.rebar.column auto-derives the ACI 318 §18.7.5 seismic confinement zone (ADR 0067 §8)

When the active standard is ACI318_seismic and the TieLayout leaves hinge_spacing/hinge_length unset, g.rebar.column(...) now auto-derives the special-moment-frame confinement zone instead of falling back to uniform spacing (the previous behaviour, which only fired a warning). The confined-end length l_o = max(member depth, clear span / 6, 18 in) (§18.7.5.2) and the dense tie spacing s_o = min(¼·min member dimension, 6·d_b of the smallest longitudinal bar, 4 + (14 − h_x)/3 in clamped to [4, 6] in) (§18.7.5.3) are computed from the section geometry — h_x is the max centre-to-centre spacing of the laterally supported bars (the cross-tie/bar spacing, capped at 14 in; the full clear distance when crossties=False). The user's ties.spacing is used outside the hinge zones; an explicit TieLayout(hinge_length=, hinge_spacing=) overrides the code rule verbatim, and a non-seismic standard (ACI318/Raw) leaves the spacing uniform (no-op). The two ACI numbers live on the standard as new ACI318_seismic.confinement_length(...) / confinement_spacing(...) methods (unit-safe via the catalogue — the s_o equation is evaluated in inches), keeping the column generator's geometry logic free of code constants. A warning reports the derived l_o/s_o/h_x so the values are visible. Locked by tests/rebar/test_detailing.py (governing-term + s_o caps + unit-safety) and tests/rebar/test_rebar_generators.py (auto-derive densifies, explicit override, non-seismic stays uniform). 115 rebar tests green.

ADDED — g.rebar cross-ties / supplementary legs for intermediate bars (ADR 0067 §25.7.2.3)

g.rebar.column(...) and g.rebar.beam(...) now generate ACI 318 §25.7.2.3 cross-ties that laterally support the intermediate (n>2 per face) longitudinal bars — closing the largest v1 detailing gap (previously a single perimeter hoop was emitted with a warning). A column gets one cross-tie per intermediate bar at every tie level: a straight transverse leg spanning the section between the two opposite-face bars it engages, with a 135° seismic hook on one end and a 90° hook on the other, alternated end-for-end on consecutive levels (ACI 318 §18.7.5.2). A beam gets a vertical supplementary leg at every stirrup station for each index-aligned interior top/bottom bar pair (unpaired bars on a count-mismatched face are skipped with a warning). Cross-ties use the tie bar size/material, carry role="crosstie" (so per_member_coupling={"crosstie": …} can route them), and resolve their hook tails from the cage's DetailingStandard at place time (seismic-hoop kind; dropped with a warning when no standard is set, like a stirrup closure). New crossties=True knob on both generators (set crossties=False for the bare perimeter hoop). The end-hook resolution is now role-aware — transverse members (tie/crosstie/hoop/stirrup) detail their end hooks as seismic hoops and tolerate a missing standard; longitudinal-bar development hooks stay primary + required. coupling="embedded" is robust; conformal cross-ties form bar/tie T-junctions that need make_conformal. Locked by tests/rebar/test_rebar_generators.py (interior-bar support, end-for-end alternation, opt-out, embedded place with ACI318_seismic, beam aligned legs + count-mismatch warning). 107 rebar tests green.

ADDED — DRM ASD absorbing boundary via add_DRM_box_from_h5drm(absorbing=True) (ADR 0066, D-4)

g.parts.add_DRM_box_from_h5drm(..., buffer=N, absorbing=True) (requires buffer >= 1) now wraps the buffered DRM box in a one-element ASD absorbing skin — a btype-tagged ghost ring on the four sides + bottom (never the free surface) — the production-SSI boundary (ADR 0054). The skin sits on the buffer's outer, NON-dataset faces (≥1 buffer layer between it and the DRM b shell), so H5DRM never sweeps the boundary into the effective-force set (H5DRMLoadPattern.cpp:580). It is built in the validated z-down frame (the same sliced-block machinery as the buffer, with the outer ring classified into btypes via the shared _btype_forplane_wave_box can't be reused directly because it builds z-up). The result's skin is a complete AbsorbingSkinResult that drops straight into the existing tested facade: ops.element.absorbing_boundary(skin=result.skin, material=…) + the staged s.activate_absorbing() gravity→absorbing flip; assign soil/stdBrick over result.domain_pg (inner + buffer). With absorbing=False the boundary stays the bare buffer faces (apply ops.fix for the validated fixed far field). Locked by tests/parts/test_h5drm_box.py (17 btypes incl. the bottom subset, conformal node count + station coincidence with the skin present, and bridge composability — absorbing_boundary(skin=…) fans 17 ASDAbsorbingBoundary3D specs). ADR 0066 runway D-1→D-4 complete. Plan: internal_docs/plan_drm_h5drm_adr0066.md.

ADDED — DRM exterior buffer via add_DRM_box_from_h5drm(buffer=N) (ADR 0066, D-3)

g.parts.add_DRM_box_from_h5drm(..., buffer=N) now wraps the inner DRM box in N exterior soil layers on the four sides + the bottom (never the free surface), at the same grid spacing — the stable path for a real run (a free DRM box diverges: the residual excites rigid-body modes). It is built as one block sliced at the inner breakpoints (the proven plane_wave_box machinery), so the inner/buffer interface is conformal by construction and the inner sub-volume still lands nodes exactly on the dataset stations, while the buffer hexes carry only non-dataset nodes — so H5DRM excludes the buffer/boundary elements from the effective-force set (H5DRMLoadPattern.cpp:580, the all-nodes-DRM rule). The DRMBoxFromH5Result gains layers, buffer_pg, domain_pg (inner+buffer roll-up — the target for material / stdBrick assignment), and exterior_pgs now reports the outer model-boundary faces (sides+bottom). API note: this folds the buffer into the existing builder (buffer= param, backward-compatible — buffer=0 is the D-2 behavior) rather than the ADR's sketched separate g.drm_buffer(drm) call, because extending an already-transfinite box via fragment is fragile and a rebuild would discard the D-2 geometry. Applying the boundary stays bridge-side (not a builder param): ops.fix(pg=…) for a fixed far boundary, Lysmer/ASD elements for absorbing — the asd staged-flip path is D-4. Locked by tests/parts/test_h5drm_box.py (conformal node count == the extended structured grid, station coincidence with buffer, soil/buffer/domain + outer-face element counts). Plan: internal_docs/plan_drm_h5drm_adr0066.md.

ADDED — g.parts.add_DRM_box_from_h5drm dataset-keyed DRM box (ADR 0066, D-2)

g.parts.add_DRM_box_from_h5drm(h5drm=, crd_scale=1000.0, name=, names=, apply_transfinite=True) — reads a ShakerMaker-style .h5drm DRM dataset and builds, in the live session, a single transfinite hex soil box whose nodes land EXACTLY on the dataset stations (so OpenSees' H5DRM node-matching is trivial — the fork study's "98/98 nodes" property). It validates that the stations form a complete, uniform, isotropic regular grid, centres the box on the file's drmbox_x0 (z-down, metres) so the matching ops.pattern.H5DRM(...) defaults reproduce the coordinates exactly, and tags the soil volume PG plus the six outer boundary-face surface PGs (xmin/xmax/ymin/ymax/top/bottom — the dataset "b" shell, cross-checked against the internal flag with a WarnDRMGridIrregular). Returns a DRMBoxFromH5Result carrying the PGs (incl. free_surface_pg, the sides+bottom exterior_pgs, and a boundary_all_pg roll-up), the frame contract (crd_scale / identity transform / zero x0 / center), and the grid descriptor (origin / spacing / counts). Geometry + PGs only — assign the soil material + stdBrick elements via the bridge (ops.element.stdBrick(pg=result.soil_pg)), the dataset-keyed sibling of the parametric g.parts.add_DRM_box. The exterior buffer (g.drm_buffer) is D-3. Plan: internal_docs/plan_drm_h5drm_adr0066.md.

CHANGED — partitioned coupling/embedded split-across-ranks fail-loud now names the recovery path

When a kinematic_coupling (RBE2), distributing_coupling (RBE3), or embeddedNode (ASDEmbeddedNodeElement) has its required node set fragmented so that no single OpenSeesMP rank can assemble the one element, the build still fails loud (unchanged, deliberate — ADR 0027 treats this as a partitioner-input condition, not a recoverable one), but the two messages (_canonical_coupling_rank / _canonical_host_rank in opensees/_internal/build.py) now point at the remedy: re-partition from the mesh phase with g.mesh.partitioning.partition_explicit(...), placing every required node's incident elements on one rank. New guide_partitioning.md §7.1 documents the case end-to-end — why element-backed couplings need a single canonical rank (vs idempotent equalDOF/rigidDiaphragm replication), why a boundary node is fine but a node-only contact between two bodies can split (METIS cuts by shared faces/edges, not nodes — _build_dual_graph), the partition_explicit recovery recipe, the chain-phase-freeze caveat (you must rebuild from the mesh phase, ADR 0038), and a note that the super-vertex-contraction partitioner that would make clusters indivisible to METIS is feasible-but-unbuilt. Docs + message strings only; no behavior change (the split across partitions match substring is preserved — 13/13 affected tests pass, mypy clean).

ADDED — ops.pattern.H5DRM typed DRM load pattern (ADR 0066, D-1)

ops.pattern.H5DRM(h5drm=, factor=1.0, crd_scale=1000.0, distance_tolerance=1.0, transform=None, x0=(0,0,0)) — a typed, field-carrying load pattern that drives a soil box with a regional incident wavefield read from an .h5drm dataset (e.g. a ShakerMaker synthetic) via OpenSees' H5DRM pattern. It owns the error-prone frame handshake validated in the OpenSees fork DRM study (ADR 0066 / PR #296): the defaults encode a model built centred at the lateral origin, z-down, in metres, so the default crd_scale=1000 (km→m) with an identity transform and zero x0 reproduces the dataset's station coordinates exactly. Emits the canonical 18-arg pattern H5DRM tag file factor crd_scale dist_tol 1 T00..T22 x00..x02 line (Tcl + openseespy; do_transform always 1) and round-trips through model.h5 generically (no series, no body). Unlike every other pattern it has no series= — the motion history lives in the file. The 3-DOF≤8-node / no-base-input-mixing guards (which need the FEM snapshot) and the dataset-keyed box builder (g.parts.add_DRM_box_from_h5drm) + exterior buffer (g.drm_buffer) land in D-2/D-3. Plan: internal_docs/plan_drm_h5drm_adr0066.md.

ADDED — finite-difference sensitivity driver (apeGmsh.sensitivity)

New apeGmsh.sensitivity sub-package — compute how a response changes with any model parameter (every damping channel included) by black-box finite differences on top of the apeSees bridge, with no solver edits and no analytic derivative. Sensitivity.from_apesees(fem, build=, params=[Param(...)], response=Response(...)) builds the deck via your build(ops, params), runs a transient, reads the response through Results, and differentiates it: .gradient() returns the full vector for one or many parameters (cost 2N central / N+1 forward solves), .step_study() exposes the step-size trust plateau, .solve(target) calibrates (1-D, damped Newton). The engine-free FDSensitivity core, reduce_response reducer, and Param/Response specs are fully unit-tested (tests/sensitivity/unit/); the one engine-coupled step (run-and-read) is a single replaceable runner= seam (default_apesees_runner). Ports the capability validated in the Ladruno OpenSees fork (PR #241 — all six damping channels demonstrated, SDOF + viscous matching closed forms to 0.00%, adversarially reviewed non-circular). See internal_docs/guide_sensitivity.md.

REMOVED — DeformedShapeDiagram retired; deform-follow is now unconditional (ADR 0058 S4, ACCEPTED)

The final ADR 0058 slice closes the arc: the legacy DeformedShapeDiagram layer — which warped its own copy of the substrate and rendered an undeformed wireframe ghost — is deleted, because both of its jobs are now first-class concurrent-geometry features. A deformed shape is a geometry with deform enabled (the per-geometry DEFORM pump warps scene.grid.points, shipped S2b); the undeformed reference is the add_reference_ghost preset (shipped S3c). With the last self-warping layer gone, the deform-follow contract is now unconditional: tests/viewers/test_deform_follow_contract.py drops its sole _EXEMPT entry and the exemption mechanism itself (test_exempt_list_is_live retired) — every diagram class that paints on the substrate must follow the deformed configuration, with no opt-out to grow back. Session migration is fail-soft: a legacy session carrying a deformed_shape diagram drops just that spec on load with a clear viewer.session/retired_diagram_dropped log line (recognized-but-retired branch in deserialize_spec; no schema bump — the rest of the session's hierarchy loads intact). Also removed: the settings-tab deformed-shape panel, the demo block, and the deformed-shape test cases; the "deformed_shape" kind string survives only as the migration-recognition literal. ADR 0058 is now Accepted — slices S0–S4 all shipped (#623–#651).

ADDED — reference-ghost preset + duplicate-with-layers (ADR 0058 S3c)

The two most-common concurrent-geometry asks become one gesture each. GeometryManager.add_reference_ghost(geom_id) is a preset verb (pure state, headless-testable) that composes the manager's own mutators into an EMPTY substrate-only geometry named "<src> (reference)": deform off, nodes off, dimmed to the GHOST_OPACITY (0.3) module constant, with offset and stage_id copied from the source so the ghost overlays it exactly, and the source left active (the ghost is decoration — the underlying duplicate() flips the active pointer to the clone, and the preset restores it). It carries no compositions — a dimmed reference must not double the source's contours; layers on a ghost are a different gesture (duplicate-with-layers + manual deform-off). It coexists with DeformedShapeDiagram's _runtime_show_undeformed ghost (that retirement is S4). ResultsDirector.duplicate_geometry(geom_id) is the director-level richer verb: it composes the manager's state-only duplicate with diagram reconstruction from each layer's DiagramSpec, replaying the exact _apply_session restore recipe (kind_def(spec.kind).diagram_class(spec, results), tag_map= for section_cut), wrapped in session_batch. Composition membership is recorded before registry.add so attach resolves the clone's scene through the existing scene_resolver (geometry_for_layer hits the clone, not the active-geometry fallback); the active-composition pointer is restored by position; layers that fail to rebuild (NoDataError, unknown kind) are skipped fail-soft. The copy rule is "what's in the spec round-trips" — runtime overrides (_runtime_show_undeformed, live scale/colormap tweaks), probes/highlights, and manual per-scene hides are explicitly NOT copied. The outline geometry-row Duplicate action upgrades to the director verb; a new Add reference ghost action calls the manager verb inside gesture_batch. No session changes — a ghost is an ordinary geometry after creation (no linkage field, by design: rename-safe, independently deletable). Locked by tests/viewers/test_scene_instances_s3c.py (ghost: empty/dimmed/deform-off/nodes-off, offset+pin copied, source-active, unique names; duplicate: composition order, distinct instances with equal specs, registry growth, clone-scene resolution, runtime overrides not copied, fail-soft skip, active-comp restore by position, section_cut tag_map path; outline gesture wiring; qt end-to-end ghost pair + duplicate-with-layers).

ADDED — per-geometry stage pin (ADR 0058 S3b)

A geometry can now be pinned to one stage while the viewport scrubs another — stage-A final configuration next to stage-B evolving, or step-by-step comparison of equal-length stages. Geometry.stage_id (None = follow the active stage) is owned by GeometryManager.set_stage_pin(geom_id, stage_id) firing the new granular GEOMETRY_STAGE_PIN_CHANGED (dispatcher matrix row STEP + DEFORM); duplicate() copies the pin. The time cursor stays director-global (per-geometry step cursors were ADR-rejected): a pinned geometry shows its stage's state at the global cursor clamped into the pinned range via the new director.local_step_for_stage(stage_id) (single-stage: clamp(step, 0, n_steps(S)−1); combined mode: the cursor minus the stage's boundary start, so the pinned geometry plays its segment of the concatenated timeline and freezes outside it). The pin scopes three read paths under one pinned-or-active rule: the substrate deform read (_read_deform_field(stage_id=) / _compute_deformed_pts), the geometry's diagrams (the registry stamps a stage_pin_resolver — S2b bar_prefix_resolver mirror — consumed by the new Diagram._effective_stage_id(); an explicit per-diagram spec.stage_id still wins, and the ReactionsDiagram defensive override shares the same helper so the two can't drift; the STEP pump pushes per-diagram effective steps), and the per-scene LAYER_STAGE activation masks (StageActivationController.mask_for_stage_id(sid), applied per geometry by _materialize_scene / _sync_stage_layers with the resync riding a RENDER-lane subscriber). A pin change re-attaches only that geometry's attached diagrams via a director typed-observer (GEOMETRY_REMOVED precedent — runs before the dispatcher pumps, so STEP/DEFORM land on fresh attachments). The shift-click time-history snap is now pin-aware (closing the third S2c deferral): install_navigation hands the picked prop through, the snap reads the hit geometry's grid, and the history tab is scoped to its stage pin (director.read_history(stage_id=) / TimeHistoryPanel(stage_id=); pinned and active histories of the same node coexist as separate tabs). The geometry settings panel grows a Stage combo ("Follow active stage" + real stages; combined excluded; disabled on single-stage files). Session schema bumps to v7 with an additive GeometrySnapshot.stage_id (legacy sessions read None; the restore applies the pin after the composition/layer loop so the reattach observer fires once against recorded membership). The Inspector's read_at_pick keeps reading the active stage (documented limitation); ghost preset + duplicate-with-layers are S3c. Locked by tests/viewers/test_scene_instances_s3b.py (mutator + event payload, matrix row + omnibus suppression, clamp helper incl. combined-mode windows, effective-stage composition incl. reactions, resolver stamping, filtered reattach walk, per-pin activation masks, v7 round-trip + legacy default, qt end-to-end pinned-hold/clamped-step/unpin-refollow over a real two-stage file).

ADDED — per-geometry spatial offset (ADR 0058 S3a)

Geometries can now sit side by side: Geometry.offset (a rigid X/Y/Z translation in model units) is applied at pump time inside the DEFORM primitive — points = reference + offset + scale·field — never as an actor transform and never baked into FEMSceneData.reference_points, so the S2c invariant (world coordinates == grid coordinates) holds and every pick / overlay / box-projection path is offset-correct with zero change. The owner mutator is GeometryManager.set_offset(geom_id, offset) (length-3 validate, float-coerce, no-op on equal) firing the new granular GEOMETRY_OFFSET_CHANGED (dispatcher matrix row DEFORM-only); a RENDER-lane subscriber drops the hit scene's cached pick KD-tree and rebuilds visible node/element label overlays so snaps and labels never use a stale frame. duplicate() copies the offset. The None fast-path stays byte-identical at zero offset — a deform-off offset geometry pumps reference + offset, and zeroing the offset restores the legacy reset-to-reference path. The geometry settings panel grows an Offset X/Y/Z spinbox row; session schema bumps to v6 with an additive GeometrySnapshot.offset (legacy sessions read (0, 0, 0); malformed values degrade to zero). Stage pin is S3b; ghost preset + duplicate-with-layers are S3c. Locked by tests/viewers/test_scene_instances_s3a.py (mutator + event payload, matrix row + omnibus suppression, pump composition incl. fast-path, node-tree invalidation + label rebuild, offset-aware box pick, v6 round-trip + legacy default, qt end-to-end offset render/pick/compose).

ADDED — geometry-aware picking (ADR 0058 S2c)

Under S2b every visible geometry's substrate actors are pickable, but pick resolution still assumed ONE scene — a pick on a non-active geometry's actor read coordinates off the wrong grid. S2c makes picking geometry-aware: the viewer keeps an id(substrate actor) → (geometry_id, scene) map in lockstep with _scene_actors (boot pair at show, clone pairs at materialization, dropped on geometry removal), and install_results_pick takes a scene_resolver so cell→element, the dim-pick gate, node-snap, the element highlight, and box-pick candidates all read the HIT geometry's scene (its deformed grid) at resolve time — box gestures (no single hit actor) resolve against the ACTIVE geometry; multi-geometry box picking is S3. The pick IR widens additively (ADR 0047 precedent): PickResult / BoxPickResult / PointProbeResult gain geometry_id (old constructors keep working), threaded through the selection log, the element-pick status line, and the PickReadoutHUD header — the geometry name shows only while MORE than one geometry is visible (mirrors the S2b scalar-bar prefix rule). Also closes the S2a known gap: ProbeOverlay and LocalAxesOverlay were constructed with the BOOT scene and held it forever; both now resolve the active geometry's scene at use time (construction scene stays as the headless/stub fallback), and per-pick probe reads take an explicit scene=. GP-marker hits keep geometry_id=None (overlay actors carry no geometry). Locked by tests/viewers/test_scene_instances_s2c.py (additive IR widening, hit-scene resolution incl. fallback/raising resolvers, actor-map lifecycle, overlay use-time resolution, HUD label gating, qt end-to-end pick on a deformed second geometry).

ADDED — concurrent geometry rendering (ADR 0058 S2b)

Every geometry with visible=True now renders concurrently — its substrate fill + wireframe pair AND its diagrams, each at its own deform state; "active" is demoted to the editing target (deform editing, node cloud, label overlays). The new Geometry.visible flag is owned by GeometryManager.set_visible() (owner-fired GEOMETRY_VISIBILITY_CHANGED, dispatcher matrix row DEFORM + GATE), and the outline geometry-row eye now drives it directly — the Plan 03 v2 geometry-level saved_visibility composition cascade is retired (composition-row snapshots stay). The composition gate composes a third term: a layer shows iff layer.is_visible AND composition gate AND owning_geometry.visible (per geometry: the active composition's layers when one is active there, else all compositions' layers). Substrate actor-pair visibility is geometry.visible AND geometry.show_mesh, with each visible geometry's own display_opacity applied to its own pair. Scalar bars stay per-diagram, but while MORE than one geometry is visible each bar title is prefixed with the owning geometry's name ("Geometry 2 — Sxx"; single-geometry sessions keep unprefixed titles; bars refresh the prefix on re-create). Session schema bumps to v5 with an additive GeometrySnapshot.visible; old sessions (no flag) restore as visible = is-active, reproducing their previous active-only rendering. Per-geometry stage pin / spatial offsets / ghost preset are S3; picking disambiguation is S2c. Locked by tests/viewers/test_scene_instances_s2b.py (gate truth table, owner-fire + matrix row, scalar-bar prefix, session mapping, outline eye, qt concurrent-deform).

(Merge note: the ## Unreleased header items for P5.2+P5.3 (#634) and the coupling host auto-scalers (#635) keep being dropped by CHANGELOG conflict resolutions on other tracks — restored above again; their sections were never lost.)

CHANGED — flat deck emission sheds its per-incidence Python overhead (~1.5× on top of the partitioned fix)

Companion to the partitioned pre-bucketing below — this slice attacks the FLAT path's constant factor (the per-element cost every emit pays regardless of partitioning). Three changes, decks byte-identical (verified by diffing ~100 MB Tcl + Py decks at 512k hexes — flat, 64-rank, and staged-partitioned fixtures — before/after):

  • ADR 0048 ndf inference is evaluated per element CLASS, not per (element × node) incidenceinfer_node_ndf makes one capability-registry probe per class and resolves per-node floors + the strict ndf_ok gate with numpy over the connectivity; semantics are exactly the unit-tested _infer_ndf_from_incidence core (kept as the reference). Was ~30% of flat emit (8 registry probes + a dict-append per hex).
  • PG fan-outs are memoised per snapshotexpand_pg_to_elements / expand_pg_to_nodes cache on a WeakKeyDictionary keyed by the (immutable) FEMData; one emit re-ran the same PG → Python-tuples materialisation for ndf inference, tag allocation, validators, and fix/mass/load fan-outs, and a same-snapshot re-emit (ops.tcl then ops.py) re-ran all of it again. Returned containers are documented read-only.
  • Emitter token dispatch is inlined per line_join (Tcl) / _ops_call (Py) dispatch int/float/str inline instead of calling _fmt_value per token, and node lines (the dominant deck band) render via a single f-string fast path behind exact-class guards (bool / numpy scalars fall through to the old path unchanged).

Measured (time.perf_counter, 512k hexes / 531k nodes, cold caches): flat ops.tcl 9.2 s → 6.2 s; 64-rank ops.tcl 11.1 s → 7.9 s; a same-snapshot follow-up ops.py lands at 4.9–6.2 s on the warm memo. (The "46.8 s flat" reading in earlier profiling notes was cProfile-inflated ~5× on this call-heavy path.)

CHANGED — CHANGELOG merge-conflict treadmill ended (union driver + frozen header)

CHANGELOG.md now merges with git's built-in merge=union driver (.gitattributes), and the single-line ## Unreleased — item · item · … ledger is frozen: PRs no longer append to it (that one line re-conflicted every open PR on every merge to main — five reconciliation rounds on #636 alone, and manual resolutions silently dropped the #634/#635 items three times). New entries are now exactly one contiguous ### section inserted at the anchor comment above; the section title doubles as the highlight. Union keeps concurrent insertions from different PRs without conflicting (verified by simulation: concurrent PRs, stale-base PRs, and the duplicated-header failure mode that froze the ledger line). tests/test_changelog_structure.py guards the structure; workflow + in-flight-PR migration notes in internal_docs/changelog_workflow.md.

CHANGED — partitioned deck emission drops its O(model × ranks) rescans (~2.5× at 512k hexes / 64 ranks)

Authoring-side emission wall-time — named by ADR 0061 as the next ceiling after the per-rank layout shipped — had three hidden O(model × ranks) terms in _emit_partitioned: the node-id → index lookup dict was rebuilt inside the per-rank loop, emit_element_spec_partitioned skip-scanned the FULL pre-allocated element plan once per rank (twice — owned-list build + main loop), and the global fix/mass passes re-ran the PG → nodes broker expansion per rank. Measured on a 512k-hex / 531k-node box at 64 ranks: ops.tcl 29.2 s → 11.7 s (ops.py 28.2 s → 11.3 s); 64k hexes / 16 ranks: 2.5 s → 1.7 s.

  • Rank-independent work now happens once, before the per-rank loop: the node-index lookup is hoisted; each element spec's plan is grouped by owner rank (bucket_pre_allocated_by_rank) and every rank walks only its own bucket (base loop + staged per-rank blocks); global fix/mass record targets are resolved once and bucketed by rank (_bucket_fix_targets_by_rank / _bucket_mass_targets_by_rank — fixes replicate on every owning rank, masses land on the primary rank only, semantics unchanged).
  • The staged per-rank pass also stops rebuilding each rank's owned-node set per stage (stage-invariant — computed once by the caller).
  • Decks are byte-identical — verified by diffing 100 MB Tcl + Py decks (flat-partitioned and staged-partitioned fixtures) emitted before/after; bucket construction preserves plan order and record/node order per rank.

ADDED — per-rank Tcl deck emission (apeSees.tcl(per_rank=True), ADR 0061)

Partitioned decks were monolithic: every MPI rank parsed the entire file and executed only its own if {[getPID] == K} { ... } blocks — per-rank parse cost and resident deck text were O(model) regardless of ownership (the measured Amdahl serial term of the emit → push → solve pipeline: ~5 s at 66k hexes, projected minutes/rank + node-RAM pressure at multi-M hexes).

  • apeSees.tcl(path, per_rank=True) writes a driver deck at path plus one ranks/rank<K>_<seq>.tcl fragment per rank block (base topology + one per stage); the driver replaces each block with a one-line guard that sources the fragment, so a rank parses only the driver plus its own fragments — O(global + model/np). Layout-only: deck semantics, the single-process rank-0 fallback (ADR 0027 INV-5), and the default monolithic output are unchanged (partition_open/partition_close span recording is observation-only).
  • The driver keeps everything global and sequential — materials, sections, timeSeries, damping, analysis chains, recorders, and the staged skeleton (domainChange / loadConst / wipeAnalysis / analyze loops) — so stage ordering is preserved by construction. Locked by reassembly parity: re-inlining the fragments reproduces the monolithic deck line-for-line (base + staged-partitioned fixtures).
  • Requires a partitioned model; mutually exclusive with split= (rank axis ≠ ADR 0043 module axis); py() per-rank is out of scope (Tcl is the HPC path). Cluster.submit / ops.run_remote need no changes — the driver is the deck entry point and the job dir is pushed wholesale.

ADDED — ADR 0055 close-out: compose filtered-audit + Phase 3 verification (staged-H5 runway COMPLETE)

ADR 0055 flips Proposed → Accepted — every phase has shipped (P1 #569 · P2 #580/#586/#589 · P4 #590/#591/#634 · P5 #598/#612/#634 · P3 here). This slice delivers the last open item, Phase 3:

  • g.compose_inspect(src) gains a filtered key (the ADR 0038 §195-196 filtered-audit that was never built): {"stages": n, "time_series": n, "patterns": n} — the droppable warn-kind analysis content a module carries, counted by the SAME probe the compose-time ComposeFilterWarning uses, so a module can be audited before composing it. Empty dict for vanilla modules.
  • FILTER+warn verified against a REAL staged archive — the existing test hand-injected an /opensees/stages group because staged archives couldn't be written when it was authored; now an actual ops.h5 staged archive composes with exactly one warning per droppable kind (stages + time-series) and the composed file inherits ZERO staged bytes.
  • P2.4 closed as absorbed (guard-test inversion landed across P2.2/P2.3/P5.1; the replay oracle is a standing gate; the partitioned-raise fixture became a write-success case when Phase 5 lifted the guard).
  • Also repairs main: test_compose_facade.py hardcoded neutral "2.12.0" and went red when #633 bumped to 2.13.0 — now imports NEUTRAL_CURRENT from the fixtures constants.

FIXED — staged stages were lost in recorder output and the viewer (unconditional domainChange + numeric stage sort + positional stage pairing)

With more than ~3 stages, later stages silently vanished from .ladruno/.mpco results and the viewer's stage selector. Root cause: the MPCO/Ladruno recorders open a new MODEL_STAGE[<stamp>] group only when the Domain's change stamp moves, and the bridge emitted the per-stage domainChange barrier only when the stage mutated the domain — a pure-loading stage (nodal-load pattern + analyze) never moves the stamp (Domain::addNodalLoad deliberately doesn't flag it), so its steps were appended into the PREVIOUS stage's MODEL_STAGE group. Run-verified on the fork build: 5 pure-loading stages produced 1 group pre-fix, 5 post-fix.

  • domainChange is now an unconditional stage barrier in both the flat and partitioned staged emits (the partitioned per-rank topology brackets stay content-gated; only the global barrier moved out). Captured staged H5 (/opensees/stages domain_change attr) and replay inherit the new shape.
  • Readers sort MODEL_STAGE[<k>] numerically (_ladruno.py / _mpco.py): lexicographic order put MODEL_STAGE[10] before MODEL_STAGE[2], scrambling stage ids from the tenth stage on. Multi-file readers delegate to reader 0 and inherit the fix.
  • Viewer stage-activation pairing gains a positional fallback (pair_capture_to_program): MPCO/Ladruno capture stages are named MODEL_STAGE[<stamp>] — never equal to program stage names — so the name-only pairing silently never matched (filter rendered everything). When no capture name matches a program stage and the counts line up, capture stage i now pairs with program stage i; any real name match or a count mismatch keeps the name mapping (fail-soft unchanged).

ADDED — partitioned staged: flat replay accepted + capture gate retired (ADR 0055 Phase 5 / P5.2 + P5.3)

Closes the Phase 5 runway (P5.4 true partitioned re-emit stays demand-gated):

  • P5.2 — flat replay accepted, zero source changes. Post-P5.1 the stage buckets of a partitioned staged archive are rank-agnostic, so OpenSeesModel.from_h5(...).build('tcl'/'py') re-emits the FLAT single-process staged deck through the existing _replay_staged_into — the same degrade the non-staged partitioned path always had. Locked by test_h5_partitioned_staged_replay.py: the replayed deck is line-multiset-equal to the unpartitioned archive's replay; cross-rank shared-node HOLD/load lines emit exactly once; a live-marked smoke RUNS the replayed py deck under single-process OpenSees (the INV-5 runtime conditional + getPID shim make it portable by construction).
  • P5.3 — ops.domain_capture forwards the bridge for every build. The last bridge=None gate (partitioned staged) is retired: the Composed run file now carries /opensees/stages + /opensees/partitions + the envelope ndf for partitioned staged captures — the feedstock the stage-aware viewer (ADR 0055 V1) reads, unlocking per-stage rendering of partitioned SSI runs. The stage-claimed phantom-node degrade (warn + sidecar-less) is unchanged.

FIXED — coupling-knob H5 schema completion (post-#630 main red)

PR #630 added the six cpl_* CouplingControl columns to the node_group + interpolation payload dtypes but merged during a GitHub Actions ingestion stall, so its CI never ran — main went red on 6 schema tests. This completes the schema work the tests demanded:

  • sr_cpl_* mirror lane (schema 2.12.0, completing) — the six CouplingControl columns are now mirrored into surface_coupling_payload_dtype as per-slave vlen arrays (sr_cpl_has / sr_cpl_k / sr_cpl_kr / sr_cpl_enforce / sr_cpl_dtcr / sr_cpl_absolute), wired through _encode_surface_coupling / _decode_surface_coupling, so a tied_contact / mortar slave record carrying explicit coupling knobs round-trips — the same lane-parity contract that test_surface_coupling_sr_lane_mirrors_interpolation_dtype locks (PR #337 precedent: the mirror lands under the same schema minor as the top-level extension). Pre-2.12.0 files decode to control=None (structural presence probe, as before).
  • Parity bookkeepingcontrol is whitelisted on NodeGroupRecord / InterpolationRecord as "persisted decomposed into the cpl_* columns"; the SR_TO_INTERP_COLUMN map gains the six sr_cpl_* rows; the dtype-shape tests cover the new columns.

ADDED — RBE2 partitioned (OpenSeesMP) emit: single-canonical-rank routing

g.constraints.kinematic_coupling now works under partitioned / OpenSeesMP emit (the handoff's deferred item C — previously the per-rank constraint planner raised NotImplementedError because the fork element, unlike the old idempotent equalDOF expansion, would allocate one element tag per owning rank ⇒ an N-fold over-constraint):

  • Single canonical rank — mirrors the ASDEmbeddedNodeElement ownership rule: the LadrunoKinematicCoupling element emits on min(intersection(node_owners[s] for s in slaves)) — the one deterministic rank where every tied slave is present — so every rank's planner agrees on the single emitter and exactly one element tag is allocated.
  • Ghost reference node — the reference node is NOT required to co-locate: when it lives on another rank it is ghost-declared (node line before the element, INV-2) on the canonical rank, exactly like the embedded path's constrained node.
  • Fail loud on split slaves — a slave set with no common rank raises with the per-slave owner map (same partitioner-input-bug stance as the embedded rule). The serial path is untouched.
  • Stage-bound partitioned couplings ride the same planner, so s.kinematic_coupling under MP inherits the routing for free.
  • Tests: planner-level canonical/ghost/min-rank/fail-loud units + integration coverage in test_emit_partitioned_mp_constraint_replication.py (element emitted exactly once with the ghost ref declared first; split set raises).

ADDED — RBE3 tributary-area weighting (weighting="area")

g.constraints.distributing_coupling(..., weighting="area") now computes each independent node's tributary area over the slave surface and emits -w w1..wN, so a force at the reference node distributes like a uniform traction on the surface (the handoff's deferred item B; previously a NotImplementedError stub — only "uniform" was wired):

g.constraints.distributing_coupling("anchor", "footing_face",
    master_point=(0, 0, 2.0), weighting="area")
  • Lumping model matches g.loads: each slave face's area (fan-triangulated polygon) is split equally among its nodes and accumulated per node — the same tributary model as g.loads surface resolution, so the RBE3 distribution is exactly proportional to a uniform tributary surface load. The fork normalizes by W = Σw, so only proportionality matters.
  • Order-safe: weights are computed in (and aligned to) the record's sorted independent order, so -w[i] pairs with independent i_i — positional user-supplied weights were never viable because the resolver sorts the set internally.
  • Fail loud: weighting="area" requires the slave label/entities to resolve to meshed surface faces (the face_map gate now covers area-weighted distributing couplings), and an independent node lying on no slave face raises instead of silently zeroing its load share.
  • No emit/H5 change needed: InterpolationRecord.weights already emitted as -w and already round-trips through model.h5.

ADDED — coupling host auto-scalers (k="auto" / k_alpha / host / bipenalty_wcap)

The fork coupling elements' host-element penalty auto-scalers are now reachable from g.constraints.kinematic_coupling(...) / distributing_coupling(...) (previously only the manual numeric knobs were wired — the handoff's deferred item A):

g.constraints.kinematic_coupling("platen", "face",
    k="auto", k_alpha=1e3, host=4021)          # K_t = k_alpha * max|K_host(i,i)|
g.constraints.distributing_coupling("anchor", "ring",
    k=1e8, host=4021, bipenalty_wcap=0.1)      # m_p = K_t/(0.1*omega_host)^2
kwarg flag meaning
k="auto" -k auto scale K_t off the host element's stiffness diagonal (requires host)
k_alpha -kAlpha $a multiplier for k="auto" (fork default 1e3)
host -host $eleTag representative host element — given as a FEM element id; the bridge translates it to the emitted OpenSees tag
bipenalty_wcap -bipenalty -wcap $beta penalty mass from the host frequency instead of a hard -dtcr budget (requires host; mutually exclusive with bipenalty_dtcr)
  • FEM-eid host translation: CouplingControl.host stores the FEM element id (stable across emits); the MP-constraint emit pass now receives the bridge's fem_eid_to_ops_tag map (serial + partitioned + stage variants) and translates at emit time. A hosted control fails loud if the map is absent or the eid never emitted — no silent wrong-element scaling.
  • Validation mirrors the fork's parse guards: k="auto" and bipenalty_wcap each require host; k_alpha only with k="auto"; a dangling host (nothing consuming it) is refused; bipenalty_dtcr + bipenalty_wcap are mutually exclusive; enforce="al" refuses both bipenalty modes.
  • H5 round-trip (neutral schema 2.12.0 → 2.13.0): four columns added to the cpl_* lane — cpl_k_auto (uint8), cpl_k_alpha (f64), cpl_host (int64 FEM eid, -1 = none), cpl_wcap (f64) — presence-probed, so 2.12.0 files decode with the v1 knobs only.
  • Schema-pin reconciliation with #632: the sibling fix that un-redded main mirrored the v1 cpl_* columns into the sr_cpl_* lane — this change extends that mirror with the four auto-scaler columns (sr_cpl_k_auto / sr_cpl_k_alpha / sr_cpl_host / sr_cpl_wcap), and the neutral-version pin now reads from tests/fixtures/schema.py instead of a literal so the next bump is a one-file edit.

ADDED — full control knobs on the fork coupling elements (RBE2 / RBE3)

g.constraints.kinematic_coupling(...) and g.constraints.distributing_coupling(...) now accept the fork elements' penalty / enforcement knobs, so the user has full manual control over the coupling mechanics (previously only the defaults were reachable):

g.constraints.kinematic_coupling("platen", "face",
    k=1e10, kr=2e10, enforce="al", bipenalty_dtcr=2e-6, absolute=True)
g.constraints.distributing_coupling("anchor", "ring",
    k=5e8, bipenalty_dtcr=2e-6)
kwarg flag meaning
k -k $Kt translational penalty (default 1e12)
kr -kr $Kr rotational penalty (default fork-derived K_t·ℓ²)
enforce -enforce penalty\|al al = augmented Lagrangian (implicit only)
bipenalty_dtcr -bipenalty -dtcr $dt explicit critical-step target (RBE3's ref node is massless → needed for explicit runs)
absolute -absolute keep the absolute tie (skip the default g0 stress-free birth)
  • A new frozen CouplingControl (apeGmsh._kernel._coupling_control) carries the knobs from the *Def onto the resolved record; the bridge appends control.emit_flags() to the emitted LadrunoKinematicCoupling / LadrunoDistributingCoupling line. Defaults are elided, so a knob-free coupling emits byte-identically to before (the resolver stores control=None).
  • Validation mirrors the fork's parse guards: enforce ∈ {penalty, al}; k/kr/bipenalty_dtcr > 0 if set; enforce="al" + bipenalty_dtcr is refused (implicit AL can't combine with explicit bipenalty).
  • H5 round-trip (neutral schema 2.11.0 → 2.12.0): six cpl_* columns added to the node_group + interpolation payload dtypes (presence-probed, so pre-2.12.0 files decode as control=None); the knobs survive model.h5 save/reload.
  • ~~Deferred (host-element auto-scalers)~~ — shipped below (k="auto" / k_alpha / host / bipenalty_wcap). RBE3 tributary-area -w weighting is still a follow-up.

ADDED — ops.run_remote one-call remote analysis + Job.wait (ADR 0060 sugar)

The deferred bridge sugar lands: ops.run_remote("./job", cluster="esmeralda") emits the Tcl deck into the job directory (with analyze_steps=/analyze_dt= passthrough), pushes it, sbatches, blocks via the new Job.wait(poll=, timeout=) until terminal, and fetches everything back — raising HPCError with the remote stderr tail on any non-COMPLETED end state (artifacts are fetched before the raise; on failure the logs are the evidence). np defaults to the model's partition count (len(fem.partitions), 1 for flat); wait=False returns the submitted Job immediately, and the JSON sidecar keeps it reloadable across sessions (Job.load(job_dir)). Run-verified end-to-end on Esmeralda: a real session-built 3-story frame, METIS 4-partition, Ladruno-fork OpenSeesMP under srun --mpi=pmix_v3 — one call, COMPLETED, fetched (job 143706). Locked by tests/hpc/test_run_remote.py (real bridge emit over the fem stub, faked ssh seam) + TestWait.

ADDED — remote HPC job submission (apeGmsh.hpc, ADR 0060)

(Re-applied: this PR #624 entry was dropped from the Unreleased header + sections by a later CHANGELOG conflict resolution.) from apeGmsh.hpc import Cluster — submit an emitted deck to a SLURM cluster from the local machine: Cluster.load("esmeralda").submit(job_dir, np=8) pushes the directory (single tarball over native ssh/scp, BatchMode=yes, zero new dependencies), renders an inspectable job.sbatch (LF + UTF-8 — CRLF breaks remote bash) next to the deck, and sbatches it by absolute path (SLURM is often only on the login-shell PATH). The returned Job survives the session via a JSON sidecar (Job.load(dir)) and offers status() / tail() / cancel() / fetch(). Status is squeue-while-alive + a script-written .exit_code sentinel after — the only durable completion record on a cluster whose sacct/slurmdbd is down (Esmeralda's is). Connection facts stay in ~/.ssh/config; cluster facts (paths, partition, srun --mpi=pmix_v3 launcher template, env lines) live in ~/.apegmsh/clusters.toml with fail-loud unknown-key/missing-key validation. Run-verified end-to-end on Esmeralda (2-rank smoke job: submit → RUNNING → COMPLETED → fetch).

ADDED — absorbing-boundary guide (internal_docs/guide_absorbing_boundary.md)

A user-facing guide for the ADR 0054 surface, written from the run-verified plane-wave campaign: building the box (g.parts.add_plane_wave_box + siblings, the AbsorbingSkinResult name bag), declaring the skin (ops.element.absorbing_boundary — homogeneous / stratified / raw, and why the impedance material is read-never-emitted: the upstream command takes raw G v rho, there is no material-tag slot), base-input injection (base_series on B-cells only), the staged hold→absorbing lifecycle (s.activate_absorbing and its emitted one-shot parameter block, per-rank semantics), the modeling recipe (nodal soil-only mass — never mass the skin; fix the bottom outer plane for explicit; global mass-proportional Rayleigh — region -ele Rayleigh is inert on rho=0 soil, and the element's addCff mirrors the domain α into its free-field columns by design), validation methodology (coherence / windowed decay / energy closure −IE = KE + DW + RES / seq↔par identity; quietness is NOT a valid check for stratified profiles, and a few-% base re-reflection at 4H/Vs is the expected Lysmer residual), and a pitfalls list.

CHANGED — per-geometry scene instances + active-geometry switching (ADR 0058 S2a)

S1's director.scene_for(geometry) returned the same scene for every geometry; S2a makes it true. The director's cache is seeded {boot geometry: bound scene} at bind_plotter(..., scene_factory=) and a miss materializes lazily through the viewer-injected factory — the director never builds scenes itself, so the diagrams package stays pyvista-free (ADR 0042 INV-2; the plan's on_scene_materialized hook is folded into the factory, which both clones and wires). clone_scene (in scene/fem_scene.py) is the materialization primitive: deep-copied grid reset to reference_points (clones are born undeformed and unhidden), index arrays shared (node_ids / node_id_to_idx / cell_to_element_id / element_id_to_cell / cell_dim / model_diagonal), render-side fields None. The viewer's factory wires per-scene ElementVisibility (+ dispatcher), shares the plotter-scoped pick inventory + opacity controller, applies the CURRENT dim-filter and stage-activation state (both now re-applied to every materialized scene on change), and builds a hidden substrate fill + wireframe pair per scene (extracted _add_substrate_actors). Switching is actor visibility, never re-attach: a RENDER-lane GEOMETRY_ACTIVE_CHANGED subscriber flips the pairs (materializing on demand), re-points the active-actor handles, re-applies display/theme state, and rebuilds visible label overlays; GEOMETRY_REMOVED removes the pair and drops the director's cache entry; the DEFORM pump needed zero changes (S1 built the loop). viewer._scene becomes a property over scene_for(active) so every display-level consumer (status line, labels, probe radius, pick extract, node cloud — which stays active-only) is automatically right. Headless binds without a factory keep the S1 single-scene fallback. Behavior-preserving: the viewport still renders only the active geometry's substrate (S2b flips concurrency; S2c does pick disambiguation). Locked by tests/viewers/test_scene_instances_s2a.py (clone contract, cache/removal, qt A→B→A switch).

CHANGED — geometry→scene resolution seam (ADR 0058 S1)

Every substrate-scene access in the results viewer now resolves through the geometry that owns it, while the viewport still renders exactly one substrate — the behavior-preserving plumbing slice ahead of concurrent geometries (S2). ResultsDirector.scene_for(geometry) is the seam (S1: every geometry maps to the single scene bound at bind_plotter; S2 swaps the internals for real per-geometry FEMSceneData instances), and DiagramRegistry.bind(..., scene_resolver=) resolves each diagram's attach scene through its owning geometry. The DEFORM pump restructures into a loop over _render_geometries() (S1: [active]): each geometry's deformed points come from its own deform state + its scene's reference_points (new FEMSceneData.reference_points field, captured at build — previously a viewer-level attribute), and the fan-out is scoped to that geometry's layers. Scoped pumps (newly-attached diagram) sync against the diagram's owning geometry's state rather than unconditionally the active one. Other geometries' diagrams are gate-hidden and re-pumped on activation, so nothing visible changes. Seam contract locked by tests/viewers/test_scene_seam_s1.py. The ADR's S1 memory measurement: a 23k-node / 124k-tet scene costs ~7 MB and deep-copies in ~2 ms — per-geometry plain copies are affordable; the copy-on-write escape hatch stays unused.

CHANGED — declarative diagram-kind registry (ADR 0058 S0)

Diagram kinds self-register via @register_diagram_kind(label=, style_class=, ...) in their own module (viewers/diagrams/_kinds.py). Four hand-maintained per-kind tables — the Add-dialog _KINDS (+ derived _KIND_TO_TOPOLOGY), the catalog _KIND_DEFINITIONS, the session _KIND_TO_STYLE, and the preset KIND_TO_STYLE_CLASS — collapse into it; the dialog, settings tab, kind catalog, session codec, preset store, and session restore all consume the registry. Adding a diagram kind drops from ~6 touch points to the class file + tests; the registry guard (tests/viewers/test_diagram_kind_registry.py) fails any Diagram subclass that forgets to register.

The drift the tables had already accumulated is fixed en route: (1) the session and preset maps silently lacked loads / reactions, so those layers never survived a session save/restore and their presets saved but could never load — both now round-trip (locked by the guard's per-kind codec test); (2) the catalog's dedicated reactions branch sat below the generic requires_data branch and was unreachable — the settings-tab creation panel listed every nodal component for Reactions instead of the curated reactions / reaction_x/y/z options; the branch now precedes the generic one (parity with the modal dialog, which always had its own correct logic); (3) the layer_stack label unifies to "Layer stack (shells)" (the dialog said "(shell)", the catalog "(shells)"). One cosmetic reorder: the settings-tab creation combo lists Vector glyph after Layer stack (registry order = dialog order; the two tables disagreed before).

ADDED — Ladruno recorder whole-model energy channel (energy=-G energy)

ops.recorder.Ladruno(..., energy=True) (and the Ladruno primitive's energy field) emit the fork's whole-model energy-balance channel — RESULTS/ON_DOMAIN/energyBalance, components KE/IE/DW/ULW/RES/ERR — closing the emit half of the channel whose reader (Results.energy()) already shipped. energy=True alone is a valid recorder (the at-least-one-channel validation now counts it). The flag is emitted last on the recorder line, and that ordering is load-bearing: the fork's -G parser eagerly consumes trailing region-tag integers and cannot rewind past a following flag, so -G energy -T nsteps 10 is a parse error while -T nsteps 10 -G energy runs (run-verified on the fork build; the ordering is locked by test_ladruno_recorder.py::TestLadrunoEnergy). Per-region energy (-G energy <regionTag…>) stays deferred on the bridge-region → OpenSees-tag seam.

FIXED — partitioned emit: additive nodal quantities (mass / pattern load) emit on ONE rank

Under OpenSeesMP, shared (interface) nodes exist on every rank that defines them and the parallel assembly SUMS each domain's nodal contributions at the merged equations. The per-rank fan-out replicated mass lines (global ops.mass, stage-bound s.mass) and pattern load lines (p.load, p.from_model imports; global and stage-scoped) on every owning rank — correct for idempotent lines (node/fix/sp), wrong for additive ones: interface nodes carried their mass once per owning rank. Run-verified on an 8-partition stratified plane-wave model: 81 massed nodes emitted 177 mass lines and the partitioned transient diverged from the byte-identical sequential run by ~100 % of peak velocity.

Each massed/loaded node now emits on its primary rank only — the lowest owning runtime rank (primary_owner_map, deterministic). fix / sp / node lines keep the every-owner fan-out, and the stage-pattern empty-bracket pre-check mirrors the new filters so a rank whose only content is a non-primary shared loaded node doesn't open an empty block (Py-emitter SyntaxError). Re-verified live after the fix: the same 8-rank model emits exactly 81 mass lines and the unpatched partitioned run agrees with the sequential run to ~4e-15 of peak. Locked by test_partitioned_additive_dedup.py (mass/load once, fix/sp/node replicated) + primary_owner_map unit locks.

FIXED — partitioned-deck getPID shim guards with info commands, not info procs

The Tcl partition shim emitted if {[info procs getPID] == ""} { proc getPID {} { return 0 } }. OpenSeesMP registers getPID via Tcl_CreateCommand — a C command, invisible to info procs — so the guard never detected the native command, the shim overrode it with the rank-0 fallback, and every MPI rank silently built rank 0's submodel (run-verified under mpiexec -n 8 OpenSeesMP: 8 byte-identical results.part-N.ladruno files, each carrying the same rank-0 node set). The guard now probes info commands, which sees both the native command and a prior proc, restoring distinct per-rank subdomains (node union = full model). Single-process behaviour is unchanged — on a build with no getPID at all the shim still installs the rank-0 fallback. The never-run-locally Scenario-C runtime smoke (test_partition_pipeline_e2e.py, skipped without OpenSeesMP + mpiexec on PATH) is why this never surfaced in CI; the unit lock (test_emitter_partition_open_close.py::test_tcl_emitter_shim_uses_info_commands_guard) now asserts the correct guard verb.

ADDED — viewers consume the remaining recorder channels (LOCAL_AXES roll · plot.energy · plot.node_envelope)

A recorder→viewer consumption audit found three recorder outputs with reader surfaces but no rendering path; all three now land:

  • Interactive diagrams orient from the recorder's true beam frame. LineForceDiagram now overlays each element's vecxz with the z-axis of the slab's local_axes_quaternion (.ladruno MODEL/LOCAL_AXES), and FiberSectionDiagram resolves frames recorder-first (results.elements.local_axes(), recorded frames only) → model vecxz_for (previously not consulted at all) → geometric default. The matplotlib plot.line_force already preferred the recorder frame; the Qt/web diagrams silently fell back to geometry — wrong cross-section roll on rolled sections, and on every model-less Results.from_ladruno(...) open (no vecxz source). The overlay parks the recorder z as the element's vecxz, so the deformed-substrate resync re-derives the same roll. Locked by tests/viewers/test_diagram_recorder_frame.py (rolled-frame fixture copies; GL-free).
  • results.plot.energy(region=, stage=) — the Ladruno -G energy balance (KE/IE/DW/ULW/RES + ERR on a twin axis) as a 2-D time-history figure. The reader (results.energy()) existed; nothing consumed it.
  • results.plot.node_envelope(component, measure="absmax"|"min"|"max") — paints the Ladruno -envelope per-node time-reduced extremes on the mesh (the file holds no time series for contour to read). Shares the contour paint path (extracted _paint_node_scalar).
  • FIXED en route: results.plot facet extraction selected 1-D elements by literal type name (line2/line3) and skipped the solver-flavoured 1-D groups a .ladruno-synthesized FEMData carries (truss, beams) — every plot.* mesh render on such an open drew nothing. Now selects by element_type.dim == 1 (endpoints = first two nodes, midnodes still dropped).

Audit residue, deliberately not in scope: director TimeMode.ENVELOPE/RANGE stay Phase-6-deferred (raise loudly); mode-stage viewing already works (stage selector + outline handle kind="mode").

FIXED — deform-follow regression: contour / fiber / layer-stack / spring diagrams ride the deformed substrate again

A deform-pipeline audit found that the viewer-side _sync_layer_grids walk — the mechanism that moved substrate-extracted submeshes under deformation by scattering vtkOriginalPointIds — iterates d._actors, which migrated diagrams never populate (post-ADR-0042 they emit backend-owned dataset copies and hold no VTK actors). Since the render-seam migration completed, four diagram kinds silently stayed at the reference configuration while the substrate warped: Contour (all five dispatch paths), FiberSection (dot cloud), LayerStack (shell submesh), SpringForce (glyph anchors).

  • Diagram.sync_substrate_points is now the only deformation fan-out, and every rendering diagram implements it: contour + layer-stack re-sample via cached vtkOriginalPointIds rows (the map survives separate_cells as carried point data, so the shattered discrete-gauss path follows too); the fiber cloud re-derives each beam's frame from the deformed chord + the cached vecxz (recorder z / model vecxz / default) and rebuilds the dots; spring arrows re-sample their anchor-node substrate rows.
  • The dead _sync_layer_grids walk is deleted; the stale base-class docstring ("extract_* layers follow automatically") is corrected to the new contract.
  • En route: SpringForceDiagram anchor collection returned a positions array positionally zipped against the requested element ids — silently misaligned when any spring's node was missing from the view. Now keyed by eid.
  • Endpoint+substrate-row collection is shared (collect_endpoints_with_substrate_rows in _beam_geometry, used by line-force and fiber-section).

Locked by tests/viewers/test_diagram_deform_follow.py — the shift/reset contract per diagram kind (contour ×4 paths, fiber, layer-stack, spring); the already-correct diagrams keep their own coverage. The contract is enforced going forward by tests/viewers/test_deform_follow_contract.py (the ADR 0056 guard pattern): it walks every Diagram subclass under apeGmsh.viewers and fails on any class inheriting the base no-op sync_substrate_points — a new diagram that forgets the hook fails at test time, not silently in a user's deformed view. Exemptions live in an explicit, reason-carrying _EXEMPT list (currently only DeformedShapeDiagram, which renders its own warp) and are stale-checked against the live class set.

FIXED — local-axes overlay triads resolve recorder-first (frame parity with the diagrams)

Since the recorder-frame rewire, LineForceDiagram / FiberSectionDiagram orient from the .ladruno MODEL/LOCAL_AXES true beam frame — but the "Local axes" toolbar overlay still drew its triads from the model vecxz / geometric default, so on a model-less Results.from_ladruno open the arrows could show a different cross-section roll than the diagrams render with. The overlay now uses the same precedence: the shared recorder_z_axes helper (moved to _beam_geometry, recorded frames only — an explicit ids= would identity-pad unrecorded elements) feeds a new vecxz_override= channel on iter_local_frames, scoped to the director's active stage; any unscopable state (combined stage, no results) falls back to the model-frame behaviour unchanged. Locked by rolled-fixture overlay tests + an iter_local_frames override unit test.

ADDED — static gauss contours + fiber dot cloud (plot.contour(topology="gauss") · plot.fibers)

The matplotlib renderer reaches viewer parity on the two element-level sources it couldn't draw:

  • plot.contour(component, topology="nodes"|"gauss", averaging="averaged"|"discrete") — same vocabulary as the interactive ContourStyle. Averaged: GP→corner extrapolation (extrapolate_gauss_slab_to_nodes, pinv of the shape-function matrix) + cross-element nodal averaging → smooth contours. Discrete: every facet painted from its own element's corner values — element-boundary jumps stay visible, which matplotlib's per-face flat shading renders exactly. Facet extraction now also returns per-facet element ownership (extract_facets_owned; a boundary face belongs to exactly one volume element). deformed=/wireframe= compose with both.
  • plot.fibers(component, ...) — the static counterpart of the viewer's FiberSectionDiagram: a 3-D dot cloud at the true world positions (station ξ from FiberSlab.station_natural_coord with the viewer's uniform-spread fallback; (y, z) section offsets in the beam frame, recorder-LOCAL_AXES-first) colored by fiber_stress/fiber_strain, ghost mesh underlay, gp_indices= station filter. Closes the docstring-deferred "fiber-section scatter" item; the 2-D per-section σ-ε panel stays deferred.

CHANGED — diagram scalar-state consolidation (ScalarColorSupport + base _scoped_results)

The copy-paste across the diagram family is gone (backlog item #4 from the result-type review):

  • ScalarColorSupport (extends ScalarBarSupport) now owns the runtime colour state (_runtime_clim / _runtime_cmap / _initial_clim), the live set_clim / set_cmap / current_clim setters, autofit_clim_at_current_step (the per-diagram _scalar_values_for_autofit hook is the one genuine variation), and the Qt LUT mirror (_init_lut / _on_lut_changed / _teardown_lut) — previously duplicated near-verbatim across Contour, VectorGlyph, GaussPoint, FiberSection and LayerStack.
  • Diagram._scoped_results(): the stage-scoping helper was verbatim in 8 diagram classes — now on the base. ReactionsDiagram keeps its deliberately defensive override (None on a bad stage id) unchanged.
  • One deliberate unification: the scalar-bar refresh on a LUT change now passes the runtime fmt for every diagram. Previously only the contour did — a set_fmt on the other four was silently lost on the next colormap change, while set_show_scalar_bar (which always passed fmt) preserved it. Locked by test_fiber_fmt_survives_lut_change.

Behavior locked by the existing suites (1388 viewers tests pass unchanged).

FIXED — degraded GP world-coordinate reconstructions are loud (WarnGaussCoordsApproximate)

GaussSlab.global_coords has two locked, reasonable degradations that were silent: element types with no shape-function coverage (pyramids, P3+) take a centroid + ½·bbox·ξ approximation, and elements that can't be resolved against the FEMData (missing element, unknown nodes) leave their GPs parked at the origin. A mis-placed Gauss-marker cloud is indistinguishable from a correct one — the same ADR 0056 INV-6 bug class as the fiber-station fix. compute_global_coords_from_arrays now emits one aggregated WarnGaussCoordsApproximate per call naming the GP count + offending type codes (bbox path) and the unresolved element ids (origin path). The supported paths stay silent — locked by running the full results+viewers suites with the warning escalated to error. Also corrects the module's stale coverage note: the catalog covers lines/tris/quads/tets/hexes/wedges including P2 forms; only pyramids and P3+ fall back.

ADDED — distributing_coupling (RBE3) ships as the fork LadrunoDistributingCoupling element (tag 33011)

g.constraints.distributing_coupling(master_label, slave_label, *, master_point=, weighting="uniform", name=) now emits the Ladruno-fork element LadrunoDistributingCoupling (class tag 33011), replacing the long-standing NotImplementedError stub (whose predecessor emitted a mechanically-wrong kinematic mean).

element LadrunoDistributingCoupling $tag $refNode $N $i1 ... $iN [-w $w1 ... $wN]

RBE3 is the flexible counterpart of RBE2 (kinematic_coupling): the reference node R is the weighted-average rigid-body fit of the independent set, and a force/moment at R is distributed to the set as a statically-equivalent pattern (Σ Fᵢ = F, Σ rᵢ × Fᵢ = M) adding no stiffness to the independents — so a load introduces at a point while the region stays free to deform. This completes the four-PR fork plane/coupling series (LadrunoQuad 33007, LadrunoCST 33008, LadrunoKinematicCoupling 33012, LadrunoDistributingCoupling 33011).

  • Reuses InterpolationRecord (no neutral-zone schema change): the reference (dependent) node R is slave_node, the independents are master_nodes — the field names read backwards for RBE3 (R is the dependent), but the geometry maps 1:1 onto the emit. Routed by record type into fem.elements.constraints (the existing dispatch already mapped DistributingCouplingDef → resolve_distributing).
  • Emit branch: _emit_surface_couplings / _emit_surface_couplings_for_rank now share _emit_one_interpolation, which branches on kind — distributing → the fork element (arbitrary independent count N≥1; the 3/4-Rnode embedded guard does not apply), tie/embeddedASDEmbeddedNodeElement as before.
  • Fork-only: added to _FORK_ONLY_ELEMENTS → stock OpenSees fails loud at the element line (it doesn't know tag 33011); emission to .tcl/.py works on any build.
  • Reference node must carry rotational DOFs (ndf 6 in 3D / 3 in 2D) to transmit a moment; independents can be translation-only (3-DOF). The fork refuses a too-small reference at setDomain.
  • v1 weighting is "uniform" (equal weights ⇒ -w omitted ⇒ the element's equal-weight default). weighting="area" raises NotImplementedError — tributary-area -w (apeGmsh computing per-independent areas) is a follow-up.
  • Partitioned (MPI): distributing rides the same single-canonical-rank rule as ASDEmbeddedNodeElement (the rank owning all independents emits; fail-loud if they split across ranks) — no silent replication.
  • DistributingCouplingDef lost its ASD-embedded fields (stiffness/stiffness_p/rotational/pressure/dofs) — it is no longer an ASDEmbeddedNodeElement carrier; the fork element owns its own -k penalty default (not yet surfaced — a follow-up, with -enforce/-bipenalty/-host/-kr).

CHANGED — kinematic_coupling emits the fork LadrunoKinematicCoupling element (RBE2, tag 33012) — BREAKING

g.constraints.kinematic_coupling(...) (and the stage claim s.kinematic_coupling(name=)) now emit the Ladruno-fork element LadrunoKinematicCoupling (class tag 33012) instead of expanding to one equalDOF per slave. The fork element is a penalty rigid-body driver that carries the correct moment-arm transport u_i = u_R + θ_R × d_i, so an offset reference node is coupled rigidly — the old equalDOF expansion ignored the lever arm and was only correct when slaves were coincident with the master.

element LadrunoKinematicCoupling $tag $refNode $N $s1 ... $sN [-dof $c1 ...]
  • BREAKING / fork-only. The deck emits on any build, but running it needs the Ladruno fork — stock OpenSees fails loud at the element line (it does not know class tag 33012; LadrunoKinematicCoupling is in _FORK_ONLY_ELEMENTS). This is intentional: the proper RBE2 mechanics are the reason the element exists. For a plain rigid same-DOF tie on coincident nodes, use g.constraints.equal_dof; rigid_link / rigid_diaphragm are unchanged.
  • dofs default is now "all the slave has" (-dof omitted), emitted only when you restrict the component list — this resolves a mixed 3-/6-DOF slave set correctly (the element handles the ragged layout), where the old [1..6] default would force rotation DOFs onto translation-only solid faces.
  • The reference node must carry the rotational DOFs (ndf 6 in 3D / 3 in 2D); the fork refuses a too-small reference at setDomain.
  • Reuses the existing NodeGroupRecord (no neutral-zone schema change); the record round-trips through model.h5 and staged-H5 archival as before, now replaying the element line.
  • Partitioned (MPI) emit fails loud for kinematic_coupling: a fork element can't be safely replicated across ranks the way an equalDOF command was (each rank would allocate its own tag → an N-fold over-constraint). Use serial emit, or keep the coupling's reference + slaves on one partition. Single-canonical-rank handling (like ASDEmbeddedNodeElement) is a follow-up.
  • Not yet exposed on the apeGmsh surface: -k / -enforce al / -bipenalty / -host / -kr / -absolute (the element's defaults — k=1e12, penalty, bipenalty off — give the rigid tie); a follow-up will surface them. The RBE3 distributing coupling (LadrunoDistributingCoupling, tag 33011) remains the last of the four fork PRs.

FIXED — fiber sections render at the beam's TRUE integration stations

FiberSlab gains station_natural_coord — the per-fiber station ξ ∈ [-1, +1] along the parent beam, read from the recorder's own integration-rule metadata instead of re-invented by the viewer. FiberSectionDiagram previously inferred station positions from a uniform spread (ξ = -1 + 2·gp/(n−1)), which mis-places fibers along the member for any non-uniform beamIntegration (a 3-point Gauss-Legendre force-based beam's end stations sit at ξ ≈ ±0.775, not ±1). Every reader had the truth on hand and dropped it:

  • MPCO: the fiber bucket layout already resolved the connectivity's GP_X — and used only its length. The coordinates now ride the slab, tiled per fiber row (and sliced by the gp_indices filter).
  • .ladruno: QUADRATURE/GP_PARAM keyed by GAUSS_ID — the same source the line-stations path reads. The layered-shell material.fiber.* spelling carries NaN: a shell's gauss id is a surface GP, not a beam station.
  • Live DomainCapture: the fiber capturer already called eleResponse(integrationPoints) and kept only the count; it now normalises the physical positions to natural ξ (end-node length probe, NaN on failure) and the native writer/reader carry an optional _station_natural_coord fibers dataset — files written before the field read back as None.
  • Multi-partition / multi-file concat NaN-fills mixed sources; all-absent stays None.

The diagram prefers the slab's true ξ and falls back to the uniform inference only for rows without one (pre-station files, failed probes) — loudly (diagram.fiber_station_xi_inferred, ADR 0056 INV-6).

ADDED — partitioned staged H5 archival (ADR 0055 Phase 5 / P5.1, schema 2.19.0)

The last apeSees.h5() fail-loud guard is lifted: PARTITIONED staged builds now archive to model.h5. The capture is rank-agnostic by construction — the /opensees/stages zone carries the flat logical program once, while the per-rank emit shape stays derivable from the neutral /partitions zone:

  • Stage-bucket dedupe/merge under rank brackets. The partitioned staged emit replicates stage-bound emission across owning ranks (ADR 0027 INV-1/INV-4 inside _emit_stages_partitioned): stage fix/mass/remove_sp/MP-constraint lines on shared nodes now capture once (with a single emit_index stamp); the stage's patterns — including the ADR 0052 HOLD pattern — re-open the same tag once per owning rank and now RESUME the captured record (per-rank load/sp/sp_hold subsets merge; shared-node lines capture once); per-rank stage-region fragments (-node member intersections sharing one tag) merge into the one logical region (member union, first-occurrence order).
  • Foreign ghost-node declarations never enter owned_node_ids. The stage MP pass declares foreign nodes before replicated constraints (INV-2); the bridge now surfaces each stage's owned-node set via the set_stage_owned_node_tags side-channel (the set_phantom_node_tags idiom) so the capture can discriminate — an "already declared" heuristic mis-classifies a foreign decl that precedes its owning rank's bracket. The phantom-claim degrade (stage-claimed node_to_surface) is unchanged and stays fail-loud.
  • One partition_NN group per rank, ever. Stage re-brackets RESUME the rank's accumulator instead of growing duplicate groups (which would also corrupt the write-time boundary-node intersection — two blocks of the same rank would see each other as "another rank"). /opensees/partitions and the element_meta/*/partition_ids columns now cover stage-owned topology.
  • Contract: the stage zone of a partitioned build is CONTENT-equal (rank-major capture order) to the same model captured unpartitioned, masking the INV-5 *_runtime_fallback chain keys; from_h5 → to_h5 of a partitioned staged archive is model_hash-stable; two fresh builds hash identically. Locked by tests/opensees/h5/test_h5_partitioned_staged_capture.py.
  • Schema 2.19.0 (no layout change — the bump marks that partitioned staged archives now exist; hard-floor window applies). The ops.domain_capture bridge=None degrade for partitioned staged builds is RETAINED pending its own verification slice (P5.3) — the run-file payoff (stage-aware viewer on partitioned SSI runs) lands there.

FIXED — partitioned H5 archives: capture dedupe + partitions restore + INV-5 fallback round-trip (ADR 0055 Phase 5 / P5.0)

Three latent defects in the partitioned (non-staged) model.h5 round trip, found while scoping ADR 0055 Phase 5 (internal_docs/plan_staged_h5_phase5_partitioned.md):

  • Rank-replicated captures duplicated rows in the archive. The partitioned global pass replicates emission across owning ranks by design (ADR 0027 INV-1/INV-4 — a shared-boundary-node fix emits inside every owning rank's bracket; a cross-rank MP constraint replicates byte-identically), and the H5 capture stored the replicas verbatim: duplicated /opensees/bcs/{fix,mass} rows, duplicated /opensees/constraints/* rows, and a flat from_h5 → build() re-emit that double-applied them (doubled penalty stiffness, duplicate equalDOFs). Worse, a Plain pattern whose targets span ranks re-opens the same tag once per rank — two capture records with one tag crashed ops.h5 outright on the patterns/<type>_<tag> group-name collision. Partition-bracketed captures now dedupe on full record identity, and a per-rank pattern re-open resumes the captured record (per-rank line subsets merge into the one logical pattern; shared-node load/sp lines capture once). Flat-build capture is untouched.
  • from_h5 → to_h5 dropped the partition zone. The re-write path was partition-blind: OpenSeesModel never loaded /opensees/partitions and the repopulated emitter re-drove the element pool with no rank brackets — the re-written archive lost the partition_NN groups (+ boundary_node_ids) and degraded every element_meta/*/partition_ids column to -1, drifting model_hash (both zones fold in). OpenSeesModel now carries the partition records (.partitions() accessor) and to_h5 echoes them back through the new H5Emitter.restore_partition_blocks (the restore_stage_blocks store-and-echo pattern; boundary_node_ids recomputes byte-identically from the restored node sets, and _element_ranks re-stamps by tag).
  • The ADR 0027 INV-5 runtime-conditional chain degraded on replay. A partitioned build auto-emits numberer ParallelPlain-with-RCM-fallback / system Mumps-with-UmfPack-fallback; the fallback attrs persisted but _replay_analysis_chain re-drove only the bare primaries — the H5 re-write silently dropped *_runtime_fallback (hash drift) and a tcl/py re-emit lost single-process portability. Replay now re-drives parallel_runtime_fallback_numberer/system when the attrs carry fallbacks.

A from_h5 → to_h5 of a partitioned archive is now model_hash-stable end-to-end (locked by tests/opensees/h5/test_h5_partitions_roundtrip.py). Note: archives written before this fix carry the duplicated rows, so re-writing one produces a (correctly) different hash — the lineage chain warns, never raises.

FIXED — viewer state-contract V5: the projection audit (ADR 0056)

The last adoption slice: every panel across the three viewers swept against INV-1 ("widgets and write-only attributes are never the sole holder; every panel can rebuild from owners alone"). The panels conform — except the shared Session tab (PreferencesTab), which failed three ways and is fixed:

  • The "Load arrows" slider never worked. It fired a "load_arrow" scale key no owner ever had — a silent no-op from the day it shipped (writes went into the pre-V3 raw dict that no reader consumed) and a KeyError crash in the Qt handler after V3 made set_scale fail loud. Slider rows now use the owner's key vocabulary (force_arrow / moment_arrow / …), locked both ways against OverlayVisibilityModel._SCALE_KEY_TO_OVERLAY by the new tests/viewers/test_preferences_projection.py.
  • Sliders and the pick-color swatch initialize from their owners (overlay_scales= / pick_color= constructor params; new public ColorManager.pick_rgb read path) instead of hardcoding 1.0× / #E74C3C — the can't-rebuild-from-owners gap INV-1 exists to kill. The model viewer constructs its ColorManager before the Session tab so the swatch has an owner to read.
  • Unbound controls are not built: the mesh viewer's Session tab showed a pick-color row wired to nothing; the model viewer's showed five overlay-scale sliders wired to nothing — silent no-op surfaces (INV-6). A control whose callback is not provided is now omitted.

Verified on the live mesh viewer: all six sliders initialize from the owner and dragging "Force arrows" round-trips through set_scale — the same gesture crashed pre-V5.

ADR 0056 is now Accepted — V5 is the last adoption slice, so the runway (V0 #593 → V1 #597 → V2 #600 → V3 #602 → V4 #603 → V5) is complete and the ADR status flips Proposed → Accepted. Open questions 1 (shared dispatch module) and 3 (ActiveObjects kept) were resolved at V3/V4; question 2's guard widening to diagrams/ stays gated on the ui/ allowlist burn-down, as the ADR already decided.

ADDED — solution-strategy ladder + established profiles (ADR 0057 Phase A)

ops.strategy.Ladder(rungs=[...]) + ops.strategy.profile(name) attach an opt-in escalation ladder to an analyze loop via s.run(..., strategy=) (staged) or apeSees.analyze(..., strategy=) (flat live). The deck emitters (py + tcl) turn the #587 fail-loud per-increment loop into a rung-walking loop: rung 0 — the chain's own algorithm — gets first shot at every increment; a failed analyze 1 re-issues the next rung's algorithm command and retries the same increment with a loud provenance print; a rescued increment restores rung 0; exhausting the ladder aborts with the fail-loud banner naming the ladder and rung count. The live emitter runs the same walk in-process and logs escalations to LiveOpsEmitter.strategy_events. strategy=None emission stays byte-identical to the pre-0057 loop.

Established profiles (stable names, evidence-revisable orderings): "standard", "non-smooth" (aliases "geotech" / "mohr-coulomb" — deliberately no line-search rung: the 2026-06-10 zoned-tunnel campaign showed NewtonLineSearch stalling in five mesh/element configurations that plain Newton carried at identical tolerance), "smooth-hardening" (alias "metal"), "penalty-stiff", "exhaustive". Rungs are solution algorithms ONLY — tolerance relaxation, test swaps and integrator changes are excluded by design (ADR 0057 §6). H5 persistence of the declaration is Phase C (an H5 replay runs the plain loop); Substep rungs with exact-λ landing are Phase B.

ADDED — LadrunoCST 3-node constant-strain triangle (Ladruno fork, tag 33008)

ops.element.LadrunoCST(pg=, material=, thickness=, plane_type=, pressure=, rho=, body_force=) emits the fork's 3-node constant-strain triangle — the thin 2D sibling of LadrunoQuad (and the second of the four plane / coupling fork features). A 1-point triangle is rank-sufficient, so there is no -formulation axis; it reduces to upstream Tri31:

element LadrunoCST $tag $n1 $n2 $n3 $matTag [-type PlaneStrain|PlaneStress]
    [-thick $t] [-rho $r] [-body $bx $by] [-pressure $p]
  • Same fork-gating (_FORK_ONLY_ELEMENTS), builder-ndf bracket (_BUILDER_NDF_GATED, the parser hard-gates ndm/ndf == 2/2), and fail-loud validation (thickness > 0, plane_type ∈ {PlaneStrain, PlaneStress}) as LadrunoQuad.
  • Registry: gmsh tri3 (etype 2), ndm/ndf={2}, identity reorder.
  • Result reads: RESPONSE_CATALOG Triangle_GL_1 (1 GP, stress_*/strain_*), the same layout as Tri31.
  • PlaneStrain is elided; the required -thick is always emitted.

The CST honestly volumetrically locks / mesh-biases localization — prefer LadrunoQuad or BezierTri6 for real 2D work (guide §CST). The RBE2 / RBE3 coupling elements remain follow-up PRs.

ADDED — LadrunoQuad 2D plane continuum element (Ladruno fork, tag 33007)

ops.element.LadrunoQuad(pg=, material=, thickness=, formulation=, plane_type=, pressure=, rho=, body_force=) emits the fork's unified 4-node plane (plane-stress / plane-strain) continuum element — the 2D sibling of LadrunoBrick, with the anti-locking treatment folded into one -formulation selector (std / bbar / ssp):

element LadrunoQuad $tag $n1..$n4 $matTag [-formulation std|bbar|ssp]
    [-type PlaneStrain|PlaneStress] [-thick $t] [-rho $r] [-body $bx $by] [-pressure $p]
  • Fork-only, gated at run not emit. Emission (ops.tcl / ops.py) works on any build; the live (ops.run()) path raises a clear "requires the Ladruno fork build" error on stock openseespy (added to _FORK_ONLY_ELEMENTS).
  • Fail-loud parse-guard parity: formulation='eas' is rejected with a targeted "reserved (ADR 25 Phase 3)" message; formulation='bbar' + plane_type='PlaneStress' is rejected (volumetric locking is a plane-strain phenomenon) — mirroring the fork's OPS_LadrunoQuad.cpp guards. thickness is required and validated > 0.
  • Builder-ndf bracket: the fork parser hard-gates on ndm/ndf == 2/2, so LadrunoQuad joins _BUILDER_NDF_GATED — the emit orchestrator brackets the block with model -ndf 2 so it survives a mixed-ndf envelope (like quad / tri6n).
  • Result reads: registered in RESPONSE_CATALOG as Quad_GL_2 (4 GPs, stress_*/strain_*) for every formulation — ssp mirrors its centroid onto all 4 GP blocks, so the layout matches FourNodeQuad.

std/PlaneStrain defaults are elided from the deck; the required -thick is always emitted. LadrunoCST (the 3-node sibling, tag 33008) and the RBE2/RBE3 coupling elements are follow-up PRs.

CHANGED — viewer state-contract V4: the model viewer joins the dispatcher (ADR 0056)

The last viewer joins; the contract now covers all three. Plus the burn-downs:

  • Model viewer dispatcher: constructed next to its VisibilityManager, entities pump bound to rebuild_now(). The 8 call-site plotter.render() calls after visibility mutators (tree hide/isolate/reveal, parts hide/isolate, toolbar H/I/R) and the vis_mgr.on_changed render subscriber are deleted — the model viewer used to double-render every visibility gesture (subscriber + call-site); it now renders once via the dispatcher.
  • VisibilityManager's "until V4" transitional comment retired: both production viewers inject a dispatcher; the no-dispatcher mode is documented as standalone/unit-test inline reconcile (it contains no render() — it was never a render fallback).
  • ActiveObjects disposition resolved (ADR 0056 OQ3): kept as the dedicated per-viewer focus-state owner (active layer/geometry/stage/step/pick-mode/selection snapshot) — the V4 census showed it conforms to the contract (owns focus state, fires owner-side, never touches artifacts or render), so the Part-6 "fold or delete" clause is superseded; dated resolution recorded in the ADR. One mechanism per concern: dispatcher → reconciler, ActiveObjects → UI projections.
  • Guard widened to model_viewer.py (renders 8 = 1 dispatcher binding + 7 out-of-scope subsystems; artifacts 3; imports 0 — live-fire measured).

CHANGED — viewer state-contract V3: the mesh viewer joins the dispatcher (ADR 0056)

The mesh viewer now runs the same owner-fires / dispatcher / reconciler contract as the results viewer (one shared Dispatcher class — ADR 0056 open question 1 resolved: kinds are data, the module is shared):

  • Two mesh primitives on the shared dispatcher: entities (the VisibilityManager actor rebuild, re-homed as the pump — its mutators now owner-fire MESH_ENTITY_VISIBILITY_CHANGED instead of rebuilding inline; legacy inline path kept for the model viewer until V4) and overlays (keyed overlay rebuild — MESH_OVERLAY_CHANGED carries the overlay key as a pass-through scope, None = rebuild all, which is exactly what a gesture_batch replay produces).
  • OverlayVisibilityModel owner-fires with the affected overlay key; the four per-overlay observer callbacks (each ending in its own plotter.render()) became the single overlays pump. Its plain observer list survives for UI-sync subscribers (outline tree).
  • Overlay glyph scales are owned state: mesh_viewer's module-private _overlay_scales dict moved into OverlayVisibilityModel (scale(key) / set_scale(key, value) — idempotent, owner-fired, unknown keys fail loud). The scale UI callbacks are now thin mutator calls.
  • 14 scattered plotter.render() calls deleted from mesh_viewer (visibility subscriber, all overlay-rebuild renders incl. the early-return ones, hide/isolate/reveal handlers) — the dispatcher's coalesced render is the render path. The 9 that remain (labels, wireframe, edges, dim filter, prefs, hover/selection recolor) belong to subsystems outside V3 scope and are ratcheted in the guard.
  • Guard scope widened (test_viewer_state_contract.py): mesh_viewer.py + overlays/** now guarded with measured count-ratchet allowlists (the AST count again beat the survey — overlay SetVisibility property calls the grep missed).

ADDED — viewer state-contract V2: the AST guard (ADR 0056 INV-5)

tests/viewers/test_viewer_state_contract.py machine-enforces the contract over src/apeGmsh/viewers/ui/**: G-RENDER (no .render() call expressions), G-ARTIFACT (no SetVisibility / set_layer_visible / SetPickable / add_mesh / remove_actor — zero baseline, hard gate from day one), G-IMPORT (no pyvista / vtk* / pyvistaqt / apeGmsh.viewers.backends imports, absolute or relative). Allowlists are per-file violation counts with a two-way ratchet — exceeding fails, and undershooting fails too with a "ratchet down" message, so the allowlist can only shrink. The durable carve-out is viewer_window.py (5 control-layer renders: camera presets / projection toggle / fit-view / theme refresh; 2 imports: it constructs the QtInteractor and applies pyvista theme defaults). The guard out-performed the grep survey on its first run — it caught a pyvistaqt import the regex baseline missed. Scope widens with each adoption slice (V3: mesh_viewer.py + overlays/; V4: model_viewer.py).

FIXED — ActiveObjects initial-state seed + the never-run Qt window tests now run (per-file)

Installing pytest-qt locally un-skipped the four @pytest.mark.qt full-window lifecycle tests in test_results_viewer_smoke.py (plus a fifth in test_stage_activation.py) — they had never executed, and running them surfaced two real issues:

  • ActiveObjects never learned the initial stage/step. The director auto-picks a stage at __init__ — before the viewer wires the ActiveObjects bridge — so on single-stage results no change event ever fires and active_stage stayed None forever (a projection that can't rebuild from its owner; ADR 0056 INV-1). ResultsViewer._show_impl now seeds ActiveObjects from the director's current stage/step right after wiring the bridge.
  • Bit-rot in the skipped test: Results.stage_ids() no longer exists; the test now reads the Results.stages property.
  • qt marker registered + deselected by default (addopts -m "not qt"): mixing real QtInteractor windows with the suite's offscreen plotters (or with another qt file in the same process) hits a native access violation in interactor init. Every qt test passes per-file in a fresh process — pytest -m qt <file>. CI is unaffected (no pytest-qt there; they skip).

Verified on a desktop session with working GL: live-viewer visual gallery (deformed shape + ghost + composition gate + scale + gesture_batch) screenshotted and pixel-diffed — the #593 composition-gate and ghost fixes and the #597 one-render-per-cascade are confirmed on real pixels.

CHANGED — viewer state-contract V1: dispatcher-always + owners-fire (ADR 0056)

The load-bearing slice of ADR 0056. Three structural changes to the results-viewer event pipeline, all behavior-preserving for single gestures and strictly better for cascades:

  • The dispatcher always exists (Part 3). ResultsDirector.__init__ constructs the Dispatcher with no-op pump defaults; ResultsViewer.show() rebinds the real pumps via the new Dispatcher.bind(...). All seven getattr(director, "dispatcher", None) defenses and both raw-render fallback branches (outline _fire_layer_visibility/_fire_render, settings-tab _fire_render) are deleted — headless contexts now exercise the same event path as the live viewer. Bonus: the settings tab's per-applier plotter.render() is gone, so a commit renders once instead of once per applier.
  • Owners fire events (Part 2). DiagramRegistry.set_visible now fires LAYER_VISIBILITY_CHANGED itself (idempotent per call — a no-op write skips notify + fire); the call-site fires in the settings-tab checkbox and outline eye-toggle are removed. The remaining UI fires (DIAGRAM_MODIFIED/ATTACHED/DETACHED, LAYER_REORDERED) go direct — no defense.
  • gesture_batch() + the matrix as data. The dispatcher's elif chain is refactored into a declarative _MATRIX table (one row per event kind, fixed primitive order step→deform→restack→gate; tests/viewers/test_dispatcher_contract.py locks every row against the legacy behavior). The new Dispatcher.gesture_batch() replays the matrix-row union of kinds fired inside the block — the outline's composition/geometry eye cascades now wrap in it, so an N-layer cascade costs one gate pump + one render (owner-fires would otherwise have made it N of each).

REMOVED — deprecated standalone apeGmshViewer/ app

The top-level apeGmshViewer/ package (~3,800 lines: MainWindow, VTU/PVD loaders, panels, its own theme/renderer/probes) is deleted. It was the pre-rebuild standalone post-processing viewer, fully superseded by the integrated ResultsViewer (results.viewer() / results.show_web()); the integrated probe overlay was mined from it back in the viewer rebuild and nothing in src/apeGmsh/ imported it except one legacy convenience wrapper:

  • g.mesh.results_viewer(...) is removed with it — it was a thin wrapper that spawned the old app on a .vtu/.pvd path (its point_data=/cell_data= branch already raised NotImplementedError pointing at the rebuilt viewer). Post-solve visualization goes through Results(...).viewer() / results.show_web(). g.mesh.viewer() (authoring viewer) is unchanged.
  • Packaging follows: the apegmsh-viewer console script, the apeGmshViewer* setuptools include (and the root where=["."] entry that existed only for it), and the package's mypy skip-override are gone. tests/test_vtu_loader.py (tested the deleted loaders) is deleted.
  • Docs/skill scrubbed: README viewers section + repo layout, docs/api/viewers.md, the apegmsh skill (canonical + derived mirror resynced via scripts/sync_skill.py). Historical mentions in internal_docs/ plans, architecture/*.md design docs, and old example notebooks (example_plate_viewer.ipynb etc., which demo the removed app) are left as historical record.

Four event/state bugs behind the chronic "diagrams toggle inconsistently / hide retains nodes" class, all traced to mutations that bypassed the dispatcher or poked dead state:

  • Composition gate was a silent no-op for every diagram. pump_gate flipped d._actors directly, but the ADR 0042 R-B migration moved all 11 diagram kinds onto backend layer handles — _actors is empty for all of them, so composition-based show/hide never reached the screen. The gate now routes through a new polymorphic Diagram.apply_effective_visibility(effective) channel which reuses each subclass's set_visible artifact path without clobbering is_visible (the user-intent flag the gate itself reads — writing the gated value back would corrupt the next gate run).
  • Outline eye-toggles bypassed the dispatcher. The outline tree's layer / composition / geometry eye icons mutated visibility and called plotter.render() directly — pump_gate never re-ran, so an eye-on could leak a layer past the active-composition filter, renders didn't coalesce, and the settings-tab checkbox (which fires LAYER_VISIBILITY_CHANGED) and the outline drifted apart. All three paths now fire LAYER_VISIBILITY_CHANGED (raw-render fallback kept for headless contexts without a dispatcher).
  • Deformed-shape undeformed ghost resurrected. The runtime "show undeformed" toggle was write-only state on the backend handle: set_visible(True) (and now every gate pump) flipped both handles back on, stage-change re-attach rebuilt the ghost from the attach-time style, and the settings tab read a _runtime_show_undeformed attribute nobody ever wrote (checkbox state lost on every tab rebuild). The toggle is now recorded on the diagram, honored by set_visible / the gate / re-attach, and visible to the settings tab.
  • Ghost nodes after hide are now loud. When the mesh-viewer scene build's per-entity getNodes pass fails, the node cloud has no ownership data and a later hide deliberately retains all nodes (locked fallback) — but both the scene-build failure and the fallback were silent, indistinguishable from a visibility bug. Both now log warnings (scene.node_centroid_pass_failed, visibility.node_cloud_no_ownership_data).

This is the surgical slice of the viewer state-ownership work; the state/event contract ADR (single store per viewer, dispatcher-only writes, reconciler-only backend calls, AST guard) follows separately.

ADDED — staged-model H5 archival, write + read (ADR 0055 Phase 2, schema 2.18.0)

Non-partitioned staged builds (ops.stage(...)) now archive to and load from model.h5 — previously apeSees.h5() failed loud on any staged build, and the whole Results/viewer chain (which requires model= per ADR 0020 INV-1) was unreachable for staged runs.

  • Write (P2.1): the H5 emitter captures the per-stage emit stream in-band into per-stage buckets (stage_openstage_close bracket) — owned nodes/elements (emit order == replay order), stage-bound fix/mass, regions (with kind + emit_index provenance — the rayleigh-vs-region interleaving carries OpenSees overwrite semantics), MP constraints, patterns incl. the ADR 0052 HOLD pattern (role="hold", sp_holds pairs), recorders, global-form rayleigh + emit-order stamps, SSI-2.E mutators, and the per-stage analysis chain + analyze loop — and persists them under /opensees/stages/stage_NNN. The declarative complement (activated_pgs, per-stage initial_stress, activate_absorbing) rides a set_stage_records side-channel with fail-loud capture↔record cross-checks. Tri-state is presence-encoded (never-set ⇒ no attr). Staged files carry no global /opensees/analysis (each stage's chain is scoped — the phantom-leak class of bug is eliminated by construction). Vanilla files are byte-identical; the zone folds into model_hash. ops.h5 now raises only for partitioned staged builds (ADR 0055 Phase 5).
  • Read (P2.2): OpenSeesModel.from_h5 loads staged archives; .stages() returns value-form StageRecordRO records; to_h5 echoes them back (from_h5 → to_h5 is model_hash-stable — the store-and-echo acceptance gate). A structurally inconsistent stages zone fails loud (MalformedH5Error) rather than loading as a different staged program. The tcl/py/live re-emit targets fail loud until the staged replay lands (P2.3); ModelData.from_h5 warns on staged archives (a write() re-save would strip them). Recorder group names ({kind}_{idx}, unpadded) are now read back in numeric emit order on both the staged and flat paths (alphabetical iteration scrambled mixed kinds and _10 before _2).

Both slices were adversarially panel-reviewed pre-PR (plan gate caught 4 fatal design defects; diff gates caught the phantom-node coordinate gap, the direct-write() bypass, recorder-order hash drift, equalDOF pad leakage, and the silent-default corruption-laundering holes — all fixed before merge).

FIXED / INTERNAL — apeGmsh.opensees is mypy-clean (178 → 0); MYPY_BASELINE: 0

The bridge package now passes mypy src/apeGmsh/opensees with zero errors, and the static-gates CI ratchet is lowered to a hard gate (MYPY_BASELINE: 0). Two of the 178 were real API-surface bugs:

  • ops.pattern.Plain/UniformExcitation(series=…) falsely rejected newer TimeSeries. The series= annotation was a concrete union frozen at Linear/Constant/Path/Trig/Pulse — the Ricker wavelet and the cyclic protocols (PR #558) never made it in, so type-checked callers were rejected and the internal stage-pattern helpers (typed against the abstract base) didn't type-check at all. series= now accepts TimeSeries | str (any subclass; runtime resolution is unchanged).
  • Quoted-annotation names that were never imported (Plain, ConstraintRecord, Any) blinded mypy at ~20 signatures (fixed in the ruff sweep, completed here).

Everything else is annotation/narrowing only — zero behavior change: renamed reused loop variables, fail-loud guards on Optionals whose invariants are now stated in the error message (e.g. split='parts' requires a buffered Tcl/Py emitter), removed stale type: ignores, and type-abstract disabled package-wide (the _resolve(ref, base=…) convention only isinstance-checks base; direct abstract instantiation is still caught by the abstract code). Docs sweep alongside: README viewer callout de-versioned (was "v1.5.0"), guide_loads.md broken TOC anchor, docs/api/loads.md autorefs updated patterncase (ADR 0051 vocabulary).

ADDED / FIXED — absorbing-skin aspect warning, centred-box fix, rotation guard (ADR 0054, AB-1c close-out)

Closes AB-1c. Three things, all surfaced by source/run verification:

  • Aspect-ratio warning. A new fail-soft WarnAbsorbingSkinAspect (from apeGmsh.parts.plane_wave_box) fires when the absorbing skin is much thicker than its adjacent soil element (ratio > ~4×; STKO ships ~2:1, the matched default is 1× and silent) — elongated boundary hexes absorb poorly. Both entry points (add_plane_wave_box, add_absorbing_shell).
  • Centred-box mesh fix. add_plane_wave_box(center=…) translates the block via an OCC translate + synchronize, which renumbers entities and stranded the slice's _metadata keys — a later g.mesh.generate then tripped the pre-mesh stale-metadata validator. The builder now reaps them (remove_orphans()) after the move, so a centred box meshes cleanly (latent since AB-1a; no prior test combined center != 0 with generate).
  • Rotation is unsupported — by the element, not apeGmsh. The OpenSees ASDAbsorbingBoundary3D element requires boundary-face normals along global X or Y (ASDAbsorbingBoundary3D.cpp:2135: "normal vector can be only X or Y"), so a rotated absorbing box is rejected by the solver. rotation_z_deg now raises a clear ValueError explaining this (was a vague "later slice" NotImplementedError). Per-axis skin_thickness was already supported since AB-1a. ADR 0054 AB-1c is complete; rotation is closed as infeasible.

ADDED — layered (stratified) absorbing boxes + per-layer material (ADR 0054, AB-1c)

Stratified soil support for both ASDAbsorbingBoundary entry points, so a layered site gets the correct per-layer impedance on its lateral skin (a uniform skin on a layered column spuriously reflects at the quiet boundary).

g.parts.add_plane_wave_box(z=[(15, 3), (25, 5)]) (a top → bottom list of (depth, n_elements) layers) and g.parts.add_absorbing_shell(box=…, element_size=…, layers=[(15, 3), (25, 5)]) (depths summing to the box's z-extent) now stratify the soil and split the lateral skin (L/R/F/K) per layer; the base skin (B*) sits on the bottom layer. AbsorbingSkinResult gains n_layers, soil_pgs (per-layer soil PGs, top → bottom — emit one stdBrick per entry) and skin_pgs_by_layer (layer → {btype → PG}); the existing fields are unchanged and a single-layer build is byte-identical to before. The bridge gains ops.element.absorbing_boundary(skin=res, materials=[m0, m1, …]) — one material per layer (top → bottom, len == n_layers, mutually exclusive with the homogeneous material=), each layer's skin cells getting that layer's derived G = E/2(1+ν); base_series still rides the bottom (B-containing) cells only. The layering lives entirely in the shared classify/tag core (_tag_and_structure per (btype, layer)) plus a _layered_axis_z helper; the BYO path slices the box at the layer interfaces before the weld. Rotation, grading, and per-axis thickness remain deferred (rest of AB-1c). Tests: turnkey + BYO 2-layer structure, per-layer-material deck (each layer's G), and guards; tests/parts green; a live 2-layer transient solves with each layer's impedance applied. Builds on AB-1b (#573).

ADDED — g.parts.add_absorbing_shell — bring-your-own-box absorbing skin (ADR 0054, AB-1b)

The second entry point for the ASDAbsorbingBoundary skin (companion to add_plane_wave_box): you build the soil box (its placement, PGs, the material / structure you put on it later) and this welds a one-element-thick absorbing skin onto its five truncation faces, returning the same AbsorbingSkinResult so the bridge element (ops.element.absorbing_boundary) and the staged flip (s.activate_absorbing) consume it identically.

g.parts.add_absorbing_shell(*, box, element_size, skin_thickness=None, faces=None, name=None, names=None, apply_transfinite=True) resolves box to a single axis-aligned rectangular volume (PG / label name or handle; fail-loud on multi-volume / rotated / curved geometry via a mass-vs-bounding-box check), builds the ≤17 skin slabs around it, and boolean-fragment-welds them on (conformal shared faces). It then reuses the AB-1a classify → PG → transfinite tail (extracted into a shared _tag_and_structure). The mesh contract is size-based: gmsh cannot report transfinite counts back and the weld renumbers entities, so the box's prior mesh state is irrelevant — element_size (scalar or (sx,sy,sz)) (re)structures box + skin together after the weld. skin_thickness defaults to element_size; faces= restricts the skin to a subset of ("L","R","F","K","B") (e.g. omit a symmetry plane). When box is a name its soil PG is reported as-is (no duplicate is synthesised). The slabs are synchronised before the weld — a synced box fragmented against unsynced slabs leaves coincident-but-separate faces (duplicate interface nodes → a disconnected, singular model); a node-sharing invariant test locks this. 13 tests in tests/parts/test_absorbing_shell.py (btype distribution, conformal all-hex, faces= restriction, soil-PG handling, fail-loud guards, and a bridge-deck plug-in proving drop-in for AB-2/AB-3); full tests/parts 72/72. A live transient over a welded box is byte-identical to the AB-4 plane-wave example (surface arrival at H/Vs, late motion 0.93 % of peak). Rotation / layered-Z / graded skins remain AB-1c.

ADDED — plane-wave SSI worked example (ADR 0054, AB-4)

Closes the ASDAbsorbingBoundary arc (AB-1a → AB-4) with a run-verified, end-to-end docs example, docs/examples/plane-wave-ssi.md: a soil column built by g.parts.add_plane_wave_box, wrapped by an ASDAbsorbingBoundary3D skin via ops.element.absorbing_boundary (a base shear-velocity Ricker injected on the bottom faces), flipped to absorbing in a staged block with s.activate_absorbing, and driven through an implicit Newmark transient. The example checks the physics directly: the base pulse reaches the free surface at the shear-wave traveltime H/Vs (0.198 s measured vs 0.200 s), then radiates out the quiet base instead of reflecting (late-window surface velocity 0.93 % of peak). Registered in the examples index and the mkdocs nav, with a surface-velocity time-history figure. Docs-only — no library code changed.

FIXED — loads / masses now fit the per-node ndf, not the model envelope

Nodal loads and masses are now sized to each node's own (per-node) ndf at emit, not the model-wide ops.model(ndm, ndf) envelope. Per ADR 0048 the per-node ndf (inferred from the declared elements, ∪ the ops.ndf overlay) is authoritative and every node already emits -ndf <its value>; a load/mass vector that did not match that count was silently dropped by OpenSees (Node::addUnbalancedLoad / setMass warn-and-return on a size mismatch).

The user-visible bug: in a mixed-ndf model (e.g. a 6-DOF envelope holding 3-DOF solid/truss nodes), a p.from_model(case) import mapped its DOF-agnostic spatial force onto the envelope ndf — emitting a 6-component load on a 3-DOF node, which OpenSees then dropped. The tip force just vanished, with only an stderr warning. This affected the flat, staged, split, and partitioned from_model paths.

The fix introduces fit_dof_vector (opensees/_internal/build.py): every load / mass vector is fitted to the target node's ndf — a short vector is zero-padded on the trailing DOFs (so ops.mass(pg, (m, m, m)) on a 6-DOF beam node is the natural translational-only mass), and a vector with a non-zero component beyond the node's ndf (e.g. a moment on a 3-DOF solid node, or Fz on a 2-DOF planar node) fails loud with a BridgeError naming the lost component. from_model loads map through broker_load_components at the per-node ndf, so an uncarriable spatial component likewise fails loud per node.

Behavior change (G3): validate_record_ndf_consistency (ADR 0049 gate G3) previously required load / mass vectors to exactly equal the node ndf; it now accepts a short vector (padded) and raises only on a non-zero overflow. fix / support mask-length and sp DOF-index checks are unchanged. The user sets the ndf (via elements / ops.ndf) and the bridge makes the loads and masses compatible with it, failing loud only when a real component cannot land.

ADDED — node-pair ops.element.<spring>(nodes=(node_i, node_j)) (ADR 0049)

Completes the SSI spring-to-ground story opened by ADR 0049: ops.element.ZeroLength, CoupledZeroLength, and TwoNodeLink now accept nodes=(node_i, node_j) as an alternative to pg=, wiring a single spring directly to two explicit endpoints — at least one typically a g.decouple_node ground — without a meshed 2-node "line" physical group. Each endpoint (NodeRef) is a g.decouple_node handle, a node-label string resolving to exactly one node, or an int tag (escape hatch, not compose-safe). pg= and nodes= are mutually exclusive (exactly one, fail-loud). ZeroLengthSection keeps pg=-only — it is non-adaptive (both nodes must carry exactly 3/6 dof), so a decoupled ground cannot be sized for it; passing nodes= to it fails loud pointing at plain ZeroLength.

The endpoint ndfs are validated by the existing G1 equal-endpoint gate (the spring's two ends must carry equal ndf, fed the inferred ∪ ops.ndf overlay map), and distinct resolved tags are required (OpenSees has no same-node guard — an i == j pair would assemble a singular element silently). Connectivity round-trips model.h5 via a new optional inline_connectivity dataset under /opensees/element_meta/{type}/ (the spring has no neutral gmsh cell to source it from), folding into model_hash (schema 2.17.0, additive). Node-pair springs are global-only in v1 (no stage binding) and fail loud under partitioned (MPI) emit (per-rank node-ownership routing of an explicit node-pair is deferred); they are also not drawn in the mesh/model viewer (no neutral cell). Plan hardened by an adversarial Opus panel before coding (caught the false "H5 round-trip is free" claim, the ZeroLengthSection non-adaptive trap, the expand_pg_to_elements(None)-returns-whole-mesh trap, and 2 more fem_eid sentinel-collision sites).

ADDED — s.activate_absorbing() — staged absorbing-boundary stage flip (ADR 0054, AB-3)

Completes the gravity→dynamic SSI cycle for the absorbing boundary. Inside a staged block, s.activate_absorbing(pg=<skin_all_pg> | elements=[...]) emits the one-way ASDAbsorbingBoundary stage switch (0→1) — the OpenSees one-shot parameter $pid / addToParameter $pid element $eid stage (one per element) / updateParameter $pid 1 / remove parameter $pid sequence — once, after the stage's analysis chain is established and before its analyze loop, so a prior gravity stage has already held the boundary by penalty. Targets resolve by PG (typically AbsorbingSkinResult.skin_all_pg) or an explicit element list; exactly one is required. Reuses the s.initial_stress parameter/addToParameter plumbing and the fem_eid -> ops_tag map, and is emitted per partition under MP (each rank flips only its owned elements; an eid absent from a rank's tag map is silently skipped, while in single-partition mode an unregistered eid is a fail-loud BridgeError). New flip_element_stage emitter method on the Tcl / openseespy / live / recording backends (no-op for H5 — analysis directives aren't archived). End-to-end over a plane-wave box emits one addToParameter ... stage per skin element, before the transient analyze.

ADDED — ASDAbsorbingBoundary3D bridge element + ops.element.absorbing_boundary (ADR 0054, AB-2)

The OpenSees-side counterpart to the AB-1a plane-wave box: a typed ASDAbsorbingBoundary3D element primitive (opensees/element/absorbing.py) and two namespace facades. The element emits element ASDAbsorbingBoundary3D $tag $n1..$n8 $G $v $rho $btype <-fx $ts> <-fy $ts> <-fz $ts>raw G/v/rho doubles (not a matTag) plus the fixed btype string; optional base-input time series are fail-loud-guarded to bottom (B-containing) boundaries only, and opposite-face / illegal / repeated btype letters are rejected at construction. ops.element.ASDAbsorbingBoundary3D(pg=, btype=, ...) takes the soil properties either as material=ElasticIsotropic(...) (derives G = E/(2(1+v)), reuses v, rho — read at construction, never emitted and not a dependency) or as raw G=/v=/rho=. The convenience ops.element.absorbing_boundary(skin=<AbsorbingSkinResult>, material=…, base_series=…, base_dirs=…) fans one declaration over every btype PG of a plane-wave skin in a single call, attaching the base series to the bottom PGs only. Registered in _ELEM_REGISTRY (mat_family="none", ndf_ok={3}) so ADR-0048 ndf inference gives the skin nodes solid DOFs. End-to-end (box → mesh → apeSees → deck) reproduces the closed-form btype tally with the base series on every bottom cell and a single nDMaterial line (the soil's; the skin carries raw G/v/rho). AB-3 (the staged s.activate_absorbing() flip) follows.

ADDED — g.parts.add_plane_wave_box — structured soil box + absorbing skin (ADR 0054, AB-1a)

First slice of the ASDAbsorbingBoundary3D track (ADR 0054 / internal_docs/plan_absorbing_skin_ab1.md). g.parts.add_plane_wave_box(x=(Lx,nx), y=(Ly,ny), z=(Lz,nz), ...) builds, in the live session (no Part/STEP round-trip), an axis-aligned structured soil box wrapped by a one-element-thick absorbing offset shell on its five truncation faces — the local +Z top is the free surface and is never shelled. Soil + shell are one rectangular block sliced only at the region breakpoints into 18 sub-volumes (1 soil + up to 17 skin regions: 5 face panels, 4 vertical edges, 4 bottom edges, 4 bottom corners). Each skin region is tagged with its OpenSees btype (the OR-combined set of truncation faces it lies outside of, canonical order BLRFK) as a volume physical group, so the forthcoming bridge element fans out one ASDAbsorbingBoundary3D per skin hex with the shared btype. Returns an AbsorbingSkinResult (soil_pg, skin_pgs keyed by btype, skin_all_pg roll-up, bottom_pgs, free_surface_pg, axes, placement). skin_thickness defaults to the adjacent soil element size per face; the block is built in the local frame then translated to center (the slice cutting-plane is sized around the origin). Fail-loud guards: rotation_z_deg != 0, layered-z, and non-positive sizes/thickness are rejected (rotation + stratigraphy are later slices). The btype→axis mapping (L=min-X, R=max-X, F=min-Y, K=max-Y, B=min-Z) is validated against the OpenSees element source and a real STKO export; the golden test reproduces that deck's exact btype tally. Does not use or modify DRMBox (that serves the Domain Reduction Method).

ADDED — g.model.geometry.add_rectangle(plane="xy"|"yz"|"xz")

add_rectangle was hardwired to the XY plane (it wrapped gmsh.occ.addRectangle, which only builds at a given z). It now takes a plane keyword so a rectangle can be authored directly on any of the three canonical planes, corner-anchored: (x, y, z) is always the corner and dx / dy run along the plane's two in-plane axes — xy → (X, Y) at constant z (default, unchanged), xz → (X, Z) at constant y, yz → (Y, Z) at constant x. Backward-compatible: omitting plane reproduces the old XY behavior bit-for-bit, and the existing angles_deg / angles_rad in-place rotation still composes on top (pivoting about the rectangle centre in the chosen plane) for fully arbitrary orientation. Implemented by building in local XY at the origin, rotating onto the target plane, then translating the corner — so rounded_radius is preserved on every plane. An unknown plane fails loud. (For a centre-anchored square with an arbitrary normal, add_cutting_plane(point, normal_vector) already exists.)

CHANGED — g.model.geometry.slice is now dimension-generic and accepts point=

slice is no longer hardwired to volumes. Two changes:

  • dim=1|2|3|'all' (default 'all') — slices entities of the chosen dimension instead of only volumes. 'all' (the default) slices every maximal entity in the model — volumes in a solid model, surfaces in a shell model, curves in a frame model — which collapses to the historical volume-only behaviour for solid models (so existing calls are unchanged). The OCC fragment result map is used to keep only the operand's own fragments, so slicing a dim-2 surface with the dim-2 cutting plane no longer miscounts the plane's pieces. Sliced surfaces / curves (which bound no volume) are registered before the orphan sweep, so they survive.
  • point= as an alternative to offset= — give the plane location directly as a point it passes through (only the axis coordinate matters) instead of a signed distance from the origin. The two are mutually exclusive (passing a non-zero offset together with point raises).
  • The first positional argument was renamed solidtarget to match the dimension-agnostic meaning. Positional callers are unaffected; the keyword form slice(solid=…) must become slice(target=…).

ADDED — ops.ndf for element-less decoupled nodes + per-node ndf gates G1–G3 (ADR 0049 DOF half)

Completes ADR 0049: after ADR 0048 made per-node ndf inferred from element classes, an element-less decoupled node — an SSI spring/dashpot ground, a control node, a mass anchor declared via g.decouple_node(...) that no element touches — had no way to state its DOF count. New ops.ndf(target, ndf=…) (the sole explicit per-node ndf channel) takes a g.decouple_node handle or its int tag and is resolved at build into an overlay merged over the inferred map ({**inferred, **overlay}). It fails loud on a mesh node, an element-touched node (inference owns those — no two-headed model), or an unresolved handle. A label=/pg= grammar is deferred (decoupled labels are not yet registered into the FEM).

Three fail-loud build-time gates close silent-failure modes OpenSees would otherwise swallow:

  • G1 (existing validate_adaptive_element_endpoints, now fed the effective inferred ∪ overlay map) — both ends of a zeroLength-family element must carry equal ndf, so a correct ops.ndf(ground, K) spring passes instead of falsely raising.
  • G2 (validate_constraint_master_ndf) — a rigidDiaphragm/rigidLink master must carry the exact ndf (6 in 3D / 3 in 2D, RigidDiaphragm.cpp: 94-100), and every DOF an equalDOF/rigidLink/kinematic_coupling references must fit both endpoints; covers broker and stage-claimed constraints.
  • G3 (validate_record_ndf_consistency) — a mass/nodal-load vector must EQUAL the node ndf (Node.cpp:940/1272 reject any mismatch and silently drop the whole record); a fix/support mask must not exceed it; an sp DOF index must fit. (p.from_model(case) loads are emit-synthesized per node and out of G3's reach.)

The resolved per-node ndf (inferred ∪ stated) persists to /opensees/nodes_ndf (schema 2.14.0; current SCHEMA_VERSION 2.15.0, no bump) and round-trips through model.h5. Migration: a nodal mass/load whose vector length did not match the node ndf was silently dropped by OpenSees before and now fails loud at build — pass a full-length (ndf-sized) vector.

IMPROVED — LadrunoBrick rejects a finite-strain material under a non-finite geometry

ops.element.LadrunoBrick(material=…, geom=…) now fails loud at construction when a finite-strain material (LogStrain / LadrunoJ2Finite / InitDefGrad) is paired with geom != "finite". Those materials are driven by setTrialF(F); under linear/corot the element never calls the F-interface, so it would silently integrate zero stress — the fork rejects this at run, and apeGmsh now catches it earlier with a clear message pointing to geom="finite". Implemented via an is_finite_strain class marker on NDMaterial (mirroring the fork's FiniteStrainNDMaterial base, and the existing is_rate_dependent marker pattern), set True on the three finite materials. The converse direction (a non-finite material under geom="finite") is left to the fork's run-time dynamic_cast check, since apeGmsh only marks the finite materials it models.

ADDED — Ladruno-fork live Monitor recorder (ops.recorder.Monitor + read_monitor / tail_monitor)

The Ladruno fork's Monitor recorder is now emittable and readable. Unlike the canonical .ladruno recorder, the Monitor is a lightweight live-telemetry sidecar: it streams a few selected nodal scalars to a small SWMR-HDF5 file (FORMAT="ladruno-monitor"COLUMNS / STEP / TIME / FRAMES) that a viewer process can tail while the analysis is still running; the same file is a valid at-rest result once the run ends. Fork-only — the recorder Monitor … line emits on any build, the fork is needed only to run.

  • Emitops.recorder.Monitor(sink=, nodes=|pg=, dofs=, resp="disp", every=, hz=). Channels are nodes × dofs, labelled node<N>.<resp>.dof<D> in node-major order; resp ∈ disp|vel|accel|reaction; every=K (step decimation) and hz=H (wall-clock throttle) bound the stream. pg= resolves to node tags against the FEM snapshot at emit (mirrors the Node recorder).
  • Readnot a Results object (the sink carries no FEM), a thin time-history instead: apeGmsh.results.read_monitor(path) → MonitorData (.columns / .step / .time / .frames, .channel(label), .to_dataframe(index="time"|"step")). apeGmsh.results.tail_monitor(path, timeout=…) follows a still-growing sink in SWMR mode, yielding (step, time, frame_row) per frame.
  • Verified against a real fork-built monitor.h5 fixture (build 605affeb) + a live parity test (sink channel == ops.nodeDisp to 1e-9) + a thread-based concurrent-tail test.

ADDED — Ladruno-fork material wrappers (LogStrain / InitDefGrad / StagedStrain / LadrunoRebarBuckling)

apeGmsh can now emit the Ladruno fork's constitutive wrappers — materials that hold an inner material and modify its input/output — as typed primitives (mirroring the existing PlaneStrain / InitialStress wrapper pattern: the inner is held by reference, its tag resolved at emit, and the bridge emits it before the wrapper):

  • ops.nDMaterial.LogStrain(inner=) (ND_TAG 33010) — the Hencky finite-strain lift: lifts an isotropic small-strain 3-D law to a FiniteStrainNDMaterial for LadrunoBrick … -geom finite. Pair with an isotropic inner (LadrunoJ2(-kin 0), ElasticIsotropic); for combined hardening at finite strain use LadrunoJ2Finite.
  • ops.nDMaterial.InitDefGrad(inner=, no_init_f=, F0=) (ND_TAG 33013, also StagedDefGrad) — finite staged stress-free birth: a continuum element born neutral at the deformed geometry in a staged build. Inner must be a finite-strain material; F0 is an optional 9 row-major birth gradient.
  • ops.nDMaterial.StagedStrain(inner=, no_init=, eps0=) (ND_TAG 33014) — the small-strain analog (2-D or 3-D everyday staged build); eps0 is an optional 6-component Voigt birth strain (required to be 6 — the parser reads it greedily and silently discards a mismatched length).
  • ops.uniaxialMaterial.LadrunoRebarBuckling(material=, lsr=, model=, …) (MAT_TAG 33001) — reinforcing-bar buckling overlay around any tension-compression uniaxial (Dhakal-Maekawa dm / Gomes-Appleton ga); lsr=0 is the identity gate.

Grammars verified against the shipped fork parsers. Construction guards mirror the parser hard-rejects: InitDefGrad F0 exactly 9 (row-major), StagedStrain eps0 exactly 6, and LadrunoRebarBuckling's post-parse rejects (lsr>0 ⇒ E>0; lsr>0 & model=dm ⇒ fy>0; reduction ∈ [0,1]). Fork-gated at run, not emit. Wrappers nest (e.g. InitDefGrad(LogStrain(LadrunoJ2))). Second of three slices (after the J2 materials; the LadrunoBrick -geom/-formulation element transformations landed separately).

ADDED — Ladruno-fork J2 plasticity materials (LadrunoJ2 / LadrunoUniaxialJ2 / LadrunoJ2Finite)

apeGmsh can now emit the Ladruno fork's combined-hardening (Voce + Chaboche) von Mises plasticity family as first-class typed primitives — the OpenSees analogue of Abaqus *PLASTIC, COMBINED:

  • ops.nDMaterial.LadrunoJ2(K=, G=, sig0=, Qinf=, b=, Hiso=, backstresses=[(C,γ),…], rho=, lch_ref=, damage=(r,s,pD,Dc), implex=) — the flagship 3-D continuum law (ND_TAG 33011); one class serves all five dimensional views.
  • ops.uniaxialMaterial.LadrunoUniaxialJ2(E=, sig0=, Qinf=, b=, Hiso=, backstresses=, damage=, implex=) — the 1-D twin (MAT_TAG 33000) for fiber sections / trusses / zeroLength; true multi-backstress ratcheting Menegotto–Pinto can't do. No -rho / -autoRegularization (the parser rejects them on the uniaxial).
  • ops.nDMaterial.LadrunoJ2Finite(K=, G=, sig0=, Qinf=, b=, Hiso=, backstresses=, rho=, implex=) — finite-strain-native combined J2 (ND_TAG 33012, a FiniteStrainNDMaterial) for combined hardening with large rotation; the consumer is LadrunoBrick … -geom finite. No -damage / -autoRegularization here.

The shared -iso voce sig0 Qinf b Hiso / -kin N C₁ γ₁ … / -damage lemaitre r s pD Dc / -implex grammar is verified line-by-line against the shipped fork parsers (OPS_LadrunoJ2 / OPS_LadrunoJ2Finite / OPS_LadrunoUniaxialJ2) and centralized in one helper (material/_ladruno_j2.py) so the three classes never drift — mirroring the fork's single-kernel design. Construction guards mirror the parser: sig0 > 0, ≤ 8 backstress pairs (the fork MAXBACK), C > 0 / γ ≥ 0, and the Lemaitre r > 0 / 0 < Dc ≤ 1 hard guard. Fork-gated at run, not emit: the deck line is produced on any build; the material errors at ops.run() on stock openseespy. Materials round-trip through model.h5 via the generic MaterialRecord (no per-type persistence). First of three slices implementing the fork's materials / wrappers / element-transformations catalog.

ADDED — Damping definition on the apeSees bridge (ops.damping / s.damping, ADR 0053)

The bridge had no way to set damping coefficients — the per-element do_rayleigh flags were inert (nothing to opt into), and there was no rayleigh command, region -rayleigh, modalDamping, or damping factory. ADR 0053 adds one domain-level namespace ops.damping (a sibling of fix / mass / region, not part of the analysis chain) that owns four of OpenSees' damping channels; material dashpots stay in ops.uniaxialMaterial.* and numerical damping in ops.integrator.*. Every member is a declaration resolved at emit — no assign, no user-held tag.

  • Rayleighops.damping.rayleigh(...) takes either the four raw coefficients or a two-target ratio fit (ratio=, f_i=, f_j= in Hz; β placed by stiffness=, default "initial"/βK0, the nonlinear-safe choice). on= is optional: absent → global rayleigh; a physical group (or list) → region $tag -ele … -rayleigh …. Because OpenSees overwrites element Rayleigh per element, globals emit before regions ("region refines global") and a RayleighOverwriteWarning fires on overlap.
  • Modalops.damping.modal(ratios, *, modes) bundles its own eigen then modalDamping (scalar → uniform; sequence → per-mode). Domain-wide (no on=). There is intentionally no modal_qmodalDampingQ is a verified upstream anti-damping bug.
  • Tagged objectsops.damping.uniform / sec_stif / urd / urd_beta (the four registered OpenSees damping types; URD/URDbeta take N≥2 ascending (freq, value) points). uniform's ratio= is the physical ζ (OpenSees doubles internally). All four take activate_time / deactivate_time (the "no damping during the gravity stage" lever) and factor= (an ops.timeSeries.* object → -factor). on= attaches them via region -damp; alternatively omit on= and hand the returned handle to a -damp-capable element's damp= kwarg (elasticBeamColumn / forceBeamColumn / dispBeamColumn / stdBrick / FourNodeQuad / the Shell family / ZeroLength). An object that attaches to nothing fails loud at build().
  • Persistence — damping objects persist to /opensees/dampings/ (bridge schema 2.14.0 → 2.15.0) and fold into model_hash; they replay on OpenSeesModel.from_h5 → build, with element-flag attachments round-tripping (region attaches share the archival-only /opensees/regions limitation).
  • Staged — the same verbs live on s.damping.* inside an ops.stage(...) block and resolve inside that stage (after domainChange). s.damping.modal is deferred (per-stage eigen / wipeAnalysis interaction).

Grammar verified line-by-line against the upstream OpenSees parser source. Shipped across D1 (#527), D2 (#528), D3a (#529), D4 (#530), D3b-1 (#531), D3b-2 (#532), D3b-3 (#534), D5 (#536).

ADDED — ops.element.BezierTri6 typed primitive (Ladruno-fork Bézier triangle)

apeGmsh can now emit the Ladruno fork's BezierTri6 — a 6-node quadratic Bézier (Bernstein) plane element (Kadapa 2018) — as a first-class typed primitive: ops.element.BezierTri6(pg=…, thickness=…, material=…, plane_type=…, bbar=…, consistent_mass=…, pressure=…, rho=…, body_force=…). On a straight-sided mesh the Gmsh tri6 (etype 9) nodes coincide with the element's control points, so connectivity is used verbatim (identity reorder, same basis seam as SixNodeTri).

Unlike SixNodeTri's positional <pressure rho b1 b2> tail, the fork grammar is flag-prefixed: element BezierTri6 $tag $n1..$n6 $thick $type $matTag [-bbar] [-cMass] [-pressure $p] [-rho $r] [-bodyForce $b1 $b2]. Each option is independently optional. The plane_type validator accepts only the 2-value canonical pair (PlaneStrain/PlaneStress) — not the *2D spellings SixNodeTri tolerates — matching the fork factory. Mirroring the fork's D5 guard, requesting bbar=True under PlaneStress emits a BezierBBarPlaneStressWarning and drops the -bbar flag (the run proceeds).

Fork-gated at run, not emit: the element BezierTri6 … line is produced on any build; the fork is required only to run the deck (a clear fork-build error and direct-drive fallback land in a later slice). Result reads are wired in the _response_catalog (ELE_TAG_BezierTri6 = 33000, live from classTags.h; the dead pre-fork 272/273 are explicitly rejected) under both the real Triangle_GL_2 rule and Custom — the recorder serves it via the element's self-declared basisInfo. The catalog row carries layout/n_gp metadata only; canonical per-GP coords come from the file's QUADRATURE/GP_PARAM (the fork integrates the 3 GPs in a permuted index order vs SixNodeTri).

ADDED — ops.element.BezierTet10 typed primitive (Ladruno-fork Bézier tetrahedron)

The 3D sibling of BezierTri6 — a 10-node quadratic Bézier (Bernstein) tetrahedron: ops.element.BezierTet10(pg=…, material=…, bbar=…, consistent_mass=…, rho=…, body_force=…, pressure=…), emitting element BezierTet10 $tag $n1..$n10 $matTag [-bbar] [-cMass] [-rho $r] [-bodyForce $b1 $b2 $b3] [-pressure $p] (flag-prefixed tail). On a straight-sided mesh the Gmsh tet10 (etype 11) nodes coincide with the control points, so connectivity is verbatim — the 10 nodes are 4 corners then 6 mid-edge nodes in TenNodeTetrahedron order (1-2, 2-3, 1-3, 1-4, 3-4, 2-4). Unlike BezierTri6 there is no plane-stress degeneracy, so B-bar is always valid (no warn-and-drop guard).

The O11 node-order identity is locked by a machine-precision test (tests/opensees/integration/test_bezier_tet10_o11.py): a straight-sided box meshed to tet10 has every mid-edge node within 2.2e-16 (relative) of its corner-pair midpoint — confirming the Gmsh tet10 order is byte-identical to the element's control-point order (the node_reorder={11: identity} decision; a wrong order would silently yield a wrong stiffness). Reads wired in _response_catalog (ELE_TAG_BezierTet10 = 33001) under Tet_GL_2 + Custom; the Tet10 GP index order is clean (matches _TET_GL_2_COORDS — no permutation, unlike the Tri6 sibling).

ADDED — Bézier elements: clear fork-build error when run on a stock build

Running a deck that contains a BezierTri6 / BezierTet10 in-process on a non-fork (stock) openseespy build now raises a clear "element BezierTri6 requires the Ladruno fork build … only running the deck in-process needs the fork … use the direct-drive fallback" error, instead of a cryptic openseespy error or a silent no-op that fails much later. LiveOpsEmitter.element verifies the fork-only element actually built (catching both the raise and the silent-warn-and-drop stock behaviors via getEleTags), caches the verdict after the first success (O(1) overhead), and skips the probe inside non-zero partition blocks. Deck emission (ops.tcl / ops.py) is unaffected — only the in-process run is gated; direct-drive remains the supported fallback on stock builds.

IMPROVED — Bézier Gauss-point world coordinates reconstructed via the Bernstein basis

results.elements.gauss.get(...).global_coords(fem) now reconstructs the world coordinate of a Bézier element's Gauss points as x = B(ξ)·X over all control points — routing BezierTri6 / BezierTet10 through the neutral apeGmsh._basis Bernstein evaluator, with ξ taken from the file's QUADRATURE/GP_PARAM (never a catalog GP order — respecting the Tri6 GP-index permutation). Previously these higher-order families had no entry in the linear Gmsh shape-function catalog and fell through to a centroid+bbox approximation (visibly wrong — off by ~0.5 on the test element). Every other element type keeps its existing linear-catalog / bbox path unchanged. Verified fork-free against committed fixtures: the Tri6 reconstruction matches an independent affine-barycentric corner map to 2.2e-16, and the Tet10 reconstruction matches the element's own GLOBAL_GP_COORDS to 2.2e-16. New fixture tests/fixtures/ladruno/bezier_tet10.ladruno.

ADDED — ops.profiler.* (Ladruno-fork stack profiler) + analyze(profile=…)

apeGmsh can now emit the Ladruno fork's stack-profiler control command, which brackets the analyze loop and writes one profile.h5. It is a control command, not a model primitive or recorder — no class tag, no _response_catalog entry, no reader (read profile.h5 with the fork's out-of-tree Ladruno_tools/profiler_viewer).

The new ops.profiler namespace exposes the five shipped fork verbs 1:1 — start(deep=, memory=, per_step=) / stop() / reset() / report(file, run=) / memory() (mapping to profiler start [-deep] [-memory] [-perStep] / stop / reset / report <file> [-run <id>] / memory). There is no config verb / -warmupSteps — the design doc showed them but OPS_profiler() never wired them.

Deck emit (Tcl / Py): record the verbs before ops.tcl(...) / ops.py(...); the bridge brackets the appended analyze line, with side chosen by verb (start / reset before, stop / report / memory after). Live: ops.analyze(steps=…, profile='profile.h5', profile_run=…, profile_deep=…, profile_memory=…, profile_per_step=…) wraps the in-process run.

Fork-gated at run time: emitting deck text works on any build; the live emitter re-raises a clear "requires the Ladruno fork build" error when stock openseespy lacks the profiler command. ops.tcl(run=True) is the recommended profiled path. One new Emitter.profiler(*args) Protocol method (Tcl/Py emit the line, live forwards + gates, h5 no-ops, recording captures).

Reading the outputapeGmsh.profiler.open(path) / apeGmsh.profiler.show_web(path) are a thin, fork-free-at-import bridge to the fork's out-of-tree Ladruno_tools/profiler_viewer: open re-exports its ProfilerResults loader (manifest / rollup / series / diff; series is the per-step "monitor"), show_web launches the one-process React UI. apeGmsh re-exports, never re-implements. The viewer dir must be importable (viewer_dir= kwarg, LADRUNO_PROFILER_VIEWER env var, or sys.path); otherwise a clear install-hint error fires.

ADDED — Ladruno .ladruno recorder: emit + canonical read (ops.recorder.Ladruno / Results.from_ladruno)

apeGmsh now emits and reads the Ladruno fork's canonical HDF5 recorder, the self-describing sibling of STKO .mpco.

Emitops.recorder.Ladruno(file, nodes=…, elements=…, dt=… | nsteps=…) produces a recorder ladruno … line on any build (the fork is required only to run the deck). Whole-model value channels via -N/-E/-T.

ReadResults.from_ladruno(path, *, fem=None, merge_partitions=True, model_h5=None). Unlike every other constructor, model_h5= is optional: a .ladruno is self-describing (geometry, regions, beam local axes all in-file), so the broker is built from the file itself (schema Principle 0). The reader keys on INFO/GENERATOR="Ladruno" + a windowed FORMAT_VERSION (ADR 0023 spirit) and needs only h5py — no fork at read time. Multi-partition runs (<stem>.part-N.ladruno) auto-discover siblings and merge (node-union + element-concat), like from_mpco.

Result reads go through the usual results.* API: - results.nodes.get(component="displacement_x") — chunked nodal channels. - results.elements.gauss.get(component="stress_xx") — continuum stress/strain, neutral vocabulary (accepts both the sigma11 and sigma_xx/eps_xx/gamma_xy token forms different element classes emit). Gauss-point natural coords come from the file's QUADRATURE/GP_PARAM. - results.elements.line_stations.get(component="axial_force") — beam internal-force diagrams, neutral (axial_force/shear_y/…); localForce end forces get the sign-continuity flip, basicForce is one station at ξ=0. - results.elements.get(component="localForce")token-driven: the component is the file's ON_ELEMENTS/<token> key (basicForce/localForce/force/globalForce), returning the raw (T, E, NUM_COLUMNS) block in the file's column order. (The one place the Ladruno element API differs from MPCO's neutral nodal_resisting_force_* — Ladruno is file-driven; the neutral beam view is line_stations.)

Beam orientation — a .ladruno writes MODEL/LOCAL_AXES (per-class quaternion frames) that .mpco omits. results.elements.local_axes(...) surfaces a LocalAxes (scalar-first quaternions + .matrices/.x_axis/.y_axis/.z_axis, axes are the matrix rows), and results.plot.line_force(...) now orients diagrams from the recorder frame (true cross-section roll) instead of guessing from node geometry — retiring the .mpco "no beam vecxz" workaround for wired classes.

Energy balanceresults.energy(region=None) → a pandas DataFrame KE/IE/DW/ULW/RES/ERR indexed by time (recorder -G energy; whole-domain or per-region), subsuming the standalone EnergyBalance recorder.

Self-describing geometry — higher-order / Bézier element groups carry a BASIS descriptor (family/topology/order + GP_PARAM) instead of a per-class shape-function table; GP world coords are reconstructed via the new neutral apeGmsh._basis evaluator (B(ξ; family, order, topology) — delegates Lagrange to the existing shape-function library, adds the Bézier/Bernstein bases, validated against the reference elements). Shared with the upcoming Bézier read path.

ADDED — g.model.geometry.add_arch(start, apex, end, *, label=)

A circular arch built as two tangent arcs that share the apex as a topological vertex, so the crown survives meshing as a conforming node.

add_arc(..., through_point=True) fits a single arc through start/apex/end and leaves the apex as a floating construction point that the mesher discards — a physical group placed on the apex then resolves to a node that never lands in the mesh (a gmsh quirk). add_arch instead computes the circle centre, emits two addCircleArc halves (start→apex, apex→end) of the same circle, then removes the construction centre so it leaves no stray node at the centre of curvature. Both halves are tangent-continuous at the apex (no kink), and the apex becomes a real vertex — guaranteeing a mesh node exactly at the crown for a crown load, monitoring point, or midspan physical group.

Returns list[Tag] = [start→apex, apex→end] (hand straight to g.physical.add_curve); label= is applied to both halves. Fails loud (ValueError) on collinear/coincident points. add_arc is unchanged.

The general declarative g.model.geometry.embed_node() (for interior points of surfaces/volumes, where the split-into-parts trick has no clean equivalent) was designed alongside this but deferred.

ADDED — CAD-import health diagnostics + scale-aware healing

g.model.io.diagnose(*, warn=False) -> ImportHealth is a new non-mutating health check for the current OCC geometry: per-dimension entity counts, sliver tallies (edges/faces below 1e-4 · bbox_diagonal), the bbox diagonal, and a suggested heal= tolerance. It never heals, dedupes, or renumbers — the look-before-you-leap counterpart to heal_shapes (which does mutate). ImportHealth.is_suspect keys on slivers only, so a surface-only import (shell models) does not false-positive.

A new typed WarnGeomImportHealth advisory is auto-emitted by load_step / load_iges / g.parts.import_step on a raw (un-healed) import when slivers are present — it names the counts and suggests heal='auto'. The import is never healed on the user's behalf (healing renumbers entities, so it stays opt-in).

g.parts.import_step(...) gains heal= / dedupe= kwargs (same semantics as g.model.io.load_step), closing the gap where the assembly path — the workflow most likely to ingest real external CAD — could not heal at all.

CHANGED — heal=True / heal="auto" on import is now scale-aware

load_step / load_iges (and the new parts.import_step) heal=True now derives a scale-aware tolerance (≈ 1e-6 · bbox_diagonal) instead of the legacy absolute 1e-8. "auto" is an explicit alias; a float still overrides. The old 1e-8 default was effectively a no-op heal on any real-world (mm/m-scale) model — heal=True now actually heals. heal=False (the import default) is unchanged.

CHANGED — raw user physical groups now protect entities from the orphan sweep

apeGmsh.core._geometry_topology._user_intentional gains a third channel: an entity that participates in ANY physical group (including raw user PGs created via gmsh.model.addPhysicalGroup directly) is treated as user-intentional and survives sweep_dangling / find_orphans / validate_pre_mesh(strict=True).

Closes PR #378 reviewer follow-up #2. The new channel lowers the false-positive rate of the open-world (strict=True) check substantially: raw-gmsh frame workflows that tag their lines with PG names like "Columns" / "Beams" are now protected, even though they bypass apeGmsh's _metadata and g.labels channels entirely.

Order of channels in _user_intentional:

  1. model._metadata (closed-world, populated by apeGmsh add_*)
  2. apeGmsh label PGs via Labels.labels_for_entity (tier-1 _label:*-prefixed PGs only)
  3. Raw user PGs via gmsh.model.getPhysicalGroupsForEntity (new — catches any PG, no prefix filter)

Mesh.generate's default strict=False auto-validation is unaffected (it only inspects _metadata). Users explicitly calling validate_pre_mesh(strict=True) or remove_orphans() get the broader protection.

ADDED — g.model.geometry.find_stale_metadata() + validate_pre_mesh(strict=...) split

  • g.model.geometry.find_stale_metadata() -> list[(dim, tag)] — closed-world inspection. Walks only the keys apeGmsh primitives recorded in model._metadata and returns those whose tag is no longer in OCC. Cannot false-positive on raw gmsh.model.geo.* / gmsh.model.occ.* workflows because those workflows don't populate _metadata in the first place.
  • g.model.geometry.validate_pre_mesh(*, strict=False) gains the strict kwarg:
  • strict=False (default; auto-fired by Mesh.generate) — runs find_stale_metadata() only. Closed-world. Catches the actual leak class the orphan-sweep PR was chasing: an apeGmsh boolean / cut / fragment op consumed an entity without cleaning its _metadata key.
  • strict=True (opt-in) — runs find_orphans() and raises on any orphan dim≤2 entity. Open-world. Users opt in when they know their build script stays inside the apeGmsh facade (_metadata + g.labels channels).
  • Mesh.generate now auto-invokes g.model.geometry.validate_pre_mesh() alongside the loads / constraints / masses validators. Because the default is strict=False, raw-gmsh workflows continue to mesh cleanly — only stale apeGmsh-managed metadata trips the auto-check.

The split is the follow-up the PR #378 review backlog asked for. The earlier attempt to auto-wire the full open-world check broke 63 tests across test_partition_*, test_loads_physical_outward, test_mesh_editing_crack, test_embedded_decomposition, test_constraint_emission, test_partition_pipeline_e2e because those workflows build geometry via raw gmsh.model.geo.addPoint/addLine/addPhysicalGroup. Splitting the validator into closed-world (auto-fires) and open-world (opt-in) gets the auto-validation back without the false positives.

FIXED — coincident-face orphan-geometry leak in slice / cut / fragment

  • g.model.geometry.slice(...) / cut_by_surface(...) / cut_by_plane(...) and g.model.boolean.fragment(...) now share a single topology-driven cleanup pass (apeGmsh.core._geometry_topology.sweep_dangling), eliminating the three pre-existing definitions of "orphan" the cleanup paths used. The leak was reliably reproducible when a cutting plane coincided with an existing face of the operand (e.g. slicing a swiss-cheese solid at its cavity-bottom z-coordinate, or fragmenting two abutting solids at their shared face): OCC consumed the tool but left at least one free-floating sub-piece behind. Plus matching dim=1 and dim=0 leaks and stale model._metadata entries.

  • The sweep keeps anything that (a) bounds a registered volume at any depth, or (b) is user-intentional — in model._metadata (every add_* primitive registers there) or carrying a label. Everything else dim ≤ 2 is removed, then stale metadata keys whose tags no longer exist in OCC are reaped. Mirrors the audit's spec verbatim.

CHANGED — boolean.fragment(cleanup_free=...) default flipped back to True

  • g.model.boolean.fragment(...) default cleanup_free flipped from False back to True, but the cleanup is now the topology-driven sweep above, not the previous centroid-in-bbox heuristic. The centroid heuristic over-collected shell-on-solid geometry whose centroid happened to fall outside the volume bbox — the topology sweep preserves any standalone shell the user explicitly created (add_rectangle, add_plane_surface, etc.) because those entities live in _metadata. Pass cleanup_free=False only when you need OCC's raw output (no sweep, no stale-metadata reap) for downstream inspection.

  • _bool_op registration narrowed. Previously every dimtag in a boolean result (including all sub-surface byproducts of a 3D fragment) was registered in _metadata. The sweep would then mis-protect those byproducts as user-intentional. Now _bool_op registers only result dimtags whose dimension matches default_dim (typically 3). cut_by_surface(keep_surface=True) re-registers cut interfaces as 'cut_interface' explicitly — unchanged for users.

ADDED — g.model.geometry.find_orphans() / remove_orphans() / validate_pre_mesh()

  • find_orphans() -> dict[int, list[int]] — inspect the model for orphan geometry without modifying it. Returns the dimtags the post-op sweep would reap.
  • remove_orphans(*, dry_run=False) -> dict[int, list[int]] — manual sweep entry point. Same algorithm slice / cut_by_* / fragment run internally.
  • validate_pre_mesh() — raise GeometryValidationError if any orphans exist. Mirrors MassesComposite.validate_pre_mesh / LoadsComposite.validate_pre_mesh / ConstraintsComposite.validate_pre_mesh. Opt-in: users call it explicitly. Mesh.generate does NOT auto-invoke it because raw gmsh.model.geo.* / gmsh.model.occ.* workflows bypass the _metadata channel and raw user PGs bypass the label channel — both would trigger false positives. Auto-wiring stays on the follow-up backlog until those channels are unified.

ADDED — coincident-face advisory + one-sided-cut warning

  • WarnGeomCoincidentFace fires from add_axis_cutting_plane when the requested plane sits within OCC tolerance of an existing axis-aligned face of an operand. The sweep cleans up the orphan regardless; the warning lets users refactor the offset to avoid the OCC fragility entirely.
  • WarnGeomOneSidedCut fires from cut_by_plane when the plane offset sits outside the operand's bounding box (only one side has fragments). Previously a silent log line; now a UserWarning subclass so test-time pytest -W error catches it.

REMOVED — dead code on cut_by_surface

  • cut_by_surface(sync=...) parameter dropped. The argument was accepted but never honored (the function always synced unconditionally). Internal callers (cut_by_plane, slice) updated accordingly.
  • Dead inherited_label block removed (computed but never read).
  • _cleanup_slice_orphans deleted — replaced by sweep_dangling.

ADDED — higher-order line broker split (ADR 0037, #349)

  • g.mesh.editing.split_higher_order_lines(physical_group, *, policy, dim=1) — broker-side resolution to the 2nd-order-continuum + frame hard-stop. When a quadratic continuum part (shell, tet10, etc.) propagates Gmsh's global mesh order to every line entity in the model, frame PGs end up with 3-node Line3 elements that OpenSees beam-columns refuse at _check_two_nodes. The new verb demotes them in place to 2-node Line2s before the bridge ever sees them.

Three policies:

  • "forbid" — raise RuntimeError if any Line3 present, naming the PG and count. Use as a build-time invariant lock when a PG must remain 1st-order through meshing.
  • "split" — for each Line3 (i, j, mid), remove the Line3 from its dim=1 entity via gmsh.model.mesh.removeElements and add two Line2 pairs (i, mid) + (mid, j) to the same entity via gmsh.model.mesh.addElements with type=1. The mid-side node, formerly a Gmsh side-node carrying no FE DOFs, becomes an endpoint of two Line2 elements and acquires DOFs in the OpenSees domain. PG membership tracks at entity level (no rebinding); no new gmsh nodes are minted.
  • "constrain" — RESERVED, raises NotImplementedError. The kinematically clean answer (mid-node linearly interpolated from i and j) requires an OpenSees primitive that doesn't exist today: ASDEmbeddedNodeElement accepts exactly 3 or 4 retained nodes per ADR 0036, so a 2-master Line2 pair can't be expressed. Gated on upstream OpenSees work on the same future track as ADR 0036's HostProjector RFC.

  • Sequencing. Call after g.mesh.generation.generate(...), before g.mesh.queries.get_fem_data(...) and g.mesh.partitioning.partition(...). Never inside a stage block — the mesh edit is global and must complete before the bridge builds.

  • Concentrated-plasticity trap (documented, not enforced). policy="split" on a PG that hosts a forceBeamColumn / dispBeamColumn with HingeRadau / HingeRadauTwo / HingeMidpoint / HingeEndpoint integration places the calibrated end-region hinges in the wrong locations (each sub-element inherits the parent rule → four hinges per parent at the wrong stations). The docstring and ADR 0037 §Consequences flag this; runtime bridge-side detection deferred until it bites.

  • _check_two_nodes sharpened. The bridge guard at opensees/element/beam_column.py gains explicit 3-node and 4-node branches that name the new verb in the error message. Per ADR 0037 INV-1: this is a friendlier loud-fail message, not bridge awareness of higher-order topology — the bridge still refuses to emit a beam with three nodes; it just tells the user where to go.

  • tests/test_mesh_editing_split_higher_order_lines.py locks the new invariants: policy dispatch, dim guard, unknown-PG KeyError, empty-iterable ValueError, policy="forbid" raise message + Line2 no-op, policy="split" count + PG membership + mid-node preservation + Line2 connectivity assertion (the load-bearing invariant: (i, mid) + (mid, j) pairs), idempotency on Line2-only PG, iterable input, multi-PG split, mixed-order-per-entity surgery (the persistent record of the one-off gmsh spike), and end-to-end through the bridge's elasticBeamColumn fan-out.

  • No schema bump anywhere. Per ADR 0037 INV-3: bridge surface (/opensees/* zones, element_meta, tag_recorder, transform fan-out) is untouched; the FEMData snapshot is consistent topology after split (no parallel "macro-origin" state).

  • Deferred (ADR 0037 §Future work): policy="constrain" (gated on upstream OpenSees primitive); dim=2 / Line4 cubic edges (additive once needed); bridge-time concentrated-plasticity enforcement.

FIXED — RecorderDeclaration element fan-out resolves FEM eids to ops_tags (#348)

  • ops.recorder.declare(elements=...) / gauss=... / line_stations=... now translates FEM eids through the bridge's fem_eid_to_ops_tag map before writing -ele arg lists. Previously the RecorderDeclaration emit path (_emit_recorder_declaration_emit_element_level_record_resolve_element_targets in opensees/_internal/build.py) passed raw FEM eids straight into -ele, silently targeting the wrong OpenSees tags whenever any ops.element.X(pg=...) spec consumed an allocator slot in _register (which is always — _kind_of groups specs and fan-out instances under the same "element" kind).

  • Consistency restored. The typed-Element recorder path (opensees/recorder.py:286-305) already had this translation; this fix brings RecorderDeclaration to parity. Both routes now resolve identically and raise BridgeError when a target eid maps to no ops_tag, with the eid in the message.

  • New _translate_to_ops_tags helper centralizes the translation so the same fail-loud message fires from all three element-recorder emit sites (canonical Element, raw Element, integrationPoints pairing for line_stations).

  • Backward compatible. Legacy direct callers (no bridge, e.g. unit tests of materialize()) pass fem_eid_to_ops_tag=None and get raw eids — only the through-bridge emit path applies the translation.

  • 12 pre-existing tests updated in tests/opensees/integration/test_recorder_declaration_emit.py (they had asserted the buggy passthrough by targeting FEM eid 1 with no Element primitive registered). Now register an elasticBeamColumn via a new _register_dummy_beam helper and assert the translated ops_tag (FEM eid 1 → ops_tag 2, because the spec consumes element-kind slot 1). 2 new regression tests: test_elements_pg_translates_fem_eids_to_ops_tags (positive) and test_elements_missing_eid_raises_bridge_error (loud-fail).

ADDED — Phase SSI-2.E between-stage Domain mutators

Five new _StageBuilder verbs lift the append-only restriction on stage-bound BCs declared in Phase SSI-2.D. Closes the _DEFERRED.md §"remove sp / mass-zero-out across stages" item.

  • s.remove_sp(*, pg=None, nodes=None, dofs) — releases prior- tier SP constraints. Emits remove sp $node $dof per resolved (node, dof) pair, INSIDE the stage block and BEFORE any new stage-bound fix / mass / region / MP-constraint lines. The emit position locks the canonical atomic-replace pattern: release prior + re-fix in the same stage works by construction.

  • s.remove_element(*, pg=None, elements=None) — drops elements from the Domain mid-analysis. Emits remove element $tag. The elements= parameter takes FEM eids (matching the recorder.Element convention); the bridge translates to OpenSees ops tags via fem_eid_to_ops_tag at emit time so the emitted line carries the same tag the rest of the deck uses.

  • s.mass(..., overwrite=True) — opts the record out of validator V2's cross-tier duplicate-mass refusal. The emitted mass line is byte-identical with or without the flag (OpenSees Domain::setMass silently overwrites); the flag is purely a build-time validator-bypass marker acknowledging the intentional overwrite. apeSees.mass(...) accepts the same kwarg for symmetry.

  • s.set_time(t) / s.set_creep(on) — emit setTime $t / setCreep 0|1 right after stage_open. Useful for stages whose pseudo-time should begin at a non-zero value (overriding the prior stage_close's loadConst -time 0.0 reset) or to toggle creep for time-dependent concrete materials.

  • s.reset() — emits the bare OpenSees reset command between the stage's recorder declarations and its analyze loop. Rarely needed; kept for parity with the OpenSees surface.

Validators

  • V5s.remove_sp target must reference an SP declared in the global apeSees.fix pool OR in a strictly-earlier stage's s.fix pool, AND not already removed by an earlier stage. Same-stage s.fix does NOT count (fix emits AFTER remove_sp in the stage block).

  • V6s.remove_element target must reference an element emitted globally OR activated by this stage / a strictly-earlier stage AND not already removed. PG-typo case (pg= resolves to nothing on the FEM snapshot) surfaces a dedicated offender line.

  • V2 widened — the existing duplicate-fix-mass validator now subtracts s.remove_sp targets from the fix alive set on encountering each stage, so the atomic-replace pattern (release prior + re-fix same DOF in same stage) passes both V5 and V2.

Protocol

Emitter protocol gains five new methods (set_time, set_creep, reset, remove_sp, remove_element). Per-emitter implementations:

  • Tcl / Py: emit the corresponding OpenSees command (setTime $t, setCreep 1|0, reset, remove sp $node $dof, remove element $tag).
  • Live: forwards to self._ops.{setTime, setCreep, reset, remove("sp", ...), remove("element", ...)}. Unreachable on the staged path (which raises at stage_open in live), but works for non-staged custom workflows.
  • H5: no-op (mirrors the existing stage_open / stage_close no-ops; the apeSees.h5(path) guard on staged models still applies).
  • Recording: tuple capture for tests.

Build pipeline

_emit_stages_flat and _emit_stages_partitioned widen the unified domain_change gate to fire on removals (a stage that ONLY does s.remove_sp still emits domain_change so the next analysis chain bind sees a fresh DOF map). Element tag allocation moves earlier in the staged path so V6 can resolve elements=[fem_eid] user inputs against the live fem_eid_to_ops_tag map.

Tests

tests/opensees/unit/test_stage_ssi_2e_mutators.py — 34 tests covering builder positive/negative, dataclass field shapes, single-partition emit position, Tcl emit text, V5 / V6 ownership- tier rules, V2 relaxation with overwrite=True, and the end-to-end atomic-replace pattern.

ADDED — embedded-host decomposition for non-simplex / higher-order hosts (ADR 0036)

  • g.constraints.embedded(...) now accepts non-simplex and higher-order hosts without remeshing. Previously the collector raised on any host element type other than tri3 / tet4, forcing users to either remesh their hex/quad concrete blocks as tets or hand-build the ASDEmbeddedNodeElement directly in Tcl/Python. The decomposition runs apeGmsh-side only — no OpenSees changes, no H5 schema bump — by virtualising the host into linear sub-tris / sub-tets that the C++ element already accepts (it stores retained tags without validating their source; see ASDEmbeddedNodeElement.cpp:293-322).

Supported host etypes (2D): tri3 (CST), tri6 (LST), quad4, quad8, quad9. Supported (3D): tet4, tet10, hex8 (6 Kuhn tets), hex20, prism6 (3 tets), prism15, pyramid5 (2 tets), pyramid13. Prism and pyramid use Kuhn-style decompositions verified positive-volume against gmsh.model.mesh.getElementProperties(etype).

  • Reserved host_coupling="linear" keyword on EmbeddedDef and embedded(...) pins the coupling kinematics so a future "trilinear" / "biquadratic" option (requiring a new OpenSees element class that supports N-node retained sets) can be added without breaking existing models. Pre-existing models keep producing identical results because "linear" is the default.

  • One UserWarning per (etype, entity) when a midside-bearing host (tri6, tet10, quad8, quad9, hex20, prism15, pyramid13) hits the decomposition path. The warning surfaces the linear-coupling consequence so a user who chose LST for bending curvature knows the embed only sees the corner-to-corner linear stretch. Set host_coupling="linear" explicitly on the call to acknowledge.

  • Mixed-dim host fail-loud. A host PG that combines 2D and 3D entities raises a clear error at collection time — the linear coupling cannot pick between them deterministically (kNN centroid search would dispatch based on opaque proximity). Split the host PG into two embedded(...) calls instead.

  • tests/test_embedded_decomposition.py locks the new invariants: Kuhn / prism / pyramid orientation tables match Gmsh's canonical reference coords; each decomposition partitions its reference cell with no gaps or overlaps; the quad4 split covers any convex quad; end-to-end resolver run on a hex8 mesh produces an InterpolationRecord with 4 master nodes drawn from the host's 8 corners; sliver-tet hosts either resolve with bounded weights or fail-loud (never silent nonsense); the higher-order warning fires exactly once per (etype, entity); the host_coupling keyword reservation rejects unimplemented values.

  • Renames _collect_host_elems_collect_host_subelements. The collector now returns virtual sub-element rows (not real gmsh elements), so the name change makes the new contract explicit. Private API only — no downstream user surface affected. ADR 0027 §"Mixed-host silent drop" historical reference updated; docs/api-flows/* regenerated.

ADDED — topology safety nets and coincident-node diagnostic

  • fem.inspect.find_coincident_node_pairs(tol=, pg=) — opt-in diagnostic that surfaces every pair of distinct nodes sharing an XYZ within tolerance and lists which elements / constraints (if any) bridge them. An empty refs list is the smoking gun for an unbridged duplicate — the classic OCC arc-line-junction failure mode where a wire built as add_ellipse(angle1, angle2) + lines produces two nodes at every corner with no moment continuity. Reuses the resolver's _SpatialIndex (SciPy cKDTree with NumPy fallback); no new dependencies.

  • Tuple-uniqueness check at the OpenSees bridge boundary — every element's connectivity tuple is now validated by emit_element_spec before emission. Repeated node tags in a single element fail loud with a BridgeError carrying (fem_eid, type, tuple). The check is tag-level, so zeroLength (two distinct tags at coincident XYZ) still passes; the only behaviour change is that previously-silent resolver bugs now stop at the bridge instead of confusing OpenSees downstream.

CHANGED — mesh.editing.remove_duplicate_nodes() always prints

  • Dropped the verbose= parameter. Node removal is now unconditional on stdout — both branches (merged N node(s) / no duplicates found) always announce themselves. Deleting nodes from a meshed model is destructive; the visibility floor is intentional so an unexpected dedup never hides in a long pipeline log. Callers passing verbose=False will now TypeError — drop the kwarg.

DOCS — make_conformal() canonical fix for arc-line junctions

  • g.model.queries.make_conformal() docstring now calls out two flavours of disjoint topology it addresses: IGES/STEP imports AND partial-arc-built wires (add_arc / add_circle(angle1,angle2) / add_ellipse(angle1,angle2) joined to lines). Adds a Warnings block on ordering: fragment renumbers entities, so any pre-built Part / Assembly holds stale Instance.entities dicts — call make_conformal() before constructing Parts, or rebuild Parts afterward.

  • Wire-builder docstrings (add_arc, add_circle, add_ellipse) now carry a See-Also pointing at make_conformal(dims=[1]) so the next person who hits the cimbra pattern finds the fix from the symbol they're already on. add_ellipse also references the new find_coincident_node_pairs diagnostic for post-mesh verification.

ADDED — stage-bound constraints (s.embedded et al.) + s.initial_stress PUSH path (Phase SSI-2.D extension)

  • Nine new builder methods on _StageBuilder claim resolved constraint records by name so the constraint emits inside the owning stage's block rather than in the global pre-stage MP-constraint pass: s.embedded(name=...), s.equal_dof(name=...), s.rigid_link(name=...), s.rigid_diaphragm(name=...), s.kinematic_coupling(name=...), s.tie(name=...), s.distributing(name=...), s.node_to_surface(name=...), s.node_to_surface_spring(name=...). The user names the constraint at apeGmsh time (g.constraints.embedded(..., name="cimbra_embed")) and claims it inside the stage block by the same name. Claim-by-name semantics (not direct-create) because the kernel resolver requires a live gmsh model + parts registry that are typically gone by bridge time.

  • s.initial_stress(name=, pg=, sigma_*, ramp_steps=, ...) — PUSH-create mirror of ops.initial_stress(...). Builds the record directly in the stage's pool, no intermediate s.add(record) step. Coexists with the existing PULL path; pick by style. A byte-identical parity test locks the equivalence.

  • Forcing function: Cerro Lindo SSI V5. STKO's canonical SSI workflow installs lining (cimbra) in Stage 3 via domainChange onto an already-equilibrated rock state. With embed records always emitting globally pre-extension, the stiff penalty constraint (K=1e8) was active from t=0 and Newton had to equilibrate rock + lining + embed simultaneously from zero — diverged on step 2. The extension defers the embed to stage 2's block with domainChange AFTER constraint emit and BEFORE the stage's analysis chain, exactly mirroring STKO's behaviour.

  • Emit pipeline: new emit_stage_mp_constraints / emit_stage_mp_constraints_partitioned orchestrators in _internal/build.py wrap a flat list of stage records via _StageConstraintAdapter and reuse the six per-kind helpers unchanged. The global emit orchestrators receive a claimed_ids= set and wrap the FEMData broker in _ExcludeClaimedConstraints, so claimed records never double-emit. domain_change() gate widens to include stage.stage_constraint_records.

  • Out of scope (deferred): s.tied_contact / s.mortartied_contact wraps slave records inside a SurfaceCouplingRecord and the global exclusion filter operates on outer-record identity; mortar is not kernel-implemented. Both tracked in _DEFERRED.md. Implicit promotion of g.constraints.* records to stages (Path A from the scoping conversation) — users with pre-existing constraints migrate by adding name= and claiming in the right stage block.

  • ADR 0034 extended with §5a (stage-bound constraints via CLAIM-by- name), §5b (s.initial_stress PUSH justification — PULL was forward-looking; side effects fire at emit time), §5c (Cerro Lindo forcing function).

ADDED — ASDEmbeddedNodeElement optional flags now reach the deck (ADR 0035)

  • g.constraints.tie(...) / embedded(...) / tied_contact(...) gain four new kwargs mapping 1:1 to the OpenSees element ASDEmbeddedNodeElement optionals documented at ASDEmbeddedNodeElement.cpp:201:
g.constraints.tie(
    master_label="shell", slave_label="solid",
    stiffness=1.0e8,        # -K  (penalty stiffness)
    stiffness_p=None,        # -KP (pressure stiffness; only with pressure=True)
    rotational=False,        # -rot (constrain Cnode rotations too)
    pressure=False,          # -p  (u-p / saturated-soil coupling)
)

Defaults (stiffness=1.0e18, stiffness_p=None, rotational=False, pressure=False) match the C++ parser at ASDEmbeddedNodeElement.cpp:222. Scripts that don't pass the new kwargs emit semantically-identical models — the visible difference is that the -K 1e+18 token now appears in the Tcl/Py output, where previously it fell through to the silent C++ default.

  • Fail-loud __post_init__ on every Def mirrors the parser's mutual-exclusion check (cpp:276): rotational=True, pressure=True raises ValueError. Setting stiffness_p=... without pressure=True also raises — -KP is ignored by OpenSees outside the u-p path, so the no-op is treated as a user mistake rather than silently absorbed.

  • Emitter Protocol embeddedNode widens from (ele_tag, cnode, *args) to (ele_tag, cnode, *master_nodes, stiffness=1.0e18, stiffness_p=None, rotational=False, pressure=False). All five concrete emitters (Tcl, Py, LiveOps, H5, Recording) honour the same kwargs; a shared _build_embedded_flag_args helper in emitter/base.py materialises the flag tokens in parser order for the text-based emitters.

  • H5 schema 2.11.0 → 2.12.0 (additive). /opensees/constraints/embeddedNode gains five typed columns — stiffness (float64), stiffness_p (float64) + has_stiffness_p (uint8 sentinel for None), rotational (uint8), pressure (uint8). Per ADR 0023 two-version reader window, the bridge accepts both 2.11.x and 2.12.x files. Old 2.11.x readers ignore the new columns; new readers default the columns to the C++ values when a 2.11.x file lacks them.

  • Motivation. Diffing apeGmsh-emitted decks against STKO surfaced a 10-order-of-magnitude K-divergence: STKO emitted -K 1e8 explicitly while apeGmsh fell through to the C++ default 1e18. Penalty conditioning of every tie / embedded / tied_contact constraint differed between the two pipelines. The exposure closes that gap and gives users the knob to tune K-conditioning on stiff-penalty models. See ADR 0035.

CHANGED — boolean.fragment default (BREAKING)

  • g.model.boolean.fragment(...) default cleanup_free flipped from True to False. The previous default silently destroyed shell surfaces sitting on top of solids in shell-on-solid coupling scenarios (and any other dim-2 entity without an upward-volume adjacency). Existing scripts that relied on the destructive cleanup must now pass cleanup_free=True explicitly. The viewer's Fragment panel checkbox also defaults unchecked to match.

ADDED — top-level g.node_ndf composite, explicit-only per-node ndf (S1b, ADR 0032)

  • New top-level composite g.node_ndf (sibling to g.constraints / g.loads / g.masses) for explicit per-node DOF count declarations. Required for any model that mixes ndf — most importantly shell-on-solid coupling where shared nodes need ndf=6. API surface:
g.node_ndf.set_default(ndf=3)             # uniform fallback
g.node_ndf.set("ShellRegion", ndf=6)      # targeted override
g.node_ndf.list()                         # registered defs
g.node_ndf.clear()                        # drop all defs

Targets follow the same flexible scheme loads and masses accept (label / PG / part / mesh-selection / raw DimTag list). Values must lie in [1, 6].

  • Fail-loud fem.nodes.ndf_for(nid) raises LookupError for any node not covered by a declaration or default; the error message names both fixes so the user picks the right one. apeGmsh deliberately does not infer ndf from element class — the user is the single source of truth (ADR 0032).

  • H5 schema 2.6.0 → 2.7.0 writes an optional /nodes/ndf int8 dataset; readers tolerate 2.6.x absence (two-version window per ADR 0023) and raise MalformedH5Error on length mismatch with /nodes/ids. The snapshot_id digest folds _ndf when present so identical geometry with different declarations hashes differently (presence-gated to preserve legacy / direct-test FEM digests).

  • PR #321 hardening: declaration-order invariant hash (the same resolved ndf array hashes identically regardless of set() ordering); _fem_built resets at the top of each FEM build so post-extract warnings only fire on genuine post-cache mutations; g.node_ndf.clear() warns when called after extraction with the same once-per-batch semantics as set / set_default; real 2.6.0 back-compat fixture replaces the synthetic version-rewrite test.

ADDED — S2: per-node ndf flows into OpenSees emit (override-only, ADR 0033)

  • g.node_ndf declarations now reach the emitted deck (PR #325). S1 stored per-node ndf on the broker; S2 wires it into every OpenSees emit path. Override-only semantics: g.node_ndf is the override channel; the model envelope set via apeSees(fem).model(ndm, ndf=K) is the default. The emitter passes -ndf K on ops.node(...) only when the broker carries a non-sentinel value at that nid; sentinel slots elide -ndf and OpenSees applies the envelope. This mirrors OpenSees-native model BasicBuilder -ndf K + per-node -ndf J override semantics.

Zero user-facing migration cost. Existing scripts that never touched g.node_ndf emit byte-identical decks — the ~285 existing apeSees(fem) test sites and every example notebook keep working without rewrite.

  • Five wiring sites consult the broker via fem.nodes.ndf_for(tag) wrapped in try/except LookupError (the miss falls back to the envelope, preserving ADR 0032's fail-loud broker contract): four owned-node emit sites in apesees.py (flat global, flat staged-owned, partitioned global, partitioned staged-owned) plus one foreign-node site in _internal/build.py::emit_mp_constraints_partitioned. The _replay_into helper in _internal/compose.py widens its per-node tuple to (tag, coords, ndf|None) so per-node declarations survive an H5 round-trip without truncating to the envelope.

  • Three-site validator (validate_envelope_covers_broker_ndf) fires at every OpenSeesModel materialisation path — apeSees.model(), OpenSeesModel.from_compose_buffers(), and OpenSeesModel.from_h5(). A misconfigured envelope (e.g. apeSees(fem).model(ndf=3) after g.node_ndf.set("Shells", ndf=6)) raises BridgeError at the call site, naming the offending node and the fix.

  • from_msh reverted to _ndf=None (undoes PR #321's zero-stamping); _hash_nodes skips the _ndf fold when None OR all-sentinel, preserving hash symmetry across construction paths AND the emit-layer envelope fallback on .msh-loaded models.

  • OpenSeesMP consistency is hash-guaranteed (ADR 0021): the resolved _ndf array folds into fem_hash; every rank deserialises the same broker, so all ranks agree on per-node ndf for shared nodes without explicit cross-rank communication.

  • Architecture: ADR 0033 codifies the wiring, validator sites, phantom carveout, and hash-guaranteed cross-rank consistency. Extends ADR 0032 (the broker contract S2 consumes).

  • PR #328 (S2 follow-up): stateful set_phantom_node_mode(emitter, bool) side-channel replaced with a stateless set_phantom_node_tags(emitter, set[int]) predicate set, pre-loaded ONCE at the entry of emit_mp_constraints / emit_mp_constraints_partitioned. Phantom tags are guaranteed disjoint from real broker tags (the resolver allocates > max(broker_node_tag)), so the pre-loaded set classifies every subsequent node() call without flag-flipping or ordering constraints. ADR 0033 §"Phantom-node carveout" gained an "Alternatives considered" subsection naming the rejected paths (mode flag, tag-formula predicate, Protocol-widening kwarg). Eight emit-path tests added in tests/test_node_ndf.py covering the headline mixed-ndf shell-on-solid case, backcompat byte-identity, each of the three validator sites, H5 round-trip, the from_msh envelope path, and the phantom predicate-set regression.

ADDED — S5: partitioned mixed-ndf shell-on-solid E2E (closes the stream)

  • tests/opensees/integration/test_emit_partitioned_mixed_ndf_shell_on_solid.py (PR #330) — six OpenSeesMP integration tests proving the per-foreign-node ndf lookup added in PR #325 fires per-tag across partition boundaries. The S2 merge unit-tested the flat case; this PR closes the partitioned half:
  • headline mixed-ndf partitioned emit — shell on rank 0 emits ndf=6, solid on rank 1 emits ndf=3, foreign-node decls on each rank carry the broker-sourced peer ndf;
  • INV-2 preserved (foreign node decl precedes equalDOF);
  • ATTR_PHANTOM_NODE_TAGS is empty frozenset when no NodeToSurfaceRecord exists (guards against real broker tags being mis-classified as phantoms);
  • cross-rank consistency — rank 0's foreign-decl ndf for tag T equals rank 1's owned-decl ndf for tag T (observable surface of ADR 0033's hash-guaranteed agreement);
  • byte-identical backcompat on uniform-ndf partitioned models;
  • envelope validator fires at apeSees.model(...), not deep in the per-rank fan-out.

Assertion strategy follows the precedent in every other tests/opensees/integration/test_emit_partitioned_* file: in-process deck capture via RecordingEmitter, bucketed per-rank by partition_open / partition_close brackets — no subprocess MPI runtime needed for emit-semantics assertions. Test-only PR; zero production code changes. Closes the S1 + S2 + S5 shell-to-solid coupling stream.

FIXED — Embedded-element pipeline: tag namespace + intersection host-rank + fail-loud guards

  • PR #329 — canonical TagAllocator + intersection host-rank rule for partitioned ASDEmbeddedNodeElement. Two latent partition- emit bugs in the surface-coupling fan-out:

  • Global tag collision under partitioning. The retired _allocate_embedded_tag_base returned a static 1_000_000 and _emit_surface_couplings_for_rank restarted its per-call counter from that base on every rank — so two distinct embedded records emitted on different ranks both received tag 1_000_000, violating ADR 0027 §"Tag determinism".

  • Duplicate emit on boundary-shared masters. _plan_rank_constraints used if partition_rank in node_owners[masters[0]] — when masters[0] was a partition- boundary node owned by multiple ranks, every owning rank's plan included the record and the embeddedNode line emitted on each (duplicate stiffness contribution at solve time).

Fix: thread the bridge's canonical TagAllocator through emit_mp_constraints / emit_mp_constraints_partitioned / _emit_surface_couplings / _emit_surface_couplings_for_rank — embedded element tags now come from tags.allocate("element"), sharing the global element-tag namespace and guaranteed unique across ranks. Intersection host-rank rule: the unique owner of an embedded record is the host element's owning rank, NOT every rank in node_owners[masters[0]]. New integration test tests/opensees/integration/test_emit_partitioned_embedded.py (~435 lines) locks both fixes end-to-end. ADR 0027 §"ASDEmbeddedNodeElement ownership" + §"Tag determinism" extended to document the canonical- allocator share and the intersection rule so future readers don't reintroduce either bug.

  • PR #331 — three fail-loud guards on the embedded resolver / emit path. Three resolver/emit-time silent-wrongs that produced "valid-looking" decks with broken physics:

  • Off-host extrapolation. resolve_embedded accepted the closest host candidate regardless of barycentric excess, so an embedded node OUTSIDE every host element produced an InterpolationRecord with NEGATIVE shape-function weights — extrapolation, not interpolation. EmbeddedDef.tolerance was documented as "not currently enforced" and did nothing. New excess: float | None field on InterpolationRecord carries the barycentric excess from the resolver; the emit-time guard in build.py raises BridgeError naming the offending slave node + its excess when the resolver returns excess > tolerance.

  • Mixed-host silent drop. _collect_host_elems kept only Gmsh element types 2 (tri3) and 4 (tet4); quad4, hex8, prism, pyramid, tri6, tet10, and any other type were silently dropped. A user meshing the host with hex + a few tets (transition region) saw only the tet subset, with embedded nodes in the hex region projecting onto distant tets (extrapolation again). The collector now retains all supported host element types; mixed- host configurations that the C++ ASDEmbeddedNodeElement parser cannot consume raise at build time, not at OpenSees runtime.
  • Bad Rnode count reaching the C++ parser. The C++ ASDEmbeddedNodeElement only accepts 3 (tri host) or 4 (tet host) Rnodes; 5+ are misread as flag positions, 2 aborts in setDomain. Hand-built records bypassing the resolver could deliver any count and crash OpenSees at runtime. New build-time guard raises BridgeError naming the offending record's Rnode count.

Combined coverage: 81 new lines in tests/test_constraint_emission.py + 80 new lines in tests/test_constraint_resolver.py lock the three guards on positive + negative cases. Full suite: 6733 passed.

ADDED — Phase SSI-2.D: stage-bound BCs and recorders (ADR 0034)

  • _StageBuilder gains four new verbs for binding boundary conditions and recorders to a specific stage of a multi-stage analysis (PRs #323 / #324 / #326):
with ops.stage("excavate") as s:
    s.activate(pgs=["Lining"])                       # SSI-2.B
    s.fix(pg="LiningAnchor", dofs=(1, 1, 1))         # NEW (SSI-2.D)
    s.mass(pg="Lining", values=(100.0, 100.0, 100.0)) # NEW
    s.region(name="lining_rayleigh", pg="Lining")    # NEW
    s.recorder(lining_recorder_spec)                 # NEW
    s.analysis(...)
    s.run(n_increments=20, dt=0.05)

Replaces the SSI-2.B workaround of "keep the BC on a globally- emitted node" for stage-bound topology. s.fix / s.mass / s.region use PUSH (inert dataclasses on the stage directly); s.recorder uses PULL (claims a Recorder already registered via ops.recorder.Node / Element / MPCO).

  • Five-validator ownership-tier surface in BuiltModel._run_staged_bc_validators — orchestrates H1 (global pool targets stage-bound nodes; refactored to share helpers with V1) + V1 (stage N's BC targets stage M > N) + V2 (cross-tier duplicate fix / mass) + V3 (region name= collision across scopes) + V4 (stage N's recorder targets stage M > N). Each emits a BridgeError with an offender list naming the offending scopes; the orchestrator runs in fixed order so error-message stability holds.

  • PR #323 also fixes a pre-existing latent H1 bug: the partitioned emit path previously skipped H1 entirely — a global fix on a stage-bound node would slip through under MP and crash OpenSees at parse time. H1 now invoked from both _emit_flat and _emit_partitioned.

  • Emit pipeline extensions: stage-bound fix / mass / region emit inside the stage block alongside topology and before a single unified domain_change (gated on activation OR fix_records OR mass_records OR region_records); stage-bound recorders emit after the chain and before analyze so they capture the stage's analyze steps. Under MP: per-rank fan-out with empty-bracket skip on non-contributing ranks (prevents Py-emitter SyntaxError); per-stage region_tag_cache keyed by name guarantees all contributing ranks emit the same scalar tag for a given region.

  • Bridge introspection symmetry: bridge.all_fix_records / all_mass_records / all_region_records / all_recorder_specs read-only properties combining global + per-stage pools, tagged by origin. Tooling that previously inspected bridge._fix_records to count fix declarations would have silently missed stage-bound entries.

  • Source-side basis: a four-agent cross-check against the OpenSees C++ source preceded implementation. Key verifications: Domain::addRegion silently appends on duplicate tag (Domain.cpp:2679-2697 — getRegion returns only the first); Domain::addSP_Constraint rejects duplicate (node, DOF) pairs (Domain.cpp:589-605); recorders cache region members at TCL PARSE TIME (TclRecorderCommands.cpp:276); Domain::setMass silently overwrites. These findings shaped V3 (mandatory), V2-fix branch, recorder emit position (after region declarations), and V2-mass branch respectively.

  • Architecture: ADR 0034, staged-analysis.md (refreshed with the SSI-2.D slot order + V1-V4 + per-stage tag cache), api-design.md §"Staged analysis" (refreshed with the four new verbs + PUSH vs PULL note).

  • Test coverage: 38 new tests across four files (15 unit V1-V3

  • 13 unit fix/mass + 6 integration partitioned fix/mass + 19 unit region/recorder/V4 + 4 integration partitioned regions). Full opensees suite: 2746 passed, 2 skipped, 0 regressions.

v2.0.0 — Three-broker chain: Results carries OpenSeesModel carries FEMData (BREAKING) · Composed file pattern · lineage chain · MP constraint emission shipped · per-zone schemas

Major architectural refactor establishing the FEM ⊂ Model ⊂ Results chain with bidirectional H5 round-trip and a git-style lineage DAG. 8 phases shipped sequentially over May 2026; 5864 → 6128 baseline tests passing (+264 net new, 0 failed). Five new ADRs lock the architecture (0019–0023); three prior ADRs (0011, 0014, 0018) preserved verbatim, AST-tested.

This is a BREAKING release — the additive-then-prune migration shipped all new surfaces alongside the old ones through Phases 4–7, then Phase 8 pruned the deprecated paths in one go. External users must update call sites (see Migration below).

ADDED — OpenSeesModel read-side broker (ADR 0019)

A new immutable, queryable Python view over a model.h5's /opensees/ zone:

from apeGmsh.opensees import OpenSeesModel

# Standalone model.h5 (apeSees(fem).h5(p) output):
om = OpenSeesModel.from_h5("model.h5")

# Composed file (results.h5 carries /model + /opensees):
om = OpenSeesModel.from_h5("results.h5", fem_root="/model")

om.fem                  # → FEMData (lazy-imported per INV-4)
om.materials()          # → list[MaterialRecord]    (typed records)
om.sections()           # → list[SectionRecord]
om.transforms()         # → list[TransformRecord]   (carries vecxz)
om.beam_integration()   # → list[BeamIntegrationRecord]
om.patterns()           # → list[PatternRecord]
om.recorders()          # → list[RecorderRecord]
om.cuts() / om.sweeps() # → list[CutRecord / SweepRecord]
om.lineage              # → Lineage(fem_hash, model_hash, warnings)

# Re-emit through any target:
om.build("tcl", "deck.tcl")
om.build("py", "deck.py")
om.build("live")     # in-process openseespy
om.to_h5("copy.h5")  # round-trip via _compose_model_h5

OpenSeesModel is the third role alongside apeSees(fem) (the bridge — write side, typed primitives) and ModelData(fem) (orientation-only side-feeder). ADR 0011 preserved verbatim — apeSees.from_h5 does NOT exist; the read goes through a separate class.

ADDED — Results carries OpenSeesModel via Composed file (ADR 0020)

The chain forward — Results.model.fem. Composed file pattern: one results.h5 carries both the model and the results data:

results.h5
├── /meta            envelope + per-zone schema versions + lineage
├── /model/          rich FEMData neutral zone
├── /opensees/       bridge zone (transforms, materials, constraints, ...)
└── /stages/...      results data

Viewer reads results.model directly; no separate model_h5= kwarg needed (preserves ADR 0014 AST guard — viewer remains a pure h5 consumer).

ADDED — Lineage chain replaces snapshot_id binding (ADR 0021)

Git-style content-hash DAG, warn-not-raise on mismatch:

fem_hash      = blake2b(canonical_neutral_zone_bytes)
model_hash    = blake2b(fem_hash || canonical_opensees_zone_bytes)
results_hash  = blake2b(model_hash || canonical_run_zone_bytes)

Stored at /meta/lineage. results.lineage.warnings surfaces mismatches (list[str]) but never raises from a constructor. results.lineage.assert_clean() is the opt-in loud-fail.

ADDED — MP constraint emission (ADR 0022) — closes §3.3 deferral

MP constraints now emit automatically into runnable Tcl/Py/Live decks via 5 new Emitter Protocol methods (equalDOF, rigidLink, rigidDiaphragm, embeddedNode, mp_constraint_comment). The build-time fan-out (opensees/_internal/build.py::emit_mp_constraints) walks fem.nodes.constraints + fem.elements.constraints in phantom-node-first order. Three integration tests run actual ops.analyze() on rigid_diaphragm / rigid_link / equalDOF / tied_contact fixtures to prove emitted decks converge (INV-1).

The bridge auto-emits ops.constraints.Transformation() when MP constraints are present and the user has not declared a constraint handler — closes the "Plain silently ignores MP constraints" footgun. Users who explicitly want Plain still get respected; a UserWarning fires.

ADDED — Per-zone schema versioning (ADR 0023)

Three independent semver stamps replace the racing single envelope:

  • /meta/neutral_schema_version = "2.6.0"
  • /meta/opensees_schema_version = "2.8.0"
  • /meta/results_schema_version = "1.1.0"

Envelope /meta/schema_version retained for single-stamp legacy back-compat. Two-version reader window: reader at X.Y.Z accepts X.Y. and X.(Y-1).; refuses everything else with SchemaVersionError.

The opensees zone shipped at 2.7.0 for the additive /opensees/constraints/ group, then bumped to 2.8.0 for a follow-up field rename: the second compound-dtype column of /opensees/constraints/embeddedNode was embedding_ele in 2.7.0 (a misnomer — the stored value is the constrained / slave node id, not an element id) and is cnode from 2.8.0 onward (matches the OpenSees $Cnode vocabulary). Same rename rippled through the Emitter.embeddedNode Protocol parameter name and the EmbeddedNodeRecord dataclass field. Two-version reader window accepts both 2.7.x and 2.8.x files; the column name is version-dependent.

ADDED — FEMData.from_h5(path, *, root="/") parameterization

The same reader handles both standalone model.h5 (FEM at root) and Composed results.h5 (FEM at /model/) via the root= kwarg. OpenSeesModel.from_h5(path, *, fem_root="/", opensees_root="/opensees") extends the same idea.

CHANGED — FEMData round-trip is now lossless on every record field

Phase 2 closed five audit gaps: - name field on every constraint/load/mass record now round-trips (was silently dropped — affected fem.inspect.constraint_summary() etc.) - partitions + part_node_map / part_elem_map now round-trip (was lost — fem.nodes.select(partition=k) / select(target=part_label) raised KeyError after from_h5) - info.bandwidth recomputed on reload (was hardcoded to 0) - /meta/snapshot_id now VERIFIED on read (was written but not checked — tampered bytes now raise MalformedH5Error) - New _assert_fem_equivalent(rebuilt, original) parity test exercises five canonical fixtures (frame, plate, mixed-dim assembly, partitioned, mesh-selection) — the meta-gap that allowed B1-B4 to slip through.

REMOVED (BREAKING — Phase 8 prune)

  • Results.from_native/from_mpco/from_recorders REQUIRE model= / model_h5=TypeError on missing. No more silent auto-resolve.
  • Results.viewer(model_h5=...) kwarg removed — chain flows through results.model. CLI: python -m apeGmsh.viewers run.h5 for native files (auto-resolves); --model-h5 PATH required ONLY for .mpco files (sibling pointer).
  • BindError class DELETED — lineage chain replaces it.
  • Director.set_model_h5(path) public method removed — use set_model(opensees_model) or pass via Results.model. Internal _bind_model_h5(path) private helper retained for cuts auto-load and session restore.
  • h5_reader.materials() / sections() / etc. now return typed records — the dict-style versions were deleted. Use materials_by_family() for the family-keyed view.
  • EXPECTED_SCHEMA_MAJOR constant removed — readers use per-zone validation via _internal/schema_version.reader_version(zone).
  • _femdata_native_io.py deleted (439 LOC) — production paths use the rich neutral-zone layout under /model/ via the parameterized read_neutral_zone_from_group / write_neutral_zone_into_group.

Migration

For users with notebooks / scripts that call the old API:

# 1. Add the OpenSeesModel import where you load Results
from apeGmsh.opensees import OpenSeesModel

# 2. For Composed files (apeSees(fem).h5(path) output):
results = Results.from_native(
    path,
    model=OpenSeesModel.from_h5(path, fem_root="/model"),
)

# 3. For standalone model.h5 references:
om = OpenSeesModel.from_h5(model_path)  # default fem_root="/"
results = Results.from_native(results_path, model=om)

# 4. For MPCO + sibling model.h5:
results = Results.from_mpco(mpco_path, model_h5=sibling_model_path)

# 5. Viewer — drop the model_h5= kwarg:
results.viewer()   # NOT results.viewer(model_h5=...)

# 6. Inspect lineage instead of catching BindError:
for w in results.lineage.warnings:
    print(f"lineage warning: {w}")
# or opt into loud-fail:
results.lineage.assert_clean()

Deferred follow-ups (not in this release)

  • embeddedNode per-kind args formalization (tie / mortar / tied_contact / embedded share one Protocol method with positional packing; works for all known cases because ASDEmbeddedNodeElement uses internal isoparametric interpolation).
  • Migration tool for archives outside the two-version reader window (per ADR 0023 INV-5 — owed but not urgent).

v1.6.0 — Selection-unification v2: one .select() idiom · legacy selection surface REMOVED (BREAKING) · half-open mesh box · loads/masses fail-loud

Selection / resolution unification (v2). A single daisy-chainable .select() idiom is now the only selection idiom at all four levels — geometry, the live mesh, the FEM broker, and results. This is a BREAKING change: the legacy selection surface was removed with no deprecation shim (project-owner-ratified full removal — v1's backward-compat constraint was explicitly dropped). Removed: fem.nodes.get / fem.elements.get / .resolve (the selection accessors), the results.*.select(...).values() chain path, g.mesh_selection.add_nodes / add_elements / from_geometric, g.model.queries.select / queries.line / select_all*, g.model.selection (SelectionComposite), the four legacy *Chain modules + GeometryChain, and the Selection / SelectionComposite package exports. The classes core._selection.Selection and viz.Selection are retained by architecture as internal terminal-payload / viewer-pick-result types — not user entry points; only their exports were dropped.

Migrate every legacy call per the old→v2 table and the two incomplete-unification gaps in api/selection.md (ADR 0017 supersedes the earlier ADR-0016 "accepted gap" framing — these are owed v2 successors, not WONTFIX). Gap 1 (geometric-selection → named mesh-selection): the capability is intact via the retained 2-call route (g.model.select(...).to_physical(name) then g.mesh_selection.from_physical(...)) — only the one-call ergonomic was lost. Gap 2 (the SelectionComposite filter grammar): a genuine unique-capability loss for which a v2-native EntitySelection successor is owed/planned (not a resurrected SelectionComposite).

Two behavior changes ride along. The g.mesh_selection box filter moves closed → half-open by default to match the results side (breaking — see below; inclusive=True restores the old closed box). And the loads/masses __ms__ consumer now fails loud instead of silently binding to zero nodes — the one remaining member of a three-path fail-loud end-state whose other two paths landed earlier (see below).

This release is selection plumbing only — no viewer or solver-bridge surface changed.

ADDED — One fluent .select() idiom at all four levels

A new canonical, daisy-chainable selection chain. Entry points, returning a chain that composes fluently:

Entry point Terminal Family Result
g.model.select(target, *, dim=) EntitySelection entity .to_label/.to_physical/.to_dataframe/.result() (→ the retained Selection payload)
fem.nodes.select(...) MeshSelection point .result()NodeResult; .ids/.coords
fem.elements.select(...) MeshSelection point .groups()/.result()/.resolve()GroupResult
results.nodes.select(...) / results.elements.select(...) MeshSelection point .values(component=, time=, stage=) → slab (forwards onto the retained results.<level>.get(...) reader)
g.mesh_selection.select(*, level=, dim=, ids=, name=) MeshSelection point .result() → same dict as get_nodes/get_elements; .ids; .save_as(name) (live-mesh only)
  • Refining verbs (identical names on every chain): .in_box(lo, hi, *, inclusive=False), .in_sphere(center, radius), .on_plane(point, normal, *, tol=), .nearest_to(point, *, count=1), .where(predicate). Each returns a new chain of the same concrete type, so they daisy-chain:
    sel = (fem.nodes.select(pg="Body")
               .in_box((0, 0, 0), (1, 1, 1))
               .on_plane((0, 0, 0), (0, 0, 1), tol=1e-6))
    result = sel.result()          # the existing NodeResult
    
  • Set algebra: | & - ^ (and the named aliases .union / .intersect / .difference), insertion-order preserving with one fixed dedup law. Combining two chains of different type or bound to different engines (different FEMData / Results / session) raises TypeError — cross-level mixing is loud, never a silent empty set.
  • Seeding reuses the existing contract-locked resolvers, never a re-implementation. Broker chains take the same selectors as .get (target / pg / label / tag / dim / partition, element_type for elements) plus ids=; no-arg seeds the whole domain. Results chains take pg= / label= / selection= / ids=. g.mesh_selection.select takes ids= or name= (an existing g.mesh_selection set, seeded id-for-id by delegating verbatim to the existing get_tag/get_nodes/get_elements surface — no new resolver, read-only, fail-loud on an unknown name); no-arg seeds the full live-mesh universe. g.model.select delegates string resolution to the same label → PG → part tier as everywhere else.
  • Two spatial families, honestly different — not interchangeable:
  • point family (fem.*, results.*, g.mesh_selection): .in_box is half-open [lo, hi) by default; inclusive=True gives the closed box [lo, hi]. Operates on node coordinates (node chains) or element centroids (element chains).
  • entity family (g.model.select): .in_box delegates to Gmsh's getEntitiesInBoundingBox — BRep bounding-box containment (the whole entity bbox must lie inside the query box, expanded by Geometry.Tolerance ≈ 1e-8). There is no half-open notion, so passing inclusive= (or any keyword) raises TypeError rather than being silently ignored. For an exact geometric on/crossing predicate use g.model.select(target).crossing_plane(spec, mode="on"|"crossing") (the v2 successor; g.model.queries.select was removed).

The two families share verb names and set algebra but never share .in_box behavior; do not assume a cross-family result is identical. - The classes core/_selection.Selection and viz/Selection.Selection are retained by architecture (the EntitySelection.result() terminal payload and the viewer pick-result type, respectively) — structurally distinct internal types, not user entry points; only their package exports were dropped. g.model.select(...).result() returns the retained core Selection payload (.to_label / .to_physical / .to_dataframe are also direct terminals). - Named persistence is .save_as(name) on a MeshSelection (live-mesh engine only), or the retained explicit-ids registrar g.mesh_selection.add(dim, ids, name=) → FEMData snapshot → results(selection=...). The removed g.mesh_selection.add_nodes(..., name=...) / from_geometric two-step keeps its capability via the retained 2-call route (g.model.select(...).to_physical(name)g.mesh_selection.from_physical(...)); only the one-call ergonomic was lost (incomplete-unification Gap 1 — see api/selection.md).

BREAKING — g.mesh_selection box is now half-open by default

g.mesh_selection.add_nodes(in_box=), add_elements(in_box=), and filter_set(in_box=) (the _mesh_filters.nodes_in_box engine) flipped from closed [lo, hi] to half-open [lo, hi) on the upper side, per axis. This matches the results side, which was already half-open — the two diverged on main; this reconciles them on the canonical (half-open) semantics.

Effect: a node/element coordinate (or centroid) lying exactly on an upper box face is now excluded by default. Adjacent boxes no longer double-count a shared face.

Migration — pass inclusive=True to restore the old closed box:

# Before (pre-v1.6.0): closed [lo, hi] — upper face INCLUDED
sid = g.mesh_selection.add_nodes(in_box=(0, 0, 0, 1, 1, 1))

# v1.6.0 default: half-open [lo, hi) — upper face EXCLUDED.
# On a 3x3x3 unit-cube lattice this drops 27 nodes -> 8.
sid = g.mesh_selection.add_nodes(in_box=(0, 0, 0, 1, 1, 1))

# To keep the exact pre-v1.6.0 result, opt back into the closed box:
sid = g.mesh_selection.add_nodes(
    in_box=(0, 0, 0, 1, 1, 1), inclusive=True,   # 27 nodes (closed)
)

inclusive= is also accepted on add_elements and filter_set. Audit any g.mesh_selection box query that intentionally relied on catching on-the-upper-face nodes; pad the upper corner outward or pass inclusive=True. Pure-interior boxes are unaffected.

CHANGED — Loads/masses __ms__ now fails loud (completing a three-path end-state)

Three paths that once returned a quietly-wrong result now raise; each is safer (a plausible-looking wrong answer becomes an explicit, located error). This release ships only path 2. Paths 1 and 3 were already loud / merged ahead of it and are described here for the complete end-state:

  1. results(...) selection= on an import-origin FEM (already loud — locked). A FEMData built from from_msh / MPCO / native input has no mesh_selection (it is None). Passing selection= against such a FEM raises RuntimeError ("selection= requires fem.mesh_selection to be present.") on both the node and element resolution arms instead of resolving to an empty set. This path was already loud; it is held by a characterization pin and is not changed by this release. Build the selection on the session (g.mesh_selection) so it travels into the snapshot.
  2. Loads / masses __ms__ consumer binding to zero nodes (the change in this release). LoadsComposite._target_nodes (and the MassesComposite counterpart) hit a if info is None: return set() arm that silently bound a load/mass to zero nodes when a named mesh selection it referenced was gone or the store was inconsistent. It now raises KeyError ("... Refusing to silently bind this load to zero nodes (fail loud)."). A load/mass that resolves to nothing is a model error, not a no-op. This is the one code behavior shipping with this release (with a flipped characterization pin and a dedicated regression test).
  3. results._element_centroids corrupting a centroid (already merged separately — not this release). This routine used np.clip to map a connectivity node id that is absent from the FEM node set to the last node — silently corrupting that element's centroid. It now raises KeyError (detecting both past-the-end and in-range-wrong-id, strictly stronger than the old clip), which also makes the legacy results.elements.in_box / nearest_to / on_plane helpers fail loud on a broken connectivity instead of returning garbage-located elements. The new chain centroids were already fail-loud; this brought the legacy helper to parity. This fix merged independently, ahead of this release — it is not introduced here.

INTERNAL — deduped Loads/Masses target resolver

MassesComposite._resolve_target was a byte-for-byte clone of LoadsComposite._resolve_target. Both now delegate to one shared core/_resolution.resolve_target engine. Behavior is byte-identical (tier order, the ("__ms__", dim, tag) sentinel, expected_dim scoping, multi-dim-PG ValueError, the verbatim per-noun error messages). No public API change; not a migration. The library-wide resolvers (FEMData._resolve_one_target, _helpers._resolve_string) are deliberately untouched.

Follow-ups (tracked, not yet shipped)

Consciously deferred; mentioned so they are not mistaken for available surface:

  • Results sub-composite .select()results.nodes / .elements have .select(); the five element sub-composites (gauss / fibers / layers / line_stations / springs) do not yet (they need per-terminal kwarg forwarding).

(The g.mesh_selection.select() name-seed that previously sat here has shipped — see the .select() ADDED section above.)

v1.5.0 — Applied loads + reactions diagrams · geometry-scoped gate

One PR landing on top of v1.4.0's post-release work. The ResultsViewer gains two new diagram kinds in the Add layer dropdown — Applied loads (constant force arrows, one diagram per load pattern) and Reactions (recorded reaction forces and moments, auto-scaling per step with the time slider). The composition gate is also tightened so adding a brand-new Geometry no longer leaves the previous Geometry's diagrams visible.

PR in this release: #92.

ADDED — Applied loads diagram (#92)

Add layer → Applied loads lists every fem.nodes.loads pattern that carries at least one non-zero force record. Each diagram renders force arrows at the resolved nodes for one pattern. Reference magnitudes only — the broker does not carry the OpenSees timeSeries function, so step scaling is intentionally deferred (the diagram's update_to_step is a documented no-op until timeSeries metadata lands in the broker). Moments are not drawn yet; they need a different glyph and will follow.

ADDED — Reactions diagram (#92)

Add layer → Reactions (enabled iff the file has any reaction_force_* or reaction_moment_* recordings). Forces render as straight arrows; moments use the existing moment_glyph curved arrow. Each family auto-fits its own scale because forces and torques have different units. The diagram is step-resolved — every time-slider move re-reads the slab and rebuilds the glyphs. An auto-filter drops nodes whose magnitude max-over-time is below zero_tol × global_max, so free-interior nodes don't pollute the scene with near-zero arrows.

FIXED — Composition gate scoped to active Geometry (#92)

The gate previously hid layers using the flat diagram registry, so adding a new (empty) Geometry kept the previous Geometry's diagrams visible — active_comp is None set show_all=True and turned every actor on regardless of which Geometry owned it. The visible-layer set is now restricted to compositions of the active Geometry; an empty Geometry shows nothing, an existing one with no active composition still shows its own layers (preserving the prior single-Geometry "show all" intent within the active Geometry).

v1.4.0 — ResultsViewer dock split · new-layer attach + lifecycle fixes · import banner

Six PRs landing on top of v1.3.0. The post-solve viewer's right rail splits into dedicated Diagram and Geometry docks (tabified with Details and Session), with a new floating "?" shortcut HUD on the viewport. New-layer attach now pushes the active step + re-fires deformation sync so freshly-added line-force / vector-glyph layers land aligned with the rest of the scene instead of paint-then-drift. A series of selection-related composition-gate fixes (Esc, outline Geometry-row click, stale session restore) stop diagrams from silently disappearing when the user navigates the outline. The HDF5 reader is now released on viewer close, so re-running a capture script in the same kernel no longer hits PermissionError. The package prints an ASCII banner + __version__ on import (suppress with APEGMSH_QUIET=1) so the running version is unambiguous.

PRs in this release: [#69], [#70], [#71], [#72], [#73], [#74].

ADDED — Diagram / Geometry dock split ([#69])

The right rail's single Details dock — which previously stacked the DiagramSettingsTab and GeometrySettingsPanel inside a QStackedWidget — is split into two dedicated docks:

  • Diagram dock hosts DiagramSettingsTab.widget directly.
  • Geometry dock hosts GeometrySettingsPanel.widget directly.
  • Details dock is reserved for future canvas-driven contextual content (contour scalebar edits, picked-node readouts).
  • Outline routing: clicking a Composition row raises the Diagram dock; clicking a Geometry row raises the Geometry dock.
  • The "+ Add layer" button is now on the Diagram dock's title row.
  • Layout schema bumped to v5 — saved v4 layouts are discarded so users land in the new arrangement on first launch.

ADDED — Floating shortcut help HUD ([#69])

ShortcutHelpHUD — small "?" button in the viewport's bottom-right corner. Click pops a list of mapped keyboard shortcuts (Esc, Ctrl+H, Q, N/E/G, Shift+LMB, Shift+click, F2).

ADDED — Banner + __version__ on import ([#73])

import apeGmsh prints the ASCII banner + the installed version to stderr. __version__ is now exposed at the package root, sourced from importlib.metadata (single source of truth = pyproject.toml). Set APEGMSH_QUIET=1 to suppress for tests / CI / piped scripts.

FIXED — New-layer attach ([#69])

After registry.add(...), the director now pushes the active step to the new layer and re-fires _apply_deformation. Resolves:

  • Line-force diagrams that rendered as collapsed slivers because step-0 internal forces are zero (the polydata was correct, the values were wrong).
  • Vector glyphs that landed at undeformed positions until the user manually scrubbed the time slider.

FIXED — HDF5 reader released on viewer close ([#70])

ResultsViewer._on_close now calls self._results.close() so the NativeReader's file handle is released. Re-running a capture script in the same Jupyter kernel — which deletes and recreates the same .h5 path — no longer hits PermissionError: [WinError 32] The process cannot access the file because it is being used by another process.

FIXED — Composition gate no longer silently hides diagrams ([#71], [#72], [#74])

Three trigger paths surfaced the same symptom — layers visible in the dock with checkboxes still checked, but nothing painting in the viewport — because the composition gate hides every actor when no composition is "active":

  • #71: Esc previously called compositions.set_active(None), which fired the gate. Esc now only clears probe markers + element / GP highlights and leaves composition state alone.
  • #72: Clicking a Geometry row in the outline previously called compositions.set_active(None) for the same reason. Selecting a Geometry row is now a navigation gesture; composition state is left unchanged.
  • #74: The session JSON persists active_composition_id. Pre-#71 / pre-#72 sessions easily saved null. On restore, the gate then hid every layer until the user clicked a Composition row. Two-part fix: heal stale sessions by defaulting to the first composition when the saved active id is null; relax the gate to "show all" when no composition is active anywhere.

v1.3.0 — ResultsViewer B++ redesign · live recorder + MPCO emission · spatial filters

Nine PRs landing on top of v1.2.0. The ResultsViewer ships a full B++ redesign — outline tree, plot pane, viewport HUDs (probe palette top- right, pick-readout top-left), inline kind picker, style presets, density toggle — replacing the right-dock tab strip. The recorder spec gains two new in-process execution strategies (emit_recorders and emit_mpco), so one declarative spec now drives five backend paths. The read side picks up nearest_to / in_box / in_sphere / on_plane spatial filters plus an element_type= selector, all composing additively with the existing pg= / label= / selection= / ids= vocabulary. Elastic beams (ElasticBeam{2d,3d}, ElasticTimoshenkoBeam{2d,3d}, ModElasticBeam2d) gain a synthesised 2-station line-stations slab via the live capture path, matching the existing MPCO behaviour. Documentation gets a 6-card landing, grouped navigation, an 8-notebook curated examples gallery rendered inline via mkdocs-jupyter, a Recorder reference page, and a Reading & filtering results guide. All 9 PRs merged green.

PRs in this release: #43, #44, #46, #47, #48, #50, #51, #52, #49.

ADDED — ResultsViewer B++ redesign (#43, #46)

Closes the B++ Implementation Guide. The right-dock tab strip (Stages / Diagrams / Settings / Inspector / Probes) is retired in favour of a 3×3 grid layout: title-bar row (40 px), three-column body (left rail · viewport · right rail), scrubber row (84 px).

  • ResultsWindow shell (#43 B0). Wraps ViewerWindow with the 3×3 grid central widget. Hidden left (260 px) and right (380 px) columns reserve space for the upcoming widgets.
  • OutlineTree (#43 B1). Left-rail single navigator with four groups: Stages, Diagrams, Probes, Plots. Replaces the StagesTab and DiagramsTab. Visibility checkboxes toggle render; clicks drive the details panel.
  • PlotPane + DetailsPanel (#43 B2). Right-rail vertical-list tabs (dot · label · ×). Re-homes the fiber-section, layer- thickness, and time-history panels that previously floated as QDockWidgets on the main-window edges. The DetailsPanel below hosts contextual content (DiagramSettingsTab when a diagram row is selected).
  • ProbePaletteHUD (#43 B3). Floating panel in the viewport's top-right corner with three mode buttons (Point / Line / Slice) + Stop / Clear. Repositions on viewport resize via a Qt event filter. Retires ProbesTab.
  • PickReadoutHUD (#46). Floating glass card in the viewport's top-left corner. Subscribes to ProbeOverlay.on_point_result and Director step / stage changes; renders the picked node id, snapped coords, and one mono-typed line per active component value. Retires InspectorTab.
  • Shift-click → time-history plot (#46). ShiftClickPicker registers a low-priority VTK observer on LeftButtonPressEvent that fires only when shift is held; opens (or focuses) a TimeHistoryPanel as a closable plot-pane tab. The default component prefers the active diagram's selector.
  • Title-bar utility strip (#46). Three decorative stop-light dots, breadcrumb label, right-aligned icon strip with theme cycle, clipboard screenshot, density toggle, help dialog. Theme cycles through every palette in PALETTES.
  • Density toggle (#46). New DensityManager singleton mirroring ThemeManager. Persists via QSettings. DensityTokens carry row_h, pad_x, pad_y, gap, fs_body, fs_head. The global stylesheet picks them up; toggling triggers a full restyle.
  • Two-way tree ↔ plot-tab binding (#46). The Plots group in the outline tree mirrors the plot-pane tab list; clicking a Plots row activates the matching tab. Empty Plots group falls back to a hint placeholder.
  • Inline 2×4 kind picker (#46). Clicking the outline tree's "+ Insert" button reveals a 2×4 grid of diagram-kind shortcuts directly under the header. Selecting a kind opens AddDiagramDialog pre-selected for that kind.
  • Diagram picker pre-flight (#46). The Add Diagram dialog now greys out kinds whose topology has no data anywhere in the Results file (— no data suffix). The Component combo placeholder distinguishes "no data in file" from "no data in selected stage".
  • Style presets (#46). New module [viewers/diagrams/_style_presets.py] with style_to_dict / style_from_dict codec, KIND_TO_STYLE_CLASS registry, and a StylePresetStore (CRUD under <QSettings AppConfigLocation>/ apeGmsh/style_presets/). Add Diagram dialog gains a Preset combo; DiagramSettingsTab gains a Save…/Apply footer. Path-traversal sanitiser refuses unsafe names.
  • Theme + global-preferences reachability (#46). The ResultsWindow help dialog promotes from QMessageBox to a proper QDialog with footer buttons that open the Theme editor and Global preferences dialogs (the dock-strip path the other viewers use is gone in B5).
  • Theme integration for the new shell (#43 B4). Hardcoded inline stylesheets removed; the global build_stylesheet picks up object-name selectors for every new widget (#ResultsTitleBar, #OutlineHeader, #PlotPaneHeader, #DetailsPanel, #ProbeHUD, #OutlineKindPicker, #OutlineKindBtn, etc.). All four palettes (catppuccin_mocha, neutral_studio, catppuccin_latte, paper) render cleanly.

ADDED — Live recorder + MPCO emission strategies (#48)

Two new in-process consumers on the recorder spec — same seam, two new code paths:

  • spec.emit_recorders(out_dir) — classic recorders pushed live into the ops domain via ops.recorder() calls, with begin_stage / end_stage scoping and per-stage filename prefixes (<stage>__<record>_<token>).
  • spec.emit_mpco(path) — single in-process MPCO recorder with a build-gate that raises with a clear remediation pointer when the active openseespy build doesn't include MPCO.
  • Threads stage_id through emit → cache → transcoder → from_recorders (default None preserves byte-for-byte export.tcl/py compatibility).
  • to_ops_args / mpco_ops_args are the live-emit equivalents of format_python / emit_mpco_python. Both flow through the existing LogicalRecorder dataclass so source-form and tuple-form share one source of truth.
  • Architecture doc rewritten: apeGmsh_results_obtaining.md covers the spec-as-seam pattern with the five-strategy comparison table.
  • New user-facing guide: guide_obtaining_results.md with worked recipes per strategy + decision flowchart + pitfalls.
  • 46 new tests; full recorder/live/mpco sweep at 495 passing.

ADDED — Spatial filters on every read-side composite (#51)

Ergonomic spatial selection lands on every composite that returns slabs:

Filter Semantics
nearest_to(point, component=…) Single nearest entity to the query point
in_box(box_min, box_max, …) Half-open on the upper side: [box_min, box_max) so adjacent boxes don't double-count shared faces. Use np.inf to relax an axis
in_sphere(center, radius, …) Closed ball
on_plane(point_on_plane, normal, tolerance, …) Absolute distance ≤ tolerance

Available on results.nodes plus the six element-level composites (results.elements, .elements.gauss, .line_stations, .fibers, .layers, .springs) via the shared _ElementGeometryMixin. Element-side queries use centroids computed lazily from the FEM's node coordinates + per-type connectivity, robust to mixed-type meshes.

  • Filters compose additively. Spatial primitives intersect with each named selector (pg= / label= / selection= / ids= / element_type=) the same way:
    results.nodes.in_box(
        (-1, -1, 0), (1, 1, 5),
        component="displacement_z", pg="Top",
    )
    
  • element_type= selector on every element-level composite. Restricts the candidate set by broker element-type name ("Tet4", "Hex8", "Quad4", etc.). Resolves via fem.elements.types and fem.elements.resolve(element_type=…).
  • Verbose parameter names per project preference: point (was xyz), box_min / box_max (was p_min / p_max), center, radius, point_on_plane, normal, tolerance.
  • New user-facing guide: guide_results_filtering.md — 12 sections covering the composite tree, selectors menu, geometric helpers, additive composition, slab shapes, time slicing, stage scoping, discovery API, worked recipes, pitfalls, and what's queued.
  • 21 spatial tests + 18 existing composite tests pass.

ADDED — Elastic-beam line-stations synthesis (#47)

The Phase 11b live-capture path previously required a force- or disp-based beam-column section.force probe; closed-form elastic beams (ElasticBeam{2d,3d}, ElasticTimoshenkoBeam{2d,3d}, ModElasticBeam2d) were silently dropped because they have no integration points. The MPCO read path already synthesises a 2-station slab from the localForce bucket — this commit ports the same synthesis to the live DomainCapture path.

  • New synthesize_line_station_layout_for_elastic_beam(class_name) in [solvers/_element_response.py] — builds a 2-station ResponseLayout at ξ ∈ {-1, +1} for any class with a NODAL_FORCE_CATALOG entry, using canonical line-station component names. Companion is_line_station_synthesis_catalogued predicate.
  • _LineStationGroup gains a mode field; _probe_element splits into _probe_section_force (existing) and _probe_local_force_synthesis (new).
  • _step_local_force reads ops.eleResponse(eid, "localForce") once per step and applies the standard sign flip on station 2 so the slab matches section-force convention (mirrors the MPCO path).
  • Loud skip warning. DomainCapture.end_stage now emits a single consolidated UserWarning if any elements were dropped from line-stations recording, listing the breakdown by reason and a sample of element IDs. Steers the user to MPCO or to rebuilding as ForceBeamColumn instead of letting the silent skip turn into a confusing empty diagram.
  • examples/EOS Examples/example_buckleUP_v2.ipynb gains a recs.line_stations(...) call so its elasticBeamColumn braces / columns / beams produce a real line-stations slab the LineForceDiagram can render.

ADDED — Recorder vocabulary discovery (#50)

The recorder vocabulary is now discoverable from three surfaces, all sharing one source of truth:

  • Method docstrings on Recorders.{nodes, elements, line_stations, gauss, fibers, layers, modal} enumerate every canonical component, every shorthand expansion, the selector vocabulary, the cadence options, coverage caveats per execution strategy, and a worked example. mkdocstrings auto-renders these on api/opensees.md, so the API page now shows the full menu.
  • Static introspection methods:
    Recorders.categories()
    Recorders.components_for(category)
    Recorders.shorthands_for(category)
    
    Useful at the REPL, in notebooks, for validation messages.
  • New reference page at Guides › Results › Recorder reference (guide_recorders_reference.md) — single-page menu with categories at a glance, shared selectors and cadence, then per-method tables of components and shorthands.
  • 16 new introspection tests; full recorder sweep at 158 passing.
  • 6-card landing (#49) replaces the 2-card grid on the docs home page. Cards are organised by user intent: First steps, Quickstart & Examples, Build a model, Run & read results, Architecture, API reference. Plus a 3-card "What's new" band.
  • Grouped navigation (#49). mkdocs.yml reorganises the bloated Guides and Architecture sections into collapsible sub-headings: Getting started / Building models / Physics / Solver bridge / Results / Reference under Guides; Foundations / Subsystems / Gmsh background under Architecture. No content moves.
  • Curated examples gallery (#49) — 8 EOS notebooks rendered inline via mkdocs-jupyter at /examples/notebooks/<name>/. docs/_hooks.py copies notebooks from examples/EOS Examples/ to docs/examples/notebooks/ on every build (mtime-aware so incremental rebuilds are fast); source of truth stays in examples/EOS Examples/.
  • 8 hero notebooks modernised (#52) — single-flow apeGmsh-Results pedagogical template:
  • Imports + parameters
  • Geometry (apeGmsh)
  • Physical groups
  • Mesh
  • OpenSees model — vanilla openseespy
  • Declare recorders — apeGmsh
  • Run analysis with spec.capture(...) or spec.emit_recorders(...)
  • Read results back via Results
  • Plot in-notebook
  • Optional viewer (subprocess, APEGMSH_SKIP_VIEWER honoured)

Strategy assignments mixed across the curriculum: 01_hello_plate, 04_portal_frame_2D, 05_labels_and_pgs, 12_interface_tie, 17_modal_analysis, 19_pushover_elastoplastic use spec.capture; 02_cantilever_beam_2D, 10b_part_assembly use spec.emit_recorders. All 8 verified end-to-end via nbconvert --execute against closed-form solutions. - Gallery page refresh (affa81d). Each card calls out the strategy used (spec.capture vs spec.emit_recorders), names the verification target, and points at the specific pedagogical moment that notebook teaches that others don't. New "Common shape across every notebook" section makes the unified template visible at the gallery level. - 31 EOS notebooks wired (#44) with a "Capture results" section before g.end(), providing two pedagogical paths: manual NativeWriter and declarative Recorders().nodes(...) → DomainCapture context. New scripts/wire_eos_notebook.py (auto-wiring) and scripts/migrate_eos_legacy_api.py (API-drift migrator) for future notebooks. Both paths gate the viewer launch on APEGMSH_SKIP_VIEWER for headless / nbconvert / CI runs. Notebooks with pre-existing breakage moved to to_review/ with a README explaining each.

Test coverage

PR New tests Notes
#43 Reorganises existing test infra (B0–B4 widget tests track the migration)
#46 ~30 HUD construction + callbacks, density manager, style presets CRUD, picker pre-flight
#47 4 Elastic-beam round trip 3D / 2D, skip-warning fires once, clean-recording no-warning
#48 46 Recorder/live/mpco sweep at 495 passing
#50 16 Static introspection — categories / components_for / shorthands_for
#51 21 nearest_to / in_box / in_sphere / on_plane / element_type semantics + additive intersection
Total ~117

v1.2.0 — Results viewer: Gauss contour, scrubber animation, shape-function catalog

Six PRs landing on top of v1.1.0's Results rebuild. ContourDiagram gains two new rendering paths so element-level Gauss data flows straight through the dialog → diagram → substrate pipeline; the time scrubber gets real Play / FPS / loop controls; the shape-function catalog grows from 5 to 12 element types so quadratic and prism meshes are first-class. All 6 PRs merged green — 377 viewer tests + 1876 non-viewer tests pass on main.

PRs in this release: #35, #36, #37, #38, #39, #42.

ADDED — ContourDiagram Gauss paths

  • Element-constant Gauss contour (#37). New gauss_cell rendering path activated when n_gp == 1 per element (CST / tri31, hex8 with one-point integration, etc.). Reads via results.elements.gauss.get(component=...), paints cell_data on a substrate submesh extracted by element IDs. Removes the manual nodal-averaging step the plate notebook was using.
  • GP→nodal extrapolation for higher-order integration (#39). New gauss_node path activated when n_gp > 1. The slab is projected onto the linear-corner shape functions via the Moore–Penrose pseudo-inverse (pinv(N)), then accumulated into a per-node sum + count for cross-element averaging. New module [apeGmsh.results._gauss_extrapolation] with two public entry points: extrapolate_gauss_slab_to_nodes(slab, fem) and per_element_max_gp_count(slab).
  • ContourStyle.topology field (#37). User-facing knob with three values:
    • "auto" (default) — prefer nodal data when both composites have the requested component; fall through to Gauss otherwise.
    • "nodes" — force the nodal-scalar path (point data).
    • "gauss" — force the Gauss path; cell-vs-node sub-decision is made internally based on n_gp.
  • Topology dropdown in the Add Diagram dialog (#38). Visible only when the selected kind is Contour. The Component combo populates from the union of nodes + gauss components under "auto", and from the picked composite under "nodes" / "gauss".

ADDED — Time scrubber animation (#36)

  • Play button drives a QTimer at 1000 / fps ms; each tick advances one step via director.set_step(...). The scrubber stays slider-passive — it only updates the slider via the Director's on_step_changed callback, never directly.
  • FPS spinner (1–60, default 30). Live — changing it while playing updates the timer interval without disturbing the run.
  • Loop modes combo: "once" (stop at last step), "loop" (wrap to step 0), "bounce" (reverse direction at boundaries, never wraps).
  • Stops on stage change automatically; a fresh stage may have a different step count, so the scrubber refreshes and waits for the user to press Play again.

ADDED — Shape-function catalog expansion (#42)

SHAPE_FUNCTIONS_BY_GMSH_CODE grows from 5 entries to 12. New types covering everything you'd hit by setting gmsh.model.mesh.setOrder(2) plus wedge6 for extruded / layered meshes:

Code Type Notes
6 wedge6 Linear prism (tri × line tensor product)
9 tri6 Quadratic triangle
10 quad9 Lagrangian biquadratic quad
11 tet10 Quadratic tet
12 hex27 Lagrangian triquadratic hex
16 quad8 Serendipity quadratic quad
17 hex20 Serendipity quadratic hex

Node orderings match Gmsh's published convention (cross-checked against the ASCII diagrams in gmsh-4.15.1/src/geo/M{Triangle, Quadrangle,Tetrahedron,Hexahedron,Prism}.h), so a connectivity row read straight from a Gmsh-generated mesh works without any reordering. Pyramids (pyr5 / pyr14) and line3 are deliberately out of scope — pyramids have a known apex singularity worth avoiding for a first pass, and line3 is rare in OpenSees output.

For GP→nodal extrapolation the higher-order types fall back to their linear counterpart (tri6 → tri3, quad8/9 → quad4, tet10 → tet4, hex20/27 → hex8). Reasons: the substrate is built from linear cells (mid-side / face / center nodes are dropped in build_fem_scene), so non-corner extrapolations are never painted; and pinv on the full higher-order N matrix produces a non-constant nodal field for a constant GP input (minimum-norm regularization of the under-determined system), which is wrong for visualization.

REFACTORED — single-source diagram topology routing (#35)

  • New Diagram.topology: str class attribute; each subclass declares it next to kind. The Add Diagram dialog's _KIND_TO_TOPOLOGY table is now derived from those attributes:
    _KIND_TO_TOPOLOGY = {
        entry.kind_id: entry.diagram_class.topology for entry in _KINDS
    }
    
    Previously the dict was hand-maintained alongside the per-class composite-reader calls and could drift.

CHANGED — ContourDiagram internals

  • Three internal effective-topology values replace the previous two: "nodes" / "gauss_cell" / "gauss_node". Dispatch is decided at attach time after a single step-0 read used both for the n_gp probe and the initial scatter.
  • Cross-element discontinuities are smoothed by the nodal averaging in the gauss_node path. Standard post-processor behaviour (STKO, ParaView). A future per-element subdivision path can preserve discontinuities — out of scope here, the gauss_cell scaffolding stays in place to keep that door open.

Test coverage

PR New tests Notes
#35 2 Pinning test for the eight kind→topology mappings
#36 10 State-machine + timer + stage-change-stop coverage
#37 10 Auto resolution, attach, in-place mutation, multi-GP rejection (later refactored to extrapolation in #39)
#38 11 Visibility per kind, component listing per topology, end-to-end run() spec construction
#39 11 Linear-field round-trip on hex8 + 2×2×2 GPs (atol=1e-12), shared-face averaging, time-axis preservation, in-place mutation on the new path
#42 37 5 invariants × 7 types: Kronecker delta, partition of unity, linear precision, dN-sum, FD cross-check
Total 81

Known follow-ups (not scheduled)

  • Discontinuity-preserving Gauss contour — subdivides each multi-GP element into linear sub-cells, samples at sub-vertices, renders cell-data per sub-cell. Preserves jumps at material interfaces. ~500–800 LOC, design-discussion-first decision.
  • Hex27 face/center node ordering — verified self-consistent in the shape-function math, but the assumed Gmsh ordering for nodes 20–26 isn't independently validated against a real Gmsh hex27 mesh. One-row fix in _HEX27_LAGRANGE_INDEX if a real mesh surfaces a mismatch.
  • Pyramid shape functionspyr5 and pyr14. Add when needed.

v1.1.0 — Results: backend-agnostic FEM post-processing system rebuild

Wholesale rebuild of the apeGmsh.results module. The legacy in-memory Results carrier (a thin VTK-feeder bound to live numpy arrays) is replaced by a lazy disk-backed reader plus a unified composite API that mirrors FEMData. Recording flows through three execution strategies — Tcl/Py recorders, in-process domain capture, and an MPCO bridge — all driven by one declarative g.opensees.recorders spec. 987 tests pass end-to-end including the El Ladruno OpenSees Tcl subprocess integration. See PR #12 and internal_docs/Results_architecture.md for full design.

ADDED — apeGmsh.results

  • Native HDF5 schema + I/O. NativeWriter / NativeReader round-trip nodes, Gauss points, fibers, layers, line stations, and per-element forces. Stages are first-class (kind="transient" / "static" / "mode"). Multi-partition stitching transparent to the reader. Embedded FEMData snapshot in /model/ — including MeshSelectionStore — so result files are self-contained.
  • Results composite class mirroring FEMData. Selection vocabulary pg= / label= / selection= / ids=. Stage scoping via results.stage(name); mode access via results.modes. Soft FEM coupling with hash-validated bind().
  • compute_snapshot_id(fem) deterministic content hash — ties recorder specs ↔ result files ↔ FEMData snapshots.
  • MPCO reader. Results.from_mpco(path) reads existing STKO .mpco files through the same composite API. Partial FEMData synthesis from MPCO MODEL/ group (nodes + elements + region-derived PGs).
  • g.opensees.recorders declarative spec composite. Standalone class, no parent ref, no gmsh dependency. .nodes, .elements, .line_stations, .gauss, .fibers, .layers, .modal declaration methods. spec.resolve(fem, ndm, ndf) expands shorthand components, validates per category, locks fem_snapshot_id.
  • Three execution strategies driven by the spec:
  • Strategy Ag.opensees.export.tcl/py(..., recorders=spec) emits recorder Node/Element ... commands + HDF5 manifest sidecar. Results.from_recorders(spec, output_dir, fem=fem) parses output .out files into native HDF5 with a cache layer at <project_root>/results/.
  • Strategy Bwith spec.capture(path, fem) as cap: wraps an openseespy analyze loop, querying ops.nodeDisp etc. per record. Multi-record merge with NaN-fill when records target disjoint node sets. cap.capture_modes() writes one mode-kind stage per ops.eigen mode.
  • Strategy Crecorders_file_format="mpco" dispatches to a single recorder mpco line aggregating all records. Validated via subprocess against the El Ladruno OpenSees Tcl build.
  • Element capability flags on _ElemSpec: has_gauss, has_fibers, has_layers, has_line_stations plus a supports(category) helper. All 16 entries in _ELEM_REGISTRY annotated.
  • MeshSelectionStore name-based lookups: names(), node_ids(name), element_ids(name) — mirrors PhysicalGroupSet's API.
  • Architecture doc internal_docs/Results_architecture.md (single canonical reference).

CHANGED — Results module API (BREAKING)

  • Results.from_fem(fem, point_data=..., cell_data=...) removed. Use Results.from_native(...), from_mpco(...), from_recorders(...), or hand-construct via NativeWriter for the in-memory case.
  • fem.viewer() raises NotImplementedError until the viewer rebuild project. The new flow will go through the rebuilt composite API.
  • g.mesh.viewer(point_data=..., cell_data=...) raises NotImplementedError. The mesh-only paths (g.mesh.viewer() and g.mesh.viewer(results=path) for a .vtu/.pvd file) still work unchanged.
  • Public exports under apeGmsh.results: Results, ResultsReader, NativeReader, MPCOReader, ResultLevel, StageInfo, BindError, and the slab dataclasses (NodeSlab, ElementSlab, LineStationSlab, GaussSlab, FiberSlab, LayerSlab).

DEFERRED — element-level transcoding

Element-level records (gauss / fibers / layers / line_stations / elements) work end-to-end on the declaration and emission side. The read/decode side is stubbed:

  • MPCOReader.read_gauss/fibers/layers/... returns empty slabs.
  • RecorderTranscoder skips element records silently.
  • DomainCapture.step() raises NotImplementedError for element categories.

All three need the same missing piece: a per-element-class response-metadata catalog. Plan in internal_docs/plan_element_transcoding.md (Phase 11a). Nodal results work everywhere today.

NEW FIXTURE

  • tests/fixtures/results/elasticFrame.mpco — 400 KB binary, 12 nodes / 11 elastic frame elements / 10 transient steps / 2 model stages. Used by the MPCO reader + integration tests.

v1.0.9 — Viewer: higher-order rendering + filter overhaul (WIP)

Lands the viewer fixes and refactor scaffolding from PR #11. Higher- order elements (Q8/Q9, Tri6, Tet10, etc.) no longer render as VTK's sub-triangle tessellation fans. The dim filter actually hides actors now, and node display scopes to the dim filter. Step 5 (corner / midside / bubble node differentiation) is deferred to the next release.

FIXED — viewer

  • Q9 / higher-order surface fill — the fill layer is now built from linearized corner-only cells (mesh_scene.GMSH_LINEAR), so a Q9 quad renders as a single quad and a Tri6 as a single triangle. 31 element types covered including P3 / P4 and bubble variants; unknown types warn instead of being silently dropped.
  • Dim filter (1D/2D/3D checkboxes) — was overridden in mesh_viewer._on_mesh_filter setup so it only set the pick mask; now also flips fill / wire / node-cloud actor visibility per dim.
  • Phantom wireframe on RevealVisibilityManager._rebuild_actors now rebuilds the wire actor alongside the fill on hide / reveal, so hidden entities lose their edges and revealed entities regain them.
  • BRep surface fill for higher-order meshesbrep_scene got the same linearization treatment for Tri6 / Quad8 / Quad9.

CHANGED — viewer

  • New wireframe layer built via extract_all_edges() per dim>=2, registered as EntityRegistry.dim_wire_actors. Replaces VTK's built-in show_edges (which rendered the higher-order cell tessellation, not the FE element boundary). Clipping plane, visibility manager, and dim filter all participate.
  • Per-dim node clouds — single global node_actor replaced by EntityRegistry.dim_node_actors keyed by dim, each containing the nodes used by entities of that dim (with includeBoundary=True). The dim filter now scopes node display: unchecking 1D drops 1D-only nodes, but boundary nodes shared with a visible 2D dim stay.
  • Tree right-click Hide / Isolate / Reveal-all — added to BRep SelectionTreePanel and BrowserTab (group + entity rows). Backed by new VisibilityManager.hide_dts(dts) / isolate_dts(dts) programmatic counterparts of the pick-driven hide() / isolate().

ADDED — viewers.core.visibility doc

  • Spelled out the filter state model in the module docstring: cosmetic dim toggle (SetVisibility), entity hide (VisibilityManager._hidden), and clipping (render-time) are three independent layers, intentionally not unified.

v1.0.8 — Embedded-node constraint resolver (ASDEmbeddedNodeElement)

Closes Phase 11b. Replaces the NotImplementedError on ConstraintsComposite._resolve_embedded with a working resolver, so embedded-rebar and similar non-conforming inclusions can be expressed without fragmenting the host mesh.

ADDED — solvers/_constraint_resolver.py

  • _barycentric_tri3(p, p0, p1, p2) — barycentric coordinates of p inside a 2D triangle, with projection onto the triangle's plane for off-plane points.
  • _barycentric_tet4(p, p0, p1, p2, p3) — same for a 3D tetrahedron.
  • ConstraintResolver.resolve_embedded(...) — given embedded nodes and host element connectivity, locates each embedded node in its host via KD-tree spatial indexing + barycentric coordinates, then emits InterpolationRecord shape-function couplings that match ASDEmbeddedNodeElement kinematics.

ADDED — integration

  • ConstraintsComposite._resolve_embedded now dispatches to the new resolver, collects host element connectivity from a labeled master region (tri3 / tet4), filters out embedded nodes that coincide with host corners, and returns the coupling records.
  • examples/EOS Examples/15_embedded_rebar.py rewritten to use the embedded path instead of the old fragment-based conformal rebar.

ADDED — tests

  • tests/test_constraint_resolver.py — 4 cases (tri3 interior + corner, tet4 centroid, multi-element search).

ADDED — regression coverage

  • tests/test_target_resolution.py — locks in FEMData.nodes.get() / .elements.get() target= precedence (label > PG) and raw [(dim, tag)] passthrough.
  • tests/test_boolean_ops.py — guards the 2D opt-in fragment(cleanup_free=True) bug so it can't regress (and now also pins that the default cleanup_free=False preserves surfaces).
  • tests/test_parts_advanced.py — covers g.parts.add(part, label=..., translate=...) on an unlabeled Part (no sidecar).

ADDED — infrastructure

  • pyproject.toml [tool.pytest.ini_options] with pythonpath = ["src"], so pytest run from a worktree picks up the worktree's source instead of the editable install pointing at the main checkout.

v1.0.7 — Selection upgrades + set_transfinite_box

Polish pass on the v1.0.6 selection API. Eliminates the hand-rolled patterns that kept showing up in scripts (two-step boundary queries, _apply_hex helpers, manual node-count-by-axis loops) and adds the predicates and combinators users were reaching for.

ADDED — boundary helpers

  • g.model.queries.boundary_curves(tag) — returns every unique curve on the boundary of an entity. Encapsulates the two-step query (faces → individual face boundaries with combined=False → deduplicate) that's needed because Gmsh's getBoundary(vol, recursive=True) skips dim=1 and goes straight to vertices. Accepts a label, PG name, int tag, dimtag, or list of any.
  • g.model.queries.boundary_points(tag) — symmetric helper for the eight corner points of a volume.

ADDED — select() upgrades

  • select() now accepts a label string with a dim= keyword: select('box', dim=2, on={'z': 0}) resolves the label, walks to dim 2, and applies the predicate — no manual boundary() call beforehand.
  • not_on= and not_crossing= negation predicates. Same signed-distance computation as the positive forms; useful for all faces except the bottom style queries.
  • The Selection.to_label() call on a mixed-dim selection no longer triggers the labels-composite collision warning — using the same name across multiple dims is the documented intent here.

ADDED — Selection ergonomics

  • Set operations: selection | other (union with deduplication), selection & other (intersection), selection - other (difference). All three preserve the back-reference to _Queries so chaining keeps working.
  • selection.partition_by(axis=None) groups entities by their dominant bounding-box axis. Returns a dict[str, Selection] keyed by 'x', 'y', 'z', or — if axis= is given — a single Selection. Semantics are dim-aware:
  • curves group by the largest extent (curve direction);
  • surfaces group by the smallest extent (perpendicular / normal direction).

ADDED — primitive factories

  • g.model.queries.plane(z=0), plane(p1, p2, p3), and plane(normal=..., through=...) build a Plane you can pass to any select(on=..., crossing=...) call (positive or negated).
  • g.model.queries.line(p1, p2) builds a Line.
  • Define a primitive once, reuse across many selections — useful when the same cutting plane appears in several queries.

ADDED — set_transfinite_box

  • g.mesh.structured.set_transfinite_box(vol, *, size=None, n=None, recombine=True) — collapses the full transfinite-hex setup (curve node counts, face transfinite + recombine, volume transfinite) into a single call. Accepts either size= (target element size; node counts derived per edge from round(length / size) + 1) or n= (uniform node count per edge). Pass recombine=False for a transfinite tet mesh instead of hex.

CHANGED

  • examples/EOS Examples/22_geometric_selection.ipynb rewrites the 3-D section to use set_transfinite_box, select('box', dim=2, ...), the plane() factory, not_on=, set operations, partition_by, and chained to_label / to_physical — every v1.0.7 feature is exercised.

FIXED

  • g.model.queries.bounding_box(tag, dim=N), center_of_mass(tag, dim=N), and mass(tag, dim=N) now honour dim as an explicit hint when tag is a bare integer. Previously these went through resolve_to_single_dimtagresolve_dim, which always searches dimensions 3 → 0 and returns the first match — so on a model containing both volume 1 and curve 1, bounding_box(1, dim=1) silently returned the volume's bounding box. Bare ints are now passed straight to the corresponding Gmsh OCC call at the requested dim. String labels and (dim, tag) tuples still go through resolution.
  • g.model.geometry.slice now passes its plane reference as an explicit (2, plane_tag) dimtag to the downstream cut_by_surface / cut_by_plane calls. Previously it passed a bare int, which triggered resolve_dim to scan the live Gmsh model — and because add_axis_cutting_plane is called with sync=False, the new plane wasn't yet visible to getEntities(2), causing the resolver to fall through to the curves and fail with "surface ref N resolved to dim=1". Together these two fixes recover ≈14 previously-failing tests in test_geometry_cutting, test_sections, and test_part_anchors.

INTERNAL

  • New _Queries._resolve_to_dimtags(tag) helper consolidates the string / int / dimtag resolution path used by boundary_curves, boundary_points, and the new select() label-string branch.
  • _select_impl now takes not_on / not_crossing and inverts the predicate result (hit ^ invert). The four kwargs are mutually exclusive — exactly one must be passed.
  • Selection is parameterised on DimTag (a tuple[int, int] alias). Method signatures use proper type hints throughout.
  • 29 new test cases in tests/test_selection.py covering the new helpers, the negation predicates, set operations, partition_by for both curves and surfaces, the primitive factories, and set_transfinite_box. Total: 55 cases passing.

v1.0.6 — Geometric selection API (g.model.queries.select)

ADDED

  • g.model.queries.select(entities, on=..., crossing=...) filters curves, surfaces, or volumes by a geometric predicate. Replaces the noisy entities_in_bounding_box(xmin,ymin,zmin,xmax,ymax,zmax) pattern with a readable description of what you want.
  • Predicates work on the bounding-box corners of each candidate:
  • on= — every corner within tol of the primitive (entity lies on it).
  • crossing= — corners exist on both sides of the primitive (entity straddles it). Same signed-distance computation underlies both.
  • Primitive formats — no imports needed:
  • {'z': 0} → axis-aligned plane z = 0.
  • [(p1), (p2)] (2 points) → infinite line, for cutting 2-D geometry.
  • [(p1), (p2), (p3)] (3 points) → infinite plane through 3 non-collinear points. Use for surfaces and volumes.
  • select() returns a Selection — a list subclass with three chainable methods:
  • .select(...) — filter further (AND logic when stacked).
  • .to_label(name) — register every entity as a label, grouped by dimension.
  • .to_physical(name) — register every entity as a physical group, grouped by dimension. Each returns self so you can keep chaining: select → label → select again → physical.
  • Selection.__repr__ describes the count by dimension and reminds the user how to chain — IDE autocomplete + the repr are the only discovery surface needed.
  • New curriculum notebook examples/EOS Examples/22_geometric_selection.ipynb walks the full workflow: predicate intro → stacking → unstructured baseline → transfinite quad mesh of a plate → 3-D hex of a box.
  • Companion script examples/example_unstructured_and_transfinite.py shows the unstructured-vs-transfinite contrast for two adjacent boxes.
  • API docs page extended at docs/api/model.md with Selection, Plane, and Line (the latter two documented as internal but exposed so the format reference is auto-generated from docstrings).

INTERNAL

  • New module src/apeGmsh/core/_selection.py holds Plane, Line, the _parse_primitive dispatcher, the _select_impl core, and the Selection class. Plane and Line are not part of the public API — they are only constructed by _parse_primitive from raw user input passed to select().
  • Selection carries a back-reference to the originating _Queries so .select(), .to_label(), and .to_physical() can route to the session's labels / physical composites without the user having to thread context.
  • Tests in tests/test_selection.py (26 cases) cover predicates in 2-D and 3-D, primitive parsing (including degenerate / collinear / coincident input), the Selection chain, label / PG registration for both single-dim and mixed-dim selections, and error paths.

v1.0.5 — Line loads with normal=True (radial / curve-perpendicular pressure)

ADDED

  • g.loads.line(..., normal=True) applies a pressure perpendicular to each edge instead of along a fixed direction. Useful for internal/external pressure on curved 2-D boundaries (Lamé-style problems, fluid-loaded arcs, etc.) where the cartesian direction varies along the curve.
  • Default sign comes from Gmsh's surface boundary orientation — gmsh.model.getBoundary([(2, surface)], oriented=True) tells the composite which side of the curve the structure sits on, so magnitude > 0 always pushes into the structure (matching g.loads.surface(..., normal=True)).
  • Optional away_from=(x0, y0, z0) reference point overrides the Gmsh path — flips the in-plane normal so it points away from that point. Use for ambiguous cases (a curve bounding two surfaces, or a free curve not bounding any surface) or when you want to be explicit.
  • Both reduction="tributary" (default) and reduction="consistent" honour normal=True.
  • New worked example examples/EOS Examples/example_plate_pyGmsh_v2.ipynb rewrites the thick-walled-cylinder Lamé problem on top of the new API — replaces ~30 lines of manual consistent-force integration on the inner arc with a single g.loads.line(pg='Pressure', magnitude=p, normal=True) declaration.

INTERNAL

  • New resolver methods LoadResolver.resolve_line_per_edge_tributary and resolve_line_per_edge_consistent accept a list of (edge, q_xyz) items so the composite can pre-compute per-edge force-per-length vectors (which vary along curved boundaries). Constant-direction line loads still use the original resolve_line_* methods unchanged.
  • Per-edge normal computation lives in LoadsComposite (it needs Gmsh queries for the boundary-orientation path); the resolver stays pure-math.

v1.0.4 — Low-level booleans preserve Instances + accept label/PG refs

FIXED

  • g.model.boolean.{fuse,cut,intersect,fragment} now keep Instance.entities consistent when called directly on tags that happen to belong to a tracked Instance. Previously the remap only ran inside g.parts.fragment_all / fragment_pair / fuse_group, so a low-level boolean left Instance entries pointing at consumed tags. The remap-from-result walk has been extracted into PartsRegistry._remap_from_result and every OCC boolean call site (both _bool_op and the Parts-level methods) now routes through that single implementation.

ADDED

  • g.model.boolean.* accepts label names and user physical-group names in objects= / tools=, matching the input shape of g.physical.add. Strings resolve via the shared resolver: label (Tier 1) first, then user PG (Tier 2). Raw tags, dimtags, and mixed lists still work.

INTERNAL

  • New resolve_to_dimtags helper in apeGmsh.core._helpers — companion to resolve_to_tags that emits (dim, tag) pairs. Handles labels / PGs that span multiple dimensions without the caller having to coerce a single dim.
  • Plan B (Instance.entities as a computed label-backed property) was weighed against this conservative fix and deferred; see internal_docs/plan_instance_computed_view.md for the signals that would trigger revisiting it.

v1.0.0 — Clean Architecture (breaking)

v1.0 bundles two breaking changes: the package rename and the Model composition refactor. A full find-replace migration guide is at docs/migration.md.

BREAKING

  • Package renamed: pyGmshapeGmsh
  • from pyGmsh import pyGmshfrom apeGmsh import apeGmsh
  • class pyGmsh(_SessionBase)class apeGmsh(_SessionBase)
  • Companion app apeGmshViewer keeps its name (only its internal imports of our theme module were updated)

  • Model methods split into five sub-composites (composition replaces mixin inheritance):

  • g.model.geometry.* — 19 primitive builders (add_point, add_line, add_box, add_cylinder, etc.)
  • g.model.boolean.* — fuse, cut, intersect, fragment
  • g.model.transforms.* — translate, rotate, scale, mirror, copy, extrude, revolve, sweep, thru_sections
  • g.model.io.* — load/save STEP, IGES, DXF, MSH, heal_shapes
  • g.model.queries.* — bounding_box, center_of_mass, mass, boundary, adjacencies, entities_in_bounding_box, remove, remove_duplicates, make_conformal, registry
  • g.model.sync(), g.model.viewer(), g.model.gui(), g.model.launch_picker(), g.model.selection stay flat on Model

  • Rename g.massg.masses for consistency with the other plural composites (g.loads, g.parts, g.physical, g.constraints, g.mesh_selection)

  • fem.massfem.masses
  • Class names (MassesComposite, MassSet, MassDef, MassRecord) unchanged

  • Removed legacy aliases:

  • g.initialize() / g.finalize() → use g.begin() / g.end()
  • g._initialized → use g.is_active
  • g.model_name → use g.name

  • Removed deprecated methods:

  • g.model.viewer_fast() / g.mesh.viewer_fast() → use viewer() (always fast now)
  • g.parts.add_physical_groups() → explicit g.physical.add_volume(inst.entities[3], name=...)
  • g.opensees.add_nodal_load() → use g.loads.point() in a g.loads.case() block
  • g.mesh_selection.add_nodes(nearest_to=...)closest_to=

  • Removed convenience delegates on the session:

  • g.remove_duplicates()g.model.queries.remove_duplicates()
  • g.make_conformal()g.model.queries.make_conformal()

  • Removed property on _SessionBase:

  • _parent.model_name_parent.name

FIXED

  • Pylance / static analyzers no longer lose track of Model methods. Composition makes every method statically discoverable through the sub-composite classes (_Geometry, _Boolean, _Transforms, _IO, _Queries), each of which is a concrete class with explicit methods. No MRO walking across 5 mixin files.

INTERNAL

  • _GeometryMixin_Geometry
  • _BooleanMixin_Boolean
  • _TransformsMixin_Transforms
  • _IOMixin_IO
  • _QueriesMixin_Queries

Each sub-composite now takes a model reference in __init__ and accesses Model state via self._model._log(...), self._model._register(...), self._model._as_dimtags(...), self._model._registry, instead of inheriting state.

MIGRATION

See docs/migration.md for the complete find-replace table and an automated migration script.

v0.3.0 is the last pyGmsh release (pre-rename safety tag). v0.3.1 is the transitional apeGmsh release (rename only). v1.0.0 is the new architecture (composition + cleanups).


v0.3.1 — Package rename (transitional)

  • Renamed pyGmsh package directory to apeGmsh/ on disk
  • All internal imports, class name, config entries updated
  • Examples and docs still reference old API (deferred to v1.0)
  • Safety release — rename is isolated from the architectural refactor that follows in v1.0

v0.3.0 — Last pyGmsh release (safety tag)

Safety checkpoint before the package rename and v1.0 refactor. This is the final tag under the pyGmsh name. If you need the old API, pin to this version.


v0.2.x — Loads, Masses, Viewer overlays

  • New g.loads composite with pattern context managers
  • New g.mass composite (renamed to g.masses in v1.0)
  • fem.loads / fem.mass auto-resolved by get_fem_data()
  • Loads/masses/constraints overlays live on g.mesh.viewer(fem=...) — they are mesh-resolved concepts and never landed on g.model.viewer()

v0.2.0 — Composites architecture

  • Assembly absorbed into apeGmsh as composites:
  • g.parts (PartsRegistry)
  • g.constraints (ConstraintsComposite)
  • MeshSelectionSet + _mesh_filters spatial query engine
  • Viewer rebuild: BRep / mesh viewers unified around EntityRegistry, PickEngine, ColorManager, VisibilityManager
  • Catppuccin Mocha theme across all viewers