Skip to main content

hippmem_write/
candidates.rs

1//! Multi-dimensional candidate discovery: match on multiple dimensions based
2//! on AssociationKeys (03 §2).
3
4use hippmem_core::model::links::{AssociationKeys, MatchDimension};
5use std::collections::HashSet;
6
7/// Candidate discovery result: includes matched dimensions and similarity info.
8#[derive(Debug, Clone)]
9pub struct CandidateResult {
10    /// List of dimensions that hit.
11    pub matched_dimensions: Vec<MatchDimension>,
12    /// Entity Jaccard similarity: |A∩B| / |A∪B| (03 §2.1).
13    pub entity_jaccard: f32,
14    /// Topic Jaccard similarity.
15    pub topic_jaccard: f32,
16    /// Temporal key intersection size (temporal proximity is not Jaccard,
17    /// 03 §2.1).
18    pub temporal_overlap: usize,
19    /// Goal Jaccard similarity.
20    pub goal_jaccard: f32,
21    /// Event Jaccard similarity.
22    pub event_jaccard: f32,
23    /// Causal key intersection size (causal crossover is not Jaccard, 03 §2.1).
24    pub causal_overlap: usize,
25    /// Emotion key intersection size (count of same emotion categories,
26    /// 03 §2.1).
27    pub emotion_overlap: usize,
28    /// Importance value of the existing memory (filled in by the caller from
29    /// MemoryUnit, 03 §2.1).
30    pub importance_value: f32,
31    /// Context sharing score (shared session/conversation/project/task,
32    /// 03 §2.1).
33    /// Filled in by the caller after computing; discover_candidates only sets
34    /// the default value 0.0.
35    pub co_context_score: f32,
36    /// SimHash Hamming similarity (0.0 = completely different, 1.0 = identical).
37    pub lexical_similarity: f32,
38    /// Semantic binary-code Hamming similarity.
39    pub semantic_binary_similarity: f32,
40}
41
42/// Discover multi-dimensional candidate matches between two AssociationKeys.
43pub fn discover_candidates(a: &AssociationKeys, b: &AssociationKeys) -> CandidateResult {
44    let mut dims = Vec::new();
45
46    let set = |v: &[u64]| -> HashSet<u64> { v.iter().copied().collect() };
47
48    // Entity: Jaccard similarity (03 §2.1)
49    let a_ent = set(&a.entity_keys);
50    let b_ent = set(&b.entity_keys);
51    let ent_intersect = a_ent.intersection(&b_ent).count();
52    let ent_union = a_ent.len() + b_ent.len() - ent_intersect;
53    let entity_jaccard = if ent_union > 0 {
54        ent_intersect as f32 / ent_union as f32
55    } else {
56        0.0
57    };
58    if entity_jaccard > 0.0 {
59        dims.push(MatchDimension::Entity);
60    }
61
62    // Topic: Jaccard similarity
63    let a_top = set(&a.topic_keys);
64    let b_top = set(&b.topic_keys);
65    let top_intersect = a_top.intersection(&b_top).count();
66    let top_union = a_top.len() + b_top.len() - top_intersect;
67    let topic_jaccard = if top_union > 0 {
68        top_intersect as f32 / top_union as f32
69    } else {
70        0.0
71    };
72    if topic_jaccard > 0.0 {
73        dims.push(MatchDimension::Topic);
74    }
75
76    // Temporal: time-bucket overlap (not Jaccard, 03 §2.1)
77    let a_tmp: HashSet<u32> = a.temporal_keys.iter().copied().collect();
78    let b_tmp: HashSet<u32> = b.temporal_keys.iter().copied().collect();
79    let temporal_overlap = a_tmp.intersection(&b_tmp).count();
80    if temporal_overlap > 0 {
81        dims.push(MatchDimension::Temporal);
82    }
83
84    // Goal: Jaccard similarity
85    let a_goal = set(&a.goal_keys);
86    let b_goal = set(&b.goal_keys);
87    let goal_intersect = a_goal.intersection(&b_goal).count();
88    let goal_union = a_goal.len() + b_goal.len() - goal_intersect;
89    let goal_jaccard = if goal_union > 0 {
90        goal_intersect as f32 / goal_union as f32
91    } else {
92        0.0
93    };
94    if goal_jaccard > 0.0 {
95        dims.push(MatchDimension::Goal);
96    }
97
98    // Event: Jaccard similarity
99    let a_evt = set(&a.event_keys);
100    let b_evt = set(&b.event_keys);
101    let evt_intersect = a_evt.intersection(&b_evt).count();
102    let evt_union = a_evt.len() + b_evt.len() - evt_intersect;
103    let event_jaccard = if evt_union > 0 {
104        evt_intersect as f32 / evt_union as f32
105    } else {
106        0.0
107    };
108    if event_jaccard > 0.0 {
109        dims.push(MatchDimension::Event);
110    }
111
112    // Causal: causal-key crossover (not Jaccard, 03 §2.1)
113    let a_cau = set(&a.causal_keys);
114    let b_cau = set(&b.causal_keys);
115    let causal_overlap = a_cau.intersection(&b_cau).count();
116    if causal_overlap > 0 {
117        dims.push(MatchDimension::Causal);
118    }
119
120    // Emotion: emotion-category overlap (u8 direct comparison, 03 §2.1)
121    let a_emo: HashSet<u8> = a.emotion_keys.iter().copied().collect();
122    let b_emo: HashSet<u8> = b.emotion_keys.iter().copied().collect();
123    let emotion_overlap = a_emo.intersection(&b_emo).count();
124    if emotion_overlap > 0 {
125        dims.push(MatchDimension::Emotion);
126    }
127
128    let lexical_similarity =
129        simhash_similarity(&a.lexical_signature.simhash, &b.lexical_signature.simhash);
130    // Only count as a semantic match when both SimHashes have signal (not all
131    // zero) and are similar enough
132    let a_has_signal = a.lexical_signature.simhash.iter().any(|&x| x != 0);
133    let b_has_signal = b.lexical_signature.simhash.iter().any(|&x| x != 0);
134    if a_has_signal && b_has_signal && lexical_similarity > 0.7 {
135        dims.push(MatchDimension::Semantic);
136    }
137
138    let binary_sim = binary_similarity(
139        &a.semantic_signature.binary_code,
140        &b.semantic_signature.binary_code,
141    );
142
143    CandidateResult {
144        matched_dimensions: dims,
145        entity_jaccard,
146        topic_jaccard,
147        temporal_overlap,
148        goal_jaccard,
149        event_jaccard,
150        causal_overlap,
151        emotion_overlap,
152        importance_value: 0.0,
153        co_context_score: 0.0,
154        lexical_similarity,
155        semantic_binary_similarity: binary_sim,
156    }
157}
158
159pub(crate) fn simhash_similarity(a: &[u64; 4], b: &[u64; 4]) -> f32 {
160    let same: u32 = a
161        .iter()
162        .zip(b.iter())
163        .map(|(x, y)| 64 - (x ^ y).count_ones())
164        .sum();
165    same as f32 / 256.0
166}
167
168fn binary_similarity(a: &[u64; 2], b: &[u64; 2]) -> f32 {
169    let same: u32 = a
170        .iter()
171        .zip(b.iter())
172        .map(|(x, y)| 64 - (x ^ y).count_ones())
173        .sum();
174    same as f32 / 128.0
175}