feat(adr-118/p2.2): EmbeddingRing 64-entry FIFO buffer — 53/53 GREEN
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<IdentityEmbedding>; RING_CAPACITY=64]
backing array, head cursor, count
* EmbeddingRing::new() / Default impl
* push(emb) -> Option<IdentityEmbedding> (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 <ruv@ruv.net>
This commit is contained in:
parent
71ca2780bf
commit
60eaaa5af1
|
|
@ -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<IdentityEmbedding>; 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<IdentityEmbedding> {
|
||||||
|
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<Item = &IdentityEmbedding> + '_ {
|
||||||
|
(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,14 @@
|
||||||
#![cfg_attr(not(feature = "std"), no_std)]
|
#![cfg_attr(not(feature = "std"), no_std)]
|
||||||
|
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
|
pub mod embedding_ring;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
pub mod sink;
|
pub mod sink;
|
||||||
|
|
||||||
pub use embedding::{IdentityEmbedding, EMBEDDING_DIM};
|
pub use embedding::{IdentityEmbedding, EMBEDDING_DIM};
|
||||||
|
pub use embedding_ring::{EmbeddingRing, RING_CAPACITY};
|
||||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
pub use frame::BfldFrame;
|
pub use frame::BfldFrame;
|
||||||
|
|
|
||||||
|
|
@ -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<f32> = 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<f32> = r.iter().map(|e| e.as_slice()[0]).collect();
|
||||||
|
let expected: Vec<f32> = (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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue