Skip to main content

zeph_memory/five_signal/
novelty.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Novelty signal computer.
5///
6/// Computes `novelty = exp(-λ × days_since_agent_init)` where
7/// `days_since_agent_init = (fact.created_at - session_start) / 86400`.
8///
9/// **Direction:** higher novelty = fact was created close to session start; lower novelty =
10/// fact was created late in a long session. This corrects stale-episodic interference —
11/// facts that accumulated late in long sessions and pollute recall despite weak relevance.
12///
13/// Facts created before session start receive `days = 0.0` → novelty = 1.0.
14pub struct NoveltyComputer {
15    session_start: i64,
16    /// Decay rate λ in `exp(-λ × days)`. Default: `0.1`.
17    decay_rate: f64,
18}
19
20impl NoveltyComputer {
21    /// Create a new `NoveltyComputer`.
22    ///
23    /// # Parameters
24    ///
25    /// - `session_start`: Unix timestamp (seconds) when the agent session began.
26    /// - `decay_rate`: λ in `exp(-λ × days)`. Larger values penalize late-session facts more strongly.
27    ///
28    /// # Examples
29    ///
30    /// ```
31    /// use zeph_memory::five_signal::novelty::NoveltyComputer;
32    ///
33    /// let start = 1_700_000_000_i64;
34    /// let computer = NoveltyComputer::new(start, 0.1);
35    ///
36    /// // Fact created at session start → novelty = 1.0
37    /// assert!((computer.compute(start) - 1.0).abs() < 1e-9);
38    ///
39    /// // Fact created 10 days into the session → novelty ≈ exp(-1.0)
40    /// let ten_days_later = start + 10 * 86400;
41    /// let expected = (-1.0_f64).exp();
42    /// assert!((computer.compute(ten_days_later) - expected).abs() < 1e-9);
43    /// ```
44    #[must_use]
45    pub fn new(session_start: i64, decay_rate: f64) -> Self {
46        Self {
47            session_start,
48            decay_rate,
49        }
50    }
51
52    /// Compute the novelty score for a fact with the given `created_at` timestamp.
53    ///
54    /// Returns a value in `(0.0, 1.0]`. Facts created before or at session start return `1.0`.
55    #[must_use]
56    #[inline]
57    pub fn compute(&self, fact_created_at: i64) -> f64 {
58        let _span = tracing::info_span!("memory.five_signal.novelty.compute").entered();
59        #[expect(clippy::cast_precision_loss)]
60        let days = ((fact_created_at - self.session_start).max(0) as f64) / 86_400.0;
61        (-self.decay_rate * days).exp()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    const SESSION_START: i64 = 1_700_000_000;
70
71    #[test]
72    fn at_session_start_is_one() {
73        let c = NoveltyComputer::new(SESSION_START, 0.1);
74        assert!((c.compute(SESSION_START) - 1.0).abs() < 1e-9);
75    }
76
77    #[test]
78    fn before_session_start_is_one() {
79        let c = NoveltyComputer::new(SESSION_START, 0.1);
80        assert!((c.compute(SESSION_START - 86_400) - 1.0).abs() < 1e-9);
81    }
82
83    #[test]
84    fn ten_days_later() {
85        let c = NoveltyComputer::new(SESSION_START, 0.1);
86        let ten_days = SESSION_START + 10 * 86_400;
87        let expected = (-1.0_f64).exp();
88        assert!((c.compute(ten_days) - expected).abs() < 1e-9);
89    }
90
91    #[test]
92    fn monotone_decreasing() {
93        let c = NoveltyComputer::new(SESSION_START, 0.1);
94        let early = c.compute(SESSION_START + 86_400);
95        let late = c.compute(SESSION_START + 10 * 86_400);
96        assert!(early > late, "novelty must decrease with time");
97    }
98}