holographic_memory/core/cognition/
analogy.rs1use fxhash::{FxHashMap, FxHashSet};
12
13use crate::core::triple_store::TripleStore;
14
15#[derive(Clone, Debug)]
17pub struct Analogy {
18 pub mapping: Vec<(String, String)>,
20 pub shared_relations: Vec<String>,
22 pub score: f64,
24 pub mapping_quality: f64,
27}
28
29pub struct AnalogyDetector;
31
32impl AnalogyDetector {
33 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 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 let components = find_components(&adj);
57
58 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 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 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 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
110fn 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
140fn 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
167fn 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 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 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 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
204
205 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 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
238fn compute_edge_overlap(
244 mapping: &[(String, String)],
245 shared_relations: &[String],
246 triples: &[crate::core::triple_store::TripleRecord],
247) -> f64 {
248 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 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 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 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 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 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 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
337fn 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 store.add("earth", "orbits", "sun", "c1");
361 store.add("earth", "has_satellite", "moon", "c2");
362 store.add("europa", "orbits", "jupiter", "c3");
364 store.add("europa", "has_satellite", "none", "c4");
365
366 let analogies = AnalogyDetector::detect(&store, 2);
367 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 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 store.add("cat", "is_a", "animal", "c1");
416 store.add("cat", "has_part", "tail", "c2");
417 store.add("animal", "lives_in", "habitat", "c3");
418 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 assert!(
434 best.mapping_quality >= 0.5,
435 "mapping_quality should be high for isomorphic domains, got {}",
436 best.mapping_quality
437 );
438 assert!(
440 best.mapping.len() >= 2,
441 "should map at least 2 entity pairs"
442 );
443 }
444}