diff --git a/iris-mpc-cpu/src/hnsw/graph/layered_graph.rs b/iris-mpc-cpu/src/hnsw/graph/layered_graph.rs index 90c3c04d6..bc27e5366 100644 --- a/iris-mpc-cpu/src/hnsw/graph/layered_graph.rs +++ b/iris-mpc-cpu/src/hnsw/graph/layered_graph.rs @@ -147,7 +147,7 @@ pub struct EntryPoint { } /// An in-memory implementation of an HNSW hierarchical graph. -#[derive(Default, PartialEq, Eq, Debug)] +#[derive(Default, Debug)] pub struct GraphMem { /// Entry points for HNSW search. /// @@ -176,6 +176,16 @@ pub struct GraphMem { /// serialized, recomputed by `from_parts`, kept in sync by the mutation /// apply. Private so all construction routes through `from_parts`. node_init_hash: SetHash, + + /// Seq_no of the last op that can invalidate existing edges: a + /// `RemoveNode`, or an `AddNode` re-minting a live serial. Invariant: a + /// neighborhood stamped at or after it holds no stale edge — seeded past + /// the load point by `from_parts` (every loaded neighborhood filters on + /// first touch) and maintained inductively by the edits themselves; a + /// `new` graph is empty, vacuously clean. Lets + /// [`Self::edit_neighborhood`] skip the staleness filter. Derived and + /// in-memory only. + last_invalidation_seq: u64, } impl Display for GraphMem { @@ -219,10 +229,34 @@ impl Clone for GraphMem { last_update_seq_no: self.last_update_seq_no, node_init: self.node_init.clone(), node_init_hash: self.node_init_hash.clone(), + last_invalidation_seq: self.last_invalidation_seq, } } } +/// Equality over the semantic state only; derived fields (`node_init_hash`, +/// `last_invalidation_seq`) are excluded — a conservatively seeded load +/// compares equal to the mint it round-trips. +impl PartialEq for GraphMem { + fn eq(&self, other: &Self) -> bool { + // Destructured so a new field forces a decision here. + let Self { + entry_points, + layers, + last_update_seq_no, + node_init, + node_init_hash: _, + last_invalidation_seq: _, + } = self; + *entry_points == other.entry_points + && *layers == other.layers + && *last_update_seq_no == other.last_update_seq_no + && *node_init == other.node_init + } +} + +impl Eq for GraphMem {} + impl<'de> Deserialize<'de> for GraphMem { fn deserialize(deserializer: D) -> std::result::Result where @@ -255,6 +289,7 @@ impl GraphMem { last_update_seq_no: 0, node_init: HashMap::new(), node_init_hash: SetHash::default(), + last_invalidation_seq: 0, } } @@ -279,6 +314,10 @@ impl GraphMem { last_update_seq_no, node_init, node_init_hash, + // History at or before the load point is unknown (legacy + // prune/migration can leave edges to absent serials); seed past it + // so every loaded neighborhood is filtered on first touch. + last_invalidation_seq: last_update_seq_no.saturating_add(1), } } @@ -416,6 +455,8 @@ impl GraphMem { self.node_init_hash .remove(Self::node_init_contribution(sid, old)); } + // Edges to the removed node are now stale everywhere. + self.last_invalidation_seq = seq_no; } MutationOp::AddNode { id, @@ -446,6 +487,10 @@ impl GraphMem { if let Some(old) = self.node_init.insert(sid, init) { self.node_init_hash .remove(Self::node_init_contribution(sid, old)); + // Re-mint: edges to the serial's old content are now + // stale. A fresh serial invalidates nothing — no list + // can hold an edge to a node minted after its stamp. + self.last_invalidation_seq = seq_no; } self.node_init_hash .add_unordered(Self::node_init_contribution(sid, init)); @@ -548,8 +593,19 @@ impl GraphMem { F: FnOnce(&mut Vec), { let content = &self.node_init; + // A certificate at or past the last invalidating op covers no stale + // edge — the filter would keep everything, so it is skipped. + let invalidation_seq = self.last_invalidation_seq; self.layers[lc].edit_links(node, tick, |old_seq, nbrs| { - nbrs.retain(|z| is_active(content, *z, old_seq)); + if old_seq < invalidation_seq { + nbrs.retain(|z| is_active(content, *z, old_seq)); + } else { + // The skip's precondition: the filter would drop nothing. + debug_assert!( + nbrs.iter().all(|z| is_active(content, *z, old_seq)), + "skipped a staleness filter that would have dropped an edge" + ); + } f(nbrs); debug_assert!( nbrs.iter().all(|z| is_active(content, *z, tick.value())), @@ -721,6 +777,18 @@ impl GraphMem { .unwrap_or_default() } + /// Return the raw stored neighbor count of `base` at `lc` — a header + /// read, no decode. + /// + /// Counts raw entries, an upper bound on the active degree. 0 if + /// `base`/`lc` absent. + pub fn raw_degree(&self, base: &SerialId, lc: usize) -> usize { + self.layers + .get(lc) + .and_then(|layer| layer.get_links(base)) + .map_or(0, |n| n.degree()) + } + /// Current `VectorId` of an in-graph node, from the content clock. `None` /// means not live — callers must not fabricate a version for it. pub fn vector_id_of(&self, serial: SerialId) -> Option { diff --git a/iris-mpc-cpu/src/hnsw/graph/layered_graph/tests.rs b/iris-mpc-cpu/src/hnsw/graph/layered_graph/tests.rs index 675670a27..a8854510c 100644 --- a/iris-mpc-cpu/src/hnsw/graph/layered_graph/tests.rs +++ b/iris-mpc-cpu/src/hnsw/graph/layered_graph/tests.rs @@ -3,8 +3,9 @@ use std::{collections::HashMap, sync::Arc}; use crate::{ hawkers::{aby3::aby3_store::FhdOps, plaintext_store::PlaintextStore}, hnsw::{ - graph::layered_graph::migrate, vector_store::VectorStoreMut, GraphMem, HnswSearcher, - VectorStore, + graph::layered_graph::{migrate, Layer, NodeInit}, + vector_store::VectorStoreMut, + GraphMem, HnswSearcher, VectorStore, }, }; use aes_prng::AesRng; @@ -1766,3 +1767,181 @@ fn insert_apply_all_short_circuits_on_first_violation() { assert!(graph.layers[0].get_links(&1).is_some()); assert!(graph.layers[0].get_links(&2).is_none()); } + +/// `last_invalidation_seq` moves only on `RemoveNode` and re-minting +/// `AddNode`; fresh `AddNode`s and edge ops leave it alone. The staleness +/// filter still physically drops a removed node from a neighborhood stamped +/// before the removal. +#[tokio::test] +async fn invalidation_seq_transitions_and_filter_still_drops_stale_edges() { + let node = |id: VectorId| MutationOp::AddNode { + id, + height: 1, + update_ep: UpdateEntryPoint::False, + }; + let v = VectorId::from_serial_id; + let mut g = GraphMem::new(); + assert_eq!(g.last_invalidation_seq, 0); + + // Fresh mints and edges: no invalidation. + g.insert_apply(&GraphMutation { + seq_no: 1, + as_of: 0, + ops: vec![node(v(1)), node(v(2)), node(v(3))], + }) + .unwrap(); + g.insert_apply(&GraphMutation { + seq_no: 2, + as_of: 1, + ops: vec![MutationOp::AddEdges { + base: 1, + neighbors: vec![2, 3], + layer: 0, + edge_type: EdgeType::All, + }], + }) + .unwrap(); + assert_eq!(g.last_invalidation_seq, 0); + + // RemoveNode invalidates. + g.insert_apply(&GraphMutation { + seq_no: 3, + as_of: 2, + ops: vec![MutationOp::RemoveNode { id: v(3) }], + }) + .unwrap(); + assert_eq!(g.last_invalidation_seq, 3); + + // Node 1's list was stamped at 2 < 3: raw still holds 3, active does not. + assert_eq!(g.get_raw_links(&1, 0), vec![2, 3]); + assert_eq!(g.get_active_links(&1, 0), vec![v(2)]); + + // Touching node 1's list runs the filter (old stamp predates the + // invalidation) and physically drops 3. + g.insert_apply(&GraphMutation { + seq_no: 4, + as_of: 3, + ops: vec![ + node(v(4)), + MutationOp::AddEdges { + base: 4, + neighbors: vec![1], + layer: 0, + edge_type: EdgeType::All, + }, + ], + }) + .unwrap(); + assert_eq!(g.last_invalidation_seq, 3, "fresh mint must not invalidate"); + assert_eq!(g.get_raw_links(&1, 0), vec![2, 4]); + + // Re-stamped at 4 >= 3: further touches take the skip path and stay + // equivalent (debug_assert in edit_neighborhood guards the skip). + g.insert_apply(&GraphMutation { + seq_no: 5, + as_of: 4, + ops: vec![ + node(v(5)), + MutationOp::AddEdges { + base: 5, + neighbors: vec![1], + layer: 0, + edge_type: EdgeType::All, + }, + ], + }) + .unwrap(); + assert_eq!(g.get_raw_links(&1, 0), vec![2, 4, 5]); + + // Re-minting an existing serial invalidates. + g.insert_apply(&GraphMutation { + seq_no: 6, + as_of: 5, + ops: vec![MutationOp::AddNode { + id: VectorId::new(2, 1), + height: 1, + update_ep: UpdateEntryPoint::False, + }], + }) + .unwrap(); + assert_eq!(g.last_invalidation_seq, 6); + + // A load seeds the watermark past the load point but compares equal. + let bytes = bincode::serialize(&g).unwrap(); + let loaded: GraphMem = bincode::deserialize(&bytes).unwrap(); + assert_eq!(loaded.last_invalidation_seq, loaded.last_update_seq_no + 1); + assert_eq!(g, loaded); + assert_eq!(g.checksum(), loaded.checksum()); +} + +/// A loaded graph may hold raw edges to serials absent from the content +/// clock (legacy prune/migration damage). Seeding the watermark past the +/// load point forces the first touch to filter them out — even when a later +/// fresh mint reuses the phantom serial. +#[tokio::test] +async fn loaded_phantom_edge_is_dropped_on_first_touch() { + // Node 1 -> [2, 500] stamped at the load height; 500 has no clock entry. + let mut layer = Layer::new(); + layer.set_links_trusted(1, vec![2, 500], 5); + layer.set_links_trusted(2, vec![1], 5); + let node_init = HashMap::from([ + ( + 1, + NodeInit { + seq_no: 1, + version: 0, + }, + ), + ( + 2, + NodeInit { + seq_no: 1, + version: 0, + }, + ), + ]); + let mut g = GraphMem::from_parts(vec![], vec![layer], 5, node_init); + assert_eq!(g.last_invalidation_seq, 6); + + // A fresh mint reuses the phantom serial: it must not resurrect the edge. + g.insert_apply(&GraphMutation { + seq_no: 6, + as_of: 5, + ops: vec![MutationOp::AddNode { + id: VectorId::new(500, 3), + height: 1, + update_ep: UpdateEntryPoint::False, + }], + }) + .unwrap(); + + // First touch of node 1's list filters against its old stamp: the + // phantom edge to 500 is dropped, the new edge to 4 is added. + g.insert_apply(&GraphMutation { + seq_no: 7, + as_of: 6, + ops: vec![ + MutationOp::AddNode { + id: VectorId::from_serial_id(4), + height: 1, + update_ep: UpdateEntryPoint::False, + }, + MutationOp::AddEdges { + base: 4, + neighbors: vec![1], + layer: 0, + edge_type: EdgeType::All, + }, + ], + }) + .unwrap(); + assert_eq!(g.get_raw_links(&1, 0), vec![2, 4]); + assert_eq!( + g.get_active_links(&1, 0), + vec![VectorId::from_serial_id(2), VectorId::from_serial_id(4)] + ); + + // A zero-height load (legacy migration paths) seeds past 0 as well. + let empty = GraphMem::from_parts(vec![], vec![Layer::new()], 0, HashMap::new()); + assert_eq!(empty.last_invalidation_seq, 1); +} diff --git a/iris-mpc-cpu/src/hnsw/searcher.rs b/iris-mpc-cpu/src/hnsw/searcher.rs index 8994300fa..da193ed21 100644 --- a/iris-mpc-cpu/src/hnsw/searcher.rs +++ b/iris-mpc-cpu/src/hnsw/searcher.rs @@ -1509,9 +1509,13 @@ impl HnswSearcher { let as_of = graph.last_update_seq_no; // Read the current neighborhood for each candidate; keep only those - // exceeding M_limit on their layer. + // exceeding M_limit on their layer. Raw degree bounds active degree, + // so a header read filters most candidates without decoding. let mut oversized: Vec<(VectorId, usize, Vec)> = Vec::new(); for (serial, layer) in candidates { + if graph.raw_degree(serial, *layer) <= self.params.get_M_limit(*layer) { + continue; + } let nbhd: Vec = graph.get_active_links(serial, *layer); if nbhd.len() > self.params.get_M_limit(*layer) { // A node in a layer always has a content-clock entry; resolve