gemini_memory_rs/reconcile/
promotion.rs

1//! Pattern promotion: staged inferences that have earned durability.
2//!
3//! Three mentions inside one conversation is repetition — or a model
4//! misunderstanding the same sentence three times. Evidence spread across
5//! sessions *and* days is what distinguishes a stable pattern from an artefact
6//! of one conversation, which is why the promotion bar counts both.
7
8use chrono::{DateTime, Duration, Utc};
9
10use crate::core::{
11    CanonicalMemory, Explicitness, MemoryStatus, PromotionConfig, PromotionEvidence,
12    meets_promotion_criteria,
13};
14
15/// What a promotion sweep decided about one staged record.
16#[derive(Debug, Clone, PartialEq)]
17pub enum PromotionOutcome {
18    /// Meets the bar; write it active.
19    Promote(Box<CanonicalMemory>),
20    /// Not yet; leave it staged.
21    Hold {
22        /// Why it did not qualify.
23        reason: PromotionShortfall,
24    },
25    /// Stale beyond the retention window; drop it.
26    Expire(Box<CanonicalMemory>),
27}
28
29/// Why a staged record was not promoted.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PromotionShortfall {
32    /// Not enough supporting observations.
33    Evidence,
34    /// Seen in too few logical sessions.
35    Sessions,
36    /// Seen on too few days.
37    Days,
38    /// Aggregated confidence below the bar.
39    Confidence,
40    /// Sensitive, and never stated outright.
41    RequiresExplicitStatement,
42}
43
44/// How long a staged pattern may sit unreinforced before it is dropped.
45///
46/// Staging is not a graveyard: an inference nothing has confirmed in three
47/// months was probably wrong, and keeping it costs retrieval quality.
48pub const STAGING_RETENTION_DAYS: i64 = 90;
49
50/// Evaluate one staged record.
51pub fn evaluate(
52    staged: &CanonicalMemory,
53    config: &PromotionConfig,
54    now: DateTime<Utc>,
55) -> PromotionOutcome {
56    let explicitness = if staged.source.is_explicit() {
57        Explicitness::ExplicitStatement
58    } else {
59        Explicitness::StrongImplication
60    };
61
62    let evidence = PromotionEvidence {
63        evidence_count: staged.evidence.count,
64        distinct_sessions: staged.evidence.distinct_sessions,
65        distinct_days: staged.evidence.distinct_days,
66        confidence: staged.confidence,
67        has_unresolved_contradiction: false,
68        sensitivity: staged.privacy.sensitivity,
69        strongest_explicitness: explicitness,
70    };
71
72    if meets_promotion_criteria(&evidence, config) {
73        let mut promoted = staged.clone();
74        promoted.status = MemoryStatus::Active;
75        promoted.temporal.updated_at = now;
76        promoted.evidence_summary = format!(
77            "Promoted from a staged pattern after {} observations across {} sessions and {} days.",
78            evidence.evidence_count, evidence.distinct_sessions, evidence.distinct_days
79        );
80        return PromotionOutcome::Promote(Box::new(promoted));
81    }
82
83    if now - staged.temporal.last_confirmed_at > Duration::days(STAGING_RETENTION_DAYS) {
84        return PromotionOutcome::Expire(Box::new(staged.clone()));
85    }
86
87    PromotionOutcome::Hold {
88        reason: shortfall(&evidence, config),
89    }
90}
91
92fn shortfall(evidence: &PromotionEvidence, config: &PromotionConfig) -> PromotionShortfall {
93    if evidence.sensitivity != crate::core::SensitivityClass::Normal
94        && !evidence.strongest_explicitness.is_explicit()
95    {
96        return PromotionShortfall::RequiresExplicitStatement;
97    }
98    if evidence.evidence_count < config.minimum_evidence_count {
99        return PromotionShortfall::Evidence;
100    }
101    if evidence.distinct_sessions < config.minimum_distinct_sessions {
102        return PromotionShortfall::Sessions;
103    }
104    if evidence.distinct_days < config.minimum_distinct_days {
105        return PromotionShortfall::Days;
106    }
107    PromotionShortfall::Confidence
108}
109
110/// Run a promotion sweep across every staged record in a namespace.
111pub fn sweep(
112    records: &[CanonicalMemory],
113    config: &PromotionConfig,
114    now: DateTime<Utc>,
115) -> Vec<PromotionOutcome> {
116    records
117        .iter()
118        .filter(|m| m.status == MemoryStatus::Staged)
119        .map(|m| evaluate(m, config, now))
120        .collect()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::core::{
127        CanonicalPredicate, EntityRef, EvidenceCounters, MemoryId, MemoryKind, MemorySource,
128        MemoryValue, PrivacyMetadata, RetrievalMetadata, SensitivityClass, SessionId,
129        TemporalMetadata, TemporalScope, TurnId, UserId,
130    };
131
132    fn staged(
133        count: u32,
134        sessions: u32,
135        days: u32,
136        confidence: f32,
137        explicitness: Explicitness,
138    ) -> CanonicalMemory {
139        let now = Utc::now();
140        CanonicalMemory {
141            id: MemoryId::new("mem_staged"),
142            owner: UserId::new("usr_1"),
143            kind: MemoryKind::Routine,
144            predicate: CanonicalPredicate::new("exercise_routine"),
145            status: MemoryStatus::Staged,
146            confidence,
147            subject: EntityRef::user(),
148            value: MemoryValue::Text("morning gym".into()),
149            statement: "The user exercises before work.".into(),
150            evidence_summary: "inferred".into(),
151            source: MemorySource::from_explicitness(
152                explicitness,
153                SessionId::new("ses_1"),
154                TurnId(2),
155            ),
156            temporal: TemporalMetadata::created_at(now),
157            retrieval: RetrievalMetadata {
158                subject: "user".into(),
159                ..Default::default()
160            },
161            evidence: EvidenceCounters {
162                count,
163                distinct_sessions: sessions,
164                distinct_days: days,
165            },
166            privacy: PrivacyMetadata::default(),
167            temporal_scope: TemporalScope::Persistent,
168            supersedes: Vec::new(),
169            superseded_by: None,
170            qualifier: None,
171        }
172    }
173
174    #[test]
175    fn a_pattern_seen_across_sessions_and_days_is_promoted() {
176        let outcome = evaluate(
177            &staged(3, 2, 2, 0.85, Explicitness::StrongImplication),
178            &PromotionConfig::default(),
179            Utc::now(),
180        );
181        match outcome {
182            PromotionOutcome::Promote(memory) => {
183                assert_eq!(memory.status, MemoryStatus::Active);
184                assert!(memory.evidence_summary.contains("Promoted from a staged"));
185            }
186            other => panic!("expected promotion, got {other:?}"),
187        }
188    }
189
190    #[test]
191    fn repetition_within_one_session_is_held_not_promoted() {
192        let outcome = evaluate(
193            &staged(5, 1, 1, 0.9, Explicitness::StrongImplication),
194            &PromotionConfig::default(),
195            Utc::now(),
196        );
197        assert_eq!(
198            outcome,
199            PromotionOutcome::Hold {
200                reason: PromotionShortfall::Sessions
201            }
202        );
203    }
204
205    #[test]
206    fn each_shortfall_is_reported_specifically() {
207        let config = PromotionConfig::default();
208        let now = Utc::now();
209        assert_eq!(
210            evaluate(
211                &staged(1, 2, 2, 0.9, Explicitness::StrongImplication),
212                &config,
213                now
214            ),
215            PromotionOutcome::Hold {
216                reason: PromotionShortfall::Evidence
217            }
218        );
219        assert_eq!(
220            evaluate(
221                &staged(3, 2, 1, 0.9, Explicitness::StrongImplication),
222                &config,
223                now
224            ),
225            PromotionOutcome::Hold {
226                reason: PromotionShortfall::Days
227            }
228        );
229        assert_eq!(
230            evaluate(
231                &staged(3, 2, 2, 0.5, Explicitness::StrongImplication),
232                &config,
233                now
234            ),
235            PromotionOutcome::Hold {
236                reason: PromotionShortfall::Confidence
237            }
238        );
239    }
240
241    #[test]
242    fn a_sensitive_pattern_is_never_promoted_on_repetition_alone() {
243        let mut record = staged(9, 5, 5, 0.99, Explicitness::StrongImplication);
244        record.privacy.sensitivity = SensitivityClass::Sensitive;
245        assert_eq!(
246            evaluate(&record, &PromotionConfig::default(), Utc::now()),
247            PromotionOutcome::Hold {
248                reason: PromotionShortfall::RequiresExplicitStatement
249            }
250        );
251    }
252
253    #[test]
254    fn a_stale_unreinforced_pattern_expires() {
255        let mut record = staged(1, 1, 1, 0.4, Explicitness::WeakInference);
256        record.temporal.last_confirmed_at = Utc::now() - Duration::days(120);
257        assert!(matches!(
258            evaluate(&record, &PromotionConfig::default(), Utc::now()),
259            PromotionOutcome::Expire(_)
260        ));
261    }
262
263    #[test]
264    fn the_sweep_ignores_records_that_are_not_staged() {
265        let mut active = staged(9, 9, 9, 0.99, Explicitness::ExplicitStatement);
266        active.status = MemoryStatus::Active;
267        assert!(sweep(&[active], &PromotionConfig::default(), Utc::now()).is_empty());
268    }
269}