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}