mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
17509a2a41
* feat(ruvector): ADR-084 Pass 1 — sketch module foundation
Implements Pass 1 of ADR-084 (RaBitQ similarity sensor): a thin
RuView-flavored API over `ruvector_core::quantization::BinaryQuantized`,
exposed at `wifi_densepose_ruvector::{Sketch, SketchBank, SketchError}`.
API surface:
- `Sketch::from_embedding(&[f32], sketch_version: u16)` — sign-quantize
a dense embedding into a 1-bit-per-dim packed sketch.
- `Sketch::distance` — hamming distance with schema-mismatch error.
- `Sketch::distance_unchecked` — hot-path variant for sketches already
validated as same-schema.
- `SketchBank::insert/topk/novelty` — bank with caller-assigned u32 IDs,
schema locked at first insert, novelty = min_distance / embedding_dim.
Schema versioning (`sketch_version: u16` + `embedding_dim: u16`) prevents
silent comparisons across embedding-model generations. Bumping the model
forces re-sketch of the candidate bank.
Pass 1 establishes the API and unit-test foundation. Acceptance criteria
(8x-30x compare-cost reduction, 90% top-K coverage, <1pp accuracy regression)
are measured per-site in Passes 2-5.
Validated:
- 12 new tests pass (sketch construction, hamming, top-K ordering,
schema lock, schema rejection, novelty)
- cargo test --workspace --no-default-features → 1,551 passed, 0 failed,
8 ignored (was 1,539 before; +12 new tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #117300)
Co-Authored-By: claude-flow <ruv@ruv.net>
* bench(ruvector): ADR-084 acceptance — sketch-vs-float compare cost
Adds sketch_bench measuring the first ADR-084 acceptance criterion
(8x-30x compare cost reduction) at three dimensions and a realistic
top-K@k=8 over 1024 sketches.
Measured (Windows host, criterion --warm-up 1s --measurement 3s):
compare_d512:
float_l2: 197.03 ns/op
float_cosine: 231.17 ns/op
sketch_hamming: 4.56 ns/op → 43-51x speedup
topk_d128_n1024_k8:
float_l2_topk: 47.59 us
sketch_hamming: 6.34 us → 7.5x speedup
Pair-wise compare exceeds the 8-30x acceptance criterion by an order
of magnitude. Top-K is at 7.5x — close to the threshold; the sort
dominates at this bank size, which is a Pass 1.5 optimization
opportunity (partial-sort heap for small K).
Co-Authored-By: claude-flow <ruv@ruv.net>
* perf(ruvector): ADR-084 Pass 1.5 — partial-sort heap in SketchBank::topk
Replace `sort_by_key + truncate` (O(n log n)) with a fixed-size max-heap
(O(n log k)) for top-K queries when n > k. Fast path when n ≤ k stays
on the simple sort.
Bench at d=128, n=1024, k=8 (Windows host, criterion 3s measurement):
Before (sort + truncate): 6.34 µs/op
After (heap): 3.83 µs/op -39.4% / +1.65× faster
Combined with the 32× memory shrink and 47.6 µs → 3.83 µs total path
saving:
topk_d128_n1024_k8 vs float_l2_topk:
Pass 1 sort_by_key: 47.59 µs / 6.34 µs = 7.5× speedup
Pass 1.5 heap: 47.59 µs / 3.83 µs = 12.4× speedup
Now over the ADR-084 acceptance criterion of 8× minimum. Heap pays off
strictly more at larger n; benchmark at n=4096 is a Pass-2 follow-up.
Co-Authored-By: claude-flow <ruv@ruv.net>
* 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>
* bench(signal): ADR-084 Pass 2 — end-to-end search_prefilter speedup
Measures EmbeddingHistory::search_prefilter (sketch + cosine refine)
vs the brute-force EmbeddingHistory::search baseline at three realistic
AETHER bank sizes, with the empirically validated prefilter_factor=8.
Measured (Windows host, criterion --warm-up 1s --measurement 3s):
d=128, k=8:
n=256 brute_force_cosine = 31.98 us, prefilter = 13.78 us → 2.3x
n=1024 brute_force_cosine = 110.4 us, prefilter = 16.64 us → 6.6x
n=4096 brute_force_cosine = 507.4 us, prefilter = 66.37 us → 7.6x
Speedup grows with bank size (sketch overhead is fixed; brute-force
scales linearly with n). At n=4k the prefilter approaches the 8x
ADR-084 acceptance criterion; at n=10k+ (realistic multi-day
deployment banks) it crosses cleanly. Below n=512 the brute-force
path is already cheap (sub-50 us) so the prefilter's narrower wins
don't materially affect the hot path.
Coverage acceptance (≥90% top-K agreement) is exercised in the
unit-test suite, not the bench. The bench measures cost only.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(signal): ADR-084 Pass 3 — EmbeddingHistory::novelty primitive
Adds the cluster-Pi novelty-sensor primitive: `EmbeddingHistory::novelty(query)`
returns `Option<f32>` in [0.0, 1.0] where 0.0 = exact-match-in-bank
and 1.0 = no-overlap. Returns None when sketches are disabled so
callers can fall back gracefully (existing `EmbeddingHistory::new`
constructor stays sketch-disabled).
This is the building block of the cluster-Pi novelty gate
described in ADR-084 §"cluster-Pi novelty sensor": each sensor node
maintains a bank of recent feature vectors, the gate scores the
incoming frame's novelty against the bank, and the heavy CNN /
pose-model wake gate consumes the score.
Wiring novelty into sensing-server's NodeState happens in a
follow-up — that's a ~50-line surgical change touching main.rs that
deserves its own commit. This patch lands the primitive + tests so
the wiring is straightforward.
Three regression tests added:
- test_novelty_returns_none_without_sketches
(graceful fallback when bank is sketch-less)
- test_novelty_zero_for_exact_match_one_for_empty_bank
(semantic boundaries)
- test_novelty_decreases_as_bank_grows_around_query
(gradient direction — guards against reversed comparator)
Validated:
- cargo test --workspace --no-default-features → 1,557 passed,
0 failed, 8 ignored (was 1,554; +3 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #7600)
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3 — wire novelty into NodeState
Wires the EmbeddingHistory::novelty primitive (Pass 3 prior commit)
into the per-node frame ingestion path on the cluster Pi. Each
incoming CSI frame now updates a per-node sketch bank of the last
6.4 s of feature vectors and produces a novelty score in [0.0, 1.0]
that downstream model-wake gates can consume.
Two NodeState structs were touched (one in types.rs and a
refactoring-leftover duplicate in main.rs that the call site uses);
both gain feature_history + last_novelty_score fields and an
update_novelty helper that:
- truncates / zero-pads incoming amplitudes to NOVELTY_VECTOR_DIM (56)
- scores novelty *before* inserting (so a frame doesn't see itself)
- FIFO-evicts when the bank reaches NOVELTY_HISTORY_CAPACITY (64)
Wired at the per-node ESP32 frame path in main.rs:3772 (immediately
before frame_history.push_back). Existing call sites that operate on
the singleton SensingState (not per-node) intentionally untouched —
they will be wired in a follow-up alongside the WebSocket update
envelope's novelty_score field.
Two new unit tests in novelty_tests:
- first_frame_yields_max_novelty_then_zero_on_repeat
(semantic boundaries: empty bank = 1.0, exact repeat = 0.0)
- handles_short_and_long_amplitude_vectors
(truncate / zero-pad robustness across hardware variants)
Validated:
- cargo test --workspace --no-default-features → 1,559 passed,
0 failed, 8 ignored (was 1,557; +2 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3900)
Co-Authored-By: claude-flow <ruv@ruv.net>
* hardening(ruvector): L2 from PR #435 review — overflow on >u16::MAX dims
Pass 1.6 hardening, addressing L2 finding from the security review on
PR #435 (https://github.com/ruvnet/RuView/pull/435#issuecomment-4321285519):
The original `Sketch::from_embedding` used `debug_assert!` for the
`embedding.len() <= u16::MAX` invariant, which compiled out in release
builds. A caller passing a 65,536+ -dim embedding would silently
truncate the dimension count via `as u16` cast — two over-long inputs
would then compare as same-dimensional rather than as 64k vs 70k, and
the dimension confusion would not surface anywhere.
Two-part fix:
- `from_embedding` (infallible) now SATURATES `embedding_dim` to
`u16::MAX` rather than truncating. Two over-long inputs still get
packed bit-correctly by `BinaryQuantized` and the saturated dim is
consistent across both, so they compare predictably (just with an
upper-bounded distance).
- `try_from_embedding` (new, fallible) returns
`Err(SketchError::EmbeddingDimOverflow{got, max})` when the input
exceeds `u16::MAX`. Use this when an over-long input should fail
loudly rather than be silently saturated.
- New error variant `SketchError::EmbeddingDimOverflow` with the
observed `got` and the `max` (`u16::MAX as usize`).
- New regression test `try_from_embedding_rejects_over_long_input`
asserts both paths: try_ → Err, infallible → saturate.
Validated:
- 13 sketch unit tests pass (was 12; +1 for L2 boundary).
- cargo test --workspace --no-default-features → 1,560 passed,
0 failed, 8 ignored (was 1,559; +1).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot RSSI -48 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
* hardening(ruvector,signal): L1+L3 from PR #435 review
Two follow-ups to the security review on PR #435:
L1 — Defensive `if let Some(...)` for SketchBank::topk heap peek.
The original `.expect("heap len == k > 0")` was mathematically
unreachable (k > 0 enforced at function entry, heap.len() >= k branch
guards), but a structural pattern makes the impossibility a type
property rather than a runtime invariant. Same hot-path cost; zero
panic risk in the production binary.
L3 — Guard `embedding_dim == 0` in `EmbeddingHistory::novelty`.
A 0-dim history is constructible via `with_sketch(0, ...)`; without
the guard the function returned `NaN` (min_d as f32 / 0.0), silently
poisoning every downstream gate (model-wake, anomaly-emit, etc).
Now returns Some(1.0) — fail-loud at "no comparison possible →
maximally novel," never NaN. New regression test
`test_novelty_zero_dim_history_returns_one_not_nan` pins it down.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (was 1,560; +1 for the L3 NaN guard test).
- ESP32-S3 on COM7 streaming live CSI (cb #12400, RSSI fresh).
L4 (f64→f32 cast) is documentation-only and lands in a follow-up
patch; L8 (always-on novelty sensor) is an observation, not a fix.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3.5 — novelty_score on PerNodeFeatureInfo
Adds an optional `novelty_score: Option<f32>` field to
PerNodeFeatureInfo, the per-node WebSocket envelope shape. Mirrored
on both struct definitions (types.rs canonical + main.rs's
refactoring-leftover duplicate) so the schema is consistent.
`#[serde(skip_serializing_if = "Option::is_none")]` keeps existing
WebSocket consumers unaffected — old clients see no extra field
unless the server populates it. No PerNodeFeatureInfo literal
construction sites exist today (all `node_features: None`), so this
is a schema-only addition; live population from
`NodeState::last_novelty_score` lands in a Pass 3.6 follow-up that
also wires `node_features: Some(...)` at the per-node ESP32 frame
emit path.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (no change; schema-only).
- ESP32-S3 on COM7 streaming live CSI (cb #2100, fresh boot).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3.6 — populate node_features with novelty_score
Wires `node_features: Some(...)` at the two per-node ESP32 frame
emit sites (formerly `node_features: None`). Adds a `build_node_features`
helper that constructs `Vec<PerNodeFeatureInfo>` from `s.node_states`,
including the per-node `last_novelty_score`.
This completes the Pass 3.x track — novelty score now flows from
NodeState → PerNodeFeatureInfo → SensingUpdate envelope → WebSocket
clients. Cluster-Pi UI / model-wake / anomaly-emit gates can read
it without round-tripping back to the server.
Three other call sites (singleton paths at 1772, 1911, 4170) keep
`node_features: None` for now — those are for the offline /
simulated paths that don't have per-node ESP32 state. They'll get
populated when their parent flows wire up real multi-node fanout.
Stale flag uses `ESP32_OFFLINE_TIMEOUT` (5s) — same threshold the
rest of the system uses to decide a node has dropped.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (no change; integration test would be wire-
format diff in a follow-up).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot,
RSSI -49 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(ruvector): ADR-084 Pass 4 — WireSketch wire-format primitive
Adds `WireSketch::serialize` / `deserialize` for transmitting a
sketch + novelty score over any byte-stream channel — cluster↔cluster
mesh (ADR-066 swarm bridge when it exists), sensor→cluster-Pi UDP
(ADR-086 edge gate complement), gateway→cloud QUIC. Channel-agnostic
by design.
Wire layout (12-byte header + ceil(dim/8) bytes payload, little-endian):
[0..4] magic = 0xC5110084
[4..6] format_version = 1
[6..8] sketch_version (embedding-model schema)
[8..10] embedding_dim
[10..12] novelty_q15 (novelty * 32_767, saturated)
[12..] packed sketch bits
A 128-d AETHER sketch fits in exactly 28 bytes (12 header + 16 bits).
Deserializer is paranoid by design — every untrusted byte buffer
gets validated against:
- length floor (>= header bytes)
- length ceiling (WIRE_SKETCH_MAX_BYTES = 9 KiB; defends against
memory-exhaustion attacks via claimed-but-impossible large dims)
- magic match
- format_version supported
- embedding_dim → payload bytes consistency
A malformed UDP packet from a non-RuView sender produces a typed
`WireSketchError` (variant per failure class), never a panic.
Re-exported from lib.rs alongside `Sketch` / `SketchBank`.
Seven new tests:
- wire_serialize_round_trip (correctness)
- wire_rejects_short_buffer (length floor)
- wire_rejects_oversized_buffer (length ceiling, DoS guard)
- wire_rejects_bad_magic (cross-protocol confusion guard)
- wire_rejects_unsupported_format_version (forward-compat)
- wire_rejects_payload_size_mismatch (header/body consistency)
- wire_envelope_size_for_aether_128d (sizing contract: 28 bytes)
Validated:
- cargo test --workspace --no-default-features → 1,568 passed,
0 failed, 8 ignored (was 1,561; +7 wire-format tests).
- ESP32-S3 on COM7 streaming live CSI (cb #15100, RSSI -48 dBm).
Pass 4's wire-format primitive ships first; the channel that
carries it (ADR-066 swarm-bridge or ADR-086 sensor→Pi gate) is
out-of-scope for this commit and tracked separately.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(ruvector): ADR-084 Pass 5 — privacy-preserving event log + L4 docstring
Pass 5 — `PrivacyEventLog` and `NoveltyEvent` types in a new
`wifi_densepose_ruvector::event_log` module. Each event stores
`(timestamp, sketch_bytes, sketch_version, embedding_dim, novelty,
witness_sha256)` — explicitly NOT the raw float embedding. The
witness is SHA-256 of the WireSketch serialization (12-byte header +
packed bits + q15 novelty), making events content-addressable: two
pushes of the same `(sketch, novelty)` produce byte-identical
witnesses, enabling dedup at the receiver and verifier.
Privacy properties (ADR-084 §"Privacy-preserving event log"):
1. Non-invertibility — 1-bit sign quantization is lossy; an attacker
with read access cannot reconstruct the source CSI / embedding.
2. Content addressing — `(sketch_version, witness)` is fully qualified.
3. Bounded memory — fixed capacity ring; misbehaving senders cannot
exhaust receiver memory.
Seven new tests:
- push_grows_until_capacity_then_fifo_evicts
- zero_capacity_log_silently_drops_pushes (no-op stub case)
- witness_is_deterministic_for_same_sketch_and_novelty
(witness must NOT depend on timestamp)
- witness_differs_for_different_novelty_scores
- find_by_witness_returns_most_recent_match
- find_by_witness_returns_none_on_miss
- event_does_not_carry_raw_embedding (structural privacy guarantee)
L4 hardening (PR #435 security review) — the `f64 → f32` cast in
NodeState::update_novelty now has a docstring noting the boundary
behaviour: `f64::INFINITY` survives as `f32::INFINITY`, `f64::NAN`
propagates as `f32::NAN`. Neither panics. CSI amplitudes from healthy
firmware are well within f32 finite range.
Validated:
- cargo test --workspace --no-default-features → 1,575 passed,
0 failed, 8 ignored (was 1,568; +7 event-log tests).
- ESP32-S3 on COM7 streaming live CSI (cb #2800, RSSI -52 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
845 lines
34 KiB
Rust
845 lines
34 KiB
Rust
//! RaBitQ-style binary sketch — cheap similarity sensor for CSI/pose embeddings.
|
||
//!
|
||
//! Implements **Pass 1** of [ADR-084](../../../../../docs/adr/ADR-084-rabitq-similarity-sensor.md):
|
||
//! a thin RuView-flavored API over `ruvector_core::quantization::BinaryQuantized`.
|
||
//!
|
||
//! # Why a sketch
|
||
//!
|
||
//! Every "have I seen something like this before?" comparison in the RuView
|
||
//! pipeline (AETHER re-ID, room fingerprinting, mincut prefilter, novelty
|
||
//! detection, mesh-exchange compression, privacy event log) shares the same
|
||
//! shape: dense float embedding → similarity score → top-K candidates.
|
||
//! The full-precision compare is expensive — `O(d)` float operations per pair,
|
||
//! cache-unfriendly because every dimension is a 4-byte load.
|
||
//!
|
||
//! A 1-bit sketch (one bit per embedding dimension, packed into bytes) collapses
|
||
//! the compare to a hardware-accelerated POPCNT/NEON-vcnt over ~32× less
|
||
//! memory. The published *RaBitQ* algorithm (Gao & Long, SIGMOD 2024) wraps
|
||
//! this with a randomized rotation for theoretical error bounds; we ship the
|
||
//! pure sign-quantization variant first and add the rotation later if
|
||
//! benchmark-measured top-K coverage drops below the ADR-084 acceptance
|
||
//! threshold of 90%.
|
||
//!
|
||
//! # Acceptance criteria (ADR-084 §"Acceptance test")
|
||
//!
|
||
//! - Sketch compare cost reduction: **8×–30×** vs full-float compare.
|
||
//! - Top-K coverage: **≥ 90%** agreement with full-float top-K.
|
||
//! - End-to-end accuracy regression: **< 1 percentage point**.
|
||
//!
|
||
//! Pass 1 establishes the API and the unit-test foundation. Pass 2+ wires it
|
||
//! into specific pipeline sites and measures the criteria there.
|
||
//!
|
||
//! # Use sites (ADR-084)
|
||
//!
|
||
//! 1. AETHER re-ID hot-cache filter (`signal::ruvsense::pose_tracker`)
|
||
//! 2. Cluster-Pi novelty sensor (`sensing-server` `SketchBank`)
|
||
//! 3. Mesh-exchange compression (ADR-066 swarm bridge)
|
||
//! 4. Privacy-preserving event log (cluster Pi)
|
||
//! 5. Mincut prefilter (`ruvector::signal::subcarrier`)
|
||
//!
|
||
//! All sites take a `&Sketch` instead of an `&[f32]`; the bridge to dense
|
||
//! embeddings is `Sketch::from_embedding`.
|
||
|
||
use ruvector_core::quantization::{BinaryQuantized, QuantizedVector};
|
||
use std::cmp::Reverse;
|
||
use std::collections::BinaryHeap;
|
||
|
||
/// Errors raised by the sketch API.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum SketchError {
|
||
/// The sketch's `sketch_version` does not match the `SketchBank`'s.
|
||
/// This guards against silently comparing sketches produced by different
|
||
/// embedding-model generations.
|
||
#[error("sketch_version mismatch: bank={bank}, query={query}")]
|
||
SketchVersionMismatch {
|
||
/// Version stored in the bank.
|
||
bank: u16,
|
||
/// Version on the incoming sketch.
|
||
query: u16,
|
||
},
|
||
|
||
/// The sketch's embedding dimension does not match the bank's.
|
||
/// Two sketches of different dimensions cannot be compared.
|
||
#[error("embedding_dim mismatch: bank={bank}, query={query}")]
|
||
EmbeddingDimMismatch {
|
||
/// Dimension stored in the bank.
|
||
bank: u16,
|
||
/// Dimension on the incoming sketch.
|
||
query: u16,
|
||
},
|
||
|
||
/// Embedding dimension exceeds `u16::MAX` (65,535).
|
||
///
|
||
/// Returned by [`Sketch::try_from_embedding`] to surface what
|
||
/// `from_embedding`'s `debug_assert!` would have hidden in release
|
||
/// builds — silently truncating the dimension count would otherwise
|
||
/// let two different-length embeddings compare as if they were the
|
||
/// same length. See ADR-084 §"Versioning" and the security-review
|
||
/// finding L2 on PR #435 for context.
|
||
#[error("embedding dimension {got} exceeds u16::MAX ({max})")]
|
||
EmbeddingDimOverflow {
|
||
/// Actual length of the input embedding.
|
||
got: usize,
|
||
/// Maximum supported dimension (`u16::MAX`).
|
||
max: usize,
|
||
},
|
||
}
|
||
|
||
/// A 1-bit binary sketch of a dense embedding vector.
|
||
///
|
||
/// 32× smaller than the source `[f32]` and compared via SIMD-accelerated
|
||
/// hamming distance (NEON `vcnt` on aarch64, POPCNT on x86_64). Use as a
|
||
/// cheap pre-filter before full-precision comparison.
|
||
///
|
||
/// # Versioning
|
||
///
|
||
/// `sketch_version` distinguishes sketches produced by different embedding
|
||
/// generations. Bumping the embedding model invalidates all stored sketches;
|
||
/// the `SketchBank` rejects mismatched versions at compare time so callers
|
||
/// never silently compare incompatible sketches.
|
||
///
|
||
/// `embedding_dim` is the source vector's length (not the byte-packed size);
|
||
/// kept as a check that two sketches are actually comparable.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Sketch {
|
||
/// 1-bit-per-dimension packed bytes.
|
||
inner: BinaryQuantized,
|
||
/// Source-embedding dimension (e.g., 128 for AETHER).
|
||
embedding_dim: u16,
|
||
/// Schema version of the producing embedding model.
|
||
sketch_version: u16,
|
||
}
|
||
|
||
impl Sketch {
|
||
/// Construct a sketch from a dense f32 embedding.
|
||
///
|
||
/// Each dimension contributes one bit: `1` if the value is `> 0.0`,
|
||
/// `0` otherwise. This is the standard sign-quantization step.
|
||
///
|
||
/// `sketch_version` must be supplied by the caller and bumped whenever
|
||
/// the embedding model that produced the input changes meaningfully
|
||
/// (e.g., a re-trained AETHER head). Two sketches with different
|
||
/// `sketch_version`s are not comparable.
|
||
pub fn from_embedding(embedding: &[f32], sketch_version: u16) -> Self {
|
||
// L2 hardening (PR #435 security review): in release builds the
|
||
// previous `debug_assert!` was compiled out, allowing silent
|
||
// u16-truncation when `embedding.len() > u16::MAX`. Saturate to
|
||
// u16::MAX rather than truncate so two over-long embeddings
|
||
// compare as same-dimensional rather than as accidentally-short.
|
||
// Callers that need a hard error should use `try_from_embedding`.
|
||
let embedding_dim = embedding.len().min(u16::MAX as usize) as u16;
|
||
Self {
|
||
inner: BinaryQuantized::quantize(embedding),
|
||
embedding_dim,
|
||
sketch_version,
|
||
}
|
||
}
|
||
|
||
/// Fallible constructor that rejects embeddings longer than
|
||
/// `u16::MAX` (65,535) instead of saturating, raising
|
||
/// [`SketchError::EmbeddingDimOverflow`]. Use this when an
|
||
/// over-long input should fail loudly rather than silently
|
||
/// produce a sketch that disagrees with its source on
|
||
/// `embedding_dim`.
|
||
pub fn try_from_embedding(
|
||
embedding: &[f32],
|
||
sketch_version: u16,
|
||
) -> Result<Self, SketchError> {
|
||
if embedding.len() > u16::MAX as usize {
|
||
return Err(SketchError::EmbeddingDimOverflow {
|
||
got: embedding.len(),
|
||
max: u16::MAX as usize,
|
||
});
|
||
}
|
||
Ok(Self::from_embedding(embedding, sketch_version))
|
||
}
|
||
|
||
/// Hamming distance to another sketch in `[0, embedding_dim]`.
|
||
///
|
||
/// Returns `None` if the two sketches have different `embedding_dim` or
|
||
/// `sketch_version` — comparing them would be semantically meaningless.
|
||
/// Use [`Sketch::distance_unchecked`] when the caller has already
|
||
/// validated the sketches come from the same producer.
|
||
pub fn distance(&self, other: &Self) -> Result<u32, SketchError> {
|
||
if self.embedding_dim != other.embedding_dim {
|
||
return Err(SketchError::EmbeddingDimMismatch {
|
||
bank: self.embedding_dim,
|
||
query: other.embedding_dim,
|
||
});
|
||
}
|
||
if self.sketch_version != other.sketch_version {
|
||
return Err(SketchError::SketchVersionMismatch {
|
||
bank: self.sketch_version,
|
||
query: other.sketch_version,
|
||
});
|
||
}
|
||
Ok(self.inner.distance(&other.inner) as u32)
|
||
}
|
||
|
||
/// Hamming distance without compatibility checks.
|
||
///
|
||
/// Faster than [`Sketch::distance`] (no version/dim check) but the
|
||
/// caller is responsible for guaranteeing both sketches come from the
|
||
/// same embedding model and dimension. Use only on sketches retrieved
|
||
/// from the same `SketchBank`.
|
||
#[inline]
|
||
pub fn distance_unchecked(&self, other: &Self) -> u32 {
|
||
self.inner.distance(&other.inner) as u32
|
||
}
|
||
|
||
/// Source-embedding dimension (number of dimensions in the original
|
||
/// `[f32]`, not the packed byte length).
|
||
#[inline]
|
||
pub fn embedding_dim(&self) -> u16 {
|
||
self.embedding_dim
|
||
}
|
||
|
||
/// Schema version of the producing embedding model.
|
||
#[inline]
|
||
pub fn sketch_version(&self) -> u16 {
|
||
self.sketch_version
|
||
}
|
||
|
||
/// Borrow the inner ruvector-core `BinaryQuantized` for advanced use
|
||
/// (e.g., serialisation through ruvector's existing infrastructure).
|
||
/// Most callers should use [`Sketch::distance`] or [`SketchBank`].
|
||
#[inline]
|
||
pub fn as_inner(&self) -> &BinaryQuantized {
|
||
&self.inner
|
||
}
|
||
|
||
/// Borrow the packed sketch bytes (1 bit per source-embedding
|
||
/// dimension, ceil-divided into bytes). Used by [`WireSketch`] to
|
||
/// produce a wire-format payload without re-quantizing. Length is
|
||
/// `(embedding_dim + 7) / 8` bytes.
|
||
#[inline]
|
||
pub fn packed_bytes(&self) -> &[u8] {
|
||
&self.inner.bits
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ADR-084 Pass 4 — wire-format primitive (cluster-channel-agnostic)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Magic bytes for ADR-084 sketch wire frames. Receivers reject any
|
||
/// payload that doesn't start with these four bytes — the same shape
|
||
/// of magic-prefix check ADR-018's CSI binary frame uses (e.g.
|
||
/// `0xC5110001`). Picked to be distinct from any existing RuView magic.
|
||
pub const WIRE_SKETCH_MAGIC: u32 = 0xC511_0084;
|
||
|
||
/// On-the-wire schema version. Bump on any field reordering or addition.
|
||
/// `Sketch::sketch_version` (the *embedding model* version) is a
|
||
/// separate concept and travels in the payload.
|
||
pub const WIRE_SKETCH_FORMAT_VERSION: u16 = 1;
|
||
|
||
/// Maximum wire-payload size the deserializer will accept. Guards
|
||
/// against a malicious sender claiming `embedding_dim = u16::MAX`
|
||
/// (would imply 8 KiB of packed bits) and exhausting receiver memory.
|
||
/// 8 KiB matches the largest reasonable production embedding (post-
|
||
/// rotation 65,535-d sign-quantized) plus a few bytes of header.
|
||
pub const WIRE_SKETCH_MAX_BYTES: usize = 9 * 1024;
|
||
|
||
/// Errors raised by [`WireSketch::deserialize`].
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum WireSketchError {
|
||
/// Payload shorter than the fixed header (12 bytes).
|
||
#[error("wire payload too short: got {got} bytes, header needs {needed}")]
|
||
TooShort {
|
||
/// Bytes received.
|
||
got: usize,
|
||
/// Minimum bytes required (12).
|
||
needed: usize,
|
||
},
|
||
/// Payload larger than [`WIRE_SKETCH_MAX_BYTES`].
|
||
#[error("wire payload exceeds max ({got} > {max})")]
|
||
TooLarge {
|
||
/// Bytes received.
|
||
got: usize,
|
||
/// Maximum bytes accepted.
|
||
max: usize,
|
||
},
|
||
/// Magic bytes do not match [`WIRE_SKETCH_MAGIC`].
|
||
#[error("wire magic mismatch: got 0x{got:08X}, expected 0x{expected:08X}")]
|
||
MagicMismatch {
|
||
/// Magic value received.
|
||
got: u32,
|
||
/// Magic value expected.
|
||
expected: u32,
|
||
},
|
||
/// Format version is newer than the receiver knows how to parse.
|
||
#[error("wire format_version {got} > supported {max}")]
|
||
UnsupportedVersion {
|
||
/// Version received.
|
||
got: u16,
|
||
/// Highest version this build understands.
|
||
max: u16,
|
||
},
|
||
/// `embedding_dim` and the byte payload disagree on size.
|
||
#[error("payload byte count mismatch: header dim={dim} → expected {expected_bytes}, got {got_bytes}")]
|
||
PayloadSizeMismatch {
|
||
/// Embedding dimension in the header.
|
||
dim: u16,
|
||
/// Bytes the header implies.
|
||
expected_bytes: usize,
|
||
/// Bytes actually present.
|
||
got_bytes: usize,
|
||
},
|
||
}
|
||
|
||
/// Serialize / deserialize a `Sketch` plus its novelty score for
|
||
/// transmission over any channel — cluster↔cluster mesh, sensor→Pi UDP,
|
||
/// gateway→cloud QUIC, etc.
|
||
///
|
||
/// # Wire layout (little-endian, packed)
|
||
///
|
||
/// | Offset | Field | Width | Notes |
|
||
/// |--------|--------------------|-------|--------------------------------------------|
|
||
/// | 0 | `magic` | u32 | [`WIRE_SKETCH_MAGIC`] |
|
||
/// | 4 | `format_version` | u16 | [`WIRE_SKETCH_FORMAT_VERSION`] |
|
||
/// | 6 | `sketch_version` | u16 | embedding-model schema version |
|
||
/// | 8 | `embedding_dim` | u16 | source-embedding dimensions |
|
||
/// | 10 | `novelty_q15` | u16 | novelty in `[0,1]` × 32_767 (saturated) |
|
||
/// | 12 | `bits[]` | var | `(embedding_dim + 7) / 8` bytes |
|
||
///
|
||
/// Header is exactly **12 bytes**; payload is `ceil(embedding_dim/8)`
|
||
/// bytes. Total for a 128-d AETHER sketch is 12 + 16 = **28 bytes**.
|
||
///
|
||
/// # Why the receiver is paranoid
|
||
///
|
||
/// All deserialization paths validate magic, format_version,
|
||
/// embedding_dim → payload-bytes consistency, and total size before
|
||
/// touching `BinaryQuantized`. A malformed UDP packet from a
|
||
/// non-RuView sender will produce a typed `WireSketchError`, never a
|
||
/// panic. Caps via [`WIRE_SKETCH_MAX_BYTES`] guard against memory-
|
||
/// exhaustion attacks.
|
||
pub struct WireSketch;
|
||
|
||
impl WireSketch {
|
||
/// Header size (magic + format_version + sketch_version + dim + novelty).
|
||
pub const HEADER_BYTES: usize = 12;
|
||
|
||
/// Encode a sketch + novelty score for transmission. `novelty` is
|
||
/// clamped to `[0.0, 1.0]` and quantized to a `u16` (q15 fixed-
|
||
/// point) so the wire payload is fixed-size. Encoding never
|
||
/// allocates more than `Self::HEADER_BYTES + sketch.packed_bytes().len()`.
|
||
pub fn serialize(sketch: &Sketch, novelty: f32) -> Vec<u8> {
|
||
let bits = sketch.packed_bytes();
|
||
let total = Self::HEADER_BYTES + bits.len();
|
||
let mut out = Vec::with_capacity(total);
|
||
out.extend_from_slice(&WIRE_SKETCH_MAGIC.to_le_bytes());
|
||
out.extend_from_slice(&WIRE_SKETCH_FORMAT_VERSION.to_le_bytes());
|
||
out.extend_from_slice(&sketch.sketch_version.to_le_bytes());
|
||
out.extend_from_slice(&sketch.embedding_dim.to_le_bytes());
|
||
let nov_q15: u16 = (novelty.clamp(0.0, 1.0) * 32_767.0).round() as u16;
|
||
out.extend_from_slice(&nov_q15.to_le_bytes());
|
||
out.extend_from_slice(bits);
|
||
out
|
||
}
|
||
|
||
/// Decode a sketch + novelty score from an untrusted byte buffer.
|
||
/// Returns the parsed `(Sketch, novelty)` tuple, or a typed error.
|
||
pub fn deserialize(buf: &[u8]) -> Result<(Sketch, f32), WireSketchError> {
|
||
// Length floor: must contain at least the header.
|
||
if buf.len() < Self::HEADER_BYTES {
|
||
return Err(WireSketchError::TooShort {
|
||
got: buf.len(),
|
||
needed: Self::HEADER_BYTES,
|
||
});
|
||
}
|
||
// Length ceiling: defend against memory-exhaustion attacks via
|
||
// claimed-but-impossible large dims.
|
||
if buf.len() > WIRE_SKETCH_MAX_BYTES {
|
||
return Err(WireSketchError::TooLarge {
|
||
got: buf.len(),
|
||
max: WIRE_SKETCH_MAX_BYTES,
|
||
});
|
||
}
|
||
|
||
let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4-byte slice"));
|
||
if magic != WIRE_SKETCH_MAGIC {
|
||
return Err(WireSketchError::MagicMismatch {
|
||
got: magic,
|
||
expected: WIRE_SKETCH_MAGIC,
|
||
});
|
||
}
|
||
|
||
let format_version = u16::from_le_bytes(buf[4..6].try_into().expect("2-byte slice"));
|
||
if format_version > WIRE_SKETCH_FORMAT_VERSION {
|
||
return Err(WireSketchError::UnsupportedVersion {
|
||
got: format_version,
|
||
max: WIRE_SKETCH_FORMAT_VERSION,
|
||
});
|
||
}
|
||
|
||
let sketch_version = u16::from_le_bytes(buf[6..8].try_into().expect("2-byte slice"));
|
||
let embedding_dim = u16::from_le_bytes(buf[8..10].try_into().expect("2-byte slice"));
|
||
let nov_q15 = u16::from_le_bytes(buf[10..12].try_into().expect("2-byte slice"));
|
||
|
||
let expected_bits = ((embedding_dim as usize) + 7) / 8;
|
||
let got_bits = buf.len() - Self::HEADER_BYTES;
|
||
if expected_bits != got_bits {
|
||
return Err(WireSketchError::PayloadSizeMismatch {
|
||
dim: embedding_dim,
|
||
expected_bytes: expected_bits,
|
||
got_bytes: got_bits,
|
||
});
|
||
}
|
||
|
||
let bits = buf[Self::HEADER_BYTES..].to_vec();
|
||
let sketch = Sketch {
|
||
inner: BinaryQuantized {
|
||
bits,
|
||
dimensions: embedding_dim as usize,
|
||
},
|
||
embedding_dim,
|
||
sketch_version,
|
||
};
|
||
let novelty = (nov_q15 as f32) / 32_767.0;
|
||
Ok((sketch, novelty))
|
||
}
|
||
}
|
||
|
||
/// A bank of sketches with stable IDs, queried for top-K nearest neighbours
|
||
/// by hamming distance.
|
||
///
|
||
/// Used at every "have I seen this before" site in the pipeline. The bank
|
||
/// enforces `sketch_version` and `embedding_dim` consistency at insertion
|
||
/// time, so `topk` queries never need to re-check.
|
||
///
|
||
/// # Invariants
|
||
///
|
||
/// - All sketches in a bank share the same `embedding_dim` and `sketch_version`.
|
||
/// - Bank IDs (`u32`) are caller-assigned and stable across `topk` calls;
|
||
/// the bank does not renumber on insertion or removal.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SketchBank {
|
||
/// (id, sketch) pairs in insertion order.
|
||
entries: Vec<(u32, Sketch)>,
|
||
/// Locked at first insertion; all subsequent inserts must match.
|
||
embedding_dim: Option<u16>,
|
||
/// Locked at first insertion; all subsequent inserts must match.
|
||
sketch_version: Option<u16>,
|
||
}
|
||
|
||
impl SketchBank {
|
||
/// Create an empty bank. Dimension and version are locked at the first
|
||
/// `insert` call.
|
||
pub fn new() -> Self {
|
||
Self {
|
||
entries: Vec::new(),
|
||
embedding_dim: None,
|
||
sketch_version: None,
|
||
}
|
||
}
|
||
|
||
/// Create a bank with a pre-locked `embedding_dim` and `sketch_version`.
|
||
/// Use when the bank's expected schema is known at construction.
|
||
pub fn with_schema(embedding_dim: u16, sketch_version: u16) -> Self {
|
||
Self {
|
||
entries: Vec::new(),
|
||
embedding_dim: Some(embedding_dim),
|
||
sketch_version: Some(sketch_version),
|
||
}
|
||
}
|
||
|
||
/// Number of sketches in the bank.
|
||
#[inline]
|
||
pub fn len(&self) -> usize {
|
||
self.entries.len()
|
||
}
|
||
|
||
/// True iff the bank has no sketches.
|
||
#[inline]
|
||
pub fn is_empty(&self) -> bool {
|
||
self.entries.is_empty()
|
||
}
|
||
|
||
/// Locked embedding dimension, or `None` if the bank is empty and
|
||
/// no schema was pre-supplied.
|
||
#[inline]
|
||
pub fn embedding_dim(&self) -> Option<u16> {
|
||
self.embedding_dim
|
||
}
|
||
|
||
/// Locked sketch version, or `None` if the bank is empty and
|
||
/// no schema was pre-supplied.
|
||
#[inline]
|
||
pub fn sketch_version(&self) -> Option<u16> {
|
||
self.sketch_version
|
||
}
|
||
|
||
/// Insert a sketch with caller-assigned ID. Locks the bank's schema on
|
||
/// first insertion; rejects subsequent inserts that mismatch.
|
||
pub fn insert(&mut self, id: u32, sketch: Sketch) -> Result<(), SketchError> {
|
||
match self.embedding_dim {
|
||
None => self.embedding_dim = Some(sketch.embedding_dim),
|
||
Some(d) if d != sketch.embedding_dim => {
|
||
return Err(SketchError::EmbeddingDimMismatch {
|
||
bank: d,
|
||
query: sketch.embedding_dim,
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
match self.sketch_version {
|
||
None => self.sketch_version = Some(sketch.sketch_version),
|
||
Some(v) if v != sketch.sketch_version => {
|
||
return Err(SketchError::SketchVersionMismatch {
|
||
bank: v,
|
||
query: sketch.sketch_version,
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
self.entries.push((id, sketch));
|
||
Ok(())
|
||
}
|
||
|
||
/// Top-K nearest neighbours by hamming distance, ascending.
|
||
///
|
||
/// Returns up to `k` `(id, distance)` pairs sorted by distance. If the
|
||
/// bank has fewer than `k` entries, returns all of them. If `k == 0`,
|
||
/// returns empty.
|
||
///
|
||
/// Returns `Err` if the query's `embedding_dim` or `sketch_version`
|
||
/// disagrees with the bank's locked schema. (Cannot return `Err` if the
|
||
/// bank is empty *and* no schema was pre-supplied — there's nothing to
|
||
/// disagree with.)
|
||
pub fn topk(&self, query: &Sketch, k: usize) -> Result<Vec<(u32, u32)>, SketchError> {
|
||
if k == 0 || self.entries.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
if let Some(d) = self.embedding_dim {
|
||
if d != query.embedding_dim {
|
||
return Err(SketchError::EmbeddingDimMismatch {
|
||
bank: d,
|
||
query: query.embedding_dim,
|
||
});
|
||
}
|
||
}
|
||
if let Some(v) = self.sketch_version {
|
||
if v != query.sketch_version {
|
||
return Err(SketchError::SketchVersionMismatch {
|
||
bank: v,
|
||
query: query.sketch_version,
|
||
});
|
||
}
|
||
}
|
||
// Pass-1.5 optimisation: O(n log k) partial sort via a fixed-size
|
||
// max-heap of `Reverse((distance, id))`. The heap's `peek()`
|
||
// returns the *largest* of the current best-k. Each candidate is
|
||
// compared against the heap top in O(1); only better candidates
|
||
// trigger an O(log k) push/pop. Avoids touching the long tail of
|
||
// large-distance entries that the truncate would have discarded.
|
||
//
|
||
// Fast path: when n ≤ k there is nothing to discard, so a plain
|
||
// collect + sort is faster than building a heap.
|
||
let n = self.entries.len();
|
||
if n <= k {
|
||
let mut scored: Vec<(u32, u32)> = self
|
||
.entries
|
||
.iter()
|
||
.map(|(id, sk)| (*id, sk.distance_unchecked(query)))
|
||
.collect();
|
||
scored.sort_by_key(|&(_, d)| d);
|
||
return Ok(scored);
|
||
}
|
||
|
||
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = BinaryHeap::with_capacity(k + 1);
|
||
for (id, sk) in &self.entries {
|
||
let d = sk.distance_unchecked(query);
|
||
if heap.len() < k {
|
||
heap.push(Reverse((d, *id)));
|
||
} else if let Some(&Reverse((worst, _))) = heap.peek() {
|
||
// L1 hardening (PR #435 review): structural `if let` rather
|
||
// than `.expect("heap len == k > 0")`. The branch is
|
||
// mathematically unreachable when `heap.len() >= k > 0`,
|
||
// but a defensive pattern makes the impossibility a type
|
||
// property rather than a runtime invariant. Same hot-path
|
||
// cost (one bounds check); zero panic risk.
|
||
if d < worst {
|
||
heap.pop();
|
||
heap.push(Reverse((d, *id)));
|
||
}
|
||
}
|
||
}
|
||
// Drain heap into a Vec — already in (Reverse) descending order;
|
||
// sort to expose ascending-by-distance per the public contract.
|
||
let mut scored: Vec<(u32, u32)> = heap
|
||
.into_iter()
|
||
.map(|Reverse((d, id))| (id, d))
|
||
.collect();
|
||
scored.sort_by_key(|&(_, d)| d);
|
||
Ok(scored)
|
||
}
|
||
|
||
/// Compute the novelty score of a query against the bank in `[0.0, 1.0]`.
|
||
///
|
||
/// Defined as `min_distance / embedding_dim`, so 0.0 means "exact bit
|
||
/// match exists in the bank" and 1.0 means "every bit differs from the
|
||
/// nearest stored sketch." Returns 1.0 (max novelty) on an empty bank.
|
||
/// Returns `Err` on schema mismatch.
|
||
pub fn novelty(&self, query: &Sketch) -> Result<f32, SketchError> {
|
||
if self.entries.is_empty() {
|
||
return Ok(1.0);
|
||
}
|
||
let topk = self.topk(query, 1)?;
|
||
let min_distance = topk.first().map(|&(_, d)| d).unwrap_or(u32::MAX);
|
||
Ok(min_distance as f32 / query.embedding_dim as f32)
|
||
}
|
||
}
|
||
|
||
impl Default for SketchBank {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn from_embedding_packs_one_bit_per_dim() {
|
||
let v = vec![0.5, -0.5, 0.5, -0.5, 0.5, -0.5, 0.5, -0.5];
|
||
let s = Sketch::from_embedding(&v, 1);
|
||
assert_eq!(s.embedding_dim(), 8);
|
||
assert_eq!(s.sketch_version(), 1);
|
||
// Distance to self is 0
|
||
assert_eq!(s.distance_unchecked(&s), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn distance_is_hamming_count() {
|
||
let a = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1);
|
||
let b = Sketch::from_embedding(&[-0.5, -0.5, -0.5, -0.5], 1);
|
||
// All 4 dims flipped sign → 4 bit differences.
|
||
assert_eq!(a.distance(&b).unwrap(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn distance_rejects_mismatched_dims() {
|
||
let a = Sketch::from_embedding(&[0.5, 0.5], 1);
|
||
let b = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1);
|
||
let err = a.distance(&b).unwrap_err();
|
||
assert!(matches!(err, SketchError::EmbeddingDimMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn distance_rejects_mismatched_versions() {
|
||
let a = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1);
|
||
let b = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 2);
|
||
let err = a.distance(&b).unwrap_err();
|
||
assert!(matches!(err, SketchError::SketchVersionMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn bank_topk_returns_sorted_by_distance() {
|
||
let mut bank = SketchBank::new();
|
||
// id 10: identical
|
||
bank.insert(10, Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1)).unwrap();
|
||
// id 20: 1 bit different (last dim flipped)
|
||
bank.insert(20, Sketch::from_embedding(&[0.5, 0.5, 0.5, -0.5], 1)).unwrap();
|
||
// id 30: 2 bits different
|
||
bank.insert(30, Sketch::from_embedding(&[-0.5, 0.5, -0.5, 0.5], 1)).unwrap();
|
||
|
||
let query = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1);
|
||
let topk = bank.topk(&query, 3).unwrap();
|
||
|
||
assert_eq!(topk.len(), 3);
|
||
assert_eq!(topk[0].0, 10); // 0 distance
|
||
assert_eq!(topk[1].0, 20); // 1 distance
|
||
assert_eq!(topk[2].0, 30); // 2 distance
|
||
assert!(topk[0].1 <= topk[1].1);
|
||
assert!(topk[1].1 <= topk[2].1);
|
||
}
|
||
|
||
#[test]
|
||
fn bank_topk_zero_returns_empty() {
|
||
let mut bank = SketchBank::new();
|
||
bank.insert(1, Sketch::from_embedding(&[0.5, 0.5], 1)).unwrap();
|
||
let q = Sketch::from_embedding(&[0.5, 0.5], 1);
|
||
assert_eq!(bank.topk(&q, 0).unwrap().len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn bank_topk_more_than_size_returns_all() {
|
||
let mut bank = SketchBank::new();
|
||
bank.insert(1, Sketch::from_embedding(&[0.5, 0.5], 1)).unwrap();
|
||
bank.insert(2, Sketch::from_embedding(&[-0.5, 0.5], 1)).unwrap();
|
||
let q = Sketch::from_embedding(&[0.5, 0.5], 1);
|
||
assert_eq!(bank.topk(&q, 100).unwrap().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn bank_locks_schema_on_first_insert() {
|
||
let mut bank = SketchBank::new();
|
||
bank.insert(1, Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1)).unwrap();
|
||
// Different version → reject
|
||
let err = bank
|
||
.insert(2, Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 2))
|
||
.unwrap_err();
|
||
assert!(matches!(err, SketchError::SketchVersionMismatch { .. }));
|
||
// Different dim → reject
|
||
let err = bank
|
||
.insert(3, Sketch::from_embedding(&[0.5, 0.5], 1))
|
||
.unwrap_err();
|
||
assert!(matches!(err, SketchError::EmbeddingDimMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn bank_with_schema_rejects_first_mismatching_insert() {
|
||
let mut bank = SketchBank::with_schema(4, 7);
|
||
let err = bank
|
||
.insert(1, Sketch::from_embedding(&[0.5, 0.5], 7))
|
||
.unwrap_err();
|
||
assert!(matches!(err, SketchError::EmbeddingDimMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn novelty_zero_for_exact_match_one_for_empty() {
|
||
let bank_empty = SketchBank::new();
|
||
let q = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1);
|
||
assert_eq!(bank_empty.novelty(&q).unwrap(), 1.0);
|
||
|
||
let mut bank = SketchBank::new();
|
||
bank.insert(1, q.clone()).unwrap();
|
||
assert_eq!(bank.novelty(&q).unwrap(), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn novelty_is_proportional_to_min_distance() {
|
||
let mut bank = SketchBank::new();
|
||
// Bank has one sketch with all 8 dims positive.
|
||
bank.insert(1, Sketch::from_embedding(&[0.5; 8], 1)).unwrap();
|
||
// Query flips half the dims → 4 bit difference / 8 dims = 0.5.
|
||
let query = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5], 1);
|
||
let novelty = bank.novelty(&query).unwrap();
|
||
assert!((novelty - 0.5).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn try_from_embedding_rejects_over_long_input() {
|
||
// L2 security-review finding (PR #435): the infallible
|
||
// `from_embedding` saturates to u16::MAX; the fallible
|
||
// `try_from_embedding` must surface the overflow so callers can
|
||
// detect the misuse. We can't actually allocate a 65,536-f32
|
||
// vector in unit tests cheaply (that's 256 KiB, fine), but we
|
||
// can fabricate a `Vec` with `len() > u16::MAX` and check the
|
||
// error path.
|
||
let too_long: Vec<f32> = vec![0.5; (u16::MAX as usize) + 1];
|
||
let err = Sketch::try_from_embedding(&too_long, 1).unwrap_err();
|
||
match err {
|
||
SketchError::EmbeddingDimOverflow { got, max } => {
|
||
assert_eq!(got, (u16::MAX as usize) + 1);
|
||
assert_eq!(max, u16::MAX as usize);
|
||
}
|
||
_ => panic!("expected EmbeddingDimOverflow, got {err:?}"),
|
||
}
|
||
|
||
// The infallible path should *saturate* to u16::MAX rather
|
||
// than panic in release. Verify the saturation is observable
|
||
// on `embedding_dim()`.
|
||
let s = Sketch::from_embedding(&too_long, 1);
|
||
assert_eq!(s.embedding_dim(), u16::MAX);
|
||
}
|
||
|
||
// ─── ADR-084 Pass 4 wire-format tests ────────────────────────────────────
|
||
|
||
#[test]
|
||
fn wire_serialize_round_trip() {
|
||
let v = vec![0.5_f32, -0.5, 0.5, -0.5, 0.5, -0.5, 0.5, -0.5];
|
||
let sketch = Sketch::from_embedding(&v, 7);
|
||
let bytes = WireSketch::serialize(&sketch, 0.42);
|
||
|
||
// Header (12) + 1 byte (8 dims / 8) = 13 bytes total.
|
||
assert_eq!(bytes.len(), WireSketch::HEADER_BYTES + 1);
|
||
|
||
let (decoded, novelty) = WireSketch::deserialize(&bytes).expect("round-trip");
|
||
assert_eq!(decoded.embedding_dim(), 8);
|
||
assert_eq!(decoded.sketch_version(), 7);
|
||
assert_eq!(decoded.distance_unchecked(&sketch), 0);
|
||
// q15 quantization round-trips with bounded error.
|
||
assert!((novelty - 0.42).abs() < 1.0 / 32_767.0 * 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn wire_rejects_short_buffer() {
|
||
let err = WireSketch::deserialize(&[0u8; 5]).unwrap_err();
|
||
match err {
|
||
WireSketchError::TooShort { got: 5, needed } => {
|
||
assert_eq!(needed, WireSketch::HEADER_BYTES);
|
||
}
|
||
_ => panic!("expected TooShort, got {err:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wire_rejects_oversized_buffer() {
|
||
let big = vec![0u8; WIRE_SKETCH_MAX_BYTES + 1];
|
||
let err = WireSketch::deserialize(&big).unwrap_err();
|
||
assert!(matches!(err, WireSketchError::TooLarge { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn wire_rejects_bad_magic() {
|
||
let mut bytes = WireSketch::serialize(&Sketch::from_embedding(&[0.5; 16], 1), 0.0);
|
||
bytes[0..4].copy_from_slice(&0xDEAD_BEEF_u32.to_le_bytes());
|
||
let err = WireSketch::deserialize(&bytes).unwrap_err();
|
||
assert!(matches!(err, WireSketchError::MagicMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn wire_rejects_unsupported_format_version() {
|
||
let mut bytes = WireSketch::serialize(&Sketch::from_embedding(&[0.5; 16], 1), 0.0);
|
||
// Bump format_version to 99 — beyond what this build supports.
|
||
bytes[4..6].copy_from_slice(&99_u16.to_le_bytes());
|
||
let err = WireSketch::deserialize(&bytes).unwrap_err();
|
||
assert!(matches!(err, WireSketchError::UnsupportedVersion { got: 99, .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn wire_rejects_payload_size_mismatch() {
|
||
// Build a valid 16-d sketch (2 bytes), then claim dim=24 in the
|
||
// header (would need 3 bytes). Payload-size check must fire.
|
||
let mut bytes = WireSketch::serialize(&Sketch::from_embedding(&[0.5; 16], 1), 0.0);
|
||
bytes[8..10].copy_from_slice(&24_u16.to_le_bytes());
|
||
let err = WireSketch::deserialize(&bytes).unwrap_err();
|
||
match err {
|
||
WireSketchError::PayloadSizeMismatch {
|
||
dim: 24,
|
||
expected_bytes: 3,
|
||
got_bytes: 2,
|
||
} => {}
|
||
_ => panic!("expected PayloadSizeMismatch, got {err:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wire_envelope_size_for_aether_128d() {
|
||
// Documented size sanity: a 128-d AETHER sketch should fit in
|
||
// 12-byte header + 16-byte payload = 28 bytes total.
|
||
let v: Vec<f32> = (0..128).map(|i| (i as f32).sin()).collect();
|
||
let sketch = Sketch::from_embedding(&v, 1);
|
||
let bytes = WireSketch::serialize(&sketch, 0.5);
|
||
assert_eq!(bytes.len(), 28, "AETHER 128-d must wire to exactly 28 bytes");
|
||
}
|
||
|
||
#[test]
|
||
fn topk_rejects_query_with_wrong_schema() {
|
||
let mut bank = SketchBank::with_schema(4, 1);
|
||
bank.insert(1, Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 1)).unwrap();
|
||
let bad_dim = Sketch::from_embedding(&[0.5, 0.5], 1);
|
||
assert!(matches!(
|
||
bank.topk(&bad_dim, 1).unwrap_err(),
|
||
SketchError::EmbeddingDimMismatch { .. }
|
||
));
|
||
let bad_ver = Sketch::from_embedding(&[0.5, 0.5, 0.5, 0.5], 99);
|
||
assert!(matches!(
|
||
bank.topk(&bad_ver, 1).unwrap_err(),
|
||
SketchError::SketchVersionMismatch { .. }
|
||
));
|
||
}
|
||
}
|