mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
9b07dff298
* refactor(train): hoist canonical PCK/OKS to un-gated metrics_core; fold test_metrics onto production (ADR-155 M1 §8) ADR-155 §8 deferred item: test_metrics.rs reference kernels validated production against their OWN reimplementation — a test that cannot catch a canonical-impl bug (both could be wrong the same way). - Extract canonical_torso_size / pck_canonical / oks_canonical / sigmas / bounding_box_diagonal into a new NON-tch-gated `metrics_core` module, so the single metric definition is reachable under `cargo test --no-default-features` (the `metrics` module is tch-gated). `metrics` re-exports every item → still exactly ONE implementation. - Rewrite tests/test_metrics.rs to assert the PRODUCTION pck_canonical / oks_canonical equal hand-computed fixtures (not a reimplementation): canonical_pck_matches_hand_computed_fixture (corr=3/total=4/pck=0.75), hip↔hip normalizer pin, zero-visible⇒0.0, OKS perfect⇒1.0, fake-Gold pin. - Keep an INDEPENDENT raw-threshold reference kernel only as a differential cross-check: test_kernel_agrees_with_canonical asserts it AGREES with canonical where torso==1.0 (genuine cross-check, not duplication). Grade: MEASURED. test_metrics 10→12 tests, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(sensing-server): relabel divergent live PCK/OKS so they're never conflated with canonical (ADR-155 M1 §2.1/§8 Goal C) Goal C named training_api.rs:804 (torso-HEIGHT PCK). Auditing it surfaced TWO findings the ADR-155 §1 table missed: 1. training_api.rs is an ORPHAN file — not declared `mod` in lib.rs OR main.rs, so it does NOT compile into the crate. It does not drive the live server. 2. The REAL live `best_pck`/`best_oks` (main.rs training path → RVF metadata JSON read by model_manager.rs) come from trainer.rs: - `pck_at_threshold` = RAW-threshold PCK, NO torso normalization (the most divergent kind), printed/serialized as bare "PCK@0.2". - `oks_map` calls `oks_single(area=1.0)` = the EXACT fake-Gold pattern ADR-155 §2.1 claimed closed elsewhere — still live here, inflating best_oks. Resolution = RELABEL (torso/raw math is load-bearing on different data; the pub fns can't be renamed without breaking API; sensing-server has no train/ ndarray dep). Honest unify is a tracked §8 backlog item. - training_api.rs: `compute_pck` → `compute_pck_torso_height` + divergence doc; val_pck/best_pck/val_oks struct fields documented as torso-HEIGHT proxies; logs say `pck_torso_h@0.2`. Test torso_pck_is_labelled_distinctly_from_canonical. - trainer.rs (LIVE): `pck_at_threshold` documented raw-unnormalized; `oks_map` area=1.0 flagged fake-Gold; test pck_at_threshold_is_raw_unnormalized_not_canonical. - main.rs: live print relabelled `pck_raw@0.2` / `oks_map(area=1.0 proxy)`. No wire-format field renames (back-compat); no pub-API rename (no silent break). Grade: MEASURED (relabel + divergence pinned). sensing-server 450→451 lib tests, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-155): mark §8 metric items RESOLVED + audit map + honest §1 under-count correction (M1b Goals A/D) - §8.1: full PCK/OKS audit map (every def: file:line, basis, canonical/ legacy/distinct), the two §8 items marked RESOLVED with resolution+why. - Honest finding: §1's "seven divergent metrics" was an UNDER-count — sensing-server's LIVE trainer.rs has a raw-unnormalized PCK and an area=1.0 fake-Gold OKS the table omitted, and the file §8 named (training_api.rs) is orphaned dead code. §9 honest-limits updated. - Goal D: metrics.rs *_v2 variants confirmed caller-less + deprecated; noted for future cleanup, NOT deleted (public API, tch-gated). - CHANGELOG [Unreleased] Fixed entry. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(ruvector): RaBitQ Pass-2 randomized rotation + topk bugfix (ADR-156 §8) Implements the deferred "Multi-bit / Extended RaBitQ Pass 2" backlog item from ADR-156 §8: a deterministic randomized orthogonal rotation applied before sign-quantization, the published RaBitQ construction (Gao & Long, SIGMOD 2024). Rotation construction: Fast Hadamard Transform + seeded ±1 sign flips ("HD" / randomized Hadamard), O(d log d) time and O(d) memory — a dense d×d rotation is O(d²) and infeasible at the 65,535-d the wire format provisions for. Pads to the next power of two; SplitMix64 seeds the sign stream so index-time and query-time rotations are bit-identical. API is additive and backward-compatible: Pass 1 (`from_embedding`) is untouched; Pass 2 is opt-in via `Sketch::from_embedding_rotated` and `SketchBank::with_rotation` (+ `insert_embedding` / `topk_embedding` / `novelty_embedding` helpers that rotate consistently). Default behaviour is unchanged. While building the Pass-2 coverage harness, found and fixed a PRE-EXISTING correctness bug in `SketchBank::topk`: the n>k heap path used `BinaryHeap<Reverse<(d,id)>>` (a min-heap) but treated its peek as the max, so it returned the k FARTHEST sketches as "nearest". The shipped unit tests only exercised the n≤k fast path, so it went unnoticed. Fixed to a plain max-heap; pinned by `topk_heap_path_returns_nearest` and `tight_clusters_give_high_coverage_with_overfetch` (the latter measured 0.072 on the old code). New tests (+17, 100→117 in the crate): rotation determinism/norm-preservation (`rotation_is_deterministic_for_seed`, `rotation_preserves_norm`), Pass-2 shape-compatibility, `pass2_coverage_not_worse_than_pass1`, and a deterministic coverage report. MEASURED top-K coverage (anisotropic planted-cluster fixture, cosine ground truth; dim=128 N=2048 K=8 64 clusters noise=0.35 128 queries): candidate_k=K=8 : Pass1 36.13% -> Pass2 46.39% (both << 90% bar) candidate_k=24 : Pass1 83.89% -> Pass2 91.60% (Pass2 clears 90%) candidate_k=32 : Pass1/Pass2 100% Honest result: rotation consistently helps (+10pp at strict K), but neither pass clears the ADR-084 90% bar at candidate_k==K on this distribution. Pass 2 reaches 90% only with ~3x over-fetch (the ADR-084 "candidate set" deployment pattern). Multi-bit Pass 3 evaluated separately. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(ruvector): multi-bit Pass-3 experiment + ADR-156/084 measured results Adds the multi-bit half of the ADR-156 §8 "Multi-bit / Extended RaBitQ" item as a MEASURED experiment (coverage::measure_multibit): rotate, then b-bit uniform scalar-quantize each coord, rank by L1 over codes — the natural multi-bit generalization of hamming. Measures the bit/coverage tradeoff the backlog item asked for. MEASURED at the strict bar (candidate_k=K=8, anisotropic planted-cluster fixture, cosine ground truth): Pass1 (1-bit, no rot) 36.13% 16 B/vec Pass2 (1-bit, rot) 46.39% 16 B/vec Pass3 (rot, 2-bit) 54.39% 32 B/vec Pass3 (rot, 3-bit) 66.70% 48 B/vec Pass3 (rot, 4-bit) 74.22% 64 B/vec Honest: multi-bit monotonically helps but even 4-bit (4x memory) reaches only 74% at the strict bar — neither rotation nor <=4-bit multi-bit clears the strict-K 90% bar on this distribution. The bar is met via over-fetch (Pass2 @ candidate_k=24). Tests: multibit_tradeoff_report, multibit_1bit_matches_pass2_approx (+ sanity that 1-bit ~= Pass-2). Docs: - ADR-156 §8 item #2 marked RESOLVED-PARTIAL; §5 #2 grade CLAIMED -> MEASURED-on-our-hardware; new §10 with full measured tables, the topk bugfix disclosure, and graded deferred sub-items. - ADR-084: "Pass 2" section answering the rotation open-question with measured numbers + the topk bug note. - CHANGELOG [Unreleased]: Added (Pass-2 milestone) + Fixed (topk heap). Co-Authored-By: claude-flow <ruv@ruv.net>
354 lines
14 KiB
Rust
354 lines
14 KiB
Rust
//! RaBitQ **Pass 2** — deterministic randomized orthogonal rotation.
|
||
//!
|
||
//! Implements the "Pass 2" deferred in [`crate::sketch`]'s Pass-1 doc and in
|
||
//! [ADR-156 §8](../../../../../docs/adr/ADR-156-ruvector-fusion-beyond-sota.md)
|
||
//! (Multi-bit / Extended RaBitQ). The published *RaBitQ* algorithm
|
||
//! (Gao & Long, SIGMOD 2024) wraps the 1-bit sign-quantization of Pass 1 with
|
||
//! a **randomized orthogonal rotation** `R` applied to every embedding *before*
|
||
//! sign-quantization. The rotation decorrelates coordinates so the per-bit sign
|
||
//! carries more independent information, which gives both the paper's
|
||
//! theoretical error bound and better top-K recall on anisotropic / correlated
|
||
//! embedding distributions (exactly the case ADR-084's "Open questions" flagged
|
||
//! for skewed spectrogram embeddings).
|
||
//!
|
||
//! # Why a Fast Hadamard Transform, not a dense d×d matrix
|
||
//!
|
||
//! A full dense orthogonal matrix `R ∈ ℝ^{d×d}` is **O(d²) memory and O(d²)
|
||
//! time per vector**. ADR-084's wire format already provisions for embeddings
|
||
//! up to `u16::MAX = 65,535` dimensions; a dense rotation there is ~4.3 G
|
||
//! floats (17 GiB) — completely infeasible on the cluster-Pi / edge targets
|
||
//! this sketch is built for.
|
||
//!
|
||
//! Instead we use the **randomized Hadamard transform** (the "HD" construction,
|
||
//! a.k.a. a structured Johnson–Lindenstrauss / fast-JL rotation):
|
||
//!
|
||
//! ```text
|
||
//! R · x = H · D · x
|
||
//! ```
|
||
//!
|
||
//! where `D` is a diagonal matrix of random ±1 sign flips and `H` is the
|
||
//! (normalized) Walsh–Hadamard matrix applied via the **Fast Hadamard
|
||
//! Transform (FHT)**. The FHT is `O(d log d)` time and `O(1)` extra memory
|
||
//! (in-place butterfly); `D` is `O(d)` memory (one sign per dimension, packed).
|
||
//! `H` and `D` are each orthogonal, so `R = H·D` is orthogonal and therefore
|
||
//! **norm-preserving** — a hard requirement for a rotation that must not distort
|
||
//! relative distances. This is the same fast-orthogonal trick used by Fast-JL,
|
||
//! Structured Orthogonal Random Features, and the RaBitQ reference rotation.
|
||
//!
|
||
//! # Determinism (index-time == query-time)
|
||
//!
|
||
//! The rotation **must** be identical when the bank is built and when it is
|
||
//! queried, or the two sign-quantizations live in different rotated frames and
|
||
//! hamming distance becomes meaningless. We therefore derive the ±1 sign flips
|
||
//! deterministically from a stored `u64` seed via a SplitMix64 PRNG — **never**
|
||
//! an unseeded / OS RNG. Two [`Rotation`]s built from the same `(seed, dim)`
|
||
//! produce bit-identical output for the same input (pinned by
|
||
//! `rotation_is_deterministic_for_seed`).
|
||
//!
|
||
//! # Power-of-two padding
|
||
//!
|
||
//! The FHT is defined on lengths that are powers of two. For a `d` that is not
|
||
//! a power of two we pad the (sign-flipped) input with zeros up to the next
|
||
//! power of two `m = next_pow2(d)`, run the length-`m` FHT, and then **read back
|
||
//! the first `d` coordinates**. Zero-padding + orthogonal `H` keeps the
|
||
//! transform norm-preserving on the padded vector; we sign-quantize the first
|
||
//! `d` rotated coordinates so the sketch dimension is unchanged from Pass 1
|
||
//! (API-compatible: same `embedding_dim`, same packed-byte length, same
|
||
//! `SketchBank` schema).
|
||
|
||
/// A deterministic randomized orthogonal rotation (FHT-based) applied to an
|
||
/// embedding before sign-quantization — RaBitQ Pass 2.
|
||
///
|
||
/// Construct once per `(seed, dim)` and reuse for **every** embedding that goes
|
||
/// into the same [`crate::SketchBank`] (and for every query against it). The
|
||
/// seed is stored so the rotation is reproducible across processes and runs.
|
||
///
|
||
/// # Invariants
|
||
///
|
||
/// - `dim` is the source-embedding dimension (the sketch keeps this dimension).
|
||
/// - `padded` is `next_pow2(dim)` — the FHT working length.
|
||
/// - `signs` has exactly `padded` entries (`+1.0` / `-1.0`), derived from
|
||
/// `seed` via SplitMix64. Padding positions get signs too; they only ever
|
||
/// multiply zeros, so their value is irrelevant to the result but they keep
|
||
/// the construction uniform.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Rotation {
|
||
/// Source-embedding dimension; the rotated sketch keeps this dimension.
|
||
dim: usize,
|
||
/// FHT working length = `next_pow2(dim)`.
|
||
padded: usize,
|
||
/// Random ±1 sign flips (the diagonal `D`), length `padded`.
|
||
signs: Vec<f32>,
|
||
/// The seed the sign flips were derived from (stored for reproducibility).
|
||
seed: u64,
|
||
}
|
||
|
||
impl Rotation {
|
||
/// Build a rotation for `dim`-dimensional embeddings from a fixed `seed`.
|
||
///
|
||
/// The same `(seed, dim)` always yields a bit-identical rotation, so an
|
||
/// index built with `Rotation::new(seed, d)` and a query rotated with a
|
||
/// freshly-constructed `Rotation::new(seed, d)` agree exactly.
|
||
///
|
||
/// `dim == 0` yields an identity (empty) rotation — `apply` returns an
|
||
/// empty vector — which keeps the constructor total (no panic on a
|
||
/// degenerate dimension).
|
||
pub fn new(seed: u64, dim: usize) -> Self {
|
||
let padded = next_pow2(dim);
|
||
let mut signs = Vec::with_capacity(padded);
|
||
// SplitMix64: a tiny, well-distributed, fully deterministic PRNG. We
|
||
// only need a reproducible stream of bits to pick ±1 per dimension;
|
||
// SplitMix64 is the standard seeding generator and is more than
|
||
// adequate (and far better-mixed than the LCG used for bench fixtures).
|
||
let mut state = seed;
|
||
for _ in 0..padded {
|
||
state = split_mix64(&mut state);
|
||
// Use the top bit of the mixed word to choose the sign.
|
||
signs.push(if state >> 63 == 1 { 1.0 } else { -1.0 });
|
||
}
|
||
Self {
|
||
dim,
|
||
padded,
|
||
signs,
|
||
seed,
|
||
}
|
||
}
|
||
|
||
/// The seed this rotation was derived from (for serialization / audit).
|
||
#[inline]
|
||
pub fn seed(&self) -> u64 {
|
||
self.seed
|
||
}
|
||
|
||
/// Source-embedding dimension this rotation expects.
|
||
#[inline]
|
||
pub fn dim(&self) -> usize {
|
||
self.dim
|
||
}
|
||
|
||
/// FHT working length (`next_pow2(dim)`).
|
||
#[inline]
|
||
pub fn padded_dim(&self) -> usize {
|
||
self.padded
|
||
}
|
||
|
||
/// Apply the rotation `R = H·D` to `embedding`, returning the first `dim`
|
||
/// rotated coordinates.
|
||
///
|
||
/// If `embedding.len() != dim` the input is treated charitably: it is
|
||
/// truncated or zero-extended to `dim` before rotation. This mirrors
|
||
/// Pass 1's saturating tolerance and keeps the call total.
|
||
///
|
||
/// The returned vector has length `self.dim`. Its L2 norm equals the L2
|
||
/// norm of the (dim-truncated / zero-extended) input up to floating-point
|
||
/// rounding — see [`Rotation::apply`] tests and
|
||
/// `rotation_preserves_norm`.
|
||
pub fn apply(&self, embedding: &[f32]) -> Vec<f32> {
|
||
if self.dim == 0 {
|
||
return Vec::new();
|
||
}
|
||
// Build the padded, sign-flipped working buffer: buf = D · x, then 0-pad.
|
||
let mut buf = vec![0.0f32; self.padded];
|
||
let n = embedding.len().min(self.dim);
|
||
for i in 0..n {
|
||
buf[i] = embedding[i] * self.signs[i];
|
||
}
|
||
// (positions n..dim and dim..padded stay zero — zero-extend + pad)
|
||
|
||
// In-place normalized Fast Hadamard Transform.
|
||
fht_normalized(&mut buf);
|
||
|
||
// Read back the first `dim` rotated coordinates as the sketch input.
|
||
buf.truncate(self.dim);
|
||
buf
|
||
}
|
||
}
|
||
|
||
/// Smallest power of two `>= n` (with `next_pow2(0) == 1`, `next_pow2(1) == 1`).
|
||
///
|
||
/// Pulled out (and `pub(crate)`) so the sketch layer and tests can reason about
|
||
/// the FHT working length without duplicating the rule.
|
||
#[inline]
|
||
pub(crate) fn next_pow2(n: usize) -> usize {
|
||
if n <= 1 {
|
||
return 1;
|
||
}
|
||
// `n` here is small relative to usize::MAX in every realistic embedding
|
||
// (<= 65_535), so `next_power_of_two` cannot overflow.
|
||
n.next_power_of_two()
|
||
}
|
||
|
||
/// SplitMix64 step: advance `state` and return a well-mixed 64-bit word.
|
||
///
|
||
/// Reference algorithm (public domain, by Sebastiano Vigna). Deterministic and
|
||
/// dependency-free — exactly what we need for a reproducible sign stream.
|
||
#[inline]
|
||
fn split_mix64(state: &mut u64) -> u64 {
|
||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||
let mut z = *state;
|
||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
z ^ (z >> 31)
|
||
}
|
||
|
||
/// In-place **normalized** Fast Hadamard Transform on a power-of-two slice.
|
||
///
|
||
/// Computes `y = (1/√m) · H_m · x` in place, where `H_m` is the `m × m`
|
||
/// Walsh–Hadamard matrix and `m = buf.len()` is a power of two. The `1/√m`
|
||
/// normalization makes `H` orthogonal (`HᵀH = I`), so the transform preserves
|
||
/// the L2 norm. Runs in `O(m log m)` with `O(1)` extra memory (the standard
|
||
/// iterative butterfly).
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Debug-asserts that `buf.len()` is a power of two. Callers in this module
|
||
/// always pass `next_pow2(dim)`, so this never fires in practice; it documents
|
||
/// the precondition.
|
||
fn fht_normalized(buf: &mut [f32]) {
|
||
let m = buf.len();
|
||
debug_assert!(m.is_power_of_two(), "FHT length must be a power of two");
|
||
if m <= 1 {
|
||
return;
|
||
}
|
||
// Unnormalized in-place Walsh–Hadamard butterfly.
|
||
let mut h = 1usize;
|
||
while h < m {
|
||
let mut i = 0usize;
|
||
while i < m {
|
||
for j in i..i + h {
|
||
let x = buf[j];
|
||
let y = buf[j + h];
|
||
buf[j] = x + y;
|
||
buf[j + h] = x - y;
|
||
}
|
||
i += h * 2;
|
||
}
|
||
h *= 2;
|
||
}
|
||
// Normalize by 1/√m so H is orthogonal (norm-preserving).
|
||
let inv_sqrt_m = 1.0f32 / (m as f32).sqrt();
|
||
for v in buf.iter_mut() {
|
||
*v *= inv_sqrt_m;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn l2(v: &[f32]) -> f32 {
|
||
v.iter().map(|&x| x * x).sum::<f32>().sqrt()
|
||
}
|
||
|
||
#[test]
|
||
fn next_pow2_rounds_up() {
|
||
assert_eq!(next_pow2(0), 1);
|
||
assert_eq!(next_pow2(1), 1);
|
||
assert_eq!(next_pow2(2), 2);
|
||
assert_eq!(next_pow2(3), 4);
|
||
assert_eq!(next_pow2(128), 128);
|
||
assert_eq!(next_pow2(129), 256);
|
||
assert_eq!(next_pow2(200), 256);
|
||
assert_eq!(next_pow2(65_535), 65_536);
|
||
}
|
||
|
||
#[test]
|
||
fn fht_is_norm_preserving_on_power_of_two() {
|
||
// Pure FHT (no sign flips) must preserve L2 norm to fp tolerance.
|
||
let mut v: Vec<f32> = (0..8).map(|i| (i as f32 - 3.5) * 0.7).collect();
|
||
let before = l2(&v);
|
||
fht_normalized(&mut v);
|
||
let after = l2(&v);
|
||
assert!(
|
||
(before - after).abs() < 1e-5,
|
||
"FHT changed norm: {before} -> {after}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fht_self_inverse_normalized() {
|
||
// Normalized H is symmetric and orthogonal, so H·H·x == x.
|
||
let original: Vec<f32> = vec![1.0, -2.0, 3.0, 0.5];
|
||
let mut v = original.clone();
|
||
fht_normalized(&mut v);
|
||
fht_normalized(&mut v);
|
||
for (a, b) in original.iter().zip(v.iter()) {
|
||
assert!((a - b).abs() < 1e-5, "H·H·x != x: {a} vs {b}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rotation_is_deterministic_for_seed() {
|
||
// Two rotations from the same (seed, dim) must produce identical
|
||
// output for the same input — the index-time == query-time contract.
|
||
let r1 = Rotation::new(0xDEAD_BEEF_CAFE_1234, 130);
|
||
let r2 = Rotation::new(0xDEAD_BEEF_CAFE_1234, 130);
|
||
let x: Vec<f32> = (0..130).map(|i| (i as f32 * 0.31).sin()).collect();
|
||
let a = r1.apply(&x);
|
||
let b = r2.apply(&x);
|
||
assert_eq!(a.len(), 130);
|
||
assert_eq!(a, b, "same seed must give identical rotation");
|
||
|
||
// A different seed must (almost surely) differ.
|
||
let r3 = Rotation::new(0x0000_0000_0000_0001, 130);
|
||
let c = r3.apply(&x);
|
||
assert_ne!(a, c, "different seed must give different rotation");
|
||
}
|
||
|
||
#[test]
|
||
fn rotation_preserves_norm() {
|
||
// R = H·D is orthogonal; on a power-of-two dim the first `dim`
|
||
// coordinates ARE the whole transform, so norm is preserved exactly
|
||
// (to fp tolerance). We test a power-of-two dim for the exact claim.
|
||
let r = Rotation::new(42, 128);
|
||
let x: Vec<f32> = (0..128).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.5).collect();
|
||
let y = r.apply(&x);
|
||
let before = l2(&x);
|
||
let after = l2(&y);
|
||
assert!(
|
||
(before - after).abs() < 1e-3 * before.max(1.0),
|
||
"rotation changed norm: {before} -> {after}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rotation_non_power_of_two_preserves_norm_via_padding() {
|
||
// For a non-power-of-two dim, reading back the first `dim` coords of a
|
||
// padded FHT only preserves norm if the padded tail carries ~no energy.
|
||
// We assert the rotated norm does not EXCEED the input norm (the padded
|
||
// transform is non-expansive on the truncated read-back) and stays
|
||
// within a loose band — enough to confirm padding is sane, not a hard
|
||
// exact-norm claim.
|
||
let r = Rotation::new(7, 130); // pads 130 -> 256
|
||
assert_eq!(r.padded_dim(), 256);
|
||
let x: Vec<f32> = (0..130).map(|i| (i as f32 * 0.13).cos()).collect();
|
||
let y = r.apply(&x);
|
||
assert_eq!(y.len(), 130);
|
||
let before = l2(&x);
|
||
let after = l2(&y);
|
||
// Truncated read-back is non-expansive: ||y|| <= ||Hx|| == ||x||.
|
||
assert!(
|
||
after <= before + 1e-4,
|
||
"truncated rotation expanded norm: {before} -> {after}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rotation_dim_zero_is_empty() {
|
||
let r = Rotation::new(1, 0);
|
||
assert!(r.apply(&[]).is_empty());
|
||
assert!(r.apply(&[1.0, 2.0]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn rotation_handles_ragged_input() {
|
||
// Charitable length handling: short input zero-extends, long truncates.
|
||
let r = Rotation::new(99, 64);
|
||
let short = r.apply(&[1.0, 2.0, 3.0]); // zero-extended to 64
|
||
assert_eq!(short.len(), 64);
|
||
let long: Vec<f32> = (0..200).map(|i| i as f32).collect();
|
||
let truncated = r.apply(&long); // truncated to 64
|
||
assert_eq!(truncated.len(), 64);
|
||
}
|
||
}
|