Skip to content

Commit d3b608f

Browse files
committed
feat(hpc): edge-codec flavors + reliability stats (measure all, validate/invalidate)
Two reusable hpc modules + a comparison harness so edge encodings can be measured and selected on evidence rather than guessed: - hpc::reliability — Pearson r, Spearman ρ, Cronbach α, ICC(2,1) absolute agreement, plus a bundled FidelityReport. General (&[f64]→f64) primitives; the measurement layer for validate/invalidate work (consumable by jc/pillar). - hpc::edge_codec — three commensurable quantizers sharing the canonical 16-byte edge block by INTERPRETATION, not layout: CoarseOnly (1 B palette index), CoarseResidue (1 + D/2, value-slab signed-4-bit residue), Pq32x4 (16 B = 32 subquantizers × 4-bit, the turbovec PQ model). Compact seeded k-means; nibble packing lo=code[2t]/hi=code[2t+1]. - examples/edge_codec_compare — encodes every flavor across blob/continuous regimes, reports the full reliability suite on pairwise-distance preservation, and checks the AMX matmul_i8_to_i32 assignment matches scalar. Measured: CoarseResidue dominates agreement (ICC 0.97-0.99, ρ 0.98, α 0.99); Pq32x4 preserves rank (ρ 0.60-0.67) but not absolute distance (ICC 0.11-0.29); CoarseOnly collapses on continuous data (ICC 0.003). AMX assign 100% vs scalar. 16 unit + 9 doctests; clippy -D warnings clean. Also silences a pre-existing excessive_precision lint in golden_helix_probe. https://claude.ai/code/session_01D2WSmezQBNC3bUdHuGfGmo
1 parent e563fdc commit d3b608f

6 files changed

Lines changed: 1100 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ required-features = ["std"]
6969
name = "edge_residue_probe"
7070
required-features = ["std"]
7171

72+
[[example]]
73+
name = "edge_codec_compare"
74+
required-features = ["std"]
75+
7276
[dependencies]
7377
num-integer = { workspace = true }
7478
num-traits = { workspace = true }

