1use crate::candidates::CandidateResult;
4use hippmem_core::config::AlgoParams;
5use hippmem_core::score::UnitScore;
6
7const W_LEXICAL: f32 = 0.18;
9const W_BINARY: f32 = 0.10;
10
11fn cold_start_factor(total_memories: u32, params: &AlgoParams) -> f32 {
19 let cold = params.cold_start_count;
20 if total_memories < cold {
21 params.single_semantic_penalty
22 } else if total_memories >= cold * 2 {
23 1.0
24 } else {
25 let t = (total_memories - cold) as f32 / cold as f32;
26 params.single_semantic_penalty + (1.0 - params.single_semantic_penalty) * t
27 }
28}
29
30pub fn associate_score(
38 c: &CandidateResult,
39 total_dim_hits: usize,
40 total_memory_count: u32,
41 params: &AlgoParams,
42) -> UnitScore {
43 let n = total_dim_hits.max(1) as f32;
44
45 let mut raw = params.w_entity * c.entity_jaccard
48 + params.w_topic * c.topic_jaccard
49 + params.w_goal * c.goal_jaccard
50 + params.w_event * c.event_jaccard
51 + params.w_temporal * overlap_ratio(c.temporal_overlap, n)
52 + params.w_causal * overlap_ratio(c.causal_overlap, n)
53 + params.w_emotion * overlap_ratio(c.emotion_overlap, n)
54 + params.w_importance * c.importance_value
55 + params.w_context * c.co_context_score;
56
57 if !c.matched_dimensions.is_empty() {
60 raw += W_LEXICAL * c.lexical_similarity + W_BINARY * c.semantic_binary_similarity;
61 }
62
63 let dim_count = c.matched_dimensions.len();
65 if dim_count >= params.multi_dim_min_dims as usize {
66 raw += params.multi_dim_bonus;
67 }
68
69 if dim_count == 1
72 && c.matched_dimensions
73 .contains(&hippmem_core::model::links::MatchDimension::Semantic)
74 {
75 raw *= cold_start_factor(total_memory_count, params);
76 }
77
78 UnitScore::new(raw.clamp(0.0, 1.0))
79}
80
81fn overlap_ratio(overlap: usize, norm: f32) -> f32 {
82 if overlap == 0 {
83 0.0
84 } else {
85 (overlap as f32 / norm).min(1.0)
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use hippmem_core::model::links::MatchDimension;
93
94 #[test]
95 fn zero_overlap_gives_low_score() {
96 let c = CandidateResult {
97 matched_dimensions: vec![],
98 entity_jaccard: 0.0,
99 topic_jaccard: 0.0,
100 temporal_overlap: 0,
101 goal_jaccard: 0.0,
102 event_jaccard: 0.0,
103 causal_overlap: 0,
104 emotion_overlap: 0,
105 importance_value: 0.0,
106 co_context_score: 0.0,
107 lexical_similarity: 0.0,
108 semantic_binary_similarity: 0.0,
109 };
110 let s = associate_score(&c, 1, 1000, &AlgoParams::default());
111 assert_eq!(s.value(), 0.0);
112 }
113
114 #[test]
115 fn jaccard_exact_values() {
116 let c = CandidateResult {
118 matched_dimensions: vec![MatchDimension::Entity],
119 entity_jaccard: 0.5, topic_jaccard: 0.0,
121 temporal_overlap: 0,
122 goal_jaccard: 0.0,
123 event_jaccard: 0.0,
124 causal_overlap: 0,
125 emotion_overlap: 0,
126 importance_value: 0.0,
127 co_context_score: 0.0,
128 lexical_similarity: 0.0,
129 semantic_binary_similarity: 0.0,
130 };
131 let s = associate_score(&c, 1, 1000, &AlgoParams::default());
132 let expected = 0.20 * 0.5; assert!(
134 (s.value() - expected).abs() < 0.001,
135 "entity_jaccard=0.5 → score ≈ {} (W_ENTITY={})",
136 expected,
137 0.20
138 );
139 }
140
141 #[test]
142 fn multi_dim_boost() {
143 let c = CandidateResult {
144 matched_dimensions: vec![
145 MatchDimension::Entity,
146 MatchDimension::Topic,
147 MatchDimension::Temporal,
148 ],
149 entity_jaccard: 0.6,
150 topic_jaccard: 0.5,
151 temporal_overlap: 1,
152 goal_jaccard: 0.0,
153 event_jaccard: 0.0,
154 causal_overlap: 0,
155 emotion_overlap: 0,
156 importance_value: 0.0,
157 co_context_score: 0.0,
158 lexical_similarity: 0.8,
159 semantic_binary_similarity: 0.0,
160 };
161 let s = associate_score(&c, 3, 1000, &AlgoParams::default());
162 assert!(s.value() > 0.3, "multi-dim should get a bonus");
164 }
165
166 fn make_semantic_only() -> CandidateResult {
169 CandidateResult {
170 matched_dimensions: vec![MatchDimension::Semantic],
171 entity_jaccard: 0.0,
172 topic_jaccard: 0.0,
173 temporal_overlap: 0,
174 goal_jaccard: 0.0,
175 event_jaccard: 0.0,
176 causal_overlap: 0,
177 emotion_overlap: 0,
178 importance_value: 0.0,
179 co_context_score: 0.0,
180 lexical_similarity: 0.5,
181 semantic_binary_similarity: 0.0,
182 }
183 }
184
185 #[test]
186 fn cold_start_below_threshold_penalty_applies() {
187 let params = AlgoParams::default();
188 let c = make_semantic_only();
189 let s = associate_score(&c, 1, 100, ¶ms);
191 assert!(s.value() > 0.0);
193 assert!(
194 s.value() < 0.1,
195 "single-semantic should be penalized during cold start"
196 );
197 }
198
199 #[test]
200 fn cold_start_at_threshold_penalty_applies() {
201 let params = AlgoParams::default();
202 let c = make_semantic_only();
203 let s_cold = associate_score(&c, 1, 500, ¶ms);
205 assert!(s_cold.value() > 0.0);
206 assert!(s_cold.value() < 0.1);
207 }
208
209 #[test]
210 fn cold_start_warming_up_linear() {
211 let params = AlgoParams::default();
212 let c = make_semantic_only();
213 let s_mid = associate_score(&c, 1, 750, ¶ms);
216 let s_cold = associate_score(&c, 1, 500, ¶ms);
218 assert!(
220 s_mid.value() > s_cold.value(),
221 "warm-up score({}) > cold-start score({})",
222 s_mid.value(),
223 s_cold.value()
224 );
225 }
226
227 #[test]
228 fn cold_start_fully_warmed_no_penalty() {
229 let params = AlgoParams::default();
230 let c = make_semantic_only();
231 let s_warm = associate_score(&c, 1, 1000, ¶ms);
233 let s_cold = associate_score(&c, 1, 100, ¶ms);
235 let expected_ratio = s_warm.value() / s_cold.value();
238 assert!(
239 expected_ratio > 1.5,
240 "fully-warmed score should be much greater than cold-start: warm={}, cold={}, ratio={}",
241 s_warm.value(),
242 s_cold.value(),
243 expected_ratio
244 );
245 }
246
247 #[test]
248 fn cold_start_no_effect_on_multi_dim() {
249 let params = AlgoParams::default();
250 let c = CandidateResult {
251 matched_dimensions: vec![MatchDimension::Entity, MatchDimension::Topic],
252 entity_jaccard: 0.6,
253 topic_jaccard: 0.5,
254 temporal_overlap: 0,
255 goal_jaccard: 0.0,
256 event_jaccard: 0.0,
257 causal_overlap: 0,
258 emotion_overlap: 0,
259 importance_value: 0.0,
260 co_context_score: 0.0,
261 lexical_similarity: 0.8,
262 semantic_binary_similarity: 0.0,
263 };
264 let s_cold = associate_score(&c, 2, 100, ¶ms);
266 let s_warm = associate_score(&c, 2, 1000, ¶ms);
267 assert!(
268 (s_cold.value() - s_warm.value()).abs() < 0.001,
269 "cold start should not affect score on multi-dim hit"
270 );
271 }
272}