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:
ruv 2026-04-26 00:06:53 -04:00
parent a28f0253c3
commit e7b2f30d9b
3 changed files with 216 additions and 3 deletions

1
v2/Cargo.lock generated
View file

@ -8434,6 +8434,7 @@ dependencies = [
"serde_json",
"thiserror 1.0.69",
"wifi-densepose-core",
"wifi-densepose-ruvector",
]
[[package]]

View file

@ -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"] }

View file

@ -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());
}
}
}