Skip to content

Commit 006d232

Browse files
committed
feat(perturbation-sim): probe 2 — witness arc as a standing wave (METHODS §11)
Proves the particle == wave identity §11 asserts: a witness arc (a Markov #1 reference chain over the SoA) read by walking it (particle, O(hops) pointer-chase) equals its standing-wave evaluation via Parseval on the Walsh-Hadamard pyramid (Hᵀ H = N·I, the fwht involution-up-to-N already in sketch.rs): ⟨field, arc⟩ = (1/N)·⟨Ĥfield, Ĥarc⟩. - witness_particle: the pointer-chase walk (∑ field·arc). - field_spectrum: the standing wave, computed ONCE (O(N log N)). - witness_from_spectrum: read any arc off the spectrum (O(N)) — the amortization win (one transform, q arcs) vs q independent particle walks. - witness_wave / particle_equals_wave: convenience + the probe's pass predicate. Self-contained in perturbation-sim; the contract witness_table evaluator is the separate gated step (SoA spine = additive-only behind the iron rules). Example `witness` runs it on the inertia-buffer field (ties §11's "inertia field on power grids" to the promoted member) — particle vs wave agree to 0.00e0. Tests: +4 (Parseval identity, one-transform-many-arcs amortization, non-pow2 + ragged-arc padding, degenerate-safe). fmt + clippy --all-targets -D warnings clean; 86 tests.
1 parent 8d87ef0 commit 006d232

4 files changed

Lines changed: 218 additions & 0 deletions

File tree

crates/perturbation-sim/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,7 @@ path = "examples/hhtl_grid.rs"
104104
[[example]]
105105
name = "chaoda"
106106
path = "examples/chaoda.rs"
107+
108+
[[example]]
109+
name = "witness"
110+
path = "examples/witness.rs"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! The witness arc as a standing wave (METHODS §11) — particle (pointer-chase) vs
2+
//! wave (Walsh-pyramid Parseval), on a real grid inertia field.
3+
//!
4+
//! Builds the per-bus inertia-buffer field, then reads three witness arcs both ways:
5+
//! the particle walk (`O(hops)` per arc) and the standing wave (one `field_spectrum`
6+
//! transform, then `O(N)` per arc). They agree to floating point — and the wave form
7+
//! amortizes one transform across all arcs.
8+
//!
9+
//! Run: cargo run --release --manifest-path crates/perturbation-sim/Cargo.toml \
10+
//! --example witness
11+
12+
use perturbation_sim::{
13+
field_spectrum, inertia_buffer_column, witness_from_spectrum, witness_particle,
14+
};
15+
16+
fn proxy_inertia(n: usize) -> Vec<f64> {
17+
(0..n).map(|i| 2.0 + (i % 5) as f64).collect()
18+
}
19+
20+
fn main() {
21+
// The field: a per-bus inertia-buffer field over a 16-bus line (METHODS §11's
22+
// "inertia field on power grids"; the promoted additive member as the carrier).
23+
let n = 16;
24+
let field = inertia_buffer_column(&proxy_inertia(n), 0.2);
25+
let field: Vec<f64> = field.iter().map(|&x| x as f64).collect();
26+
27+
// Three witness arcs (Markov #1 reference chains over the buses):
28+
// - a single-hop witness (read one bus),
29+
// - a coarse dyadic arc (the first half — a Raumgewinn-scale reference),
30+
// - an alternating signed chain (a fine-scale infight reference).
31+
let mut single = vec![0.0; n];
32+
single[5] = 1.0;
33+
let mut coarse = vec![0.0; n];
34+
for c in coarse.iter_mut().take(n / 2) {
35+
*c = 1.0;
36+
}
37+
let alt: Vec<f64> = (0..n)
38+
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
39+
.collect();
40+
let arcs: [(&str, &[f64]); 3] = [
41+
("single-hop", &single),
42+
("coarse dyadic", &coarse),
43+
("alternating", &alt),
44+
];
45+
46+
// Wave view: transform the field ONCE; every arc is then an O(N) read off it.
47+
let spectrum = field_spectrum(&field);
48+
49+
println!("witness arc as a standing wave — particle (walk) vs wave (Parseval)\n");
50+
println!(" field: {n}-bus inertia-buffer field; one Walsh transform reused by all arcs\n");
51+
println!(
52+
" {:>14} {:>14} {:>14} {:>10}",
53+
"arc", "particle", "wave", "Δ"
54+
);
55+
let mut max_err = 0.0_f64;
56+
for (name, arc) in arcs {
57+
let p = witness_particle(&field, arc);
58+
let w = witness_from_spectrum(&spectrum, arc);
59+
let d = (p - w).abs();
60+
max_err = max_err.max(d);
61+
println!(" {name:>14} {p:>14.9} {w:>14.9} {d:>10.2e}");
62+
}
63+
println!(
64+
"\n max |particle − wave| = {max_err:.2e} (Parseval: Hᵀ H = N·I, exact up to fp).\n \
65+
particle = O(hops) pointer-chase per arc; wave = O(N log N) once + O(N) per arc.\n \
66+
The standing wave IS the witness arc — evaluated all at once, no chain walk.\n \
67+
(Demonstration in perturbation-sim; the contract witness_table evaluator is the\n \
68+
separate gated step — the SoA spine is additive-only behind the iron rules.)"
69+
);
70+
}

crates/perturbation-sim/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub mod sketch;
6868
pub mod splat;
6969
pub mod stats;
7070
pub mod timing;
71+
pub mod witness;
7172

7273
pub use acflow::{AcBus, AcLine, AcSystem, BusKind, PowerFlowResult};
7374
pub use basin::{
@@ -102,3 +103,6 @@ pub use timing::{
102103
cascade_wall_time, collapse_number, implied_dt_per_hop, mechanism_from_timescale, meta_cascade,
103104
meta_cascade_phase, per_hop_time, rocof_hz_per_s, tier_composite, MetaHop, HHTL_WEIGHTS,
104105
};
106+
pub use witness::{
107+
field_spectrum, particle_equals_wave, witness_from_spectrum, witness_particle, witness_wave,
108+
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! Probe 2 — the **witness arc as a standing wave** (METHODS §11).
2+
//!
3+
//! A *witness arc* is a Markov #1 reference chain over the SoA — a path that reads
4+
//! a field by accumulating it along the chain. METHODS §11 says the same arc can be
5+
//! evaluated two ways, and they must agree:
6+
//!
7+
//! - **Particle view (pointer chasing).** Walk the arc hop by hop, accumulating the
8+
//! field along it: `∑ field[i]·arc[i]`. One dependent load per hop — `O(hops)`
9+
//! sequential dereferences (the `CausalEdge64` W-slot → witness chain walk).
10+
//! - **Wave view (standing wave).** The arc's reading IS an inner product against
11+
//! the field, and by **Parseval for the Walsh–Hadamard transform** (`Hᵀ H = N·I`,
12+
//! the involution-up-to-`N` the pyramid already provides via [`fwht`]):
13+
//! `⟨field, arc⟩ = (1/N)·⟨Ĥfield, Ĥarc⟩`. Transform the field **once**
14+
//! (`O(N log N)`); then every witness arc is an `O(N)` dot against its spectrum —
15+
//! the whole arc is evaluated at once, no walk.
16+
//!
17+
//! **The identity `particle == wave` is what this probe proves** ([`particle_equals_wave`]).
18+
//! The payoff is amortization: [`field_spectrum`] computes the standing wave once,
19+
//! and [`witness_from_spectrum`] reads many arcs off it — `O(N log N) + q·O(N)` for
20+
//! `q` arcs, vs the particle view's `q` independent pointer-chasing walks.
21+
//!
22+
//! Self-contained, in perturbation-sim — this demonstrates the pyramid/field
23+
//! mechanism on a grid field (e.g. the [`crate::inertia_buffer_column`] field).
24+
//! Wiring it as the *actual* `witness_table` evaluator in the contract is a separate,
25+
//! gated step: the witness/SoA types are the cognitive spine — additive only, behind
26+
//! the iron rules.
27+
28+
use crate::sketch::fwht;
29+
30+
/// Pad a field to the next power-of-two length (zero-filled), the length [`fwht`]
31+
/// requires. Returns the padded buffer.
32+
fn pad_pow2(v: &[f64]) -> Vec<f64> {
33+
let mut n = 1usize;
34+
while n < v.len().max(1) {
35+
n <<= 1;
36+
}
37+
let mut a = vec![0.0; n];
38+
a[..v.len()].copy_from_slice(v);
39+
a
40+
}
41+
42+
/// **Particle view.** The witness arc read by walking it: `∑ field[i]·arc[i]`, the
43+
/// pointer-chase accumulation (`O(len)` sequential). `field` and `arc` are read up to
44+
/// their shared length.
45+
pub fn witness_particle(field: &[f64], arc: &[f64]) -> f64 {
46+
field.iter().zip(arc).map(|(f, a)| f * a).sum()
47+
}
48+
49+
/// The field's **standing-wave spectrum** — the Walsh–Hadamard pyramid of the field,
50+
/// computed ONCE. Reuse across many witness arcs via [`witness_from_spectrum`].
51+
pub fn field_spectrum(field: &[f64]) -> Vec<f64> {
52+
let mut a = pad_pow2(field);
53+
fwht(&mut a);
54+
a
55+
}
56+
57+
/// Read a witness arc off a precomputed field spectrum: `(1/N)·⟨spectrum, Ĥarc⟩`
58+
/// (Parseval). Equals [`witness_particle`] up to floating-point. `spectrum` must come
59+
/// from [`field_spectrum`] (length `N`, a power of two).
60+
pub fn witness_from_spectrum(spectrum: &[f64], arc: &[f64]) -> f64 {
61+
let n = spectrum.len();
62+
if n == 0 {
63+
return 0.0;
64+
}
65+
let mut a = vec![0.0; n];
66+
let take = arc.len().min(n);
67+
a[..take].copy_from_slice(&arc[..take]);
68+
fwht(&mut a);
69+
spectrum.iter().zip(&a).map(|(x, y)| x * y).sum::<f64>() / n as f64
70+
}
71+
72+
/// **Wave view.** The same reading as [`witness_particle`], via Parseval on the Walsh
73+
/// pyramid — transform field + arc, then `(1/N)·∑ Ĥfield·Ĥarc`. Self-contained
74+
/// (transforms both); for many arcs prefer [`field_spectrum`] + [`witness_from_spectrum`].
75+
pub fn witness_wave(field: &[f64], arc: &[f64]) -> f64 {
76+
witness_from_spectrum(&field_spectrum(field), arc)
77+
}
78+
79+
/// Convenience: does the wave view reproduce the particle view for this `(field,
80+
/// arc)` within `tol`? The probe's pass/fail predicate.
81+
pub fn particle_equals_wave(field: &[f64], arc: &[f64], tol: f64) -> bool {
82+
(witness_particle(field, arc) - witness_wave(field, arc)).abs() <= tol
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
89+
/// Parseval: the standing wave reproduces the pointer-chase walk exactly (fp).
90+
#[test]
91+
fn particle_equals_wave_parseval() {
92+
let field = [0.3, -1.2, 0.7, 2.1, -0.4, 0.9, 1.5, -0.8];
93+
// A witness arc: a signed Markov reference chain over the nodes.
94+
let arc = [1.0, 0.0, -1.0, 1.0, 0.0, 0.0, -1.0, 1.0];
95+
let p = witness_particle(&field, &arc);
96+
let w = witness_wave(&field, &arc);
97+
assert!((p - w).abs() < 1e-9, "particle {p} vs wave {w}");
98+
assert!(particle_equals_wave(&field, &arc, 1e-9));
99+
}
100+
101+
/// The amortization claim: ONE field transform, then many arcs read off it — each
102+
/// matching its particle walk.
103+
#[test]
104+
fn standing_wave_reuses_one_transform() {
105+
let field = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
106+
let spectrum = field_spectrum(&field); // computed once
107+
let arcs = [
108+
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], // single-hop witness
109+
[1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], // coarse dyadic arc
110+
[0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0], // alternating chain
111+
];
112+
for arc in &arcs {
113+
let p = witness_particle(&field, arc);
114+
let w = witness_from_spectrum(&spectrum, arc);
115+
assert!(
116+
(p - w).abs() < 1e-9,
117+
"arc {arc:?}: particle {p} vs wave {w}"
118+
);
119+
}
120+
}
121+
122+
/// Non-power-of-two fields are padded transparently; the identity still holds.
123+
#[test]
124+
fn handles_non_power_of_two_and_ragged_arc() {
125+
let field = [0.5, -0.5, 2.0, 1.0, -1.0]; // length 5 → pads to 8
126+
let arc = [1.0, 1.0, -1.0]; // shorter arc → zero-extended
127+
let p = witness_particle(&field, &arc);
128+
let w = witness_wave(&field, &arc);
129+
assert!((p - w).abs() < 1e-9, "ragged: particle {p} vs wave {w}");
130+
}
131+
132+
/// Degenerate inputs never panic / never NaN.
133+
#[test]
134+
fn degenerate_is_safe() {
135+
assert_eq!(witness_particle(&[], &[]), 0.0);
136+
assert_eq!(witness_wave(&[], &[]), 0.0);
137+
assert!(field_spectrum(&[]).len().is_power_of_two());
138+
assert_eq!(witness_from_spectrum(&[], &[1.0]), 0.0);
139+
}
140+
}

0 commit comments

Comments
 (0)