1use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::atomic::{AtomicI64, Ordering};
11
12use super::types::{FactStatus, Millis, RememberInput};
13
14pub trait Clock: Send + Sync {
16 fn now_millis(&self) -> Millis;
17}
18
19#[derive(Clone, Copy, Debug, Default)]
21pub struct SystemClock;
22
23impl Clock for SystemClock {
24 fn now_millis(&self) -> Millis {
25 std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
27 .map_or(0, |elapsed| {
28 i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
29 })
30 }
31}
32
33#[derive(Debug, Default)]
35pub struct ManualClock(AtomicI64);
36
37impl ManualClock {
38 pub fn new(now: Millis) -> Self {
39 Self(AtomicI64::new(now))
40 }
41
42 pub fn set(&self, now: Millis) {
43 self.0.store(now, Ordering::SeqCst);
44 }
45
46 pub fn advance(&self, by: Millis) {
47 self.0.fetch_add(by, Ordering::SeqCst);
48 }
49}
50
51impl Clock for ManualClock {
52 fn now_millis(&self) -> Millis {
53 self.0.load(Ordering::SeqCst)
54 }
55}
56
57#[derive(Clone, Debug, PartialEq)]
59pub struct FactView {
60 pub id: String,
61 pub subject: Option<String>,
62 pub effective_from: Millis,
65 pub ingest_time: Millis,
66}
67
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub struct Resolution {
71 pub supersedes: Vec<String>,
73 pub superseded_by: Vec<String>,
76}
77
78pub trait SupersessionPolicy: Send + Sync {
84 fn resolve(&self, incoming: &FactView, same_subject: &[FactView]) -> Resolution;
87}
88
89#[derive(Clone, Copy, Debug, Default)]
91pub struct ExplicitOnly;
92
93impl SupersessionPolicy for ExplicitOnly {
94 fn resolve(&self, _: &FactView, _: &[FactView]) -> Resolution {
95 Resolution::default()
96 }
97}
98
99#[derive(Clone, Copy, Debug, Default)]
105pub struct LatestBySubject;
106
107impl SupersessionPolicy for LatestBySubject {
108 fn resolve(&self, incoming: &FactView, same_subject: &[FactView]) -> Resolution {
109 let mut resolution = Resolution::default();
110 let before = same_subject
111 .iter()
112 .rfind(|fact| fact.id != incoming.id && fact.effective_from <= incoming.effective_from);
113 if let Some(fact) = before {
114 resolution.supersedes.push(fact.id.clone());
115 }
116 let after = same_subject
117 .iter()
118 .find(|fact| fact.id != incoming.id && fact.effective_from > incoming.effective_from);
119 if let Some(fact) = after {
120 resolution.superseded_by.push(fact.id.clone());
121 }
122 resolution
123 }
124}
125
126#[derive(Clone, Copy, Debug)]
128pub struct RecencyContext {
129 pub now: Millis,
130 pub event_time: Option<Millis>,
131 pub valid_from: Option<Millis>,
132 pub ingest_time: Option<Millis>,
133 pub status: FactStatus,
134}
135
136pub trait RecencyPolicy: Send + Sync {
139 fn is_identity(&self) -> bool {
142 false
143 }
144
145 fn factor(&self, fact: &RecencyContext) -> f64;
147}
148
149#[derive(Clone, Copy, Debug, Default)]
150pub struct NoRecency;
151
152impl RecencyPolicy for NoRecency {
153 fn is_identity(&self) -> bool {
154 true
155 }
156
157 fn factor(&self, _: &RecencyContext) -> f64 {
158 1.0
159 }
160}
161
162#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
164#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "kebab-case")]
166pub enum RecencyBasis {
167 #[default]
169 Event,
170 Ingest,
171}
172
173#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct ExponentialRecency {
177 pub half_life_ms: Millis,
178 pub basis: RecencyBasis,
179 pub floor: f64,
180}
181
182impl RecencyPolicy for ExponentialRecency {
183 fn factor(&self, fact: &RecencyContext) -> f64 {
184 let at = match self.basis {
185 RecencyBasis::Event => fact.event_time.or(fact.valid_from).or(fact.ingest_time),
186 RecencyBasis::Ingest => fact.ingest_time,
187 };
188 let Some(at) = at else { return 1.0 };
189 let age = fact.now.saturating_sub(at);
190 if age <= 0 {
191 return 1.0;
192 }
193 (0.5f64.powf(age as f64 / self.half_life_ms as f64)).max(self.floor)
194 }
195}
196
197#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
199#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct FactSummary {
202 pub id: String,
203 pub text: Option<String>,
205 pub status: FactStatus,
206 pub ingest_time: Option<Millis>,
207 pub event_time: Option<Millis>,
208 pub valid_from: Option<Millis>,
209 pub valid_to: Option<Millis>,
210 pub effective_from: Millis,
211 pub subject: Option<String>,
212 pub superseded_at: Option<Millis>,
214}
215
216pub trait ForgettingPolicy: Send + Sync {
218 fn needs_text(&self) -> bool {
219 false
220 }
221
222 fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String>;
224}
225
226#[derive(Clone, Copy, Debug, Default)]
228pub struct KeepAll;
229
230impl ForgettingPolicy for KeepAll {
231 fn select(&self, _: &[FactSummary], _: Millis) -> Vec<String> {
232 Vec::new()
233 }
234}
235
236#[derive(Clone, Copy, Debug)]
238pub struct ExpiredFor {
239 pub grace_ms: Millis,
240}
241
242impl ForgettingPolicy for ExpiredFor {
243 fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String> {
244 facts
245 .iter()
246 .filter(|fact| {
247 fact.valid_to
248 .is_some_and(|to| now.saturating_sub(to) > self.grace_ms)
249 })
250 .map(|fact| fact.id.clone())
251 .collect()
252 }
253}
254
255#[derive(Clone, Copy, Debug)]
258pub struct SupersededFor {
259 pub age_ms: Millis,
260}
261
262impl ForgettingPolicy for SupersededFor {
263 fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String> {
264 facts
265 .iter()
266 .filter(|fact| {
267 fact.status == FactStatus::Superseded
268 && fact
269 .superseded_at
270 .is_some_and(|at| now.saturating_sub(at) > self.age_ms)
271 })
272 .map(|fact| fact.id.clone())
273 .collect()
274 }
275}
276
277#[derive(Clone, Copy, Debug)]
280pub struct MaxFacts {
281 pub limit: usize,
282}
283
284impl ForgettingPolicy for MaxFacts {
285 fn select(&self, facts: &[FactSummary], _: Millis) -> Vec<String> {
286 let excess = facts.len().saturating_sub(self.limit);
287 let mut order: Vec<&FactSummary> = facts.iter().collect();
288 order.sort_by(|a, b| {
289 let rank = |fact: &FactSummary| fact.status == FactStatus::Current;
290 (rank(a), a.ingest_time, &a.id).cmp(&(rank(b), b.ingest_time, &b.id))
291 });
292 order
293 .into_iter()
294 .take(excess)
295 .map(|fact| fact.id.clone())
296 .collect()
297 }
298}
299
300#[derive(Clone, Debug, PartialEq)]
302pub struct ConsolidationAction {
303 pub sources: Vec<String>,
304 pub summary: RememberInput,
306 pub forget_sources: bool,
309}
310
311pub trait ConsolidationPolicy: Send + Sync {
314 fn needs_text(&self) -> bool {
315 false
316 }
317
318 fn plan(&self, facts: &[FactSummary], now: Millis) -> Vec<ConsolidationAction>;
319}
320
321#[derive(Clone, Copy, Debug, Default)]
323pub struct NoConsolidation;
324
325impl ConsolidationPolicy for NoConsolidation {
326 fn plan(&self, _: &[FactSummary], _: Millis) -> Vec<ConsolidationAction> {
327 Vec::new()
328 }
329}
330
331#[derive(Clone, Copy, Debug, Default)]
334pub struct CollapseDuplicates;
335
336impl ConsolidationPolicy for CollapseDuplicates {
337 fn needs_text(&self) -> bool {
338 true
339 }
340
341 fn plan(&self, facts: &[FactSummary], _: Millis) -> Vec<ConsolidationAction> {
342 let mut groups: BTreeMap<(Option<&str>, String), Vec<&FactSummary>> = BTreeMap::new();
343 for fact in facts.iter().filter(|f| f.status == FactStatus::Current) {
344 let Some(text) = fact.text.as_deref() else {
345 continue;
346 };
347 let key = text
348 .split_whitespace()
349 .collect::<Vec<_>>()
350 .join(" ")
351 .to_lowercase();
352 groups
353 .entry((fact.subject.as_deref(), key))
354 .or_default()
355 .push(fact);
356 }
357 groups
358 .into_values()
359 .filter(|group| group.len() > 1)
360 .map(|mut group| {
361 group.sort_by(|a, b| {
362 (a.effective_from, a.ingest_time, &a.id).cmp(&(
363 b.effective_from,
364 b.ingest_time,
365 &b.id,
366 ))
367 });
368 let newest = group[group.len() - 1];
369 let sources: Vec<String> = group.iter().map(|fact| fact.id.clone()).collect();
370 ConsolidationAction {
371 summary: RememberInput {
372 text: newest.text.clone().unwrap_or_default(),
373 event_time: newest.event_time,
374 valid_from: newest.valid_from,
375 valid_to: newest.valid_to,
376 subject: newest.subject.clone(),
377 supersedes: Some(sources.clone()),
378 ..RememberInput::default()
379 },
380 sources,
381 forget_sources: false,
382 }
383 })
384 .collect()
385 }
386}
387
388pub trait EntityExtractor: Send + Sync {
392 fn entities(&self, text: &str) -> Vec<String>;
393}
394
395impl<F: Fn(&str) -> Vec<String> + Send + Sync> EntityExtractor for F {
396 fn entities(&self, text: &str) -> Vec<String> {
397 self(text)
398 }
399}
400
401pub trait MemoryObserver: Send + Sync {
404 fn on_remembered(&self, _fact: &super::types::Remembered, _input: &RememberInput) {}
405 fn on_forgotten(&self, _ids: &[String]) {}
406}
407
408#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
410#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "kebab-case")]
412pub enum SupersessionSpec {
413 #[default]
415 Explicit,
416 LatestBySubject,
418}
419
420#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
421#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
422#[serde(
423 rename_all = "kebab-case",
424 tag = "kind",
425 rename_all_fields = "camelCase"
426)]
427pub enum RecencySpec {
428 #[default]
430 None,
431 Exponential {
432 half_life_ms: Millis,
433 #[serde(default)]
434 basis: RecencyBasis,
435 #[serde(default)]
436 floor: f64,
437 },
438}
439
440#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
441#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
442#[serde(
443 rename_all = "kebab-case",
444 tag = "kind",
445 rename_all_fields = "camelCase"
446)]
447pub enum ForgettingSpec {
448 #[default]
450 KeepAll,
451 ExpiredFor {
452 grace_ms: Millis,
453 },
454 SupersededFor {
455 age_ms: Millis,
456 },
457 MaxFacts {
458 limit: usize,
459 },
460}
461
462#[cfg_attr(feature = "server", derive(schemars::JsonSchema))]
463#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(rename_all = "kebab-case")]
465pub enum ConsolidationSpec {
466 #[default]
468 None,
469 CollapseDuplicates,
470}