examples/edge_codec_compare.rs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
//! Edge-codec flavor comparison — measure ALL flavors, validate/invalidate.
2+
//!
3+
//! For each data regime, encodes the same vectors with the three edge-codec
4+
//! flavors and reports the full reliability suite (Pearson r, Spearman ρ,
5+
//! ICC(2,1), Cronbach α) on DISTANCE PRESERVATION (true pairwise L2 vs
6+
//! reconstructed pairwise L2 — the metric that matters for nearest-neighbour
7+
//! order), plus per-vector reconstruction rel-L2 and cosine.
8+
//!
9+
//! CoarseOnly 1 B/vec palette index (the EdgeBlock byte as-is)
10+
//! CoarseResidue 1 + D/2 palette + value-slab signed-4-bit residue
11+
//! Pq32x4 16 B 32 subquantizers × 4-bit (the edge block as PQ)
12+
//!
13+
//! The point: a class/schema picks the flavor by its fidelity/byte tradeoff, and
14+
//! these are the numbers that justify the pick. The deterministic part (nearest
15+
//! centroid) is also shown running on the AMX `matmul_i8_to_i32` tile path,
16+
//! bit-checked against the scalar assignment.
17+
//!
18+
//! RUSTFLAGS="-C target-cpu=native" cargo run --release --example edge_codec_compare
19+
20+
use std::time::Instant;
21+
22+
use ndarray::hpc::edge_codec::{reconstruct_coarse, CoarseResidueCodec, Codebook, ProductQuantizer};
23+
use ndarray::hpc::reliability::FidelityReport;
24+
use ndarray::simd::{amx_available, matmul_i8_to_i32};
25+
use ndarray::{ArrayView2, ArrayViewMut2};
26+
27+
fn splitmix(s: &mut u64) -> f32 {
28+
*s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
29+
let mut z = *s;
30+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
31+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
32+
z ^= z >> 31;
33+
(z >> 40) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0 // [-1, 1)
34+
}
35+
36+
/// Clustered data: each vector = a random centroid + noise (the regime where a
37+
/// coarse code is meaningful and the residue captures the within-cell offset).
38+
fn gen_blobs(n: usize, dim: usize, k: usize, noise: f32, seed: u64) -> Vec<f32> {
39+
let mut s = seed;
40+
let centers: Vec<f32> = (0..k * dim).map(|_| splitmix(&mut s)).collect();
41+
let mut data = vec![0.0f32; n * dim];
42+
for i in 0..n {
43+
let c = (splitmix(&mut s).abs() * k as f32) as usize % k;
44+
for d in 0..dim {
45+
data[i * dim + d] = centers[c * dim + d] + noise * splitmix(&mut s);
46+
}
47+
}
48+
data
49+
}
50+
51+
/// Continuous high-dimensional data (no cluster structure): the regime where a
52+
/// coarse codebook can't tile the space and product quantization pulls ahead.
53+
fn gen_continuous(n: usize, dim: usize, seed: u64) -> Vec<f32> {
54+
let mut s = seed;
55+
(0..n * dim).map(|_| splitmix(&mut s)).collect()
56+
}
57+
58+
fn l2(a: &[f32], b: &[f32]) -> f64 {
59+
a.iter()
60+
.zip(b)
61+
.map(|(x, y)| ((x - y) as f64).powi(2))
62+
.sum::<f64>()
63+
.sqrt()
64+
}
65+
66+
fn cosine(a: &[f32], b: &[f32]) -> f64 {
67+
let mut dot = 0.0;
68+
let mut na = 0.0;
69+
let mut nb = 0.0;
70+
for (x, y) in a.iter().zip(b) {
71+
dot += (*x as f64) * (*y as f64);
72+
na += (*x as f64).powi(2);
73+
nb += (*y as f64).powi(2);
74+
}
75+
if na < 1e-24 || nb < 1e-24 {
76+
0.0
77+
} else {
78+
dot / (na.sqrt() * nb.sqrt())
79+
}
80+
}
81+
82+
/// Deterministic candidate pairs (i, j) for the distance-preservation metric.
83+
fn sample_pairs(n: usize, m: usize, seed: u64) -> Vec<(usize, usize)> {
84+
let mut s = seed;
85+
let mut next = || {
86+
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
87+
let mut z = s;
88+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
89+
z ^= z >> 31;
90+
z
91+
};
92+
(0..m)
93+
.map(|_| {
94+
let i = (next() as usize) % n;
95+
let mut j = (next() as usize) % n;
96+
if j == i {
97+
j = (j + 1) % n;
98+
}
99+
(i, j)
100+
})
101+
.collect()
102+
}
103+
104+
/// One flavor's measured row: byte cost + reconstruction + distance fidelity.
105+
fn report_flavor(
106+
name: &str, bytes_per_vec: f64, data: &[f32], recon: &[f32], n: usize, dim: usize, pairs: &[(usize, usize)],
107+
) {
108+
// Per-vector reconstruction quality.
109+
let mut rel_num = 0.0;
110+
let mut rel_den = 0.0;
111+
let mut cos_sum = 0.0;
112+
for i in 0..n {
113+
let v = &data[i * dim..(i + 1) * dim];
114+
let r = &recon[i * dim..(i + 1) * dim];
115+
rel_num += l2(v, r);
116+
rel_den += v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
117+
cos_sum += cosine(v, r);
118+
}
119+
let recon_rel = rel_num / rel_den.max(1e-12);
120+
let recon_cos = cos_sum / n as f64;
121+
122+
// Distance preservation: true vs reconstructed pairwise L2.
123+
let true_d: Vec<f64> = pairs
124+
.iter()
125+
.map(|&(i, j)| l2(&data[i * dim..(i + 1) * dim], &data[j * dim..(j + 1) * dim]))
126+
.collect();
127+
let rec_d: Vec<f64> = pairs
128+
.iter()
129+
.map(|&(i, j)| l2(&recon[i * dim..(i + 1) * dim], &recon[j * dim..(j + 1) * dim]))
130+
.collect();
131+
let f = FidelityReport::compute(&true_d, &rec_d);
132+
133+
println!(
134+
" {name:<14} {bytes_per_vec:>6.1} B | recon rel-L2 {recon_rel:.4} cos {recon_cos:.4} | dist: r {:.4} ρ {:.4} ICC {:.4} α {:.4}",
135+
f.pearson, f.spearman, f.icc, f.cronbach
136+
);
137+
}
138+
139+
/// AMX vs scalar assignment agreement + throughput (the deterministic part).
140+
fn amx_assign_demo(data: &[f32], cb: &Codebook, n: usize, dim: usize) {
141+
if !amx_available() {
142+
println!(" (AMX unavailable — deterministic assign runs scalar)");
143+
return;
144+
}
145+
let k = cb.k;
146+
let q = |x: &[f32]| -> (Vec<i8>, f32) {
147+
let amax = x.iter().fold(0.0f32, |a, &v| a.max(v.abs())).max(1e-12);
148+
let sc = 127.0 / amax;
149+
(
150+
x.iter()
151+
.map(|&v| (v * sc).round().clamp(-127.0, 127.0) as i8)
152+
.collect(),
153+
sc,
154+
)
155+
};
156+
let (v_i8, _) = q(data);
157+
let (cb_i8, _) = q(&cb.centroids);
158+
let mut cbt = vec![0i8; dim * k]; // D×K transpose
159+
for c in 0..k {
160+
for d in 0..dim {
161+
cbt[d * k + c] = cb_i8[c * dim + d];
162+
}
163+
}
164+
let mut g = vec![0i32; n * k];
165+
let t0 = Instant::now();
166+
matmul_i8_to_i32(
167+
ArrayView2::from_shape((n, dim), &v_i8[..]).unwrap(),
168+
ArrayView2::from_shape((dim, k), &cbt[..]).unwrap(),
169+
ArrayViewMut2::from_shape((n, k), &mut g[..]).unwrap(),
170+
)
171+
.unwrap();
172+
let ns = t0.elapsed().as_nanos() as f64;
173+
let cnorm: Vec<i32> = (0..k)
174+
.map(|c| (0..dim).map(|d| (cb_i8[c * dim + d] as i32).pow(2)).sum())
175+
.collect();
176+
let mut agree = 0usize;
177+
for i in 0..n {
178+
let mut best = i32::MIN;
179+
let mut bj = 0u32;
180+
for j in 0..k {
181+
let score = 2 * g[i * k + j] - cnorm[j];
182+
if score > best {
183+
best = score;
184+
bj = j as u32;
185+
}
186+
}
187+
if bj == cb.assign(&data[i * dim..(i + 1) * dim]) {
188+
agree += 1;
189+
}
190+
}
191+
let macs = (n * k * dim) as f64;
192+
println!(
193+
" AMX assign: {:.0} ns ({:.1} GMAC/s), agrees with scalar on {:.1}% of vectors",
194+
ns,
195+
macs / ns,
196+
100.0 * agree as f64 / n as f64
197+
);
198+
}
199+
200+
fn run(label: &str, data: &[f32], n: usize, dim: usize, k: usize) {
201+
println!("\n== {label} (N={n} D={dim} K={k}) ==");
202+
let pairs = sample_pairs(n, 4096, 0xF00D);
203+
204+
// Flavor 1: coarse only.
205+
let cb = Codebook::train(data, n, dim, k, 12, 1);
206+
let mut recon_coarse = vec![0.0f32; n * dim];
207+
for i in 0..n {
208+
let idx = cb.assign(&data[i * dim..(i + 1) * dim]);
209+
recon_coarse[i * dim..(i + 1) * dim].copy_from_slice(&reconstruct_coarse(&cb, idx));
210+
}
211+
report_flavor("CoarseOnly", 1.0, data, &recon_coarse, n, dim, &pairs);
212+
213+
// Flavor 2: coarse + per-dim 4-bit residue.
214+
let crc = CoarseResidueCodec::fit(data, n, dim, k, 12, 1);
215+
let mut recon_res = vec![0.0f32; n * dim];
216+
for i in 0..n {
217+
let code = crc.encode(&data[i * dim..(i + 1) * dim]);
218+
recon_res[i * dim..(i + 1) * dim].copy_from_slice(&crc.reconstruct(&code));
219+
}
220+
report_flavor("CoarseResidue", 1.0 + dim as f64 / 2.0, data, &recon_res, n, dim, &pairs);
221+
222+
// Flavor 3: product quantizer 32×4-bit (16 B).
223+
if dim.is_multiple_of(32) {
224+
let pq = ProductQuantizer::fit(data, n, dim, 32, 12, 2);
225+
let mut recon_pq = vec![0.0f32; n * dim];
226+
for i in 0..n {
227+
let code = pq.encode(&data[i * dim..(i + 1) * dim]);
228+
recon_pq[i * dim..(i + 1) * dim].copy_from_slice(&pq.reconstruct(&code));
229+
}
230+
report_flavor("Pq32x4", 16.0, data, &recon_pq, n, dim, &pairs);
231+
} else {
232+
println!(" Pq32x4 (skipped — D={dim} not divisible by 32)");
233+
}
234+
235+
amx_assign_demo(data, &cb, n, dim);
236+
}
237+
238+
fn main() {
239+
println!("== Edge-codec flavor comparison (measure all, validate/invalidate) ==");
240+
println!("amx_available() = {}", amx_available());
241+
println!("metrics: recon rel-L2/cosine (per-vector) · dist r/ρ/ICC/α (pairwise L2 preservation)");
242+
243+
let (n, dim, k) = (4096, 128, 256);
244+
run("blobs σ=0.15", &gen_blobs(n, dim, k, 0.15, 0x1111), n, dim, k);
245+
run("blobs σ=0.30", &gen_blobs(n, dim, k, 0.30, 0x2222), n, dim, k);
246+
run("continuous", &gen_continuous(n, dim, 0x3333), n, dim, k);
247+
}

examples/golden_helix_probe.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
//!
2727
//! cargo run --release --example golden_helix_probe
2828
29+
// √5 to full f64 precision; the literal is intentionally exact (π(3−√5)).
30+
#[allow(clippy::excessive_precision)]
2931
const GAMMA: f64 = std::f64::consts::PI * (3.0 - 2.2360679774997896); // π(3−√5), golden angle
3032

3133
/// Golden-spiral hemisphere unit vectors (the helix node directions).

0 commit comments

Comments
 (0)