use serde::{Deserialize, Serialize};
use crate::graph::unified::node::id::NodeId;
use crate::graph::unified::storage::arena::NodeArena;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct NodeProvenance {
pub first_seen_epoch: u64,
pub last_seen_epoch: u64,
pub content_hash: [u8; 32],
}
impl NodeProvenance {
#[must_use]
pub fn fresh(epoch: u64, content_hash: [u8; 32]) -> Self {
Self {
first_seen_epoch: epoch,
last_seen_epoch: epoch,
content_hash,
}
}
pub fn touch(&mut self, epoch: u64) {
if epoch > self.last_seen_epoch {
self.last_seen_epoch = epoch;
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct NodeProvenanceStore {
slots: Vec<Option<(u64, NodeProvenance)>>,
}
impl NodeProvenanceStore {
#[must_use]
pub fn new() -> Self {
Self { slots: Vec::new() }
}
#[must_use]
pub fn empty_for(arena: &NodeArena) -> Self {
Self {
slots: vec![None; arena.slot_count()],
}
}
#[must_use]
pub fn slot_count(&self) -> usize {
self.slots.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.slots.iter().filter(|s| s.is_some()).count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn resize_to(&mut self, arena_slot_count: usize) {
self.slots.resize(arena_slot_count, None);
}
pub fn insert(&mut self, id: NodeId, provenance: NodeProvenance) {
let index = id.index() as usize;
if index >= self.slots.len() {
self.slots.resize(index + 1, None);
}
self.slots[index] = Some((id.generation(), provenance));
}
#[must_use]
pub fn lookup(&self, id: NodeId) -> Option<&NodeProvenance> {
let index = id.index() as usize;
let (stored_generation, provenance) = self.slots.get(index)?.as_ref()?;
if *stored_generation == id.generation() {
Some(provenance)
} else {
None
}
}
pub fn clear_slot(&mut self, index: u32) {
let i = index as usize;
if let Some(entry) = self.slots.get_mut(i) {
*entry = None;
}
}
pub fn iter_node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
self.slots.iter().enumerate().filter_map(|(index, slot)| {
slot.as_ref().map(|(generation, _provenance)| {
NodeId::new(
u32::try_from(index).expect("slot index fits u32"),
*generation,
)
})
})
}
#[allow(dead_code)]
pub(crate) fn retain_by_node(&mut self, keep: &dyn Fn(NodeId) -> bool) {
for (index, slot) in self.slots.iter_mut().enumerate() {
let Some((generation, _provenance)) = slot else {
continue;
};
let id = NodeId::new(
u32::try_from(index).expect("slot index fits u32"),
*generation,
);
if !keep(id) {
*slot = None;
}
}
}
}
impl crate::graph::unified::memory::GraphMemorySize for NodeProvenanceStore {
fn heap_bytes(&self) -> usize {
self.slots.capacity() * std::mem::size_of::<Option<(u64, NodeProvenance)>>()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::unified::node::id::NodeId;
fn prov(epoch: u64, byte: u8) -> NodeProvenance {
NodeProvenance::fresh(epoch, [byte; 32])
}
#[test]
fn new_store_is_empty_and_has_zero_slots() {
let store = NodeProvenanceStore::new();
assert_eq!(store.slot_count(), 0);
assert_eq!(store.len(), 0);
assert!(store.is_empty());
}
#[test]
fn resize_extends_with_none() {
let mut store = NodeProvenanceStore::new();
store.resize_to(16);
assert_eq!(store.slot_count(), 16);
assert_eq!(store.len(), 0);
assert!(store.is_empty());
}
#[test]
fn insert_lookup_round_trip() {
let mut store = NodeProvenanceStore::new();
let id = NodeId::new(7, 3);
let p = prov(100, 0xAB);
store.insert(id, p);
let got = store.lookup(id).expect("provenance present");
assert_eq!(got.first_seen_epoch, 100);
assert_eq!(got.last_seen_epoch, 100);
assert_eq!(got.content_hash, [0xAB; 32]);
assert_eq!(store.len(), 1);
assert!(store.slot_count() >= 8);
}
#[test]
fn insert_auto_extends_slots() {
let mut store = NodeProvenanceStore::new();
store.insert(NodeId::new(0, 1), prov(1, 0));
store.insert(NodeId::new(5, 1), prov(2, 0));
store.insert(NodeId::new(3, 1), prov(3, 0));
assert_eq!(store.slot_count(), 6);
assert_eq!(store.len(), 3);
}
#[test]
fn stale_generation_returns_none_even_when_slot_is_populated() {
let mut store = NodeProvenanceStore::new();
let old_id = NodeId::new(4, 1);
let new_id = NodeId::new(4, 2);
store.insert(new_id, prov(50, 0xCC));
assert!(store.lookup(new_id).is_some());
assert!(store.lookup(old_id).is_none());
}
#[test]
fn lookup_out_of_range_returns_none() {
let store = NodeProvenanceStore::new();
assert!(store.lookup(NodeId::new(999, 1)).is_none());
}
#[test]
fn lookup_vacant_slot_returns_none() {
let mut store = NodeProvenanceStore::new();
store.resize_to(10);
assert!(store.lookup(NodeId::new(5, 1)).is_none());
}
#[test]
fn clear_slot_drops_provenance_and_generation() {
let mut store = NodeProvenanceStore::new();
let id = NodeId::new(2, 5);
store.insert(id, prov(100, 0x11));
assert!(store.lookup(id).is_some());
store.clear_slot(2);
assert!(store.lookup(id).is_none());
assert_eq!(store.len(), 0);
}
#[test]
fn clear_slot_out_of_range_is_noop() {
let mut store = NodeProvenanceStore::new();
store.resize_to(4);
store.clear_slot(99);
assert_eq!(store.slot_count(), 4);
}
#[test]
fn realloc_into_recycled_slot_does_not_leak_prior_provenance() {
let mut store = NodeProvenanceStore::new();
let first = NodeId::new(3, 1);
store.insert(first, prov(10, 0xAA));
store.clear_slot(3);
let second = NodeId::new(3, 2);
store.insert(second, prov(20, 0xBB));
assert!(
store.lookup(first).is_none(),
"stale first NodeId must not see any provenance"
);
let got = store
.lookup(second)
.expect("second NodeId must see its own provenance");
assert_eq!(got.first_seen_epoch, 20);
assert_eq!(got.content_hash, [0xBB; 32]);
}
#[test]
fn provenance_touch_advances_only_forward() {
let mut p = prov(100, 0);
p.touch(50);
assert_eq!(
p.last_seen_epoch, 100,
"stale touch must not move last_seen backwards"
);
p.touch(200);
assert_eq!(p.last_seen_epoch, 200);
assert_eq!(p.first_seen_epoch, 100, "first_seen must never move");
}
#[test]
fn postcard_round_trip_preserves_dense_layout() {
let mut store = NodeProvenanceStore::new();
store.resize_to(4);
store.insert(NodeId::new(0, 1), prov(10, 0x01));
store.insert(NodeId::new(2, 4), prov(20, 0x02));
let encoded = postcard::to_allocvec(&store).expect("encode");
let decoded: NodeProvenanceStore = postcard::from_bytes(&encoded).expect("decode");
assert_eq!(decoded.slot_count(), 4);
assert_eq!(decoded.len(), 2);
assert_eq!(
decoded.lookup(NodeId::new(0, 1)).unwrap().first_seen_epoch,
10
);
assert_eq!(
decoded.lookup(NodeId::new(2, 4)).unwrap().content_hash,
[0x02; 32]
);
assert!(decoded.lookup(NodeId::new(0, 9)).is_none());
}
#[test]
fn empty_for_sizes_to_arena_slot_count() {
let arena = NodeArena::new();
let store = NodeProvenanceStore::empty_for(&arena);
assert_eq!(store.slot_count(), arena.slot_count());
assert!(store.is_empty());
}
#[test]
fn default_is_new() {
let a = NodeProvenanceStore::default();
let b = NodeProvenanceStore::new();
assert_eq!(a.slot_count(), b.slot_count());
assert_eq!(a.len(), b.len());
}
#[test]
fn memory_budget_64_bytes_per_slot_serialized_at_1k_nodes() {
let mut store = NodeProvenanceStore::new();
for i in 0..1000u32 {
let id = NodeId::new(i, 1);
store.insert(id, prov(u64::from(i) + 1, (i & 0xff) as u8));
}
let encoded = postcard::to_allocvec(&store).expect("encode");
assert!(
encoded.len() <= 64 * 1000,
"dense store budget exceeded: {} bytes > {} bytes ceiling",
encoded.len(),
64 * 1000
);
}
}