Files
rustytorch/crates/specialized/rtx-fea/tests/newton_rescue.rs
T
Omar SobhandClaude Fable 5 10c779e96e
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
rtx-fea: banded LU replaces the dense factorization on the Newton tangent — the march's cost center, fixed
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
2026-08-29 23:20:50 -05:00

329 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The nonlinear Newmark stepper under violent sudden loads: the rescue
//! path (backtracking line search, then step subdivision) behind
//! [`NonlinearDynamicStepper::step`].
//!
//! Why this exists: both FSI3 study-march deaths (2026-08-24) were the
//! flag's SVK Newton returning `ConvergenceFailed { iterations: 60 }`
//! inside a coupling pass at a violent mid-cycle load — the structural
//! solver, not the coupling. A full Newton step from the Newmark
//! predictor under a load far from the current configuration can leave
//! SVK's region of convergence; the plain loop had no line search and no
//! subdivision, so the first such step killed a five-hour march.
//!
//! The failure shape, measured by a probe before the rescue was written
//! (2026-08-24, this mesh, 60-iteration budget):
//! - A static tip load from a quiescent state NEVER failed — up to
//! 1e6 N (tip deflection past the flag's own length) plain Newton
//! converged in ≤ 19 iterations. From rest the predictor IS the
//! current configuration and `M/(β Δt²)` regularizes the walk.
//! Pinned below (`static_loads_from_rest_never_need_rescue`).
//! - The kill is MID-SWING: three steps of swing-up under a 1e4 N tip
//! load at dt = 5e-3 (tip at 0.31 m, 31 m/s), then the load
//! 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
//! bit-identical separately).
//! 2. The measured killer step must be rescued, deterministically, to a
//! state consistent with a fine-dt reference march of the same total
//! interval.
use nalgebra::Vector3;
use rtx_fea::analysis::{
AnalysisConfig, ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis,
NonlinearDynamicStepper,
};
use rtx_fea::assembly::dof_mapping::DofComponent;
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
const E_MOD: f64 = 1.4e6;
const NU: f64 = 0.4;
const RHO: f64 = 1000.0;
/// The measured plain-Newton killer (see the module docs): swing up for
/// three steps under this tip load, then reverse it.
const SWING_LOAD: f64 = 1e4;
const SWING_DT: f64 = 5e-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`.
fn quad8_rect_mesh(x0: f64, x1: f64, y0: f64, y1: f64, nx: usize, ny: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = x0 + (x1 - x0) * i as f64 / (2 * nx) as f64;
let y = y0 + (y1 - y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
fn materials() -> MaterialDatabase {
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(E_MOD, NU).with_density(RHO),
None,
);
db
}
fn clamp_left(mesh: &Mesh, x_left: f64) -> BoundaryConditionSet {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - x_left).abs() < 1e-12)
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
set.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
set
}
fn point_a(mesh: &Mesh, x: f64, y: f64) -> NodeId {
mesh.nodes
.iter()
.find(|(_, node)| {
(node.position().x - x).abs() < 1e-12 && (node.position().y - y).abs() < 1e-12
})
.map(|(&id, _)| id)
.expect("tracking point must be a mesh node")
}
/// The FSI2/FSI3 flag geometry at 10x2 Quad8 with the harness's
/// 60-iteration Newton budget.
fn flag_analysis(dt: f64) -> (NonlinearDynamicAnalysis, NodeId) {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 10, 2);
let a_node = point_a(&mesh, 0.6, 0.2);
let analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
1,
AnalysisConfig::default(),
)
.with_total_lagrangian()
.with_convergence_criteria(ConvergenceCriteria {
max_iterations: 60,
..ConvergenceCriteria::default()
});
(analysis, a_node)
}
/// Swing the flag up for [`SWING_STEPS`] steps under `(0, -SWING_LOAD)`
/// from a quiescent start, through the PLAIN path (asserted).
fn swing_up(stepper: &mut NonlinearDynamicStepper<'_>, a_node: NodeId) -> DynamicState {
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, -SWING_LOAD, 0.0))]);
let mut state = stepper.rest_state().unwrap();
state.acceleration.fill(0.0);
for _ in 0..SWING_STEPS {
let (next, _) = stepper.step(&state).expect("swing-up step");
state = next;
}
assert_eq!(
stepper.rescue_counts(),
(0, 0),
"the swing-up must not need rescuing — it is the reference plain path"
);
state
}
/// Measured negative result, pinned: a static tip load from a quiescent
/// state does not defeat plain Newton even at 1e6 N (tip deflection
/// beyond the flag's own length, 19 iterations). The rescue must stay
/// out of the way.
#[test]
fn static_loads_from_rest_never_need_rescue() {
for load in [1e4, 1e6] {
let (analysis, a_node) = flag_analysis(SWING_DT);
let mut stepper = analysis.stepper().unwrap();
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, -load, 0.0))]);
let mut state = stepper.rest_state().unwrap();
state.acceleration.fill(0.0);
let (next, iterations) = stepper
.step(&state)
.expect("static load from rest must converge on the plain path");
assert!(next.displacement.iter().all(|v| v.is_finite()));
assert!(
iterations <= 25,
"static {load:.0e} N took {iterations} iterations"
);
assert_eq!(
stepper.rescue_counts(),
(0, 0),
"static load from rest engaged the rescue"
);
}
}
/// A benign step must go through the untouched plain path: zero rescues,
/// few iterations.
#[test]
fn benign_step_is_never_rescued() {
let (analysis, a_node) = flag_analysis(SWING_DT);
let mut stepper = analysis.stepper().unwrap();
stepper.set_nodal_forces(&[(a_node, Vector3::new(0.0, -0.1, 0.0))]);
let mut state = stepper.rest_state().unwrap();
state.acceleration.fill(0.0);
let (next, iterations) = stepper.step(&state).expect("benign step must converge");
assert!(next.displacement.iter().all(|v| v.is_finite()));
assert!(
iterations <= 5,
"benign step took {iterations} Newton iterations"
);
assert_eq!(stepper.rescue_counts(), (0, 0));
}
/// The measured killer step (mid-swing load reversal — plain Newton dies
/// in 60 iterations here, the FSI3 death shape) must be rescued:
/// deterministically, and to a state the fine-dt reference march
/// corroborates.
#[test]
fn mid_swing_load_reversal_is_rescued_and_matches_fine_dt_reference() {
let (analysis, a_node) = flag_analysis(SWING_DT);
let mut stepper = analysis.stepper().unwrap();
let a_dofs = stepper.node_dofs(a_node);
let state = swing_up(&mut stepper, a_node);
// The reversal.
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");
let rescues = stepper.rescue_counts();
assert!(
rescues.0 + rescues.1 > 0,
"this step was measured to defeat plain Newton (60 iterations); zero \
rescues means the scenario no longer bites and this test is vacuous"
);
assert!(rescued.displacement.iter().all(|v| v.is_finite()));
let uy = rescued.displacement[a_dofs[1]];
// Determinism: the coupling subiterates by re-running the same step
// from the same state — the rescue must be a pure function of
// (state, forces) too.
let (again, _) = stepper.step(&state).unwrap();
assert_eq!(
rescued.displacement, again.displacement,
"rescued step is not deterministic"
);
assert_eq!(rescued.velocity, again.velocity);
// Fine-dt reference: the same interval marched at dt/32 under the
// same constant reversed load from the same mid-swing state (the
// DOF numbering is the same mesh's). Newmark at two different steps
// agrees to O(dt²) — but at a violent reversal the one-step coarse
// answer legitimately differs in detail, so the band is generous; it
// still catches a wrong-branch answer (a different deformation
// 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, 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");
ref_state = next;
}
let uy_ref = ref_state.displacement[a_dofs[1]];
println!(
" reversal from uy {:.4e} (v_tip {:.3e}): rescued uy {uy:.4e}, fine-dt \
(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 (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}"
);
}
/// After a rescued step, the march must be able to CONTINUE — the
/// coupling re-steps and then keeps marching from whatever the rescue
/// returned. Ten further steps under the reversed load must all
/// converge (plain or rescued) and stay finite.
#[test]
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, REVERSAL_LOAD, 0.0))]);
let (mut state, _) = stepper.step(&state).expect("rescued step");
for k in 0..10 {
let (next, _) = stepper
.step(&state)
.unwrap_or_else(|e| panic!("step {k} after the rescue failed: {e:?}"));
assert!(
next.displacement.iter().all(|v| v.is_finite()),
"step {k} after the rescue went non-finite"
);
state = next;
}
let (line_search, subdivision) = stepper.rescue_counts();
println!(" post-rescue march: rescues line-search {line_search}, subdivision {subdivision}");
}