1use 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 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 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 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 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 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 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(|°ree| 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 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 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 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 #[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, ¶ms).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, ¶ms).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, ¶ms).unwrap();
759 let borrowed = Graph::build_with_training_queries(&data, 2, ¶ms, None).unwrap();
760 let owned =
761 Graph::build_owned_with_training_queries(data.clone(), 2, ¶ms, 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, ¤t);
770 }
771 bytes = Some(current);
772 assert_eq!(
773 Graph::load(&data, 2, &path, ¶ms)
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, ¶ms).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, ¶ms, Some(&queries)).unwrap();
793 let owned =
794 Graph::build_owned_with_training_queries(data, 2, ¶ms, 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, ¶ms, Some(&reversed))
801 .unwrap();
802 assert_eq!(borrowed.structure_hash(), reordered.structure_hash());
803 }
804
805 #[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, ¶ms).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, ¶ms).unwrap();
834 exact
835 .apply_training_queries_with(&queries, limit, k, pd)
836 .unwrap();
837 let mut supplied = Graph::build(&data, dim, ¶ms).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 let error = Graph::build_with_training_queries(&[], 2, ¶ms, 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 ¶ms,
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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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}