diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 988c071e..4cba12f0 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,9 @@ +## 2026-06-23 (cont.³⁹) — V3 (part_of:is_a) 8:8 tile for the electric grid + q2 V1/V2→V3 consumer-awareness + +**Main thread (Opus), operator-directed.** Operator: read q2 `OGAR_CONSUMER_INTEGRATION.md` + `V3_SOA_WIRING.md`, check which lance-graph/OGAR consumers need the V1/V2→V3 (`part_of:is_a`) bump, focus first on representing the Spain electric grid better with V3. **Access:** pygithub/api.github.com is org-app-gated (403) — but `raw.githubusercontent.com` + `GH_TOKEN` works (different host, dodges the gate); fetched both docs that way. **V3 insight:** each 16-bit HHTL tier is an 8:8 split — high byte = `part_of` (PLACE/where, mereology), low byte = `is_a` (TISSUE/what, taxonomy); high-byte chain prefix-routes containment, low-byte chain prefix-routes type; `EdgeBlock` in-family = `part_of` siblings = `connected_to`. V3 needs **no layout change** (interpretation of the locked 3×u16). **Consumer-awareness (reported):** lance-graph `canonical_node` (no 8:8 accessor — additive opportunity); my `cascade_key` #605 (V1/V2 spatial-only → bumped here); `contract::soa_graph`/`graph_render`; **live blocker** q2 `osint-bake/fma.rs` calls `NodeGuid::new_v2(...LEAF...)` — a 7-group API that does NOT exist in `canonical_node` (the doc flags it as `I-LEGACY-API-FEATURE-GATED`; V3 sidesteps it — 6-group-compatible). Did NOT touch that gate (other session's OGAR/contract surface) — flagged only. **Shipped (`perturbation-sim/src/cascade_key.rs`, additive to #605):** `IsaPath {class,kind,sub}` (the is_a low-byte chain) + `CascadeKeyV3 {heel,hip,twig}` (each tier `(place<<8)|tissue`) + `place_chain`/`tissue_chain`/`part_of_distance`/`is_a_distance`/`to_guid_tiers` + `cascade_keys_v3(grid, alive, &[IsaPath])` (place = 24-bit Morton spectral cell, 3 octets; tissue = is_a taxonomy). For the grid: `part_of` prefix = "which region blacked out", `is_a` prefix = "all generators/loads" — two orthogonal queries on ONE key (V1/V2 spatial-only couldn't). **+4 V3 tests (9 total green):** 8:8 packing, axis-independence, blackout part_of-locality + is_a separability (source vs sink distinct class bytes), isa-count guard. Example `spain_cascade.rs` extended with the V3 dual-query (source/sink roles read off the is_a byte). clippy `-D warnings` clean, fmt-clean. EPIPHANIES `E-V3-PART-OF-IS-A-TILE`. Rides a PR on jirak. +## 2026-06-23 (cont.³⁸) — electric-outage cascade wired onto the FULL 16-bit-per-tier spatial key (leaf/family/identity = HEEL/HIP/TWIG) — one key, six lenses + +**Main thread (Opus), operator-directed.** Operator: read q2 + OGAR spatial representation, then re-wire the Spain-blackout cascade with leaf-16/family-16/identity-16 perfectly aligned with the cascade — "proves location, math, learning, representation, substrate, thinking." Grounded on OGAR P0 canon (256×256 centroid tile / Morton / 3×4) + ndarray `guid-prefix-shape-routing.md` §4/§4b (the key selects the grid; deterministic-phase pyramid). *(pygithub for q2/OGAR was proxy-gated — Claude GitHub App not connected for the org, 403 — but OGAR's spatial canon is in-context + the ndarray spatial doc is local, which IS the representation source; q2 is the downstream renderer, not the encoding.)* **NEW `perturbation-sim/src/cascade_key.rs`:** `CascadeKey { family:u16, leaf:u16, identity:u16 }` — the OGAR **production form** the existing `hhtl.rs` explicitly defers ("binary-Cheeger fills only the low bit per tier, NOT that full encoding"). Each tier = a full 16-bit 256×256 centroid tile (two byte-axes, nibble-interleaved `splat::morton2`) built from the bus's `basin::spectral_embedding` position (electrical coords, "topology IS the key"); 3 tiers ⇒ 24-bit-per-axis Morton, coarse→fine = HEEL/HIP/TWIG. Methods: `from_spectral`, `to_guid_tiers` (the canonical (HEEL,HIP,TWIG) triple), `morton48` (packed SoA key), `shared_prefix_tiers`/`cascade_distance` (O(1) Morton-containment), `tile` (decode→spectral tile). `cascade_keys(grid, alive)` assigns all buses (min-max norm ⇒ prefix = quad-tree ancestry, the 4⁴ condition). **+5 tests + example `spain_cascade.rs`.** The six lenses PROVEN: location (tile decode), math (`cascade_distance` bus0-bus1=0 / bus0-bus11=3), representation+substrate (family/leaf/identity = HEEL/HIP/TWIG u16, morton48 bit-exact), **learning+thinking — the blackout epicentre is prefix-local: mean cascade-distance 1.000 ≪ 2.561 random baseline** (the footprint learns the basin tree; the cascade traverses the same key). Zero-dep, deterministic; `cargo test … cascade_key` 5/5, clippy `-D warnings` clean, my files fmt-clean (pre-existing `chaoda_surge_epicenter.rs` fmt drift left untouched — not my change). The Spain perturbation artifact extended additively, never deleted. EPIPHANIES `E-CASCADE-KEY-IS-THE-SPATIAL-ADDRESS`. Rides a PR on jirak. ## 2026-06-23 (cont.³⁷) — sealed the capstone OUT/IN-leg public surface + end-to-end mixed-trigger test **Main thread (Opus), self-directed ("what do you want").** Disk reality: ~11 GB free vs ~14-18 GB for the lance/datafusion build (ENOSPC'd twice) → S3-live is **disk-walled in this env**, not permission-gated; its home is the symbiont golden-image harness (already pulls lance-7). So took the feasible completion: made the shipped OUT/IN-leg drivers an actual crate surface. `lance-graph-supervisor/lib.rs` now re-exports `deliver_kanban_step`, `drive_mul_advance`, `drive_scheduled_tick`, `drive_version_tick`, `run_to_absorbing`, `KanbanRouteError` (were module-path-only; only `KanbanActor`/`KanbanMsg` were public) — the surface the live S3 consumer will `use` when it lands. **+1 test (15 total green):** `mixed_triggers_compose_on_one_owner_s2_gate_then_s3_ticks` — the capstone integration: the S2 MUL gate takes the first Rubicon step (Flow qualia → Planning→CognitiveWork) and S3 version ticks (`run_to_absorbing`) carry the rest to Commit, proving the two DIFFERENT triggers compose on ONE mailbox-as-owner (no panic, no spurious rejection, lands absorbing). clippy + fmt clean; light build. The actor-side capstone is now a sealed, consumable surface; only the disk-gated live wiring (S3 `versions()` source, S2 shader-driver loop) remains, to be done where the heavy build fits. Rides a PR on jirak. diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index b1b38b53..bd73ad2d 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -23,6 +23,46 @@ collision is cleared. Constant-following consumers (the `ReadMode::FMA` registry inherit the fix with no change. Tests updated to assert `0x0A01` / Anatomy and to forbid the `0x0901` alias. Cross-ref: OGAR PR #117 (the Anatomy mint + audit), q2 PR #50 (independent `[mixin:instance]` convergence, F-6). +## 2026-06-23 — E-V3-PART-OF-IS-A-TILE — each 16-bit HHTL tier is an 8:8 (part_of:is_a) split: two hierarchies, one key + +**Status:** FINDING (q2 `converge.rs` / `V3_SOA_WIRING.md` are the existence proof on FMA anatomy; this entry records the grid instance + the consumer-bump awareness). **Confidence:** the 8:8 split + dual-query are shipped/tested; the consumer-bump items are an inventory, not yet PRs. + +q2's V3 addressing reinterprets each canonical 16-bit HHTL tier as **high byte = `part_of`** (PLACE / mereology / where) **: low byte = `is_a`** (TISSUE / taxonomy / what). The high-byte chain (HEEL.hi→HIP.hi→TWIG.hi) prefix-routes containment; the low-byte chain routes type. `EdgeBlock` in-family = `part_of` siblings = `connected_to`. **It needs no `ENVELOPE_LAYOUT_VERSION` bump** — it is an *interpretation* of the operator-locked 3×u16 tiers (the 6-group `NodeGuid`, NOT the in-flight 7-group LEAF `new_v2`). + +For the **electric grid** (`perturbation-sim::CascadeKeyV3`, additive to the V1/V2 `CascadeKey`): `part_of` (place) = the 24-bit Morton spectral cell (where in the grid), `is_a` (tissue) = the bus-role taxonomy (source/sink/transfer, generalizable to `BusKind`). The payoff over V1/V2 spatial-only: a `part_of` prefix selects "which region blacked out" AND an `is_a` prefix selects "all generators / all loads" — **two orthogonal queries on ONE key** (proven: source vs sink carry distinct is_a class bytes; the outage footprint is part_of-local). EdgeBlock in-family = the lines the cascade propagates along. + +**Consumer V1/V2→V3 bump inventory:** lance-graph `canonical_node` lacks an 8:8 `(part_of/is_a)` byte accessor (additive, layout-preserving — the natural canonical surface); `contract::soa_graph`/`graph_render` could split place/tissue on render; **live blocker** — q2 `osint-bake/fma.rs` calls `NodeGuid::new_v2(...LEAF...)`, a 7-group API absent from `canonical_node` (`I-LEGACY-API-FEATURE-GATED`); V3 is the 6-group-compatible resolution that sidesteps it. (Not actioned here — OGAR/contract surface is another session's; flagged only.) Ref: AGENT_LOG cont.³⁹, `cascade_key.rs` (V3 section), q2 `V3_SOA_WIRING.md` / `OGAR_CONSUMER_INTEGRATION.md`. + +## 2026-06-23 — E-CASCADE-KEY-IS-THE-SPATIAL-ADDRESS — one 48-bit Morton key, read six ways + +**Status:** FINDING (measured, scoped to perturbation-sim). **Confidence:** the +locality result is a measured probe, not a proof; the six-lens *identity* is +structural. + +The electric-outage cascade re-wired onto `perturbation-sim::CascadeKey` +(`family:u16 | leaf:u16 | identity:u16` = HEEL/HIP/TWIG, the OGAR production form +the old binary-Cheeger `HhtlKey` defers). Each tier is a full 16-bit 256×256 +centroid tile of the bus's `spectral_embedding` position (Morton-interleaved, +min-max norm ⇒ nibble prefix = quad-tree ancestry, the 4⁴ condition). The same +key is six readings of one address, NOT six artifacts: + +- **location** = `tile()` decode (the address is the position); +- **math** = `cascade_distance` = `3 − shared_prefix_tiers`, O(1) Morton + containment, zero value decode; +- **representation** = `to_guid_tiers()` IS the canonical `(HEEL,HIP,TWIG)`; +- **substrate** = three `u16` = the `NodeGuid` cascade tiers at bytes 4..10; + `morton48()` = the packed SoA key; +- **learning + thinking** = the blackout epicentre is **prefix-local** — measured + mean cascade-distance **1.000 vs 2.561 random baseline** on the 3-region grid; + the footprint learns the basin tree and the cascade traverses the same key. + +This realizes `guid-prefix-shape-routing.md` §4 ("the key selects the grid") on a +real perturbation domain. Honest fences: spectrally-symmetric buses (a clique) +collide at the finest tier (degenerate eigenvectors — broken symmetry separates +them in real grids); the locality number is a single-probe measurement (cite +Jirak `n^(p/2−1)`, not IID, for any significance claim per I-NOISE-FLOOR-JIRAK); +"topology IS the key" — these are electrical (spectral) coordinates, not geography. +Ref: AGENT_LOG cont.³⁸, `perturbation-sim/src/cascade_key.rs` + `examples/spain_cascade.rs`. ## 2026-06-23 — E-CLASSRBAC-PROMOTED-TO-CONTRACT — the §11 trait-placement that lets ogar join the RBAC chain diff --git a/crates/perturbation-sim/examples/spain_cascade.rs b/crates/perturbation-sim/examples/spain_cascade.rs new file mode 100644 index 00000000..985f4807 --- /dev/null +++ b/crates/perturbation-sim/examples/spain_cascade.rs @@ -0,0 +1,149 @@ +//! The electric-outage perturbation, wired onto the full 16-bit-per-tier spatial +//! cascade key (`CascadeKey` = HEEL/HIP/TWIG = family/leaf/identity, each u16). +//! +//! One key, six lenses — run: `cargo run --example spain_cascade`. +//! +//! Echoes the 2025-04-28 Iberian blackout shape: a stressed inter-region tie +//! trips, flow redistributes, the cascade fragments one region. The point this +//! example makes is that the SAME 48-bit Morton key proves location, math, +//! learning, representation, substrate, and thinking — it is not six artifacts, +//! it is six readings of one address. + +use perturbation_sim::{ + cascade_keys, cascade_keys_v3, simulate_outage, CascadeConfig, Edge, Grid, IsaPath, +}; + +/// A small Iberian-shaped transmission graph: three regional 4-clique pockets +/// (e.g. North / Centre / South) joined by two weak tie-lines — the topology +/// where a single trip cascades within a region. +fn iberian_grid() -> Grid { + let mut e = Vec::new(); + for region in 0..3 { + let b = region * 4; + for (a, c) in [(0, 1), (0, 2), (1, 3), (2, 3), (0, 3)] { + e.push(Edge::new(b + a, b + c, 1.0, 1e6)); + } + } + e.push(Edge::new(3, 4, 0.01, 1e6)); // North–Centre tie + e.push(Edge::new(7, 8, 0.01, 1e6)); // Centre–South tie + Grid::new(12, e) +} + +fn main() { + let g = iberian_grid(); + let alive = vec![true; g.edges.len()]; + let keys = cascade_keys(&g, &alive); + + println!("== electric-outage perturbation on the 16-bit-per-tier cascade key ==\n"); + + // REPRESENTATION + SUBSTRATE: the key IS the canonical (HEEL,HIP,TWIG) GUID + // cascade path; morton48 is the packed key the SoA node carries. + println!("bus family(HEEL) leaf(HIP) identity(TWIG) morton48"); + for (bus, k) in keys.iter().enumerate() { + let (h, hp, t) = k.to_guid_tiers(); + println!( + "{bus:>3} 0x{h:04X} 0x{hp:04X} 0x{t:04X} 0x{:012X}", + k.morton48() + ); + } + + // LOCATION: decode a key back to its quantized spectral tile (the address is + // the position). + let (x, y) = keys[0].tile(); + println!("\nlocation: bus 0 sits at spectral tile (x24=0x{x:06X}, y24=0x{y:06X})"); + + // MATH: O(1) Morton-prefix cascade distance, zero value decode. + println!( + "math: cascade_distance(bus0, bus1)={} cascade_distance(bus0, bus11)={}", + keys[0].cascade_distance(keys[1]), + keys[0].cascade_distance(keys[11]), + ); + + // THINKING + LEARNING: the outage cascade traverses the key; its epicentre is + // a low-distance neighbourhood (prefix-local) — the footprint learns the tree. + let mut p = vec![0.0; g.n]; + p[0] = 1.0; + p[10] = -1.0; + let res = simulate_outage( + &g, + &p, + g.edges.len() - 1, + CascadeConfig { + overload_factor: 1.0, + max_rounds: 16, + rel_tol: 1e-12, + }, + ); + let epi: Vec = res.shape.epicentre(4).into_iter().map(|(b, _)| b).collect(); + let mean = |bs: &[usize]| { + let (mut s, mut n) = (0u32, 0u32); + for i in 0..bs.len() { + for j in (i + 1)..bs.len() { + s += keys[bs[i]].cascade_distance(keys[bs[j]]) as u32; + n += 1; + } + } + if n == 0 { + 0.0 + } else { + s as f64 / n as f64 + } + }; + let all: Vec = (0..g.n).collect(); + println!( + "\nthinking: outage epicentre buses {epi:?} ({} lines tripped)", + res.shape.n_tripped() + ); + println!( + "learning: epicentre mean cascade-distance {:.3} < random baseline {:.3} \ + ⇒ footprint is prefix-local (placement learns the basin tree)", + mean(&epi), + mean(&all), + ); + + // ── V3 (part_of:is_a): each tier = (place:tissue), two hierarchies one key ── + println!("\n== V3 (part_of:is_a) — the better grid representation ==\n"); + // is_a taxonomy from the power balance: source (p>0) / sink (p<0) / transfer. + let is_a: Vec = p + .iter() + .map(|&pi| { + let class = if pi > 0.0 { + 1 + } else if pi < 0.0 { + 2 + } else { + 3 + }; + IsaPath { + class, + kind: class, + sub: 0, + } + }) + .collect(); + let v3 = cascade_keys_v3(&g, &alive, &is_a); + println!("bus HEEL HIP TWIG place(part_of) tissue(is_a) role"); + for (bus, k) in v3.iter().enumerate() { + let (h, hp, t) = k.to_guid_tiers(); + let role = match k.tissue_chain()[0] { + 1 => "source/gen", + 2 => "sink/load", + _ => "transfer", + }; + println!( + "{bus:>3} {h:04X} {hp:04X} {t:04X} {:?} {:?} {role}", + k.place_chain(), + k.tissue_chain() + ); + } + // Two orthogonal prefix queries on ONE key — impossible with V1/V2 spatial-only: + let gen = 0usize; + let load = 10usize; + println!( + "\npart_of: outage epicentre is place-local (where it blacked out)\n\ + is_a: bus{gen}(source) vs bus{load}(sink) part_of_distance={} is_a_distance={} \ + — same key, orthogonal axes", + v3[gen].part_of_distance(v3[load]), + v3[gen].is_a_distance(v3[load]), + ); +} diff --git a/crates/perturbation-sim/src/cascade_key.rs b/crates/perturbation-sim/src/cascade_key.rs new file mode 100644 index 00000000..82866b2d --- /dev/null +++ b/crates/perturbation-sim/src/cascade_key.rs @@ -0,0 +1,630 @@ +//! Full **16-bit-per-tier** spatial cascade key — the OGAR production form of +//! the HHTL address (`OGAR/CLAUDE.md` P0; `ndarray .../guid-prefix-shape-routing.md` +//! §4 "the key selects the grid"). +//! +//! [`crate::hhtl::HhtlKey`] derives the cascade address by **binary** Cheeger +//! bisection — its own doc note is honest that this only fills the *low bit* of +//! each tier (an 8-leaf tree), "**not** that full encoding". This module realizes +//! the deferred form: each cascade tier is a full **16-bit 256×256 centroid tile** +//! (two byte-axes, nibble-interleaved Morton — [`crate::splat::morton2`]), built +//! from the bus's position in the [`spectral_embedding`] (electrical coordinates, +//! NOT geography — "topology IS the key"). Three tiers ⇒ a 24-bit-per-axis Morton +//! address over the spectral plane, perfectly aligned with the cascade. +//! +//! The three 16-bit roles map coarse→fine onto the canonical GUID cascade tiers: +//! +//! | role | bits | GUID tier | meaning | +//! |--------------|------|-----------|------------------------------------------| +//! | [`family`] | 16 | **HEEL** | coarsest 256×256 tile — the broad basin | +//! | [`leaf`] | 16 | **HIP** | mid tile — the leaf basin in the family | +//! | [`identity`] | 16 | **TWIG** | finest tile — the per-bus identity cell | +//! +//! [`family`]: CascadeKey::family +//! [`leaf`]: CascadeKey::leaf +//! [`identity`]: CascadeKey::identity +//! +//! ## The six lenses this one key proves +//! +//! - **location** — [`CascadeKey::tile`] decodes the key back to the quantized +//! spectral tile the bus sits in (the address *is* the position). +//! - **math** — [`CascadeKey::cascade_distance`] is Morton-prefix containment: +//! `3 − shared_prefix_tiers`, an O(1) tier compare, no value decode. +//! - **learning** — the blackout footprint is **prefix-local**: co-failing buses +//! share a `family`/`leaf` prefix (proven in the `learning_*` test against a +//! random baseline), so field-perturbation placement *learns* the basin tree. +//! - **representation** — [`CascadeKey::to_guid_tiers`] is exactly the canonical +//! `(HEEL, HIP, TWIG)` `u16` triple; the dash-groups are the only semantics. +//! - **substrate** — three `u16` are bit-for-bit the canonical `NodeGuid` cascade +//! tiers at byte offsets 4..6 / 6..8 / 8..10; [`CascadeKey::morton48`] is the +//! packed key the SoA node carries with zero value decode. +//! - **thinking** — the outage cascade ([`crate::cascade::simulate_outage`]) +//! traverses the same key arithmetic: its epicentre is a low-`cascade_distance` +//! neighbourhood. + +use crate::basin::spectral_embedding; +use crate::graph::Grid; +use crate::splat::morton2; + +/// A node's full-resolution HHTL cascade address: three 16-bit centroid tiles, +/// coarse→fine. Each tier interleaves one byte of each spectral axis (a 256×256 +/// tile); the whole key is a 48-bit Morton code over the 2-D embedding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct CascadeKey { + /// **HEEL** — coarsest 256×256 tile: the broad basin / family. + pub family: u16, + /// **HIP** — mid 256×256 tile: the leaf basin within the family. + pub leaf: u16, + /// **TWIG** — finest 256×256 tile: the per-bus identity cell. + pub identity: u16, +} + +/// Interleave one byte of each axis into a 16-bit centroid tile (the per-tier +/// 256×256 Morton code). `morton2` of two ≤255 values fits in the low 16 bits. +#[inline] +fn tile(x_byte: u8, y_byte: u8) -> u16 { + morton2(x_byte as u16, y_byte as u16) as u16 +} + +/// Map a normalized coordinate `t ∈ [0,1]` to a 24-bit axis index `[0, 2^24)`. +/// Linear (not rank) so a nibble prefix = a quad-tree quadrant — the +/// `256 = 4⁴` hierarchical-ancestry condition (`guid-prefix-shape-routing.md` §6). +#[inline] +fn axis24(t: f64) -> u32 { + // (1<<24) - 1 keeps t==1.0 in-range (closed interval, no overflow to bit 24). + (t.clamp(0.0, 1.0) * ((1u32 << 24) - 1) as f64).round() as u32 +} + +impl CascadeKey { + /// Build the key from a bus's spectral coordinate `(u, v)` given the per-axis + /// `(min, max)` bounds of the embedding. Coarsest tier = the top byte of each + /// 24-bit axis index; finest = the low byte. + pub fn from_spectral(u: f64, v: f64, ub: (f64, f64), vb: (f64, f64)) -> Self { + let norm = |x: f64, (lo, hi): (f64, f64)| { + let w = hi - lo; + if w.abs() < 1e-300 { + 0.0 + } else { + (x - lo) / w + } + }; + let xi = axis24(norm(u, ub)); + let yi = axis24(norm(v, vb)); + CascadeKey { + family: tile((xi >> 16) as u8, (yi >> 16) as u8), + leaf: tile((xi >> 8) as u8, (yi >> 8) as u8), + identity: tile(xi as u8, yi as u8), + } + } + + /// The canonical `(HEEL, HIP, TWIG)` `u16` triple — the cascade tiers a + /// `NodeGuid` stores at byte offsets 4..6 / 6..8 / 8..10. The key IS the GUID + /// cascade path; this is the representation lens. + #[inline] + pub fn to_guid_tiers(self) -> (u16, u16, u16) { + (self.family, self.leaf, self.identity) + } + + /// The 48-bit Morton code `family<<32 | leaf<<16 | identity` — the packed + /// cascade key the SoA node carries (prerenders/routes with zero value + /// decode). The substrate lens. + #[inline] + pub fn morton48(self) -> u64 { + ((self.family as u64) << 32) | ((self.leaf as u64) << 16) | (self.identity as u64) + } + + /// How many tiers agree from the coarsest down (0..=3): `family`, then + /// `leaf`, then `identity`. Stops at the first divergence (prefix property). + #[inline] + pub fn shared_prefix_tiers(self, other: Self) -> u8 { + if self.family != other.family { + 0 + } else if self.leaf != other.leaf { + 1 + } else if self.identity != other.identity { + 2 + } else { + 3 + } + } + + /// Morton-containment cascade distance: `3 − shared_prefix_tiers`. `0` = + /// identical cell, `3` = diverge at the coarsest tier. O(1), zero value + /// decode — the math lens. + #[inline] + pub fn cascade_distance(self, other: Self) -> u8 { + 3 - self.shared_prefix_tiers(other) + } + + /// The quantized spectral tile this key addresses, as `(x24, y24)` axis + /// indices in `[0, 2^24)` — decode of [`from_spectral`](Self::from_spectral)'s + /// placement (the location lens). Inverse of the per-tier Morton interleave. + pub fn tile(self) -> (u32, u32) { + // De-interleave a 16-bit tile back to its two byte-axes. + fn unmorton(t: u16) -> (u8, u8) { + fn compact(mut v: u32) -> u8 { + v &= 0x5555_5555; + v = (v | (v >> 1)) & 0x3333_3333; + v = (v | (v >> 2)) & 0x0F0F_0F0F; + v = (v | (v >> 4)) & 0x00FF_00FF; + v = (v | (v >> 8)) & 0x0000_FFFF; + v as u8 + } + let t = t as u32; + (compact(t), compact(t >> 1)) + } + let (fx, fy) = unmorton(self.family); + let (lx, ly) = unmorton(self.leaf); + let (ix, iy) = unmorton(self.identity); + let x = ((fx as u32) << 16) | ((lx as u32) << 8) | ix as u32; + let y = ((fy as u32) << 16) | ((ly as u32) << 8) | iy as u32; + (x, y) + } +} + +/// Assign every bus its full 16-bit-per-tier [`CascadeKey`] from the 2-D spectral +/// embedding of the live grid. Deterministic: the embedding is a pure function of +/// the Laplacian spectrum, and the Morton quantization is fixed — same grid ⇒ same +/// keys (the representation lens). Min/max normalization makes the address fill +/// the cascade space, so spectral adjacency becomes a shared prefix. +pub fn cascade_keys(grid: &Grid, alive: &[bool]) -> Vec { + let emb = spectral_embedding(grid, alive, 2); + if emb.is_empty() { + return Vec::new(); + } + let axis = |k: usize| { + emb.iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), e| { + let x = e.get(k).copied().unwrap_or(0.0); + (lo.min(x), hi.max(x)) + }) + }; + let ub = axis(0); + let vb = axis(1); + emb.iter() + .map(|e| { + CascadeKey::from_spectral( + e.first().copied().unwrap_or(0.0), + e.get(1).copied().unwrap_or(0.0), + ub, + vb, + ) + }) + .collect() +} + +// ─── V3: the `(part_of : is_a)` 8:8 tile (q2 `converge.rs`, V3_SOA_WIRING.md) ─── + +/// A node's 3-level `is_a` taxonomy path — the **low-byte (tissue) chain** of the +/// HHTL tiers. Each field is a sibling-rank under the node's parent in the type +/// taxonomy (1-based, 0 = root/unset), coarse→fine. For the electric grid: +/// `class` = role in the power balance (source/sink/transfer), `kind` = a finer +/// electrical class (e.g. connectivity bucket / `BusKind`), `sub` = leaf rank. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct IsaPath { + /// Level-0 `is_a` rank (HEEL low byte) — the broad type class. + pub class: u8, + /// Level-1 `is_a` rank (HIP low byte) — the kind within the class. + pub kind: u8, + /// Level-2 `is_a` rank (TWIG low byte) — the leaf type. + pub sub: u8, +} + +/// The **V3** cascade key: each 16-bit HHTL tier is an 8:8 `(place : tissue)` = +/// `(part_of : is_a)` split (q2 `converge.rs`; `V3_SOA_WIRING.md` §2). The +/// **high-byte chain** (HEEL.hi → HIP.hi → TWIG.hi) prefix-routes WHERE the node +/// sits (`part_of` / mereology — here the Morton spectral cell); the **low-byte +/// chain** (HEEL.lo → HIP.lo → TWIG.lo) prefix-routes WHAT it is (`is_a` / +/// taxonomy). Two orthogonal hierarchies, one key — the strict refinement of the +/// V1/V2 [`CascadeKey`] (which spent both bytes on spatial Morton). +/// +/// For the electric grid this is the better representation: a `part_of` prefix +/// selects "this region of the grid" (the blackout footprint), an `is_a` prefix +/// selects "all generators" / "all loads" — **independently, on the same key**. +/// `EdgeBlock` in-family = `part_of`/`connected_to` siblings (the lines the +/// cascade propagates along); out-of-family = the `is_a` parent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct CascadeKeyV3 { + /// HEEL = `(place₀ : tissue₀)`. + pub heel: u16, + /// HIP = `(place₁ : tissue₁)`. + pub hip: u16, + /// TWIG = `(place₂ : tissue₂)`. + pub twig: u16, +} + +/// Pack one 8:8 tier: high byte = `place` (part_of), low byte = `tissue` (is_a). +/// Literally `converge.rs`'s `fn tier(hi, lo) -> u16`. +#[inline] +fn tier_v3(place: u8, tissue: u8) -> u16 { + ((place as u16) << 8) | tissue as u16 +} + +impl CascadeKeyV3 { + /// Build from the 3 `part_of` place octets (the Morton spectral-cell bytes, + /// coarse→fine) and the `is_a` taxonomy path. + pub fn new(place: [u8; 3], isa: IsaPath) -> Self { + CascadeKeyV3 { + heel: tier_v3(place[0], isa.class), + hip: tier_v3(place[1], isa.kind), + twig: tier_v3(place[2], isa.sub), + } + } + + /// The `part_of` / PLACE chain (high bytes, coarse→fine) — WHERE it sits. + #[inline] + pub fn place_chain(self) -> [u8; 3] { + [ + (self.heel >> 8) as u8, + (self.hip >> 8) as u8, + (self.twig >> 8) as u8, + ] + } + + /// The `is_a` / TISSUE chain (low bytes, coarse→fine) — WHAT it is. + #[inline] + pub fn tissue_chain(self) -> [u8; 3] { + [self.heel as u8, self.hip as u8, self.twig as u8] + } + + /// Canonical `(HEEL, HIP, TWIG)` `u16` triple — the V3 key IS a 6-group + /// `NodeGuid` cascade path (no LEAF tier; both hierarchies live inside the + /// existing 3×u16, so this needs **no `ENVELOPE_LAYOUT_VERSION` bump**). + #[inline] + pub fn to_guid_tiers(self) -> (u16, u16, u16) { + (self.heel, self.hip, self.twig) + } + + /// Shared prefix length (0..=3) along ONE byte chain. + fn shared(a: [u8; 3], b: [u8; 3]) -> u8 { + let mut n = 0u8; + while (n as usize) < 3 && a[n as usize] == b[n as usize] { + n += 1; + } + n + } + + /// `part_of` (place) distance: `3 − shared place-prefix`. Small ⇒ same region + /// of the grid. This is the blackout-locality metric. + #[inline] + pub fn part_of_distance(self, other: Self) -> u8 { + 3 - Self::shared(self.place_chain(), other.place_chain()) + } + + /// `is_a` (tissue) distance: `3 − shared tissue-prefix`. Small ⇒ same type + /// (both generators, both loads). Orthogonal to `part_of_distance`. + #[inline] + pub fn is_a_distance(self, other: Self) -> u8 { + 3 - Self::shared(self.tissue_chain(), other.tissue_chain()) + } +} + +/// Assign every bus its V3 `(part_of:is_a)` key. `part_of` (place) = the 24-bit +/// Morton spectral cell (12 bits per spectral axis → 3 octets, coarse→fine); +/// `is_a` (tissue) = the per-bus [`IsaPath`] taxonomy. `is_a.len()` must equal the +/// node count. Deterministic (spectrum + fixed Morton); same grid + same taxonomy +/// ⇒ same keys. +pub fn cascade_keys_v3(grid: &Grid, alive: &[bool], is_a: &[IsaPath]) -> Vec { + assert_eq!( + is_a.len(), + grid.n, + "cascade_keys_v3 needs one IsaPath per bus (got {} for {} buses)", + is_a.len(), + grid.n + ); + let emb = spectral_embedding(grid, alive, 2); + if emb.is_empty() { + return Vec::new(); + } + let axis = |k: usize| { + emb.iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), e| { + let x = e.get(k).copied().unwrap_or(0.0); + (lo.min(x), hi.max(x)) + }) + }; + let (ub, vb) = (axis(0), axis(1)); + let norm = |x: f64, (lo, hi): (f64, f64)| { + let w = hi - lo; + if w.abs() < 1e-300 { + 0.0 + } else { + ((x - lo) / w).clamp(0.0, 1.0) + } + }; + emb.iter() + .zip(is_a.iter()) + .map(|(e, &isa)| { + // 12-bit per spectral axis → 24-bit Morton code → 3 place octets. + let xi = (norm(e.first().copied().unwrap_or(0.0), ub) * 4095.0).round() as u16; + let yi = (norm(e.get(1).copied().unwrap_or(0.0), vb) * 4095.0).round() as u16; + let m = morton2(xi, yi); // 24-bit Z-order (12+12 interleaved) + let place = [(m >> 16) as u8, (m >> 8) as u8, m as u8]; + CascadeKeyV3::new(place, isa) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cascade::{simulate_outage, CascadeConfig}; + use crate::graph::{Edge, Grid}; + + /// Three weakly-bridged 4-cliques — a clean 3-basin transmission topology + /// (the regional-grid regime this crate targets). Seeded faults stay local. + fn three_region_grid() -> Grid { + let mut e = Vec::new(); + for blk in 0..3 { + let b = blk * 4; + for (a, c) in [(0, 1), (0, 2), (1, 3), (2, 3), (0, 3)] { + e.push(Edge::new(b + a, b + c, 1.0, 1e6)); + } + } + // weak inter-region tie-lines + e.push(Edge::new(3, 4, 0.01, 1e6)); + e.push(Edge::new(7, 8, 0.01, 1e6)); + Grid::new(12, e) + } + + fn keys() -> Vec { + let g = three_region_grid(); + cascade_keys(&g, &vec![true; g.edges.len()]) + } + + #[test] + fn representation_is_deterministic() { + // Same grid ⇒ same keys: the address is a pure function of the spectrum. + assert_eq!(keys(), keys()); + } + + #[test] + fn location_tile_round_trips_the_morton_interleave() { + // Each tier de-interleaves back to its byte-axes; morton48 packs the + // canonical (HEEL,HIP,TWIG) at 32/16/0 bit-exact (substrate layout). + let k = CascadeKey::from_spectral(0.3, 0.7, (0.0, 1.0), (0.0, 1.0)); + let (x, y) = k.tile(); + // 0.3*16777215 ≈ 5033164 (0x4CCCCC); 0.7 ≈ 11744150 (0xB33333). + assert_eq!(x >> 16, 0x4C, "top byte of x-axis recovered"); + assert_eq!(y >> 16, 0xB3, "top byte of y-axis recovered"); + let (h, hp, t) = k.to_guid_tiers(); + assert_eq!( + k.morton48(), + ((h as u64) << 32) | ((hp as u64) << 16) | t as u64 + ); + } + + #[test] + fn math_distance_is_a_prefix_metric() { + let a = CascadeKey { + family: 7, + leaf: 3, + identity: 1, + }; + assert_eq!(a.cascade_distance(a), 0); // identical cell + assert_eq!( + a.cascade_distance(CascadeKey { + family: 7, + leaf: 3, + identity: 9 + }), + 1 // share family+leaf, differ at identity + ); + assert_eq!( + a.cascade_distance(CascadeKey { + family: 7, + leaf: 8, + identity: 1 + }), + 2 // share family, differ at leaf + ); + assert_eq!( + a.cascade_distance(CascadeKey { + family: 9, + leaf: 3, + identity: 1 + }), + 3 // diverge at the coarsest tier + ); + } + + #[test] + fn representation_distinct_basins_get_distinct_addresses() { + // The three electrically-separated cliques do not all collapse to one + // address — the embedding spreads them across the cascade space. + let k = keys(); + let distinct: std::collections::HashSet<_> = k.iter().map(|c| c.morton48()).collect(); + assert!( + distinct.len() >= 3, + "≥3 distinct cascade addresses for 3 basins, got {}", + distinct.len() + ); + } + + #[test] + fn learning_blackout_footprint_is_prefix_local() { + // THE learning lens: a seeded outage's epicentre clusters in the cascade + // tree — the impacted buses share a coarse prefix, so their mean pairwise + // cascade_distance is strictly below the all-pairs (random) baseline. + // Placement LEARNS the basin tree; the cascade traverses the same key. + let g = three_region_grid(); + let k = cascade_keys(&g, &vec![true; g.edges.len()]); + // Inject power at region-0, draw at region-2 → flow stresses the ties. + let mut p = vec![0.0; g.n]; + p[0] = 1.0; + p[10] = -1.0; + let res = simulate_outage( + &g, + &p, + g.edges.len() - 1, // trip a tie-line as the seed + CascadeConfig { + overload_factor: 1.0, + max_rounds: 16, + rel_tol: 1e-12, + }, + ); + let epi: Vec = res.shape.epicentre(4).into_iter().map(|(b, _)| b).collect(); + assert!(epi.len() >= 2, "need an epicentre to measure locality"); + + let mean_dist = |buses: &[usize]| { + let mut s = 0u32; + let mut n = 0u32; + for i in 0..buses.len() { + for j in (i + 1)..buses.len() { + s += k[buses[i]].cascade_distance(k[buses[j]]) as u32; + n += 1; + } + } + if n == 0 { + 0.0 + } else { + s as f64 / n as f64 + } + }; + let all: Vec = (0..g.n).collect(); + let epi_local = mean_dist(&epi); + let baseline = mean_dist(&all); + assert!( + epi_local < baseline, + "epicentre cascade-distance {epi_local} must beat the random baseline {baseline} \ + (the footprint is prefix-local — placement learns the tree)" + ); + } + + // ─── V3 (part_of:is_a) ─── + + /// Build the grid's `is_a` taxonomy from the injection pattern: a bus is a + /// SOURCE (generator, p>0), a SINK (load, p<0), or a TRANSFER node (p==0). + /// `kind` buckets the source/sink finer; `sub` is the leaf rank. + fn grid_isa(p: &[f64]) -> Vec { + p.iter() + .map(|&pi| { + let class = if pi > 0.0 { + 1 + } else if pi < 0.0 { + 2 + } else { + 3 + }; + IsaPath { + class, + kind: class, + sub: 0, + } + }) + .collect() + } + + #[test] + fn v3_packs_part_of_high_byte_and_is_a_low_byte() { + // The 8:8 split: place in the high byte, tissue in the low byte. + let k = CascadeKeyV3::new( + [0xAB, 0xCD, 0xEF], + IsaPath { + class: 1, + kind: 2, + sub: 3, + }, + ); + assert_eq!(k.to_guid_tiers(), (0xAB01, 0xCD02, 0xEF03)); + assert_eq!(k.place_chain(), [0xAB, 0xCD, 0xEF]); // part_of / where + assert_eq!(k.tissue_chain(), [1, 2, 3]); // is_a / what + } + + #[test] + fn v3_two_hierarchies_are_independent() { + // Same place, different type → part_of_distance 0, is_a_distance > 0. + // Different place, same type → the reverse. The two axes are orthogonal. + let gen_here = CascadeKeyV3::new( + [5, 5, 5], + IsaPath { + class: 1, + kind: 1, + sub: 0, + }, + ); + let load_here = CascadeKeyV3::new( + [5, 5, 5], + IsaPath { + class: 2, + kind: 2, + sub: 0, + }, + ); + let gen_there = CascadeKeyV3::new( + [9, 5, 5], + IsaPath { + class: 1, + kind: 1, + sub: 0, + }, + ); + assert_eq!(gen_here.part_of_distance(load_here), 0, "same place"); + assert!(gen_here.is_a_distance(load_here) > 0, "different type"); + assert_eq!(gen_here.is_a_distance(gen_there), 0, "same type"); + assert!(gen_here.part_of_distance(gen_there) > 0, "different place"); + } + + #[test] + fn v3_blackout_is_part_of_local_and_queryable_by_is_a() { + // The electric-grid payoff: the outage footprint is part_of (place)-local + // — AND the SAME key answers "which kind blacked out" via is_a, a query + // the V1/V2 spatial-only key cannot express. + let g = three_region_grid(); + let mut p = vec![0.0; g.n]; + p[0] = 1.0; // region-0 generator (source) + p[10] = -1.0; // region-2 load (sink) + let keys = cascade_keys_v3(&g, &vec![true; g.edges.len()], &grid_isa(&p)); + + let res = simulate_outage( + &g, + &p, + g.edges.len() - 1, + CascadeConfig { + overload_factor: 1.0, + max_rounds: 16, + rel_tol: 1e-12, + }, + ); + let epi: Vec = res.shape.epicentre(4).into_iter().map(|(b, _)| b).collect(); + assert!(epi.len() >= 2); + + // part_of (place) locality: epicentre mean place-distance < random baseline. + let mean = |bs: &[usize], f: &dyn Fn(usize, usize) -> u8| { + let (mut s, mut n) = (0u32, 0u32); + for i in 0..bs.len() { + for j in (i + 1)..bs.len() { + s += f(bs[i], bs[j]) as u32; + n += 1; + } + } + if n == 0 { + 0.0 + } else { + s as f64 / n as f64 + } + }; + let pod = |a: usize, b: usize| keys[a].part_of_distance(keys[b]); + let all: Vec = (0..g.n).collect(); + assert!( + mean(&epi, &pod) < mean(&all, &pod), + "blackout footprint must be part_of (place)-local" + ); + + // is_a query: the SAME key cleanly separates the sources from the sinks + // (their is_a class bytes differ) — orthogonal to where they sit. + let src = 0usize; // p>0 → class 1 + let sink = 10usize; // p<0 → class 2 + assert_ne!( + keys[src].tissue_chain()[0], + keys[sink].tissue_chain()[0], + "source and sink carry distinct is_a class bytes" + ); + } + + #[test] + #[should_panic(expected = "one IsaPath per bus")] + fn v3_rejects_isa_count_mismatch() { + let g = three_region_grid(); + let _ = cascade_keys_v3(&g, &vec![true; g.edges.len()], &[IsaPath::default()]); + } +} diff --git a/crates/perturbation-sim/src/lib.rs b/crates/perturbation-sim/src/lib.rs index 4d046a69..3e8e5815 100644 --- a/crates/perturbation-sim/src/lib.rs +++ b/crates/perturbation-sim/src/lib.rs @@ -53,6 +53,7 @@ pub mod acflow; pub mod basin; pub mod buffer; pub mod cascade; +pub mod cascade_key; pub mod chaoda; pub mod columns; pub mod eigen; @@ -78,6 +79,7 @@ pub use basin::{ }; pub use buffer::{compartment_buffer, impulse_buffer, inertia_buffer_column, ketchup_yield, Yield}; pub use cascade::{simulate_outage, CascadeConfig, CascadeResult, PerturbationShape}; +pub use cascade_key::{cascade_keys, cascade_keys_v3, CascadeKey, CascadeKeyV3, IsaPath}; pub use chaoda::{ anomaly_ranking, cakes_neighbors, chaoda_scores, resilience_basin_features, CHAODA_FLAG, };