Skip to main content

writerslogic_warm/core/qgraph/
projection.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Query-neighborhood projection, following the graph concept in RoarGraph
4//! (Chen et al., 2024, <https://arxiv.org/abs/2408.08933>).
5
6use super::{invalid, Adjacency, BuildParams, Graph, Metric, Rows};
7use crate::core::error::WarmError;
8use nalgebra::DMatrix;
9use rayon::prelude::*;
10use std::cmp::Ordering;
11use std::collections::BinaryHeap;
12
13const TRAINING_LIMIT: usize = 32768;
14const QUERY_BLOCK: usize = 32;
15
16#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
17pub struct ProjectionStats {
18    pub training_queries: usize,
19    pub neighbors_per_query: usize,
20    pub projected_degree: usize,
21    pub original_edges: usize,
22    pub forward_edges: usize,
23    pub reverse_edges: usize,
24    pub added_edges: usize,
25    pub total_edges: usize,
26    pub mean_degree: f64,
27    pub max_degree: usize,
28    pub degree_capacity: usize,
29}
30
31fn allocation<T>(count: usize) -> Result<(), WarmError> {
32    if count
33        .checked_mul(std::mem::size_of::<T>())
34        .is_none_or(|bytes| bytes > isize::MAX as usize)
35    {
36        return Err(invalid("projection allocation size overflows".into()));
37    }
38    Ok(())
39}
40
41fn reserved<T>(count: usize) -> Result<Vec<T>, WarmError> {
42    allocation::<T>(count)?;
43    let mut values = Vec::new();
44    values
45        .try_reserve_exact(count)
46        .map_err(|_| invalid("cannot allocate projection scratch".into()))?;
47    Ok(values)
48}
49
50fn lists(count: usize) -> Result<Vec<Vec<u32>>, WarmError> {
51    let mut values = reserved(count)?;
52    values.resize_with(count, Vec::new);
53    Ok(values)
54}
55
56fn add_edges(total: usize, edges: usize) -> Result<usize, WarmError> {
57    total
58        .checked_add(edges)
59        .ok_or_else(|| invalid("projection edge count overflows".into()))
60}
61
62fn select(
63    rows: &Rows<'_>,
64    vertex: u32,
65    candidates: &mut Vec<u32>,
66    existing: &[u32],
67    additional: &[u32],
68    degree: usize,
69    alpha: f32,
70) -> Result<Vec<u32>, WarmError> {
71    candidates.sort_unstable();
72    candidates.dedup();
73    candidates.retain(|&id| id != vertex && !existing.contains(&id) && !additional.contains(&id));
74    let mut scored = reserved(candidates.len())?;
75    for &id in candidates.iter() {
76        let distance = rows.d2(vertex, id);
77        if !distance.is_finite() {
78            return Err(invalid("projection base distance is nonfinite".into()));
79        }
80        scored.push((distance, id));
81    }
82    scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
83    let limit = degree.min(scored.len());
84    let mut prunable = reserved(scored.len())?;
85    prunable.extend_from_slice(&scored);
86    let mut selected = rows.prune(vertex, prunable, alpha, limit);
87    for &(_, id) in &scored {
88        if selected.len() == limit {
89            break;
90        }
91        if !selected.contains(&id) {
92            selected.push(id);
93        }
94    }
95    Ok(selected)
96}
97
98pub(super) fn validate_queries(
99    queries: &[f32],
100    dim: usize,
101    metric: Metric,
102) -> Result<(), WarmError> {
103    if dim == 0 || queries.is_empty() || !queries.len().is_multiple_of(dim) {
104        return Err(invalid(
105            "training queries must be nonempty rows of graph dimension".into(),
106        ));
107    }
108    if queries.iter().any(|value| !value.is_finite()) {
109        return Err(invalid("training queries must be finite".into()));
110    }
111    if metric == Metric::Angular
112        && queries
113            .chunks_exact(dim)
114            .any(|row| row.iter().all(|&value| value == 0.0))
115    {
116        return Err(invalid("angular training queries must be nonzero".into()));
117    }
118    Ok(())
119}
120
121#[derive(Clone, Copy, Debug)]
122struct Nearest(f64, u32);
123
124impl PartialEq for Nearest {
125    fn eq(&self, other: &Self) -> bool {
126        self.cmp(other) == Ordering::Equal
127    }
128}
129
130impl Eq for Nearest {}
131
132impl PartialOrd for Nearest {
133    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
134        Some(self.cmp(other))
135    }
136}
137
138impl Ord for Nearest {
139    fn cmp(&self, other: &Self) -> Ordering {
140        self.0.total_cmp(&other.0).then(self.1.cmp(&other.1))
141    }
142}
143
144impl Graph {
145    /// Builds a graph with optional construction queries in the graph's input coordinates.
146    /// Queries must be independent of evaluation data. The first 32768 rows guide additional
147    /// projected edges; all supplied rows are validated. `None` preserves the legacy build.
148    /// MIPS callers must augment base rows and append zero to queries before this call.
149    pub fn build_with_training_queries(
150        data: &[f32],
151        dim: usize,
152        params: &BuildParams,
153        training_queries: Option<&[f32]>,
154    ) -> Result<Self, WarmError> {
155        let Some(queries) = training_queries else {
156            return Self::build(data, dim, params);
157        };
158        validate_queries(queries, dim, params.metric)?;
159        let mut graph = Self::build(data, dim, params)?;
160        graph.apply_training_queries(queries)?;
161        Ok(graph)
162    }
163
164    /// Owned-data equivalent of [`Graph::build_with_training_queries`].
165    pub fn build_owned_with_training_queries(
166        data: Vec<f32>,
167        dim: usize,
168        params: &BuildParams,
169        training_queries: Option<&[f32]>,
170    ) -> Result<Self, WarmError> {
171        let Some(queries) = training_queries else {
172            return Self::build_owned(data, dim, params);
173        };
174        validate_queries(queries, dim, params.metric)?;
175        let mut graph = Self::build_owned(data, dim, params)?;
176        graph.apply_training_queries(queries)?;
177        Ok(graph)
178    }
179
180    pub(super) fn apply_training_queries(&mut self, queries: &[f32]) -> Result<(), WarmError> {
181        self.apply_training_queries_with(queries, TRAINING_LIMIT, 100, 32)
182            .map(|_| ())
183    }
184
185    /// Applies the first `limit` construction queries, with the exact neighbours scored
186    /// per query and the projected degree
187    /// as arguments; each is capped by the graph size. The neighbours are found by exact
188    /// scoring of every base row, so the cost grows with `limit * n`.
189    pub fn apply_training_queries_with(
190        &mut self,
191        queries: &[f32],
192        limit: usize,
193        neighbors: usize,
194        projected_degree: usize,
195    ) -> Result<ProjectionStats, WarmError> {
196        validate_queries(queries, self.dim, self.metric)?;
197        if self.n == 1 {
198            return self.project_neighborhoods(None, &[], 0, 0);
199        }
200        let count = (queries.len() / self.dim).min(limit.max(1));
201        let queries = &queries[..count * self.dim];
202        let neighbors = self.n.min(neighbors.max(2));
203        let ids = self.training_neighbors(queries, neighbors)?;
204        self.project_neighborhoods(
205            Some(queries),
206            &ids,
207            neighbors,
208            (self.n - 1).min(projected_degree.max(1)),
209        )
210    }
211
212    /// Projects neighbour lists the caller found for construction queries (for example by an
213    /// approximate search of an index encoded from this graph), `neighbors_per_query` ids per
214    /// query, nearest first. `queries` are in graph coordinates and only validated; the
215    /// lists must come from construction queries, never evaluation data.
216    pub fn project_training_neighbors(
217        &mut self,
218        queries: &[f32],
219        query_neighbor_ids: &[u32],
220        neighbors_per_query: usize,
221        projected_degree: usize,
222    ) -> Result<ProjectionStats, WarmError> {
223        validate_queries(queries, self.dim, self.metric)?;
224        self.project_neighborhoods(
225            Some(queries),
226            query_neighbor_ids,
227            neighbors_per_query,
228            projected_degree,
229        )
230    }
231
232    fn training_neighbors(&self, queries: &[f32], neighbors: usize) -> Result<Vec<u32>, WarmError> {
233        let count = queries.len() / self.dim;
234        let base_len = self
235            .n
236            .checked_mul(self.dim)
237            .ok_or_else(|| invalid("training base matrix size overflows".into()))?;
238        let mut norms: Vec<f64> = reserved(self.n)?;
239        for row in self.vectors.chunks_exact(self.dim) {
240            norms.push(row.iter().map(|&value| f64::from(value).powi(2)).sum());
241        }
242        // DMatrix is column-major: columns hold one coordinate across all base rows, so
243        // gemm uses matrixmultiply rather than the scalar transpose-multiply path.
244        let mut base = reserved(base_len)?;
245        for coordinate in 0..self.dim {
246            for (vertex, &norm) in norms.iter().enumerate() {
247                let value = f64::from(self.vectors[vertex * self.dim + coordinate]);
248                base.push(if self.metric == Metric::Angular && norm > 0.0 {
249                    value / norm.sqrt()
250                } else {
251                    value
252                });
253            }
254        }
255        let base = DMatrix::from_vec(self.n, self.dim, base);
256        let width = count.min(QUERY_BLOCK);
257        let query_len = self
258            .dim
259            .checked_mul(width)
260            .ok_or_else(|| invalid("training query block size overflows".into()))?;
261        let product_len = self
262            .n
263            .checked_mul(width)
264            .ok_or_else(|| invalid("training product block size overflows".into()))?;
265        let result_len = count
266            .checked_mul(neighbors)
267            .ok_or_else(|| invalid("training neighborhood size overflows".into()))?;
268        let mut query_values = reserved(query_len)?;
269        query_values.resize(query_len, 0.0f64);
270        let mut query_block = DMatrix::from_vec(self.dim, width, query_values);
271        let mut product_values = reserved(product_len)?;
272        product_values.resize(product_len, 0.0f64);
273        let mut products = DMatrix::from_vec(self.n, width, product_values);
274        let mut result = reserved(result_len)?;
275        let mut best = BinaryHeap::new();
276        best.try_reserve(neighbors)
277            .map_err(|_| invalid("cannot allocate training neighbor heap".into()))?;
278        let mut ordered: Vec<Nearest> = reserved(neighbors)?;
279        for block in queries.chunks(query_len) {
280            query_block.as_mut_slice().fill(0.0);
281            for (value, &input) in query_block.as_mut_slice().iter_mut().zip(block) {
282                *value = f64::from(input);
283            }
284            products.gemm(1.0, &base, &query_block, 0.0);
285            for column in 0..block.len() / self.dim {
286                best.clear();
287                for vertex in 0..self.n {
288                    let product = products[(vertex, column)];
289                    let score = match self.metric {
290                        Metric::Angular => -product,
291                        Metric::Euclidean => norms[vertex] - 2.0 * product,
292                    };
293                    if !score.is_finite() {
294                        return Err(invalid("training neighbor score is nonfinite".into()));
295                    }
296                    let candidate = Nearest(if score == 0.0 { 0.0 } else { score }, vertex as u32);
297                    if best.len() < neighbors {
298                        best.push(candidate);
299                    } else if candidate < *best.peek().expect("nonempty training neighbor heap") {
300                        *best.peek_mut().expect("nonempty training neighbor heap") = candidate;
301                    }
302                }
303                ordered.clear();
304                ordered.extend(best.drain());
305                ordered.sort_unstable();
306                result.extend(ordered.iter().map(|candidate| candidate.1));
307            }
308        }
309        Ok(result)
310    }
311
312    /// Projects supplied construction-query neighborhoods into additional base-layer edges.
313    /// Queries use graph coordinates; callers must transform original MIPS queries first.
314    /// Neighbor lists must come exclusively from construction queries, never evaluation data.
315    /// Each neighbor row starts with its nearest base ID, which becomes the projection pivot.
316    /// Every old edge is retained, with separate forward and reverse degree quotas.
317    /// Increased capacity invalidates the original build's cache key; persist a VGraph.
318    pub(super) fn project_neighborhoods(
319        &mut self,
320        training_queries: Option<&[f32]>,
321        query_neighbor_ids: &[u32],
322        neighbors_per_query: usize,
323        projected_degree: usize,
324    ) -> Result<ProjectionStats, WarmError> {
325        let original_edges = self
326            .adj
327            .iter()
328            .try_fold(0usize, |sum, ids| add_edges(sum, ids.len()))?;
329        let mut stats = ProjectionStats {
330            original_edges,
331            total_edges: original_edges,
332            mean_degree: original_edges as f64 / self.n as f64,
333            max_degree: self.adj.iter().map(<[u32]>::len).max().unwrap_or(0),
334            degree_capacity: self.degree,
335            ..ProjectionStats::default()
336        };
337        let Some(queries) = training_queries else {
338            return Ok(stats);
339        };
340        if self.dim == 0 || queries.is_empty() || !queries.len().is_multiple_of(self.dim) {
341            return Err(invalid(
342                "projection queries must be nonempty rows of graph dimension".into(),
343            ));
344        }
345        if queries.iter().any(|value| !value.is_finite()) {
346            return Err(invalid("projection queries must be finite".into()));
347        }
348        if !(2..=self.n).contains(&neighbors_per_query)
349            || projected_degree == 0
350            || projected_degree >= self.n
351        {
352            return Err(invalid(
353                "projection requires 2 <= neighbors_per_query <= n and 1 <= projected_degree < n"
354                    .into(),
355            ));
356        }
357        let query_count = queries.len() / self.dim;
358        let neighbor_count = query_count
359            .checked_mul(neighbors_per_query)
360            .ok_or_else(|| invalid("projection neighbor count overflows".into()))?;
361        if query_neighbor_ids.len() != neighbor_count {
362            return Err(invalid(
363                "projection neighbor matrix has the wrong length".into(),
364            ));
365        }
366        allocation::<u32>(neighbor_count)?;
367        let degree_capacity = projected_degree
368            .checked_mul(2)
369            .and_then(|extra| self.degree.checked_add(extra))
370            .filter(|&degree| degree < u32::MAX as usize)
371            .ok_or_else(|| invalid("projection degree capacity overflows".into()))?;
372        let slots = self
373            .n
374            .checked_mul(degree_capacity)
375            .ok_or_else(|| invalid("projection adjacency size overflows".into()))?;
376        allocation::<u32>(slots)?;
377        allocation::<u32>(self.n)?;
378
379        let mut ordered = reserved(neighbors_per_query)?;
380        for ids in query_neighbor_ids.chunks_exact(neighbors_per_query) {
381            if ids.iter().any(|&id| id as usize >= self.n) {
382                return Err(invalid(
383                    "projection neighbor ID is outside the graph".into(),
384                ));
385            }
386            ordered.clear();
387            ordered.extend_from_slice(ids);
388            ordered.sort_unstable();
389            if ordered.windows(2).any(|pair| pair[0] == pair[1]) {
390                return Err(invalid(
391                    "projection neighbor IDs must be unique in every row".into(),
392                ));
393            }
394        }
395
396        let mut candidates = lists(self.n)?;
397        for ids in query_neighbor_ids.chunks_exact(neighbors_per_query) {
398            let pooled = &mut candidates[ids[0] as usize];
399            pooled
400                .try_reserve(ids.len() - 1)
401                .map_err(|_| invalid("cannot allocate projection candidates".into()))?;
402            pooled.extend_from_slice(&ids[1..]);
403        }
404        let rows = self.rows();
405        let (adj, alpha) = (&self.adj, self.build.alpha);
406        // Every vertex selects from its own pool, so the passes run in parallel; the ordered
407        // collect keeps the result independent of the thread count.
408        let forward = candidates
409            .par_iter_mut()
410            .enumerate()
411            .map(|(vertex, pooled)| {
412                select(
413                    &rows,
414                    vertex as u32,
415                    pooled,
416                    adj.get(vertex),
417                    &[],
418                    projected_degree,
419                    alpha,
420                )
421            })
422            .collect::<Result<Vec<_>, WarmError>>()?;
423        let mut reverse_candidates = lists(self.n)?;
424        for (vertex, outgoing) in forward.iter().enumerate() {
425            stats.forward_edges = add_edges(stats.forward_edges, outgoing.len())?;
426            for &neighbor in outgoing {
427                let incoming = &mut reverse_candidates[neighbor as usize];
428                incoming
429                    .try_reserve(1)
430                    .map_err(|_| invalid("cannot allocate projection reverse candidates".into()))?;
431                incoming.push(vertex as u32);
432            }
433        }
434        drop(candidates);
435        let reverse = reverse_candidates
436            .par_iter_mut()
437            .zip(forward.par_iter())
438            .enumerate()
439            .map(|(vertex, (incoming, outgoing))| {
440                select(
441                    &rows,
442                    vertex as u32,
443                    incoming,
444                    adj.get(vertex),
445                    outgoing,
446                    projected_degree,
447                    alpha,
448                )
449            })
450            .collect::<Result<Vec<_>, WarmError>>()?;
451        drop(reverse_candidates);
452        let mut replacement = Adjacency::new(self.n, degree_capacity);
453        let mut combined = reserved(degree_capacity)?;
454        stats.max_degree = 0;
455        for (vertex, reverse) in reverse.iter().enumerate() {
456            stats.reverse_edges = add_edges(stats.reverse_edges, reverse.len())?;
457            combined.clear();
458            combined.extend_from_slice(self.adj.get(vertex));
459            combined.extend_from_slice(&forward[vertex]);
460            combined.extend_from_slice(reverse);
461            stats.max_degree = stats.max_degree.max(combined.len());
462            replacement.set(vertex, &combined);
463        }
464        stats.added_edges = add_edges(stats.forward_edges, stats.reverse_edges)?;
465        stats.total_edges = add_edges(original_edges, stats.added_edges)?;
466        stats.training_queries = query_count;
467        stats.neighbors_per_query = neighbors_per_query;
468        stats.projected_degree = projected_degree;
469        stats.mean_degree = stats.total_edges as f64 / self.n as f64;
470        stats.degree_capacity = degree_capacity;
471        self.adj = replacement;
472        self.degree = degree_capacity;
473        Ok(stats)
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::core::qgraph::{Residency, SearchKnobs, VGraph, VSearchParams};
481
482    fn scalar_neighbors(graph: &Graph, queries: &[f32], k: usize) -> Vec<u32> {
483        let mut result = Vec::new();
484        for query in queries.chunks_exact(graph.dim) {
485            let mut scored = Vec::new();
486            for (id, row) in graph.vectors.chunks_exact(graph.dim).enumerate() {
487                let score = if graph.metric == Metric::Euclidean {
488                    row.iter()
489                        .zip(query)
490                        .map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2))
491                        .sum::<f64>()
492                } else {
493                    let norm = row
494                        .iter()
495                        .map(|&value| f64::from(value).powi(2))
496                        .sum::<f64>()
497                        .sqrt();
498                    if norm == 0.0 {
499                        0.0
500                    } else {
501                        -row.iter()
502                            .zip(query)
503                            .map(|(&a, &b)| f64::from(a) * f64::from(b))
504                            .sum::<f64>()
505                            / norm
506                    }
507                };
508                scored.push(Nearest(if score == 0.0 { 0.0 } else { score }, id as u32));
509            }
510            scored.sort_unstable();
511            result.extend(scored.iter().take(k).map(|candidate| candidate.1));
512        }
513        result
514    }
515
516    fn graph() -> Graph {
517        let vectors = vec![
518            -10.0, 0.0, -11.0, 0.0, -12.0, 0.0, 10.0, 0.0, 11.0, 0.0, 12.0, 0.0,
519        ];
520        let mut adj = Adjacency::new(6, 2);
521        for (vertex, next) in [1, 2, 0, 4, 5, 3].into_iter().enumerate() {
522            adj.set(vertex, &[next]);
523        }
524        Graph {
525            n: 6,
526            dim: 2,
527            degree: 2,
528            metric: Metric::Euclidean,
529            norms: vectors
530                .as_chunks::<2>()
531                .0
532                .iter()
533                .map(|row| row[0] * row[0])
534                .collect(),
535            vectors,
536            adj,
537            entry: 0,
538            upper_ids: vec![0, 3],
539            layers: vec![vec![vec![1], vec![0]]],
540            build: BuildParams {
541                metric: Metric::Euclidean,
542                degree: 2,
543                ..BuildParams::default()
544            },
545        }
546    }
547
548    fn build_params(metric: Metric) -> BuildParams {
549        BuildParams {
550            metric,
551            degree: 2,
552            build_ef: 8,
553            ..Default::default()
554        }
555    }
556
557    #[test]
558    fn absent_queries_preserve_serialized_bytes_even_with_unused_arguments() {
559        let directory = tempfile::tempdir().unwrap();
560        let before = directory.path().join("before");
561        let after = directory.path().join("after");
562        let mut graph = graph();
563        graph.save(&before).unwrap();
564        let stats = graph
565            .project_neighborhoods(None, &[u32::MAX], usize::MAX, usize::MAX)
566            .unwrap();
567        graph.save(&after).unwrap();
568        assert_eq!(
569            std::fs::read(before).unwrap(),
570            std::fs::read(after).unwrap()
571        );
572        assert_eq!(stats.added_edges, 0);
573        assert_eq!(stats.degree_capacity, 2);
574    }
575
576    #[test]
577    fn query_neighbor_bridge_preserves_old_edges_and_upper_layers() {
578        let mut graph = graph();
579        let old = graph.adj.to_lists();
580        let upper_ids = graph.upper_ids.clone();
581        let layers = graph.layers.clone();
582        let stats = graph
583            .project_neighborhoods(Some(&[0.0, 0.0]), &[0, 3, 4], 3, 1)
584            .unwrap();
585        assert!(graph.adj.get(0).contains(&3));
586        assert!(graph.adj.get(3).contains(&0));
587        for (vertex, previous) in old.iter().enumerate() {
588            assert!(previous.iter().all(|id| graph.adj.get(vertex).contains(id)));
589            let mut unique = graph.adj.get(vertex).to_vec();
590            unique.sort_unstable();
591            unique.dedup();
592            assert_eq!(unique.len(), graph.adj.get(vertex).len());
593            assert!(!unique.contains(&(vertex as u32)));
594        }
595        assert_eq!(graph.upper_ids, upper_ids);
596        assert_eq!(graph.layers, layers);
597        assert_eq!(stats.forward_edges, 1);
598        assert_eq!(stats.reverse_edges, 1);
599        assert_eq!(stats.original_edges, 6);
600        assert_eq!(stats.total_edges, 8);
601        assert_eq!(stats.max_degree, 2);
602        assert_eq!(stats.degree_capacity, 4);
603    }
604
605    #[test]
606    fn pooling_is_deterministic_and_independent_of_query_order() {
607        let mut first = graph();
608        let mut second = graph();
609        let queries = [0.0, 0.0, 1.0, 0.0];
610        let first_stats = first
611            .project_neighborhoods(Some(&queries), &[0, 3, 4, 0, 4, 5], 3, 2)
612            .unwrap();
613        let second_stats = second
614            .project_neighborhoods(Some(&queries), &[0, 4, 5, 0, 3, 4], 3, 2)
615            .unwrap();
616        assert_eq!(first.adj, second.adj);
617        assert_eq!(first_stats, second_stats);
618        assert!(first.adj.iter().all(|ids| ids.len() <= 6));
619    }
620
621    /// Projects with `ids`, asserting a rejection leaves the graph exactly as it was.
622    fn project(
623        graph: &mut Graph,
624        queries: &[f32],
625        ids: &[u32],
626        neighbors: usize,
627        degree: usize,
628    ) -> Result<ProjectionStats, WarmError> {
629        let (hash, before) = (graph.structure_hash(), graph.degree);
630        let result = graph.project_neighborhoods(Some(queries), ids, neighbors, degree);
631        if result.is_err() {
632            assert_eq!(
633                graph.structure_hash(),
634                hash,
635                "a rejection changed the edges"
636            );
637            assert_eq!(graph.degree, before, "a rejection changed the degree");
638        }
639        result
640    }
641
642    /// `(name, queries, neighbor ids, neighbors_per_query, projected_degree)`.
643    type Case = (&'static str, &'static [f32], &'static [u32], usize, usize);
644
645    #[test]
646    fn invalid_inputs_leave_the_graph_unchanged() {
647        let cases: &[Case] = &[
648            ("no queries", &[], &[], 3, 1),
649            ("query shorter than dim", &[0.0], &[0, 3, 4], 3, 1),
650            ("NaN query", &[f32::NAN, 0.0], &[0, 3, 4], 3, 1),
651            ("infinite query", &[f32::INFINITY, 0.0], &[0, 3, 4], 3, 1),
652            ("short neighbor row", &[0.0, 0.0], &[0, 3], 3, 1),
653            ("neighbor outside the graph", &[0.0, 0.0], &[0, 3, 6], 3, 1),
654            ("duplicate neighbor", &[0.0, 0.0], &[0, 3, 3], 3, 1),
655            ("zero neighbors", &[0.0, 0.0], &[0, 3, 4], 0, 1),
656            ("degree usize::MAX", &[0.0, 0.0], &[0, 3, 4], 3, usize::MAX),
657        ];
658        for &(name, queries, ids, neighbors, degree) in cases {
659            let result = project(&mut graph(), queries, ids, neighbors, degree);
660            assert!(
661                matches!(result, Err(WarmError::InvalidParam { .. })),
662                "{name}: {result:?}"
663            );
664        }
665    }
666
667    /// `n = 6`: `2 <= neighbors_per_query <= n` and `1 <= projected_degree < n`, each at
668    /// limit - 1, limit and limit + 1; an accepted projection widens the degree by twice the
669    /// projected degree.
670    #[test]
671    fn neighbor_count_and_projected_degree_bounds() {
672        for (name, neighbors, degree, accepted) in [
673            ("neighbors 1", 1, 1, false),
674            ("neighbors 2", 2, 1, true),
675            ("neighbors 3, degree 1", 3, 1, true),
676            ("neighbors n - 1", 5, 1, true),
677            ("neighbors n", 6, 1, true),
678            ("neighbors n + 1", 7, 1, false),
679            ("degree 0", 3, 0, false),
680            ("degree 2", 3, 2, true),
681            ("degree n - 2", 3, 4, true),
682            ("degree n - 1", 3, 5, true),
683            ("neighbors n, degree n - 1", 6, 5, true),
684            ("degree n", 3, 6, false),
685        ] {
686            let mut graph = graph();
687            let ids: Vec<u32> = (0..neighbors as u32).collect();
688            let result = project(&mut graph, &[0.0, 0.0], &ids, neighbors, degree);
689            if accepted {
690                let stats = result.unwrap_or_else(|e| panic!("{name}: {e}"));
691                assert_eq!(graph.degree, 2 + 2 * degree, "{name}");
692                assert_eq!(stats.degree_capacity, graph.degree, "{name}");
693            } else {
694                assert!(
695                    matches!(result, Err(WarmError::InvalidParam { .. })),
696                    "{name}: {result:?}"
697                );
698            }
699        }
700        let mut graph = graph();
701        graph.degree = usize::MAX - 1;
702        let result = graph.project_neighborhoods(Some(&[0.0, 0.0]), &[0, 3], 2, 1);
703        assert!(
704            matches!(result, Err(WarmError::InvalidParam { .. })),
705            "degree capacity overflow: {result:?}"
706        );
707    }
708
709    #[test]
710    fn block_matrix_neighbors_match_scalar_angular_and_euclidean_metrics() {
711        let data = [2.0, 0.0, 0.0, 3.0, -1.0, 1.0, 4.0, 4.0, 0.0, 0.0];
712        let mut queries = Vec::new();
713        for i in 0..33 {
714            queries.extend_from_slice(&[i as f32 + 1.0, (i % 5) as f32 - 2.0]);
715        }
716        for metric in [Metric::Angular, Metric::Euclidean] {
717            let params = build_params(metric);
718            let graph = Graph::build(&data, 2, &params).unwrap();
719            for neighbors in [1, 4, 5] {
720                assert_eq!(
721                    graph.training_neighbors(&queries, neighbors).unwrap(),
722                    scalar_neighbors(&graph, &queries, neighbors)
723                );
724            }
725        }
726    }
727
728    #[test]
729    fn asymmetric_mips_neighbors_match_original_variable_norm_inner_products() {
730        let original: [[f32; 2]; 5] =
731            [[3.0, 0.0], [0.0, 4.0], [-3.0, 0.0], [0.0, -4.0], [0.0, 0.0]];
732        let augmented = [
733            3.0, 0.0, 4.0, 0.0, 4.0, 3.0, -3.0, 0.0, 4.0, 0.0, -4.0, 3.0, 0.0, 0.0, 5.0,
734        ];
735        let queries = [2.0, 1.0, 0.0, -1.0, 3.0, 0.0];
736        let params = build_params(Metric::Euclidean);
737        let graph = Graph::build(&augmented, 3, &params).unwrap();
738        let mut expected = Vec::new();
739        for query in queries.as_chunks::<3>().0 {
740            let mut ids: Vec<u32> = (0..5).collect();
741            let score = |id: u32| {
742                original[id as usize]
743                    .iter()
744                    .zip(query)
745                    .map(|(&a, &b)| f64::from(a) * f64::from(b))
746                    .sum::<f64>()
747            };
748            ids.sort_by(|&a, &b| score(b).total_cmp(&score(a)).then(a.cmp(&b)));
749            expected.extend(ids);
750        }
751        assert_eq!(graph.training_neighbors(&queries, 5).unwrap(), expected);
752    }
753
754    #[test]
755    fn untrained_optional_builds_preserve_legacy_cache_bytes() {
756        let data = graph().vectors;
757        let params = build_params(Metric::Euclidean);
758        let legacy = Graph::build(&data, 2, &params).unwrap();
759        let borrowed = Graph::build_with_training_queries(&data, 2, &params, None).unwrap();
760        let owned =
761            Graph::build_owned_with_training_queries(data.clone(), 2, &params, None).unwrap();
762        let directory = tempfile::tempdir().unwrap();
763        let mut bytes = None;
764        for (id, graph) in [legacy, borrowed, owned].iter().enumerate() {
765            let path = directory.path().join(id.to_string());
766            graph.save(&path).unwrap();
767            let current = std::fs::read(&path).unwrap();
768            if let Some(previous) = &bytes {
769                assert_eq!(previous, &current);
770            }
771            bytes = Some(current);
772            assert_eq!(
773                Graph::load(&data, 2, &path, &params)
774                    .unwrap()
775                    .structure_hash(),
776                graph.structure_hash()
777            );
778        }
779    }
780
781    #[test]
782    fn optional_training_matches_projection_of_independent_neighbors() {
783        let data = graph().vectors;
784        let params = build_params(Metric::Euclidean);
785        let queries = [0.0, 0.0, 8.0, 1.0];
786        let mut expected = Graph::build(&data, 2, &params).unwrap();
787        let ids = scalar_neighbors(&expected, &queries, 6);
788        expected
789            .project_neighborhoods(Some(&queries), &ids, 6, 5)
790            .unwrap();
791        let borrowed =
792            Graph::build_with_training_queries(&data, 2, &params, Some(&queries)).unwrap();
793        let owned =
794            Graph::build_owned_with_training_queries(data, 2, &params, Some(&queries)).unwrap();
795        assert_eq!(borrowed.structure_hash(), expected.structure_hash());
796        assert_eq!(owned.structure_hash(), expected.structure_hash());
797        assert_eq!(borrowed.degree, 12);
798        let reversed = [8.0, 1.0, 0.0, 0.0];
799        let reordered =
800            Graph::build_with_training_queries(&borrowed.vectors, 2, &params, Some(&reversed))
801                .unwrap();
802        assert_eq!(borrowed.structure_hash(), reordered.structure_hash());
803    }
804
805    /// The limited exact path equals projecting caller-supplied exact lists for the first
806    /// `limit` queries, and the parallel selection gives the same graph on 1 and 4 threads.
807    #[test]
808    fn caller_neighbors_match_limited_training_on_any_thread_count() {
809        let (n, dim) = (400usize, 4usize);
810        let data: Vec<f32> = (0..n * dim)
811            .map(|i| ((i * 7919 % 1009) as f32 / 1009.0 - 0.5) * (1.0 + (i % dim) as f32))
812            .collect();
813        let queries: Vec<f32> = (0..40 * dim)
814            .map(|i| ((i * 104_729 % 997) as f32 / 997.0 - 0.3) * 1.5)
815            .collect();
816        let params = BuildParams {
817            metric: Metric::Euclidean,
818            degree: 8,
819            build_ef: 32,
820            ..Default::default()
821        };
822        let (limit, k, pd) = (30, 12, 4);
823        let base = Graph::build(&data, dim, &params).unwrap();
824        let ids = scalar_neighbors(&base, &queries[..limit * dim], k);
825        let hashes: Vec<(u64, u64)> = [1, 4]
826            .into_iter()
827            .map(|threads| {
828                let pool = rayon::ThreadPoolBuilder::new()
829                    .num_threads(threads)
830                    .build()
831                    .unwrap();
832                pool.install(|| {
833                    let mut exact = Graph::build(&data, dim, &params).unwrap();
834                    exact
835                        .apply_training_queries_with(&queries, limit, k, pd)
836                        .unwrap();
837                    let mut supplied = Graph::build(&data, dim, &params).unwrap();
838                    let stats = supplied
839                        .project_training_neighbors(&queries[..limit * dim], &ids, k, pd)
840                        .unwrap();
841                    assert_eq!(stats.training_queries, limit);
842                    assert!(stats.added_edges > 0);
843                    (exact.structure_hash(), supplied.structure_hash())
844                })
845            })
846            .collect();
847        assert_eq!(hashes[0].0, hashes[0].1);
848        assert_eq!(hashes[0], hashes[1]);
849        assert_ne!(hashes[0].0, base.structure_hash());
850    }
851
852    #[test]
853    fn invalid_queries_are_rejected_before_build_including_unused_rows() {
854        let data = graph().vectors;
855        for metric in [Metric::Angular, Metric::Euclidean] {
856            let params = build_params(metric);
857            for queries in [
858                &[][..],
859                &[1.0][..],
860                &[f32::NAN, 1.0][..],
861                &[f32::INFINITY, 1.0][..],
862            ] {
863                // The base set is empty, so only query validation names the training rows.
864                let error = Graph::build_with_training_queries(&[], 2, &params, Some(queries))
865                    .err()
866                    .expect("invalid training query");
867                assert!(
868                    matches!(error, WarmError::InvalidParam { .. })
869                        && error.to_string().contains("training"),
870                    "{metric:?} {queries:?}: {error}"
871                );
872                let owned = Graph::build_owned_with_training_queries(
873                    data.clone(),
874                    2,
875                    &params,
876                    Some(queries),
877                )
878                .err();
879                assert!(
880                    matches!(owned, Some(WarmError::InvalidParam { .. })),
881                    "{metric:?} {queries:?}: {owned:?}"
882                );
883            }
884            let mut queries = vec![1.0; (TRAINING_LIMIT + 1) * 2];
885            *queries.last_mut().unwrap() = f32::NAN;
886            let unused_row =
887                Graph::build_with_training_queries(&data, 2, &params, Some(&queries)).err();
888            assert!(
889                matches!(unused_row, Some(WarmError::InvalidParam { .. })),
890                "{metric:?}: NaN past the training limit: {unused_row:?}"
891            );
892            if metric == Metric::Angular {
893                let zero =
894                    Graph::build_with_training_queries(&data, 2, &params, Some(&[0.0, 0.0])).err();
895                assert!(
896                    matches!(zero, Some(WarmError::InvalidParam { .. })),
897                    "zero angular query: {zero:?}"
898                );
899            }
900        }
901    }
902
903    #[test]
904    fn singleton_training_is_valid_and_adds_no_edges() {
905        for metric in [Metric::Angular, Metric::Euclidean] {
906            let params = build_params(metric);
907            let graph =
908                Graph::build_with_training_queries(&[3.0, 4.0], 2, &params, Some(&[1.0, 0.0]))
909                    .unwrap();
910            assert_eq!(graph.degree, 2);
911            assert!(graph.adj.get(0).is_empty());
912        }
913    }
914
915    #[test]
916    fn trained_graph_cache_is_rejected_and_encoded_index_round_trips() {
917        let data = graph().vectors;
918        let params = build_params(Metric::Euclidean);
919        let queries = [0.0, 0.0, 8.0, 1.0];
920        let graph = Graph::build_with_training_queries(&data, 2, &params, Some(&queries)).unwrap();
921        let directory = tempfile::tempdir().unwrap();
922        let cache = directory.path().join("cache");
923        std::fs::write(&cache, b"preserve existing cache").unwrap();
924        let error = graph.save(&cache).unwrap_err();
925        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
926        assert!(error.to_string().contains("VGraph"));
927        assert_eq!(std::fs::read(cache).unwrap(), b"preserve existing cache");
928        let index = VGraph::from_graph(&graph, true).unwrap();
929        let path = directory.path().join("trained-index");
930        index.save(&path).unwrap();
931        let opened = VGraph::open(&path, Residency::Heap).unwrap();
932        assert_eq!(index.index_bytes(), opened.index_bytes());
933        let (mut first, mut second) = (
934            index.searcher_with(SearchKnobs::OFF),
935            opened.searcher_with(SearchKnobs::OFF),
936        );
937        for query in queries.as_chunks::<2>().0 {
938            let (mut a, mut b) = (Vec::new(), Vec::new());
939            let search = VSearchParams {
940                ef: 6,
941                rerank: 6,
942                ..Default::default()
943            };
944            let first_count = first.search(query, 3, search, &mut a).unwrap();
945            let second_count = second.search(query, 3, search, &mut b).unwrap();
946            assert_eq!((first_count, a), (second_count, b));
947        }
948    }
949}