mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
feat(signal): ADR-084 Pass 2 — sketch-prefilter for EmbeddingHistory::search
Adds `EmbeddingHistory::with_sketch(...)` and `search_prefilter(query, k,
prefilter_factor)`. The prefilter sketches the query, hamming-ranks the
parallel sketch array to take the top `k * prefilter_factor` candidates,
then refines those with exact cosine and returns the top-K.
`EmbeddingHistory::new(...)` is unchanged — sketches are opt-in via the
new constructor. `search_prefilter` falls back to brute-force `search`
when sketches are disabled, so callers never see incorrect results.
ADR-084 acceptance criterion empirically validated:
Synthetic 128-d AETHER-shape, n=256, 16 queries:
k=8, prefilter_factor=4 → 78.9% top-K coverage (FAIL <90%)
k=8, prefilter_factor=8 → ≥90% top-K coverage (PASS)
k=16, prefilter_factor=8 → ≥90% top-K coverage (PASS)
The factor=4 default that I'd planned in Pass 1 falls below the 90% bar
on uniform-random synthetic data. Production callers should use **8**
unless their embeddings carry enough structure (real AETHER traces
likely will) to clear the bar at lower factors. Documented in the
search_prefilter docstring and asserted in
test_search_prefilter_topk_coverage_meets_adr_084.
FIFO eviction now drains the parallel sketches array in lockstep —
test_search_prefilter_evicts_sketches_on_fifo guards against the two
arrays drifting (which would silently corrupt top-K via index
mismatch).
Validated:
- cargo test --workspace --no-default-features → 1,554 passed,
0 failed, 8 ignored (was 1,551; +3 new prefilter tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3200)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
a28f0253c3
commit
e7b2f30d9b
3 changed files with 216 additions and 3 deletions
1
v2/Cargo.lock
generated
1
v2/Cargo.lock
generated
|
|
@ -8434,6 +8434,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-ruvector",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ midstreamer-attractor = { workspace = true }
|
|||
|
||||
# Internal
|
||||
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
|
||||
# ADR-084 Pass 2: sketch-prefilter for the EmbeddingHistory search loop.
|
||||
wifi-densepose-ruvector = { version = "0.3.0", path = "../wifi-densepose-ruvector", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
|
|
|||
|
|
@ -338,25 +338,58 @@ pub struct EmbeddingEntry {
|
|||
///
|
||||
/// In production, this would be backed by an HNSW index for fast
|
||||
/// nearest-neighbor search. This implementation uses brute-force
|
||||
/// cosine similarity for correctness.
|
||||
/// cosine similarity for correctness, with an optional RaBitQ-style
|
||||
/// sketch prefilter (ADR-084) for hot-path queries.
|
||||
#[derive(Debug)]
|
||||
pub struct EmbeddingHistory {
|
||||
entries: Vec<EmbeddingEntry>,
|
||||
/// Per-entry sketch (parallel to `entries`); maintained on push/evict.
|
||||
/// Always populated when `sketch_version` is set.
|
||||
sketches: Vec<wifi_densepose_ruvector::Sketch>,
|
||||
max_entries: usize,
|
||||
embedding_dim: usize,
|
||||
/// Sketch schema version (ADR-084 §"Versioning"). When set, every push
|
||||
/// computes a sketch alongside the float embedding so `search_prefilter`
|
||||
/// can use it. `None` disables the prefilter path entirely (compatible
|
||||
/// with existing callers that never opted in).
|
||||
sketch_version: Option<u16>,
|
||||
}
|
||||
|
||||
impl EmbeddingHistory {
|
||||
/// Create a new embedding history store.
|
||||
/// Create a new embedding history store with the sketch prefilter
|
||||
/// **disabled**. Callers that want the ADR-084 prefilter path should
|
||||
/// use [`EmbeddingHistory::with_sketch`] instead.
|
||||
pub fn new(embedding_dim: usize, max_entries: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
sketches: Vec::new(),
|
||||
max_entries,
|
||||
embedding_dim,
|
||||
sketch_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an embedding entry.
|
||||
/// Create a history store with the ADR-084 sketch prefilter enabled.
|
||||
///
|
||||
/// `sketch_version` is the producing embedding-model version (bump it
|
||||
/// on any model change so callers can invalidate stored sketches
|
||||
/// instead of silently comparing across generations).
|
||||
pub fn with_sketch(
|
||||
embedding_dim: usize,
|
||||
max_entries: usize,
|
||||
sketch_version: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
sketches: Vec::new(),
|
||||
max_entries,
|
||||
embedding_dim,
|
||||
sketch_version: Some(sketch_version),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an embedding entry. If sketches are enabled, also computes
|
||||
/// and stores the per-entry sketch.
|
||||
pub fn push(&mut self, entry: EmbeddingEntry) -> Result<(), LongitudinalError> {
|
||||
if entry.embedding.len() != self.embedding_dim {
|
||||
return Err(LongitudinalError::EmbeddingDimensionMismatch {
|
||||
|
|
@ -366,6 +399,13 @@ impl EmbeddingHistory {
|
|||
}
|
||||
if self.entries.len() >= self.max_entries {
|
||||
self.entries.drain(..1); // FIFO eviction — acceptable for daily-rate inserts
|
||||
if !self.sketches.is_empty() {
|
||||
self.sketches.drain(..1);
|
||||
}
|
||||
}
|
||||
if let Some(sv) = self.sketch_version {
|
||||
let sk = wifi_densepose_ruvector::Sketch::from_embedding(&entry.embedding, sv);
|
||||
self.sketches.push(sk);
|
||||
}
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
|
|
@ -385,6 +425,68 @@ impl EmbeddingHistory {
|
|||
similarities
|
||||
}
|
||||
|
||||
/// ADR-084 Pass 2: sketch-prefiltered K-nearest cosine search.
|
||||
///
|
||||
/// Two-stage pipeline:
|
||||
///
|
||||
/// 1. **Prefilter:** sketch the query, hamming-rank all stored
|
||||
/// sketches, take the top `k * prefilter_factor` candidates.
|
||||
/// 2. **Refine:** compute exact cosine similarity against just those
|
||||
/// candidates and return the top-K by cosine.
|
||||
///
|
||||
/// `prefilter_factor` controls the recall/cost trade-off — larger
|
||||
/// values widen the candidate set (more cosine work, higher top-K
|
||||
/// coverage) and smaller values narrow it (less work, risk of
|
||||
/// missing the true top-K). ADR-084 acceptance is **≥ 90% top-K
|
||||
/// agreement** with the brute-force `search`; on synthetic uniform-
|
||||
/// random 128-d embeddings (the AETHER shape), measured coverage is
|
||||
/// **78.9% at factor=4 (FAIL)** and **≥ 90% at factor=8 (PASS)** —
|
||||
/// so callers should pass at least **8**. Real AETHER traces have
|
||||
/// more structure than uniform noise and usually clear the bar at
|
||||
/// lower factors; recalibrate against your bank.
|
||||
///
|
||||
/// Falls back to [`EmbeddingHistory::search`] if sketches were not
|
||||
/// enabled at construction (`sketch_version = None`) — the caller
|
||||
/// gets correct behaviour either way, just without the speedup.
|
||||
pub fn search_prefilter(
|
||||
&self,
|
||||
query: &[f32],
|
||||
k: usize,
|
||||
prefilter_factor: usize,
|
||||
) -> Vec<(usize, f32)> {
|
||||
let sv = match self.sketch_version {
|
||||
Some(v) => v,
|
||||
None => return self.search(query, k),
|
||||
};
|
||||
if k == 0 || self.entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let query_sk = wifi_densepose_ruvector::Sketch::from_embedding(query, sv);
|
||||
let prefilter_k = (k.saturating_mul(prefilter_factor.max(1))).min(self.entries.len());
|
||||
|
||||
// Stage 1: sketch hamming top-K' over all sketches.
|
||||
// (Inlined here rather than going through SketchBank because
|
||||
// EmbeddingHistory owns the parallel `sketches` array directly.)
|
||||
let mut hamming: Vec<(usize, u32)> = self
|
||||
.sketches
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sk)| (i, sk.distance_unchecked(&query_sk)))
|
||||
.collect();
|
||||
hamming.sort_by_key(|&(_, d)| d);
|
||||
hamming.truncate(prefilter_k);
|
||||
|
||||
// Stage 2: refine the prefilter set with exact cosine.
|
||||
let mut refined: Vec<(usize, f32)> = hamming
|
||||
.into_iter()
|
||||
.map(|(i, _)| (i, cosine_similarity(query, &self.entries[i].embedding)))
|
||||
.collect();
|
||||
refined.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
refined.truncate(k);
|
||||
refined
|
||||
}
|
||||
|
||||
/// Number of entries stored.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
|
|
@ -689,4 +791,112 @@ mod tests {
|
|||
let c = vec![1.0_f32, 0.0, 0.0];
|
||||
assert!((cosine_similarity(&a, &c) - 1.0).abs() < 1e-6, "Same = 1");
|
||||
}
|
||||
|
||||
// ─── ADR-084 Pass 2: sketch-prefilter tests ──────────────────────────────
|
||||
|
||||
/// Deterministic LCG so synthetic test embeddings are reproducible
|
||||
/// without pulling in a `rand` dev-dep just for fixture generation.
|
||||
fn lcg_embedding(dim: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
|
||||
(0..dim)
|
||||
.map(|_| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let u = (s >> 8) as f32 / (1u32 << 24) as f32;
|
||||
u * 2.0 - 1.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_prefilter_falls_back_when_sketches_disabled() {
|
||||
// `EmbeddingHistory::new` does NOT enable sketches; the prefilter
|
||||
// must transparently fall back to brute-force search so callers
|
||||
// never see incorrect results.
|
||||
let mut h = EmbeddingHistory::new(8, 100);
|
||||
for i in 0..5 {
|
||||
h.push(EmbeddingEntry {
|
||||
person_id: i,
|
||||
day_us: i,
|
||||
embedding: lcg_embedding(8, i as u32 + 1),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let q = lcg_embedding(8, 42);
|
||||
let bf = h.search(&q, 3);
|
||||
let pf = h.search_prefilter(&q, 3, 4);
|
||||
assert_eq!(bf, pf, "fallback path must equal brute-force exactly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_prefilter_topk_coverage_meets_adr_084() {
|
||||
// ADR-084 acceptance criterion: prefilter top-K must agree with
|
||||
// brute-force top-K on at least 90% of results. We use a 256-entry
|
||||
// bank of 128-d synthetic embeddings (the AETHER shape) and check
|
||||
// both K=8 and K=16 to span the realistic range.
|
||||
const DIM: usize = 128;
|
||||
const N: usize = 256;
|
||||
const K_VALUES: [usize; 2] = [8, 16];
|
||||
const PREFILTER_FACTOR: usize = 8;
|
||||
const SKETCH_VERSION: u16 = 1;
|
||||
|
||||
let mut h = EmbeddingHistory::with_sketch(DIM, N, SKETCH_VERSION);
|
||||
for i in 0..N {
|
||||
h.push(EmbeddingEntry {
|
||||
person_id: i as u64,
|
||||
day_us: i as u64,
|
||||
embedding: lcg_embedding(DIM, i as u32 + 1),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for &k in &K_VALUES {
|
||||
let mut total_overlap = 0usize;
|
||||
let mut total_expected = 0usize;
|
||||
// 16 different queries to smooth out any single-query luck.
|
||||
for q_seed in 0..16u32 {
|
||||
let q = lcg_embedding(DIM, q_seed.wrapping_add(0xCAFE_BABE));
|
||||
let bf: std::collections::HashSet<usize> =
|
||||
h.search(&q, k).into_iter().map(|(i, _)| i).collect();
|
||||
let pf: std::collections::HashSet<usize> = h
|
||||
.search_prefilter(&q, k, PREFILTER_FACTOR)
|
||||
.into_iter()
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
total_overlap += bf.intersection(&pf).count();
|
||||
total_expected += k;
|
||||
}
|
||||
let coverage = total_overlap as f32 / total_expected as f32;
|
||||
assert!(
|
||||
coverage >= 0.90,
|
||||
"ADR-084 acceptance failed at k={k}: prefilter coverage {coverage:.3} < 0.90"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_prefilter_evicts_sketches_on_fifo() {
|
||||
// FIFO eviction must drop sketches in lockstep with entries; if
|
||||
// the two arrays drift the prefilter would index the wrong sketch
|
||||
// for an entry and silently corrupt top-K results.
|
||||
let mut h = EmbeddingHistory::with_sketch(4, 3, 1);
|
||||
for i in 0..5u32 {
|
||||
h.push(EmbeddingEntry {
|
||||
person_id: i as u64,
|
||||
day_us: i as u64,
|
||||
embedding: lcg_embedding(4, i + 1),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(h.len(), 3);
|
||||
// Sanity: first two entries (day_us 0, 1) evicted.
|
||||
assert_eq!(h.get(0).unwrap().day_us, 2);
|
||||
|
||||
// Prefilter still works post-eviction (no panic, returns valid indices).
|
||||
let q = lcg_embedding(4, 99);
|
||||
let pf = h.search_prefilter(&q, 2, 4);
|
||||
assert_eq!(pf.len(), 2);
|
||||
for (i, _) in &pf {
|
||||
assert!(*i < h.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue