writerslogic_warm/core/engine/
concepts.rs1use crate::core::hash::{FastMap, FastSet};
5
6use super::WarmCore;
7use crate::core::entangled::EntangledHVec;
8use crate::core::query_limits::{QueryBudget, QueryLimits};
9use crate::core::types::ConceptCandidate;
10use anyhow::Result;
11
12impl WarmCore {
13 pub fn synthesize_concepts(&self) -> Vec<ConceptCandidate> {
19 self.synthesize_concepts_with_limits(QueryLimits::default())
20 .expect("concept query budget exhausted; use synthesize_concepts_with_limits")
21 }
22
23 pub fn synthesize_concepts_with_limits(
25 &self,
26 limits: QueryLimits,
27 ) -> Result<Vec<ConceptCandidate>> {
28 let mut budget = QueryBudget::new(limits);
29 let cfg = &self.config.concepts;
30 let mut all_ids = Vec::new();
31 let mut all_vectors = Vec::new();
32
33 {
34 let _transaction = self.mutation_gate.read();
35 let shards = self.shards.read();
36 shards.try_for_each_shard_indexed(|_, shard| {
37 let vectors = shard.vectors.read();
38 let registry = shard.registry.read();
39 for id in registry.iter() {
40 if let Some(vector) = vectors.get(id) {
41 budget.work(1)?;
42 budget.reserve(
43 id.len()
44 .saturating_mul(2)
45 .saturating_add(vector.indices().len().saturating_mul(8))
46 .saturating_add(128),
47 )?;
48 all_ids.push(id.clone());
49 all_vectors.push(vector.clone());
50 }
51 }
52 Ok(())
53 })?;
54 }
55
56 if all_ids.len() < cfg.min_cluster_size {
57 return Ok(Vec::new());
58 }
59
60 let n = all_vectors.len();
61 let mut used = vec![false; n];
62 let mut concepts = Vec::new();
63
64 let use_postings = n > 500;
65 let postings = if use_postings {
66 let mut postings: FastMap<u32, Vec<usize>> = FastMap::default();
67 for (i, vec) in all_vectors.iter().enumerate() {
68 for &idx in vec.indices() {
69 budget.work(1)?;
70 budget.reserve(16 + if postings.contains_key(&idx) { 0 } else { 64 })?;
71 postings.entry(idx).or_default().push(i);
72 }
73 }
74 Some(postings)
75 } else {
76 None
77 };
78
79 for i in 0..n {
80 if used[i] {
81 continue;
82 }
83 budget.reserve(16)?;
84 let (candidates, candidate_bytes) =
85 concept_candidates(i, &all_vectors, postings.as_ref(), &mut budget)?;
86 let mut cluster = vec![i];
87 for &j in &candidates {
88 budget.work(1)?;
89 if !used[j] && all_vectors[i].similarity(&all_vectors[j]) > cfg.similarity_threshold
90 {
91 budget.reserve(16)?;
92 cluster.push(j);
93 }
94 }
95 drop(candidates);
96 budget.release(candidate_bytes);
97 if cluster.len() >= cfg.min_cluster_size {
98 budget.frontier(concepts.len().saturating_add(1))?;
99 for &idx in &cluster {
100 used[idx] = true;
101 }
102 let cluster_vecs: Vec<&EntangledHVec> =
103 cluster.iter().map(|&idx| &all_vectors[idx]).collect();
104 let centroid = bounded_bundle(self, &cluster_vecs, &mut budget)?;
105 budget.work(cluster_vecs.len())?;
106 let coherence: f64 = cluster_vecs
107 .iter()
108 .map(|v| v.similarity(¢roid))
109 .sum::<f64>()
110 / cluster_vecs.len() as f64;
111 let member_ids: Vec<String> = cluster
112 .iter()
113 .map(|&idx| {
114 budget.reserve(all_ids[idx].len().saturating_mul(2).saturating_add(64))?;
115 Ok(all_ids[idx].clone())
116 })
117 .collect::<Result<_>>()?;
118
119 concepts.push(ConceptCandidate {
120 centroid_id: member_ids.first().cloned().unwrap_or_default(),
121 member_count: cluster.len() as u32,
122 coherence,
123 member_ids,
124 stable: false,
125 });
126 }
127 budget.release(cluster.len().saturating_mul(16));
128 }
129
130 if concepts.len() >= 2 {
132 budget.reserve(n.saturating_mul(160))?;
133 let cluster_indices: Vec<usize> = (0..n).filter(|&i| used[i]).collect();
134 let by_id: FastMap<_, _> = all_ids
135 .iter()
136 .enumerate()
137 .map(|(index, id)| (id.as_str(), index))
138 .collect();
139
140 let mut assignment: FastMap<usize, usize> = FastMap::default();
142 for (ci, concept) in concepts.iter().enumerate() {
143 for mid in &concept.member_ids {
144 budget.work(1)?;
145 if let Some(&pos) = by_id.get(mid.as_str()) {
146 assignment.insert(pos, ci);
147 }
148 }
149 }
150
151 let mut centroids: Vec<EntangledHVec> = concepts
152 .iter()
153 .enumerate()
154 .map(|(ci, _)| {
155 budget.work(cluster_indices.len())?;
156 let members: Vec<&EntangledHVec> = cluster_indices
157 .iter()
158 .filter(|&&vi| assignment.get(&vi) == Some(&ci))
159 .map(|&vi| &all_vectors[vi])
160 .collect();
161 bounded_bundle(self, &members, &mut budget)
162 })
163 .collect::<Result<_>>()?;
164
165 let mut stable = true;
166 for _iter in 0..3 {
167 let mut changed = false;
168 for &vi in &cluster_indices {
169 let mut best_ci = assignment[&vi];
170 let mut best_sim = all_vectors[vi].similarity(¢roids[best_ci]);
171 for (ci, centroid) in centroids.iter().enumerate() {
172 budget.work(1)?;
173 let sim = all_vectors[vi].similarity(centroid);
174 if sim > best_sim {
175 best_sim = sim;
176 best_ci = ci;
177 }
178 }
179 if best_ci != assignment[&vi] {
180 assignment.insert(vi, best_ci);
181 changed = true;
182 }
183 }
184 if !changed {
185 stable = true;
186 break;
187 }
188 stable = false;
189 for (ci, centroid) in centroids.iter_mut().enumerate() {
190 budget.work(cluster_indices.len())?;
191 let members: Vec<&EntangledHVec> = cluster_indices
192 .iter()
193 .filter(|&&vi| assignment.get(&vi) == Some(&ci))
194 .map(|&vi| &all_vectors[vi])
195 .collect();
196 if !members.is_empty() {
197 *centroid = bounded_bundle(self, &members, &mut budget)?;
198 }
199 }
200 }
201
202 for (ci, concept) in concepts.iter_mut().enumerate() {
203 budget.work(cluster_indices.len())?;
204 let members: Vec<usize> = cluster_indices
205 .iter()
206 .filter(|&&vi| assignment.get(&vi) == Some(&ci))
207 .copied()
208 .collect();
209 if members.is_empty() {
210 concept.member_count = 0;
211 concept.member_ids.clear();
212 concept.coherence = 0.0;
213 concept.stable = stable;
214 continue;
215 }
216 let member_vecs: Vec<&EntangledHVec> =
217 members.iter().map(|&vi| &all_vectors[vi]).collect();
218 budget.work(member_vecs.len())?;
219 let coherence: f64 = member_vecs
220 .iter()
221 .map(|v| v.similarity(¢roids[ci]))
222 .sum::<f64>()
223 / member_vecs.len() as f64;
224 concept.member_ids = members
225 .iter()
226 .map(|&vi| {
227 budget.reserve(all_ids[vi].len().saturating_mul(2).saturating_add(64))?;
228 Ok(all_ids[vi].clone())
229 })
230 .collect::<Result<_>>()?;
231 concept.centroid_id = concept.member_ids.first().cloned().unwrap_or_default();
232 concept.member_count = members.len() as u32;
233 concept.coherence = coherence;
234 concept.stable = stable;
235 }
236
237 concepts.retain(|c| c.member_count > 0);
238 } else {
239 for concept in &mut concepts {
241 concept.stable = true;
242 }
243 }
244
245 Ok(concepts)
246 }
247}
248
249fn concept_candidates(
250 index: usize,
251 vectors: &[EntangledHVec],
252 postings: Option<&FastMap<u32, Vec<usize>>>,
253 budget: &mut QueryBudget,
254) -> Result<(Vec<usize>, usize)> {
255 let mut bytes = 0usize;
256 let mut candidates = if let Some(postings) = postings {
257 let mut selected = FastSet::default();
258 for active in vectors[index].indices() {
259 for &other in &postings[active] {
260 budget.work(1)?;
261 if other != index && !selected.contains(&other) {
262 budget.frontier(selected.len().saturating_add(1))?;
263 budget.reserve(48)?;
264 bytes += 48;
265 selected.insert(other);
266 }
267 }
268 }
269 selected.into_iter().collect::<Vec<_>>()
270 } else {
271 budget.frontier(vectors.len() - index - 1)?;
272 bytes = (vectors.len() - index - 1).saturating_mul(16);
273 budget.reserve(bytes)?;
274 (index + 1..vectors.len()).collect()
275 };
276 budget.work(
277 candidates
278 .len()
279 .saturating_mul(candidates.len().max(1).ilog2() as usize + 1),
280 )?;
281 candidates.sort_unstable();
282 Ok((candidates, bytes))
283}
284
285fn bounded_bundle(
286 core: &WarmCore,
287 vectors: &[&EntangledHVec],
288 budget: &mut QueryBudget,
289) -> Result<EntangledHVec> {
290 let indices = vectors.iter().fold(0usize, |total, vector| {
291 total.saturating_add(vector.indices().len())
292 });
293 let scratch = core
294 .dimensions
295 .saturating_mul(if core.config.privacy.dp_enabled {
296 32
297 } else {
298 16
299 })
300 .saturating_add(indices.saturating_mul(16));
301 budget.work(indices.saturating_add(if core.config.privacy.dp_enabled {
302 core.dimensions
303 } else {
304 0
305 }))?;
306 budget.reserve(scratch)?;
307 let centroid = core.bundle(vectors);
308 budget.release(scratch);
309 budget.reserve(
310 centroid
311 .indices()
312 .len()
313 .saturating_mul(8)
314 .saturating_add(64),
315 )?;
316 Ok(centroid)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::core::query_limits::QueryLimitExceeded;
323
324 fn fixture(distinct: bool) -> (tempfile::TempDir, WarmCore) {
325 let directory = tempfile::tempdir().unwrap();
326 let mut config = crate::core::WarmConfig::default();
327 config.concepts.similarity_threshold = 0.9;
328 let core = WarmCore::new(
329 1024,
330 Some(directory.path().display().to_string()),
331 Some(config),
332 )
333 .unwrap();
334 for index in 0..501 {
335 let vector = EntangledHVec::from_indices(
336 if distinct {
337 vec![0, index + 1]
338 } else {
339 vec![0, 1]
340 },
341 1024,
342 );
343 core.shards
344 .read()
345 .insert(format!("v{index:03}"), vector, 1024)
346 .unwrap();
347 }
348 (directory, core)
349 }
350
351 #[test]
352 fn hot_dimension_concepts_use_one_candidate_frontier_and_preserve_all_members() {
353 let (_directory, core) = fixture(false);
354 let concepts = core
355 .synthesize_concepts_with_limits(QueryLimits {
356 max_bytes: 180_000,
357 max_work: 100_000,
358 ..Default::default()
359 })
360 .unwrap();
361 assert_eq!(concepts.len(), 1);
362 assert_eq!(concepts[0].member_count, 501);
363 assert_eq!(concepts[0].coherence, 1.0);
364 for frontier in [499, 500, 501] {
365 let result = core.synthesize_concepts_with_limits(QueryLimits {
366 max_frontier: frontier,
367 ..Default::default()
368 });
369 if frontier == 499 {
370 assert_eq!(
371 result
372 .err()
373 .unwrap()
374 .downcast_ref::<QueryLimitExceeded>()
375 .unwrap()
376 .resource,
377 "frontier"
378 );
379 } else {
380 assert_eq!(result.unwrap()[0].member_count, 501);
381 }
382 }
383 }
384
385 #[test]
386 fn skewed_concept_work_and_input_bytes_fail_without_partial_answers() {
387 let (_directory, core) = fixture(true);
388 for (limits, resource) in [
389 (
390 QueryLimits {
391 max_work: 5_000,
392 ..Default::default()
393 },
394 "work",
395 ),
396 (
397 QueryLimits {
398 max_bytes: 1_000,
399 ..Default::default()
400 },
401 "bytes",
402 ),
403 ] {
404 let error = core
405 .synthesize_concepts_with_limits(limits)
406 .err()
407 .expect("budget should reject the complete query");
408 assert_eq!(
409 error.downcast_ref::<QueryLimitExceeded>().unwrap().resource,
410 resource
411 );
412 assert_eq!(core.vector_count(), 501);
413 }
414 }
415}