Skip to content

Commit 15da603

Browse files
fix(esn): scale by true spectral radius, not infinity-norm (obligation 1.1) (#101)
## What `esn::scale_to_spectral_radius` scaled the recurrent matrix by its **max absolute row sum** (the matrix ∞-norm), which only *bounds* the spectral radius from above (ρ(W) ≤ ‖W‖∞). So the configured `spectral_radius` (e.g. `0.9`) was **never the value actually realised** — the true ρ was some unknown smaller number. This is the genuine correctness bug behind obligation **1.1** (Echo State Property). ## Fix - Add `estimate_spectral_radius()` — **power iteration** (deterministic non-degenerate start vector, convergence-checked, nilpotent/zero-matrix guards). This estimates |λ_max|, the quantity that actually governs the Echo State Property. - `scale_to_spectral_radius()` now scales by the estimated spectral radius, so the configured value is achieved. ## Tests (added) - `test_spectral_radius_estimate_diagonal` — diagonal matrix ρ = max|diag| (exact for power iteration). - `test_scale_to_spectral_radius_achieves_target` — after scaling, the **re-estimated ρ = 0.9**, proving the target is realised (not merely bounded). - Full suite green: `cargo test -p esn` → 11 unit + 6 integration pass. ## Notes - Branch is off `main`; clean (no licence churn — that's tracked separately). - This is the first proof-obligation made real from the **#84** map. Closes neurophone **#88**. Closes #88 https://claude.ai/code/session_01Gu1JFCZHuBtBhAWPr4sMQw --- _Generated by [Claude Code](https://claude.ai/code/session_01Gu1JFCZHuBtBhAWPr4sMQw)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f215a5 commit 15da603

1 file changed

Lines changed: 71 additions & 10 deletions

File tree

crates/esn/src/lib.rs

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,54 @@ impl EchoStateNetwork {
175175
weights * scale
176176
}
177177

178-
/// Scale matrix to achieve target spectral radius
178+
/// Estimate the spectral radius (largest eigenvalue magnitude) of a square
179+
/// matrix via power iteration.
180+
///
181+
/// This is the quantity that actually governs the Echo State Property. The
182+
/// previous implementation used the matrix infinity-norm (max absolute row
183+
/// sum), which only *bounds* the spectral radius from above — so the
184+
/// configured `spectral_radius` was never the value truly realised.
185+
fn estimate_spectral_radius(weights: &Array2<f32>) -> f32 {
186+
let n = weights.nrows();
187+
if n == 0 || weights.ncols() != n {
188+
return 0.0;
189+
}
190+
191+
// Deterministic, non-degenerate start vector (a constant vector can be
192+
// orthogonal to the dominant eigenvector and stall the iteration).
193+
let mut v: Array1<f32> = Array1::from_shape_fn(n, |i| ((i % 7) as f32) + 1.0);
194+
let norm = v.dot(&v).sqrt();
195+
if norm == 0.0 {
196+
return 0.0;
197+
}
198+
v /= norm;
199+
200+
let mut lambda = 0.0f32;
201+
for _ in 0..1000 {
202+
let w = weights.dot(&v);
203+
let w_norm = w.dot(&w).sqrt();
204+
if w_norm == 0.0 {
205+
return 0.0; // nilpotent / zero matrix
206+
}
207+
// With ||v|| == 1, the growth factor ||Wv|| estimates |λ_max|.
208+
let next = w_norm;
209+
v = w / w_norm;
210+
if (next - lambda).abs() <= 1e-6 * next.max(1.0) {
211+
return next;
212+
}
213+
lambda = next;
214+
}
215+
lambda
216+
}
217+
218+
/// Scale a matrix so that its spectral radius equals `target_radius`.
219+
///
220+
/// Scales by the true (power-iteration-estimated) spectral radius, so the
221+
/// configured `spectral_radius` is the value actually achieved.
179222
fn scale_to_spectral_radius(weights: &Array2<f32>, target_radius: f32) -> Array2<f32> {
180-
// Simplified: compute maximum absolute row sum as approximation
181-
let max_row_sum = weights
182-
.rows()
183-
.into_iter()
184-
.map(|row| row.iter().map(|x| x.abs()).sum::<f32>())
185-
.fold(0.0f32, f32::max);
186-
187-
if max_row_sum > 0.0 {
188-
weights * (target_radius / max_row_sum)
223+
let rho = Self::estimate_spectral_radius(weights);
224+
if rho > 0.0 {
225+
weights * (target_radius / rho)
189226
} else {
190227
weights.clone()
191228
}
@@ -305,6 +342,30 @@ mod tests {
305342
assert!(EchoStateNetwork::new(config).is_err());
306343
}
307344

345+
#[test]
346+
fn test_spectral_radius_estimate_diagonal() {
347+
// Diagonal matrix: spectral radius = max |diagonal entry|.
348+
let mut w = Array2::<f32>::zeros((3, 3));
349+
w[[0, 0]] = 0.5;
350+
w[[1, 1]] = -0.3;
351+
w[[2, 2]] = 0.1;
352+
let rho = EchoStateNetwork::estimate_spectral_radius(&w);
353+
assert!((rho - 0.5).abs() < 1e-3, "expected ~0.5, got {rho}");
354+
}
355+
356+
#[test]
357+
fn test_scale_to_spectral_radius_achieves_target() {
358+
// After scaling, the *realised* spectral radius must equal the target,
359+
// not merely be bounded by it (the old infinity-norm behaviour).
360+
let mut w = Array2::<f32>::zeros((3, 3));
361+
w[[0, 0]] = 2.0;
362+
w[[1, 1]] = -1.0;
363+
w[[2, 2]] = 0.5;
364+
let scaled = EchoStateNetwork::scale_to_spectral_radius(&w, 0.9);
365+
let rho = EchoStateNetwork::estimate_spectral_radius(&scaled);
366+
assert!((rho - 0.9).abs() < 1e-3, "expected spectral radius 0.9, got {rho}");
367+
}
368+
308369
#[test]
309370
fn test_esn_step() {
310371
let config = EsnConfig {

0 commit comments

Comments
 (0)