1use crate::candidates::CandidateResult;
5use crate::scoring::associate_score;
6use hippmem_core::config::AlgoParams;
7use hippmem_core::ids::MemoryId;
8use hippmem_core::model::links::{
9 AssociationLink, LinkDirection, LinkEvidence, LinkType, MatchDimension, ObservationState,
10};
11use hippmem_core::time::Timestamp;
12
13pub struct EdgeBuildParams {
15 pub strong_threshold: f32,
16 pub strong_max: usize,
17 pub weak_max: usize,
18 pub min_score: f32,
19 pub observation_max: f32,
20 pub max_candidates: usize,
25}
26
27impl Default for EdgeBuildParams {
28 fn default() -> Self {
29 Self {
30 strong_threshold: 0.55,
31 strong_max: 8,
32 weak_max: 24,
33 min_score: 0.25,
34 observation_max: 0.55,
35 max_candidates: 0, }
37 }
38}
39
40pub struct EdgeBuildResult {
41 pub created_links: Vec<AssociationLink>,
42}
43
44#[allow(clippy::too_many_arguments)]
50pub fn build_edges(
51 source_id: MemoryId,
52 target_id: MemoryId,
53 candidate: &CandidateResult,
54 dim_count: usize,
55 edge_params: &EdgeBuildParams,
56 algo_params: &AlgoParams,
57 existing_links: &[AssociationLink],
58 now: Timestamp,
59 total_memory_count: u32,
60) -> EdgeBuildResult {
61 if source_id == target_id {
62 return EdgeBuildResult {
63 created_links: vec![],
64 };
65 }
66
67 let score = associate_score(candidate, dim_count, total_memory_count, algo_params);
68 let sv = score.value();
69
70 if sv < edge_params.min_score {
71 return EdgeBuildResult {
72 created_links: vec![],
73 };
74 }
75
76 let link_type = determine_link_type(candidate);
77
78 if existing_links
80 .iter()
81 .any(|l| l.target_id == target_id && l.link_type == link_type)
82 {
83 return EdgeBuildResult {
84 created_links: vec![],
85 };
86 }
87
88 let observation = if sv <= edge_params.observation_max {
89 ObservationState::Observing { since: now }
90 } else {
91 ObservationState::Confirmed
92 };
93
94 let evidence = LinkEvidence {
95 contributing_dimensions: candidate.matched_dimensions.clone(),
96 score_breakdown: candidate
97 .matched_dimensions
98 .iter()
99 .map(|&d| (d, sv))
100 .collect(),
101 text_spans: vec![],
102 note: None,
103 };
104
105 let link = AssociationLink {
106 target_id,
107 link_type,
108 direction: LinkDirection::Forward,
109 strength: score,
110 confidence: score,
111 evidence,
112 formed_at: now,
113 last_activated_at: Some(now),
114 activation_count: 0,
115 observation,
116 };
117
118 EdgeBuildResult {
119 created_links: vec![link],
120 }
121}
122
123fn determine_link_type(candidate: &CandidateResult) -> LinkType {
124 let dims = &candidate.matched_dimensions;
125 if dims.contains(&MatchDimension::Causal) {
127 LinkType::Causal
128 } else if dims.contains(&MatchDimension::Entity) {
129 LinkType::EntityOverlap
130 } else if dims.contains(&MatchDimension::Temporal) {
131 LinkType::TemporalAdjacent
132 } else if dims.contains(&MatchDimension::Topic) {
133 LinkType::TopicRelated
134 } else if dims.contains(&MatchDimension::Goal) {
135 LinkType::SameGoal
136 } else if dims.contains(&MatchDimension::Event) {
137 LinkType::SameEvent
138 } else {
139 LinkType::SemanticSimilar
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use hippmem_core::score::UnitScore;
147
148 fn make_cand(dims: Vec<MatchDimension>, n: usize) -> CandidateResult {
149 CandidateResult {
150 matched_dimensions: dims,
151 entity_jaccard: if n >= 1 { 0.6 } else { 0.0 },
152 topic_jaccard: if n >= 2 { 0.5 } else { 0.0 },
153 temporal_overlap: if n >= 3 { 1 } else { 0 },
154 goal_jaccard: 0.0,
155 event_jaccard: 0.0,
156 causal_overlap: 0,
157 emotion_overlap: 0,
158 importance_value: 0.0,
159 co_context_score: 0.0,
160 lexical_similarity: 0.8,
161 semantic_binary_similarity: 0.0,
162 }
163 }
164
165 #[test]
166 fn no_self_loop() {
167 let c = make_cand(vec![MatchDimension::Entity], 1);
168 let r = build_edges(
169 MemoryId(1),
170 MemoryId(1),
171 &c,
172 2,
173 &EdgeBuildParams::default(),
174 &AlgoParams::default(),
175 &[],
176 Timestamp(0),
177 1000,
178 );
179 assert!(r.created_links.is_empty());
180 }
181
182 #[test]
183 fn dedup_skips_existing() {
184 let c = make_cand(vec![MatchDimension::Entity], 2);
185 let existing = vec![AssociationLink {
186 target_id: MemoryId(2),
187 link_type: LinkType::EntityOverlap,
188 direction: LinkDirection::Forward,
189 strength: UnitScore::new(0.5),
190 confidence: UnitScore::new(0.5),
191 evidence: LinkEvidence {
192 contributing_dimensions: vec![],
193 score_breakdown: vec![],
194 text_spans: vec![],
195 note: None,
196 },
197 formed_at: Timestamp(0),
198 last_activated_at: None,
199 activation_count: 0,
200 observation: ObservationState::Confirmed,
201 }];
202 let r = build_edges(
203 MemoryId(1),
204 MemoryId(2),
205 &c,
206 2,
207 &EdgeBuildParams::default(),
208 &AlgoParams::default(),
209 &existing,
210 Timestamp(0),
211 1000,
212 );
213 assert!(r.created_links.is_empty());
214 }
215
216 #[test]
217 fn strong_edge_with_multi_dim() {
218 let c = make_cand(
219 vec![
220 MatchDimension::Entity,
221 MatchDimension::Topic,
222 MatchDimension::Temporal,
223 ],
224 3,
225 );
226 let r = build_edges(
227 MemoryId(1),
228 MemoryId(2),
229 &c,
230 3,
231 &EdgeBuildParams::default(),
232 &AlgoParams::default(),
233 &[],
234 Timestamp(0),
235 1000,
236 );
237 assert!(!r.created_links.is_empty());
238 }
239}