Skip to main content

hirn_engine/
scoring.rs

1//! Composite scoring: multi-factor ranking combining similarity, importance,
2//! recency, and activation.
3
4use std::fmt;
5
6use hirn_core::HirnError;
7use hirn_core::record::MemoryRecord;
8use hirn_core::types::Origin;
9
10/// Scoring weights for the composite formula:
11///
12/// `score = α·similarity + β·importance + γ·recency(t) + δ·activation(t) + ε·causal_relevance + ζ·surprise + η·source_reliability`
13///
14/// Surprise (ζ) captures Bayesian surprise from EM-LLM (ICLR 2025): high-surprise
15/// memories are preferentially retrieved in ambiguous queries.
16#[derive(Debug, Clone, Copy)]
17pub struct ScoringWeights {
18    /// α — similarity weight.
19    pub similarity: f32,
20    /// β — importance / confidence weight.
21    pub importance: f32,
22    /// γ — recency weight.
23    pub recency: f32,
24    /// δ — activation weight.
25    pub activation: f32,
26    /// ε — causal relevance weight (active only with FOLLOW CAUSES).
27    pub causal_relevance: f32,
28    /// ζ — surprise weight (F-044). High-surprise memories are preferentially retrieved.
29    pub surprise: f32,
30    /// η — source reliability weight. Direct observation ranked higher than inferred.
31    pub source_reliability: f32,
32}
33
34impl ScoringWeights {
35    /// Validate that weights are in [0.0, 1.0] and sum to 1.0.
36    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        // Weights must sum to 1.0 (verified by the test below).
81        // causal_relevance: 0.05 matches HirnConfig::scoring_causal_relevance_weight default.
82        // surprise: 0.05 reduced from 0.10 to make room for causal_relevance.
83        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/// Map a memory record's provenance to the canonical source-reliability score.
133#[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/// Map a provenance origin to the canonical source-reliability score.
146#[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
156/// Compute the composite score for a single result.
157///
158/// - `similarity`: cosine similarity (or metric-converted) in \[0.0, 1.0\].
159/// - `importance`: record importance / confidence in \[0.0, 1.0\].
160/// - `age_hours`: how many hours ago the record was created.
161/// - `decay_lambda`: base exponential decay constant (from `HirnConfig`).
162/// - `access_freq`: number of times the record has been accessed (for FadeMem modulation).
163/// - `activation`: graph activation score in \[0.0, 1.0\] from spreading activation.
164/// - `causal_rel`: causal relevance score in \[0.0, 1.0\] (0.0 when FOLLOW CAUSES inactive).
165/// - `surprise`: surprise score in \[0.0, 1.0\] (Bayesian surprise from EM-LLM).
166/// - `source_rel`: source reliability score in \[0.0, 1.0\] (direct_observation=1.0, unknown=0.4).
167/// - `weights`: scoring weights.
168///
169/// **FadeMem adaptive decay:** `decay_rate = base × (1/(1+importance)) × (1/(1+access_freq))`.
170/// Important, frequently-accessed memories decay slower.
171pub 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
209/// F-34: Re-export the reranker trait from hirn-core.
210///
211/// The canonical `Reranker` trait now lives in `hirn_core::embed` with a
212/// richer signature (`documents: &[&str], top_k`) designed for cross-encoder
213/// models. The store-local `Reranker` trait is removed in favour of the core one.
214pub 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); // 30 days
265        let recent = composite_score(0.8, 0.5, 1.0, 0.01, 0, 0.0, 0.0, 0.0, 0.0, &w); // 1 hour
266        assert!(recent > old);
267    }
268
269    #[test]
270    fn recency_decay() {
271        let w = ScoringWeights::PURE_SIMILARITY;
272        // With pure similarity, recency doesn't matter.
273        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}