Skip to main content

writerslogic_warm/core/memory_api/
policy.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pluggable policies. Each is a trait with a documented default that does nothing
5//! destructive and nothing surprising, a built-in or two, and a serde-friendly spec so
6//! bindings can select a built-in by name. Rust callers may pass any implementation.
7
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::atomic::{AtomicI64, Ordering};
11
12use super::types::{FactStatus, Millis, RememberInput};
13
14/// The source of "now" for ingest times and for recall without `as_of`.
15pub trait Clock: Send + Sync {
16    fn now_millis(&self) -> Millis;
17}
18
19/// The system clock. Default.
20#[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/// A clock that only moves when told to; for deterministic runs and tests.
34#[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/// A fact as supersession policies see it.
58#[derive(Clone, Debug, PartialEq)]
59pub struct FactView {
60    pub id: String,
61    pub subject: Option<String>,
62    /// When the fact takes effect on the valid-time axis: valid-from, else event time, else
63    /// ingest time.
64    pub effective_from: Millis,
65    pub ingest_time: Millis,
66}
67
68/// The links a policy asks for.
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub struct Resolution {
71    /// Existing facts the incoming fact supersedes.
72    pub supersedes: Vec<String>,
73    /// Existing facts that already supersede the incoming fact: it arrived late and is born
74    /// history.
75    pub superseded_by: Vec<String>,
76}
77
78/// Decides, when a fact with a subject is remembered, which stored facts about the same
79/// subject it supersedes or is superseded by. Explicit `supersedes` ids on the input always
80/// apply in addition. Ids returned must come from `same_subject`; others are an error.
81///
82/// Default: [`ExplicitOnly`].
83pub trait SupersessionPolicy: Send + Sync {
84    /// `same_subject` holds the stored facts of the incoming fact's subject ordered by
85    /// effect, ingest time and id, including earlier facts of the same batch.
86    fn resolve(&self, incoming: &FactView, same_subject: &[FactView]) -> Resolution;
87}
88
89/// Supersession happens only where the caller names it. Default.
90#[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/// Treats a subject as a single-valued slot on the valid-time axis: a fact supersedes the
100/// latest earlier-or-equal fact of its subject, and is itself superseded by the earliest
101/// later one. Arrival order does not matter, so a late-ingested older fact becomes history
102/// instead of overriding newer knowledge. Use subjects that name one slot ("alice/city"),
103/// not a topic that holds several facts.
104#[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/// What a recency policy sees of a fact.
127#[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
136/// Multiplies a result's retrieval score. Default: [`NoRecency`], which leaves scores
137/// exactly as document search returned them.
138pub trait RecencyPolicy: Send + Sync {
139    /// True when the policy never changes a score; recall then skips it and keeps the
140    /// document search order.
141    fn is_identity(&self) -> bool {
142        false
143    }
144
145    /// A finite, nonnegative multiplier.
146    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/// Which time a recency decay measures from.
163#[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    /// Event time, else valid-from, else ingest time. Default.
168    #[default]
169    Event,
170    Ingest,
171}
172
173/// `0.5^(age / half_life)`, never below `floor`. Facts without a time, and facts newer than
174/// the evaluation time, are not decayed.
175#[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/// A stored fact as maintenance policies see it.
198#[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    /// Present only when the policy's `needs_text` is true.
204    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    /// The earliest time a superseder takes effect, when one exists.
213    pub superseded_at: Option<Millis>,
214}
215
216/// Chooses facts to remove. Default: [`KeepAll`].
217pub trait ForgettingPolicy: Send + Sync {
218    fn needs_text(&self) -> bool {
219        false
220    }
221
222    /// Ids to forget, from the facts stored at `now`.
223    fn select(&self, facts: &[FactSummary], now: Millis) -> Vec<String>;
224}
225
226/// Never forgets. Default.
227#[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/// Forgets facts whose validity ended more than `grace_ms` ago.
237#[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/// Forgets superseded facts whose superseder took effect more than `age_ms` ago: prunes old
256/// history while current facts are untouched.
257#[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/// Keeps at most `limit` facts: forgets the non-current ones first, oldest ingested first,
278/// then the oldest current ones.
279#[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/// One consolidation step: a new fact that supersedes `sources`.
301#[derive(Clone, Debug, PartialEq)]
302pub struct ConsolidationAction {
303    pub sources: Vec<String>,
304    /// The merged fact. Its `supersedes` is filled with `sources`.
305    pub summary: RememberInput,
306    /// Ask for the sources to be removed. Ignored unless the run allows destructive
307    /// consolidation.
308    pub forget_sources: bool,
309}
310
311/// Plans merges of related facts. Default: [`NoConsolidation`]. A plan only ever adds facts
312/// that supersede their sources, so history survives unless the caller opts into removal.
313pub 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/// Plans nothing. Default.
322#[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/// Merges current facts whose text is identical after trimming and case folding (and that
332/// share a subject) into one fact superseding all of them.
333#[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
388/// Finds the entities a fact mentions. Optional; default none. The result is stored with
389/// the fact and matched exactly by `RecallOptions::entities`. This and [`MemoryObserver`]
390/// are the extension points for a graph or entity layer; nothing in the store requires one.
391pub 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
401/// Receives memory events after they are durable. Default: none. Callbacks run on the
402/// calling thread and must not call back into the same `Memory`'s writers.
403pub 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/// Built-in supersession policies by name.
409#[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    /// Only explicit `supersedes`. Default.
414    #[default]
415    Explicit,
416    /// [`LatestBySubject`].
417    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    /// Scores are untouched. Default.
429    #[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    /// Never forgets. Default.
449    #[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    /// Plans nothing. Default.
467    #[default]
468    None,
469    CollapseDuplicates,
470}