Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions iris-mpc-cpu/src/hnsw/graph/layered_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
Expand Down Expand Up @@ -255,6 +289,7 @@ impl GraphMem {
last_update_seq_no: 0,
node_init: HashMap::new(),
node_init_hash: SetHash::default(),
last_invalidation_seq: 0,
}
}

Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -548,8 +593,19 @@ impl GraphMem {
F: FnOnce(&mut Vec<SerialId>),
{
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())),
Expand Down Expand Up @@ -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<VectorId> {
Expand Down
183 changes: 181 additions & 2 deletions iris-mpc-cpu/src/hnsw/graph/layered_graph/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
6 changes: 5 additions & 1 deletion iris-mpc-cpu/src/hnsw/searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<VectorId>)> = Vec::new();
for (serial, layer) in candidates {
if graph.raw_degree(serial, *layer) <= self.params.get_M_limit(*layer) {
continue;
}
let nbhd: Vec<VectorId> = 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
Expand Down
Loading