Skip to main content

ailake_core/
episodic.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Phase 9 — Episodic memory schema and recency scoring primitives.
3//!
4//! Provides zero-I/O building blocks for agent memory tables:
5//! - `EpisodicMemorySchema` marker + `episodic_columns` constants
6//! - `RecencyConfig` / `recency_weight` — exponential time-decay
7//! - `hybrid_score` — fuses distance, recency, and importance into one ranking signal
8//!
9//! These are pure-math functions with no async, no I/O, no external deps.
10
11use serde::{Deserialize, Serialize};
12
13// ── Column name constants ─────────────────────────────────────────────────────
14
15/// Canonical column names for episodic memory tables.
16///
17/// Include these alongside `llm_columns::*` in the Arrow schema of any
18/// `EpisodicMemorySchema` table. The AI-Lake SDK reads columns by name.
19pub mod episodic_columns {
20    /// Recency weight in [0.0, 1.0] — decays exponentially with time since last access.
21    /// Recomputed by `MemoryDecayJob`; initial value is 1.0 at write time.
22    /// Formula: `exp(-λ * days_since_access)` where λ = `RecencyConfig::lambda`.
23    pub const RECENCY_WEIGHT: &str = "recency_weight";
24
25    /// Number of times this memory chunk has been retrieved by `recall()`.
26    /// Higher access count signals relevance; used by agents for importance inference.
27    pub const ACCESS_COUNT: &str = "access_count";
28
29    /// Timestamp of the most recent retrieval.
30    /// Arrow type: `Timestamp(Nanosecond, Some("UTC"))` — use `ailake_core::now_ns()` to populate.
31    /// Updated in-place via logical delete + reinsert on each `recall()` hit.
32    pub const LAST_ACCESSED_AT: &str = "last_accessed_at";
33
34    /// Agent-assigned importance score in [0.0, 1.0].
35    /// Set at `remember()` time; never decays automatically (unlike `recency_weight`).
36    /// Agents use this to pin critical memories (e.g. user preferences, hard constraints).
37    pub const IMPORTANCE_SCORE: &str = "importance_score";
38
39    /// UUID of the agent instance that owns this memory chunk.
40    /// Matches `tool_call_columns::AGENT_ID` for cross-table joins.
41    pub const AGENT_ID: &str = "agent_id";
42
43    /// UUID of the conversation / task session this memory was created in.
44    pub const SESSION_ID: &str = "session_id";
45
46    /// Timestamp when this memory was first written.
47    /// Arrow type: `Timestamp(Nanosecond, Some("UTC"))` — use `ailake_core::now_ns()` to populate.
48    pub const CREATED_AT: &str = "created_at";
49}
50
51// ── EpisodicMemorySchema marker ───────────────────────────────────────────────
52
53/// Marker struct for episodic agent memory tables (Phase 9).
54/// Actual schema is enforced by column names in `episodic_columns` module.
55///
56/// An episodic memory table extends `LlmContextSchema` with recency and
57/// importance signals, enabling hybrid scoring during recall:
58///
59/// ```text
60/// -- From llm_columns::* (required baseline)
61/// chunk_id:          Utf8
62/// chunk_text:        Utf8
63/// embedding:         FixedSizeBinary(N)  -- F16, cosine
64///
65/// -- From episodic_columns::* (Phase 9 extensions)
66/// agent_id:          Utf8       -- UUID string
67/// session_id:        Utf8       -- UUID string
68/// created_at:        Timestamp(ns, UTC)  -- use ailake_core::now_ns()
69/// recency_weight:    Float32    -- exp(-λ * days_since_access), updated by MemoryDecayJob
70/// access_count:      UInt32     -- incremented on each recall() hit
71/// last_accessed_at:  Timestamp(ns, UTC)  -- updated on recall, use ailake_core::now_ns()
72/// importance_score:  Float32    -- agent-assigned [0.0, 1.0]
73/// ```
74///
75/// **Hybrid scoring**: after HNSW retrieval, re-rank results by
76/// `hybrid_score(distance, recency_weight, importance_score)`. Memories
77/// that are semantically similar AND recently accessed AND flagged important
78/// rank highest.
79///
80/// **Recommended setup**:
81/// - One HNSW over `embedding` (text, cosine, dim=1536).
82/// - Partition by `agent_id` via `VectorStoragePolicy` hidden partitioning.
83/// - Run `MemoryDecayJob` daily to update `recency_weight` via compaction.
84pub struct EpisodicMemorySchema;
85
86// ── Recency decay ─────────────────────────────────────────────────────────────
87
88/// Parameters for exponential time-decay of memory recency.
89///
90/// The decay formula is: `recency_weight = exp(-lambda * days_since_access)`
91///
92/// Common presets:
93/// | Half-life | lambda  |
94/// |-----------|---------|
95/// | 1 day     | 0.693   |
96/// | 1 week    | 0.099   |
97/// | 1 month   | 0.023   |
98/// | 3 months  | 0.0077  |
99#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
100pub struct RecencyConfig {
101    /// Decay rate λ. Must be > 0. Typical range: 0.005–1.0.
102    pub lambda: f32,
103}
104
105impl RecencyConfig {
106    /// λ ≈ ln(2)/7 — half-life of 7 days. Good default for conversational agents.
107    pub const WEEKLY_DECAY: Self = Self { lambda: 0.099 };
108
109    /// λ ≈ ln(2)/30 — half-life of 30 days. Good for long-term knowledge bases.
110    pub const MONTHLY_DECAY: Self = Self { lambda: 0.023 };
111
112    /// λ ≈ ln(2)/1 — half-life of 1 day. Aggressive decay for short-term sessions.
113    pub const DAILY_DECAY: Self = Self { lambda: 0.693 };
114
115    pub fn new(lambda: f32) -> Self {
116        Self { lambda }
117    }
118}
119
120impl Default for RecencyConfig {
121    fn default() -> Self {
122        Self::WEEKLY_DECAY
123    }
124}
125
126/// Compute the recency weight for a memory chunk.
127///
128/// Returns a value in (0.0, 1.0]:
129/// - 1.0 when `days_since_access == 0` (accessed right now)
130/// - 0.5 at the half-life (days = ln(2) / lambda)
131/// - Approaches 0 for very old, never-accessed memories
132///
133/// `days_since_access` may be fractional (e.g. 0.5 = 12 hours).
134/// Negative values are clamped to 0 (future timestamps treated as "now").
135#[inline]
136pub fn recency_weight(days_since_access: f32, cfg: &RecencyConfig) -> f32 {
137    let days = days_since_access.max(0.0);
138    (-cfg.lambda * days).exp()
139}
140
141// ── Hybrid scoring ────────────────────────────────────────────────────────────
142
143/// Compute the hybrid ranking score for a retrieved memory chunk.
144///
145/// Fuses three signals into a single ascending score (lower = better rank,
146/// consistent with AI-Lake's distance-ascending convention):
147///
148/// ```text
149/// hybrid_score = distance / (recency_weight * importance_score)
150/// ```
151///
152/// Rationale:
153/// - `distance` is HNSW cosine distance in [0.0, 2.0] (lower = more similar).
154/// - Dividing by `recency_weight * importance_score` boosts recent/important
155///   memories (high values shrink the score → rise in rank).
156/// - Result approaches `distance` as recency and importance → 1.0 (neutral).
157/// - Safeguard: denominator clamped to `f32::EPSILON` to avoid division by zero.
158///
159/// **Usage**: call after HNSW top-k retrieval, before returning results to the agent.
160///
161/// # Arguments
162/// - `distance`: HNSW cosine distance for this result (from `SearchResult.distance`)
163/// - `recency_weight`: value from `episodic_columns::RECENCY_WEIGHT` column (or computed via `recency_weight()`)
164/// - `importance_score`: value from `episodic_columns::IMPORTANCE_SCORE` column
165#[inline]
166pub fn hybrid_score(distance: f32, recency_weight: f32, importance_score: f32) -> f32 {
167    let denom = (recency_weight * importance_score).max(f32::EPSILON);
168    distance / denom
169}
170
171// ── Tests ─────────────────────────────────────────────────────────────────────
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn recency_weight_at_zero_days() {
179        let w = recency_weight(0.0, &RecencyConfig::WEEKLY_DECAY);
180        assert!((w - 1.0).abs() < 1e-6, "expected 1.0 at day 0, got {w}");
181    }
182
183    #[test]
184    fn recency_weight_half_life() {
185        // At half-life = ln(2)/lambda days, weight should be ~0.5
186        let cfg = RecencyConfig::WEEKLY_DECAY;
187        let half_life_days = std::f32::consts::LN_2 / cfg.lambda;
188        let w = recency_weight(half_life_days, &cfg);
189        assert!(
190            (w - 0.5).abs() < 0.01,
191            "expected ~0.5 at half-life, got {w}"
192        );
193    }
194
195    #[test]
196    fn recency_weight_negative_clamped() {
197        let w = recency_weight(-5.0, &RecencyConfig::WEEKLY_DECAY);
198        assert!((w - 1.0).abs() < 1e-6, "negative days should clamp to 0");
199    }
200
201    #[test]
202    fn hybrid_score_neutral_signals() {
203        // recency=1.0, importance=1.0 → hybrid_score == distance
204        let d = 0.3_f32;
205        let s = hybrid_score(d, 1.0, 1.0);
206        assert!((s - d).abs() < 1e-6);
207    }
208
209    #[test]
210    fn hybrid_score_old_memory_ranks_worse() {
211        // Low recency (old memory) → larger score → worse rank
212        let d = 0.3_f32;
213        let old_memory = hybrid_score(d, 0.3, 1.0); // recency 0.3 = accessed long ago
214        let recent_memory = hybrid_score(d, 1.0, 1.0); // recency 1.0 = just accessed
215        assert!(
216            old_memory > recent_memory,
217            "old memory should rank lower (higher score)"
218        );
219    }
220
221    #[test]
222    fn hybrid_score_unimportant_ranks_worse() {
223        // Low importance → larger score → worse rank
224        let d = 0.3_f32;
225        let unimportant = hybrid_score(d, 1.0, 0.2);
226        let important = hybrid_score(d, 1.0, 1.0);
227        assert!(
228            unimportant > important,
229            "unimportant memory should rank lower"
230        );
231    }
232
233    #[test]
234    fn hybrid_score_zero_denom_no_panic() {
235        // Should not divide by zero
236        let s = hybrid_score(0.5, 0.0, 0.0);
237        assert!(s.is_finite());
238    }
239}