Skip to main content

hippmem_write/
scoring.rs

1//! Association scoring: multi-dimensional scoring function (03 §2).
2
3use crate::candidates::CandidateResult;
4use hippmem_core::config::AlgoParams;
5use hippmem_core::score::UnitScore;
6
7/// Lexical/binary are not yet split out in AlgoParams; keep temporary constants.
8const W_LEXICAL: f32 = 0.18;
9const W_BINARY: f32 = 0.10;
10
11/// Compute the cold-start warm-up factor (03 §2.3).
12///
13/// When the total memory count < `cold_start_count`, the single-semantic
14/// penalty is active (= `single_semantic_penalty`);
15/// as memories grow, the factor ramps linearly from `single_semantic_penalty`
16/// back up to 1.0;
17/// when `total >= 2 * cold_start_count`, the penalty disappears entirely.
18fn 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
30/// Compute the association score (range [0, 1]).
31///
32/// - `candidate`: candidate discovery result (includes Jaccard similarity).
33/// - `total_dim_hits`: total number of dimensions hit.
34/// - `total_memory_count`: current total memory count (used for cold-start
35///   warm-up, 03 §2.3).
36/// - `params`: configurable algorithm parameters (weights/bonuses/penalties).
37pub 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    // Entity/Topic/Goal/Event: use Jaccard similarity (03 §2.1)
46    // Temporal/Causal/Emotion: use overlap count normalized
47    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    // lexical/binary participate only when other dimensions hit (avoid pure
58    // noise signals)
59    if !c.matched_dimensions.is_empty() {
60        raw += W_LEXICAL * c.lexical_similarity + W_BINARY * c.semantic_binary_similarity;
61    }
62
63    // Multi-dimensional bonus
64    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    // Single-semantic penalty: penalize only when the Semantic dimension alone
70    // hits; the factor warms up with total memory count (03 §2.3)
71    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        // Entity Jaccard: |A∩B|=2, |A|=3, |B|=3 → union=4 → 2/4=0.5
117        let c = CandidateResult {
118            matched_dimensions: vec![MatchDimension::Entity],
119            entity_jaccard: 0.5, // |{a,b,c} ∩ {a,b,d}| / |{a,b,c} ∪ {a,b,d}| = 2/4
120            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; // W_ENTITY * 0.5 = 0.10
133        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        // Multi-dim bonus should make the score > the no-bonus case
163        assert!(s.value() > 0.3, "multi-dim should get a bonus");
164    }
165
166    // ── Cold-start warm-up tests (03 §2.3) ──
167
168    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        // total=100 < cold_start_count=500 → factor = 0.6
190        let s = associate_score(&c, 1, 100, &params);
191        // score = (lexical 0.18 * 0.5) * 0.6 = 0.09 * 0.6 = 0.054
192        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        // total == cold_start_count=500 → factor = 0.6
204        let s_cold = associate_score(&c, 1, 500, &params);
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        // total=750 (midpoint between 500 and 1000)
214        // factor = 0.6 + 0.4 * (250/500) = 0.6 + 0.2 = 0.8
215        let s_mid = associate_score(&c, 1, 750, &params);
216        // total=500 → factor=0.6
217        let s_cold = associate_score(&c, 1, 500, &params);
218        // Score during warm-up should be greater than during cold start
219        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        // total=1000 (== 2*cold_start_count) → factor = 1.0
232        let s_warm = associate_score(&c, 1, 1000, &params);
233        // total=100 (cold start) → factor = 0.6
234        let s_cold = associate_score(&c, 1, 100, &params);
235        // Fully-warmed score = cold-start score / 0.6 (because factor goes from
236        // 0.6 to 1.0)
237        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        // Multi-dim hit: cold start should not affect it (dim_count >= 2)
265        let s_cold = associate_score(&c, 2, 100, &params);
266        let s_warm = associate_score(&c, 2, 1000, &params);
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}