Skip to main content

writerslogic_warm/core/engine/
concepts.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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    /// Cluster similar vectors and return concept candidates with centroids and coherence scores.
14    ///
15    /// Large sparse collections generate candidates from postings, one bounded seed
16    /// frontier at a time. Common dimensions can still require quadratic comparison work.
17    /// Panics on budget exhaustion; use `synthesize_concepts_with_limits` to handle it.
18    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    /// Synthesizes complete concepts or reports a work, frontier or byte limit error.
24    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(&centroid))
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        // k-means-style centroid refinement, up to 3 iterations.
131        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            // Vector index -> concept index.
141            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(&centroids[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(&centroids[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            // Single or zero concepts: trivially stable
240            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}