rtx-fea: reduced Newmark (mor::dynamic) + the phase-4a offline replay — the ≥10x gate is REFUTED by measurement at the validated resolution
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 dynamic layer over ReducedNonlinearModel: reduced consistent mass
V'MV (full element sum, never ECSW-sampled — ECSW weights are trained
on internal-force virtual work and would conserve the wrong inertia),
reduced_force_and_jacobian exposed (solve() refactored onto it), and
ReducedNewmark mirroring NonlinearDynamicStepper::newmark_newton in
reduced coordinates (same predictor, residual, tangent shape; no
rescue ladder by design — a reduced Newton death is a finding).

TDD (tests/reduced_newmark.rs): identity-basis march reproduces the
full stepper to 2.4e-14 over 15 steps (both Newton loops tightened to
1e-10 so only solver rounding separates them); rigid-translation
reduced mass = rho*A to 1e-9; a 6-mode POD basis tracks its training
trajectory at 4.2e-4 rms against a 1.0e-4 projection floor.

Phase 4a (fsi3_ecsw_offline.rs, fsi3_reduced_newmark_replay,
env-gated): reduced Newmark replay of the harvested FSI3 trajectory at
record cadence (dt_rec = 5x march dt), driven by the recorded
end-of-step loads. Measured, m=12/20:

- COST (dt-independent, the verdict): 3,068/3,580 us/step at 4.6/5.0
  Newton iters — 2.0-2.3x the banded full-order structural step
  (7,200 us/pass, bandedlu_fsi3_ny62_t85). The >=10x gate needs
  <=720 us/step; one reduced eval alone costs ~640 us because phase 2
  refuted hyperreduction (every eval loops all 70 elements). The gate
  arithmetic is closed: reduced Newton needs >=2 evals, capping the
  ROM at ~5x. THE CAMPAIGN GATE (pinned cycle bands at >=10x
  structural speedup) CANNOT BE MET at the validated resolution.
- TRACKING at record cadence diverges in the release transient (dies
  t=4.35-4.45) — and the RTX_REPLAY_IDENTITY control dies EARLIER
  (t=4.13) in the exact subspace: the death is the 5x-coarse
  integration + aliased loads, NOT the reduction. The record-cadence
  replay cannot judge subspace dynamics; the projection floor
  (1.1e-3 at m=12) remains the honest subspace statement.

