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 continue;
375 }
376 by_window
377 .entry(candidate.subject_predicate().to_string())
378 .or_default()
379 .push(fingerprint.clone());
380 }
381
382 for (_, fingerprints) in by_window {
383 if fingerprints.len() < 2 {
384 continue;
385 }
386 let corrected = fingerprints.iter().any(|f| {
390 state.candidates[f].mutation_intent == Some(crate::core::MutationIntent::Correct)
391 });
392 let single_valued = fingerprints
393 .iter()
394 .any(|f| is_single_valued(state.candidates[f].predicate.as_str()));
395 if !corrected && !single_valued {
396 continue;
397 }
398 let winner = fingerprints
399 .iter()
400 .max_by(|a, b| {
401 let ca = &state.candidates[*a];
402 let cb = &state.candidates[*b];
403 ca.explicitness
404 .cmp(&cb.explicitness)
405 .then_with(|| ca.last_seen_turn.cmp(&cb.last_seen_turn))
406 })
407 .cloned()
408 .expect("non-empty window");
409
410 for fingerprint in fingerprints {
411 if fingerprint == winner {
412 continue;
413 }
414 if let Some(candidate) = state.candidates.get_mut(&fingerprint)
415 && candidate.status != SessionCandidateStatus::Suppressed
416 {
417 candidate.status = SessionCandidateStatus::Suppressed;
418 report.suppressed += 1;
419 }
420 }
421 }
422
423 for candidate in state.candidates.values_mut() {
425 if candidate.status == SessionCandidateStatus::Observed
426 && (candidate.distinct_turns >= 2
427 || candidate.confidence >= self.config.minimum_observation_confidence * 2.0)
428 {
429 candidate.status = SessionCandidateStatus::StagedForReconciliation;
430 report.staged += 1;
431 }
432 }
433
434 report.candidates = state.candidates.len();
435 state.revision += 1;
436 report
437 }
438}
439
440#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
442pub struct MicroReconciliationReport {
443 pub suppressed: usize,
445 pub staged: usize,
447 pub candidates: usize,
449}
450
451#[async_trait]
452impl SessionLedger for InMemorySessionLedger {
453 async fn append_observation(
454 &self,
455 observation: MemoryObservation,
456 ) -> Result<LedgerOutcome, MemoryError> {
457 if self.inner.read().sealed {
458 return Err(MemoryError::PolicyRefused(
459 "session ledger is sealed".to_string(),
460 ));
461 }
462
463 let persistence = match admit_observation(&observation, &self.config) {
464 AdmissionVerdict::Accept(persistence) => persistence,
465 AdmissionVerdict::Reject(reason) => return Ok(LedgerOutcome::Rejected(reason)),
466 };
467
468 let now = Utc::now();
469 let fingerprint = observation.fingerprint();
470 let mut state = self.inner.write();
471 state.revision += 1;
472
473 match state.candidates.get_mut(&fingerprint) {
474 Some(existing) => {
475 existing.absorb(&observation, now);
476 Ok(LedgerOutcome::Reinforced {
477 fingerprint,
478 evidence_count: existing.evidence.len(),
479 })
480 }
481 None => {
482 state.candidates.insert(
483 fingerprint.clone(),
484 SessionCandidate::from_observation(&observation, persistence, now),
485 );
486 Ok(LedgerOutcome::Created(fingerprint))
487 }
488 }
489 }
490
491 async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError> {
492 let state = self.inner.read();
493 Ok(SessionLedgerSnapshot {
494 session_id: self.session_id.clone(),
495 candidates: state.candidates.values().cloned().collect(),
496 revision: state.revision,
497 })
498 }
499
500 async fn seal(&self) -> Result<SealedSessionLedger, MemoryError> {
501 let mut state = self.inner.write();
502 state.sealed = true;
503 Ok(SealedSessionLedger {
504 session_id: self.session_id.clone(),
505 candidates: state
506 .candidates
507 .values()
508 .filter(|c| {
509 !matches!(
510 c.status,
511 SessionCandidateStatus::Rejected | SessionCandidateStatus::Suppressed
512 )
513 })
514 .cloned()
515 .collect(),
516 sealed_at: Utc::now(),
517 })
518 }
519}
520
521fn is_single_valued(predicate: &str) -> bool {
528 !matches!(predicate, "preference" | "routine")
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::core::{CanonicalPredicate, EntityRef, SpeakerAttribution, TranscriptEvidence};
535
536 fn observation(
537 predicate: &str,
538 value: &str,
539 turn: u64,
540 explicitness: Explicitness,
541 ) -> MemoryObservation {
542 MemoryObservation {
543 observation_id: ObservationId::generate(),
544 session_id: SessionId::new("ses_1"),
545 turn_id: TurnId(turn),
546 subject: EntityRef::user(),
547 predicate: CanonicalPredicate::new(predicate),
548 value: MemoryValue::Text(value.to_string()),
549 canonical_statement: format!("The user is {value}."),
550 kind: MemoryKind::Preference,
551 explicitness,
552 confidence: 0.9,
553 persistence: ProposedPersistence::Durable,
554 temporal_scope: TemporalScope::Persistent,
555 valid_from: None,
556 expected_expiry: None,
557 transcript_evidence: TranscriptEvidence::new(format!("I am {value}")),
558 speaker_attribution: SpeakerAttribution::User,
559 sensitivity: SensitivityClass::Normal,
560 mutation_intent: None,
561 search_terms: Vec::new(),
562 }
563 }
564
565 fn ledger() -> InMemorySessionLedger {
566 InMemorySessionLedger::new(SessionId::new("ses_1"), IngestionConfig::default())
567 }
568
569 #[tokio::test]
570 async fn the_same_fact_stated_twice_becomes_one_candidate_with_two_evidences() {
571 let ledger = ledger();
572 let first = ledger
573 .append_observation(observation(
574 "dietary_identity",
575 "pescatarian",
576 1,
577 Explicitness::ExplicitStatement,
578 ))
579 .await
580 .unwrap();
581 assert!(matches!(first, LedgerOutcome::Created(_)));
582
583 let second = ledger
584 .append_observation(observation(
585 "dietary_identity",
586 "pescatarian",
587 4,
588 Explicitness::ExplicitStatement,
589 ))
590 .await
591 .unwrap();
592 assert!(matches!(
593 second,
594 LedgerOutcome::Reinforced {
595 evidence_count: 2,
596 ..
597 }
598 ));
599 assert_eq!(ledger.len(), 1);
600 assert_eq!(ledger.usable_candidates()[0].distinct_turns, 2);
601 }
602
603 #[tokio::test]
604 async fn refused_observations_never_enter_the_ledger() {
605 let ledger = ledger();
606 let mut bystander = observation(
607 "dietary_identity",
608 "vegan",
609 1,
610 Explicitness::ExplicitStatement,
611 );
612 bystander.speaker_attribution = SpeakerAttribution::Bystander;
613
614 let outcome = ledger.append_observation(bystander).await.unwrap();
615 assert_eq!(
616 outcome,
617 LedgerOutcome::Rejected(DiscardReason::SpeakerNotUser)
618 );
619 assert!(ledger.is_empty());
620 }
621
622 #[tokio::test]
623 async fn a_later_contradiction_suppresses_the_earlier_candidate() {
624 let ledger = ledger();
625 ledger
626 .append_observation(observation(
627 "dietary_identity",
628 "vegetarian",
629 1,
630 Explicitness::ExplicitStatement,
631 ))
632 .await
633 .unwrap();
634 ledger
635 .append_observation(observation(
636 "dietary_identity",
637 "pescatarian",
638 5,
639 Explicitness::ExplicitStatement,
640 ))
641 .await
642 .unwrap();
643
644 let report = ledger.micro_reconcile();
645 assert_eq!(report.suppressed, 1);
646
647 let usable = ledger.usable_candidates();
648 assert_eq!(usable.len(), 1);
649 assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
650 }
651
652 #[tokio::test]
653 async fn an_explicit_correction_beats_a_more_recent_inference() {
654 let ledger = ledger();
655 ledger
656 .append_observation(observation(
657 "dietary_identity",
658 "pescatarian",
659 1,
660 Explicitness::ExplicitStatement,
661 ))
662 .await
663 .unwrap();
664 ledger
666 .append_observation(observation(
667 "dietary_identity",
668 "vegan",
669 6,
670 Explicitness::WeakInference,
671 ))
672 .await
673 .unwrap();
674
675 ledger.micro_reconcile();
676 let usable = ledger.usable_candidates();
677 assert_eq!(usable.len(), 1);
678 assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
679 }
680
681 #[tokio::test]
682 async fn repeated_inference_earns_staging_but_not_more() {
683 let ledger = ledger();
684 for turn in 1..=2 {
685 ledger
686 .append_observation(observation(
687 "exercise_routine",
688 "morning gym",
689 turn,
690 Explicitness::StrongImplication,
691 ))
692 .await
693 .unwrap();
694 }
695 let report = ledger.micro_reconcile();
696 assert_eq!(report.staged, 1);
697 assert_eq!(
698 ledger.usable_candidates()[0].status,
699 SessionCandidateStatus::StagedForReconciliation
700 );
701 }
702
703 #[tokio::test]
704 async fn sealing_drops_suppressed_and_rejected_candidates_and_stops_writes() {
705 let ledger = ledger();
706 ledger
707 .append_observation(observation(
708 "dietary_identity",
709 "vegetarian",
710 1,
711 Explicitness::ExplicitStatement,
712 ))
713 .await
714 .unwrap();
715 ledger
716 .append_observation(observation(
717 "dietary_identity",
718 "pescatarian",
719 5,
720 Explicitness::ExplicitStatement,
721 ))
722 .await
723 .unwrap();
724 ledger.micro_reconcile();
725
726 let sealed = ledger.seal().await.unwrap();
727 assert_eq!(sealed.candidates.len(), 1);
728
729 let err = ledger
730 .append_observation(observation(
731 "dietary_identity",
732 "vegan",
733 9,
734 Explicitness::ExplicitStatement,
735 ))
736 .await
737 .unwrap_err();
738 assert!(matches!(err, MemoryError::PolicyRefused(_)));
739 }
740
741 #[tokio::test]
742 async fn confidence_aggregates_rather_than_accumulates() {
743 let ledger = ledger();
744 for turn in 1..=4 {
745 ledger
746 .append_observation(observation(
747 "beverage_preference",
748 "flat white",
749 turn,
750 Explicitness::ExplicitStatement,
751 ))
752 .await
753 .unwrap();
754 }
755 let candidate = &ledger.usable_candidates()[0];
756 assert!(candidate.confidence <= 0.95);
757 assert!(candidate.confidence >= 0.9);
758 }
759}