rtx-fsi: ECSW phase-2 offline study — POD subspace confirmed, hyperreduction refuted at this resolution
CI / Clippy Check (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
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
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s

fsi3_ecsw_offline.rs (env-gated on RTX_ECSW_SNAP; committed default is
a no-op) measures the three pre-registered quantities on the harvested
run-7 flag trajectory, with a numbering self-check (clamped-DOF
displacement exactly 0.0 across all 3,294 snapshots).

Measured: (1) the flapping manifold compresses 560 free DOFs to 12-20
POD modes at ~0.5% held-out rms projection error — the subspace
exists. (2) ECSW has NOTHING TO EXPLOIT on the 70-element flag: the
NNLS residual sits at single-element scale until nearly every element
joins (31 el -> 10%, 51 -> 10%, 67 -> 2.5%, 70 -> 1e-15; held-out ==
training everywhere; cycle-only manifold identical) — each macroscopic
Quad8 carries non-redundant virtual work, so no sub-percent sample
smaller than the mesh exists. (3) full reduced assembly 548 us/eval;
ECSW at best 1.4x at a useless 10% residual.

ReducedNonlinearModel gains assemble_reduced_force (the measured
quantity). The campaign's >=10x structural gate cannot come from
hyperreduction at the validated resolution — recorded in the campaign
doc with the re-scope options.

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 15:06:33 -05:00
co-authored by Claude Fable 5
parent d9d8801f1a
commit 50e382c046
2 changed files with 298 additions and 0 deletions
@@ -0,0 +1,276 @@
//! ECSW×FSI campaign, phase 2: the offline POD/ECSW measurements on the
//! harvested FSI3 flag trajectory (omni-cortex `next_session.md`, the
//! campaign section; snapshots from the phase-1 `FSNP` dump).
//!
//! Three measurements, pre-registered in the campaign doc — deliberately
//! NOT a static-solve-vs-dynamic-state comparison (the recorded
//! trajectory is dynamic; the dynamic reduced Newmark is phase 4):
//!
//! 1. **POD projection error vs mode count** — does the flapping flag's
//! solution manifold live in a small subspace? (The campaign's
//! load-bearing question.)
//! 2. **ECSW training + HELD-OUT residual** (total-Lagrangian operators,
//! matching the march's `with_total_lagrangian`) — does a small
//! element sample reproduce the reduced internal force on states it
//! never trained on?
//! 3. **Assembly wall-clock, full-active vs sampled** — the
//! per-Newton-iteration cost ECSW actually reduces.
//!
//! Env-gated study: set `RTX_ECSW_SNAP=<path to .fsnp>`; without it the
//! test prints a skip note and passes (committed default is a no-op).
mod fsi2_harness;
use fsi2_harness::{FLAG_X0, FSI3, flag_mesh};
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::mor::{
Formulation, 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>,
}
fn read_fsnp(path: &str) -> (usize, Vec<Snapshot>) {
let data = std::fs::read(path).expect("snapshot file");
assert_eq!(&data[0..4], b"FSNP", "bad magic");
assert_eq!(u32::from_le_bytes(data[4..8].try_into().unwrap()), 1);
let n_dofs = u64::from_le_bytes(data[8..16].try_into().unwrap()) as usize;
let mut off = 16usize;
let mut records = Vec::new();
let f64_at = |data: &[u8], off: usize| -> f64 {
f64::from_le_bytes(data[off..off + 8].try_into().unwrap())
};
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 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 });
}
assert_eq!(off, data.len(), "trailing bytes");
(n_dofs, records)
}
/// The numbering the flag's analysis uses internally, rebuilt identically
/// (same strategy, same clamp criterion as `clamp_left`), so full-DOF
/// indices in the dump line up.
fn numbering(mesh: &rtx_fea::mesh::Mesh) -> AdvancedDofNumbering {
let mut dof_numbering =
AdvancedDofNumbering::displacement_only(mesh, DofMappingStrategy::Sequential).unwrap();
for (&node_id, node) in &mesh.nodes {
if (node.position().x - FLAG_X0).abs() < 1e-9 {
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
let dof = dof_numbering.get_dof(node_id, component).unwrap();
dof_numbering.constrain_dof(dof).unwrap();
}
}
}
dof_numbering
}
#[test]
fn fsi3_ecsw_offline_study() {
let Ok(snap_path) = std::env::var("RTX_ECSW_SNAP") else {
println!(" RTX_ECSW_SNAP not set — offline ECSW study skipped");
return;
};
let (n_dofs, records) = read_fsnp(&snap_path);
println!(
" {} snapshots, {} full DOFs, t in [{:.4}, {:.4}]",
records.len(),
n_dofs,
records.first().unwrap().t,
records.last().unwrap().t
);
// The flag exactly as the march builds it (FSI3 defaults).
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");
// Numbering self-check: the dump's clamped DOFs must be exactly zero
// at every snapshot — if the rebuilt numbering disagreed with the
// analysis's internal one, real displacements would land on
// "constrained" indices and this fails loudly.
let mut is_free = vec![false; dof_numbering.total_dofs];
for &dof in &dof_numbering.free_dofs {
is_free[dof] = true;
}
let worst_clamped = records
.iter()
.flat_map(|r| {
r.displacement
.iter()
.enumerate()
.filter(|(dof, _)| !is_free[*dof])
.map(|(_, v)| v.abs())
})
.fold(0.0f64, f64::max);
println!(" numbering self-check: worst clamped-DOF displacement {worst_clamped:.2e}");
assert!(
worst_clamped < 1e-14,
"clamped DOFs carry displacement {worst_clamped:.2e} — numbering mismatch"
);
let to_free = |full: &[f64]| -> DVector<f64> {
DVector::from_iterator(n_free, dof_numbering.free_dofs.iter().map(|&dof| full[dof]))
};
// Interleaved split so both halves span release AND settled cycle.
let train: Vec<DVector<f64>> = records
.iter()
.step_by(2)
.map(|r| to_free(&r.displacement))
.collect();
let held_out: Vec<DVector<f64>> = records
.iter()
.skip(1)
.step_by(2)
.map(|r| to_free(&r.displacement))
.collect();
// ---- Measurement 1: POD projection error vs mode count. ----
let basis_full = pod_basis(&train, 1e-14).unwrap();
println!(
" POD basis: {} modes at energy tolerance 1e-14 (of {n_free} free DOFs)",
basis_full.ncols()
);
let proj_errors = |basis: &DMatrix<f64>, set: &[DVector<f64>]| -> (f64, f64) {
let mut max = 0.0f64;
let mut sum_sq = 0.0f64;
for d in set {
let q = basis.transpose() * d;
let err = (d - basis * q).norm() / d.norm().max(1e-30);
max = max.max(err);
sum_sq += err * err;
}
(max, (sum_sq / set.len() as f64).sqrt())
};
println!(" modes | train max / rms | held-out max / rms");
for m in [2usize, 4, 6, 8, 12, 16, 20, 30] {
if m > basis_full.ncols() {
break;
}
let v = basis_full.columns(0, m).into_owned();
let (tr_max, tr_rms) = proj_errors(&v, &train);
let (ho_max, ho_rms) = proj_errors(&v, &held_out);
println!(" {m:5} | {tr_max:.3e} / {tr_rms:.3e} | {ho_max:.3e} / {ho_rms:.3e}");
}
// ---- Measurement 2: ECSW training + held-out residual (TL). ----
// Thinned sets: the NNLS system is (snapshots x modes) rows.
let ecsw_held: Vec<DVector<f64>> = held_out.iter().step_by(4).cloned().collect();
// Cycle-only variant: restrict to the settled cycle (t > 6), the
// narrower manifold the coupled phase-4 model would actually live on.
let cycle_train: Vec<DVector<f64>> = records
.iter()
.filter(|r| r.t > 6.0)
.step_by(8)
.map(|r| to_free(&r.displacement))
.collect();
for (m, tol, thin) in [
(8usize, 1e-3f64, 4usize),
(8, 1e-2, 4),
(8, 3e-2, 4),
(8, 1e-1, 4),
(4, 1e-1, 4),
(8, 1e-2, 16),
(12, 1e-2, 4),
(8, 1e-2, 0), // thin = 0 marks the cycle-only training set
] {
let ecsw_train: Vec<DVector<f64>> = if thin == 0 {
cycle_train.clone()
} else {
train.iter().step_by(thin).cloned().collect()
};
let v = basis_full.columns(0, m).into_owned();
let model = train_ecsw_formulated(
&mesh,
&materials,
&dof_numbering,
&v,
&ecsw_train,
tol,
Formulation::TotalLagrangian,
)
.unwrap();
let held = ecsw_residual(
&mesh,
&materials,
&dof_numbering,
&v,
&ecsw_held,
&model,
Formulation::TotalLagrangian,
)
.unwrap();
println!(
" ECSW m={m} tol={tol:.0e} thin={thin} ({} train states): {} of {} elements, \
training residual {:.3e}, held-out residual {held:.3e}",
ecsw_train.len(),
model.weights.len(),
mesh.num_elements(),
model.training_residual
);
// ---- Measurement 3: assembly wall-clock, full vs sampled. ----
let full_model = ReducedNonlinearModel::new_formulated(
&mesh,
&materials,
&dof_numbering,
v.clone(),
Formulation::TotalLagrangian,
)
.unwrap();
let sampled_model = ReducedNonlinearModel::new_formulated(
&mesh,
&materials,
&dof_numbering,
v.clone(),
Formulation::TotalLagrangian,
)
.unwrap()
.with_ecsw(&model);
let time_per_eval = |model: &ReducedNonlinearModel, label: &str| -> f64 {
let states = &ecsw_held;
// Warm-up pass, then best of 3 timed sweeps.
for d in states.iter().take(8) {
let _ = model.assemble_reduced_force(d).unwrap();
}
let mut best = f64::INFINITY;
for _ in 0..3 {
let start = std::time::Instant::now();
for d in states {
let _ = model.assemble_reduced_force(d).unwrap();
}
best = best.min(start.elapsed().as_secs_f64() / states.len() as f64);
}
println!(
" {label}: {:.1} us/assembly over {} states ({} elements)",
best * 1e6,
states.len(),
model.active_elements()
);
best
};
let t_full = time_per_eval(&full_model, "full-active");
let t_sampled = time_per_eval(&sampled_model, "ECSW-sampled");
println!(" assembly speedup {:.1}x", t_full / t_sampled);
}
}