From 65fec272fef2f5f6feb2b4ad2343761fb7db5aa3 Mon Sep 17 00:00:00 2001 From: Stuart Woodbury Date: Thu, 9 Jul 2026 12:14:54 -0400 Subject: [PATCH 1/3] add metrics to measure the effect of supermatchers --- iris-mpc-cpu/src/execution/hawk_main/search.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/iris-mpc-cpu/src/execution/hawk_main/search.rs b/iris-mpc-cpu/src/execution/hawk_main/search.rs index 33a1cb805..87d66889c 100644 --- a/iris-mpc-cpu/src/execution/hawk_main/search.rs +++ b/iris-mpc-cpu/src/execution/hawk_main/search.rs @@ -251,6 +251,16 @@ async fn classify_and_extend( metrics::counter!("supermatcher_still_saturated_after_extended").increment(1); } + metrics::histogram!("extended_search_new_anon_stats_matches").record( + supermatch_classified.anon_stats_matches.results.len() as f64 + - classified.anon_stats_matches.results.len() as f64, + ); + + metrics::histogram!("extended_search_new_matches").record( + supermatch_classified.matches.results.len() as f64 + - classified.matches.results.len() as f64, + ); + return Ok(supermatch_classified); } From 6aab55c3e364941dd931a55e7dade255ad98c306 Mon Sep 17 00:00:00 2001 From: Stuart Woodbury Date: Wed, 15 Jul 2026 18:16:27 -0400 Subject: [PATCH 2/3] bundle supermatcher result with the first search --- iris-mpc-cpu/src/execution/hawk_main.rs | 4 + .../src/execution/hawk_main/matching.rs | 310 +++++++++++++----- .../src/execution/hawk_main/search.rs | 16 +- iris-mpc-cpu/src/hnsw/searcher.rs | 1 + 4 files changed, 238 insertions(+), 93 deletions(-) diff --git a/iris-mpc-cpu/src/execution/hawk_main.rs b/iris-mpc-cpu/src/execution/hawk_main.rs index 9f425a8ba..ef5de1f0e 100644 --- a/iris-mpc-cpu/src/execution/hawk_main.rs +++ b/iris-mpc-cpu/src/execution/hawk_main.rs @@ -434,6 +434,10 @@ pub struct ClassifiedMatches { pub matches: SaturableMatches, /// Neighbors below the anon stats threshold (0.375). Superset of `matches`. Used for anon stats. pub anon_stats_matches: SaturableMatches, + /// The pre-extension `matches`, retained only when a supermatcher extended + /// search replaced the original results. Lets the decision step compare the + /// extended search's outcome against the original search alone. + pub pre_extension: Option, } /// A high-level plan for inserting a query into the HNSW graph after a search. diff --git a/iris-mpc-cpu/src/execution/hawk_main/matching.rs b/iris-mpc-cpu/src/execution/hawk_main/matching.rs index cb6c0d8b0..d175b497c 100644 --- a/iris-mpc-cpu/src/execution/hawk_main/matching.rs +++ b/iris-mpc-cpu/src/execution/hawk_main/matching.rs @@ -70,69 +70,130 @@ impl BatchStep1 { } } -struct Step1 { +/// The inner/anti join of one request's search matches, plus per-eye saturation. +/// +/// `inner_join` holds vectors that matched on both eyes directly in the search +/// results. `anti_join[side]` holds vectors that matched only on `side`; the +/// other eye is resolved later via `resolve` using the MPC `missing_is_match`. +#[derive(Clone, Debug)] +struct SearchJoin { inner_join: VecEdges<(VectorId, BothEyes)>, anti_join: BothEyes>, /// True per eye if any rotation's match results were saturated (supermatcher). saturated: BothEyes, +} + +struct Step1 { + /// Search matches from the (possibly supermatcher-extended) results. + join: SearchJoin, + /// Search matches from the pre-extension results, present only for requests + /// whose search was extended by the supermatcher. `None` means no extension + /// happened. + pre_join: Option, luc_ids: Vec, request_type: RequestType, } -impl Step1 { - fn new( +impl SearchJoin { + /// Build the inner/anti join by merging match results across all rotations + /// of both eyes. When `use_pre` is set, the pre-extension matches are used + /// for any rotation that was extended by the supermatcher (falling back to + /// the normal matches for rotations that were not extended). + fn from_rotations( search_results: BothEyes<&VecRotations>, - luc_ids: Vec, - request_type: RequestType, - ) -> Step1 { + use_pre: bool, + ) -> SearchJoin { let mut full_join: MapEdges> = HashMap::new(); let mut saturated = [false, false]; for (side, rotations) in izip!([LEFT, RIGHT], search_results) { // Merge matches from all rotations. for rotation in rotations.iter() { - if rotation.classified.matches.saturated { + let matches = if use_pre { + rotation + .classified + .pre_extension + .as_ref() + .unwrap_or(&rotation.classified.matches) + } else { + &rotation.classified.matches + }; + if matches.saturated { saturated[side] = true; } - for (vector_id, _) in rotation.classified.matches.results.iter() { + for (vector_id, _) in matches.results.iter() { full_join.entry(*vector_id).or_default()[side] = true; } } } - let mut step1 = Step1::with_capacity(full_join.len()); - step1.saturated = saturated; - step1.luc_ids = luc_ids; - step1.request_type = request_type; - let full_join_partial_matches_ordered: Vec<_> = full_join .into_iter() .filter(|(_, [is_match_l, is_match_r])| *is_match_l || *is_match_r) .sorted() .collect(); + let mut inner_join = Vec::new(); + let mut anti_join: BothEyes> = [Vec::new(), Vec::new()]; for (vector_id, is_match_lr) in full_join_partial_matches_ordered { match is_match_lr { - [true, true] => step1.inner_join.push((vector_id, [true, true])), - [true, false] => step1.anti_join[LEFT].push(vector_id), - [false, true] => step1.anti_join[RIGHT].push(vector_id), + [true, true] => inner_join.push((vector_id, [true, true])), + [true, false] => anti_join[LEFT].push(vector_id), + [false, true] => anti_join[RIGHT].push(vector_id), [false, false] => {} } } - step1 + SearchJoin { + inner_join, + anti_join, + saturated, + } + } + + /// Resolve anti-join entries into a full join using the MPC-computed + /// `missing_is_match` results for the opposite eye. + fn resolve( + &self, + missing_is_match: BothEyes<&MapEdges>, + ) -> VecEdges<(VectorId, BothEyes)> { + let mut full_join = self.inner_join.clone(); + for id in &self.anti_join[LEFT] { + if let Some(right) = missing_is_match[RIGHT].get(id) { + full_join.push((*id, [true, *right])); + } + } + for id in &self.anti_join[RIGHT] { + if let Some(left) = missing_is_match[LEFT].get(id) { + full_join.push((*id, [*left, true])); + } + } + full_join } +} + +impl Step1 { + fn new( + search_results: BothEyes<&VecRotations>, + luc_ids: Vec, + request_type: RequestType, + ) -> Step1 { + let join = SearchJoin::from_rotations(search_results, false); + + // Only build the pre-extension join when at least one rotation was + // actually extended by the supermatcher; otherwise it equals `join`. + let any_pre = search_results.iter().any(|rotations| { + rotations + .iter() + .any(|r| r.classified.pre_extension.is_some()) + }); + let pre_join = any_pre.then(|| SearchJoin::from_rotations(search_results, true)); - fn with_capacity(capacity: usize) -> Self { Step1 { - inner_join: Vec::with_capacity(capacity), - anti_join: [ - Vec::with_capacity(capacity / 2), - Vec::with_capacity(capacity / 2), - ], - saturated: [false, false], - luc_ids: Vec::new(), - request_type: RequestType::Unsupported, + join, + pre_join, + luc_ids, + request_type, } } @@ -145,11 +206,18 @@ impl Step1 { fn missing_vector_ids(&self, side: usize) -> VecEdges { let other_side = 1 - side; - let anti_join = &self.anti_join[other_side]; + let anti_join = &self.join.anti_join[other_side]; + // Include the pre-extension anti-join so the pre-extension outcome can be + // resolved from the same MPC results (its ids are a subset of `join`'s, + // but include them explicitly to be safe). + let pre_anti_join = self + .pre_join + .iter() + .flat_map(|j| j.anti_join[other_side].iter()); // Always add reauth target so is_match is computed even if the search didn't hit it. let reauth_id = self.reauth_id().map(|(id, _)| id); - chain!(anti_join, &self.luc_ids, &reauth_id) + chain!(anti_join, pre_anti_join, &self.luc_ids, &reauth_id) .cloned() .unique() .collect_vec() @@ -171,44 +239,29 @@ impl Step1 { .collect_vec(); let reauth_result = self.reauth_id().map(|(id, or_rule)| { - tracing::info!("Reauth ID: {id}, or_rule: {or_rule}"); - tracing::info!( - "Left match: {}, missing_is_match[LEFT] {:?}", - missing_is_match[LEFT].get(&id).unwrap_or(&false), - missing_is_match[LEFT] - ); - tracing::info!( - "Right match: {}, missing_is_match[RIGHT] {:?}", - missing_is_match[RIGHT].get(&id).unwrap_or(&false), - missing_is_match[RIGHT] - ); let is_match = [LEFT, RIGHT].map(|side| *missing_is_match[side].get(&id).unwrap_or(&false)); + tracing::debug!("Reauth ID: {id}, or_rule: {or_rule}, is_match: {is_match:?}"); (id, or_rule, is_match) }); - let mut step2 = Step2 { - full_join: self.inner_join, + let join = ResolvedJoin { + full_join: self.join.resolve(missing_is_match), + saturated: self.join.saturated, + }; + let pre_join = self.pre_join.as_ref().map(|pre| ResolvedJoin { + full_join: pre.resolve(missing_is_match), + saturated: pre.saturated, + }); + + Step2 { + join, + pre_join, luc_results, reauth_result, intra_matches, - saturated: self.saturated, request_type: self.request_type, - }; - - for id in &self.anti_join[LEFT] { - if let Some(right) = missing_is_match[RIGHT].get(id) { - step2.full_join.push((*id, [true, *right])); - } } - - for id in &self.anti_join[RIGHT] { - if let Some(left) = missing_is_match[LEFT].get(id) { - step2.full_join.push((*id, [*left, true])); - } - } - - step2 } } @@ -255,6 +308,66 @@ pub const DECISION_FILTER: Filter = Filter { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BatchStep3(VecRequests); +/// Evaluate whether a uniqueness request matched, given its selected match ids +/// and the decisions already made for earlier requests in the batch. +/// +/// Returns `(is_match, because_supermatch)` where `because_supermatch` is true +/// if a `Supermatch` (saturation) id was the reason a match was found. Note this +/// depends on `Supermatch` being yielded last by `select`, so it is only set +/// when no ordinary match short-circuited the search first. +fn uniqueness_is_match( + ids: impl Iterator, + prior_decisions: &[Decision], +) -> (bool, bool) { + let mut because_supermatch = false; + let is_match = ids.into_iter().any(|id| match id { + Search(_) | Luc(_) | Reauth(_) => true, + Supermatch => { + because_supermatch = true; + true + } + IntraBatch(request_i) => { + match prior_decisions.get(request_i) { + // If the request we matched with will be inserted or updated, + // then we are blocked by this intra-batch match. + Some(decision) => decision.is_mutation(), + // The request we matched with is after us in the batch, so we are not blocked by it. + None => false, + } + } + }); + (is_match, because_supermatch) +} + +/// Supermatcher A/B comparison: for requests whose search was extended by the +/// supermatcher, compare the extended search-match outcome against what the +/// pre-extension search alone would have produced. The `Supermatch` (saturation) +/// signal is excluded so we isolate whether the extended search surfaced a +/// *real* neighbor match that the original search missed. +fn record_extension_metrics(request: &Step3, filter: Filter, prior_decisions: &[Decision]) { + if !request.has_pre_extension() { + return; + } + let not_supermatch = |id: &MatchId| !matches!(id, Supermatch); + let (extended_match, _) = uniqueness_is_match( + request.select(filter).filter(not_supermatch), + prior_decisions, + ); + let (pre_match, _) = uniqueness_is_match( + request.select_pre(filter).filter(not_supermatch), + prior_decisions, + ); + match (pre_match, extended_match) { + (false, true) => { + metrics::counter!("supermatcher_extended_search_found_new_match").increment(1); + } + (true, false) => { + metrics::counter!("supermatcher_extended_search_lost_match").increment(1); + } + _ => {} + } +} + impl BatchStep3 { /// The final decision of what to do with a request. /// @@ -264,7 +377,7 @@ impl BatchStep3 { /// Applies supermatcher rejection: if any rotation's match results were /// saturated on either eye, the decision is forced to `NoMutation`. pub fn decisions(&self) -> VecRequests { - tracing::info!( + tracing::debug!( "Calculating decisions for batch of {} requests", self.0.len() ); @@ -275,7 +388,7 @@ impl BatchStep3 { let mut decisions = Vec::::with_capacity(self.0.len()); for request in &self.0 { - tracing::info!( + tracing::debug!( "Processing request type normal: {:?} mirror {:?}", request.normal.request_type, request.mirror.request_type, @@ -284,22 +397,11 @@ impl BatchStep3 { let decision = match request.normal.request_type { RequestType::Uniqueness(UniquenessRequest { skip_persistence }) => { - let is_match = request.select(filter).any(|id| match id { - Search(_) | Luc(_) | Reauth(_) => true, - Supermatch => { - because_supermatch = true; - true - } - IntraBatch(request_i) => { - match decisions.get(request_i) { - // If the request we matched with will be inserted or updated, - // then we are blocked by this intra-batch match. - Some(decision) => decision.is_mutation(), - // The request we matched with is after us in the batch, so we are not blocked by it. - None => false, - } - } - }); + let (is_match, bsm) = uniqueness_is_match(request.select(filter), &decisions); + because_supermatch = bsm; + + record_extension_metrics(request, filter, &decisions); + if is_match { NoMutation } else if skip_persistence { @@ -322,10 +424,10 @@ impl BatchStep3 { }; if because_supermatch { - tracing::info!("Supermatcher rejection"); + tracing::debug!("Supermatcher rejection"); metrics::counter!("supermatcher_rejections").increment(1); } - tracing::info!("Pushing decision: {decision:?}"); + tracing::debug!("Pushing decision: {decision:?}"); decisions.push(decision); } @@ -343,20 +445,49 @@ impl BatchStep3 { /// Results for one request. #[derive(Clone, Debug, PartialEq, Eq)] -struct Step2 { +/// A search join after the missing-side MPC comparisons have been resolved, +/// bundled with its per-eye saturation (supermatcher) flags. +struct ResolvedJoin { full_join: VecEdges<(VectorId, BothEyes)>, + /// True per eye if any rotation's match results were saturated (supermatcher). + saturated: BothEyes, +} + +struct Step2 { + /// Search matches from the (possibly supermatcher-extended) results. + join: ResolvedJoin, + /// Search matches from the pre-extension results, present only when this + /// request's search was extended by the supermatcher. `None` means no + /// extension happened, in which case the pre-extension outcome equals `join`. + pre_join: Option, luc_results: VecEdges<(VectorId, BothEyes)>, reauth_result: Option<(VectorId, UseOrRule, BothEyes)>, intra_matches: Vec, - /// True per eye if any rotation's match results were saturated (supermatcher). - saturated: BothEyes, request_type: RequestType, } impl Step2 { /// The IDs of the vectors that matched this request. fn select(&self, filter: Filter) -> impl Iterator + '_ { - let search = self + self.select_with(filter, &self.join) + } + + /// Like `select`, but using the pre-extension search matches. When this + /// request's search was not extended by the supermatcher, this is identical + /// to `select`. + fn select_pre(&self, filter: Filter) -> impl Iterator + '_ { + self.select_with(filter, self.pre_join.as_ref().unwrap_or(&self.join)) + } + + /// The IDs of the vectors that matched this request, evaluated against a + /// specific resolved `join` (the luc/reauth/intra contributions are + /// unaffected by supermatcher extension and always use `self`). + fn select_with<'a>( + &'a self, + filter: Filter, + join: &'a ResolvedJoin, + ) -> impl Iterator + 'a { + let search = join .full_join .iter() .filter(move |(_, [l, r])| filter.search_rule(*l, *r)) @@ -380,7 +511,7 @@ impl Step2 { .map(|m| MatchId::IntraBatch(m.other_request_i)); let supermatch = filter - .supermatch_rule(self.saturated) + .supermatch_rule(join.saturated) .then_some(MatchId::Supermatch); chain!(search, luc, reauth, intra, supermatch) @@ -432,6 +563,22 @@ impl Step3 { ) .flatten() } + + /// Like `select`, but using the pre-extension search matches on both + /// orientations. Identical to `select` for requests that were not extended. + fn select_pre(&self, filter: Filter) -> impl Iterator + '_ { + chain!( + matches!(filter.orient, Only(Normal) | Both).then_some(self.normal.select_pre(filter)), + matches!(filter.orient, Only(Mirror) | Both).then_some(self.mirror.select_pre(filter)), + ) + .flatten() + } + + /// True if this request's search was extended by the supermatcher on either + /// orientation, so a pre-extension comparison is meaningful. + fn has_pre_extension(&self) -> bool { + self.normal.pre_join.is_some() || self.mirror.pre_join.is_some() + } } /// Search *AND* policy: only match if both eyes match (like `mergeDbResults`). @@ -834,6 +981,7 @@ mod tests { results: matches, saturated: side_saturated, }, + pre_extension: None, }, plan: InsertPlanV { query: Aby3Query::new(QueryId::new()), diff --git a/iris-mpc-cpu/src/execution/hawk_main/search.rs b/iris-mpc-cpu/src/execution/hawk_main/search.rs index 87d66889c..fb9dc6071 100644 --- a/iris-mpc-cpu/src/execution/hawk_main/search.rs +++ b/iris-mpc-cpu/src/execution/hawk_main/search.rs @@ -10,7 +10,7 @@ use crate::{ InsertPlanV, StoreId, }, hawkers::aby3::aby3_store::{Aby3DistanceRef, Aby3Query, Aby3Store, DistanceOps}, - hnsw::{graph::UpdateEntryPoint, GraphMem, HnswSearcher}, + hnsw::{graph::UpdateEntryPoint, GraphMem, HnswSearcher, SortedNeighborhood}, }; use eyre::{OptionExt, Result}; use iris_mpc_common::iris_db::iris::Threshold; @@ -236,13 +236,14 @@ async fn classify_and_extend( .search_layer_0_seeded(aby3_store, graph_store, query, seeded_nbhd, ef_supermatch) .await?; - let supermatch_classified = classify_edges( + let mut supermatch_classified = classify_edges( &supermatch_neighbors.edges, aby3_store, ef_supermatch, margin, ) .await?; + supermatch_classified.pre_extension = Some(classified.matches); if supermatch_classified.anon_stats_matches.saturated { tracing::warn!( @@ -251,16 +252,6 @@ async fn classify_and_extend( metrics::counter!("supermatcher_still_saturated_after_extended").increment(1); } - metrics::histogram!("extended_search_new_anon_stats_matches").record( - supermatch_classified.anon_stats_matches.results.len() as f64 - - classified.anon_stats_matches.results.len() as f64, - ); - - metrics::histogram!("extended_search_new_matches").record( - supermatch_classified.matches.results.len() as f64 - - classified.matches.results.len() as f64, - ); - return Ok(supermatch_classified); } @@ -320,6 +311,7 @@ async fn classify_edges( results: anon_stats_matches, saturated: anon_stats_saturated, }, + pre_extension: None, }) } diff --git a/iris-mpc-cpu/src/hnsw/searcher.rs b/iris-mpc-cpu/src/hnsw/searcher.rs index d7a7c61f7..8b947b0d9 100644 --- a/iris-mpc-cpu/src/hnsw/searcher.rs +++ b/iris-mpc-cpu/src/hnsw/searcher.rs @@ -33,6 +33,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashSet}, hash::{Hash, Hasher}, iter::once, + time::Instant, }; use tracing::{debug, instrument, trace_span, Instrument}; From 5c7f12a5af236732a1a914dfc494223ac64cec58 Mon Sep 17 00:00:00 2001 From: Stuart Woodbury Date: Wed, 15 Jul 2026 18:26:43 -0400 Subject: [PATCH 3/3] change logs back --- iris-mpc-cpu/src/execution/hawk_main/matching.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/iris-mpc-cpu/src/execution/hawk_main/matching.rs b/iris-mpc-cpu/src/execution/hawk_main/matching.rs index d175b497c..e08bfcbd6 100644 --- a/iris-mpc-cpu/src/execution/hawk_main/matching.rs +++ b/iris-mpc-cpu/src/execution/hawk_main/matching.rs @@ -241,7 +241,7 @@ impl Step1 { let reauth_result = self.reauth_id().map(|(id, or_rule)| { let is_match = [LEFT, RIGHT].map(|side| *missing_is_match[side].get(&id).unwrap_or(&false)); - tracing::debug!("Reauth ID: {id}, or_rule: {or_rule}, is_match: {is_match:?}"); + tracing::info!("Reauth ID: {id}, or_rule: {or_rule}, is_match: {is_match:?}"); (id, or_rule, is_match) }); @@ -377,7 +377,7 @@ impl BatchStep3 { /// Applies supermatcher rejection: if any rotation's match results were /// saturated on either eye, the decision is forced to `NoMutation`. pub fn decisions(&self) -> VecRequests { - tracing::debug!( + tracing::info!( "Calculating decisions for batch of {} requests", self.0.len() ); @@ -388,7 +388,7 @@ impl BatchStep3 { let mut decisions = Vec::::with_capacity(self.0.len()); for request in &self.0 { - tracing::debug!( + tracing::info!( "Processing request type normal: {:?} mirror {:?}", request.normal.request_type, request.mirror.request_type, @@ -424,10 +424,10 @@ impl BatchStep3 { }; if because_supermatch { - tracing::debug!("Supermatcher rejection"); + tracing::info!("Supermatcher rejection"); metrics::counter!("supermatcher_rejections").increment(1); } - tracing::debug!("Pushing decision: {decision:?}"); + tracing::info!("Pushing decision: {decision:?}"); decisions.push(decision); } @@ -453,6 +453,7 @@ struct ResolvedJoin { saturated: BothEyes, } +#[derive(Clone, Debug, PartialEq, Eq)] struct Step2 { /// Search matches from the (possibly supermatcher-extended) results. join: ResolvedJoin,