uqa_storage/diskann_index/build/
merge.rs1use sha2::{Digest, Sha256};
8use std::path::Path;
9use uqa_core::memory::BudgetedVec;
10
11use super::runs::{RunReader, RunWriter};
12use super::temporary::TemporaryRun;
13use super::{invalid, DiskANNBuildInput, DiskANNPartitionRuns, DiskANNPartitionSummary};
14use crate::diskann_index::{vamana::Selection, NavigationInput, NavigationVector};
15use crate::{read_control::StorageReadControl, StorageBackendResult};
16
17mod sort;
18#[cfg(test)]
19mod tests;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct DiskANNMergeOptions {
24 pub sort_buffer_records: usize,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct DiskANNMergeSummary {
30 pub work_order_revision: u32,
31 pub partitions: DiskANNPartitionSummary,
32 pub options: DiskANNMergeOptions,
33 pub merge_passes: u8,
34 pub edges: u64,
35 pub adjacency_digest: [u8; 32],
36}
37
38pub struct DiskANNMergedGraph {
40 run: TemporaryRun,
41 nodes: u64,
42 degree: usize,
43 summary: DiskANNMergeSummary,
44 control: StorageReadControl,
45}
46
47impl DiskANNMergedGraph {
48 pub(super) fn check_input(&self, input: &DiskANNBuildInput) -> StorageBackendResult<()> {
49 input.control.check()?;
50 if self.nodes != input.node_count()
51 || self.summary.partitions.coverage != input.coverage
52 || !self.control.shares_context(&input.control)
53 || !self.run.uses_allowance(&input.temporary)
54 {
55 return Err(invalid("merged graph source or build allowance differs"));
56 }
57 Ok(())
58 }
59
60 pub fn summary(&self) -> &DiskANNMergeSummary {
61 &self.summary
62 }
63
64 pub fn visit_neighbors(
66 &self,
67 visitor: &mut dyn FnMut(u64, &[u64]) -> StorageBackendResult<()>,
68 ) -> StorageBackendResult<()> {
69 self.run.read(&self.control, |file| {
70 let mut hash = adjacency_hash(&self.summary.partitions, self.nodes, self.degree);
71 let mut edges = 0_u64;
72 if self.nodes != 0 {
73 let mut reader = RunReader::new(file, &self.control)?;
74 let mut neighbors = BudgetedVec::new(self.control.memory());
75 neighbors.reserve(self.degree)?;
76 for node in 0..self.nodes {
77 self.control.check()?;
78 let bytes = reader.record::<8>()?;
79 hash.update(bytes);
80 let count = u64::from_le_bytes(bytes);
81 if count > self.degree as u64 || (self.nodes > 1 && count == 0) {
82 return Err(invalid("invalid merged neighborhood length"));
83 }
84 neighbors.clear();
85 for slot in 0..self.degree {
86 let bytes = reader.record::<8>()?;
87 hash.update(bytes);
88 let neighbor = u64::from_le_bytes(bytes);
89 if (slot as u64) < count {
90 if neighbor >= self.nodes
91 || neighbor == node
92 || neighbors.last().is_some_and(|&last| last >= neighbor)
93 {
94 return Err(invalid("invalid merged neighbor"));
95 }
96 neighbors.push(neighbor)?;
97 } else if neighbor != 0 {
98 return Err(invalid("nonzero merged neighborhood padding"));
99 }
100 }
101 if self.nodes > 1 && !neighbors.contains(&((node + 1) % self.nodes)) {
102 return Err(invalid("merged neighborhood lacks global successor"));
103 }
104 edges = edges
105 .checked_add(count)
106 .ok_or_else(|| invalid("merged edge overflow"))?;
107 visitor(node, &neighbors)?;
108 }
109 }
110 if edges != self.summary.edges
111 || <[u8; 32]>::from(hash.finalize()) != self.summary.adjacency_digest
112 {
113 return Err(invalid(
114 "merged adjacency differs from its construction digest",
115 ));
116 }
117 Ok(())
118 })
119 }
120}
121
122impl DiskANNBuildInput {
123 pub fn merge_partitions(
125 &self,
126 partitions: DiskANNPartitionRuns,
127 directory: &Path,
128 options: DiskANNMergeOptions,
129 ) -> StorageBackendResult<DiskANNMergedGraph> {
130 self.control.check()?;
131 if options.sort_buffer_records == 0 {
132 return Err(invalid("global sort capacity must be positive"));
133 }
134 let (run, summary) = partitions.into_source(self)?;
135 merge(self, run, &summary, directory, options)
136 }
137}
138
139fn merge(
140 input: &DiskANNBuildInput,
141 candidates: TemporaryRun,
142 partitions: &DiskANNPartitionSummary,
143 directory: &Path,
144 options: DiskANNMergeOptions,
145) -> StorageBackendResult<DiskANNMergedGraph> {
146 let sorted = sort::sort(
147 input,
148 candidates,
149 partitions.edges,
150 directory,
151 options.sort_buffer_records,
152 )?;
153 let control = &input.control;
154 let nodes = input.node_count();
155 let degree = (partitions.parameters.max_degree as u64).min(nodes.saturating_sub(1)) as usize;
156 let mut writer = RunWriter::new(directory, &input.temporary, control)?;
157 if nodes != 0 {
158 writer.prepare()?;
159 }
160 let mut hash = adjacency_hash(partitions, nodes, degree);
161 let mut edges = 0_u64;
162 sorted.run.read(control, |file| {
163 if nodes == 0 {
164 return Ok(());
165 }
166 let mut candidates = sort::Range::new(file, 0, sorted.count, nodes, control)?;
167 let mut head = candidates.next()?;
168 for node in 0..nodes {
169 control.check()?;
170 let successor = (node + 1) % nodes;
171 let mut selected = Selection::new(
172 partitions.parameters.alpha,
173 degree.saturating_sub(1),
174 control,
175 )?;
176 let mut vectors = BudgetedVec::new(control.memory());
177 vectors.reserve(degree.saturating_sub(1))?;
178 let mut previous = None;
179 while let Some(candidate) = head.filter(|candidate| candidate.source == node) {
180 control.check()?;
181 if previous != Some(candidate.neighbor)
182 && candidate.neighbor != successor
183 && !selected.is_full()
184 {
185 let vector = navigation(input, candidate.neighbor)?;
186 if selected.consider(
187 candidate.neighbor,
188 candidate.distance,
189 control,
190 |position, _| {
191 let neighbor: &NavigationVector = &vectors[position];
192 neighbor.squared_distance(&vector, control)
193 },
194 )? {
195 vectors.push(vector)?;
196 }
197 }
198 previous = Some(candidate.neighbor);
199 head = candidates.next()?;
200 }
201 drop(vectors);
202 let mut neighbors = selected.finish();
203 if nodes > 1 {
204 neighbors.push(successor)?;
205 }
206 neighbors.sort_unstable();
207 edges = edges
208 .checked_add(neighbors.len() as u64)
209 .ok_or_else(|| invalid("merged edge overflow"))?;
210 write_word(neighbors.len() as u64, &mut writer, &mut hash)?;
211 for slot in 0..degree {
212 control.check()?;
213 write_word(
214 neighbors.get(slot).copied().unwrap_or(0),
215 &mut writer,
216 &mut hash,
217 )?;
218 }
219 }
220 if head.is_some() {
221 return Err(invalid("global candidate source was not consumed"));
222 }
223 Ok(())
224 })?;
225 let summary = DiskANNMergeSummary {
226 work_order_revision: 1,
227 partitions: *partitions,
228 options,
229 merge_passes: sorted.passes,
230 edges,
231 adjacency_digest: hash.finalize().into(),
232 };
233 drop(sorted);
234 let run = writer.finish()?;
235 control.check()?;
236 Ok(DiskANNMergedGraph {
237 run,
238 nodes,
239 degree,
240 summary,
241 control: control.clone(),
242 })
243}
244
245fn navigation(input: &DiskANNBuildInput, node: u64) -> StorageBackendResult<NavigationVector> {
246 let raw = input.read_node(node)?;
247 match NavigationInput::from_raw(input.dimensions, raw.raw(), &input.control)? {
248 NavigationInput::Navigable(vector) => Ok(vector),
249 NavigationInput::Exact(_) => Err(invalid(
250 "global candidate changed navigation classification",
251 )),
252 }
253}
254
255fn adjacency_hash(partitions: &DiskANNPartitionSummary, nodes: u64, degree: usize) -> Sha256 {
256 crate::diskann_index::format::adjacency_hash(partitions.coverage, nodes, degree)
257}
258
259fn write_word(value: u64, writer: &mut RunWriter, hash: &mut Sha256) -> StorageBackendResult<()> {
260 let bytes = value.to_le_bytes();
261 writer.append(&bytes)?;
262 hash.update(bytes);
263 Ok(())
264}