use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicI64, Ordering};
use super::types::{FactStatus, Millis, RememberInput};
pub trait Clock: Send + Sync {
fn now_millis(&self) -> Millis;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now_millis(&self) -> Millis {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
})
}
}
#[derive(Debug, Default)]
pub struct ManualClock(AtomicI64);
impl ManualClock {
pub fn new(now: Millis) -> Self {
Self(AtomicI64::new(now))
}
pub fn set(&self, now: Millis) {
self.0.store(now, Ordering::SeqCst);
}
pub fn advance(&self, by: Millis) {
self.0.fetch_add(by, Ordering::SeqCst);
}
}
impl Clock for ManualClock {
fn now_millis(&self) -> Millis {
self.0.load(Ordering::SeqCst)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FactView {
pub id: String,
pub subject: Option<String>,
pub effective_from: Millis,
pub ingest_time: Millis,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Resolution {
pub supersedes: Vec<String>,
pub superseded_by: Vec<String>,
}
pub trait SupersessionPolicy: Send + Sync {
fn resolve(&self, incoming: &FactView, same_subject: &[FactView]) -> Resolution;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ExplicitOnly;
impl SupersessionPolicy for ExplicitOnly {
fn resolve(&self, _: &FactView, _: &[FactView]) -> Resolution {
Resolution::default()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct LatestBySubject;
impl SupersessionPolicy for LatestBySubject {
fn resolve(&self, incoming: &FactView, same_subject: &[FactView]) -> Resolution {
let mut resolution = Resolution::default();
let before = same_subject
.iter()
.rfind(|fact| fact.id != incoming.id && fact.effective_from <= incoming.effective_from);
if let Some(fact) = before {
resolution.supersedes.push(fact.id.clone());
}
let after = same_subject
.iter()
.find(|fact| fact.id != incoming.id && fact.effective_from > incoming.effective_from);
if let Some(fact) = after {
resolution.superseded_by.push(fact.id.clone());
}
resolution
}
}
#[derive(Clone, Copy, Debug)]
pub struct RecencyContext {
pub now: Millis,
pub event_time: Option<Millis>,
pub valid_from: Option<Millis>,
pub ingest_time: Option<Millis>,
pub status: FactStatus,
}
pub trait RecencyPolicy: Send + Sync {
fn is_identity(&self) -> bool {
false
}
fn factor(&self, fact: &RecencyContext) -> f64;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NoRecency;
impl RecencyPolicy for NoRecency {
fn is_identity(&self) -> bool {
true
}
fn factor(&self, _: &RecencyContext) -> f64 {
1.0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecencyBasis {
#[default]
Event,
Ingest,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExponentialRecency {
pub half_life_ms: Millis,
pub basis: RecencyBasis,
pub floor: f64,
}
impl RecencyPolicy for ExponentialRecency {
fn factor(&self, fact: &RecencyContext) -> f64 {
let at = match self.basis {
RecencyBasis::Event => fact.event_time.or(fact.valid_from).or(fact.ingest_time),
RecencyBasis::Ingest => fact.ingest_time,
};
let Some(at) = at else { return 1.0 };
let age = fact.now.saturating_sub(at);
if age <= 0 {
return 1.0;
}
(0.5f64.powf(age as f64 / self.half_life_ms as f64)).max(self.floor)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FactSummary {
pub id: String,
pub text: Option<String>,
pub status: FactStatus,
pub ingest_time: Option<Millis>,
pub event_time: Option<Millis>,
pub valid_from: Option<Millis>,
pub valid_to: Option<Millis>,
pub effective_from: Millis,
pub subject: Option<String>,
pub superseded_at: Option<Millis>,
}
pub trait ForgettingPolicy: Send + Sync {
fn needs_text(&self) -> bool {
false
}
fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct KeepAll;
impl ForgettingPolicy for KeepAll {
fn select(&self, _: &[FactSummary], _: Millis) -> Vec<String> {
Vec::new()
}
}
#[derive(Clone, Copy, Debug)]
pub struct ExpiredFor {
pub grace_ms: Millis,
}
impl ForgettingPolicy for ExpiredFor {
fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String> {
facts
.iter()
.filter(|fact| {
fact.valid_to
.is_some_and(|to| now.saturating_sub(to) > self.grace_ms)
})
.map(|fact| fact.id.clone())
.collect()
}
}
#[derive(Clone, Copy, Debug)]
pub struct SupersededFor {
pub age_ms: Millis,
}
impl ForgettingPolicy for SupersededFor {
fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String> {
facts
.iter()
.filter(|fact| {
fact.status == FactStatus::Superseded
&& fact
.superseded_at
.is_some_and(|at| now.saturating_sub(at) > self.age_ms)
})
.map(|fact| fact.id.clone())
.collect()
}
}
#[derive(Clone, Copy, Debug)]
pub struct MaxFacts {
pub limit: usize,
}
impl ForgettingPolicy for MaxFacts {
fn select(&self, facts: &[FactSummary], _: Millis) -> Vec<String> {
let excess = facts.len().saturating_sub(self.limit);
let mut order: Vec<&FactSummary> = facts.iter().collect();
order.sort_by(|a, b| {
let rank = |fact: &FactSummary| fact.status == FactStatus::Current;
(rank(a), a.ingest_time, &a.id).cmp(&(rank(b), b.ingest_time, &b.id))
});
order
.into_iter()
.take(excess)
.map(|fact| fact.id.clone())
.collect()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConsolidationAction {
pub sources: Vec<String>,
pub summary: RememberInput,
pub forget_sources: bool,
}
pub trait ConsolidationPolicy: Send + Sync {
fn needs_text(&self) -> bool {
false
}
fn plan(&self, facts: &[FactSummary], now: Millis) -> Vec<ConsolidationAction>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NoConsolidation;
impl ConsolidationPolicy for NoConsolidation {
fn plan(&self, _: &[FactSummary], _: Millis) -> Vec<ConsolidationAction> {
Vec::new()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct CollapseDuplicates;
impl ConsolidationPolicy for CollapseDuplicates {
fn needs_text(&self) -> bool {
true
}
fn plan(&self, facts: &[FactSummary], _: Millis) -> Vec<ConsolidationAction> {
let mut groups: BTreeMap<(Option<&str>, String), Vec<&FactSummary>> = BTreeMap::new();
for fact in facts.iter().filter(|f| f.status == FactStatus::Current) {
let Some(text) = fact.text.as_deref() else {
continue;
};
let key = text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
groups
.entry((fact.subject.as_deref(), key))
.or_default()
.push(fact);
}
groups
.into_values()
.filter(|group| group.len() > 1)
.map(|mut group| {
group.sort_by(|a, b| {
(a.effective_from, a.ingest_time, &a.id).cmp(&(
b.effective_from,
b.ingest_time,
&b.id,
))
});
let newest = group[group.len() - 1];
let sources: Vec<String> = group.iter().map(|fact| fact.id.clone()).collect();
ConsolidationAction {
summary: RememberInput {
text: newest.text.clone().unwrap_or_default(),
event_time: newest.event_time,
valid_from: newest.valid_from,
valid_to: newest.valid_to,
subject: newest.subject.clone(),
supersedes: Some(sources.clone()),
..RememberInput::default()
},
sources,
forget_sources: false,
}
})
.collect()
}
}
pub trait EntityExtractor: Send + Sync {
fn entities(&self, text: &str) -> Vec<String>;
}
impl<F: Fn(&str) -> Vec<String> + Send + Sync> EntityExtractor for F {
fn entities(&self, text: &str) -> Vec<String> {
self(text)
}
}
pub trait MemoryObserver: Send + Sync {
fn on_remembered(&self, _fact: &super::types::Remembered, _input: &RememberInput) {}
fn on_forgotten(&self, _ids: &[String]) {}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SupersessionSpec {
#[default]
Explicit,
LatestBySubject,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(
rename_all = "kebab-case",
tag = "kind",
rename_all_fields = "camelCase"
)]
pub enum RecencySpec {
#[default]
None,
Exponential {
half_life_ms: Millis,
#[serde(default)]
basis: RecencyBasis,
#[serde(default)]
floor: f64,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(
rename_all = "kebab-case",
tag = "kind",
rename_all_fields = "camelCase"
)]
pub enum ForgettingSpec {
#[default]
KeepAll,
ExpiredFor {
grace_ms: Millis,
},
SupersededFor {
age_ms: Millis,
},
MaxFacts {
limit: usize,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConsolidationSpec {
#[default]
None,
CollapseDuplicates,
}