Skip to main content

holographic_memory/core/cognition/
refiner.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Distributional refinement of atom vectors.
5//!
6//! Atoms start as random vectors (deterministic from string hash). Over time,
7//! atoms that appear in similar relational contexts (same relations, same
8//! co-occurring peers) should become more similar. This module refines atom
9//! vectors by blending them toward their abstraction prototypes, producing
10//! self-organizing semantic embeddings without any external model.
11//!
12//! The refinement step runs as part of the cognition loop or is called
13//! explicitly via the Governor. It requires write access to AtomMemory.
14
15use fxhash::FxHashMap;
16
17use crate::core::atom_memory::AtomMemory;
18use crate::core::entangled::EntangledHVec;
19use crate::core::triple_store::TripleStore;
20
21/// Result of a refinement pass.
22#[derive(Clone, Debug, Default)]
23pub struct RefinementReport {
24    /// Number of atoms that were refined.
25    pub atoms_refined: usize,
26    /// Number of atoms skipped (too few contexts).
27    pub atoms_skipped: usize,
28    /// Average number of context relations per refined atom.
29    pub avg_context_depth: f64,
30}
31
32/// Configuration for distributional refinement.
33#[derive(Clone, Debug)]
34pub struct RefinerConfig {
35    /// Blending factor: 0.0 = keep original, 1.0 = fully replace with context.
36    /// Default 0.15 — conservative blending to avoid catastrophic drift.
37    pub alpha: f64,
38    /// Minimum number of distinct relations an atom must participate in
39    /// before refinement applies. Atoms with fewer contexts keep their
40    /// original random vectors.
41    pub min_context_relations: usize,
42    /// Minimum number of peer atoms sharing a relation for that relation's
43    /// context to contribute. Filters out singleton relations.
44    pub min_peers_per_relation: usize,
45}
46
47impl Default for RefinerConfig {
48    fn default() -> Self {
49        Self {
50            alpha: 0.15,
51            min_context_relations: 2,
52            min_peers_per_relation: 2,
53        }
54    }
55}
56
57/// Refines atom vectors by blending them toward distributional context vectors.
58///
59/// For each atom A that participates in relations {R1, R2, ...}:
60/// 1. For each relation Ri, collect all peer atoms that also participate in Ri
61/// 2. Build a context vector by frequency-counting indices across peer atoms
62/// 3. Blend: refined_A = merge(original_A, context_vector, alpha)
63///
64/// The merge preserves sparsity by taking the top-k indices from the
65/// weighted union of original and context indices.
66pub struct DistributionalRefiner;
67
68impl DistributionalRefiner {
69    /// Run one refinement pass over all atoms.
70    ///
71    /// This modifies atom vectors in-place. Each atom's vector is blended
72    /// toward the centroid of its relational peers. Requires write access
73    /// to AtomMemory (call via Governor, not from the read-only cognition loop).
74    pub fn refine(
75        atom_memory: &AtomMemory,
76        triple_store: &TripleStore,
77        config: &RefinerConfig,
78    ) -> RefinementReport {
79        let snapshot = triple_store.snapshot();
80        if snapshot.is_empty() {
81            return RefinementReport::default();
82        }
83
84        // Build: atom -> set of relations it participates in (as subject)
85        let mut atom_relations: FxHashMap<String, Vec<String>> = FxHashMap::default();
86        for t in &snapshot {
87            atom_relations
88                .entry(t.subject_id.clone())
89                .or_default()
90                .push(t.relation_id.clone());
91        }
92        // Deduplicate
93        for rels in atom_relations.values_mut() {
94            rels.sort();
95            rels.dedup();
96        }
97
98        // Build: relation -> list of subject atoms
99        let mut relation_subjects: FxHashMap<String, Vec<String>> = FxHashMap::default();
100        for t in &snapshot {
101            relation_subjects
102                .entry(t.relation_id.clone())
103                .or_default()
104                .push(t.subject_id.clone());
105        }
106        for subjects in relation_subjects.values_mut() {
107            subjects.sort();
108            subjects.dedup();
109        }
110
111        let mut report = RefinementReport::default();
112        let mut total_context_depth: usize = 0;
113
114        // For each atom, compute its context vector and blend
115        for (atom_id, relations) in &atom_relations {
116            if relations.len() < config.min_context_relations {
117                report.atoms_skipped += 1;
118                continue;
119            }
120
121            let original = match atom_memory.get(atom_id) {
122                Some(v) => v,
123                None => continue,
124            };
125
126            // Collect peer vectors across all relations this atom participates in
127            let mut peer_index_freq: FxHashMap<u32, f64> = FxHashMap::default();
128            let mut contributing_relations = 0usize;
129
130            for rel in relations {
131                let peers = match relation_subjects.get(rel) {
132                    Some(p) => p,
133                    None => continue,
134                };
135                // Exclude self, check minimum peer count
136                let other_peers: Vec<&String> = peers.iter().filter(|p| *p != atom_id).collect();
137                if other_peers.len() < config.min_peers_per_relation {
138                    continue;
139                }
140                contributing_relations += 1;
141
142                // Weight each peer's indices by 1/num_peers for this relation
143                let weight = 1.0 / other_peers.len() as f64;
144                for peer_id in &other_peers {
145                    if let Some(peer_vec) = atom_memory.get(peer_id) {
146                        for &idx in peer_vec.indices() {
147                            *peer_index_freq.entry(idx).or_insert(0.0) += weight;
148                        }
149                    }
150                }
151            }
152
153            if contributing_relations == 0 {
154                report.atoms_skipped += 1;
155                continue;
156            }
157
158            total_context_depth += contributing_relations;
159
160            // Build blended vector via slot allocation.
161            // Reserve (1 - alpha) fraction of slots for original indices,
162            // and alpha fraction for the highest-frequency context indices.
163            // This guarantees context indices actually appear in the result.
164            let dim = original.dim;
165            let target_count = (dim / 256).max(1);
166            let ctx_slots = ((target_count as f64) * config.alpha).round() as usize;
167            let orig_slots = target_count.saturating_sub(ctx_slots);
168
169            // Top context indices by frequency (excluding those already in original)
170            let orig_set: fxhash::FxHashSet<u32> = original.indices().iter().copied().collect();
171            let mut ctx_scored: Vec<(u32, f64)> = peer_index_freq
172                .iter()
173                .filter(|(idx, _)| !orig_set.contains(idx))
174                .map(|(&idx, &freq)| (idx, freq))
175                .collect();
176            ctx_scored.sort_unstable_by(|a, b| {
177                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
178            });
179            ctx_scored.truncate(ctx_slots);
180
181            // Keep top original indices (by posting frequency if available,
182            // otherwise just first orig_slots)
183            let mut orig_indices: Vec<u32> = original.indices().to_vec();
184            orig_indices.truncate(orig_slots);
185
186            // Merge
187            let mut new_indices: Vec<u32> = orig_indices;
188            new_indices.extend(ctx_scored.iter().map(|(idx, _)| *idx));
189            new_indices.sort_unstable();
190            new_indices.dedup();
191            new_indices.truncate(target_count);
192
193            let refined = EntangledHVec::from_indices(new_indices, dim);
194
195            // Only update if the refined vector is meaningfully different
196            if original.similarity(&refined) < 0.999 {
197                atom_memory.delete(atom_id);
198                atom_memory.load_atom(atom_id.clone(), refined);
199                report.atoms_refined += 1;
200            } else {
201                report.atoms_skipped += 1;
202            }
203        }
204
205        if report.atoms_refined > 0 {
206            report.avg_context_depth = total_context_depth as f64 / report.atoms_refined as f64;
207            atom_memory.rebuild_indices();
208        }
209
210        report
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_refine_converges_cooccurring_atoms() {
220        let dim = 16384;
221        let atom_mem = AtomMemory::new(dim, 3.0);
222        let triple_store = TripleStore::new();
223
224        // Create atoms
225        atom_mem.get_or_insert("paris");
226        atom_mem.get_or_insert("berlin");
227        atom_mem.get_or_insert("tokyo");
228        atom_mem.get_or_insert("france");
229        atom_mem.get_or_insert("germany");
230        atom_mem.get_or_insert("japan");
231        atom_mem.get_or_insert("europe");
232
233        // paris and berlin share two relations; tokyo shares one
234        triple_store.add("paris", "capital_of", "france", "c1");
235        triple_store.add("berlin", "capital_of", "germany", "c2");
236        triple_store.add("tokyo", "capital_of", "japan", "c3");
237        triple_store.add("paris", "located_in", "europe", "c4");
238        triple_store.add("berlin", "located_in", "europe", "c5");
239
240        let paris_before = atom_mem.get("paris").unwrap();
241        let berlin_before = atom_mem.get("berlin").unwrap();
242        let sim_before = paris_before.similarity(&berlin_before);
243
244        // Run refinement
245        let config = RefinerConfig {
246            alpha: 0.3,
247            min_context_relations: 2,
248            min_peers_per_relation: 2,
249        };
250        let report = DistributionalRefiner::refine(&atom_mem, &triple_store, &config);
251
252        assert!(
253            report.atoms_refined > 0,
254            "Should refine at least some atoms"
255        );
256
257        let paris_after = atom_mem.get("paris").unwrap();
258        let berlin_after = atom_mem.get("berlin").unwrap();
259        let sim_after = paris_after.similarity(&berlin_after);
260
261        assert!(
262            sim_after > sim_before,
263            "paris and berlin should become more similar after refinement: before={:.4}, after={:.4}",
264            sim_before,
265            sim_after
266        );
267    }
268
269    #[test]
270    fn test_refine_skips_low_context_atoms() {
271        let dim = 16384;
272        let atom_mem = AtomMemory::new(dim, 3.0);
273        let triple_store = TripleStore::new();
274
275        atom_mem.get_or_insert("lonely");
276        atom_mem.get_or_insert("x");
277        triple_store.add("lonely", "r", "x", "c1");
278
279        let config = RefinerConfig {
280            min_context_relations: 2,
281            ..Default::default()
282        };
283        let report = DistributionalRefiner::refine(&atom_mem, &triple_store, &config);
284        assert_eq!(report.atoms_refined, 0);
285        assert!(report.atoms_skipped > 0);
286    }
287
288    #[test]
289    fn test_refine_empty_store() {
290        let dim = 16384;
291        let atom_mem = AtomMemory::new(dim, 3.0);
292        let triple_store = TripleStore::new();
293        let config = RefinerConfig::default();
294        let report = DistributionalRefiner::refine(&atom_mem, &triple_store, &config);
295        assert_eq!(report.atoms_refined, 0);
296        assert_eq!(report.atoms_skipped, 0);
297    }
298
299    #[test]
300    fn test_refine_preserves_vector_sparsity() {
301        let dim = 16384;
302        let atom_mem = AtomMemory::new(dim, 3.0);
303        let triple_store = TripleStore::new();
304
305        for city in &["a", "b", "c", "d", "e"] {
306            atom_mem.get_or_insert(city);
307        }
308        for country in &["x", "y", "z", "w", "v"] {
309            atom_mem.get_or_insert(country);
310        }
311        atom_mem.get_or_insert("continent");
312
313        // All share two relations
314        for (city, country) in [("a", "x"), ("b", "y"), ("c", "z"), ("d", "w"), ("e", "v")] {
315            triple_store.add(city, "capital_of", country, &format!("c_{}", city));
316            triple_store.add(city, "in", "continent", &format!("l_{}", city));
317        }
318
319        let config = RefinerConfig {
320            alpha: 0.3,
321            min_context_relations: 2,
322            min_peers_per_relation: 2,
323        };
324        let report = DistributionalRefiner::refine(&atom_mem, &triple_store, &config);
325
326        // Check sparsity is maintained
327        let target = dim / 256;
328        for city in &["a", "b", "c", "d", "e"] {
329            let vec = atom_mem.get(city).unwrap();
330            assert!(
331                vec.indices().len() <= target + 1,
332                "Refined vector for {} has {} indices, expected <= {}",
333                city,
334                vec.indices().len(),
335                target + 1
336            );
337        }
338        assert!(report.atoms_refined >= 3);
339    }
340
341    #[test]
342    fn test_multiple_refinement_rounds() {
343        let dim = 16384;
344        let atom_mem = AtomMemory::new(dim, 3.0);
345        let triple_store = TripleStore::new();
346
347        atom_mem.get_or_insert("a");
348        atom_mem.get_or_insert("b");
349        atom_mem.get_or_insert("c");
350        atom_mem.get_or_insert("x");
351        atom_mem.get_or_insert("y");
352
353        triple_store.add("a", "r1", "x", "c1");
354        triple_store.add("b", "r1", "y", "c2");
355        triple_store.add("c", "r1", "x", "c3");
356        triple_store.add("a", "r2", "y", "c4");
357        triple_store.add("b", "r2", "x", "c5");
358        triple_store.add("c", "r2", "y", "c6");
359
360        let config = RefinerConfig {
361            alpha: 0.2,
362            min_context_relations: 2,
363            min_peers_per_relation: 2,
364        };
365
366        let sim_before = atom_mem
367            .get("a")
368            .unwrap()
369            .similarity(&atom_mem.get("b").unwrap());
370
371        // Run 3 rounds of refinement
372        for _ in 0..3 {
373            DistributionalRefiner::refine(&atom_mem, &triple_store, &config);
374        }
375
376        let sim_after = atom_mem
377            .get("a")
378            .unwrap()
379            .similarity(&atom_mem.get("b").unwrap());
380        assert!(
381            sim_after > sim_before,
382            "Multiple rounds should increase similarity: before={:.4}, after={:.4}",
383            sim_before,
384            sim_after
385        );
386    }
387}