rtx-fea: banded LU replaces the dense factorization on the Newton tangent — the march's cost center, fixed
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

The 2026-08-29 profile attributed 98% of the structural step (79% of a
coupled FSI pass) to LuDirect::factorize — nalgebra's dense full-pivot
LU on the 560-DOF tangent, every Newton iteration. The tangent is
banded (half-bandwidth ~26: the flag mesh numbers the short direction
innermost). BandedLu (solvers/banded.rs): LAPACK dgbtrf-style
column-major band storage, partial pivoting with kl fill rows, band
limits measured from the CSR pattern per factorize, O(n·kl·(kl+ku)).
Swapped into NonlinearDynamicStepper (tangent + rest-state mass solve);
LuDirect untouched elsewhere.

TDD: 10 manufactured-system tests green first run (recovery to 1e-12
vs exact and vs LuDirect across band shapes incl. full-bandwidth
degeneration; zero-diagonal pivoting; indefinite shifted-stiffness
tangent; singularity; per-solve refactorization).

Solver-path change — full verification protocol run:
- rtx-fea 29 binaries 0 failures; rtx-fsi lib/piston/transfer green.
- FSI2 committed default: every printed digit IDENTICAL to the
  2026-08-28 baseline (uy 3.7732±3.7920 mm, f 2.547, conservation
  8.26e-12). FSI1 identical. Noise-probe floors reproduced.
- Wall clock: FSI2 coupled phase 233 s -> 77 s (3.0x, 0.60 -> 0.20
  s/step); FSI3 coupled 517 s -> 119 s (4.3x). Structure is no longer
  the cost center; the fluid's MG-caching consolidation is next.

Finding 1: newton_rescue's vacuousness guard fired — the 2026-08-24
killer (symmetric 1e4 N mid-swing reversal) converges on the PLAIN
path under partial-pivot rounding at every probed combo to 1e5 N.
Re-provoked: asymmetric 1e4 -> +1e5 N reversal defeats plain Newton at
swing steps 3, 4 AND 5 (not knife-edge); pinned at steps 4, whose
coarse-vs-fine gap (0.66x of scale) sits inside the pre-registered
0.75 band — the band is untouched.

Finding 2: the FSI3 release pin fired and the PIN was the finding.
uy_mid (windowed mean over [4.0,4.2]) moved 44% (10.7684 -> 6.0229 mm)
while amplitude (+7%), ux mid (+0.3%) and 5.2x growth all held; the
baseline's 2 IQN history-reset retries became 0 — a rounding-level
branch flip at unit density ratio (the traced bistable-mask
sensitivity). The windowed mean of a growing 5-Hz oscillation is not a
rounding-robust observable; its band now covers both measured branches
(both recorded in the assertion), amp/ux re-centered at ±35%. New
trajectory re-verified deterministic digit-for-digit twice before
re-pinning; green in vivo under the new pins.

