Skip to main content

gemini_memory_rs/reconcile/
proposal.rs

1//! What consolidation proposes and what resolution decides.
2//!
3//! The split matters: a model may generate proposals, but only the resolver —
4//! deterministic code — turns one into a mutation, and only the committer
5//! writes it. A proposal is a request, never an instruction.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::core::{
11    CanonicalMemory, CanonicalPredicate, DiscardReason, EntityRef, EvidenceCounters, Explicitness,
12    FactFingerprint, MemoryId, MemoryKind, MemorySource, MemoryStatus, MemoryValue, MutationIntent,
13    PrivacyMetadata, ProposedPersistence, RetrievalMetadata, SensitivityClass, SessionId,
14    TemporalMetadata, TemporalScope, TurnId, UserId, normalize_token,
15};
16
17/// A memory consolidation is asking to be allowed to write.
18#[derive(Debug, Clone, PartialEq)]
19pub struct ProposedMemory {
20    /// Deduplication key.
21    pub fingerprint: FactFingerprint,
22    /// Subject of the fact.
23    pub subject: EntityRef,
24    /// Canonical predicate.
25    pub predicate: CanonicalPredicate,
26    /// Value side.
27    pub value: MemoryValue,
28    /// Natural-language rendering.
29    pub statement: String,
30    /// Why the engine believes it.
31    pub evidence_summary: String,
32    /// Proposed kind.
33    pub kind: MemoryKind,
34    /// Expected persistence class.
35    pub temporal_scope: TemporalScope,
36    /// Strongest explicitness behind it.
37    pub explicitness: Explicitness,
38    /// Aggregated confidence.
39    pub confidence: f32,
40    /// Evidence counters.
41    pub evidence: EvidenceCounters,
42    /// Retention proposal.
43    pub persistence: ProposedPersistence,
44    /// Expiry for episodic proposals.
45    pub expected_expiry: Option<DateTime<Utc>>,
46    /// Explicit command behind it, if any.
47    pub mutation_intent: Option<MutationIntent>,
48    /// Privacy classification.
49    pub sensitivity: SensitivityClass,
50    /// Context qualifier distinguishing coexisting facts.
51    pub qualifier: Option<String>,
52    /// Session the evidence came from.
53    pub session_id: SessionId,
54    /// Last turn the evidence came from.
55    pub turn_id: TurnId,
56    /// Search tags derived from the evidence.
57    pub tags: Vec<String>,
58}
59
60impl ProposedMemory {
61    /// Materialize the proposal as a canonical record owned by `owner`.
62    pub fn into_canonical(
63        self,
64        owner: &UserId,
65        id: MemoryId,
66        status: MemoryStatus,
67        now: DateTime<Utc>,
68    ) -> CanonicalMemory {
69        let mut temporal = TemporalMetadata::created_at(now);
70        temporal.expires_at =
71            crate::core::resolve_expiry(self.kind, self.temporal_scope, self.expected_expiry, now);
72
73        CanonicalMemory {
74            id,
75            owner: owner.clone(),
76            kind: self.kind,
77            predicate: self.predicate.clone(),
78            status,
79            confidence: self.confidence,
80            subject: self.subject.clone(),
81            value: self.value,
82            statement: self.statement,
83            evidence_summary: self.evidence_summary,
84            source: MemorySource::from_explicitness(
85                self.explicitness,
86                self.session_id,
87                self.turn_id,
88            ),
89            temporal,
90            retrieval: RetrievalMetadata {
91                subject: normalize_token(&self.subject.display),
92                tags: self.tags,
93                aliases: Vec::new(),
94                entities: self.subject.surface_forms(),
95                location: None,
96            },
97            evidence: self.evidence,
98            privacy: PrivacyMetadata {
99                deletable: true,
100                exportable: true,
101                sensitivity: self.sensitivity,
102            },
103            temporal_scope: self.temporal_scope,
104            supersedes: Vec::new(),
105            superseded_by: None,
106            qualifier: self.qualifier,
107        }
108    }
109}
110
111/// How to identify records the user asked to remove.
112#[derive(Debug, Clone, PartialEq)]
113pub enum MemorySelector {
114    /// A specific record.
115    ById(MemoryId),
116    /// Everything asserted about a subject and predicate.
117    BySubjectPredicate(String),
118    /// Everything whose statement mentions a topic.
119    ByTopic(String),
120}
121
122impl MemorySelector {
123    /// Whether a record is targeted by this selector.
124    pub fn matches(&self, memory: &CanonicalMemory) -> bool {
125        match self {
126            Self::ById(id) => &memory.id == id,
127            Self::BySubjectPredicate(prefix) => {
128                memory.fingerprint().subject_predicate() == prefix.as_str()
129            }
130            Self::ByTopic(topic) => {
131                // Word-sequence matching, not substring: deletion is
132                // irreversible, topics are often a single short word, and
133                // `contains` would let "forget art" delete a memory about a
134                // shopping cart.
135                let needle = normalize_token(topic);
136                !needle.is_empty()
137                    && (contains_word_sequence(&normalize_token(&memory.statement), &needle)
138                        || memory
139                            .retrieval
140                            .tags
141                            .iter()
142                            .any(|t| normalize_token(t) == needle))
143            }
144        }
145    }
146}
147
148/// Whether `needle` occurs in `haystack` as a whole word sequence.
149///
150/// Both arguments must already be normalized.
151fn contains_word_sequence(haystack: &str, needle: &str) -> bool {
152    let hay: Vec<&str> = haystack.split_whitespace().collect();
153    let ned: Vec<&str> = needle.split_whitespace().collect();
154    if ned.is_empty() || ned.len() > hay.len() {
155        return false;
156    }
157    hay.windows(ned.len()).any(|w| w == ned.as_slice())
158}
159
160/// What resolution decided to do with a proposal.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum ResolutionKind {
164    /// No equivalent record existed.
165    Create,
166    /// An equivalent active record existed; strengthen it.
167    Reinforce,
168    /// New evidence is more precise but compatible.
169    Refine,
170    /// Newer explicit evidence contradicts the active record.
171    Supersede,
172    /// The statements differ by context and both hold.
173    Coexist,
174    /// Evidence is insufficient; hold for reinforcement.
175    Stage,
176    /// The user asked for removal.
177    Delete,
178    /// Refused.
179    Discard,
180}
181
182impl ResolutionKind {
183    /// A stable label for events and metrics.
184    pub fn label(self) -> &'static str {
185        match self {
186            Self::Create => "create",
187            Self::Reinforce => "reinforce",
188            Self::Refine => "refine",
189            Self::Supersede => "supersede",
190            Self::Coexist => "coexist",
191            Self::Stage => "stage",
192            Self::Delete => "delete",
193            Self::Discard => "discard",
194        }
195    }
196
197    /// Whether this outcome writes anything durable.
198    pub fn is_write(self) -> bool {
199        !matches!(self, Self::Discard)
200    }
201}
202
203/// A resolved mutation, ready to be committed.
204#[derive(Debug, Clone, PartialEq)]
205pub struct ResolvedMutation {
206    /// What was decided.
207    pub kind: ResolutionKind,
208    /// The proposal's fingerprint, for auditing.
209    pub fingerprint: FactFingerprint,
210    /// Records to write, in order.
211    pub writes: Vec<CanonicalMemory>,
212    /// Records to remove.
213    pub deletes: Vec<MemoryId>,
214    /// Why, when the outcome was a refusal.
215    pub discard_reason: Option<DiscardReason>,
216}
217
218impl ResolvedMutation {
219    /// A refusal.
220    pub fn discard(fingerprint: FactFingerprint, reason: DiscardReason) -> Self {
221        Self {
222            kind: ResolutionKind::Discard,
223            fingerprint,
224            writes: Vec::new(),
225            deletes: Vec::new(),
226            discard_reason: Some(reason),
227        }
228    }
229
230    /// A single-record write.
231    pub fn write(
232        kind: ResolutionKind,
233        fingerprint: FactFingerprint,
234        memory: CanonicalMemory,
235    ) -> Self {
236        Self {
237            kind,
238            fingerprint,
239            writes: vec![memory],
240            deletes: Vec::new(),
241            discard_reason: None,
242        }
243    }
244
245    /// Whether anything will actually be written or removed.
246    pub fn is_empty(&self) -> bool {
247        self.writes.is_empty() && self.deletes.is_empty()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn memory(id: &str, statement: &str, tags: &[&str]) -> CanonicalMemory {
256        let now = Utc::now();
257        CanonicalMemory {
258            id: MemoryId::new(id),
259            owner: UserId::new("usr_1"),
260            kind: MemoryKind::Preference,
261            predicate: CanonicalPredicate::new("dietary_identity"),
262            status: MemoryStatus::Active,
263            confidence: 0.9,
264            subject: EntityRef::user(),
265            value: MemoryValue::Text(statement.into()),
266            statement: statement.into(),
267            evidence_summary: "stated".into(),
268            source: MemorySource::from_explicitness(
269                Explicitness::ExplicitStatement,
270                SessionId::new("ses_1"),
271                TurnId(1),
272            ),
273            temporal: TemporalMetadata::created_at(now),
274            retrieval: RetrievalMetadata {
275                subject: "user".into(),
276                tags: tags.iter().map(|t| (*t).to_string()).collect(),
277                ..Default::default()
278            },
279            evidence: EvidenceCounters::first(),
280            privacy: PrivacyMetadata::default(),
281            temporal_scope: TemporalScope::Persistent,
282            supersedes: Vec::new(),
283            superseded_by: None,
284            qualifier: None,
285        }
286    }
287
288    #[test]
289    fn selectors_target_what_they_name_and_nothing_else() {
290        let record = memory("mem_a", "The user is pescatarian.", &["diet"]);
291
292        assert!(MemorySelector::ById(MemoryId::new("mem_a")).matches(&record));
293        assert!(!MemorySelector::ById(MemoryId::new("mem_b")).matches(&record));
294
295        assert!(
296            MemorySelector::BySubjectPredicate("user|dietary_identity".into()).matches(&record)
297        );
298        assert!(!MemorySelector::BySubjectPredicate("user|coffee_order".into()).matches(&record));
299
300        assert!(MemorySelector::ByTopic("pescatarian".into()).matches(&record));
301        assert!(MemorySelector::ByTopic("diet".into()).matches(&record));
302        assert!(!MemorySelector::ByTopic("cycling".into()).matches(&record));
303    }
304
305    #[test]
306    fn a_topic_matches_whole_words_only() {
307        // "forget art" must not delete a memory about a shopping cart.
308        let cart = memory("mem_cart", "The user left items in the cart.", &[]);
309        assert!(!MemorySelector::ByTopic("art".into()).matches(&cart));
310        assert!(MemorySelector::ByTopic("cart".into()).matches(&cart));
311
312        // Multi-word topics still match as a sequence.
313        let dinner = memory("mem_d", "The user enjoyed the quiet dinner in Bandra.", &[]);
314        assert!(MemorySelector::ByTopic("quiet dinner".into()).matches(&dinner));
315        assert!(!MemorySelector::ByTopic("dinner quiet".into()).matches(&dinner));
316    }
317
318    #[test]
319    fn an_empty_topic_selector_matches_nothing() {
320        let record = memory("mem_a", "The user is pescatarian.", &[]);
321        assert!(!MemorySelector::ByTopic("   ".into()).matches(&record));
322    }
323
324    #[test]
325    fn every_resolution_but_discard_writes_something() {
326        for kind in [
327            ResolutionKind::Create,
328            ResolutionKind::Reinforce,
329            ResolutionKind::Refine,
330            ResolutionKind::Supersede,
331            ResolutionKind::Coexist,
332            ResolutionKind::Stage,
333            ResolutionKind::Delete,
334        ] {
335            assert!(kind.is_write(), "{} should write", kind.label());
336        }
337        assert!(!ResolutionKind::Discard.is_write());
338    }
339}