Skip to content

Commit ebd2468

Browse files
committed
cascade: NaN-abort preserves last finite state, never returns NaN shape (codex #554 P2)
The previous guard broke the loop on a non-finite theta/flow, but the shape was then built from those SAME non-finite values (node_field/edge_field at the build step) — so the "abort" still leaked NaN/Inf into the perturbation shape and downstream rankings/stats. Fix: seed theta/flow with the finite base state and overwrite them each round ONLY after the new values are confirmed finite (theta_next/flow_next). A non-finite round now breaks WITHOUT adopting it, so theta/flow/components_final retain the last finite state and the shape is built from that — never from NaN/Inf. Matches the islanding-break semantics (which already left a finite proxy). New test perturbation_shape_is_always_finite locks the invariant across all terminal paths (converge / cascade / island); islanding_is_flagged also now asserts finite shape. clippy --all-targets clean; cascade suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcpLeEC3XK8Eye53GKBVvi
1 parent 657f0fb commit ebd2468

1 file changed

Lines changed: 89 additions & 10 deletions

File tree

crates/perturbation-sim/src/cascade.rs

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,26 +119,36 @@ pub fn simulate_outage(
119119

120120
// Assigned on every loop iteration before any break (the loop body always
121121
// runs at least once), so no initializer is needed.
122-
let mut theta: Vec<f64>;
123-
let mut flow: Vec<f64>;
122+
// Seed `theta`/`flow` with the finite base state and overwrite them each
123+
// round ONLY after the new values are confirmed finite — so they always
124+
// hold the last finite state. If a round goes non-finite we break without
125+
// adopting it, and the shape below is built from that last-good state,
126+
// never from NaN/Inf (codex #554 P2).
127+
let mut theta: Vec<f64> = theta_base.clone();
128+
let mut flow: Vec<f64> = flow_base.clone();
124129
let mut islanded = false;
125-
let mut components_final: usize;
130+
let mut components_final: usize = 1;
126131
let mut rounds = 0usize;
127132

128133
loop {
129134
rounds += 1;
130135
let eig = symmetric_eigen(&grid.laplacian_of(&alive), n);
131-
components_final = eig.nullity(cfg.rel_tol);
136+
let nullity = eig.nullity(cfg.rel_tol);
132137

133-
theta = eig.pseudo_apply(p, cfg.rel_tol);
134-
flow = dc_flows(grid, &alive, &theta);
138+
let theta_next = eig.pseudo_apply(p, cfg.rel_tol);
139+
let flow_next = dc_flows(grid, &alive, &theta_next);
135140

136-
// NaN guard: a non-finite angle/flow signals numerical failure; treat
137-
// as terminal (matching the islanding-break style) so NaN cannot
138-
// propagate into the output shape or keep the loop alive.
139-
if theta.iter().any(|x| !x.is_finite()) || flow.iter().any(|x| !x.is_finite()) {
141+
// NaN guard: a non-finite angle/flow signals numerical failure (solver
142+
// breakdown, overflow). Break WITHOUT adopting the bad round — `theta`/
143+
// `flow`/`components_final` retain the last finite state, so the
144+
// perturbation shape can never carry NaN/Inf into node_field/edge_field
145+
// or downstream rankings/statistics (codex #554 P2).
146+
if theta_next.iter().any(|x| !x.is_finite()) || flow_next.iter().any(|x| !x.is_finite()) {
140147
break;
141148
}
149+
theta = theta_next;
150+
flow = flow_next;
151+
components_final = nullity;
142152

143153
if components_final > 1 {
144154
// Network fragmented: injections no longer balance per island, so
@@ -293,5 +303,74 @@ mod tests {
293303
let r = simulate_outage(&g, &p, 6, CascadeConfig::default());
294304
assert!(r.islanded, "bridge trip must island the network");
295305
assert_eq!(r.components_final, 2);
306+
// The islanding shape (least-norm proxy) must still be finite.
307+
assert!(r.shape.node_field.iter().all(|x| x.is_finite()));
308+
assert!(r.shape.edge_field.iter().all(|x| x.is_finite()));
309+
}
310+
311+
#[test]
312+
fn perturbation_shape_is_always_finite() {
313+
// codex #554 P2: the perturbation shape must NEVER carry NaN/Inf — the
314+
// NaN guard preserves the last finite theta/flow, so node_field /
315+
// edge_field stay finite across every terminal path (converge, cascade,
316+
// island, max_rounds). Locks the invariant the fix guarantees.
317+
let cases: [(Grid, Vec<f64>, usize); 3] = [
318+
// generous limits: only the seed trips (converge path).
319+
(
320+
Grid::new(
321+
4,
322+
vec![
323+
Edge::new(0, 1, 1.0, 1e6),
324+
Edge::new(1, 2, 1.0, 1e6),
325+
Edge::new(2, 3, 1.0, 1e6),
326+
Edge::new(3, 0, 1.0, 1e6),
327+
],
328+
),
329+
vec![1.0, 0.0, -1.0, 0.0],
330+
0,
331+
),
332+
// tight limits: multi-line cascade path.
333+
(
334+
Grid::new(
335+
4,
336+
vec![
337+
Edge::new(0, 1, 1.0, 0.6),
338+
Edge::new(1, 2, 1.0, 0.6),
339+
Edge::new(2, 3, 1.0, 0.6),
340+
Edge::new(3, 0, 1.0, 0.6),
341+
],
342+
),
343+
vec![1.0, 0.0, -1.0, 0.0],
344+
0,
345+
),
346+
// two triangles + bridge: islanding path.
347+
(
348+
Grid::new(
349+
6,
350+
vec![
351+
Edge::new(0, 1, 1.0, 1e6),
352+
Edge::new(1, 2, 1.0, 1e6),
353+
Edge::new(2, 0, 1.0, 1e6),
354+
Edge::new(3, 4, 1.0, 1e6),
355+
Edge::new(4, 5, 1.0, 1e6),
356+
Edge::new(5, 3, 1.0, 1e6),
357+
Edge::new(2, 3, 1.0, 1e6),
358+
],
359+
),
360+
vec![1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
361+
6,
362+
),
363+
];
364+
for (g, p, seed) in cases {
365+
let r = simulate_outage(&g, &p, seed, CascadeConfig::default());
366+
assert!(
367+
r.shape.node_field.iter().all(|x| x.is_finite()),
368+
"node_field carried a non-finite value"
369+
);
370+
assert!(
371+
r.shape.edge_field.iter().all(|x| x.is_finite()),
372+
"edge_field carried a non-finite value"
373+
);
374+
}
296375
}
297376
}

0 commit comments

Comments
 (0)