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}