From 60eaaa5af10f3ec3e46b75f6d40e96299c960f3c Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 14:37:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-118/p2.2):=20EmbeddingRing=2064-entry?= =?UTF-8?q?=20FIFO=20buffer=20=E2=80=94=2053/53=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 8. Lands the lifecycle half of ADR-120 §2.5: a bounded, in-place, no_std-compatible ring of IdentityEmbeddings. Insertion is O(1); when full, push evicts the oldest entry, whose Drop runs and zeroizes the f32 storage. drain() clears the ring on the coherence-gate Recalibrate action (ADR-121 §2.4). Added: - src/embedding_ring.rs (no_std-compatible; no heap): * EmbeddingRing struct with [Option; RING_CAPACITY=64] backing array, head cursor, count * EmbeddingRing::new() / Default impl * push(emb) -> Option (evicted oldest when full) * len / is_empty / capacity / is_full / iter * iter() returns occupied slots in insertion order (oldest first) * drain() -> usize (empties the ring, returns count drained) - pub use EmbeddingRing, RING_CAPACITY from lib.rs Uses `[const { None }; RING_CAPACITY]` (stable since 1.79) to initialize the slot array for a non-Copy element type. tests/embedding_ring.rs (9 named tests, all green): new_ring_is_empty default_constructor_matches_new push_below_capacity_returns_none iter_yields_in_insertion_order push_at_capacity_evicts_oldest_and_returns_it (verifies eviction reports the FIRST pushed value, not the last) push_beyond_capacity_keeps_last_n_entries (after 74 pushes into a 64-slot ring, the surviving 64 are positions 10..74) drain_empties_the_ring_and_returns_count drain_on_empty_ring_returns_zero ring_can_be_refilled_after_drain (post-drain push lands cleanly at index 0; iter yields exactly that entry) ACs progressed: - I2 ↑ — ring eviction and explicit drain both drop IdentityEmbeddings, which the iter-7 Drop impl zeroizes. The "in-RAM-only" lifecycle is now end-to-end: bounded buffer in, FIFO out, drain on Recalibrate. Test config: - cargo test --no-default-features → 31 passed (22 + 9) - cargo test → 53 passed (44 + 9) Out of scope (next iter target): - PrivacyGate::demote(frame, target_class) — ADR-120 §2.4 monotonic class transition with field zeroization, refusing demote-to-Raw (compile-fail). - SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the Recalibrate exemption hook is wireable from `--features soul-signature`. Co-Authored-By: claude-flow --- .../wifi-densepose-bfld/src/embedding_ring.rs | 105 ++++++++++++++++++ v2/crates/wifi-densepose-bfld/src/lib.rs | 2 + .../tests/embedding_ring.rs | 104 +++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 v2/crates/wifi-densepose-bfld/src/embedding_ring.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/embedding_ring.rs diff --git a/v2/crates/wifi-densepose-bfld/src/embedding_ring.rs b/v2/crates/wifi-densepose-bfld/src/embedding_ring.rs new file mode 100644 index 00000000..ca99ae1f --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/embedding_ring.rs @@ -0,0 +1,105 @@ +//! `EmbeddingRing` — bounded FIFO of `IdentityEmbedding`s. +//! +//! Holds at most [`RING_CAPACITY`] (default 64) embeddings. When full, `push` +//! evicts and returns the oldest entry so its `Drop` runs and the f32 storage +//! is zeroized. `drain()` is the explicit "rotate site_salt" hook from the +//! coherence-gate `Recalibrate` action (ADR-121 §2.4): it clears every slot +//! at once. The ring is `no_std`-compatible; no heap allocation. + +use crate::embedding::IdentityEmbedding; + +/// Default ring capacity — matches ADR-120 §2.5 ("ring buffer of 64 entries"). +pub const RING_CAPACITY: usize = 64; + +/// Fixed-capacity FIFO of identity embeddings. Insertion-ordered; oldest +/// evicted first when full. +pub struct EmbeddingRing { + slots: [Option; RING_CAPACITY], + /// Index of the oldest slot — the next eviction target. + head: usize, + /// Number of currently-occupied slots (0..=RING_CAPACITY). + count: usize, +} + +impl EmbeddingRing { + /// Build an empty ring. + #[must_use] + pub const fn new() -> Self { + Self { + slots: [const { None }; RING_CAPACITY], + head: 0, + count: 0, + } + } + + /// Insert `emb`. If the ring is already full, evicts and returns the + /// oldest entry (its `Drop` runs as the returned `Option` is dropped). + pub fn push(&mut self, emb: IdentityEmbedding) -> Option { + if self.count < RING_CAPACITY { + // Not full — write into the slot at head + count. + let idx = (self.head + self.count) % RING_CAPACITY; + self.slots[idx] = Some(emb); + self.count += 1; + None + } else { + // Full — overwrite the oldest slot, advance head. + let evicted = self.slots[self.head].take(); + self.slots[self.head] = Some(emb); + self.head = (self.head + 1) % RING_CAPACITY; + evicted + } + } + + /// Number of occupied slots. + #[must_use] + pub const fn len(&self) -> usize { + self.count + } + + /// `true` iff `len() == 0`. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.count == 0 + } + + /// Maximum number of slots — always [`RING_CAPACITY`]. + #[must_use] + pub const fn capacity(&self) -> usize { + RING_CAPACITY + } + + /// `true` iff `len() == capacity()`. + #[must_use] + pub const fn is_full(&self) -> bool { + self.count == RING_CAPACITY + } + + /// Iterate occupied slots in **insertion order** (oldest first). + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.count).map(move |i| { + let idx = (self.head + i) % RING_CAPACITY; + self.slots[idx].as_ref().expect("occupied slot") + }) + } + + /// Empty the ring. Every contained `IdentityEmbedding` is dropped, which + /// zeroizes its storage. Returns the number of entries that were drained. + pub fn drain(&mut self) -> usize { + let drained = self.count; + for slot in &mut self.slots { + // Take() moves the embedding out; the temporary is dropped at the + // end of this statement, running IdentityEmbedding::drop which + // zeroes the f32 array. + let _ = slot.take(); + } + self.head = 0; + self.count = 0; + drained + } +} + +impl Default for EmbeddingRing { + fn default() -> Self { + Self::new() + } +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 84b95841..cf4ddf44 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -14,12 +14,14 @@ #![cfg_attr(not(feature = "std"), no_std)] pub mod embedding; +pub mod embedding_ring; pub mod frame; #[cfg(feature = "std")] pub mod payload; pub mod sink; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; +pub use embedding_ring::{EmbeddingRing, RING_CAPACITY}; pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; #[cfg(feature = "std")] pub use frame::BfldFrame; diff --git a/v2/crates/wifi-densepose-bfld/tests/embedding_ring.rs b/v2/crates/wifi-densepose-bfld/tests/embedding_ring.rs new file mode 100644 index 00000000..f2b9806e --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/embedding_ring.rs @@ -0,0 +1,104 @@ +//! Acceptance tests for ADR-120 §2.5 `EmbeddingRing` lifecycle. + +use wifi_densepose_bfld::{EmbeddingRing, IdentityEmbedding, EMBEDDING_DIM, RING_CAPACITY}; + +fn embedding_with_first(v: f32) -> IdentityEmbedding { + let mut arr = [0.0f32; EMBEDDING_DIM]; + arr[0] = v; + IdentityEmbedding::from_raw(arr) +} + +#[test] +fn new_ring_is_empty() { + let r = EmbeddingRing::new(); + assert_eq!(r.len(), 0); + assert!(r.is_empty()); + assert!(!r.is_full()); + assert_eq!(r.capacity(), RING_CAPACITY); + assert_eq!(r.iter().count(), 0); +} + +#[test] +fn default_constructor_matches_new() { + let r = EmbeddingRing::default(); + assert_eq!(r.len(), 0); +} + +#[test] +fn push_below_capacity_returns_none() { + let mut r = EmbeddingRing::new(); + for i in 0..5 { + let evicted = r.push(embedding_with_first(i as f32)); + assert!(evicted.is_none(), "no eviction expected at i={i}"); + } + assert_eq!(r.len(), 5); +} + +#[test] +fn iter_yields_in_insertion_order() { + let mut r = EmbeddingRing::new(); + for i in 0..5 { + r.push(embedding_with_first(i as f32)); + } + let firsts: Vec = r.iter().map(|e| e.as_slice()[0]).collect(); + assert_eq!(firsts, vec![0.0, 1.0, 2.0, 3.0, 4.0]); +} + +#[test] +fn push_at_capacity_evicts_oldest_and_returns_it() { + let mut r = EmbeddingRing::new(); + for i in 0..RING_CAPACITY { + r.push(embedding_with_first(i as f32)); + } + assert!(r.is_full()); + let evicted = r + .push(embedding_with_first(999.0)) + .expect("must evict when full"); + // The evicted slot held the very first push (first = 0.0). + assert_eq!(evicted.as_slice()[0], 0.0); + assert_eq!(r.len(), RING_CAPACITY); +} + +#[test] +fn push_beyond_capacity_keeps_last_n_entries() { + let mut r = EmbeddingRing::new(); + // Push capacity + 10 entries; the first 10 must have been evicted. + for i in 0..(RING_CAPACITY + 10) { + r.push(embedding_with_first(i as f32)); + } + let firsts: Vec = r.iter().map(|e| e.as_slice()[0]).collect(); + let expected: Vec = (10..(RING_CAPACITY + 10) as i32) + .map(|i| i as f32) + .collect(); + assert_eq!(firsts, expected); +} + +#[test] +fn drain_empties_the_ring_and_returns_count() { + let mut r = EmbeddingRing::new(); + for i in 0..7 { + r.push(embedding_with_first(i as f32)); + } + let drained = r.drain(); + assert_eq!(drained, 7); + assert!(r.is_empty()); + assert_eq!(r.iter().count(), 0); +} + +#[test] +fn drain_on_empty_ring_returns_zero() { + let mut r = EmbeddingRing::new(); + assert_eq!(r.drain(), 0); + assert!(r.is_empty()); +} + +#[test] +fn ring_can_be_refilled_after_drain() { + let mut r = EmbeddingRing::new(); + r.push(embedding_with_first(1.0)); + r.push(embedding_with_first(2.0)); + r.drain(); + r.push(embedding_with_first(42.0)); + assert_eq!(r.len(), 1); + assert_eq!(r.iter().next().unwrap().as_slice()[0], 42.0); +}