rtx-fsi: trust-region cap on the IQN step + increment-scaled stall acceptance
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 s = 1 FSI2 march (deepest rung: 1.955 Hz, ±73 mm mid-growth) found
two coupler failure modes at peak motion:

- run 1 (budget 12): NotConverged at residual 6.1e-4 = 9% of the step's
  own increment, after the history-reset retry — killed at t = 11.1 s.
- run 2 (budget 30): the deeper budget let an ill-conditioned secant
  model extrapolate the locally violent map into a candidate interface
  that swept to the domain wall and crashed the mask build BEFORE any
  residual guard could fire (t ~ 9.9 s).

Fixes, both scale-relative per the absolute-threshold rule:

- STEP_CAP = 50: the full quasi-Newton step r + W alpha is capped at
  50x the current residual norm, direction kept. Legitimate large
  Newton steps (near-marginal gains) pass; thousand-fold geometric
  extrapolations cannot. Pinned by a noisy-map test asserting every
  iterate's step stays within the cap.
- The march accepts a stalled step at residual < max(5 x tolerance,
  0.1 x the step's own increment) — the rare violent step near peak
  motion carries an order-below-increment error, counted like every
  stall and bounded by the existing stall-fraction assert; the retry
  trigger mirrors the same bound.

46 lib tests green, clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
This commit is contained in:
Omar Sobh
2026-08-21 19:14:53 -07:00
co-authored by Claude Fable 5
parent d5f19ea497
commit a342e703a4
2 changed files with 81 additions and 4 deletions
+71 -1
View File
@@ -253,6 +253,19 @@ impl Subiterated {
/// already accepted before it is dropped, **relative to its own norm**. /// already accepted before it is dropped, **relative to its own norm**.
const COLUMN_FILTER: f64 = 1e-8; const COLUMN_FILTER: f64 = 1e-8;
/// Trust region for the quasi-Newton step: the full update `r + W alpha`
/// is capped at this multiple of the current residual norm (direction
/// kept). Legitimate Newton steps exceed `||r||` only by the inverse
/// distance of the map's gain from one — large for near-marginal maps,
/// which this cap still admits — while a secant model extrapolating a
/// locally violent nonlinear map can propose steps thousands of times
/// the residual (measured on TurekHron FSI2 at subcycle 1: a candidate
/// interface displacement swept to the domain wall and killed the mask
/// build before any residual-based guard could fire). The cap is
/// relative to the residual's own scale, per this workspace's
/// absolute-threshold rule.
const STEP_CAP: f64 = 50.0;
/// One secant sample: `(delta residual, delta pass-output)` between two /// One secant sample: `(delta residual, delta pass-output)` between two
/// successive iterations. /// successive iterations.
type SecantColumn = (Vec<f64>, Vec<f64>); type SecantColumn = (Vec<f64>, Vec<f64>);
@@ -437,7 +450,18 @@ impl IqnIls {
.collect(); .collect();
match least_squares_update(&columns, &r) { match least_squares_update(&columns, &r) {
Some(delta) => { Some(delta) => {
x = x_tilde.iter().zip(&delta).map(|(a, b)| a + b).collect(); // The full step from x is r + delta; cap it at
// STEP_CAP x the residual (see the constant's docs).
let mut step: Vec<f64> = r.iter().zip(&delta).map(|(a, b)| a + b).collect();
let step_norm = norm_of(&step);
let cap = STEP_CAP * norm;
if step_norm > cap {
let scale = cap / step_norm;
for value in &mut step {
*value *= scale;
}
}
x = x.iter().zip(&step).map(|(a, b)| a + b).collect();
} }
None => { None => {
// No usable secant information yet: one relaxed // No usable secant information yet: one relaxed
@@ -862,6 +886,52 @@ mod tests {
); );
} }
#[test]
fn the_quasi_newton_step_is_trust_region_capped() {
// Per-pass noise corrupts the secant columns, and the
// least-squares extrapolation can then propose steps orders of
// magnitude beyond the residual — the FSI2 subcycle-1 march had
// a candidate interface swept to the domain wall this way,
// crashing the mask build before any residual guard could fire.
// Every iterate's step must stay within STEP_CAP x its own
// residual.
let calls = std::cell::Cell::new(0u64);
let trace: std::cell::RefCell<Vec<(Vec<f64>, Vec<f64>)>> =
std::cell::RefCell::new(Vec::new());
let noisy = |state: &[f64]| -> Vec<f64> {
calls.set(calls.get() + 1);
let noise = (calls.get() as f64 * 2.399_963).sin() * 1e-3;
let out: Vec<f64> = state.iter().map(|x| -2.5 * x + 1.0 + noise).collect();
trace.borrow_mut().push((state.to_vec(), out.clone()));
out
};
let mut scheme = IqnIls::new(60, 1e-14).expect("valid");
let _ = scheme.solve(&[1.0, -1.0], noisy); // unreachable tolerance
let trace = trace.into_inner();
assert!(trace.len() >= 10, "expected a full noisy iteration");
for pair in trace.windows(2) {
let (input, output) = &pair[0];
let (next_input, _) = &pair[1];
let residual: f64 = output
.iter()
.zip(input)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let step: f64 = next_input
.iter()
.zip(input)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
assert!(
step <= 50.0 * residual * (1.0 + 1e-9),
"step {step:.3e} exceeded the trust region at residual \
{residual:.3e}"
);
}
}
#[test] #[test]
fn resetting_the_history_restores_a_cold_start() { fn resetting_the_history_restores_a_cold_start() {
// The recovery path a marching coupler uses when stale secant // The recovery path a marching coupler uses when stale secant
@@ -338,7 +338,7 @@ fn fsi2_flapping_flag() {
e, e,
rtx_fsi::FsiError::CouplingNotConverged { residual, .. } rtx_fsi::FsiError::CouplingNotConverged { residual, .. }
| rtx_fsi::FsiError::CouplingDiverged { residual, .. } | rtx_fsi::FsiError::CouplingDiverged { residual, .. }
if *residual >= 5.0 * tol_step if *residual >= (5.0 * tol_step).max(0.1 * increment)
); );
if recoverable { if recoverable {
iqn_ref.reset_history(); iqn_ref.reset_history();
@@ -361,9 +361,16 @@ fn fsi2_flapping_flag() {
iterations, iterations,
residual, residual,
}, },
) if residual < 5.0 * tol_step => { ) if residual < (5.0 * tol_step).max(0.1 * increment) => {
// The noise floor, not divergence: accept the last // The noise floor, not divergence: accept the last
// candidate, count it, and bound it at the end. // candidate, count it, and bound it at the end. The
// second bound is for the rare violent step near peak
// motion (the s = 1 run died at residual = 9% of its own
// increment after retrying): a residual an order below
// the step's own physical increment is an occasional
// acceptable error, counted like every stall and bounded
// by the stall-fraction assert — a SYSTEMATIC scatter at
// that scale would trip it.
stalled_steps += 1; stalled_steps += 1;
worst_stall = worst_stall.max(residual); worst_stall = worst_stall.max(residual);
total_subiterations += iterations; total_subiterations += iterations;