1use std::fmt;
5
6use hirn_core::HirnError;
7use hirn_core::record::MemoryRecord;
8use hirn_core::types::Origin;
9
10#[derive(Debug, Clone, Copy)]
17pub struct ScoringWeights {
18 pub similarity: f32,
20 pub importance: f32,
22 pub recency: f32,
24 pub activation: f32,
26 pub causal_relevance: f32,
28 pub surprise: f32,
30 pub source_reliability: f32,
32}
33
34impl ScoringWeights {
35 pub fn validate(&self) -> Result<(), HirnError> {
37 for (name, w) in [
38 ("similarity", self.similarity),
39 ("importance", self.importance),
40 ("recency", self.recency),
41 ("activation", self.activation),
42 ("causal_relevance", self.causal_relevance),
43 ("surprise", self.surprise),
44 ("source_reliability", self.source_reliability),
45 ] {
46 if w < 0.0 || w > 1.0 {
47 return Err(HirnError::InvalidInput(format!(
48 "scoring weight '{name}' must be in [0.0, 1.0], got {w}"
49 )));
50 }
51 }
52 let sum = self.similarity
53 + self.importance
54 + self.recency
55 + self.activation
56 + self.causal_relevance
57 + self.surprise
58 + self.source_reliability;
59 if (sum - 1.0).abs() > 1e-4 {
60 return Err(HirnError::InvalidInput(format!(
61 "scoring weights must sum to 1.0, got {sum}"
62 )));
63 }
64 Ok(())
65 }
66
67 pub const PURE_SIMILARITY: Self = Self {
68 similarity: 1.0,
69 importance: 0.0,
70 recency: 0.0,
71 activation: 0.0,
72 causal_relevance: 0.0,
73 surprise: 0.0,
74 source_reliability: 0.0,
75 };
76}
77
78impl Default for ScoringWeights {
79 fn default() -> Self {
80 Self {
84 similarity: 0.30,
85 importance: 0.20,
86 recency: 0.20,
87 activation: 0.10,
88 causal_relevance: 0.05,
89 surprise: 0.05,
90 source_reliability: 0.10,
91 }
92 }
93}
94
95#[cfg(test)]
96mod weight_tests {
97 use super::*;
98
99 #[test]
100 fn scoring_weights_default_sum_to_one() {
101 ScoringWeights::default().validate().unwrap();
102 }
103}
104
105#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
106pub struct ScoreBreakdown {
107 pub similarity: f32,
108 pub importance: f32,
109 pub recency: f32,
110 pub activation: f32,
111 pub causal_relevance: f32,
112 pub surprise: f32,
113 pub source_reliability: f32,
114}
115
116impl fmt::Display for ScoreBreakdown {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(
119 f,
120 "sim={:.3} imp={:.3} rec={:.3} act={:.3} caus={:.3} sur={:.3} src={:.3}",
121 self.similarity,
122 self.importance,
123 self.recency,
124 self.activation,
125 self.causal_relevance,
126 self.surprise,
127 self.source_reliability,
128 )
129 }
130}
131
132#[must_use]
134pub fn source_reliability_for_record(record: &MemoryRecord) -> f32 {
135 let origin = match record {
136 MemoryRecord::Episodic(e) => e.provenance.origin(),
137 MemoryRecord::Semantic(s) => s.provenance.origin(),
138 MemoryRecord::Working(_) => return 0.8,
139 MemoryRecord::Procedural(_) => return 0.8,
140 };
141
142 source_reliability_for_origin(*origin)
143}
144
145#[must_use]
147pub fn source_reliability_for_origin(origin: Origin) -> f32 {
148 match origin {
149 Origin::DirectObservation | Origin::UserProvided => 1.0,
150 Origin::LlmExtraction => 0.8,
151 Origin::Consolidation | Origin::DreamReplay => 0.6,
152 Origin::CrossAgent => 0.5,
153 }
154}
155
156pub fn composite_score(
172 similarity: f32,
173 importance: f32,
174 age_hours: f64,
175 decay_lambda: f64,
176 access_freq: u64,
177 activation: f32,
178 causal_rel: f32,
179 surprise: f32,
180 source_rel: f32,
181 weights: &ScoringWeights,
182) -> f32 {
183 let recency = fade_mem_recency(importance, age_hours, decay_lambda, access_freq);
184
185 let score = weights.similarity * similarity.clamp(0.0, 1.0)
186 + weights.importance * importance.clamp(0.0, 1.0)
187 + weights.recency * recency.clamp(0.0, 1.0)
188 + weights.activation * activation.clamp(0.0, 1.0)
189 + weights.causal_relevance * causal_rel.clamp(0.0, 1.0)
190 + weights.surprise * surprise.clamp(0.0, 1.0)
191 + weights.source_reliability * source_rel.clamp(0.0, 1.0);
192
193 score.clamp(0.0, 1.0)
194}
195
196#[must_use]
197pub fn fade_mem_recency(
198 importance: f32,
199 age_hours: f64,
200 decay_lambda: f64,
201 access_freq: u64,
202) -> f32 {
203 let imp = importance.clamp(0.0, 1.0) as f64;
204 let freq = access_freq as f64;
205 let adaptive_rate = decay_lambda * (1.0 / (1.0 + imp)) * (1.0 / (1.0 + freq));
206 (-adaptive_rate * age_hours).exp() as f32
207}
208
209pub use hirn_core::embed::{NoopReranker, RerankResult, Reranker};
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn pure_similarity() {
222 let score = composite_score(
223 0.9,
224 0.5,
225 1.0,
226 0.01,
227 0,
228 0.0,
229 0.0,
230 0.0,
231 0.0,
232 &ScoringWeights::PURE_SIMILARITY,
233 );
234 assert!((score - 0.9).abs() < 1e-4);
235 }
236
237 #[test]
238 fn higher_importance_ranks_higher() {
239 let w = ScoringWeights {
240 similarity: 0.5,
241 importance: 0.5,
242 recency: 0.0,
243 activation: 0.0,
244 causal_relevance: 0.0,
245 surprise: 0.0,
246 source_reliability: 0.0,
247 };
248 let low = composite_score(0.8, 0.2, 0.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w);
249 let high = composite_score(0.8, 0.9, 0.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w);
250 assert!(high > low);
251 }
252
253 #[test]
254 fn more_recent_ranks_higher() {
255 let w = ScoringWeights {
256 similarity: 0.5,
257 importance: 0.0,
258 recency: 0.5,
259 activation: 0.0,
260 causal_relevance: 0.0,
261 surprise: 0.0,
262 source_reliability: 0.0,
263 };
264 let old = composite_score(0.8, 0.5, 720.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w); let recent = composite_score(0.8, 0.5, 1.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w); assert!(recent > old);
267 }
268
269 #[test]
270 fn recency_decay() {
271 let w = ScoringWeights::PURE_SIMILARITY;
272 let s1 = composite_score(0.9, 0.5, 1.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w);
274 let s2 = composite_score(0.9, 0.5, 720.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w);
275 assert!((s1 - s2).abs() < 1e-4);
276 }
277
278 #[test]
279 fn score_in_range() {
280 let w = ScoringWeights::default();
281 for sim in [0.0, 0.1, 0.5, 0.9, 1.0] {
282 for imp in [0.0, 0.5, 1.0] {
283 for age in [0.0, 1.0, 24.0, 720.0] {
284 let s = composite_score(sim, imp, age, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w);
285 assert!(
286 (0.0..=1.0).contains(&s),
287 "score {s} out of range for sim={sim}, imp={imp}, age={age}"
288 );
289 }
290 }
291 }
292 }
293
294 #[test]
295 fn invalid_weights() {
296 let w = ScoringWeights {
297 similarity: 0.5,
298 importance: 0.5,
299 recency: 0.5,
300 activation: 0.0,
301 causal_relevance: 0.0,
302 surprise: 0.0,
303 source_reliability: 0.0,
304 };
305 assert!(w.validate().is_err());
306 }
307
308 #[test]
309 fn valid_weights() {
310 ScoringWeights::default().validate().unwrap();
311 ScoringWeights::PURE_SIMILARITY.validate().unwrap();
312 }
313}