Campaign verdict to be recorded in omni-cortex in the pre-registered
words.

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-30 07:07:49 -05:00
co-authored by Claude Fable 5
parent 10c779e96e
commit 0b4f306ed1
5 changed files with 852 additions and 20 deletions
@@ -26,15 +26,23 @@ use nalgebra::{DMatrix, DVector};
use rtx_fea::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::MaterialId;
use rtx_fea::mesh::NodeId;
use rtx_fea::mor::{
Formulation, ReducedNonlinearModel, ecsw_residual, pod_basis, train_ecsw_formulated,
Formulation, ReducedNewmark, ReducedNonlinearModel, ecsw_residual, pod_basis,
train_ecsw_formulated,
};
/// One FSNP record (the phase-1 dump format; see `march::write_snapshot`).
struct Snapshot {
#[allow(dead_code)]
t: f64,
displacement: Vec<f64>,
/// Full-DOF velocity and acceleration — the phase-4 replay's initial
/// state.
velocity: Vec<f64>,
acceleration: Vec<f64>,
/// The committed sparse nodal load `(node id, [fx, fy, fz])` — the
/// external force the structural step was fed.
loads: Vec<(usize, [f64; 3])>,
}
fn read_fsnp(path: &str) -> (usize, Vec<Snapshot>) {
@@ -50,11 +58,34 @@ fn read_fsnp(path: &str) -> (usize, Vec<Snapshot>) {
while off < data.len() {
let t = f64_at(&data, off);
off += 8;
let displacement: Vec<f64> = (0..n_dofs).map(|i| f64_at(&data, off + 8 * i)).collect();
off += 8 * n_dofs * 3; // skip velocity + acceleration for this phase
let mut series = |off: &mut usize| -> Vec<f64> {
let v: Vec<f64> = (0..n_dofs).map(|i| f64_at(&data, *off + 8 * i)).collect();
*off += 8 * n_dofs;
v
};
let displacement = series(&mut off);
let velocity = series(&mut off);
let acceleration = series(&mut off);
let n_forces = u64::from_le_bytes(data[off..off + 8].try_into().unwrap()) as usize;
off += 8 + n_forces * 32;
records.push(Snapshot { t, displacement });
off += 8;
let mut loads = Vec::with_capacity(n_forces);
for _ in 0..n_forces {
let node = u64::from_le_bytes(data[off..off + 8].try_into().unwrap()) as usize;
let f = [
f64_at(&data, off + 8),
f64_at(&data, off + 16),
f64_at(&data, off + 24),
];
off += 32;
loads.push((node, f));
}
records.push(Snapshot {
t,
displacement,
velocity,
acceleration,
loads,
});
}
assert_eq!(off, data.len(), "trailing bytes");
(n_dofs, records)
@@ -274,3 +305,192 @@ fn fsi3_ecsw_offline_study() {
println!(" assembly speedup {:.1}x", t_full / t_sampled);
}
}
/// ECSW×FSI campaign, phase 4a: the OFFLINE reduced Newmark replay.
///
/// The reduced dynamic model (POD basis, total-Lagrangian operators,
/// projected consistent mass, plain reduced Newton — `mor::dynamic`,
/// identity-basis-verified against the full stepper at 2.4e-14) marches
/// the harvested trajectory's horizon at the RECORD cadence, driven by
/// the recorded end-of-step nodal loads, from the recorded initial
/// state. Measured, pre-registered:
///
/// 1. Tracking error vs the recorded full-order displacement, release
/// window and settled cycle separately, against the projection floor
/// (the best any model in this subspace can do).
/// 2. Wall-clock per reduced step (the gate's numerator): the banded
/// full-order structural step measured 7.2 ms/pass on the FSI3
/// study march (2026-08-30, `bandedlu_fsi3_ny62_t85`), so ≥10×
/// demands ≤0.72 ms/step here.
///
/// Caveat, pre-named: the replay integrates at the record spacing
/// (5× the march dt), so tracking error conflates subspace closure
/// with integrator-dt difference; the cost number does not care, and a
/// model that tracks at THIS dt would only track better at the march's.
/// A reduced Newton death (no rescue ladder) is a finding, printed and
/// not papered over.
#[test]
fn fsi3_reduced_newmark_replay() {
let Ok(snap_path) = std::env::var("RTX_ECSW_SNAP") else {
println!(" RTX_ECSW_SNAP not set — reduced Newmark replay skipped");
return;
};
let (n_dofs, records) = read_fsnp(&snap_path);
let mesh = flag_mesh(35, 2);
let mut materials = MaterialDatabase::new();
materials.add_material(
MaterialId(0),
LinearElastic::new(FSI3.e_s, FSI3.nu_s).with_density(FSI3.rho_s),
None,
);
let dof_numbering = numbering(&mesh);
let n_free = dof_numbering.free_dofs.len();
assert_eq!(dof_numbering.total_dofs, n_dofs, "DOF count mismatch");
let to_free = |full: &[f64]| -> DVector<f64> {
DVector::from_iterator(n_free, dof_numbering.free_dofs.iter().map(|&dof| full[dof]))
};
let mut free_index = vec![None; dof_numbering.total_dofs];
for (i, &dof) in dof_numbering.free_dofs.iter().enumerate() {
free_index[dof] = Some(i);
}
let load_to_free = |loads: &[(usize, [f64; 3])]| -> DVector<f64> {
let mut force = DVector::zeros(n_free);
for &(node, f) in loads {
for (component, &dof) in dof_numbering.get_node_dofs(NodeId(node)).iter().enumerate() {
if let Some(free) = free_index[dof] {
force[free] += f[component];
}
}
}
force
};
// Record cadence (the replay dt). The march's own dt is 5x finer.
let spacings: Vec<f64> = records.windows(2).map(|w| w[1].t - w[0].t).collect();
let dt_rec = spacings.iter().sum::<f64>() / spacings.len() as f64;
let worst_spacing = spacings
.iter()
.map(|s| (s - dt_rec).abs())
.fold(0.0f64, f64::max);
println!(
" {} records, dt_rec {dt_rec:.6e} (worst spacing deviation {worst_spacing:.2e})",
records.len()
);
assert!(
worst_spacing < 1e-9,
"record spacing is not uniform — the fixed-dt replay is invalid"
);
// The basis: trained exactly as phase 2's measurement 1 (interleaved
// half), so the projection numbers line up with the campaign record.
let train: Vec<DVector<f64>> = records
.iter()
.step_by(2)
.map(|r| to_free(&r.displacement))
.collect();
let basis_full = pod_basis(&train, 1e-14).unwrap();
let scale = records
.iter()
.map(|r| to_free(&r.displacement).norm())
.fold(0.0f64, f64::max);
// The dt-vs-reduction control: RTX_REPLAY_IDENTITY runs the same
// replay with the identity basis — the exact subspace, so any death
// there is the record-cadence integration (or the plain Newton
// without a rescue ladder), NOT the reduction.
let bases: Vec<(String, DMatrix<f64>)> = if std::env::var("RTX_REPLAY_IDENTITY").is_ok() {
vec![("identity".to_string(), DMatrix::identity(n_free, n_free))]
} else {
[12usize, 20]
.iter()
.map(|&m| (format!("m={m}"), basis_full.columns(0, m).into_owned()))
.collect()
};
for (label, v) in bases {
let modes = &label;
// Projection floor over the whole recorded trajectory, same
// normalization as the tracking metric.
let mut proj_sum = 0.0;
for r in &records {
let d = to_free(&r.displacement);
let err = (&d - &v * (v.transpose() * &d)).norm();
proj_sum += err * err;
}
let proj_rms = (proj_sum / records.len() as f64).sqrt() / scale;
let model = ReducedNonlinearModel::new_formulated(
&mesh,
&materials,
&dof_numbering,
v.clone(),
Formulation::TotalLagrangian,
)
.unwrap();
let newmark = ReducedNewmark::new(&model, dt_rec).unwrap();
// Initial state: the first record, projected.
let mut state = rtx_fea::mor::ReducedState {
q: v.transpose() * to_free(&records[0].displacement),
q_dot: v.transpose() * to_free(&records[0].velocity),
q_ddot: v.transpose() * to_free(&records[0].acceleration),
};
let mut sum_sq_release = 0.0f64;
let mut n_release = 0usize;
let mut sum_sq_cycle = 0.0f64;
let mut max_cycle = 0.0f64;
let mut n_cycle = 0usize;
let mut total_iterations = 0usize;
let mut died_at: Option<f64> = None;
let start = std::time::Instant::now();
for k in 0..records.len() - 1 {
let external = load_to_free(&records[k + 1].loads);
match newmark.step(&state, &external) {
Ok((next, iterations)) => {
state = next;
total_iterations += iterations;
}
Err(e) => {
died_at = Some(records[k + 1].t);
println!(
" {modes}: reduced Newton DIED at t = {:.4} (step {k} of {}): {e:?}",
records[k + 1].t,
records.len() - 1
);
break;
}
}
let err = (to_free(&records[k + 1].displacement) - model.expand(&state.q)).norm();
if records[k + 1].t < 6.0 {
sum_sq_release += err * err;
n_release += 1;
} else {
sum_sq_cycle += err * err;
max_cycle = max_cycle.max(err);
n_cycle += 1;
}
}
let steps_done = n_release + n_cycle;
let per_step = start.elapsed().as_secs_f64() / steps_done.max(1) as f64;
println!(
" {modes}: {steps_done} steps, {:.2} Newton iters/step, {:.0} us/step \
(full-order structural: 7200 us/pass -> {:.1}x)",
total_iterations as f64 / steps_done.max(1) as f64,
per_step * 1e6,
7.2e-3 / per_step
);
println!(
" tracking rms: release {:.3e}, cycle {:.3e} (max {:.3e}); projection floor \
{:.3e} (of max |d| {scale:.3e} m)",
(sum_sq_release / n_release.max(1) as f64).sqrt() / scale,
(sum_sq_cycle / n_cycle.max(1) as f64).sqrt() / scale,
max_cycle / scale,
proj_rms
);
if let Some(t) = died_at {
println!(" DIED at t = {t:.4} — recorded as a phase-4 finding");
}
}
}