Files
ruvnet--RuView/v2/crates/wifi-densepose-ruvector/src/event_log.rs
T
rUv 17509a2a41 feat(ruvector,signal,sensing-server): ADR-084 Passes 1/1.5/2/3 — RaBitQ similarity sensor implementation (#435)
* 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>
2026-04-26 02:21:35 -04:00

267 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ADR-084 Pass 5 — privacy-preserving event log.
//!
//! Stores `(timestamp, sketch, novelty, witness_sha256)` tuples instead
//! of raw float embeddings. Two privacy properties matter:
//!
//! 1. **Non-invertibility.** The 1-bit sketch is lossy — there is no
//! general mathematical inverse from a stored event back to a
//! `[f32]` source embedding. Even an attacker with side-channel
//! information about the embedding model's output distribution
//! cannot reconstruct the underlying CSI.
//!
//! 2. **Content addressing.** Each event carries a SHA-256 of the
//! serialized [`crate::WireSketch`] payload (header + packed bits).
//! Two events with the same `witness` are byte-equal — the cluster-Pi
//! can deduplicate, the gateway can checkpoint without re-storing,
//! and downstream verifiers can prove "this event came from that
//! sketch" without ever holding the original embedding.
//!
//! See ADR-084 §"Privacy-preserving event log" and the post-merge
//! security review on PR #435 (finding L7) for context.
//!
//! # Bounded by design
//!
//! [`PrivacyEventLog`] is a fixed-capacity ring buffer; once full,
//! oldest events are FIFO-evicted. A misbehaving sender cannot exhaust
//! receiver memory by flooding the bank — peak footprint is
//! `capacity × (sketch_bytes + 50)` bytes.
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use crate::sketch::{Sketch, WireSketch};
/// One entry in the privacy-preserving event log.
///
/// All fields are public so callers can serialize / inspect / forward
/// events through their own pipelines without going through getters.
/// The struct is intentionally self-contained — no references to
/// external state, so an event can be moved across thread / process /
/// host boundaries without dangling.
#[derive(Debug, Clone, PartialEq)]
pub struct NoveltyEvent {
/// Microseconds since UNIX epoch when the underlying frame was
/// observed. Caller-supplied; the event log doesn't fetch the
/// clock so test fixtures are deterministic.
pub timestamp_us: u64,
/// 1-bit packed sketch bytes (`(embedding_dim + 7) / 8` bytes long).
pub sketch_bytes: Vec<u8>,
/// Embedding-model schema version so `(version, witness)` is a
/// fully qualified content address.
pub sketch_version: u16,
/// Source-embedding dimension, fixing the bit count of `sketch_bytes`.
pub embedding_dim: u16,
/// Novelty score in `[0.0, 1.0]` at the time the event was logged.
/// Saturated and stored as f32 for direct downstream use; the q15
/// quantization happens on the wire format
/// ([`crate::WireSketch`]) — the in-memory log keeps full f32
/// precision.
pub novelty: f32,
/// SHA-256 of the serialized [`crate::WireSketch`] payload
/// (header + packed bits + the q15 novelty quantum). Two events
/// with the same witness are byte-identical on the wire.
pub witness_sha256: [u8; 32],
}
/// Fixed-capacity, FIFO-evicting log of [`NoveltyEvent`]s.
///
/// Used as the cluster-Pi's per-node anomaly trail. The log is **not**
/// the source of truth for novelty (that's [`crate::SketchBank`] and
/// `EmbeddingHistory::novelty`); it's the *audit* of what happened.
///
/// # Memory bound
///
/// `capacity * (sketch_bytes_per_event + ~50 fixed bytes)` is the worst
/// case. For 64 events × 16-byte sketches that's ~4 KiB — fits in any
/// per-node state struct without concern.
#[derive(Debug, Clone)]
pub struct PrivacyEventLog {
capacity: usize,
events: VecDeque<NoveltyEvent>,
}
impl PrivacyEventLog {
/// Create a new log with the given fixed capacity.
///
/// `capacity == 0` is allowed; the log accepts pushes but
/// immediately discards them, which is occasionally useful as a
/// no-op stub in test fixtures or when the privacy log is meant
/// to be disabled at deployment time.
pub fn new(capacity: usize) -> Self {
Self {
capacity,
events: VecDeque::with_capacity(capacity.min(1024)),
}
}
/// Append an event built from a `Sketch` + novelty score.
///
/// The event's `witness_sha256` is computed over the [`WireSketch`]
/// serialization of `(sketch, novelty)` — so two pushes of the same
/// `(sketch, novelty)` produce byte-identical witnesses, enabling
/// dedup at the receiver.
///
/// FIFO-evicts the oldest event if the log is at capacity. Returns
/// the number of events present after the push (0 when capacity is
/// 0, otherwise `<= capacity`).
pub fn push(&mut self, sketch: &Sketch, novelty: f32, timestamp_us: u64) -> usize {
if self.capacity == 0 {
return 0;
}
let wire = WireSketch::serialize(sketch, novelty);
let mut hasher = Sha256::new();
hasher.update(&wire);
let witness: [u8; 32] = hasher.finalize().into();
if self.events.len() >= self.capacity {
self.events.pop_front();
}
self.events.push_back(NoveltyEvent {
timestamp_us,
sketch_bytes: sketch.packed_bytes().to_vec(),
sketch_version: sketch.sketch_version(),
embedding_dim: sketch.embedding_dim(),
novelty,
witness_sha256: witness,
});
self.events.len()
}
/// Number of events currently stored.
#[inline]
pub fn len(&self) -> usize {
self.events.len()
}
/// True iff the log has no events.
#[inline]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Bank capacity (the max number of events ever held simultaneously).
#[inline]
pub fn capacity(&self) -> usize {
self.capacity
}
/// Iterate over events oldest-first.
pub fn iter(&self) -> impl Iterator<Item = &NoveltyEvent> {
self.events.iter()
}
/// Find the most recent event whose `witness_sha256` matches.
/// Returns `None` if no event matches.
///
/// Used by content-addressable lookups — a downstream receiver
/// can ask "have you logged this exact `(sketch, novelty)` before?"
/// without re-transmitting the sketch.
pub fn find_by_witness(&self, witness: &[u8; 32]) -> Option<&NoveltyEvent> {
self.events
.iter()
.rev()
.find(|e| &e.witness_sha256 == witness)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sketch::Sketch;
fn make_sketch(seed: u32) -> Sketch {
let v: Vec<f32> = (0..32)
.map(|i| ((i as u32).wrapping_mul(seed) as f32).sin())
.collect();
Sketch::from_embedding(&v, 1)
}
#[test]
fn push_grows_until_capacity_then_fifo_evicts() {
let mut log = PrivacyEventLog::new(3);
for i in 0..5u64 {
log.push(&make_sketch(i as u32 + 1), 0.5, i * 1000);
}
assert_eq!(log.len(), 3, "must cap at capacity");
// Oldest two evicted; first remaining timestamp is 2_000.
let first = log.iter().next().unwrap();
assert_eq!(first.timestamp_us, 2000);
}
#[test]
fn zero_capacity_log_silently_drops_pushes() {
let mut log = PrivacyEventLog::new(0);
let n = log.push(&make_sketch(1), 0.5, 0);
assert_eq!(n, 0);
assert_eq!(log.len(), 0);
assert!(log.is_empty());
}
#[test]
fn witness_is_deterministic_for_same_sketch_and_novelty() {
let mut log_a = PrivacyEventLog::new(2);
let mut log_b = PrivacyEventLog::new(2);
let s = make_sketch(7);
// Same sketch + same novelty + (intentionally different)
// timestamps — witness must NOT depend on timestamp; the
// wire format does not include it.
log_a.push(&s, 0.25, 100);
log_b.push(&s, 0.25, 999_999);
let wa = log_a.iter().next().unwrap().witness_sha256;
let wb = log_b.iter().next().unwrap().witness_sha256;
assert_eq!(wa, wb, "witness must be content-addressable, not time-addressable");
}
#[test]
fn witness_differs_for_different_novelty_scores() {
let mut log = PrivacyEventLog::new(2);
let s = make_sketch(11);
log.push(&s, 0.10, 0);
log.push(&s, 0.90, 0);
let mut iter = log.iter();
let w0 = iter.next().unwrap().witness_sha256;
let w1 = iter.next().unwrap().witness_sha256;
assert_ne!(w0, w1, "different novelty → different witness");
}
#[test]
fn find_by_witness_returns_most_recent_match() {
let mut log = PrivacyEventLog::new(5);
let s = make_sketch(42);
log.push(&s, 0.5, 100);
log.push(&make_sketch(99), 0.3, 200);
log.push(&s, 0.5, 300); // duplicate by witness, newer timestamp
let target_witness = log.iter().nth(2).unwrap().witness_sha256;
let hit = log.find_by_witness(&target_witness).unwrap();
assert_eq!(hit.timestamp_us, 300, "find_by_witness returns most recent");
}
#[test]
fn find_by_witness_returns_none_on_miss() {
let mut log = PrivacyEventLog::new(2);
log.push(&make_sketch(1), 0.5, 0);
let bogus = [0xAA_u8; 32];
assert!(log.find_by_witness(&bogus).is_none());
}
#[test]
fn event_does_not_carry_raw_embedding() {
// The whole point of the event log: an attacker with read
// access to the log cannot recover the source CSI / embedding.
// Verify structurally that no `Vec<f32>` field exists on
// NoveltyEvent — only the bit-packed sketch.
let mut log = PrivacyEventLog::new(1);
let s = make_sketch(5);
log.push(&s, 0.5, 0);
let event = log.iter().next().unwrap();
// The packed sketch is bytes (1-bit-per-source-dim, ceil-divided).
// Length proves the source dim (32 bits = 4 bytes).
assert_eq!(event.sketch_bytes.len(), 4);
assert_eq!(event.embedding_dim, 32);
// No way to reconstruct the original `[f32; 32]` from these 4 bytes
// alone; that's the privacy guarantee. (Compile-time witnessed:
// there's no Vec<f32> field on NoveltyEvent.)
}
}