Skip to main content

zeppelin_embed/graph/
search.rs

1//! Single-core fixed-stride graph traversal.
2
3use crate::graph::block::{CheckedNodeId, GraphNodeBlocks, GraphNodeError};
4use crate::kernels::{Bit4Rows4, GatherShapeError, score_bit4_ptrs};
5use crate::lifecycle::QueryCancellation;
6use crate::meta::DocBitmap;
7use crate::quant::{
8    Bit4Query, QuantError, RescoreError, RescoreMetric, RescorePool, prepare_bit4_query,
9    rescore_top_k,
10};
11use crate::scan::ScanError;
12
13/// Actual graph traversals and successful query preparations on one explicitly
14/// observed caller thread. Shipping builds retain no observation state.
15#[cfg(any(test, feature = "test-support"))]
16#[derive(Default)]
17#[doc(hidden)]
18pub struct GraphQueryTestObservations {
19    /// Actual traversal entries after request and cancellation checks.
20    pub traversals: usize,
21    /// Original dimensions, padded dimensions and stochastic rounding seed.
22    pub preparations: Vec<(usize, usize, u64)>,
23    /// Rows actually evaluated by the canonical f64 exact-scoring kernel.
24    pub exact_rows: Vec<usize>,
25    /// Full retained pools submitted to the immutable-row integrity validator.
26    pub validated_pools: Vec<Vec<u32>>,
27}
28
29#[cfg(any(test, feature = "test-support"))]
30pub(crate) fn observe_exact_score(row: usize) {
31    GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| {
32        if let Some(observations) = observations.borrow_mut().as_mut() {
33            observations.exact_rows.push(row);
34        }
35    });
36}
37
38#[cfg(any(test, feature = "test-support"))]
39thread_local! {
40    static GRAPH_QUERY_TEST_OBSERVATIONS: std::cell::RefCell<Option<GraphQueryTestObservations>> =
41        const { std::cell::RefCell::new(None) };
42}
43
44/// Starts a caller-thread observation window at actual graph call sites.
45#[cfg(any(test, feature = "test-support"))]
46#[doc(hidden)]
47pub fn begin_graph_query_test_observations() {
48    GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| {
49        *observations.borrow_mut() = Some(GraphQueryTestObservations::default());
50    });
51}
52
53/// Returns the observed calls and disables further collection.
54#[cfg(any(test, feature = "test-support"))]
55#[doc(hidden)]
56pub fn take_graph_query_test_observations() -> GraphQueryTestObservations {
57    GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| observations.take().unwrap_or_default())
58}
59
60/// Query data independent of graph nodes, frontiers and retained candidates.
61pub(crate) struct PreparedGraphQuery {
62    query: Bit4Query,
63    norm: f64,
64}
65
66impl PreparedGraphQuery {
67    pub(crate) fn new(
68        query: &[f32],
69        padded_dimensions: usize,
70        seed: u64,
71    ) -> Result<Self, GraphSearchError> {
72        if padded_dimensions < query.len() {
73            return Err(GraphSearchError::Geometry(
74                "query padding is smaller than its original dimensions".to_owned(),
75            ));
76        }
77        let padded = if padded_dimensions == query.len() {
78            None
79        } else {
80            let mut values = Vec::with_capacity(padded_dimensions);
81            values.extend_from_slice(query);
82            values.resize(padded_dimensions, 0.0);
83            Some(values)
84        };
85        let prepared = prepare_bit4_query(padded.as_deref().unwrap_or(query), seed)?;
86        #[cfg(any(test, feature = "test-support"))]
87        GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| {
88            if let Some(observations) = observations.borrow_mut().as_mut() {
89                observations
90                    .preparations
91                    .push((query.len(), padded_dimensions, seed));
92            }
93        });
94        Ok(Self {
95            query: prepared,
96            norm: squared_norm(query),
97        })
98    }
99
100    pub(crate) fn resident_bytes(&self) -> usize {
101        self.query.resident_bytes()
102    }
103
104    pub(crate) fn bit4(&self) -> &Bit4Query {
105        &self.query
106    }
107}
108
109/// Research lower-bound multiplier from `tasks/reports/index-design-research.md` section 3.1.
110const SIFT_RESEARCH_FLOOR_TENTHS: usize = 14;
111/// Denominator for the section-3.1 `1.4 * k` lower bound.
112const EF_TENTHS_DENOMINATOR: usize = 10;
113/// Section-3.1's absolute SIFT-class floor; the measured `ef=140` smoke point
114/// in `tasks/cross-benchmark/results/zeppelin-embed/sift-128-euclidean/2026-08-23T16:38:53.443Z-a34b3b7c-graph-ef-140.json`
115/// reached only 0.862 recall, so this is deliberately not the shipped target.
116const SIFT_RESEARCH_ABSOLUTE_FLOOR: usize = 140;
117/// The measured SIFT smoke curve's first arm above hnswlib recall was `ef=200`
118/// at `k=100` (0.932 recall, 0.109417 ms p50), recorded in
119/// `tasks/cross-benchmark/results/zeppelin-embed/sift-128-euclidean/2026-08-23T16:38:53.443Z-a34b3b7c-graph-ef-200.json`.
120const SIFT_SHIPPED_EF_PER_K: usize = 2;
121/// Owner-specified angular floor from research section 3.1. The full M4b
122/// measurement in `tasks/evidence/19-M4b-cross-dataset-graphs.md` shows that
123/// `2 * k` is insufficient on glove. M10 ships the `4 * k` mechanism while
124/// the real glove/nytimes acceptance sweep remains deliberately deferred.
125const ANGULAR_EF_PER_K: usize = 4;
126/// Absolute tolerance on squared L2 norm for caller-declared unit vectors.
127pub const UNIT_NORM_SQUARED_TOLERANCE: f64 = 1.0e-3;
128
129pub(crate) fn non_unit_squared_norm(vector: &[f32]) -> Option<f64> {
130    if vector.is_empty() || vector.iter().any(|value| !value.is_finite()) {
131        return None;
132    }
133    let squared_norm = vector
134        .iter()
135        .map(|value| f64::from(*value) * f64::from(*value))
136        .sum::<f64>();
137    ((squared_norm - 1.0).abs() > UNIT_NORM_SQUARED_TOLERANCE).then_some(squared_norm)
138}
139
140/// Dataset-shape profile used when the caller leaves `ef` adaptive.
141#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
142pub enum GraphSearchProfile {
143    /// SIFT-class Euclidean data, shipping at the first measured passing curve arm.
144    #[default]
145    SiftClass,
146    /// Angular data, using M10's section-3.1 `4 * k` floor.
147    Angular,
148}
149
150/// Exact distance semantics used to match an epoch to one graph profile.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum GraphDistanceMetric {
153    /// Exact rescore and graph construction use squared Euclidean distance.
154    SquaredL2,
155}
156
157impl std::fmt::Display for GraphDistanceMetric {
158    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::SquaredL2 => formatter.write_str("squared-l2"),
161        }
162    }
163}
164
165/// Persisted epoch shape consulted by the graph-profile policy.
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub struct GraphProfileShape {
168    /// Persisted document-tower dimensions.
169    pub document_dims: u32,
170    /// Persisted document-tower normalization.
171    pub document_normalization: crate::epoch::Normalization,
172    /// Persisted query-tower dimensions.
173    pub query_dims: u32,
174    /// Persisted query-tower normalization.
175    pub query_normalization: crate::epoch::Normalization,
176    /// Exact metric implemented by the graph build and query paths.
177    pub metric: GraphDistanceMetric,
178}
179
180/// One measured end-to-end graph profile selected from persisted epoch shape.
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum EpochGraphProfile {
183    /// The measured 128-dimensional, unnormalized, squared-L2 SIFT class.
184    SiftClass,
185    /// L2-normalized document and query towers scored by squared L2.
186    AngularClass,
187}
188
189impl EpochGraphProfile {
190    /// Returns the adaptive query profile belonging to this epoch profile.
191    #[must_use]
192    pub const fn search_profile(self) -> GraphSearchProfile {
193        match self {
194            Self::SiftClass => GraphSearchProfile::SiftClass,
195            Self::AngularClass => GraphSearchProfile::Angular,
196        }
197    }
198
199    /// Returns the construction parameters belonging to this epoch profile.
200    #[must_use]
201    pub const fn build_params(self) -> crate::graph::GraphParams {
202        match self {
203            Self::SiftClass => crate::graph::GraphParams::sift_1m(),
204            Self::AngularClass => crate::graph::GraphParams::angular(),
205        }
206    }
207}
208
209/// Typed failure to resolve one persisted epoch to a measured graph profile.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum GraphProfileError {
212    /// The store predates the epoch registry needed to select a profile.
213    EpochUnstamped,
214    /// A validated alias did not have a matching registry entry in the pinned snapshot.
215    EpochNotRegistered {
216        /// Alias that could not be resolved.
217        epoch: crate::epoch::EpochIdentity,
218    },
219    /// The registry entry's complete shape has no measured profile.
220    UnrecognizedEpochShape {
221        /// Persisted embedding and tokenizer epoch being reported.
222        epoch: crate::epoch::EpochIdentity,
223        /// Complete policy key that had no table entry.
224        shape: GraphProfileShape,
225    },
226}
227
228impl std::fmt::Display for GraphProfileError {
229    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            Self::EpochUnstamped => formatter.write_str(
232                "graph profile requires a persisted epoch registry, but the store is unstamped",
233            ),
234            Self::EpochNotRegistered { epoch } => write!(
235                formatter,
236                "graph profile epoch ({}, {}) is absent from the pinned registry",
237                epoch.embedding, epoch.tokenizer
238            ),
239            Self::UnrecognizedEpochShape { epoch, shape } => write!(
240                formatter,
241                "graph profile epoch ({}, {}) has unrecognized shape: document dims {}, document normalization {:?}, query dims {}, query normalization {:?}, metric {}",
242                epoch.embedding,
243                epoch.tokenizer,
244                shape.document_dims,
245                shape.document_normalization,
246                shape.query_dims,
247                shape.query_normalization,
248                shape.metric
249            ),
250        }
251    }
252}
253
254impl std::error::Error for GraphProfileError {}
255
256/// Selects the supported graph profile for one persisted epoch.
257pub fn select_epoch_graph_profile(
258    epoch: &crate::manifest::EpochMeta,
259    metric: GraphDistanceMetric,
260) -> Result<EpochGraphProfile, GraphProfileError> {
261    let identity = crate::epoch::EpochIdentity {
262        embedding: epoch.id,
263        tokenizer: epoch.tokenizer,
264    };
265    let shape = GraphProfileShape {
266        document_dims: epoch.embedding.document.dims,
267        document_normalization: epoch.embedding.document.normalization,
268        query_dims: epoch.embedding.query.dims,
269        query_normalization: epoch.embedding.query.normalization,
270        metric,
271    };
272    // The refusal axis is NORMALIZATION and METRIC, not dimension count.
273    //
274    // Dimension is deliberately NOT a refusal axis. The profile's parameters
275    // are graph degree, alpha and ef, which track intrinsic dimensionality and
276    // corpus size rather than the raw dimension count, and refusing on an
277    // exact equality to 128 would refuse every real deployment -- 384, 768 and
278    // 1536-dimension embeddings included -- while admitting nothing SIFT-like
279    // that a 128-dimension check admits. Tuning a profile per dimension band
280    // is a measurement campaign, not a predicate.
281    //
282    // Mismatched document and query towers are refused: a graph built in one
283    // space and queried from another is not a profile question.
284    if shape.document_dims == shape.query_dims && shape.metric == GraphDistanceMetric::SquaredL2 {
285        match (shape.document_normalization, shape.query_normalization) {
286            (crate::epoch::Normalization::None, crate::epoch::Normalization::None) => {
287                return Ok(EpochGraphProfile::SiftClass);
288            }
289            (crate::epoch::Normalization::L2, crate::epoch::Normalization::L2) => {
290                // On the unit sphere, squared-L2 and cosine induce the same
291                // ordering, so M10 needs no new persisted metric or kernel.
292                return Ok(EpochGraphProfile::AngularClass);
293            }
294            _ => {}
295        }
296    }
297    Err(GraphProfileError::UnrecognizedEpochShape {
298        epoch: identity,
299        shape,
300    })
301}
302
303/// Typed rejection while resolving an adaptive or explicit `ef`.
304#[derive(Clone, Copy, Debug, Eq, PartialEq)]
305pub enum AdaptiveEfError {
306    /// Top-k must request at least one result.
307    ZeroK,
308    /// Top-k exceeded the graph's row count.
309    KExceedsRows {
310        /// Requested result count.
311        k: usize,
312        /// Available graph rows.
313        rows: usize,
314    },
315    /// An explicit override was narrower than top-k.
316    ExplicitBelowK {
317        /// Requested result count.
318        k: usize,
319        /// Supplied traversal width.
320        ef: usize,
321    },
322    /// An explicit override exceeded the graph's row count.
323    ExplicitExceedsRows {
324        /// Supplied traversal width.
325        ef: usize,
326        /// Available graph rows.
327        rows: usize,
328    },
329    /// Adaptive multiplier arithmetic overflowed `usize`.
330    ArithmeticOverflow,
331}
332
333impl std::fmt::Display for AdaptiveEfError {
334    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        match self {
336            Self::ZeroK => formatter.write_str("adaptive graph top-k must be positive"),
337            Self::KExceedsRows { k, rows } => {
338                write!(formatter, "graph top-k {k} exceeds graph row count {rows}")
339            }
340            Self::ExplicitBelowK { k, ef } => {
341                write!(formatter, "explicit graph ef {ef} is below top-k {k}")
342            }
343            Self::ExplicitExceedsRows { ef, rows } => {
344                write!(
345                    formatter,
346                    "explicit graph ef {ef} exceeds graph row count {rows}"
347                )
348            }
349            Self::ArithmeticOverflow => formatter.write_str("adaptive graph ef overflowed usize"),
350        }
351    }
352}
353
354impl std::error::Error for AdaptiveEfError {}
355
356/// One graph-query invocation.
357#[derive(Clone, Copy, Debug)]
358pub struct GraphSearchRequest<'a> {
359    query: &'a [f32],
360    k: usize,
361    ef: Option<usize>,
362    profile: GraphSearchProfile,
363    seed: u64,
364    trace_candidates: bool,
365    observed_core_class: QueryCoreClass,
366    prefetch: TraversalPrefetch,
367}
368
369impl<'a> GraphSearchRequest<'a> {
370    /// Creates a deterministic SIFT-class request with adaptive shipped `ef`.
371    #[must_use]
372    pub const fn new(query: &'a [f32], k: usize, seed: u64) -> Self {
373        Self {
374            query,
375            k,
376            ef: None,
377            profile: GraphSearchProfile::SiftClass,
378            seed,
379            trace_candidates: false,
380            observed_core_class: QueryCoreClass::Unverified,
381            prefetch: TraversalPrefetch::Enabled,
382        }
383    }
384
385    /// Selects the dataset-shape profile used by adaptive `ef`.
386    #[must_use]
387    pub const fn with_profile(mut self, profile: GraphSearchProfile) -> Self {
388        self.profile = profile;
389        self
390    }
391
392    /// Overrides adaptive `ef` with one explicit traversal width.
393    #[must_use]
394    pub const fn with_ef(mut self, ef: usize) -> Self {
395        self.ef = Some(ef);
396        self
397    }
398
399    /// Returns the profile's documented research floor before row-count clamping.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`AdaptiveEfError`] for zero `k` or multiplier overflow.
404    pub fn research_floor_ef(self) -> Result<usize, AdaptiveEfError> {
405        if self.k == 0 {
406            return Err(AdaptiveEfError::ZeroK);
407        }
408        match self.profile {
409            GraphSearchProfile::SiftClass => self
410                .k
411                .checked_mul(SIFT_RESEARCH_FLOOR_TENTHS)
412                .map(|value| value.div_ceil(EF_TENTHS_DENOMINATOR))
413                .map(|value| value.max(SIFT_RESEARCH_ABSOLUTE_FLOOR))
414                .ok_or(AdaptiveEfError::ArithmeticOverflow),
415            GraphSearchProfile::Angular => self
416                .k
417                .checked_mul(ANGULAR_EF_PER_K)
418                .ok_or(AdaptiveEfError::ArithmeticOverflow),
419        }
420    }
421
422    /// Resolves the explicit override or adaptive profile against one graph.
423    ///
424    /// # Errors
425    ///
426    /// Returns [`AdaptiveEfError`] when `k` or an explicit override is outside
427    /// the graph, or when adaptive multiplier arithmetic overflows.
428    pub fn effective_ef(self, rows: usize) -> Result<usize, AdaptiveEfError> {
429        if self.k == 0 {
430            return Err(AdaptiveEfError::ZeroK);
431        }
432        if self.k > rows {
433            return Err(AdaptiveEfError::KExceedsRows { k: self.k, rows });
434        }
435        if let Some(ef) = self.ef {
436            if ef < self.k {
437                return Err(AdaptiveEfError::ExplicitBelowK { k: self.k, ef });
438            }
439            if ef > rows {
440                return Err(AdaptiveEfError::ExplicitExceedsRows { ef, rows });
441            }
442            return Ok(ef);
443        }
444        let floor = self.research_floor_ef()?;
445        let target = match self.profile {
446            GraphSearchProfile::SiftClass => self
447                .k
448                .checked_mul(SIFT_SHIPPED_EF_PER_K)
449                .map(|ef| ef.max(floor))
450                .ok_or(AdaptiveEfError::ArithmeticOverflow)?,
451            GraphSearchProfile::Angular => floor,
452        };
453        Ok(target.min(rows))
454    }
455
456    /// Records the logical Bit4 candidate sequence for deterministic diagnostics.
457    #[must_use]
458    pub const fn with_candidate_trace(mut self) -> Self {
459        self.trace_candidates = true;
460        self
461    }
462
463    /// Attaches a caller-observed core class without changing scheduling policy.
464    #[must_use]
465    pub const fn with_observed_core_class(mut self, core_class: QueryCoreClass) -> Self {
466        self.observed_core_class = core_class;
467        self
468    }
469
470    /// Selects whether traversal and rescore issue software prefetch hints.
471    #[must_use]
472    pub const fn with_prefetch(mut self, prefetch: TraversalPrefetch) -> Self {
473        self.prefetch = prefetch;
474        self
475    }
476}
477
478/// Runtime-selectable software-prefetch arm for traversal A/B measurements.
479#[derive(Clone, Copy, Debug, Eq, PartialEq)]
480pub enum TraversalPrefetch {
481    /// Issue graph-node and f32-rescore prefetch hints.
482    Enabled,
483    /// Execute the identical traversal without software prefetch hints.
484    Disabled,
485}
486
487impl TraversalPrefetch {
488    const fn is_enabled(self) -> bool {
489        matches!(self, Self::Enabled)
490    }
491}
492
493/// One exact-rescored row returned by graph traversal.
494#[derive(Clone, Copy, Debug, PartialEq)]
495pub struct GraphSearchCandidate {
496    row_id: u32,
497    distance: f64,
498}
499
500impl GraphSearchCandidate {
501    /// Returns the dense segment-local row id.
502    #[must_use]
503    pub const fn row_id(self) -> u32 {
504        self.row_id
505    }
506
507    /// Returns the exact squared-L2 distance from the f32 rescore row.
508    #[must_use]
509    pub const fn distance(self) -> f64 {
510        self.distance
511    }
512}
513
514/// Observed caller QoS class for latency attribution.
515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub enum QueryQosClass {
517    /// Darwin user-interactive QoS.
518    UserInteractive,
519    /// Darwin user-initiated QoS.
520    UserInitiated,
521    /// Darwin default QoS.
522    Default,
523    /// Darwin utility QoS.
524    Utility,
525    /// Darwin background QoS.
526    Background,
527    /// Darwin reported no explicit QoS.
528    Unspecified,
529    /// The platform cannot report Darwin QoS.
530    Unavailable,
531    /// Darwin returned an unrecognized class value.
532    Unknown,
533}
534
535/// Core class observed by an external fail-closed residency canary.
536#[derive(Clone, Copy, Debug, Eq, PartialEq)]
537pub enum QueryCoreClass {
538    /// A performance-core canary passed immediately before the query run.
539    Performance,
540    /// An efficiency-core canary identified an invalid latency run.
541    Efficiency,
542    /// No external core-residency observation was supplied.
543    Unverified,
544}
545
546/// Deterministic work counters for one completed query.
547#[derive(Clone, Copy, Debug, Eq, PartialEq)]
548pub struct GraphSearchCounters {
549    effective_ef: usize,
550    hops: usize,
551    candidates_scored: usize,
552    candidates_rescored: usize,
553    pushes: usize,
554    visited: usize,
555    visited_epoch_cleared: bool,
556    dims_touched: u64,
557    bytes_read: u64,
558    qos_class: QueryQosClass,
559    qos_relative_priority: i32,
560    core_class: QueryCoreClass,
561}
562
563/// Scheduling-independent counters suitable for deterministic comparisons.
564#[derive(Clone, Copy, Debug, Eq, PartialEq)]
565pub struct DeterministicGraphWork {
566    /// Adaptive or explicit traversal width used for this query.
567    pub effective_ef: usize,
568    /// Expanded frontier nodes.
569    pub hops: usize,
570    /// Distinct Bit4 candidates scored.
571    pub candidates_scored: usize,
572    /// Rows from the retained pool read from f32 storage.
573    pub candidates_rescored: usize,
574    /// Successful frontier insertions.
575    pub pushes: usize,
576    /// Distinct visited nodes.
577    pub visited: usize,
578}
579
580impl GraphSearchCounters {
581    /// Returns the work tuple without caller scheduling attribution.
582    #[must_use]
583    pub const fn deterministic_work(self) -> DeterministicGraphWork {
584        DeterministicGraphWork {
585            effective_ef: self.effective_ef,
586            hops: self.hops,
587            candidates_scored: self.candidates_scored,
588            candidates_rescored: self.candidates_rescored,
589            pushes: self.pushes,
590            visited: self.visited,
591        }
592    }
593
594    /// Returns the adaptive or explicit traversal width used by the query.
595    #[must_use]
596    pub const fn effective_ef(self) -> usize {
597        self.effective_ef
598    }
599
600    /// Returns expanded frontier nodes.
601    #[must_use]
602    pub const fn hops(self) -> usize {
603        self.hops
604    }
605
606    /// Returns distinct nodes scored by the Bit4 estimator.
607    #[must_use]
608    pub const fn candidates_scored(self) -> usize {
609        self.candidates_scored
610    }
611
612    /// Returns the number of retained candidates exactly rescored from f32 rows.
613    #[must_use]
614    pub const fn candidates_rescored(self) -> usize {
615        self.candidates_rescored
616    }
617
618    /// Returns successful frontier insertions.
619    #[must_use]
620    pub const fn pushes(self) -> usize {
621        self.pushes
622    }
623
624    /// Returns distinct nodes marked visited.
625    #[must_use]
626    pub const fn visited(self) -> usize {
627        self.visited
628    }
629
630    /// Returns whether this query paid the once-per-255-query visited memset.
631    #[must_use]
632    pub const fn visited_epoch_cleared(self) -> bool {
633        self.visited_epoch_cleared
634    }
635
636    /// Returns logical coordinates scored by coarse traversal plus exact rescore.
637    #[must_use]
638    pub const fn dims_touched(self) -> u64 {
639        self.dims_touched
640    }
641
642    /// Returns exact stored coarse and f32-rescore bytes read by this query.
643    #[must_use]
644    pub const fn bytes_read(self) -> u64 {
645        self.bytes_read
646    }
647
648    /// Returns the caller thread's observed QoS class.
649    #[must_use]
650    pub const fn qos_class(self) -> QueryQosClass {
651        self.qos_class
652    }
653
654    /// Returns Darwin's observed relative QoS priority, or zero when unavailable.
655    #[must_use]
656    pub const fn qos_relative_priority(self) -> i32 {
657        self.qos_relative_priority
658    }
659
660    /// Returns the externally observed core class for this query.
661    #[must_use]
662    pub const fn core_class(self) -> QueryCoreClass {
663        self.core_class
664    }
665}
666
667/// Exact results plus deterministic traversal evidence.
668#[derive(Clone, Debug, PartialEq)]
669pub struct GraphSearchResult {
670    candidates: Vec<GraphSearchCandidate>,
671    counters: GraphSearchCounters,
672    candidate_sequence: Option<Vec<u32>>,
673}
674
675impl GraphSearchResult {
676    /// Returns exact-rescored candidates in best-first order.
677    #[must_use]
678    pub fn candidates(&self) -> &[GraphSearchCandidate] {
679        &self.candidates
680    }
681
682    /// Returns deterministic traversal work and caller scheduling attribution.
683    #[must_use]
684    pub const fn counters(&self) -> GraphSearchCounters {
685        self.counters
686    }
687
688    /// Returns the logical scored-row sequence when requested.
689    #[must_use]
690    pub fn candidate_sequence(&self) -> Option<&[u32]> {
691        self.candidate_sequence.as_deref()
692    }
693
694    /// Counts returned row ids present in an independently supplied truth set.
695    #[must_use]
696    pub fn recall_against(&self, truth: &[u32]) -> usize {
697        self.candidates
698            .iter()
699            .filter(|candidate| truth.contains(&candidate.row_id))
700            .count()
701    }
702}
703
704/// Completion of one filter-aware traversal before any store-level exact fallback.
705#[derive(Clone, Debug, PartialEq)]
706pub enum FilteredGraphSearchOutcome {
707    /// Traversal retained only allowed rows and exact-rescored that complete pool.
708    Traversed(GraphSearchResult),
709    /// Expected selectivity pressure crossed its independent work budget.
710    VisitedBudgetExceeded {
711        /// Distinct graph nodes visited before traversal abandoned.
712        visited: usize,
713        /// Filter-only selectivity budget supplied by the planner.
714        budget: usize,
715        /// Work performed before the exact allow-list fallback takes over.
716        counters: GraphSearchCounters,
717    },
718}
719
720impl FilteredGraphSearchOutcome {
721    /// Returns deterministic work performed before either completion disposition.
722    #[must_use]
723    pub const fn counters(&self) -> GraphSearchCounters {
724        match self {
725            Self::Traversed(result) => result.counters,
726            Self::VisitedBudgetExceeded { counters, .. } => *counters,
727        }
728    }
729}
730
731enum TraversalOutcome {
732    Retained {
733        counters: GraphSearchCounters,
734        candidate_sequence: Option<Vec<u32>>,
735    },
736    Budget(SearchInnerOutcome),
737}
738
739enum SearchInnerOutcome {
740    Traversed(GraphSearchResult),
741    VisitedBudgetExceeded {
742        visited: usize,
743        budget: usize,
744        counters: GraphSearchCounters,
745    },
746}
747
748/// Typed graph traversal rejection.
749#[derive(Clone, Debug, PartialEq)]
750pub enum GraphSearchError {
751    /// Request shape or graph/rescore geometry is invalid.
752    Geometry(String),
753    /// The pinned epoch has no measured automatic graph profile.
754    Profile(GraphProfileError),
755    /// A validated node became unavailable through the fixed-stride view.
756    Graph(GraphNodeError),
757    /// Query preparation or Bit4 scoring rejected the input.
758    Quant(QuantError),
759    /// The four-row gather boundary rejected a graph/query shape mismatch.
760    Gather(GatherShapeError),
761    /// Adaptive or explicit `ef` resolution rejected the request.
762    AdaptiveEf(AdaptiveEfError),
763    /// The canonical f32 pool-rescore path rejected its inputs.
764    Rescore(RescoreError),
765    /// A filtered traversal retained fewer allowed rows than the requested result count.
766    FilteredCandidateShortfall {
767        /// Requested result count.
768        k: usize,
769        /// Allowed candidates retained by the original traversal.
770        candidates: usize,
771        /// Exact work performed by that original traversal before refusal.
772        counters: GraphSearchCounters,
773    },
774    /// Chunk-local integrity validation rejected exact-rescore bytes.
775    ExactRescoreUnavailable(String),
776    /// Unfiltered traversal crossed the Task-16 collapse guard; M6 owns fallback.
777    VisitedCapExceeded {
778        /// Distinct nodes marked before traversal stopped.
779        visited: usize,
780        /// Fail-closed guard `4 * ef * R`.
781        cap: usize,
782    },
783    /// The caller cancelled traversal; partial answers are never returned.
784    Cancelled {
785        /// Permanently false.
786        partial: bool,
787    },
788    /// The query deadline expired; partial answers are never returned.
789    Timeout {
790        /// Permanently false.
791        partial: bool,
792    },
793    /// Store close cancelled the admitted read; partial answers are never returned.
794    ReadCancelled {
795        /// Permanently false.
796        partial: bool,
797    },
798    /// An unexpected scan-layer cancellation check failed.
799    Scan(ScanError),
800}
801
802impl std::fmt::Display for GraphSearchError {
803    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
804        match self {
805            Self::Geometry(detail) => {
806                write!(formatter, "graph search geometry is invalid: {detail}")
807            }
808            Self::Profile(error) => error.fmt(formatter),
809            Self::Graph(error) => error.fmt(formatter),
810            Self::Quant(error) => error.fmt(formatter),
811            Self::Gather(error) => error.fmt(formatter),
812            Self::AdaptiveEf(error) => error.fmt(formatter),
813            Self::Rescore(error) => error.fmt(formatter),
814            Self::FilteredCandidateShortfall { k, candidates, .. } => write!(
815                formatter,
816                "filtered graph search retained {candidates} candidates for requested k={k}"
817            ),
818            Self::ExactRescoreUnavailable(detail) => formatter.write_str(detail),
819            Self::VisitedCapExceeded { visited, cap } => write!(
820                formatter,
821                "graph search visited {visited} nodes, exceeding the fail-closed cap {cap}"
822            ),
823            Self::Cancelled { partial } => {
824                write!(formatter, "graph search was cancelled (partial={partial})")
825            }
826            Self::Timeout { partial } => {
827                write!(
828                    formatter,
829                    "graph search deadline expired (partial={partial})"
830                )
831            }
832            Self::ReadCancelled { partial } => write!(
833                formatter,
834                "store close cancelled graph search (partial={partial})"
835            ),
836            Self::Scan(error) => error.fmt(formatter),
837        }
838    }
839}
840
841impl std::error::Error for GraphSearchError {
842    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
843        match self {
844            Self::Profile(error) => Some(error),
845            Self::Graph(error) => Some(error),
846            Self::Quant(error) => Some(error),
847            Self::Gather(error) => Some(error),
848            Self::AdaptiveEf(error) => Some(error),
849            Self::Rescore(error) => Some(error),
850            Self::Scan(error) => Some(error),
851            Self::Geometry(_)
852            | Self::ExactRescoreUnavailable(_)
853            | Self::FilteredCandidateShortfall { .. }
854            | Self::VisitedCapExceeded { .. }
855            | Self::Cancelled { .. }
856            | Self::Timeout { .. }
857            | Self::ReadCancelled { .. } => None,
858        }
859    }
860}
861
862impl From<GraphNodeError> for GraphSearchError {
863    fn from(error: GraphNodeError) -> Self {
864        Self::Graph(error)
865    }
866}
867
868impl From<QuantError> for GraphSearchError {
869    fn from(error: QuantError) -> Self {
870        Self::Quant(error)
871    }
872}
873
874impl From<GatherShapeError> for GraphSearchError {
875    fn from(error: GatherShapeError) -> Self {
876        Self::Gather(error)
877    }
878}
879
880impl From<AdaptiveEfError> for GraphSearchError {
881    fn from(error: AdaptiveEfError) -> Self {
882        Self::AdaptiveEf(error)
883    }
884}
885
886impl From<RescoreError> for GraphSearchError {
887    fn from(error: RescoreError) -> Self {
888        Self::Rescore(error)
889    }
890}
891
892#[derive(Clone, Copy, Debug)]
893struct ScoredNode {
894    row_id: CheckedNodeId,
895    distance: f64,
896}
897
898/// Query-independent exact-row norm enclosure for one immutable graph segment.
899///
900/// The graph stores the original row norm as `scale * normalized_norm`, where
901/// only the normalized factor is rounded to f32. One full normalized-factor
902/// ULP on either side therefore encloses the norm used to build the segment.
903#[derive(Clone, Copy, Debug)]
904pub(crate) struct GraphSegmentNormRange {
905    minimum: f64,
906    maximum: f64,
907}
908
909impl GraphSegmentNormRange {
910    pub(crate) fn from_factors(factors: &[crate::quant::Bit4Factors]) -> Self {
911        let mut minimum = f64::INFINITY;
912        let mut maximum = 0.0_f64;
913        for factors in factors {
914            let [scale, normalized_norm, _] = factors.persisted_fields();
915            if scale < 0.0 || normalized_norm < 0.0 {
916                return Self::unbounded();
917            }
918            let center = f64::from(scale) * f64::from(normalized_norm);
919            let normalized_ulp = f64::from(next_up_f32(normalized_norm) - normalized_norm);
920            let uncertainty = f64::from(scale) * normalized_ulp;
921            minimum = minimum.min((center - uncertainty).max(0.0));
922            maximum = maximum.max(center + uncertainty);
923        }
924        if !minimum.is_finite() || !maximum.is_finite() {
925            return Self::unbounded();
926        }
927        Self { minimum, maximum }
928    }
929
930    pub(crate) fn from_exact_rows(rows: &[f32], dimensions: usize) -> Self {
931        if dimensions == 0 || rows.is_empty() || !rows.len().is_multiple_of(dimensions) {
932            return Self::unbounded();
933        }
934        let relative_error = accumulation_relative_error(dimensions);
935        let mut maximum = 0.0_f64;
936        for row in rows.chunks_exact(dimensions) {
937            let squared_norm = row
938                .iter()
939                .map(|value| {
940                    let value = f64::from(*value);
941                    value * value
942                })
943                .sum::<f64>();
944            let norm = next_up_f64((squared_norm / (1.0 - relative_error)).max(0.0).sqrt());
945            maximum = maximum.max(norm);
946        }
947        if !maximum.is_finite() {
948            return Self::unbounded();
949        }
950        Self {
951            minimum: 0.0,
952            maximum,
953        }
954    }
955
956    pub(crate) fn from_graph(
957        graph: GraphNodeBlocks<'_>,
958        cancellation: Option<&QueryCancellation<'_>>,
959    ) -> Result<Self, GraphSearchError> {
960        let mut minimum = f64::INFINITY;
961        let mut maximum = 0.0_f64;
962        for raw_row_id in 0..graph.node_count() {
963            if raw_row_id.is_multiple_of(256) {
964                check_cancellation(cancellation)?;
965            }
966            let row_id = graph.checked_node_id(raw_row_id)?;
967            let (_, factors) = graph.score_row_checked(row_id)?;
968            let [scale, normalized_norm, _] = factors.persisted_fields();
969            if scale < 0.0 || normalized_norm < 0.0 {
970                return Ok(Self::unbounded());
971            }
972            let center = f64::from(scale) * f64::from(normalized_norm);
973            let normalized_ulp = f64::from(next_up_f32(normalized_norm) - normalized_norm);
974            let uncertainty = f64::from(scale) * normalized_ulp;
975            minimum = minimum.min((center - uncertainty).max(0.0));
976            maximum = maximum.max(center + uncertainty);
977        }
978        check_cancellation(cancellation)?;
979        if !minimum.is_finite() || !maximum.is_finite() {
980            return Ok(Self::unbounded());
981        }
982        Ok(Self { minimum, maximum })
983    }
984
985    const fn unbounded() -> Self {
986        Self {
987            minimum: 0.0,
988            maximum: f64::INFINITY,
989        }
990    }
991
992    /// Returns both enclosure endpoints as raw bits for exact comparison.
993    #[cfg(test)]
994    pub(crate) fn endpoint_bits(self) -> (u64, u64) {
995        (self.minimum.to_bits(), self.maximum.to_bits())
996    }
997
998    /// Returns a conservative lower bound in the exact-rescore f64 arithmetic.
999    ///
1000    /// The query norm interval covers positive f64 accumulation error for the
1001    /// crate-wide maximum dimension. The final factor covers the same bounded
1002    /// accumulation error in `squared_l2_f64`; both endpoints are rounded
1003    /// outward. A returned value is therefore never above a distance that the
1004    /// existing independent exact-rescore path can compute for this segment.
1005    pub(crate) fn squared_l2_lower_bound(self, query: &[f32]) -> f64 {
1006        if !self.maximum.is_finite() || query.is_empty() {
1007            return 0.0;
1008        }
1009        let squared_norm = query
1010            .iter()
1011            .map(|value| {
1012                let value = f64::from(*value);
1013                value * value
1014            })
1015            .sum::<f64>();
1016        let relative_error = accumulation_relative_error(query.len());
1017        let query_minimum = next_down_f64((squared_norm / (1.0 + relative_error)).max(0.0).sqrt());
1018        let query_maximum = next_up_f64((squared_norm / (1.0 - relative_error)).max(0.0).sqrt());
1019        let gap = if query_maximum < self.minimum {
1020            self.minimum - query_maximum
1021        } else if query_minimum > self.maximum {
1022            query_minimum - self.maximum
1023        } else {
1024            0.0
1025        };
1026        next_down_f64(gap * gap * (1.0 - relative_error))
1027    }
1028
1029    /// Returns an upper bound used only to preserve today's finite-f32 result
1030    /// validation when deciding whether a whole segment may be skipped.
1031    pub(crate) fn squared_l2_upper_bound(self, query: &[f32]) -> f64 {
1032        if !self.maximum.is_finite() || query.is_empty() {
1033            return f64::INFINITY;
1034        }
1035        let squared_norm = query
1036            .iter()
1037            .map(|value| {
1038                let value = f64::from(*value);
1039                value * value
1040            })
1041            .sum::<f64>();
1042        let relative_error = accumulation_relative_error(query.len());
1043        let query_maximum = next_up_f64((squared_norm / (1.0 - relative_error)).max(0.0).sqrt());
1044        next_up_f64(
1045            (query_maximum + self.maximum)
1046                * (query_maximum + self.maximum)
1047                * (1.0 + relative_error),
1048        )
1049    }
1050}
1051
1052fn accumulation_relative_error(terms: usize) -> f64 {
1053    let scaled = terms as f64 * f64::EPSILON;
1054    (scaled / (1.0 - scaled)) * 2.0
1055}
1056
1057fn next_up_f32(value: f32) -> f32 {
1058    if value == f32::INFINITY {
1059        return value;
1060    }
1061    if value == -0.0 {
1062        return f32::from_bits(1);
1063    }
1064    if value >= 0.0 {
1065        f32::from_bits(value.to_bits().saturating_add(1))
1066    } else {
1067        f32::from_bits(value.to_bits().saturating_sub(1))
1068    }
1069}
1070
1071fn next_up_f64(value: f64) -> f64 {
1072    if value == f64::INFINITY {
1073        return value;
1074    }
1075    if value == -0.0 {
1076        return f64::from_bits(1);
1077    }
1078    if value >= 0.0 {
1079        f64::from_bits(value.to_bits().saturating_add(1))
1080    } else {
1081        f64::from_bits(value.to_bits().saturating_sub(1))
1082    }
1083}
1084
1085fn next_down_f64(value: f64) -> f64 {
1086    if value <= 0.0 {
1087        return 0.0;
1088    }
1089    f64::from_bits(value.to_bits().saturating_sub(1))
1090}
1091
1092/// Owned, lifetime-free reusable state for single-core graph queries.
1093#[derive(Debug)]
1094pub struct GraphSearchScratch {
1095    node_count: u32,
1096    max_degree: u8,
1097    visited: Vec<u8>,
1098    epoch: u8,
1099    pool: Vec<ScoredNode>,
1100    frontier: Vec<ScoredNode>,
1101    hop_candidates: Vec<CheckedNodeId>,
1102    rescore_row_ids: Vec<u32>,
1103    rescore_coarse_scores: Vec<f32>,
1104}
1105
1106impl GraphSearchScratch {
1107    /// Allocates the node-count and maximum-degree state that is independent of `ef`.
1108    pub fn new(node_count: u32, max_degree: u8) -> Result<Self, GraphSearchError> {
1109        Self::with_ef_capacity(node_count, max_degree, 0)
1110    }
1111
1112    pub(crate) fn with_ef_capacity(
1113        node_count: u32,
1114        max_degree: u8,
1115        ef: usize,
1116    ) -> Result<Self, GraphSearchError> {
1117        let node_count_usize = node_count as usize;
1118        let mut visited = Vec::new();
1119        visited
1120            .try_reserve_exact(node_count_usize)
1121            .map_err(|error| {
1122                GraphSearchError::Geometry(format!("visited allocation failed: {error}"))
1123            })?;
1124        visited.resize(node_count_usize, 0);
1125        let mut pool = Vec::new();
1126        pool.try_reserve_exact(ef).map_err(|error| {
1127            GraphSearchError::Geometry(format!("pool allocation failed: {error}"))
1128        })?;
1129        let mut frontier = Vec::new();
1130        frontier.try_reserve_exact(ef).map_err(|error| {
1131            GraphSearchError::Geometry(format!("frontier allocation failed: {error}"))
1132        })?;
1133        let mut hop_candidates = Vec::new();
1134        hop_candidates
1135            .try_reserve_exact(usize::from(max_degree))
1136            .map_err(|error| {
1137                GraphSearchError::Geometry(format!("hop-candidate allocation failed: {error}"))
1138            })?;
1139        let mut rescore_row_ids = Vec::new();
1140        rescore_row_ids.try_reserve_exact(ef).map_err(|error| {
1141            GraphSearchError::Geometry(format!("rescore row-id allocation failed: {error}"))
1142        })?;
1143        let mut rescore_coarse_scores = Vec::new();
1144        rescore_coarse_scores
1145            .try_reserve_exact(ef)
1146            .map_err(|error| {
1147                GraphSearchError::Geometry(format!("rescore score allocation failed: {error}"))
1148            })?;
1149        Ok(Self {
1150            node_count,
1151            max_degree,
1152            visited,
1153            epoch: 0,
1154            pool,
1155            frontier,
1156            hop_candidates,
1157            rescore_row_ids,
1158            rescore_coarse_scores,
1159        })
1160    }
1161
1162    /// Returns the exact requested bytes for one scratch at the supplied geometry.
1163    pub fn allocation_bytes(
1164        node_count: u32,
1165        max_degree: u8,
1166        ef: usize,
1167    ) -> Result<usize, GraphSearchError> {
1168        let scored = ef
1169            .checked_mul(std::mem::size_of::<ScoredNode>())
1170            .and_then(|bytes| bytes.checked_mul(2))
1171            .ok_or_else(|| GraphSearchError::Geometry("scratch byte count overflow".to_owned()))?;
1172        let hops = usize::from(max_degree)
1173            .checked_mul(std::mem::size_of::<CheckedNodeId>())
1174            .ok_or_else(|| GraphSearchError::Geometry("scratch byte count overflow".to_owned()))?;
1175        let rescore = ef
1176            .checked_mul(std::mem::size_of::<u32>() + std::mem::size_of::<f32>())
1177            .ok_or_else(|| GraphSearchError::Geometry("scratch byte count overflow".to_owned()))?;
1178        (node_count as usize)
1179            .checked_add(scored)
1180            .and_then(|bytes| bytes.checked_add(hops))
1181            .and_then(|bytes| bytes.checked_add(rescore))
1182            .ok_or_else(|| GraphSearchError::Geometry("scratch byte count overflow".to_owned()))
1183    }
1184
1185    /// Returns the exact capacities, in bytes, of every owned scratch vector.
1186    pub fn resident_bytes(&self) -> Result<usize, GraphSearchError> {
1187        let widths = [
1188            (self.visited.capacity(), std::mem::size_of::<u8>()),
1189            (self.pool.capacity(), std::mem::size_of::<ScoredNode>()),
1190            (self.frontier.capacity(), std::mem::size_of::<ScoredNode>()),
1191            (
1192                self.hop_candidates.capacity(),
1193                std::mem::size_of::<CheckedNodeId>(),
1194            ),
1195            (self.rescore_row_ids.capacity(), std::mem::size_of::<u32>()),
1196            (
1197                self.rescore_coarse_scores.capacity(),
1198                std::mem::size_of::<f32>(),
1199            ),
1200        ];
1201        widths.iter().try_fold(0_usize, |total, (capacity, width)| {
1202            capacity
1203                .checked_mul(*width)
1204                .and_then(|bytes| total.checked_add(bytes))
1205                .ok_or_else(|| GraphSearchError::Geometry("scratch byte count overflow".to_owned()))
1206        })
1207    }
1208
1209    pub(crate) fn supports(&self, node_count: u32, max_degree: u8, ef: usize) -> bool {
1210        self.node_count == node_count
1211            && self.max_degree == max_degree
1212            && self.pool.capacity() >= ef
1213            && self.frontier.capacity() >= ef
1214            && self.rescore_row_ids.capacity() >= ef
1215            && self.rescore_coarse_scores.capacity() >= ef
1216    }
1217}
1218
1219/// Per-query binding of mmap-backed graph views to owned reusable scratch.
1220pub struct GraphSearcher<'a> {
1221    graph: GraphNodeBlocks<'a>,
1222    rescore: &'a [f32],
1223    rescore_validator: Option<&'a dyn RescoreValidator>,
1224    entries: [CheckedNodeId; 4],
1225    scratch: &'a mut GraphSearchScratch,
1226    #[cfg(any(test, feature = "test-support"))]
1227    vector_fault: Option<(
1228        &'a crate::scan::vector_fault::VectorFaultController,
1229        crate::scan::vector_fault::VectorRowSource,
1230        crate::scan::vector_fault::VectorSearchTier,
1231    )>,
1232    #[cfg(any(test, feature = "test-support"))]
1233    hop_cancellation: Option<TestHopCancellation>,
1234}
1235
1236pub(crate) trait RescoreValidator {
1237    fn validate_rows(&self, rows: &[u32]) -> Result<(), String>;
1238}
1239
1240#[cfg(any(test, feature = "test-support"))]
1241#[derive(Debug)]
1242struct TestHopCancellation {
1243    after_hops: usize,
1244    token: crate::lifecycle::CancelToken,
1245    observed_hops: std::rc::Rc<std::cell::Cell<usize>>,
1246}
1247
1248impl<'a> GraphSearcher<'a> {
1249    /// Validates geometry and retains the existing O(N) entry-seed discovery behavior.
1250    pub fn new(
1251        graph: GraphNodeBlocks<'a>,
1252        rescore: &'a [f32],
1253        scratch: &'a mut GraphSearchScratch,
1254    ) -> Result<Self, GraphSearchError> {
1255        let entries = Self::discover_entry_row_ids(graph)?;
1256        Self::with_entry_row_ids(graph, rescore, entries, scratch)
1257    }
1258
1259    /// Discovers all four persisted entry seeds by validating every graph block.
1260    pub fn discover_entry_row_ids(
1261        graph: GraphNodeBlocks<'_>,
1262    ) -> Result<[u32; 4], GraphSearchError> {
1263        let mut entries = Vec::with_capacity(4);
1264        for row_id in 0..graph.node_count() {
1265            if graph.block(row_id)?.flags() & 1 != 0 {
1266                entries.push(row_id);
1267            }
1268        }
1269        let entry_count = entries.len();
1270        entries.try_into().map_err(|_: Vec<u32>| {
1271            GraphSearchError::Geometry(format!(
1272                "graph contains {entry_count} persisted entry seeds, expected medoid plus three refined seeds"
1273            ))
1274        })
1275    }
1276
1277    /// Binds cached entry row ids after validating only those four graph blocks.
1278    pub fn with_entry_row_ids(
1279        graph: GraphNodeBlocks<'a>,
1280        rescore: &'a [f32],
1281        entry_row_ids: [u32; 4],
1282        scratch: &'a mut GraphSearchScratch,
1283    ) -> Result<Self, GraphSearchError> {
1284        let dimensions = graph.layout().dims() as usize;
1285        let expected_rescore = (graph.node_count() as usize)
1286            .checked_mul(dimensions)
1287            .ok_or_else(|| GraphSearchError::Geometry("rescore length overflow".to_owned()))?;
1288        if rescore.len() != expected_rescore {
1289            return Err(GraphSearchError::Geometry(format!(
1290                "rescore has {} values, expected {expected_rescore}",
1291                rescore.len()
1292            )));
1293        }
1294        if scratch.node_count != graph.node_count()
1295            || scratch.max_degree != graph.layout().max_degree()
1296        {
1297            return Err(GraphSearchError::Geometry(format!(
1298                "scratch geometry nodes/degree {}/{}, graph {}/{}",
1299                scratch.node_count,
1300                scratch.max_degree,
1301                graph.node_count(),
1302                graph.layout().max_degree()
1303            )));
1304        }
1305        let mut entries = [None; 4];
1306        for (position, row_id) in entry_row_ids.into_iter().enumerate() {
1307            let block = graph.block(row_id)?;
1308            if block.flags() & 1 == 0 {
1309                return Err(GraphSearchError::Geometry(format!(
1310                    "cached entry row {row_id} does not carry the persisted entry flag"
1311                )));
1312            }
1313            let destination = entries.get_mut(position).ok_or_else(|| {
1314                GraphSearchError::Geometry("cached entry position overflow".to_owned())
1315            })?;
1316            *destination = Some(graph.checked_node_id(row_id)?);
1317        }
1318        let entries = entries.map(|entry| {
1319            entry.ok_or_else(|| GraphSearchError::Geometry("cached entry is absent".to_owned()))
1320        });
1321        let [first, second, third, fourth] = entries;
1322        Ok(Self {
1323            graph,
1324            rescore,
1325            rescore_validator: None,
1326            entries: [first?, second?, third?, fourth?],
1327            scratch,
1328            #[cfg(any(test, feature = "test-support"))]
1329            vector_fault: None,
1330            #[cfg(any(test, feature = "test-support"))]
1331            hop_cancellation: None,
1332        })
1333    }
1334
1335    pub(crate) fn with_rescore_validator(mut self, validator: &'a dyn RescoreValidator) -> Self {
1336        self.rescore_validator = Some(validator);
1337        self
1338    }
1339
1340    #[cfg(any(test, feature = "test-support"))]
1341    pub(crate) fn with_vector_fault_controller(
1342        mut self,
1343        controller: &'a crate::scan::vector_fault::VectorFaultController,
1344        source: crate::scan::vector_fault::VectorRowSource,
1345        tier: crate::scan::vector_fault::VectorSearchTier,
1346    ) -> Self {
1347        self.vector_fault = Some((controller, source, tier));
1348        self
1349    }
1350
1351    #[cfg(any(test, feature = "test-support"))]
1352    pub(crate) fn cancel_after_hops(
1353        &mut self,
1354        after_hops: usize,
1355        token: crate::lifecycle::CancelToken,
1356    ) -> std::rc::Rc<std::cell::Cell<usize>> {
1357        let observed_hops = std::rc::Rc::new(std::cell::Cell::new(0));
1358        self.hop_cancellation = Some(TestHopCancellation {
1359            after_hops,
1360            token,
1361            observed_hops: std::rc::Rc::clone(&observed_hops),
1362        });
1363        observed_hops
1364    }
1365
1366    #[cfg(any(test, feature = "test-support"))]
1367    fn observe_hop_for_cancellation(&self, hops: usize) {
1368        let Some(source) = &self.hop_cancellation else {
1369            return;
1370        };
1371        source.observed_hops.set(hops);
1372        if hops == source.after_hops {
1373            source.token.cancel();
1374        }
1375    }
1376
1377    /// Traverses, then exact-rescores the complete retained pool.
1378    pub fn search(
1379        &mut self,
1380        request: GraphSearchRequest<'_>,
1381        cancellation: Option<&QueryCancellation<'_>>,
1382    ) -> Result<GraphSearchResult, GraphSearchError> {
1383        match self.search_inner(request, None, None, cancellation)? {
1384            SearchInnerOutcome::Traversed(result) => Ok(result),
1385            SearchInnerOutcome::VisitedBudgetExceeded { .. } => Err(GraphSearchError::Geometry(
1386                "unfiltered traversal produced a filtered-budget disposition".to_owned(),
1387            )),
1388        }
1389    }
1390
1391    /// Traverses every reachable node while retaining only rows in `allow_list`.
1392    ///
1393    /// `filtered_visited_budget` is a selectivity work budget. Crossing it is a
1394    /// normal disposition for the store planner to answer through its exact
1395    /// allow-list branch; it is deliberately distinct from the fail-closed
1396    /// unfiltered visited-cap error.
1397    pub fn search_filtered(
1398        &mut self,
1399        request: GraphSearchRequest<'_>,
1400        allow_list: &DocBitmap,
1401        filtered_visited_budget: usize,
1402        cancellation: Option<&QueryCancellation<'_>>,
1403    ) -> Result<FilteredGraphSearchOutcome, GraphSearchError> {
1404        match self.search_inner(
1405            request,
1406            Some(allow_list),
1407            Some(filtered_visited_budget),
1408            cancellation,
1409        )? {
1410            SearchInnerOutcome::Traversed(result) => {
1411                Ok(FilteredGraphSearchOutcome::Traversed(result))
1412            }
1413            SearchInnerOutcome::VisitedBudgetExceeded {
1414                visited,
1415                budget,
1416                counters,
1417            } => Ok(FilteredGraphSearchOutcome::VisitedBudgetExceeded {
1418                visited,
1419                budget,
1420                counters,
1421            }),
1422        }
1423    }
1424
1425    fn search_inner(
1426        &mut self,
1427        request: GraphSearchRequest<'_>,
1428        allow_list: Option<&DocBitmap>,
1429        filtered_visited_budget: Option<usize>,
1430        cancellation: Option<&QueryCancellation<'_>>,
1431    ) -> Result<SearchInnerOutcome, GraphSearchError> {
1432        let entry = self.begin_traversal(request, cancellation)?;
1433        let prepared = PreparedGraphQuery::new(
1434            request.query,
1435            self.graph.layout().padded_dims() as usize,
1436            request.seed,
1437        )?;
1438        match self.traverse_prepared(
1439            request,
1440            allow_list,
1441            filtered_visited_budget,
1442            cancellation,
1443            entry,
1444            &prepared,
1445        )? {
1446            TraversalOutcome::Retained {
1447                counters,
1448                candidate_sequence,
1449            } => self.finalize_traversal(
1450                request,
1451                allow_list,
1452                cancellation,
1453                counters,
1454                candidate_sequence,
1455            ),
1456            TraversalOutcome::Budget(outcome) => Ok(outcome),
1457        }
1458    }
1459
1460    pub(crate) fn search_reusing(
1461        &mut self,
1462        request: GraphSearchRequest<'_>,
1463        cancellation: Option<&QueryCancellation<'_>>,
1464        context: &mut crate::lifecycle::prepared::PreparedVectorQuery<'_>,
1465        segment: &crate::segment::reader::SegmentReader,
1466        source: crate::ingest::RowSource,
1467    ) -> Result<GraphSearchResult, crate::lifecycle::QueryError> {
1468        use crate::lifecycle::map_graph_error;
1469        let entry = self
1470            .begin_traversal(request, cancellation)
1471            .map_err(map_graph_error)?;
1472        let prepared = context.graph(
1473            request.query,
1474            self.graph.layout().padded_dims() as usize,
1475            request.seed,
1476        )?;
1477        let traversal = self
1478            .traverse_prepared(request, None, None, cancellation, entry, prepared)
1479            .map_err(map_graph_error)?;
1480        let TraversalOutcome::Retained {
1481            counters,
1482            candidate_sequence,
1483        } = traversal
1484        else {
1485            return Err(map_graph_error(GraphSearchError::Geometry(
1486                "unfiltered traversal produced a filtered-budget disposition".to_owned(),
1487            )));
1488        };
1489        // The validator still sees the complete retained pool on every round,
1490        // including rows whose exact score is already cached.
1491        let rows = self.rescore;
1492        let dimensions = self.graph.layout().dims() as usize;
1493        let pool = self
1494            .prepare_rescore(request, cancellation, counters)
1495            .map_err(map_graph_error)?;
1496        let rescored = context.rescore_graph(
1497            request.query,
1498            rows,
1499            dimensions,
1500            pool,
1501            request.k,
1502            segment,
1503            source,
1504        )?;
1505        match self
1506            .finish_rescore(
1507                request,
1508                None,
1509                cancellation,
1510                counters,
1511                candidate_sequence,
1512                Ok(rescored),
1513            )
1514            .map_err(map_graph_error)?
1515        {
1516            SearchInnerOutcome::Traversed(result) => Ok(result),
1517            SearchInnerOutcome::VisitedBudgetExceeded { .. } => {
1518                Err(map_graph_error(GraphSearchError::Geometry(
1519                    "rescore produced a filtered-budget disposition".to_owned(),
1520                )))
1521            }
1522        }
1523    }
1524
1525    fn begin_traversal(
1526        &self,
1527        request: GraphSearchRequest<'_>,
1528        cancellation: Option<&QueryCancellation<'_>>,
1529    ) -> Result<(usize, QueryQosClass, i32), GraphSearchError> {
1530        let ef = self.validate_request(request)?;
1531        check_cancellation(cancellation)?;
1532        #[cfg(any(test, feature = "test-support"))]
1533        GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| {
1534            if let Some(observations) = observations.borrow_mut().as_mut() {
1535                observations.traversals += 1;
1536            }
1537        });
1538        let (qos_class, qos_relative_priority) = observed_qos();
1539        Ok((ef, qos_class, qos_relative_priority))
1540    }
1541
1542    #[allow(clippy::too_many_arguments)]
1543    fn traverse_prepared(
1544        &mut self,
1545        request: GraphSearchRequest<'_>,
1546        allow_list: Option<&DocBitmap>,
1547        filtered_visited_budget: Option<usize>,
1548        cancellation: Option<&QueryCancellation<'_>>,
1549        entry: (usize, QueryQosClass, i32),
1550        prepared: &PreparedGraphQuery,
1551    ) -> Result<TraversalOutcome, GraphSearchError> {
1552        let (ef, qos_class, qos_relative_priority) = entry;
1553        let query_norm = prepared.norm;
1554        let prepared = prepared.bit4();
1555        let visited_cap = ef
1556            .checked_mul(usize::from(self.graph.layout().max_degree()))
1557            .and_then(|value| value.checked_mul(4))
1558            .ok_or_else(|| GraphSearchError::Geometry("visited cap overflow".to_owned()))?;
1559        let visited_epoch_cleared = self.next_epoch();
1560        let mut counters = GraphSearchCounters {
1561            effective_ef: ef,
1562            candidates_rescored: 0,
1563            hops: 0,
1564            candidates_scored: 0,
1565            pushes: 0,
1566            visited: 0,
1567            visited_epoch_cleared,
1568            dims_touched: 0,
1569            bytes_read: 0,
1570            qos_class,
1571            qos_relative_priority,
1572            core_class: request.observed_core_class,
1573        };
1574        self.scratch.pool.clear();
1575        self.scratch.frontier.clear();
1576        reserve_exact_scratch(&mut self.scratch.pool, ef, "pool")?;
1577        reserve_exact_scratch(&mut self.scratch.frontier, ef, "frontier")?;
1578        let mut candidate_sequence = request.trace_candidates.then(|| Vec::with_capacity(ef));
1579        let entries = self.entries;
1580        if request.prefetch.is_enabled() {
1581            for entry in &entries {
1582                self.graph.prefetch_line0(*entry);
1583            }
1584        }
1585        let mut seed_group = [None; 4];
1586        let mut seed_count = 0_usize;
1587        for entry in entries {
1588            if mark_visited(&mut self.scratch.visited, self.scratch.epoch, entry)? {
1589                counters.visited += 1;
1590                ensure_visited_cap(counters.visited, visited_cap)?;
1591                if filtered_visited_budget.is_some_and(|budget| counters.visited > budget) {
1592                    return self.filtered_budget_outcome(counters, filtered_visited_budget);
1593                }
1594                let Some(slot) = seed_group.get_mut(seed_count) else {
1595                    return Err(GraphSearchError::Geometry(
1596                        "entry seed count exceeds fixed seed group".to_owned(),
1597                    ));
1598                };
1599                *slot = Some(entry);
1600                seed_count += 1;
1601            }
1602        }
1603        self.score_and_push_group(
1604            prepared,
1605            query_norm,
1606            &seed_group,
1607            seed_count,
1608            ef,
1609            allow_list,
1610            request.prefetch,
1611            &mut counters,
1612            &mut candidate_sequence,
1613        )?;
1614        loop {
1615            check_cancellation(cancellation)?;
1616            let Some(candidate) = min_heap_pop(&mut self.scratch.frontier)? else {
1617                break;
1618            };
1619            if self.scratch.pool.len() >= ef
1620                && self
1621                    .scratch
1622                    .pool
1623                    .first()
1624                    .is_some_and(|worst| candidate.distance > worst.distance)
1625            {
1626                break;
1627            }
1628            counters.hops += 1;
1629            #[cfg(any(test, feature = "test-support"))]
1630            self.observe_hop_for_cancellation(counters.hops);
1631            let (degree, neighbors) = self.graph.adjacency_checked(candidate.row_id)?;
1632            let degree = usize::from(degree);
1633            self.scratch.hop_candidates.clear();
1634            for raw_neighbor in neighbors.take(degree) {
1635                let neighbor = self.graph.checked_node_id(raw_neighbor)?;
1636                if !mark_visited(&mut self.scratch.visited, self.scratch.epoch, neighbor)? {
1637                    continue;
1638                }
1639                counters.visited += 1;
1640                ensure_visited_cap(counters.visited, visited_cap)?;
1641                if filtered_visited_budget.is_some_and(|budget| counters.visited > budget) {
1642                    return self.filtered_budget_outcome(counters, filtered_visited_budget);
1643                }
1644                if request.prefetch.is_enabled() {
1645                    self.graph.prefetch_line0(neighbor);
1646                }
1647                self.scratch.hop_candidates.push(neighbor);
1648            }
1649            let mut group_start = 0_usize;
1650            let hop_candidate_count = self.scratch.hop_candidates.len();
1651            while group_start < hop_candidate_count {
1652                check_cancellation(cancellation)?;
1653                let group_count = (hop_candidate_count - group_start).min(4);
1654                let mut group = [None; 4];
1655                for lane in 0..group_count {
1656                    let source = self.scratch.hop_candidates.get(group_start + lane).copied();
1657                    let Some(destination) = group.get_mut(lane) else {
1658                        return Err(GraphSearchError::Geometry(
1659                            "candidate gather lane is unavailable".to_owned(),
1660                        ));
1661                    };
1662                    *destination = source;
1663                }
1664                self.score_and_push_group(
1665                    prepared,
1666                    query_norm,
1667                    &group,
1668                    group_count,
1669                    ef,
1670                    allow_list,
1671                    request.prefetch,
1672                    &mut counters,
1673                    &mut candidate_sequence,
1674                )?;
1675                group_start += group_count;
1676            }
1677        }
1678        Ok(TraversalOutcome::Retained {
1679            counters,
1680            candidate_sequence,
1681        })
1682    }
1683
1684    fn finalize_traversal(
1685        &mut self,
1686        request: GraphSearchRequest<'_>,
1687        allow_list: Option<&DocBitmap>,
1688        cancellation: Option<&QueryCancellation<'_>>,
1689        counters: GraphSearchCounters,
1690        candidate_sequence: Option<Vec<u32>>,
1691    ) -> Result<SearchInnerOutcome, GraphSearchError> {
1692        let rows = self.rescore;
1693        let dimensions = self.graph.layout().dims() as usize;
1694        let pool = self.prepare_rescore(request, cancellation, counters)?;
1695        let rescored = rescore_top_k(request.query, rows, dimensions, pool, request.k);
1696        self.finish_rescore(
1697            request,
1698            allow_list,
1699            cancellation,
1700            counters,
1701            candidate_sequence,
1702            rescored,
1703        )
1704    }
1705
1706    fn prepare_rescore(
1707        &mut self,
1708        request: GraphSearchRequest<'_>,
1709        cancellation: Option<&QueryCancellation<'_>>,
1710        counters: GraphSearchCounters,
1711    ) -> Result<RescorePool<'_>, GraphSearchError> {
1712        check_cancellation(cancellation)?;
1713        self.scratch.rescore_row_ids.clear();
1714        self.scratch.rescore_coarse_scores.clear();
1715        let retained = self.scratch.pool.len();
1716        reserve_exact_scratch(
1717            &mut self.scratch.rescore_row_ids,
1718            retained,
1719            "rescore row-id",
1720        )?;
1721        reserve_exact_scratch(
1722            &mut self.scratch.rescore_coarse_scores,
1723            retained,
1724            "rescore score",
1725        )?;
1726        for candidate in &self.scratch.pool {
1727            self.scratch.rescore_row_ids.push(candidate.row_id.raw());
1728            let score = -(candidate.distance as f32);
1729            if !score.is_finite() {
1730                return Err(GraphSearchError::Geometry(format!(
1731                    "coarse distance for row {} is not representable as f32",
1732                    candidate.row_id.raw()
1733                )));
1734            }
1735            self.scratch.rescore_coarse_scores.push(score);
1736        }
1737        if let Some(validator) = self.rescore_validator {
1738            validator
1739                .validate_rows(&self.scratch.rescore_row_ids)
1740                .map_err(GraphSearchError::ExactRescoreUnavailable)?;
1741            #[cfg(any(test, feature = "test-support"))]
1742            GRAPH_QUERY_TEST_OBSERVATIONS.with(|observations| {
1743                if let Some(observations) = observations.borrow_mut().as_mut() {
1744                    observations
1745                        .validated_pools
1746                        .push(self.scratch.rescore_row_ids.clone());
1747                }
1748            });
1749        }
1750        let coarse_bytes_per_row = self
1751            .graph
1752            .layout()
1753            .code_bytes()
1754            .checked_add(12)
1755            .ok_or_else(|| GraphSearchError::Geometry("coarse row bytes overflow".to_owned()))?;
1756        let pool = RescorePool::retained(
1757            &self.scratch.rescore_row_ids,
1758            &self.scratch.rescore_coarse_scores,
1759            RescoreMetric::SquaredL2,
1760            counters.candidates_scored,
1761            coarse_bytes_per_row,
1762        )
1763        .with_prefetch(request.prefetch.is_enabled());
1764        Ok(pool)
1765    }
1766
1767    #[allow(clippy::too_many_arguments)]
1768    fn finish_rescore(
1769        &self,
1770        _request: GraphSearchRequest<'_>,
1771        allow_list: Option<&DocBitmap>,
1772        cancellation: Option<&QueryCancellation<'_>>,
1773        mut counters: GraphSearchCounters,
1774        candidate_sequence: Option<Vec<u32>>,
1775        rescored: Result<crate::quant::RescoreResult, RescoreError>,
1776    ) -> Result<SearchInnerOutcome, GraphSearchError> {
1777        let coarse_bytes_per_row = self
1778            .graph
1779            .layout()
1780            .code_bytes()
1781            .checked_add(12)
1782            .ok_or_else(|| GraphSearchError::Geometry("coarse row bytes overflow".to_owned()))?;
1783        let rescored = match rescored {
1784            Ok(rescored) => rescored,
1785            Err(RescoreError::InsufficientCandidates { k, candidates }) if allow_list.is_some() => {
1786                counters.candidates_rescored = 0;
1787                counters.dims_touched = u64::try_from(counters.candidates_scored)
1788                    .ok()
1789                    .and_then(|rows| rows.checked_mul(u64::from(self.graph.layout().dims())))
1790                    .ok_or_else(|| {
1791                        GraphSearchError::Geometry("dimension counter overflow".to_owned())
1792                    })?;
1793                counters.bytes_read = counters
1794                    .candidates_scored
1795                    .checked_mul(coarse_bytes_per_row)
1796                    .and_then(|bytes| u64::try_from(bytes).ok())
1797                    .ok_or_else(|| {
1798                        GraphSearchError::Geometry("byte counter exceeds u64".to_owned())
1799                    })?;
1800                return Err(GraphSearchError::FilteredCandidateShortfall {
1801                    k,
1802                    candidates,
1803                    counters,
1804                });
1805            }
1806            Err(error) => return Err(GraphSearchError::Rescore(error)),
1807        };
1808        check_cancellation(cancellation)?;
1809        counters.candidates_rescored = rescored.candidates_rescored;
1810        let touched_rows = counters
1811            .candidates_scored
1812            .checked_add(counters.candidates_rescored)
1813            .ok_or_else(|| GraphSearchError::Geometry("dimension counter overflow".to_owned()))?;
1814        counters.dims_touched = u64::try_from(touched_rows)
1815            .ok()
1816            .and_then(|rows| rows.checked_mul(u64::from(self.graph.layout().dims())))
1817            .ok_or_else(|| GraphSearchError::Geometry("dimension counter overflow".to_owned()))?;
1818        counters.bytes_read = u64::try_from(rescored.bytes.total())
1819            .map_err(|_| GraphSearchError::Geometry("byte counter exceeds u64".to_owned()))?;
1820        let candidates = rescored
1821            .hits
1822            .into_iter()
1823            .map(|hit| {
1824                let row_id = u32::try_from(hit.row_index).map_err(|_| {
1825                    GraphSearchError::Geometry("rescored row id exceeds u32".to_owned())
1826                })?;
1827                Ok(GraphSearchCandidate {
1828                    row_id,
1829                    distance: (-hit.score).max(0.0),
1830                })
1831            })
1832            .collect::<Result<Vec<_>, GraphSearchError>>()?;
1833        Ok(SearchInnerOutcome::Traversed(GraphSearchResult {
1834            candidates,
1835            counters,
1836            candidate_sequence,
1837        }))
1838    }
1839
1840    fn filtered_budget_outcome(
1841        &self,
1842        mut counters: GraphSearchCounters,
1843        filtered_visited_budget: Option<usize>,
1844    ) -> Result<TraversalOutcome, GraphSearchError> {
1845        let budget = filtered_visited_budget.ok_or_else(|| {
1846            GraphSearchError::Geometry(
1847                "filtered budget disposition has no configured budget".to_owned(),
1848            )
1849        })?;
1850        counters.dims_touched = u64::try_from(counters.candidates_scored)
1851            .ok()
1852            .and_then(|rows| rows.checked_mul(u64::from(self.graph.layout().dims())))
1853            .ok_or_else(|| GraphSearchError::Geometry("dimension counter overflow".to_owned()))?;
1854        let coarse_bytes_per_row = self
1855            .graph
1856            .layout()
1857            .code_bytes()
1858            .checked_add(12)
1859            .ok_or_else(|| GraphSearchError::Geometry("coarse row bytes overflow".to_owned()))?;
1860        counters.bytes_read = counters
1861            .candidates_scored
1862            .checked_mul(coarse_bytes_per_row)
1863            .and_then(|bytes| u64::try_from(bytes).ok())
1864            .ok_or_else(|| GraphSearchError::Geometry("byte counter exceeds u64".to_owned()))?;
1865        Ok(TraversalOutcome::Budget(
1866            SearchInnerOutcome::VisitedBudgetExceeded {
1867                visited: counters.visited,
1868                budget,
1869                counters,
1870            },
1871        ))
1872    }
1873
1874    fn validate_request(&self, request: GraphSearchRequest<'_>) -> Result<usize, GraphSearchError> {
1875        let dimensions = self.graph.layout().dims() as usize;
1876        if request.query.len() != dimensions {
1877            return Err(GraphSearchError::Geometry(format!(
1878                "query has {} dimensions, expected {dimensions}",
1879                request.query.len()
1880            )));
1881        }
1882        request
1883            .effective_ef(self.graph.node_count() as usize)
1884            .map_err(Into::into)
1885    }
1886
1887    fn next_epoch(&mut self) -> bool {
1888        self.scratch.epoch = self.scratch.epoch.wrapping_add(1);
1889        if self.scratch.epoch == 0 {
1890            self.scratch.visited.fill(0);
1891            self.scratch.epoch = 1;
1892            return true;
1893        }
1894        false
1895    }
1896
1897    #[allow(clippy::too_many_arguments)]
1898    fn score_and_push_group(
1899        &mut self,
1900        query: &Bit4Query,
1901        query_norm: f64,
1902        row_ids: &[Option<CheckedNodeId>; 4],
1903        row_count: usize,
1904        ef: usize,
1905        allow_list: Option<&DocBitmap>,
1906        prefetch: TraversalPrefetch,
1907        counters: &mut GraphSearchCounters,
1908        candidate_sequence: &mut Option<Vec<u32>>,
1909    ) -> Result<(), GraphSearchError> {
1910        let scored = score_group(&self.graph, query, query_norm, row_ids, row_count)?;
1911        counters.candidates_scored = counters
1912            .candidates_scored
1913            .checked_add(row_count)
1914            .ok_or_else(|| GraphSearchError::Geometry("candidate counter overflow".to_owned()))?;
1915        for candidate in scored.into_iter().flatten() {
1916            let allowed = allow_list.is_none_or(|mask| mask.contains(candidate.row_id.raw()));
1917            #[cfg(any(test, feature = "test-support"))]
1918            if allowed && let Some((controller, source, tier)) = self.vector_fault {
1919                let local_row = usize::try_from(candidate.row_id.raw()).map_err(|_| {
1920                    GraphSearchError::Geometry("graph row id exceeds usize".to_owned())
1921                })?;
1922                if controller.after_eligible_row(source, tier, local_row) {
1923                    return Err(GraphSearchError::Cancelled { partial: false });
1924                }
1925            }
1926            if let Some(sequence) = candidate_sequence.as_mut() {
1927                sequence.push(candidate.row_id.raw());
1928            }
1929            let mut pushed = false;
1930            if allowed && max_heap_would_keep(&self.scratch.pool, candidate, ef) {
1931                max_heap_insert_bounded(&mut self.scratch.pool, candidate, ef)?;
1932                min_heap_push(&mut self.scratch.frontier, candidate)?;
1933                counters.pushes = counters.pushes.checked_add(1).ok_or_else(|| {
1934                    GraphSearchError::Geometry("push counter overflow".to_owned())
1935                })?;
1936                pushed = true;
1937            } else if !allowed
1938                && (self.scratch.pool.len() < ef
1939                    || self
1940                        .scratch
1941                        .pool
1942                        .first()
1943                        .is_some_and(|worst| candidate.distance <= worst.distance))
1944            {
1945                min_heap_push(&mut self.scratch.frontier, candidate)?;
1946                counters.pushes = counters.pushes.checked_add(1).ok_or_else(|| {
1947                    GraphSearchError::Geometry("push counter overflow".to_owned())
1948                })?;
1949                pushed = true;
1950            }
1951            if pushed
1952                && prefetch.is_enabled()
1953                && let Some(head) = self.scratch.frontier.first()
1954            {
1955                self.graph.prefetch_head_block(head.row_id);
1956            }
1957        }
1958        Ok(())
1959    }
1960}
1961
1962fn reserve_exact_scratch<T>(
1963    buffer: &mut Vec<T>,
1964    needed: usize,
1965    what: &'static str,
1966) -> Result<(), GraphSearchError> {
1967    if buffer.capacity() < needed {
1968        buffer.try_reserve_exact(needed).map_err(|error| {
1969            GraphSearchError::Geometry(format!("{what} allocation failed: {error}"))
1970        })?;
1971    }
1972    Ok(())
1973}
1974
1975fn mark_visited(
1976    visited: &mut [u8],
1977    epoch: u8,
1978    row_id: CheckedNodeId,
1979) -> Result<bool, GraphSearchError> {
1980    let marker = visited.get_mut(row_id.raw() as usize).ok_or_else(|| {
1981        GraphSearchError::Geometry(format!("row id {} is unavailable", row_id.raw()))
1982    })?;
1983    if *marker == epoch {
1984        return Ok(false);
1985    }
1986    *marker = epoch;
1987    Ok(true)
1988}
1989
1990fn ensure_visited_cap(visited: usize, cap: usize) -> Result<(), GraphSearchError> {
1991    if visited > cap {
1992        return Err(GraphSearchError::VisitedCapExceeded { visited, cap });
1993    }
1994    Ok(())
1995}
1996
1997fn check_cancellation(
1998    cancellation: Option<&QueryCancellation<'_>>,
1999) -> Result<(), GraphSearchError> {
2000    let Some(cancellation) = cancellation else {
2001        return Ok(());
2002    };
2003    match cancellation.check_graph() {
2004        Ok(()) => Ok(()),
2005        Err(ScanError::Cancelled { partial }) => Err(GraphSearchError::Cancelled { partial }),
2006        Err(ScanError::Timeout { partial }) => Err(GraphSearchError::Timeout { partial }),
2007        Err(ScanError::ReadCancelled { partial }) => {
2008            Err(GraphSearchError::ReadCancelled { partial })
2009        }
2010        Err(error) => Err(GraphSearchError::Scan(error)),
2011    }
2012}
2013
2014fn scored_best_first(left: &ScoredNode, right: &ScoredNode) -> std::cmp::Ordering {
2015    left.distance
2016        .total_cmp(&right.distance)
2017        .then_with(|| left.row_id.raw().cmp(&right.row_id.raw()))
2018}
2019
2020fn score_group(
2021    graph: &GraphNodeBlocks<'_>,
2022    query: &Bit4Query,
2023    query_norm: f64,
2024    row_ids: &[Option<CheckedNodeId>; 4],
2025    row_count: usize,
2026) -> Result<[Option<ScoredNode>; 4], GraphSearchError> {
2027    let first_id = row_ids
2028        .first()
2029        .copied()
2030        .flatten()
2031        .ok_or_else(|| GraphSearchError::Geometry("empty gather group".to_owned()))?;
2032    let (first_row, first_factors) = graph.score_row_checked(first_id)?;
2033    let mut rows = [first_row; 4];
2034    let mut factors = [first_factors; 4];
2035    for lane in 1..row_count {
2036        let row_id =
2037            row_ids.get(lane).copied().flatten().ok_or_else(|| {
2038                GraphSearchError::Geometry(format!("gather lane {lane} is empty"))
2039            })?;
2040        let (row, factors_for_row) = graph.score_row_checked(row_id)?;
2041        let row_slot = rows
2042            .get_mut(lane)
2043            .ok_or_else(|| GraphSearchError::Geometry("gather row lane overflow".to_owned()))?;
2044        *row_slot = row;
2045        let factor_slot = factors
2046            .get_mut(lane)
2047            .ok_or_else(|| GraphSearchError::Geometry("gather factor lane overflow".to_owned()))?;
2048        *factor_slot = factors_for_row;
2049    }
2050    let gathered = Bit4Rows4::from_rows(rows, graph.layout().code_bytes())?;
2051    let (query_codes, query_sum, query_scale_half) = query.kernel_parts();
2052    let mut dots = [0.0_f32; 4];
2053    score_bit4_ptrs(
2054        query_codes,
2055        query_sum,
2056        query_scale_half,
2057        &gathered,
2058        &factors,
2059        &mut dots,
2060    )?;
2061    let mut scored = [None; 4];
2062    for lane in 0..row_count {
2063        let row_id =
2064            row_ids.get(lane).copied().flatten().ok_or_else(|| {
2065                GraphSearchError::Geometry(format!("scored lane {lane} is empty"))
2066            })?;
2067        let dot = f64::from(
2068            *dots
2069                .get(lane)
2070                .ok_or_else(|| GraphSearchError::Geometry("dot lane overflow".to_owned()))?,
2071        );
2072        let norm = factors.get(lane).copied().unwrap_or(first_factors).norm();
2073        let destination = scored
2074            .get_mut(lane)
2075            .ok_or_else(|| GraphSearchError::Geometry("score lane overflow".to_owned()))?;
2076        *destination = Some(ScoredNode {
2077            row_id,
2078            distance: (query_norm + norm * norm - 2.0 * dot).max(0.0),
2079        });
2080    }
2081    Ok(scored)
2082}
2083
2084fn max_heap_would_keep(pool: &[ScoredNode], node: ScoredNode, limit: usize) -> bool {
2085    pool.len() < limit
2086        || pool
2087            .first()
2088            .is_some_and(|worst| scored_best_first(&node, worst).is_lt())
2089}
2090
2091fn max_heap_insert_bounded(
2092    pool: &mut Vec<ScoredNode>,
2093    node: ScoredNode,
2094    limit: usize,
2095) -> Result<(), GraphSearchError> {
2096    if limit == 0 {
2097        return Err(GraphSearchError::Geometry(
2098            "bounded pool capacity is zero".to_owned(),
2099        ));
2100    }
2101    if pool.len() < limit {
2102        pool.push(node);
2103        let child = pool.len().saturating_sub(1);
2104        max_heap_sift_up(pool, child)?;
2105        return Ok(());
2106    }
2107    let root = pool
2108        .first_mut()
2109        .ok_or_else(|| GraphSearchError::Geometry("full pool has no root".to_owned()))?;
2110    *root = node;
2111    max_heap_sift_down(pool, 0)
2112}
2113
2114fn min_heap_push(frontier: &mut Vec<ScoredNode>, node: ScoredNode) -> Result<(), GraphSearchError> {
2115    frontier.push(node);
2116    let child = frontier.len().saturating_sub(1);
2117    min_heap_sift_up(frontier, child)
2118}
2119
2120fn min_heap_pop(frontier: &mut Vec<ScoredNode>) -> Result<Option<ScoredNode>, GraphSearchError> {
2121    let Some(last) = frontier.pop() else {
2122        return Ok(None);
2123    };
2124    let Some(root) = frontier.first_mut() else {
2125        return Ok(Some(last));
2126    };
2127    let result = *root;
2128    *root = last;
2129    min_heap_sift_down(frontier, 0)?;
2130    Ok(Some(result))
2131}
2132
2133fn min_heap_sift_up(heap: &mut [ScoredNode], mut child: usize) -> Result<(), GraphSearchError> {
2134    let start = child;
2135    let moving = heap_node(heap, child)?;
2136    while child != 0 {
2137        let parent = child.saturating_sub(1) / 2;
2138        let parent_node = heap_node(heap, parent)?;
2139        if !scored_best_first(&moving, &parent_node).is_lt() {
2140            break;
2141        }
2142        heap_store(heap, child, parent_node)?;
2143        child = parent;
2144    }
2145    if child != start {
2146        heap_store(heap, child, moving)?;
2147    }
2148    Ok(())
2149}
2150
2151fn max_heap_sift_up(heap: &mut [ScoredNode], mut child: usize) -> Result<(), GraphSearchError> {
2152    let start = child;
2153    let moving = heap_node(heap, child)?;
2154    while child != 0 {
2155        let parent = child.saturating_sub(1) / 2;
2156        let parent_node = heap_node(heap, parent)?;
2157        if !scored_best_first(&moving, &parent_node).is_gt() {
2158            break;
2159        }
2160        heap_store(heap, child, parent_node)?;
2161        child = parent;
2162    }
2163    if child != start {
2164        heap_store(heap, child, moving)?;
2165    }
2166    Ok(())
2167}
2168
2169fn min_heap_sift_down(heap: &mut [ScoredNode], mut parent: usize) -> Result<(), GraphSearchError> {
2170    let start = parent;
2171    let moving = heap_node(heap, parent)?;
2172    loop {
2173        let left = parent
2174            .checked_mul(2)
2175            .and_then(|value| value.checked_add(1))
2176            .ok_or_else(|| GraphSearchError::Geometry("min-heap child overflow".to_owned()))?;
2177        if left >= heap.len() {
2178            break;
2179        }
2180        let right = left.saturating_add(1);
2181        let best_child = if right < heap.len()
2182            && scored_best_first(&heap_node(heap, right)?, &heap_node(heap, left)?).is_lt()
2183        {
2184            right
2185        } else {
2186            left
2187        };
2188        let child_node = heap_node(heap, best_child)?;
2189        if !scored_best_first(&child_node, &moving).is_lt() {
2190            break;
2191        }
2192        heap_store(heap, parent, child_node)?;
2193        parent = best_child;
2194    }
2195    if parent != start {
2196        heap_store(heap, parent, moving)?;
2197    }
2198    Ok(())
2199}
2200
2201fn max_heap_sift_down(heap: &mut [ScoredNode], mut parent: usize) -> Result<(), GraphSearchError> {
2202    let start = parent;
2203    let moving = heap_node(heap, parent)?;
2204    loop {
2205        let left = parent
2206            .checked_mul(2)
2207            .and_then(|value| value.checked_add(1))
2208            .ok_or_else(|| GraphSearchError::Geometry("max-heap child overflow".to_owned()))?;
2209        if left >= heap.len() {
2210            break;
2211        }
2212        let right = left.saturating_add(1);
2213        let worst_child = if right < heap.len()
2214            && scored_best_first(&heap_node(heap, right)?, &heap_node(heap, left)?).is_gt()
2215        {
2216            right
2217        } else {
2218            left
2219        };
2220        let child_node = heap_node(heap, worst_child)?;
2221        if !scored_best_first(&child_node, &moving).is_gt() {
2222            break;
2223        }
2224        heap_store(heap, parent, child_node)?;
2225        parent = worst_child;
2226    }
2227    if parent != start {
2228        heap_store(heap, parent, moving)?;
2229    }
2230    Ok(())
2231}
2232
2233fn heap_node(heap: &[ScoredNode], index: usize) -> Result<ScoredNode, GraphSearchError> {
2234    heap.get(index)
2235        .copied()
2236        .ok_or_else(|| GraphSearchError::Geometry(format!("heap slot {index} is unavailable")))
2237}
2238
2239fn heap_store(
2240    heap: &mut [ScoredNode],
2241    index: usize,
2242    value: ScoredNode,
2243) -> Result<(), GraphSearchError> {
2244    let slot = heap
2245        .get_mut(index)
2246        .ok_or_else(|| GraphSearchError::Geometry(format!("heap slot {index} is unavailable")))?;
2247    *slot = value;
2248    Ok(())
2249}
2250
2251fn squared_norm(values: &[f32]) -> f64 {
2252    values
2253        .iter()
2254        .map(|value| f64::from(*value) * f64::from(*value))
2255        .sum()
2256}
2257
2258#[cfg(target_vendor = "apple")]
2259fn observed_qos() -> (QueryQosClass, i32) {
2260    let mut class = libc::qos_class_t::QOS_CLASS_UNSPECIFIED;
2261    let mut priority = 0_i32;
2262    // SAFETY: pthread_self returns the current live thread and both output pointers are valid.
2263    let status =
2264        unsafe { libc::pthread_get_qos_class_np(libc::pthread_self(), &mut class, &mut priority) };
2265    if status != 0 {
2266        return (QueryQosClass::Unknown, 0);
2267    }
2268    let class = match class {
2269        libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE => QueryQosClass::UserInteractive,
2270        libc::qos_class_t::QOS_CLASS_USER_INITIATED => QueryQosClass::UserInitiated,
2271        libc::qos_class_t::QOS_CLASS_DEFAULT => QueryQosClass::Default,
2272        libc::qos_class_t::QOS_CLASS_UTILITY => QueryQosClass::Utility,
2273        libc::qos_class_t::QOS_CLASS_BACKGROUND => QueryQosClass::Background,
2274        libc::qos_class_t::QOS_CLASS_UNSPECIFIED => QueryQosClass::Unspecified,
2275    };
2276    (class, priority)
2277}
2278
2279#[cfg(not(target_vendor = "apple"))]
2280const fn observed_qos() -> (QueryQosClass, i32) {
2281    (QueryQosClass::Unavailable, 0)
2282}
2283
2284#[cfg(test)]
2285#[allow(
2286    clippy::expect_used,
2287    clippy::indexing_slicing,
2288    clippy::panic,
2289    clippy::unwrap_used
2290)]
2291mod tests {
2292    use std::time::Duration;
2293
2294    use proptest::prelude::*;
2295    use proptest::test_runner::{Config, RngSeed, TestRunner};
2296    use rand::RngCore;
2297
2298    use super::{
2299        AdaptiveEfError, FilteredGraphSearchOutcome, GraphSearchError, GraphSearchProfile,
2300        GraphSearchRequest, GraphSearchScratch, GraphSearcher, GraphSegmentNormRange,
2301        QueryCoreClass, TraversalPrefetch, next_down_f64, next_up_f32, next_up_f64,
2302    };
2303    use crate::graph::block::{
2304        GraphNodeBlockBuild, GraphNodeBlockInput, GraphNodeLayout, decode_node_blocks,
2305        encode_node_blocks,
2306    };
2307    use crate::kernels::GatherShapeError;
2308    use crate::lifecycle::{CancelToken, OpenOptions, QueryCancellation, QueryControl, Store};
2309    use crate::meta::DocBitmap;
2310    use crate::quant::{Bit4Factors, QuantError, RescoreError, quantize_bit4};
2311    use crate::scan::ScanError;
2312
2313    const DIMS: usize = 128;
2314
2315    #[test]
2316    fn adaptive_defaults_follow_the_measured_profile_rules() {
2317        let query = [0.0_f32; DIMS];
2318
2319        let sift = GraphSearchRequest::new(&query, 100, 0x19_05);
2320        assert_eq!(sift.research_floor_ef(), Ok(140));
2321        assert_eq!(sift.effective_ef(1_000_000), Ok(200));
2322        assert_eq!(sift.effective_ef(180), Ok(180));
2323
2324        let angular =
2325            GraphSearchRequest::new(&query, 100, 0x19_05).with_profile(GraphSearchProfile::Angular);
2326        assert_eq!(angular.research_floor_ef(), Ok(400));
2327        assert_eq!(angular.effective_ef(1_000_000), Ok(400));
2328
2329        let explicit = GraphSearchRequest::new(&query, 100, 0x19_05).with_ef(300);
2330        assert_eq!(explicit.effective_ef(1_000_000), Ok(300));
2331    }
2332
2333    #[test]
2334    fn adaptive_ef_overflow_is_typed() {
2335        let request = GraphSearchRequest::new(&[], usize::MAX, 0x19_05);
2336        assert_eq!(
2337            request.effective_ef(usize::MAX),
2338            Err(AdaptiveEfError::ArithmeticOverflow)
2339        );
2340    }
2341
2342    #[test]
2343    fn request_and_search_geometry_rejections_preserve_the_invalid_values() {
2344        let query = vector(0.0);
2345        assert_eq!(
2346            GraphSearchRequest::new(&query, 0, 7).effective_ef(9),
2347            Err(AdaptiveEfError::ZeroK)
2348        );
2349        assert_eq!(
2350            GraphSearchRequest::new(&query, 10, 7).effective_ef(9),
2351            Err(AdaptiveEfError::KExceedsRows { k: 10, rows: 9 })
2352        );
2353        assert_eq!(
2354            GraphSearchRequest::new(&query, 4, 7)
2355                .with_ef(3)
2356                .effective_ef(9),
2357            Err(AdaptiveEfError::ExplicitBelowK { k: 4, ef: 3 })
2358        );
2359        assert_eq!(
2360            GraphSearchRequest::new(&query, 4, 7)
2361                .with_ef(10)
2362                .effective_ef(9),
2363            Err(AdaptiveEfError::ExplicitExceedsRows { ef: 10, rows: 9 })
2364        );
2365
2366        let (encoded, rescore) = best_first_graph_fixture();
2367        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2368        let mut wrong_scratch =
2369            GraphSearchScratch::new(graph.node_count() + 1, graph.layout().max_degree())
2370                .expect("mismatched scratch allocates");
2371        assert!(matches!(
2372            GraphSearcher::new(graph, &rescore, &mut wrong_scratch),
2373            Err(GraphSearchError::Geometry(detail))
2374                if detail.contains("scratch geometry")
2375        ));
2376
2377        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2378            .expect("fixture scratch");
2379        assert!(matches!(
2380            GraphSearcher::new(graph, &rescore[..rescore.len() - 1], &mut scratch),
2381            Err(GraphSearchError::Geometry(detail))
2382                if detail.contains("rescore has") && detail.contains("expected")
2383        ));
2384        assert!(matches!(
2385            GraphSearcher::with_entry_row_ids(graph, &rescore, [0, 1, 2, 4], &mut scratch),
2386            Err(GraphSearchError::Geometry(detail))
2387                if detail.contains("cached entry row 4")
2388        ));
2389
2390        let mut searcher =
2391            GraphSearcher::new(graph, &rescore, &mut scratch).expect("valid searcher");
2392        assert!(matches!(
2393            searcher.search(
2394                GraphSearchRequest::new(&query[..DIMS - 1], 1, 7).with_ef(4),
2395                None,
2396            ),
2397            Err(GraphSearchError::Geometry(detail))
2398                if detail.contains("127 dimensions") && detail.contains("expected 128")
2399        ));
2400        let mut non_finite = query.clone();
2401        non_finite[17] = f32::NAN;
2402        assert_eq!(
2403            searcher.search(GraphSearchRequest::new(&non_finite, 1, 7).with_ef(4), None,),
2404            Err(GraphSearchError::Quant(QuantError::NonFinite { index: 17 }))
2405        );
2406        let recovered = searcher
2407            .search(GraphSearchRequest::new(&query, 1, 7).with_ef(4), None)
2408            .expect("a rejected request leaves reusable scratch valid");
2409        assert_eq!(recovered.candidates()[0].row_id(), 8);
2410
2411        let one_entry = factor_graph_fixture(Bit4Factors::from_persisted(1.0, 1.0, 0.0), 1);
2412        let one_entry = decode_node_blocks(one_entry.as_bytes()).expect("one-entry graph is valid");
2413        assert!(matches!(
2414            GraphSearcher::discover_entry_row_ids(one_entry),
2415            Err(GraphSearchError::Geometry(detail)) if detail.contains("contains 1 persisted entry")
2416        ));
2417    }
2418
2419    #[test]
2420    fn segment_norm_bounds_round_outward_or_fail_open() {
2421        let bounded = factor_graph_fixture(Bit4Factors::from_persisted(2.0, 10.0, 0.0), 0);
2422        let bounded = decode_node_blocks(bounded.as_bytes()).expect("bounded graph is valid");
2423        let bounded = GraphSegmentNormRange::from_graph(bounded, None).expect("norm scan succeeds");
2424        let zero_query = vec![0.0_f32; DIMS];
2425        assert!(bounded.squared_l2_lower_bound(&zero_query) <= 400.0);
2426        assert!(bounded.squared_l2_upper_bound(&zero_query) >= 400.0);
2427        assert!(bounded.squared_l2_lower_bound(&zero_query) > 0.0);
2428        assert_eq!(bounded.squared_l2_lower_bound(&[]), 0.0);
2429        assert_eq!(bounded.squared_l2_upper_bound(&[]), f64::INFINITY);
2430
2431        for factors in [
2432            Bit4Factors::from_persisted(-1.0, 1.0, 0.0),
2433            Bit4Factors::from_persisted(f32::MAX, f32::MAX, 0.0),
2434        ] {
2435            let encoded = factor_graph_fixture(factors, 0);
2436            let graph = decode_node_blocks(encoded.as_bytes()).expect("finite factors decode");
2437            let range = GraphSegmentNormRange::from_graph(graph, None).expect("norm scan succeeds");
2438            assert_eq!(range.squared_l2_lower_bound(&zero_query), 0.0);
2439            assert_eq!(range.squared_l2_upper_bound(&zero_query), f64::INFINITY);
2440        }
2441    }
2442
2443    #[test]
2444    fn norm_bound_successors_are_exact_ieee_neighbours() {
2445        assert_eq!(next_up_f32(f32::INFINITY), f32::INFINITY);
2446        assert_eq!(next_up_f32(-0.0).to_bits(), 1);
2447        assert_eq!(next_up_f32(-1.0).to_bits(), (-1.0_f32).to_bits() - 1);
2448        assert_eq!(next_up_f64(f64::INFINITY), f64::INFINITY);
2449        assert_eq!(next_up_f64(-0.0).to_bits(), 1);
2450        assert_eq!(next_up_f64(-1.0).to_bits(), (-1.0_f64).to_bits() - 1);
2451        assert_eq!(next_down_f64(0.0), 0.0);
2452        assert_eq!(next_down_f64(1.0).to_bits(), 1.0_f64.to_bits() - 1);
2453    }
2454
2455    #[test]
2456    fn graph_search_errors_keep_typed_sources_and_actionable_messages() {
2457        let sourced = [
2458            GraphSearchError::from(crate::graph::block::GraphNodeError::InvalidHeader(
2459                "bad graph header".to_owned(),
2460            )),
2461            GraphSearchError::from(QuantError::EmptyVector),
2462            GraphSearchError::from(GatherShapeError::RowBytes {
2463                expected: 64,
2464                actual: 63,
2465            }),
2466            GraphSearchError::from(AdaptiveEfError::ExplicitBelowK { k: 4, ef: 3 }),
2467            GraphSearchError::from(AdaptiveEfError::ZeroK),
2468            GraphSearchError::from(AdaptiveEfError::KExceedsRows { k: 10, rows: 9 }),
2469            GraphSearchError::from(AdaptiveEfError::ExplicitExceedsRows { ef: 10, rows: 9 }),
2470            GraphSearchError::from(AdaptiveEfError::ArithmeticOverflow),
2471            GraphSearchError::from(RescoreError::InsufficientCandidates {
2472                k: 4,
2473                candidates: 3,
2474            }),
2475            GraphSearchError::Scan(ScanError::ZeroDimension),
2476        ];
2477        for error in sourced {
2478            assert!(!error.to_string().is_empty());
2479            assert!(std::error::Error::source(&error).is_some());
2480        }
2481
2482        let unsourced = [
2483            GraphSearchError::Geometry("bad request shape".to_owned()),
2484            GraphSearchError::VisitedCapExceeded {
2485                visited: 17,
2486                cap: 16,
2487            },
2488            GraphSearchError::Cancelled { partial: false },
2489            GraphSearchError::Timeout { partial: false },
2490            GraphSearchError::ReadCancelled { partial: false },
2491        ];
2492        let messages = unsourced
2493            .iter()
2494            .map(ToString::to_string)
2495            .collect::<Vec<_>>();
2496        assert!(messages[0].contains("bad request shape"));
2497        assert!(messages[1].contains("17") && messages[1].contains("16"));
2498        assert!(messages[2].contains("cancelled") && messages[2].contains("partial=false"));
2499        assert!(messages[3].contains("deadline") && messages[3].contains("partial=false"));
2500        assert!(messages[4].contains("store close") && messages[4].contains("partial=false"));
2501        assert!(
2502            unsourced
2503                .iter()
2504                .all(|error| std::error::Error::source(error).is_none())
2505        );
2506    }
2507
2508    #[test]
2509    fn adaptive_ef_stays_between_k_and_rows_under_adversarial_shapes() {
2510        let mut seeded = crate::test_support::seeded_rng(
2511            "graph::search::tests::adaptive_ef_stays_between_k_and_rows_under_adversarial_shapes",
2512        );
2513        let mut runner = TestRunner::new(Config {
2514            cases: 256,
2515            rng_seed: RngSeed::Fixed(seeded.next_u64()),
2516            ..Config::default()
2517        });
2518        let result = runner.run(
2519            &(1_usize..1_000_001, 1_usize..1_000_001, any::<bool>()),
2520            |(rows, raw_k, angular)| {
2521                let k = raw_k.min(rows);
2522                let profile = if angular {
2523                    GraphSearchProfile::Angular
2524                } else {
2525                    GraphSearchProfile::SiftClass
2526                };
2527                let request = GraphSearchRequest::new(&[], k, 0x19_05).with_profile(profile);
2528                let ef = request.effective_ef(rows).expect("valid shape resolves");
2529                prop_assert!(ef >= k);
2530                prop_assert!(ef <= rows);
2531                Ok(())
2532            },
2533        );
2534
2535        assert!(result.is_ok(), "property result: {result:?}");
2536    }
2537
2538    #[test]
2539    fn traversal_returns_the_same_topk_as_brute_force_at_high_ef() {
2540        let (encoded, rescore) = complete_graph_fixture(12);
2541        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2542        let query = vector(4.25);
2543        let expected = brute_force_topk(&rescore, &query, 5);
2544        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2545            .expect("fixture scratch");
2546        let mut searcher =
2547            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
2548
2549        let result = searcher
2550            .search(GraphSearchRequest::new(&query, 5, 0x19_04), None)
2551            .expect("high-ef traversal succeeds");
2552        let actual = result
2553            .candidates()
2554            .iter()
2555            .map(|candidate| candidate.row_id())
2556            .collect::<Vec<_>>();
2557
2558        assert_eq!(actual, expected);
2559        assert_eq!(result.counters().effective_ef(), 12);
2560        assert_eq!(result.counters().candidates_rescored(), 12);
2561    }
2562
2563    #[test]
2564    fn an_unfiltered_traversal_is_byte_identical_with_the_mask_parameter_absent() {
2565        let (encoded, rescore) = complete_graph_fixture(12);
2566        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2567        let query = vector(4.25);
2568        let request = GraphSearchRequest::new(&query, 5, 0x19_06).with_ef(12);
2569        let mut unfiltered_scratch =
2570            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2571                .expect("unfiltered scratch");
2572        let mut full_mask_scratch =
2573            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2574                .expect("full-mask scratch");
2575        let mut unfiltered = GraphSearcher::new(graph, &rescore, &mut unfiltered_scratch)
2576            .expect("unfiltered searcher");
2577        let mut full_mask = GraphSearcher::new(graph, &rescore, &mut full_mask_scratch)
2578            .expect("full-mask searcher");
2579
2580        let expected = unfiltered.search(request, None).expect("unfiltered search");
2581        let actual = full_mask
2582            .search_filtered(
2583                request,
2584                &DocBitmap::full(graph.node_count()),
2585                usize::MAX,
2586                None,
2587            )
2588            .expect("full-mask search");
2589        let FilteredGraphSearchOutcome::Traversed(actual) = actual else {
2590            panic!("an unbounded full mask must traverse");
2591        };
2592
2593        assert_eq!(actual.candidates(), expected.candidates());
2594        assert_eq!(
2595            actual.counters().deterministic_work(),
2596            expected.counters().deterministic_work()
2597        );
2598        assert_eq!(actual.candidate_sequence(), expected.candidate_sequence());
2599    }
2600
2601    #[test]
2602    fn every_filtered_graph_result_satisfies_the_predicate_and_is_alive() {
2603        let (encoded, rescore) = complete_graph_fixture(12);
2604        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2605        let query = vector(4.0);
2606        let allowed = DocBitmap::from_ids([1, 2, 6, 9, 11]);
2607        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2608            .expect("filtered scratch");
2609        let mut searcher =
2610            GraphSearcher::new(graph, &rescore, &mut scratch).expect("filtered searcher");
2611
2612        let outcome = searcher
2613            .search_filtered(
2614                GraphSearchRequest::new(&query, 5, 0x19_06).with_ef(12),
2615                &allowed,
2616                usize::MAX,
2617                None,
2618            )
2619            .expect("filtered graph search");
2620        let FilteredGraphSearchOutcome::Traversed(result) = outcome else {
2621            panic!("an unbounded complete graph must traverse");
2622        };
2623
2624        assert_eq!(
2625            result
2626                .candidates()
2627                .iter()
2628                .map(|candidate| candidate.row_id())
2629                .collect::<Vec<_>>(),
2630            vec![2, 6, 1, 9, 11]
2631        );
2632        assert!(
2633            result
2634                .candidates()
2635                .iter()
2636                .all(|candidate| allowed.contains(candidate.row_id()))
2637        );
2638        assert!(
2639            result
2640                .candidates()
2641                .iter()
2642                .all(|candidate| candidate.row_id() != 4),
2643            "the exact nearest row is planted outside the effective alive-and-filter mask"
2644        );
2645        assert_eq!(
2646            result.counters().candidates_rescored(),
2647            allowed.cardinality() as usize
2648        );
2649
2650        let mut seeded = crate::test_support::seeded_rng(
2651            "graph::search::tests::every_filtered_graph_result_satisfies_the_predicate_and_is_alive",
2652        );
2653        let mut runner = TestRunner::new(Config {
2654            cases: 1_024,
2655            rng_seed: RngSeed::Fixed(seeded.next_u64()),
2656            ..Config::default()
2657        });
2658        let property = runner.run(
2659            &(
2660                prop::collection::vec(any::<bool>(), 12),
2661                prop::collection::vec(any::<bool>(), 12),
2662            ),
2663            |(predicate_matches, alive_rows)| {
2664                let effective = DocBitmap::from_ids(
2665                    predicate_matches
2666                        .iter()
2667                        .zip(&alive_rows)
2668                        .enumerate()
2669                        .filter_map(|(row, (matches, alive))| {
2670                            (*matches && *alive).then_some(row as u32)
2671                        }),
2672                );
2673                prop_assume!(!effective.is_empty());
2674                let k = effective.cardinality() as usize;
2675                let mut scratch =
2676                    GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2677                        .expect("property scratch");
2678                let mut searcher =
2679                    GraphSearcher::new(graph, &rescore, &mut scratch).expect("property searcher");
2680                let outcome = searcher
2681                    .search_filtered(
2682                        GraphSearchRequest::new(&query, k, 0x19_0602).with_ef(12),
2683                        &effective,
2684                        usize::MAX,
2685                        None,
2686                    )
2687                    .expect("property filtered traversal");
2688                let FilteredGraphSearchOutcome::Traversed(result) = outcome else {
2689                    return Err(TestCaseError::fail("unbounded complete graph abandoned"));
2690                };
2691                prop_assert_eq!(result.candidates().len(), k);
2692                let all_valid = result.candidates().iter().all(|candidate| {
2693                    let row = candidate.row_id() as usize;
2694                    effective.contains(candidate.row_id())
2695                        && predicate_matches[row]
2696                        && alive_rows[row]
2697                });
2698                prop_assert!(all_valid);
2699                Ok(())
2700            },
2701        );
2702        assert!(property.is_ok(), "property result: {property:?}");
2703    }
2704
2705    #[test]
2706    fn vector_row_checkpoint_counts_only_filter_accepted_graph_rows() {
2707        let (encoded, rescore) = complete_graph_fixture(12);
2708        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2709        let query = vector(4.0);
2710        let request = GraphSearchRequest::new(&query, 1, 0x27_24)
2711            .with_ef(12)
2712            .with_candidate_trace();
2713        let mut trace_scratch =
2714            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2715                .expect("trace scratch");
2716        let mut trace =
2717            GraphSearcher::new(graph, &rescore, &mut trace_scratch).expect("trace searcher");
2718        let trace_result = trace.search(request, None).expect("trace traversal");
2719        let sequence = trace_result.candidate_sequence().expect("candidate trace");
2720        let first = *sequence.first().expect("first scored row");
2721        let accepted = *sequence
2722            .iter()
2723            .find(|&&row| row != first)
2724            .expect("later filter-accepted row");
2725        let allow_list = DocBitmap::from_ids([accepted]);
2726        let controller = crate::scan::vector_fault::VectorFaultController::armed(
2727            crate::scan::vector_fault::VectorFault::CancelAfterRows {
2728                source: crate::scan::vector_fault::VectorRowSource::Sealed([0x27; 16]),
2729                requested_rows: 1,
2730                tier: crate::scan::vector_fault::VectorSearchTier::Graph,
2731            },
2732            27_240,
2733        );
2734        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2735            .expect("fault scratch");
2736        let mut searcher = GraphSearcher::new(graph, &rescore, &mut scratch)
2737            .expect("fault searcher")
2738            .with_vector_fault_controller(
2739                &controller,
2740                crate::scan::vector_fault::VectorRowSource::Sealed([0x27; 16]),
2741                crate::scan::vector_fault::VectorSearchTier::Graph,
2742            );
2743        let error = searcher
2744            .search_filtered(request, &allow_list, usize::MAX, None)
2745            .expect_err("first filter-accepted row must cancel");
2746        assert_eq!(error, GraphSearchError::Cancelled { partial: false });
2747        controller.finalize_search(false);
2748        let receipts = controller.take_typed_receipts();
2749        assert_eq!(receipts.len(), 1);
2750        assert!(
2751            matches!(
2752                receipts[0].effect(),
2753                crate::scan::vector_fault::VectorFaultEffect::CancelledAfterRows {
2754                    local_row,
2755                    requested_rows: 1,
2756                    observed_rows: 1,
2757                    ..
2758                } if *local_row == accepted
2759            ),
2760            "filtered-out row {first} incorrectly consumed the eligible-row checkpoint: {:?}",
2761            receipts[0].effect()
2762        );
2763    }
2764
2765    #[test]
2766    fn traversal_expands_the_nearest_frontier_node_before_insertion_order() {
2767        let (encoded, rescore) = best_first_graph_fixture();
2768        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2769        let query = vector(0.0);
2770        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2771            .expect("fixture scratch");
2772        let mut searcher =
2773            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
2774
2775        let result = searcher
2776            .search(
2777                GraphSearchRequest::new(&query, 1, 0x19_04_bf)
2778                    .with_ef(4)
2779                    .with_observed_core_class(QueryCoreClass::Performance)
2780                    .with_candidate_trace(),
2781                None,
2782            )
2783            .expect("best-first traversal reaches the hidden nearest row");
2784
2785        assert_eq!(
2786            result
2787                .candidates()
2788                .iter()
2789                .map(|candidate| candidate.row_id())
2790                .collect::<Vec<_>>(),
2791            vec![8]
2792        );
2793        assert_eq!(result.candidate_sequence(), Some(&[0, 1, 2, 3, 8][..]));
2794        assert_eq!(result.counters().hops(), 4);
2795        assert_eq!(result.counters().pushes(), 5);
2796        assert_eq!(result.counters().core_class(), QueryCoreClass::Performance);
2797        assert_eq!(result.counters().candidates_rescored(), 4);
2798        assert!(result.counters().candidates_scored() < 9);
2799    }
2800
2801    #[test]
2802    fn visited_epoch_wraps_correctly_at_255_queries() {
2803        let (encoded, rescore) = complete_graph_fixture(12);
2804        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2805        let query = vector(4.25);
2806        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2807            .expect("fixture scratch");
2808        let mut searcher =
2809            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
2810
2811        for query_index in 0..255 {
2812            let result = searcher
2813                .search(
2814                    GraphSearchRequest::new(&query, 5, query_index).with_ef(12),
2815                    None,
2816                )
2817                .expect("epoch before wrap succeeds");
2818            assert!(!result.counters().visited_epoch_cleared());
2819        }
2820        let wrapped = searcher
2821            .search(GraphSearchRequest::new(&query, 5, 255).with_ef(12), None)
2822            .expect("first query after 255 epochs succeeds");
2823
2824        assert!(wrapped.counters().visited_epoch_cleared());
2825        assert_eq!(
2826            wrapped
2827                .candidates()
2828                .iter()
2829                .map(|candidate| candidate.row_id())
2830                .collect::<Vec<_>>(),
2831            brute_force_topk(&rescore, &query, 5)
2832        );
2833    }
2834
2835    #[test]
2836    fn no_node_is_scored_twice_within_one_query() {
2837        let (encoded, rescore) = complete_graph_fixture(12);
2838        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2839        let query = vector(4.25);
2840        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2841            .expect("fixture scratch");
2842        let mut searcher =
2843            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
2844
2845        let result = searcher
2846            .search(
2847                GraphSearchRequest::new(&query, 5, 0x19_04)
2848                    .with_ef(12)
2849                    .with_candidate_trace(),
2850                None,
2851            )
2852            .expect("traced traversal succeeds");
2853        let sequence = result
2854            .candidate_sequence()
2855            .expect("candidate tracing was requested");
2856        let mut seen = [false; 12];
2857        for row_id in sequence {
2858            let slot = &mut seen[*row_id as usize];
2859            assert!(!*slot, "row {row_id} was scored twice");
2860            *slot = true;
2861        }
2862
2863        assert_eq!(sequence.len(), result.counters().candidates_scored());
2864        assert_eq!(sequence.len(), result.counters().visited());
2865        assert_eq!(sequence, &(0_u32..12).collect::<Vec<_>>());
2866    }
2867
2868    #[test]
2869    fn traversal_is_deterministic_under_seed() {
2870        let (encoded, rescore) = complete_graph_fixture(12);
2871        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2872        let query = vector(4.25);
2873        let request = GraphSearchRequest::new(&query, 5, 0x19_0400)
2874            .with_ef(9)
2875            .with_candidate_trace();
2876        let mut first_scratch =
2877            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2878                .expect("first scratch");
2879        let mut second_scratch =
2880            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2881                .expect("second scratch");
2882        let mut first = GraphSearcher::new(graph, &rescore, &mut first_scratch)
2883            .expect("fixture geometry is valid");
2884        let mut second = GraphSearcher::new(graph, &rescore, &mut second_scratch)
2885            .expect("fixture geometry is valid");
2886
2887        let first_result = first
2888            .search(request, None)
2889            .expect("first traversal succeeds");
2890        let second_result = second
2891            .search(request, None)
2892            .expect("second traversal succeeds");
2893
2894        assert_eq!(
2895            first_result.candidate_sequence(),
2896            second_result.candidate_sequence()
2897        );
2898        assert_eq!(
2899            first_result.counters().deterministic_work(),
2900            second_result.counters().deterministic_work()
2901        );
2902        assert_eq!(first_result.candidates(), second_result.candidates());
2903    }
2904
2905    #[test]
2906    fn prefetch_switch_preserves_results_work_and_candidate_sequence() {
2907        let (encoded, rescore) = complete_graph_fixture(12);
2908        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2909        let query = vector(4.25);
2910        let mut enabled_scratch =
2911            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2912                .expect("enabled scratch");
2913        let mut disabled_scratch =
2914            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2915                .expect("disabled scratch");
2916        let mut enabled =
2917            GraphSearcher::new(graph, &rescore, &mut enabled_scratch).expect("enabled searcher");
2918        let mut disabled =
2919            GraphSearcher::new(graph, &rescore, &mut disabled_scratch).expect("disabled searcher");
2920        let enabled_result = enabled
2921            .search(
2922                GraphSearchRequest::new(&query, 5, 0x19_0400)
2923                    .with_ef(9)
2924                    .with_candidate_trace(),
2925                None,
2926            )
2927            .expect("prefetch-enabled traversal succeeds");
2928        let disabled_result = disabled
2929            .search(
2930                GraphSearchRequest::new(&query, 5, 0x19_0400)
2931                    .with_ef(9)
2932                    .with_candidate_trace()
2933                    .with_prefetch(TraversalPrefetch::Disabled),
2934                None,
2935            )
2936            .expect("prefetch-disabled traversal succeeds");
2937
2938        assert_eq!(
2939            enabled_result.candidate_sequence(),
2940            disabled_result.candidate_sequence()
2941        );
2942        assert_eq!(
2943            enabled_result.counters().deterministic_work(),
2944            disabled_result.counters().deterministic_work()
2945        );
2946        assert_eq!(enabled_result.candidates(), disabled_result.candidates());
2947    }
2948
2949    #[test]
2950    fn visited_cap_returns_a_typed_error_not_a_wrong_answer() {
2951        let (encoded, rescore) = chain_graph_fixture(10);
2952        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2953        let query = vector(9.0);
2954        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2955            .expect("fixture scratch");
2956        let mut searcher =
2957            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
2958
2959        let error = searcher
2960            .search(
2961                GraphSearchRequest::new(&query, 1, 0x19_0405).with_ef(1),
2962                None,
2963            )
2964            .expect_err("the visited guard must fail closed");
2965
2966        assert_eq!(
2967            error,
2968            super::GraphSearchError::VisitedCapExceeded { visited: 5, cap: 4 }
2969        );
2970    }
2971
2972    #[test]
2973    fn filtered_budget_and_fail_closed_visited_cap_fire_independently() {
2974        let (encoded, rescore) = chain_graph_fixture(10);
2975        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
2976        let query = vector(9.0);
2977        let allowed = DocBitmap::full(graph.node_count());
2978        let request = GraphSearchRequest::new(&query, 1, 0x19_0605).with_ef(1);
2979
2980        let mut budget_scratch =
2981            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2982                .expect("budget scratch");
2983        let mut budget_searcher =
2984            GraphSearcher::new(graph, &rescore, &mut budget_scratch).expect("budget searcher");
2985        let budget_outcome = budget_searcher
2986            .search_filtered(request, &allowed, 2, None)
2987            .expect("selectivity budget is a normal filtered disposition");
2988        assert!(matches!(
2989            budget_outcome,
2990            FilteredGraphSearchOutcome::VisitedBudgetExceeded {
2991                visited: 3,
2992                budget: 2,
2993                ..
2994            }
2995        ));
2996
2997        let mut invariant_scratch =
2998            GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
2999                .expect("invariant scratch");
3000        let mut invariant_searcher = GraphSearcher::new(graph, &rescore, &mut invariant_scratch)
3001            .expect("invariant searcher");
3002        let error = invariant_searcher
3003            .search_filtered(request, &allowed, usize::MAX, None)
3004            .expect_err("removing the selectivity budget must expose the invariant guard");
3005        assert_eq!(
3006            error,
3007            GraphSearchError::VisitedCapExceeded { visited: 5, cap: 4 }
3008        );
3009    }
3010
3011    #[test]
3012    fn cancellation_is_visible_across_threads_during_traversal() {
3013        let (encoded, rescore) = long_chain_graph_fixture(40_000);
3014        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
3015        let query = vec![0.0_f32; DIMS];
3016        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
3017            .expect("fixture scratch");
3018        let mut searcher =
3019            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
3020        let directory = tempfile::tempdir().expect("temporary store directory");
3021        let store = Store::open(directory.path(), OpenOptions::default()).expect("store opens");
3022        let lease = store.snapshot().expect("snapshot lease");
3023        let token = CancelToken::new();
3024        let control = QueryControl::Cancel(token.clone());
3025        let cancellation = QueryCancellation::new(&control, &lease);
3026        let error = std::thread::scope(|scope| {
3027            let canceller = scope.spawn(|| {
3028                std::thread::sleep(Duration::from_millis(2));
3029                token.cancel();
3030            });
3031            let result = searcher.search(
3032                GraphSearchRequest::new(&query, 1, 0x19_0406).with_ef(200),
3033                Some(&cancellation),
3034            );
3035            canceller.join().expect("canceller thread joins");
3036            result
3037        })
3038        .expect_err("in-flight traversal must stop");
3039
3040        assert_eq!(error, super::GraphSearchError::Cancelled { partial: false });
3041    }
3042
3043    #[test]
3044    fn pre_cancelled_token_is_rejected_at_search_entry() {
3045        let (encoded, rescore) = long_chain_graph_fixture(16);
3046        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
3047        let query = vec![0.0_f32; DIMS];
3048        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
3049            .expect("fixture scratch");
3050        let mut searcher =
3051            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
3052        let directory = tempfile::tempdir().expect("temporary store directory");
3053        let store = Store::open(directory.path(), OpenOptions::default()).expect("store opens");
3054        let lease = store.snapshot().expect("snapshot lease");
3055        let token = CancelToken::new();
3056        token.cancel();
3057        let control = QueryControl::Cancel(token);
3058        let cancellation = QueryCancellation::new(&control, &lease);
3059
3060        let error = searcher
3061            .search(
3062                GraphSearchRequest::new(&query, 1, 0x19_0406).with_ef(16),
3063                Some(&cancellation),
3064            )
3065            .expect_err("pre-cancelled search must stop at entry");
3066
3067        assert_eq!(error, GraphSearchError::Cancelled { partial: false });
3068    }
3069
3070    #[test]
3071    fn counting_token_cancels_after_exact_number_of_hops() {
3072        let (encoded, rescore) = long_chain_graph_fixture(40_000);
3073        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
3074        let query = vec![0.0_f32; DIMS];
3075        let mut scratch = GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
3076            .expect("fixture scratch");
3077        let mut searcher =
3078            GraphSearcher::new(graph, &rescore, &mut scratch).expect("fixture geometry is valid");
3079        let directory = tempfile::tempdir().expect("temporary store directory");
3080        let store = Store::open(directory.path(), OpenOptions::default()).expect("store opens");
3081        let lease = store.snapshot().expect("snapshot lease");
3082        let token = CancelToken::new();
3083        let observed_hops = searcher.cancel_after_hops(7, token.clone());
3084        let control = QueryControl::Cancel(token);
3085        let cancellation = QueryCancellation::new(&control, &lease);
3086
3087        let error = searcher
3088            .search(
3089                GraphSearchRequest::new(&query, 1, 0x19_0406).with_ef(200),
3090                Some(&cancellation),
3091            )
3092            .expect_err("counting token must cancel during traversal");
3093
3094        assert_eq!(error, GraphSearchError::Cancelled { partial: false });
3095        assert_eq!(observed_hops.get(), 7);
3096    }
3097
3098    #[test]
3099    fn recall_is_monotonic_in_ef() {
3100        let (encoded, rescore) = complete_graph_fixture(12);
3101        let graph = decode_node_blocks(encoded.as_bytes()).expect("fixture graph is valid");
3102        let mut seeded =
3103            crate::test_support::seeded_rng("graph::search::tests::recall_is_monotonic_in_ef");
3104        let mut runner = TestRunner::new(Config {
3105            cases: 64,
3106            rng_seed: RngSeed::Fixed(seeded.next_u64()),
3107            ..Config::default()
3108        });
3109        let result = runner.run(&(5_usize..12, 1_usize..8, -1.0_f32..13.0), |input| {
3110            let (ef1, delta, query_value) = input;
3111            let ef2 = ef1.saturating_add(delta).min(12);
3112            prop_assume!(ef1 < ef2);
3113            let query = vector(query_value);
3114            let truth = brute_force_topk(&rescore, &query, 5);
3115            let mut first_scratch =
3116                GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
3117                    .expect("first scratch");
3118            let mut second_scratch =
3119                GraphSearchScratch::new(graph.node_count(), graph.layout().max_degree())
3120                    .expect("second scratch");
3121            let mut first =
3122                GraphSearcher::new(graph, &rescore, &mut first_scratch).expect("first searcher");
3123            let mut second =
3124                GraphSearcher::new(graph, &rescore, &mut second_scratch).expect("second searcher");
3125            let result1 = first
3126                .search(
3127                    GraphSearchRequest::new(&query, 5, 0x19_0408).with_ef(ef1),
3128                    None,
3129                )
3130                .expect("ef1 traversal");
3131            let result2 = second
3132                .search(
3133                    GraphSearchRequest::new(&query, 5, 0x19_0408).with_ef(ef2),
3134                    None,
3135                )
3136                .expect("ef2 traversal");
3137            prop_assert!(result2.recall_against(&truth) >= result1.recall_against(&truth));
3138            Ok(())
3139        });
3140
3141        assert!(result.is_ok(), "property result: {result:?}");
3142    }
3143
3144    fn complete_graph_fixture(rows: usize) -> (crate::graph::block::EncodedNodeBlocks, Vec<f32>) {
3145        let layout = GraphNodeLayout::new(DIMS as u32, DIMS as u32, (rows - 1) as u8)
3146            .expect("fixture layout is valid");
3147        let rescore = (0..rows)
3148            .flat_map(|row| vector(row as f32))
3149            .collect::<Vec<_>>();
3150        let mut codes = vec![0_u8; rows * DIMS.div_ceil(2)];
3151        let mut factors = Vec::with_capacity(rows);
3152        for (row, destination) in rescore
3153            .chunks_exact(DIMS)
3154            .zip(codes.chunks_exact_mut(DIMS.div_ceil(2)))
3155        {
3156            factors.push(quantize_bit4(row, destination).expect("fixture row is finite"));
3157        }
3158        let neighbors = (0..rows)
3159            .map(|row| {
3160                (0..rows)
3161                    .filter(|candidate| *candidate != row)
3162                    .map(|candidate| candidate as u32)
3163                    .collect::<Vec<_>>()
3164            })
3165            .collect::<Vec<_>>();
3166        let nodes = (0..rows)
3167            .map(|row| GraphNodeBlockInput {
3168                codes: &codes[row * DIMS.div_ceil(2)..(row + 1) * DIMS.div_ceil(2)],
3169                factors: factors[row],
3170                flags: u8::from(row < 4),
3171                neighbors: &neighbors[row],
3172            })
3173            .collect::<Vec<_>>();
3174        let encoded = encode_node_blocks(GraphNodeBlockBuild {
3175            layout,
3176            nodes: &nodes,
3177        })
3178        .expect("fixture graph encodes");
3179        (encoded, rescore)
3180    }
3181
3182    fn chain_graph_fixture(rows: usize) -> (crate::graph::block::EncodedNodeBlocks, Vec<f32>) {
3183        let layout =
3184            GraphNodeLayout::new(DIMS as u32, DIMS as u32, 1).expect("fixture layout is valid");
3185        let rescore = (0..rows)
3186            .flat_map(|row| vector(row as f32))
3187            .collect::<Vec<_>>();
3188        let mut codes = vec![0_u8; rows * DIMS.div_ceil(2)];
3189        let mut factors = Vec::with_capacity(rows);
3190        for (row, destination) in rescore
3191            .chunks_exact(DIMS)
3192            .zip(codes.chunks_exact_mut(DIMS.div_ceil(2)))
3193        {
3194            factors.push(quantize_bit4(row, destination).expect("fixture row is finite"));
3195        }
3196        let neighbors = (0..rows)
3197            .map(|row| {
3198                (row + 1 < rows)
3199                    .then_some((row + 1) as u32)
3200                    .into_iter()
3201                    .collect::<Vec<_>>()
3202            })
3203            .collect::<Vec<_>>();
3204        let nodes = (0..rows)
3205            .map(|row| GraphNodeBlockInput {
3206                codes: &codes[row * DIMS.div_ceil(2)..(row + 1) * DIMS.div_ceil(2)],
3207                factors: factors[row],
3208                flags: u8::from(row < 4),
3209                neighbors: &neighbors[row],
3210            })
3211            .collect::<Vec<_>>();
3212        let encoded = encode_node_blocks(GraphNodeBlockBuild {
3213            layout,
3214            nodes: &nodes,
3215        })
3216        .expect("fixture graph encodes");
3217        (encoded, rescore)
3218    }
3219
3220    fn best_first_graph_fixture() -> (crate::graph::block::EncodedNodeBlocks, Vec<f32>) {
3221        let values = [10.0_f32, 9.0, 8.0, 1.0, 7.0, 6.0, 5.0, 4.0, 0.0];
3222        let layout =
3223            GraphNodeLayout::new(DIMS as u32, DIMS as u32, 4).expect("best-first layout is valid");
3224        let rescore = values.into_iter().flat_map(vector).collect::<Vec<_>>();
3225        let mut codes = vec![0_u8; values.len() * DIMS.div_ceil(2)];
3226        let factors = rescore
3227            .chunks_exact(DIMS)
3228            .zip(codes.chunks_exact_mut(DIMS.div_ceil(2)))
3229            .map(|(row, destination)| {
3230                quantize_bit4(row, destination).expect("best-first row is finite")
3231            })
3232            .collect::<Vec<_>>();
3233        let neighbors = [
3234            vec![4, 5, 6, 7],
3235            Vec::new(),
3236            Vec::new(),
3237            vec![8],
3238            Vec::new(),
3239            Vec::new(),
3240            Vec::new(),
3241            Vec::new(),
3242            Vec::new(),
3243        ];
3244        let nodes = (0..values.len())
3245            .map(|row| GraphNodeBlockInput {
3246                codes: &codes[row * DIMS.div_ceil(2)..(row + 1) * DIMS.div_ceil(2)],
3247                factors: factors[row],
3248                flags: u8::from(row < 4),
3249                neighbors: &neighbors[row],
3250            })
3251            .collect::<Vec<_>>();
3252        let encoded = encode_node_blocks(GraphNodeBlockBuild {
3253            layout,
3254            nodes: &nodes,
3255        })
3256        .expect("best-first graph encodes");
3257        (encoded, rescore)
3258    }
3259
3260    fn factor_graph_fixture(
3261        factors: Bit4Factors,
3262        flags: u8,
3263    ) -> crate::graph::block::EncodedNodeBlocks {
3264        let layout = GraphNodeLayout::new(DIMS as u32, DIMS as u32, 0)
3265            .expect("factor graph layout is valid");
3266        let codes = vec![0_u8; DIMS.div_ceil(2)];
3267        let node = GraphNodeBlockInput {
3268            codes: &codes,
3269            factors,
3270            flags,
3271            neighbors: &[],
3272        };
3273        encode_node_blocks(GraphNodeBlockBuild {
3274            layout,
3275            nodes: &[node],
3276        })
3277        .expect("factor graph encodes")
3278    }
3279
3280    fn long_chain_graph_fixture(rows: usize) -> (crate::graph::block::EncodedNodeBlocks, Vec<f32>) {
3281        let layout =
3282            GraphNodeLayout::new(DIMS as u32, DIMS as u32, 44).expect("fixture layout is valid");
3283        let codes = vec![0_u8; rows * DIMS.div_ceil(2)];
3284        let factors = (0..rows)
3285            .map(|row| Bit4Factors::from_persisted(1.0, (rows - row) as f32, 0.0))
3286            .collect::<Vec<_>>();
3287        let neighbors = (0..rows)
3288            .map(|row| {
3289                (row + 1 < rows)
3290                    .then_some((row + 1) as u32)
3291                    .into_iter()
3292                    .collect::<Vec<_>>()
3293            })
3294            .collect::<Vec<_>>();
3295        let nodes = (0..rows)
3296            .map(|row| GraphNodeBlockInput {
3297                codes: &codes[row * DIMS.div_ceil(2)..(row + 1) * DIMS.div_ceil(2)],
3298                factors: factors[row],
3299                flags: u8::from(row < 4),
3300                neighbors: &neighbors[row],
3301            })
3302            .collect::<Vec<_>>();
3303        let encoded = encode_node_blocks(GraphNodeBlockBuild {
3304            layout,
3305            nodes: &nodes,
3306        })
3307        .expect("fixture graph encodes");
3308        (encoded, vec![0.0_f32; rows * DIMS])
3309    }
3310
3311    fn vector(value: f32) -> Vec<f32> {
3312        let mut vector = vec![0.0_f32; DIMS];
3313        vector[0] = value;
3314        vector[1] = value * value * 0.1;
3315        vector[17] = value.sin();
3316        vector
3317    }
3318
3319    fn brute_force_topk(base: &[f32], query: &[f32], k: usize) -> Vec<u32> {
3320        let mut scored = base
3321            .chunks_exact(DIMS)
3322            .enumerate()
3323            .map(|(row_id, row)| {
3324                let distance = row
3325                    .iter()
3326                    .zip(query)
3327                    .map(|(left, right)| {
3328                        let delta = f64::from(*left) - f64::from(*right);
3329                        delta * delta
3330                    })
3331                    .sum::<f64>();
3332                (distance, row_id as u32)
3333            })
3334            .collect::<Vec<_>>();
3335        scored.sort_unstable_by(|left, right| {
3336            left.0
3337                .total_cmp(&right.0)
3338                .then_with(|| left.1.cmp(&right.1))
3339        });
3340        scored
3341            .into_iter()
3342            .take(k)
3343            .map(|(_, row_id)| row_id)
3344            .collect()
3345    }
3346}