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        self.matches_fields(
126            Some(&memory.id),
127            memory.fingerprint().subject_predicate(),
128            &memory.statement,
129            &memory.retrieval.tags,
130        )
131    }
132
133    pub(crate) fn matches_proposal(&self, proposal: &ProposedMemory) -> bool {
134        self.matches_fields(
135            None,
136            proposal.fingerprint.subject_predicate(),
137            &proposal.statement,
138            &proposal.tags,
139        )
140    }
141
142    fn matches_fields(
143        &self,
144        id: Option<&MemoryId>,
145        subject_predicate: &str,
146        statement: &str,
147        tags: &[String],
148    ) -> bool {
149        match self {
150            Self::ById(expected) => id == Some(expected),
151            Self::BySubjectPredicate(prefix) => subject_predicate == prefix,
152            Self::ByTopic(topic) => {
153                // Word-sequence matching, not substring: deletion is
154                // irreversible, topics are often a single short word, and
155                // `contains` would let "forget art" delete a memory about a
156                // shopping cart.
157                let needle = normalize_token(topic);
158                !needle.is_empty()
159                    && (contains_word_sequence(&normalize_token(statement), &needle)
160                        || tags.iter().any(|t| normalize_token(t) == needle))
161            }
162        }
163    }
164}
165
166/// Whether `needle` occurs in `haystack` as a whole word sequence.
167///
168/// Both arguments must already be normalized.
169fn contains_word_sequence(haystack: &str, needle: &str) -> bool {
170    let hay: Vec<&str> = haystack.split_whitespace().collect();
171    let ned: Vec<&str> = needle.split_whitespace().collect();
172    if ned.is_empty() || ned.len() > hay.len() {
173        return false;
174    }
175    hay.windows(ned.len()).any(|w| w == ned.as_slice())
176}
177
178/// What resolution decided to do with a proposal.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ResolutionKind {
182    /// No equivalent record existed.
183    Create,
184    /// An equivalent active record existed; strengthen it.
185    Reinforce,
186    /// New evidence is more precise but compatible.
187    Refine,
188    /// Newer explicit evidence contradicts the active record.
189    Supersede,
190    /// The statements differ by context and both hold.
191    Coexist,
192    /// Evidence is insufficient; hold for reinforcement.
193    Stage,
194    /// The user asked for removal.
195    Delete,
196    /// Refused.
197    Discard,
198}
199
200impl ResolutionKind {
201    /// A stable label for events and metrics.
202    pub fn label(self) -> &'static str {
203        match self {
204            Self::Create => "create",
205            Self::Reinforce => "reinforce",
206            Self::Refine => "refine",
207            Self::Supersede => "supersede",
208            Self::Coexist => "coexist",
209            Self::Stage => "stage",
210            Self::Delete => "delete",
211            Self::Discard => "discard",
212        }
213    }
214
215    /// Whether this outcome writes anything durable.
216    pub fn is_write(self) -> bool {
217        !matches!(self, Self::Discard)
218    }
219}
220
221/// A resolved mutation, ready to be committed.
222#[derive(Debug, Clone, PartialEq)]
223pub struct ResolvedMutation {
224    /// What was decided.
225    pub kind: ResolutionKind,
226    /// The proposal's fingerprint, for auditing.
227    pub fingerprint: FactFingerprint,
228    /// Records to write, in order.
229    pub writes: Vec<CanonicalMemory>,
230    /// Records to remove.
231    pub deletes: Vec<MemoryId>,
232    /// Why, when the outcome was a refusal.
233    pub discard_reason: Option<DiscardReason>,
234}
235
236impl ResolvedMutation {
237    /// A refusal.
238    pub fn discard(fingerprint: FactFingerprint, reason: DiscardReason) -> Self {
239        Self {
240            kind: ResolutionKind::Discard,
241            fingerprint,
242            writes: Vec::new(),
243            deletes: Vec::new(),
244            discard_reason: Some(reason),
245        }
246    }
247
248    /// A single-record write.
249    pub fn write(
250        kind: ResolutionKind,
251        fingerprint: FactFingerprint,
252        memory: CanonicalMemory,
253    ) -> Self {
254        Self {
255            kind,
256            fingerprint,
257            writes: vec![memory],
258            deletes: Vec::new(),
259            discard_reason: None,
260        }
261    }
262
263    /// Whether anything will actually be written or removed.
264    pub fn is_empty(&self) -> bool {
265        self.writes.is_empty() && self.deletes.is_empty()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn memory(id: &str, statement: &str, tags: &[&str]) -> CanonicalMemory {
274        let now = Utc::now();
275        CanonicalMemory {
276            id: MemoryId::new(id),
277            owner: UserId::new("usr_1"),
278            kind: MemoryKind::Preference,
279            predicate: CanonicalPredicate::new("dietary_identity"),
280            status: MemoryStatus::Active,
281            confidence: 0.9,
282            subject: EntityRef::user(),
283            value: MemoryValue::Text(statement.into()),
284            statement: statement.into(),
285            evidence_summary: "stated".into(),
286            source: MemorySource::from_explicitness(
287                Explicitness::ExplicitStatement,
288                SessionId::new("ses_1"),
289                TurnId(1),
290            ),
291            temporal: TemporalMetadata::created_at(now),
292            retrieval: RetrievalMetadata {
293                subject: "user".into(),
294                tags: tags.iter().map(|t| (*t).to_string()).collect(),
295                ..Default::default()
296            },
297            evidence: EvidenceCounters::first(),
298            privacy: PrivacyMetadata::default(),
299            temporal_scope: TemporalScope::Persistent,
300            supersedes: Vec::new(),
301            superseded_by: None,
302            qualifier: None,
303        }
304    }
305
306    #[test]
307    fn selectors_target_what_they_name_and_nothing_else() {
308        let record = memory("mem_a", "The user is pescatarian.", &["diet"]);
309
310        assert!(MemorySelector::ById(MemoryId::new("mem_a")).matches(&record));
311        assert!(!MemorySelector::ById(MemoryId::new("mem_b")).matches(&record));
312
313        assert!(
314            MemorySelector::BySubjectPredicate("user|dietary_identity".into()).matches(&record)
315        );
316        assert!(!MemorySelector::BySubjectPredicate("user|coffee_order".into()).matches(&record));
317
318        assert!(MemorySelector::ByTopic("pescatarian".into()).matches(&record));
319        assert!(MemorySelector::ByTopic("diet".into()).matches(&record));
320        assert!(!MemorySelector::ByTopic("cycling".into()).matches(&record));
321    }
322
323    #[test]
324    fn a_topic_matches_whole_words_only() {
325        // "forget art" must not delete a memory about a shopping cart.
326        let cart = memory("mem_cart", "The user left items in the cart.", &[]);
327        assert!(!MemorySelector::ByTopic("art".into()).matches(&cart));
328        assert!(MemorySelector::ByTopic("cart".into()).matches(&cart));
329
330        // Multi-word topics still match as a sequence.
331        let dinner = memory("mem_d", "The user enjoyed the quiet dinner in Bandra.", &[]);
332        assert!(MemorySelector::ByTopic("quiet dinner".into()).matches(&dinner));
333        assert!(!MemorySelector::ByTopic("dinner quiet".into()).matches(&dinner));
334    }
335
336    #[test]
337    fn an_empty_topic_selector_matches_nothing() {
338        let record = memory("mem_a", "The user is pescatarian.", &[]);
339        assert!(!MemorySelector::ByTopic("   ".into()).matches(&record));
340    }
341
342    #[test]
343    fn every_resolution_but_discard_writes_something() {
344        for kind in [
345            ResolutionKind::Create,
346            ResolutionKind::Reinforce,
347            ResolutionKind::Refine,
348            ResolutionKind::Supersede,
349            ResolutionKind::Coexist,
350            ResolutionKind::Stage,
351            ResolutionKind::Delete,
352        ] {
353            assert!(kind.is_write(), "{} should write", kind.label());
354        }
355        assert!(!ResolutionKind::Discard.is_write());
356    }
357}