1use async_trait::async_trait;
9use chrono::{DateTime, Datelike, Utc};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashSet};
13
14use crate::core::{
15 AdmissionVerdict, DiscardReason, Explicitness, FactFingerprint, IngestionConfig, MemoryError,
16 MemoryKind, MemoryObservation, MemoryValue, MutationIntent, ObservationId, ProposedPersistence,
17 SensitivityClass, SessionId, TemporalScope, TurnId, admit_observation, aggregate_confidence,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SessionCandidateStatus {
24 Observed,
26 ActiveSessionFact,
28 StagedForReconciliation,
30 Suppressed,
32 Rejected,
34 PendingExplicitCommit,
36}
37
38impl SessionCandidateStatus {
39 pub fn is_usable(self) -> bool {
41 matches!(
42 self,
43 Self::ActiveSessionFact | Self::StagedForReconciliation | Self::PendingExplicitCommit
44 )
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ObservationEvidence {
51 pub observation_id: ObservationId,
53 pub turn_id: TurnId,
55 pub explicitness: Explicitness,
57 pub confidence: f32,
59 pub utterance: String,
61 pub observed_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct SessionCandidate {
68 pub fingerprint: FactFingerprint,
70 pub subject: crate::core::EntityRef,
72 pub predicate: crate::core::CanonicalPredicate,
74 pub value: MemoryValue,
76 pub canonical_statement: String,
78 pub kind: MemoryKind,
80 pub temporal_scope: TemporalScope,
82 pub evidence: Vec<ObservationEvidence>,
84 pub distinct_turns: usize,
86 pub first_seen_turn: TurnId,
88 pub last_seen_turn: TurnId,
90 pub confidence: f32,
92 pub explicitness: Explicitness,
94 pub proposed_persistence: ProposedPersistence,
96 pub status: SessionCandidateStatus,
98 pub mutation_intent: Option<MutationIntent>,
100 pub search_terms: Vec<String>,
102 pub sensitivity: SensitivityClass,
108 pub expected_expiry: Option<DateTime<Utc>>,
110}
111
112impl SessionCandidate {
113 fn from_observation(
114 observation: &MemoryObservation,
115 persistence: ProposedPersistence,
116 now: DateTime<Utc>,
117 ) -> Self {
118 let evidence = ObservationEvidence {
119 observation_id: observation.observation_id.clone(),
120 turn_id: observation.turn_id,
121 explicitness: observation.explicitness,
122 confidence: observation.confidence,
123 utterance: observation.transcript_evidence.utterance.clone(),
124 observed_at: now,
125 };
126 let status = status_for(persistence, observation);
127 Self {
128 fingerprint: observation.fingerprint(),
129 subject: observation.subject.clone(),
130 predicate: observation.predicate.clone(),
131 value: observation.value.clone(),
132 canonical_statement: observation.canonical_statement.clone(),
133 kind: observation.kind,
134 temporal_scope: observation.temporal_scope,
135 evidence: vec![evidence],
136 distinct_turns: 1,
137 first_seen_turn: observation.turn_id,
138 last_seen_turn: observation.turn_id,
139 confidence: observation.confidence,
140 explicitness: observation.explicitness,
141 proposed_persistence: persistence,
142 status,
143 mutation_intent: observation.mutation_intent,
144 search_terms: observation.search_terms.clone(),
145 sensitivity: observation.sensitivity,
146 expected_expiry: observation.expected_expiry,
147 }
148 }
149
150 fn absorb(&mut self, observation: &MemoryObservation, now: DateTime<Utc>) {
151 if self
152 .evidence
153 .iter()
154 .any(|e| e.observation_id == observation.observation_id)
155 {
156 return;
157 }
158 let new_turn = observation.turn_id != self.last_seen_turn;
159 self.evidence.push(ObservationEvidence {
160 observation_id: observation.observation_id.clone(),
161 turn_id: observation.turn_id,
162 explicitness: observation.explicitness,
163 confidence: observation.confidence,
164 utterance: observation.transcript_evidence.utterance.clone(),
165 observed_at: now,
166 });
167 if new_turn {
168 self.distinct_turns += 1;
169 }
170 self.last_seen_turn = observation.turn_id;
171 self.explicitness = self.explicitness.max(observation.explicitness);
172 self.confidence = aggregate_confidence(
173 &self
174 .evidence
175 .iter()
176 .map(|e| (e.confidence, e.explicitness))
177 .collect::<Vec<_>>(),
178 );
179 if observation.mutation_intent.is_some() {
180 self.mutation_intent = observation.mutation_intent;
181 }
182 self.sensitivity = self.sensitivity.max(observation.sensitivity);
184 for term in &observation.search_terms {
187 if !self
188 .search_terms
189 .iter()
190 .any(|t| t.eq_ignore_ascii_case(term))
191 {
192 self.search_terms.push(term.clone());
193 }
194 }
195 if self.status == SessionCandidateStatus::Observed && self.explicitness.is_explicit() {
196 self.status = SessionCandidateStatus::ActiveSessionFact;
197 }
198 }
199
200 pub fn distinct_days(&self) -> u32 {
202 self.evidence
203 .iter()
204 .map(|e| (e.observed_at.year(), e.observed_at.ordinal()))
205 .collect::<HashSet<_>>()
206 .len() as u32
207 }
208
209 pub fn subject_predicate(&self) -> &str {
211 self.fingerprint.subject_predicate()
212 }
213}
214
215fn status_for(
216 persistence: ProposedPersistence,
217 observation: &MemoryObservation,
218) -> SessionCandidateStatus {
219 if observation.mutation_intent.is_some() {
220 return SessionCandidateStatus::PendingExplicitCommit;
221 }
222 match persistence {
223 ProposedPersistence::Durable | ProposedPersistence::Episodic => {
224 SessionCandidateStatus::ActiveSessionFact
225 }
226 ProposedPersistence::SessionOnly => SessionCandidateStatus::ActiveSessionFact,
227 ProposedPersistence::Staged => SessionCandidateStatus::Observed,
228 ProposedPersistence::Discard => SessionCandidateStatus::Rejected,
229 }
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub enum LedgerOutcome {
235 Created(FactFingerprint),
237 Reinforced {
239 fingerprint: FactFingerprint,
241 evidence_count: usize,
243 },
244 Rejected(DiscardReason),
246}
247
248#[derive(Debug, Clone)]
250pub struct SealedSessionLedger {
251 pub session_id: SessionId,
253 pub candidates: Vec<SessionCandidate>,
255 pub sealed_at: DateTime<Utc>,
257}
258
259#[derive(Debug, Clone)]
261pub struct SessionLedgerSnapshot {
262 pub session_id: SessionId,
264 pub candidates: Vec<SessionCandidate>,
266 pub revision: u64,
268}
269
270#[async_trait]
272pub trait SessionLedger: Send + Sync {
273 async fn append_observation(
275 &self,
276 observation: MemoryObservation,
277 ) -> Result<LedgerOutcome, MemoryError>;
278
279 async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError>;
281
282 async fn seal(&self) -> Result<SealedSessionLedger, MemoryError>;
284}
285
286#[derive(Debug)]
288pub struct InMemorySessionLedger {
289 session_id: SessionId,
290 config: IngestionConfig,
291 inner: RwLock<LedgerState>,
292}
293
294#[derive(Debug, Default)]
295struct LedgerState {
296 candidates: BTreeMap<FactFingerprint, SessionCandidate>,
297 revision: u64,
298 sealed: bool,
299}
300
301impl InMemorySessionLedger {
302 pub fn new(session_id: SessionId, config: IngestionConfig) -> Self {
304 Self {
305 session_id,
306 config,
307 inner: RwLock::new(LedgerState::default()),
308 }
309 }
310
311 pub fn session_id(&self) -> &SessionId {
313 &self.session_id
314 }
315
316 pub fn revision(&self) -> u64 {
318 self.inner.read().revision
319 }
320
321 pub fn len(&self) -> usize {
323 self.inner.read().candidates.len()
324 }
325
326 pub fn is_empty(&self) -> bool {
328 self.inner.read().candidates.is_empty()
329 }
330
331 pub fn usable_candidates(&self) -> Vec<SessionCandidate> {
333 self.inner
334 .read()
335 .candidates
336 .values()
337 .filter(|c| c.status.is_usable())
338 .cloned()
339 .collect()
340 }
341
342 pub fn micro_reconcile(&self) -> MicroReconciliationReport {
348 let mut state = self.inner.write();
349 let mut report = MicroReconciliationReport::default();
350
351 let mut by_window: BTreeMap<String, Vec<FactFingerprint>> = BTreeMap::new();
372 for (fingerprint, candidate) in state.candidates.iter() {
373 if candidate.status == SessionCandidateStatus::Rejected
374 || matches!(
375 candidate.mutation_intent,
376 Some(
377 crate::core::MutationIntent::Forget
378 | crate::core::MutationIntent::Delete
379 | crate::core::MutationIntent::List
380 )
381 )
382 {
383 continue;
384 }
385 by_window
386 .entry(candidate.subject_predicate().to_string())
387 .or_default()
388 .push(fingerprint.clone());
389 }
390
391 for (_, fingerprints) in by_window {
392 if fingerprints.len() < 2 {
393 continue;
394 }
395 let corrected = fingerprints.iter().any(|f| {
399 state.candidates[f].mutation_intent == Some(crate::core::MutationIntent::Correct)
400 });
401 let single_valued = fingerprints
402 .iter()
403 .any(|f| is_single_valued(state.candidates[f].predicate.as_str()));
404 if !corrected && !single_valued {
405 continue;
406 }
407 let winner = fingerprints
408 .iter()
409 .max_by(|a, b| {
410 let ca = &state.candidates[*a];
411 let cb = &state.candidates[*b];
412 ca.explicitness
413 .cmp(&cb.explicitness)
414 .then_with(|| ca.last_seen_turn.cmp(&cb.last_seen_turn))
415 })
416 .cloned()
417 .expect("non-empty window");
418
419 for fingerprint in fingerprints {
420 if fingerprint == winner {
421 continue;
422 }
423 if let Some(candidate) = state.candidates.get_mut(&fingerprint)
424 && candidate.status != SessionCandidateStatus::Suppressed
425 {
426 candidate.status = SessionCandidateStatus::Suppressed;
427 report.suppressed += 1;
428 }
429 }
430 }
431
432 for candidate in state.candidates.values_mut() {
434 if candidate.status == SessionCandidateStatus::Observed
435 && (candidate.distinct_turns >= 2
436 || candidate.confidence >= self.config.minimum_observation_confidence * 2.0)
437 {
438 candidate.status = SessionCandidateStatus::StagedForReconciliation;
439 report.staged += 1;
440 }
441 }
442
443 report.candidates = state.candidates.len();
444 state.revision += 1;
445 report
446 }
447}
448
449#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
451pub struct MicroReconciliationReport {
452 pub suppressed: usize,
454 pub staged: usize,
456 pub candidates: usize,
458}
459
460#[async_trait]
461impl SessionLedger for InMemorySessionLedger {
462 async fn append_observation(
463 &self,
464 observation: MemoryObservation,
465 ) -> Result<LedgerOutcome, MemoryError> {
466 if self.inner.read().sealed {
467 return Err(MemoryError::PolicyRefused(
468 "session ledger is sealed".to_string(),
469 ));
470 }
471
472 let persistence = match admit_observation(&observation, &self.config) {
473 AdmissionVerdict::Accept(persistence) => persistence,
474 AdmissionVerdict::Reject(reason) => return Ok(LedgerOutcome::Rejected(reason)),
475 };
476
477 let now = Utc::now();
478 let fingerprint = observation.fingerprint();
479 let mut state = self.inner.write();
480 state.revision += 1;
481
482 match state.candidates.get_mut(&fingerprint) {
483 Some(existing) => {
484 existing.absorb(&observation, now);
485 Ok(LedgerOutcome::Reinforced {
486 fingerprint,
487 evidence_count: existing.evidence.len(),
488 })
489 }
490 None => {
491 state.candidates.insert(
492 fingerprint.clone(),
493 SessionCandidate::from_observation(&observation, persistence, now),
494 );
495 Ok(LedgerOutcome::Created(fingerprint))
496 }
497 }
498 }
499
500 async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError> {
501 let state = self.inner.read();
502 Ok(SessionLedgerSnapshot {
503 session_id: self.session_id.clone(),
504 candidates: state.candidates.values().cloned().collect(),
505 revision: state.revision,
506 })
507 }
508
509 async fn seal(&self) -> Result<SealedSessionLedger, MemoryError> {
510 let mut state = self.inner.write();
511 state.sealed = true;
512 Ok(SealedSessionLedger {
513 session_id: self.session_id.clone(),
514 candidates: state
515 .candidates
516 .values()
517 .filter(|c| {
518 !matches!(
519 c.status,
520 SessionCandidateStatus::Rejected | SessionCandidateStatus::Suppressed
521 )
522 })
523 .cloned()
524 .collect(),
525 sealed_at: Utc::now(),
526 })
527 }
528}
529
530fn is_single_valued(predicate: &str) -> bool {
537 !matches!(predicate, "preference" | "routine")
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::core::{CanonicalPredicate, EntityRef, SpeakerAttribution, TranscriptEvidence};
544
545 fn observation(
546 predicate: &str,
547 value: &str,
548 turn: u64,
549 explicitness: Explicitness,
550 ) -> MemoryObservation {
551 MemoryObservation {
552 observation_id: ObservationId::generate(),
553 session_id: SessionId::new("ses_1"),
554 turn_id: TurnId(turn),
555 subject: EntityRef::user(),
556 predicate: CanonicalPredicate::new(predicate),
557 value: MemoryValue::Text(value.to_string()),
558 canonical_statement: format!("The user is {value}."),
559 kind: MemoryKind::Preference,
560 explicitness,
561 confidence: 0.9,
562 persistence: ProposedPersistence::Durable,
563 temporal_scope: TemporalScope::Persistent,
564 valid_from: None,
565 expected_expiry: None,
566 transcript_evidence: TranscriptEvidence::new(format!("I am {value}")),
567 speaker_attribution: SpeakerAttribution::User,
568 sensitivity: SensitivityClass::Normal,
569 mutation_intent: None,
570 search_terms: Vec::new(),
571 }
572 }
573
574 fn ledger() -> InMemorySessionLedger {
575 InMemorySessionLedger::new(SessionId::new("ses_1"), IngestionConfig::default())
576 }
577
578 #[tokio::test]
579 async fn the_same_fact_stated_twice_becomes_one_candidate_with_two_evidences() {
580 let ledger = ledger();
581 let first = ledger
582 .append_observation(observation(
583 "dietary_identity",
584 "pescatarian",
585 1,
586 Explicitness::ExplicitStatement,
587 ))
588 .await
589 .unwrap();
590 assert!(matches!(first, LedgerOutcome::Created(_)));
591
592 let second = ledger
593 .append_observation(observation(
594 "dietary_identity",
595 "pescatarian",
596 4,
597 Explicitness::ExplicitStatement,
598 ))
599 .await
600 .unwrap();
601 assert!(matches!(
602 second,
603 LedgerOutcome::Reinforced {
604 evidence_count: 2,
605 ..
606 }
607 ));
608 assert_eq!(ledger.len(), 1);
609 assert_eq!(ledger.usable_candidates()[0].distinct_turns, 2);
610 }
611
612 #[tokio::test]
613 async fn refused_observations_never_enter_the_ledger() {
614 let ledger = ledger();
615 let mut bystander = observation(
616 "dietary_identity",
617 "vegan",
618 1,
619 Explicitness::ExplicitStatement,
620 );
621 bystander.speaker_attribution = SpeakerAttribution::Bystander;
622
623 let outcome = ledger.append_observation(bystander).await.unwrap();
624 assert_eq!(
625 outcome,
626 LedgerOutcome::Rejected(DiscardReason::SpeakerNotUser)
627 );
628 assert!(ledger.is_empty());
629 }
630
631 #[tokio::test]
632 async fn a_later_contradiction_suppresses_the_earlier_candidate() {
633 let ledger = ledger();
634 ledger
635 .append_observation(observation(
636 "dietary_identity",
637 "vegetarian",
638 1,
639 Explicitness::ExplicitStatement,
640 ))
641 .await
642 .unwrap();
643 ledger
644 .append_observation(observation(
645 "dietary_identity",
646 "pescatarian",
647 5,
648 Explicitness::ExplicitStatement,
649 ))
650 .await
651 .unwrap();
652
653 let report = ledger.micro_reconcile();
654 assert_eq!(report.suppressed, 1);
655
656 let usable = ledger.usable_candidates();
657 assert_eq!(usable.len(), 1);
658 assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
659 }
660
661 #[tokio::test]
662 async fn an_explicit_correction_beats_a_more_recent_inference() {
663 let ledger = ledger();
664 ledger
665 .append_observation(observation(
666 "dietary_identity",
667 "pescatarian",
668 1,
669 Explicitness::ExplicitStatement,
670 ))
671 .await
672 .unwrap();
673 ledger
675 .append_observation(observation(
676 "dietary_identity",
677 "vegan",
678 6,
679 Explicitness::WeakInference,
680 ))
681 .await
682 .unwrap();
683
684 ledger.micro_reconcile();
685 let usable = ledger.usable_candidates();
686 assert_eq!(usable.len(), 1);
687 assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
688 }
689
690 #[tokio::test]
691 async fn repeated_inference_earns_staging_but_not_more() {
692 let ledger = ledger();
693 for turn in 1..=2 {
694 ledger
695 .append_observation(observation(
696 "exercise_routine",
697 "morning gym",
698 turn,
699 Explicitness::StrongImplication,
700 ))
701 .await
702 .unwrap();
703 }
704 let report = ledger.micro_reconcile();
705 assert_eq!(report.staged, 1);
706 assert_eq!(
707 ledger.usable_candidates()[0].status,
708 SessionCandidateStatus::StagedForReconciliation
709 );
710 }
711
712 #[tokio::test]
713 async fn sealing_drops_suppressed_and_rejected_candidates_and_stops_writes() {
714 let ledger = ledger();
715 ledger
716 .append_observation(observation(
717 "dietary_identity",
718 "vegetarian",
719 1,
720 Explicitness::ExplicitStatement,
721 ))
722 .await
723 .unwrap();
724 ledger
725 .append_observation(observation(
726 "dietary_identity",
727 "pescatarian",
728 5,
729 Explicitness::ExplicitStatement,
730 ))
731 .await
732 .unwrap();
733 ledger.micro_reconcile();
734
735 let sealed = ledger.seal().await.unwrap();
736 assert_eq!(sealed.candidates.len(), 1);
737
738 let err = ledger
739 .append_observation(observation(
740 "dietary_identity",
741 "vegan",
742 9,
743 Explicitness::ExplicitStatement,
744 ))
745 .await
746 .unwrap_err();
747 assert!(matches!(err, MemoryError::PolicyRefused(_)));
748 }
749
750 #[tokio::test]
751 async fn confidence_aggregates_rather_than_accumulates() {
752 let ledger = ledger();
753 for turn in 1..=4 {
754 ledger
755 .append_observation(observation(
756 "beverage_preference",
757 "flat white",
758 turn,
759 Explicitness::ExplicitStatement,
760 ))
761 .await
762 .unwrap();
763 }
764 let candidate = &ledger.usable_candidates()[0];
765 assert!(candidate.confidence <= 0.95);
766 assert!(candidate.confidence >= 0.9);
767 }
768}