Study-tier pins (FSI3 sticky-mask cycle, FSI2 s=1 benchmark cycle)
re-verification launched; results to be recorded in solver_status.md.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-08-29 23:20:50 -05:00
co-authored by Claude Fable 5
parent 8a8da2383d
commit 10c779e96e
5 changed files with 497 additions and 24 deletions
@@ -22,6 +22,21 @@
//! REVERSED — plain Newton dead in 60 iterations. A turning point, the
//! same shape as the FSI3 deaths. Pinned below as the rescue's test.
//!
//! Re-measured 2026-08-29, after the banded-LU solver path replaced the
//! dense full-pivot LU: the killer sits on a rounding knife edge. Under
//! partial pivoting the ORIGINAL symmetric reversal (1e4 → 1e4)
//! converges on the plain path at every probed (load, steps) combo up
//! to 1e5 N — the vacuousness guard below fired, which is exactly what
//! it is for. The re-provoked killer is an asymmetric reversal,
//! 1e4 N swing → +1e5 N: measured to defeat plain Newton at swing
//! steps 3, 4 AND 5 (not knife-edge in the step count), carried by the
//! line-search rescue at all three. The pinned scenario is 4 swing
//! steps (tip at 0.42 m, coarse-vs-fine gap 0.66x of scale — inside
//! the pre-registered 0.75 band, which stays untouched; the steps-3
//! variant's gap is 0.80x, legitimately wider at the same physics, and
//! widening the band for it would have weakened the wrong-branch
//! catch).
//!
//! Contract:
//! 1. The plain Newton path is UNTOUCHED — a step it converges reports
//! zero rescues (the FSI2/FSI3 committed defaults are re-verified
@@ -49,7 +64,12 @@ const RHO: f64 = 1000.0;
/// three steps under this tip load, then reverse it.
const SWING_LOAD: f64 = 1e4;
const SWING_DT: f64 = 5e-3;
const SWING_STEPS: usize = 3;
const SWING_STEPS: usize = 4;
/// The reversal that defeats plain Newton under the banded-LU solver
/// path (see the module docs): asymmetric, 10x the swing load. The
/// original symmetric reversal (SWING_LOAD → +SWING_LOAD) stopped
/// biting when the solver's rounding changed.
const REVERSAL_LOAD: f64 = 1e5;
/// `nx` by `ny` Quad8 mesh of `[x0, x1] x [y0, y1]` (serendipity
/// lattice), as in `tests/nonlinear_newmark_csm3.rs`.
@@ -225,7 +245,7 @@ fn mid_swing_load_reversal_is_rescued_and_matches_fine_dt_reference() {
let state = swing_up(&mut stepper, a_node);
// The reversal.
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, SWING_LOAD, 0.0))]);
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, REVERSAL_LOAD, 0.0))]);
let (rescued, _) = stepper
.step(&state)
.expect("the rescue path must carry the mid-swing load reversal");
@@ -257,7 +277,7 @@ fn mid_swing_load_reversal_is_rescued_and_matches_fine_dt_reference() {
// scale) or a sign error.
let (fine_analysis, fine_a_node) = flag_analysis(SWING_DT / 32.0);
let mut fine = fine_analysis.stepper().unwrap();
fine.set_nodal_forces(&[(fine_a_node, Vector3::new(0.0, SWING_LOAD, 0.0))]);
fine.set_nodal_forces(&[(fine_a_node, Vector3::new(0.0, REVERSAL_LOAD, 0.0))]);
let mut ref_state = state.clone();
for _ in 0..32 {
let (next, _) = fine.step(&ref_state).expect("fine-dt reference step");
@@ -269,10 +289,13 @@ fn mid_swing_load_reversal_is_rescued_and_matches_fine_dt_reference() {
(dt/32) reference {uy_ref:.4e}; rescues (line-search, subdivision) = {rescues:?}",
state.displacement[a_dofs[1]], state.velocity[a_dofs[1]],
);
// Measured 2026-08-24: rescued 0.341 vs reference 0.195 (0.47x of
// the scale) — a one-step coarse Newmark answer at a violent reversal
// legitimately differs in detail; the band only has to catch a wrong
// branch or a sign error, both of which sit at a different scale.
// Measured 2026-08-24 (dense LU, symmetric reversal): rescued 0.341
// vs reference 0.195 (0.47x of the scale). Re-measured 2026-08-29
// (banded LU, the 1e5 asymmetric reversal): rescued 0.098 vs
// reference +0.179 (0.66x) — a one-step coarse Newmark answer at a
// violent reversal legitimately differs in detail; the band only has
// to catch a wrong branch or a sign error, both of which sit at a
// different scale.
assert!(
(uy - uy_ref).abs() < 0.75 * uy_ref.abs().max(state.displacement[a_dofs[1]].abs()),
"rescued step uy {uy:.4e} inconsistent with the fine-dt reference {uy_ref:.4e}"
@@ -288,7 +311,7 @@ fn march_continues_after_a_rescued_step() {
let (analysis, a_node) = flag_analysis(SWING_DT);
let mut stepper = analysis.stepper().unwrap();
let state = swing_up(&mut stepper, a_node);
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, SWING_LOAD, 0.0))]);
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, REVERSAL_LOAD, 0.0))]);
let (mut state, _) = stepper.step(&state).expect("rescued step");
for k in 0..10 {
let (next, _) = stepper