Skip to main content

holographic_memory/core/cognition/
analogy.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Analogy detection via structural isomorphism.
5//!
6//! Finds pairs of subgraphs that share the same relational structure
7//! but with different entities. Uses connected components and ranked
8//! bipartite matching by relation profile similarity, validated by
9//! edge-overlap scoring.
10
11use fxhash::{FxHashMap, FxHashSet};
12
13use crate::core::triple_store::TripleStore;
14
15/// A detected analogy between two domains.
16#[derive(Clone, Debug)]
17pub struct Analogy {
18    /// Mapping from domain A entities to domain B entities.
19    pub mapping: Vec<(String, String)>,
20    /// Relations that are shared between the two domains.
21    pub shared_relations: Vec<String>,
22    /// Quality score: fraction of edges that match under the mapping.
23    pub score: f64,
24    /// Edge-overlap quality: fraction of edges in the smaller domain
25    /// that have a corresponding edge in the other domain under the mapping.
26    pub mapping_quality: f64,
27}
28
29/// Detects structural isomorphisms between subgraphs.
30pub struct AnalogyDetector;
31
32impl AnalogyDetector {
33    /// Find analogies by grouping triples into connected components
34    /// and comparing components with the same relational structure.
35    ///
36    /// `min_shared_relations`: minimum number of shared relation types
37    /// between two components to consider them analogous.
38    pub fn detect(triple_store: &TripleStore, min_shared_relations: usize) -> Vec<Analogy> {
39        let snapshot = triple_store.snapshot();
40        if snapshot.is_empty() {
41            return Vec::new();
42        }
43
44        // Build adjacency list (undirected) for connected components
45        let mut adj: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
46        for t in &snapshot {
47            adj.entry(t.subject_id.clone())
48                .or_default()
49                .insert(t.object_id.clone());
50            adj.entry(t.object_id.clone())
51                .or_default()
52                .insert(t.subject_id.clone());
53        }
54
55        // Find connected components via BFS
56        let components = find_components(&adj);
57
58        // Pre-build subject -> relations map for O(1) lookup
59        let mut subject_relations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
60        for t in &snapshot {
61            subject_relations
62                .entry(t.subject_id.clone())
63                .or_default()
64                .insert(t.relation_id.clone());
65        }
66
67        // For each component, compute its relation signature
68        let mut comp_signatures: Vec<(FxHashSet<String>, FxHashSet<String>)> = Vec::new();
69        for comp_entities in &components {
70            let mut relations = FxHashSet::default();
71            for entity in comp_entities {
72                if let Some(rels) = subject_relations.get(entity) {
73                    relations.extend(rels.iter().cloned());
74                }
75            }
76            comp_signatures.push((comp_entities.clone(), relations));
77        }
78
79        // Compare pairs of components
80        let mut analogies = Vec::new();
81        for i in 0..comp_signatures.len() {
82            for j in (i + 1)..comp_signatures.len() {
83                let (ref entities_a, ref rels_a) = comp_signatures[i];
84                let (ref entities_b, ref rels_b) = comp_signatures[j];
85
86                let shared: Vec<String> = rels_a.intersection(rels_b).cloned().collect();
87                if shared.len() < min_shared_relations {
88                    continue;
89                }
90
91                // Ranked matching: compute all pairwise similarities, then select
92                // best non-conflicting mapping in descending similarity order.
93                if let Some(analogy) = ranked_match(entities_a, entities_b, &shared, &snapshot) {
94                    if analogy.score > 0.0 {
95                        analogies.push(analogy);
96                    }
97                }
98            }
99        }
100
101        analogies.sort_by(|a, b| {
102            b.score
103                .partial_cmp(&a.score)
104                .unwrap_or(std::cmp::Ordering::Equal)
105        });
106        analogies
107    }
108}
109
110/// Find connected components via BFS.
111fn find_components(adj: &FxHashMap<String, FxHashSet<String>>) -> Vec<FxHashSet<String>> {
112    let mut visited = FxHashSet::default();
113    let mut components = Vec::new();
114
115    for start in adj.keys() {
116        if visited.contains(start) {
117            continue;
118        }
119        let mut component = FxHashSet::default();
120        let mut queue = vec![start.clone()];
121        while let Some(node) = queue.pop() {
122            if !visited.insert(node.clone()) {
123                continue;
124            }
125            component.insert(node.clone());
126            if let Some(neighbors) = adj.get(&node) {
127                for n in neighbors {
128                    if !visited.contains(n) {
129                        queue.push(n.clone());
130                    }
131                }
132            }
133        }
134        components.push(component);
135    }
136
137    components
138}
139
140/// Build a relation profile for an entity: relation -> roles (subject/object).
141fn entity_profile(
142    entity: &str,
143    triples: &[crate::core::triple_store::TripleRecord],
144    shared_relations: &[String],
145) -> FxHashMap<String, Vec<String>> {
146    let mut profile: FxHashMap<String, Vec<String>> = FxHashMap::default();
147    for t in triples {
148        if !shared_relations.contains(&t.relation_id) {
149            continue;
150        }
151        if t.subject_id == entity {
152            profile
153                .entry(t.relation_id.clone())
154                .or_default()
155                .push("subject".to_string());
156        }
157        if t.object_id == entity {
158            profile
159                .entry(t.relation_id.clone())
160                .or_default()
161                .push("object".to_string());
162        }
163    }
164    profile
165}
166
167/// Ranked bipartite matching: compute all pairwise profile similarities,
168/// sort by descending similarity, then greedily assign non-conflicting pairs.
169/// After mapping, compute edge-overlap scoring for structural validation.
170fn ranked_match(
171    entities_a: &FxHashSet<String>,
172    entities_b: &FxHashSet<String>,
173    shared_relations: &[String],
174    triples: &[crate::core::triple_store::TripleRecord],
175) -> Option<Analogy> {
176    // Build profiles for each entity in A and B
177    let profiles_a: Vec<(String, FxHashMap<String, Vec<String>>)> = entities_a
178        .iter()
179        .map(|e| (e.clone(), entity_profile(e, triples, shared_relations)))
180        .collect();
181    let profiles_b: Vec<(String, FxHashMap<String, Vec<String>>)> = entities_b
182        .iter()
183        .map(|e| (e.clone(), entity_profile(e, triples, shared_relations)))
184        .collect();
185
186    if profiles_a.is_empty() || profiles_b.is_empty() {
187        return None;
188    }
189
190    // Compute all pairwise similarities
191    let mut pairs: Vec<(f64, usize, usize)> =
192        Vec::with_capacity(profiles_a.len() * profiles_b.len());
193    for (i, (_ea, pa)) in profiles_a.iter().enumerate() {
194        for (j, (_eb, pb)) in profiles_b.iter().enumerate() {
195            let sim = profile_similarity(pa, pb);
196            if sim > 0.0 {
197                pairs.push((sim, i, j));
198            }
199        }
200    }
201
202    // Sort by descending similarity
203    pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
204
205    // Greedily select best non-conflicting mapping
206    let mut used_a = FxHashSet::default();
207    let mut used_b = FxHashSet::default();
208    let mut mapping = Vec::new();
209    let mut total_score = 0.0;
210
211    for (sim, idx_a, idx_b) in &pairs {
212        if used_a.contains(idx_a) || used_b.contains(idx_b) {
213            continue;
214        }
215        used_a.insert(*idx_a);
216        used_b.insert(*idx_b);
217        mapping.push((profiles_a[*idx_a].0.clone(), profiles_b[*idx_b].0.clone()));
218        total_score += sim;
219    }
220
221    if mapping.is_empty() {
222        return None;
223    }
224
225    let score = total_score / mapping.len() as f64;
226
227    // Compute edge-overlap scoring
228    let mapping_quality = compute_edge_overlap(&mapping, shared_relations, triples);
229
230    Some(Analogy {
231        mapping,
232        shared_relations: shared_relations.to_vec(),
233        score,
234        mapping_quality,
235    })
236}
237
238/// Compute edge-overlap: fraction of triples in the smaller domain that
239/// have a corresponding triple in the other domain under the entity mapping.
240///
241/// A triple (s, r, o) in domain A maps to (map(s), r, map(o)) in domain B.
242/// We check how many such mapped triples actually exist.
243fn compute_edge_overlap(
244    mapping: &[(String, String)],
245    shared_relations: &[String],
246    triples: &[crate::core::triple_store::TripleRecord],
247) -> f64 {
248    // Build forward and reverse mapping lookups
249    let forward: FxHashMap<&str, &str> = mapping
250        .iter()
251        .map(|(a, b)| (a.as_str(), b.as_str()))
252        .collect();
253    let reverse: FxHashMap<&str, &str> = mapping
254        .iter()
255        .map(|(a, b)| (b.as_str(), a.as_str()))
256        .collect();
257
258    let mapped_entities_a: FxHashSet<&str> = mapping.iter().map(|(a, _)| a.as_str()).collect();
259    let mapped_entities_b: FxHashSet<&str> = mapping.iter().map(|(_, b)| b.as_str()).collect();
260
261    let shared_set: FxHashSet<&str> = shared_relations.iter().map(|s| s.as_str()).collect();
262
263    // Build a set of (subject, relation, object) triples in B for fast lookup
264    let triples_b: FxHashSet<(&str, &str, &str)> = triples
265        .iter()
266        .filter(|t| {
267            shared_set.contains(t.relation_id.as_str())
268                && mapped_entities_b.contains(t.subject_id.as_str())
269        })
270        .map(|t| {
271            (
272                t.subject_id.as_str(),
273                t.relation_id.as_str(),
274                t.object_id.as_str(),
275            )
276        })
277        .collect();
278
279    // Build a set of (subject, relation, object) triples in A for fast lookup
280    let triples_a: FxHashSet<(&str, &str, &str)> = triples
281        .iter()
282        .filter(|t| {
283            shared_set.contains(t.relation_id.as_str())
284                && mapped_entities_a.contains(t.subject_id.as_str())
285        })
286        .map(|t| {
287            (
288                t.subject_id.as_str(),
289                t.relation_id.as_str(),
290                t.object_id.as_str(),
291            )
292        })
293        .collect();
294
295    // Count how many A-triples map to existing B-triples
296    let mut matched_a = 0usize;
297    for &(subj, rel, obj) in &triples_a {
298        if let (Some(&mapped_subj), Some(&mapped_obj)) = (forward.get(subj), forward.get(obj)) {
299            if triples_b.contains(&(mapped_subj, rel, mapped_obj)) {
300                matched_a += 1;
301            }
302        }
303    }
304
305    // Count how many B-triples map to existing A-triples
306    let mut matched_b = 0usize;
307    for &(subj, rel, obj) in &triples_b {
308        if let (Some(&mapped_subj), Some(&mapped_obj)) = (reverse.get(subj), reverse.get(obj)) {
309            if triples_a.contains(&(mapped_subj, rel, mapped_obj)) {
310                matched_b += 1;
311            }
312        }
313    }
314
315    // Use the smaller domain's edge count as the denominator
316    let count_a = triples_a.len();
317    let count_b = triples_b.len();
318
319    if count_a == 0 && count_b == 0 {
320        return 0.0;
321    }
322
323    // Fraction of edges in the smaller domain that map correctly
324    if count_a <= count_b {
325        if count_a == 0 {
326            0.0
327        } else {
328            matched_a as f64 / count_a as f64
329        }
330    } else if count_b == 0 {
331        0.0
332    } else {
333        matched_b as f64 / count_b as f64
334    }
335}
336
337/// Jaccard-like similarity between two entity profiles.
338fn profile_similarity(
339    a: &FxHashMap<String, Vec<String>>,
340    b: &FxHashMap<String, Vec<String>>,
341) -> f64 {
342    let keys_a: FxHashSet<&String> = a.keys().collect();
343    let keys_b: FxHashSet<&String> = b.keys().collect();
344    let intersection = keys_a.intersection(&keys_b).count();
345    let union = keys_a.union(&keys_b).count();
346    if union == 0 {
347        return 0.0;
348    }
349    intersection as f64 / union as f64
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_detect_analogy() {
358        let store = TripleStore::new();
359        // Domain A: solar system
360        store.add("earth", "orbits", "sun", "c1");
361        store.add("earth", "has_satellite", "moon", "c2");
362        // Domain B: Jupiter system (separate component)
363        store.add("europa", "orbits", "jupiter", "c3");
364        store.add("europa", "has_satellite", "none", "c4");
365
366        let analogies = AnalogyDetector::detect(&store, 2);
367        // Should find an analogy between the two domains
368        if !analogies.is_empty() {
369            assert!(analogies[0].shared_relations.len() >= 2);
370            assert!(analogies[0].score > 0.0);
371        }
372    }
373
374    #[test]
375    fn test_no_analogy_single_component() {
376        let store = TripleStore::new();
377        store.add("a", "r", "b", "c1");
378        store.add("b", "r", "c", "c2");
379        // All in one component, no pair to compare
380        let analogies = AnalogyDetector::detect(&store, 1);
381        assert!(analogies.is_empty());
382    }
383
384    #[test]
385    fn test_no_analogy_empty() {
386        let store = TripleStore::new();
387        let analogies = AnalogyDetector::detect(&store, 1);
388        assert!(analogies.is_empty());
389    }
390
391    #[test]
392    fn test_find_components() {
393        let mut adj: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
394        adj.entry("a".to_string())
395            .or_default()
396            .insert("b".to_string());
397        adj.entry("b".to_string())
398            .or_default()
399            .insert("a".to_string());
400        adj.entry("c".to_string())
401            .or_default()
402            .insert("d".to_string());
403        adj.entry("d".to_string())
404            .or_default()
405            .insert("c".to_string());
406
407        let components = find_components(&adj);
408        assert_eq!(components.len(), 2);
409    }
410
411    #[test]
412    fn test_ranked_matching_quality() {
413        let store = TripleStore::new();
414        // Domain A: structured knowledge graph
415        store.add("cat", "is_a", "animal", "c1");
416        store.add("cat", "has_part", "tail", "c2");
417        store.add("animal", "lives_in", "habitat", "c3");
418        // Domain B: parallel structure with different entities
419        store.add("dog", "is_a", "creature", "c4");
420        store.add("dog", "has_part", "paw", "c5");
421        store.add("creature", "lives_in", "den", "c6");
422
423        let analogies = AnalogyDetector::detect(&store, 2);
424        assert!(!analogies.is_empty(), "should detect an analogy");
425
426        let best = &analogies[0];
427        assert!(best.score > 0.0, "score should be positive");
428        assert!(
429            best.mapping_quality > 0.0,
430            "mapping_quality should be positive for structurally isomorphic domains"
431        );
432        // With perfect structural isomorphism, mapping_quality should be 1.0
433        assert!(
434            best.mapping_quality >= 0.5,
435            "mapping_quality should be high for isomorphic domains, got {}",
436            best.mapping_quality
437        );
438        // Verify the mapping covers entities from both domains
439        assert!(
440            best.mapping.len() >= 2,
441            "should map at least 2 entity pairs"
442        );
443    }
444}