gemini_memory_rs/reconcile/
promotion.rs1use chrono::{DateTime, Duration, Utc};
9
10use crate::core::{
11 CanonicalMemory, Explicitness, MemoryStatus, PromotionConfig, PromotionEvidence,
12 meets_promotion_criteria,
13};
14
15#[derive(Debug, Clone, PartialEq)]
17pub enum PromotionOutcome {
18 Promote(Box<CanonicalMemory>),
20 Hold {
22 reason: PromotionShortfall,
24 },
25 Expire(Box<CanonicalMemory>),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PromotionShortfall {
32 Evidence,
34 Sessions,
36 Days,
38 Confidence,
40 RequiresExplicitStatement,
42}
43
44pub const STAGING_RETENTION_DAYS: i64 = 90;
49
50pub 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
110pub 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}