forked from lance-format/lance-graph
-
Notifications
You must be signed in to change notification settings - Fork 0
probe: family-basin Weyl multi-hop is hop-local at the crisp tier (reinstates hop-bounds-reach, tier-scoped) #551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
260 changes: 260 additions & 0 deletions
260
crates/perturbation-sim/examples/family_basin_weyl_multihop.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,260 @@ | ||
| //! PROBE — family-basin partitioning recovers hop-locality on the Weyl axis | ||
| //! (the reinstatement test for `E-OUTAGE-CASCADE-IS-NON-LOCAL`). | ||
| //! | ||
| //! Claim (operator): the un-partitioned DC cascade is non-local (measured), but | ||
| //! **with family basins** the perturbation math becomes hop-local — a rank-1 | ||
| //! trip inside a basin localizes to that block (Davis-Kahan), and only crosses | ||
| //! to the next basin at the weak seam. So "multi-hop Weyl" = chained per-block | ||
| //! first-hop Weyl, gated at the seams. | ||
| //! | ||
| //! Why the earlier outage probe found non-locality: it seeded the **max-flow | ||
| //! line** — which is the *seam* (the bottleneck carrying cross-basin flow). A | ||
| //! seam trip leaks globally by construction. This probe separates the two cases. | ||
| //! | ||
| //! Measurement (real ES grid, the old PyPSA data), swept over **every HHTL tier** | ||
| //! (HEEL top split → HIP → LEAF): | ||
| //! 1. `hhtl_keys` → the family-basin partition at that tier. | ||
| //! 2. CONTAINMENT — inject a balanced dipole, DC-solve θ = L⁺p (the | ||
| //! effective-resistance response = the perturbation shape), and measure the | ||
| //! fraction of response energy Σθ² that stays in the seed's basin: | ||
| //! - WITHIN-basin dipoles → block-localized (high) ⇒ hop-local; | ||
| //! - SEAM-straddling dipoles → leaks across (the hop boundary). | ||
| //! 3. WEYL — for a within-basin line trip, confirm `weyl_satisfied`. | ||
| //! | ||
| //! Verdict gate (honest, no rubber-stamp): a tier is HOP-LOCAL only if within-basin | ||
| //! containment ≥ 0.70 (block-tight) AND within/seam ratio ≥ 1.30 (a real margin) | ||
| //! AND Weyl satisfied. No "2×null" (the null ≈ 0.5 for coarse tiers and breaks the | ||
| //! test) and no Davis-Kahan bound as evidence (it is vacuous when the eigengap is | ||
| //! tiny). The ratio, not the absolute, is the hop-boundary test. | ||
| //! | ||
| //! Run: cargo run --manifest-path crates/perturbation-sim/Cargo.toml \ | ||
| //! --example family_basin_weyl_multihop -- /tmp/pypsa/buses.csv /tmp/pypsa/lines.csv ES | ||
|
|
||
| use perturbation_sim::{ | ||
| from_pypsa_csv, hhtl_keys, spectral_perturbation, symmetric_eigen, Grid, HhtlKey, | ||
| }; | ||
| use std::collections::BTreeMap; | ||
| use std::fs; | ||
|
|
||
| const MIN_BASIN: usize = 6; // basins smaller than this are too tiny for a meaningful dipole | ||
| const REL_TOL: f64 = 1e-9; | ||
|
|
||
| type Basin = (u16, u16, u16); | ||
| /// A tier partitioner: a node's HHTL key → its basin at one HHTL tier. | ||
| type TierPart = fn(HhtlKey) -> Basin; | ||
|
|
||
| /// Fraction of response energy `Σθ²` carried by `members`. | ||
| fn containment(theta: &[f64], members: &[usize]) -> f64 { | ||
| let total: f64 = theta.iter().map(|x| x * x).sum(); | ||
| if total <= 0.0 { | ||
| return 0.0; | ||
| } | ||
| let inb: f64 = members.iter().map(|&i| theta[i] * theta[i]).sum(); | ||
| inb / total | ||
| } | ||
|
|
||
| /// Balanced ±1 dipole between `s` and `t`. | ||
| fn dipole(n: usize, s: usize, t: usize) -> Vec<f64> { | ||
| let mut p = vec![0.0; n]; | ||
| p[s] = 1.0; | ||
| p[t] = -1.0; | ||
| p | ||
| } | ||
|
|
||
| /// The honest HOP-LOCAL gate (single source, used by `main` and tested): | ||
| /// block-tight within-basin containment (`wm ≥ 0.70`) AND a real within/seam | ||
| /// margin (`ratio ≥ 1.30`) AND a valid within-basin Weyl check — **and the | ||
| /// evidence must actually exist** (`has_within && has_straddle`). Missing | ||
| /// evidence ⇒ NOT supported (no ∞-ratio or defaulted-true Weyl false positives). | ||
| /// No "2×null" gate (the null ≈ 0.5 for coarse tiers and breaks the test); no | ||
| /// Davis-Kahan bound (it is vacuous when the eigengap is tiny). | ||
| fn tier_supported( | ||
| has_within: bool, | ||
| has_straddle: bool, | ||
| wm: f64, | ||
| ratio: f64, | ||
| weyl_ok: bool, | ||
| ) -> bool { | ||
| has_within && has_straddle && wm >= 0.70 && ratio >= 1.30 && weyl_ok | ||
| } | ||
|
|
||
| fn main() { | ||
| let args: Vec<String> = std::env::args().collect(); | ||
| let (bpath, lpath, country) = ( | ||
| args.get(1) | ||
| .map(String::as_str) | ||
| .unwrap_or("/tmp/pypsa/buses.csv"), | ||
| args.get(2) | ||
| .map(String::as_str) | ||
| .unwrap_or("/tmp/pypsa/lines.csv"), | ||
| args.get(3).map(String::as_str).unwrap_or("ES"), | ||
| ); | ||
| let buses = fs::read_to_string(bpath).expect("read buses.csv"); | ||
| let lines = fs::read_to_string(lpath).expect("read lines.csv"); | ||
| let import = from_pypsa_csv(&buses, &lines, Some(country)) | ||
| .expect("parse pypsa") | ||
| .largest_component(); | ||
| let grid: &Grid = &import.grid; | ||
| let (n, m) = (grid.n, grid.edges.len()); | ||
|
|
||
| // Family-basin partition (per tier) + eigendecomposition (once). | ||
| let keys = hhtl_keys(grid); | ||
| let eig = symmetric_eigen(&grid.laplacian_of(&vec![true; m]), n); | ||
| let mut deg = vec![0usize; n]; | ||
| for e in &grid.edges { | ||
| deg[e.from] += 1; | ||
| deg[e.to] += 1; | ||
| } | ||
|
|
||
| println!("PROBE — family-basin Weyl multi-hop (reinstatement test, real {country} grid)"); | ||
| println!(" grid: {n} buses, {m} lines"); | ||
| println!( | ||
| " containment = fraction of L⁺ dipole-response energy in the seed's basin.\n \ | ||
| hop-local ⇔ WITHIN-basin dipoles contained, SEAM-straddle dipoles leak. Per HHTL tier:\n" | ||
| ); | ||
|
|
||
| // Tier partitioners: HEEL (top crisp split), HIP (+ the 2-line seam), LEAF (finest). | ||
| let tiers: [(&str, TierPart); 3] = [ | ||
| ("HEEL", |k| (k.heel, 0, 0)), | ||
| ("HIP", |k| (k.heel, k.hip, 0)), | ||
| ("LEAF", |k| (k.heel, k.hip, k.twig)), | ||
| ]; | ||
|
|
||
| let mean = |v: &[f64]| { | ||
| if v.is_empty() { | ||
| 0.0 | ||
| } else { | ||
| v.iter().sum::<f64>() / v.len() as f64 | ||
| } | ||
| }; | ||
| let alive = vec![true; m]; | ||
|
|
||
| println!( | ||
| " {:<5} {:>6} {:>10} {:>10} {:>10} {:>8} verdict", | ||
| "tier", "basins", "within", "seam", "ratio", "weyl" | ||
| ); | ||
|
|
||
| let mut any_tier_supported = false; | ||
| for (name, part) in tiers { | ||
| let mut basins: BTreeMap<Basin, Vec<usize>> = BTreeMap::new(); | ||
| for (i, k) in keys.iter().enumerate() { | ||
| basins.entry(part(*k)).or_default().push(i); | ||
| } | ||
|
|
||
| // WITHIN-basin containment. | ||
| let mut within: Vec<f64> = Vec::new(); | ||
| for members in basins.values() { | ||
| if members.len() < MIN_BASIN { | ||
| continue; | ||
| } | ||
| let mut by_deg = members.clone(); | ||
| by_deg.sort_by_key(|&i| std::cmp::Reverse(deg[i])); | ||
| let theta = eig.pseudo_apply(&dipole(n, by_deg[0], by_deg[1]), REL_TOL); | ||
| within.push(containment(&theta, members)); | ||
| } | ||
| // SEAM-straddle containment (dipole across an inter-basin edge). | ||
| let mut straddle: Vec<f64> = Vec::new(); | ||
| for e in &grid.edges { | ||
| let (ba, bb) = (part(keys[e.from]), part(keys[e.to])); | ||
| if ba == bb { | ||
| continue; | ||
| } | ||
| let (ma, mb) = (&basins[&ba], &basins[&bb]); | ||
| if ma.len() < MIN_BASIN || mb.len() < MIN_BASIN { | ||
| continue; | ||
| } | ||
| let theta = eig.pseudo_apply(&dipole(n, e.from, e.to), REL_TOL); | ||
| straddle.push(containment(&theta, ma).max(containment(&theta, mb))); | ||
| } | ||
| // Weyl on a within-basin line trip at this tier. No within-basin line ⇒ | ||
| // `false` (no valid check ⇒ not supported), NOT a defaulted `true`. | ||
| let weyl_ok = grid | ||
| .edges | ||
| .iter() | ||
| .position(|e| part(keys[e.from]) == part(keys[e.to])) | ||
| .map(|line| spectral_perturbation(grid, &alive, line).weyl_satisfied) | ||
| .unwrap_or(false); | ||
|
|
||
| let (has_within, has_straddle) = (!within.is_empty(), !straddle.is_empty()); | ||
| let (wm, sm) = (mean(&within), mean(&straddle)); | ||
| // ratio = 0 (not ∞) when there are no seam samples — a missing seam | ||
| // comparison must never pass the margin trivially. | ||
| let ratio = if has_straddle && sm > 0.0 { | ||
| wm / sm | ||
| } else { | ||
| 0.0 | ||
| }; | ||
| let supported = tier_supported(has_within, has_straddle, wm, ratio, weyl_ok); | ||
| any_tier_supported |= supported; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| println!( | ||
| " {name:<5} {:>6} {wm:>10.3} {sm:>10.3} {ratio:>10.2} {:>8} {}", | ||
| basins.values().filter(|m| m.len() >= MIN_BASIN).count(), | ||
| weyl_ok, | ||
| if supported { "HOP-LOCAL" } else { "leaks" } | ||
| ); | ||
| } | ||
|
|
||
| println!("\n VERDICT:"); | ||
| if any_tier_supported { | ||
| println!( | ||
| " [SUPPORTED at the crisp tier(s)] where the bisection is stable, a within-basin \ | ||
| perturbation stays block-contained (≥0.70) and clearly beats the seam-straddle \ | ||
| (ratio ≥1.30) with Weyl satisfied → multi-hop Weyl IS hop-local there. REINSTATES \ | ||
| 'hop bounds reach' on the eigenvalue axis — at the tier where the family-basin block \ | ||
| structure is real. Finer tiers leak (loose coupling), exactly as the out-of-family \ | ||
| tie growth (0→2→9→20) and the marginal deep-tier DK gaps predict." | ||
| ); | ||
| } else { | ||
| println!( | ||
| " [NOT SUPPORTED on this grid] no tier shows block-tight containment (≥0.70) with a \ | ||
| ≥1.30 within/seam ratio. Family basins concentrate the perturbation above random, but \ | ||
| the blocks leak too much to call the seam a hop boundary on real ES — report honestly, \ | ||
| do not promote. The earlier outage non-locality finding stands unrefined." | ||
| ); | ||
| } | ||
| println!( | ||
| " NOTE: containment is the L⁺ (effective-resistance) response energy; a high WITHIN vs \ | ||
| low SEAM ratio is the block-localization signature (Cheeger/spectral-clustering). The \ | ||
| ratio, not the absolute, is the hop-boundary test." | ||
| ); | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn containment_basic_and_edges() { | ||
| // All energy on members → 1.0. | ||
| assert!((containment(&[3.0, 4.0, 0.0], &[0, 1]) - 1.0).abs() < 1e-12); | ||
| // θ=[3,4] → energies 9,16; members={0} → 9/25. | ||
| assert!((containment(&[3.0, 4.0], &[0]) - 9.0 / 25.0).abs() < 1e-12); | ||
| // Zero field → 0.0, never NaN. | ||
| assert_eq!(containment(&[0.0, 0.0], &[0]), 0.0); | ||
| // Empty members → 0.0. | ||
| assert_eq!(containment(&[1.0, 2.0], &[]), 0.0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn dipole_is_balanced() { | ||
| let p = dipole(5, 1, 3); | ||
| assert_eq!(p, vec![0.0, 1.0, 0.0, -1.0, 0.0]); | ||
| assert!(p.iter().sum::<f64>().abs() < 1e-12); // Σp = 0 | ||
| } | ||
|
|
||
| #[test] | ||
| fn gate_requires_evidence_not_just_thresholds() { | ||
| // Strong, complete evidence → supported. | ||
| assert!(tier_supported(true, true, 0.96, 1.54, true)); | ||
| // Missing seam samples (ratio would have been ∞) → NOT supported. | ||
| assert!(!tier_supported(true, false, 0.96, 0.0, true)); | ||
| // Missing within-basin line / Weyl false → NOT supported. | ||
| assert!(!tier_supported(true, true, 0.96, 1.54, false)); | ||
| // No within evidence → NOT supported. | ||
| assert!(!tier_supported(false, true, 0.96, 1.54, true)); | ||
| // Thin margin (the leaky tiers) → NOT supported. | ||
| assert!(!tier_supported(true, true, 0.58, 0.93, true)); | ||
| // Block-tight but margin just under 1.30 → NOT supported. | ||
| assert!(!tier_supported(true, true, 0.95, 1.29, true)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.