Skip to main content

gemini_memory_rs/ingestion/
observation.rs

1//! Turning finalized user speech into candidate observations.
2//!
3//! This is a different question from retrieval planning, asked by a different
4//! call with a different schema: *did the user just reveal something worth
5//! keeping?* Conflating the two produces a model that stores what it was asked
6//! to recall.
7//!
8//! Extraction never runs on a partial transcript. A partial may be revised, and
9//! evidence that can be revised is not evidence.
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14use std::time::Duration;
15
16use crate::core::{
17    CanonicalPredicate, EntityRef, Explicitness, MemoryError, MemoryKind, MemoryObservation,
18    MemoryValue, MutationIntent, ObservationId, ProposedPersistence, SensitivityClass, SessionId,
19    SpeakerAttribution, TemporalScope, TranscriptEvidence, TurnId, normalize_token,
20};
21
22/// The system instruction for the observation extractor.
23pub const OBSERVATION_EXTRACTION_INSTRUCTION: &str = "\
24You extract candidate memories from a single finalized user utterance for a \
25personal memory system.
26
27Rules:
28- Only extract things the USER said about themselves or their life. Never \
29  extract from the assistant's turns, and never from speech attributed to \
30  anyone else.
31- Distinguish durable facts (preferences, relationships, identity, routines) \
32  from time-bounded events (plans, moods, one-off situations). Mark the latter \
33  episodic with an expected expiry.
34- Mark a transient feeling or a passing remark as session-only or discard it.
35- Set explicitness honestly. If the user did not say it, it is an inference, \
36  however obvious it seems.
37- Never infer sensitive attributes (health, religion, politics, sexuality) the \
38  user did not state outright.
39- Recognise explicit memory commands ('remember that…', 'forget…', 'actually, \
40  I…') and set mutation_intent accordingly.
41- Reuse a predicate from the 'Predicates already in use' list whenever the new \
42  fact is about the same thing, even when it CONTRADICTS the stored one. A \
43  correction must land on the same predicate as the fact it corrects, or it \
44  becomes a second record instead of replacing the first. Invent a new \
45  predicate only when nothing in the list covers the fact.
46- Write the fact itself in English, always, whatever language the user spoke. \
47  statement, predicate, value, subject and qualifier are the canonical record: \
48  one user saying 'main vegetarian hoon', 'naan vegetarian', and 'I am \
49  vegetarian' must produce the SAME predicate and the SAME value, so that the \
50  three reinforce one memory instead of creating three.
51- search_terms are the exception and must NOT be normalized to English. They \
52  are not a transcription of this sentence — they are your guess at the words \
53  a FUTURE QUESTION would use. Write 4-8 of them: the topic word in the user's \
54  language even when this sentence never used it, its English equivalent, and \
55  the obvious synonyms. A user who says 'main vegetarian hoon' will later ask \
56  'mera khaana ka preference kya hai' or 'what do I eat', so this fact needs \
57  khaana, khana, food, diet, eat — not just the words hoon and khata that \
58  happen to appear above.
59- Return an empty list when the utterance reveals nothing worth keeping. That \
60  is the common case.";
61
62/// What the observation extractor is given.
63#[derive(Debug, Clone)]
64pub struct ObservationExtractionContext {
65    /// The finalized user utterance.
66    pub transcript: String,
67    /// Preceding user turns, for pronoun resolution.
68    pub recent_user_turns: Vec<String>,
69    /// The preceding assistant turn, for reference resolution only.
70    pub recent_assistant_turn: Option<String>,
71    /// Predicates already in use in this user's corpus, most-used first.
72    ///
73    /// Reconciliation matches on subject and predicate, so a correction only
74    /// supersedes the fact it corrects when the two agree on a name. Left to
75    /// invent one per call, the model writes `dietary_preference` on Monday
76    /// and `dietary_identity` on Tuesday, and the correction becomes a second
77    /// active record instead of replacing the first. Showing it the names
78    /// already in use is the same move as learning entities from the corpus:
79    /// the vocabulary comes from the data, not from a list in the binary and
80    /// not from the model's imagination each time.
81    pub known_predicates: Vec<String>,
82    /// Who the utterance is attributed to.
83    pub speaker: SpeakerAttribution,
84    /// The logical session.
85    pub session_id: SessionId,
86    /// The turn.
87    pub turn_id: TurnId,
88    /// Evaluation time, for resolving relative dates.
89    pub now: DateTime<Utc>,
90}
91
92impl ObservationExtractionContext {
93    /// A context for a finalized user turn.
94    pub fn user_turn(
95        transcript: impl Into<String>,
96        session_id: SessionId,
97        turn_id: TurnId,
98        now: DateTime<Utc>,
99    ) -> Self {
100        Self {
101            transcript: transcript.into(),
102            recent_user_turns: Vec::new(),
103            recent_assistant_turn: None,
104            known_predicates: Vec::new(),
105            speaker: SpeakerAttribution::User,
106            session_id,
107            turn_id,
108            now,
109        }
110    }
111
112    /// Attribute the utterance to someone other than the enrolled user.
113    pub fn attributed_to(mut self, speaker: SpeakerAttribution) -> Self {
114        self.speaker = speaker;
115        self
116    }
117
118    /// Offer the predicate names the corpus already uses.
119    pub fn with_known_predicates(mut self, predicates: Vec<String>) -> Self {
120        self.known_predicates = predicates;
121        self
122    }
123}
124
125/// Extracts candidate memories from an utterance.
126#[async_trait]
127pub trait MemoryObservationExtractor: Send + Sync {
128    /// Extract observations. An empty result is normal and expected.
129    async fn extract(
130        &self,
131        context: ObservationExtractionContext,
132    ) -> Result<Vec<MemoryObservation>, MemoryError>;
133}
134
135/// The JSON Schema a structured-output extraction should be constrained to.
136pub fn observation_schema() -> serde_json::Value {
137    let schema = schemars::schema_for!(MemoryObservation);
138    serde_json::to_value(schema).unwrap_or(serde_json::Value::Null)
139}
140
141/// Runs an extractor under a deadline.
142///
143/// Unlike retrieval planning there is no rule-based result worth substituting
144/// for a failed model call, so a timeout yields nothing. Missing one turn's
145/// evidence is recoverable — the durable transcript event allows a retry — and
146/// far preferable to blocking the pipeline.
147pub struct BoundedObservationExtractor {
148    inner: Arc<dyn MemoryObservationExtractor>,
149    timeout: Duration,
150}
151
152impl BoundedObservationExtractor {
153    /// Bound `inner` to `timeout`.
154    pub fn new(inner: Arc<dyn MemoryObservationExtractor>, timeout: Duration) -> Self {
155        Self { inner, timeout }
156    }
157}
158
159#[async_trait]
160impl MemoryObservationExtractor for BoundedObservationExtractor {
161    async fn extract(
162        &self,
163        context: ObservationExtractionContext,
164    ) -> Result<Vec<MemoryObservation>, MemoryError> {
165        match tokio::time::timeout(self.timeout, self.inner.extract(context)).await {
166            Ok(result) => result,
167            Err(_) => Err(MemoryError::DeadlineExceeded {
168                operation: "observation extraction",
169                budget_ms: self.timeout.as_millis() as u64,
170            }),
171        }
172    }
173}
174
175/// A rule-based extractor covering explicit statements and memory commands.
176///
177/// This is a floor, not a ceiling: it exists so the engine is useful and
178/// testable without a model in the loop, and so an unavailable extraction model
179/// still captures the cases that matter most — the ones where the user said
180/// "remember this" in so many words.
181#[derive(Debug, Default)]
182pub struct RuleBasedObservationExtractor;
183
184impl RuleBasedObservationExtractor {
185    /// A new extractor.
186    pub fn new() -> Self {
187        Self
188    }
189}
190
191/// Command phrases and the intent they signal, longest first so
192/// "don't forget" is not read as "forget".
193const COMMANDS: &[(&str, MutationIntent)] = &[
194    ("what do you remember", MutationIntent::List),
195    ("what do you know about me", MutationIntent::List),
196    ("please remember that", MutationIntent::Remember),
197    ("please remember", MutationIntent::Remember),
198    ("remember that", MutationIntent::Remember),
199    ("do not forget that", MutationIntent::Remember),
200    ("dont forget that", MutationIntent::Remember),
201    ("do not forget", MutationIntent::Remember),
202    ("dont forget", MutationIntent::Remember),
203    ("from now on", MutationIntent::Remember),
204    ("delete everything about", MutationIntent::Delete),
205    ("delete what you know about", MutationIntent::Delete),
206    ("forget that", MutationIntent::Forget),
207    ("forget about", MutationIntent::Forget),
208    ("forget", MutationIntent::Forget),
209    ("i am no longer", MutationIntent::Correct),
210    ("im no longer", MutationIntent::Correct),
211    ("correct that", MutationIntent::Correct),
212    ("actually i am", MutationIntent::Correct),
213    ("actually im", MutationIntent::Correct),
214];
215
216/// Statement openers that introduce a first-person fact.
217const SELF_STATEMENTS: &[(&str, MemoryKind)] = &[
218    ("i am allergic to", MemoryKind::Identity),
219    ("im allergic to", MemoryKind::Identity),
220    ("i do not eat", MemoryKind::Preference),
221    ("i dont eat", MemoryKind::Preference),
222    ("i never eat", MemoryKind::Preference),
223    ("i always", MemoryKind::Routine),
224    ("i usually", MemoryKind::Routine),
225    ("i have started", MemoryKind::Routine),
226    ("ive started", MemoryKind::Routine),
227    ("i prefer", MemoryKind::Preference),
228    ("i love", MemoryKind::Preference),
229    ("i like", MemoryKind::Preference),
230    ("i hate", MemoryKind::Preference),
231    ("i work at", MemoryKind::Identity),
232    ("i live in", MemoryKind::Identity),
233    ("i am", MemoryKind::Identity),
234    ("im", MemoryKind::Identity),
235];
236
237/// Words suggesting a time-bounded rather than durable statement.
238const EPISODIC_MARKERS: &[&str] = &[
239    "tonight",
240    "today",
241    "tomorrow",
242    "this morning",
243    "this afternoon",
244    "this evening",
245    "last night",
246    "yesterday",
247    "this week",
248    "right now",
249    "at the moment",
250];
251
252/// Categories that must be stated, never inferred.
253const SENSITIVE_MARKERS: &[&str] = &[
254    "diagnosed",
255    "medication",
256    "therapy",
257    "depressed",
258    "anxiety",
259    "pregnant",
260    "church",
261    "mosque",
262    "temple",
263    "synagogue",
264    "voted",
265    "political",
266];
267
268/// Normalize an utterance for phrase matching.
269///
270/// [`normalize_token`] turns every non-alphanumeric character into a separator.
271/// That is right for identifiers and fingerprints and wrong for a spoken
272/// sentence: it splits "I'm" into "i m" and "don't" into "don t", so the
273/// contracted forms this module's tables are *written in* — `im`, `im allergic
274/// to`, `dont forget` — could never match, and every one of them was dead.
275///
276/// It matters more here than it looks. This is a voice product; a transcript of
277/// someone talking is mostly contractions, so the effect was that "I'm allergic
278/// to sesame" produced no evidence at all while "I am allergic to sesame"
279/// produced a durable fact. Eliding the apostrophe first is the smallest change
280/// that makes the tables reachable, and it leaves `normalize_token` — which
281/// fingerprints the existing corpus — untouched.
282fn normalize_utterance(raw: &str) -> String {
283    let elided: String = raw
284        .chars()
285        .filter(|c| !matches!(c, '\'' | '\u{2019}' | '\u{02BC}'))
286        .collect();
287    normalize_token(&elided)
288}
289
290#[async_trait]
291impl MemoryObservationExtractor for RuleBasedObservationExtractor {
292    async fn extract(
293        &self,
294        context: ObservationExtractionContext,
295    ) -> Result<Vec<MemoryObservation>, MemoryError> {
296        // Attribution is checked here as well as at admission: an extractor
297        // should not be producing candidates it knows are inadmissible.
298        if !context.speaker.may_be_stored() {
299            return Ok(Vec::new());
300        }
301
302        let evidence = TranscriptEvidence::new(&context.transcript);
303        Ok(utterance_sentences(&context.transcript)
304            .into_iter()
305            .flat_map(|sentence| extract_sentence(&context, &evidence, sentence))
306            .collect())
307    }
308}
309
310// Preserve sentence boundaries before normalizing punctuation out of values.
311// A period inside a decimal or word is not a sentence boundary.
312fn utterance_sentences(raw: &str) -> Vec<&str> {
313    let mut sentences = Vec::new();
314    let mut start = 0;
315    let mut chars = raw.char_indices().peekable();
316    while let Some((index, ch)) = chars.next() {
317        let boundary = matches!(ch, '!' | '?' | ';' | '\n')
318            || (ch == '.' && chars.peek().is_none_or(|(_, next)| next.is_whitespace()));
319        if boundary {
320            sentences.push(&raw[start..index]);
321            start = index + ch.len_utf8();
322        }
323    }
324    sentences.push(&raw[start..]);
325    sentences
326}
327
328fn extract_sentence(
329    context: &ObservationExtractionContext,
330    evidence: &TranscriptEvidence,
331    sentence: &str,
332) -> Vec<MemoryObservation> {
333    let normalized = normalize_utterance(sentence);
334    let mut observations = Vec::new();
335
336    if let Some((phrase, intent)) = COMMANDS
337        .iter()
338        .find(|(phrase, _)| normalized.contains(phrase))
339    {
340        let raw_remainder = normalized
341            .split_once(phrase)
342            .map(|(_, rest)| rest.trim().to_string())
343            .unwrap_or_default();
344        // "remember that I am pescatarian now" carries the fact "the user
345        // is pescatarian" — stripping the self-reference is what lets it
346        // fingerprint against the same fact stated plainly.
347        let remainder = strip_self_reference(&raw_remainder);
348        observations.push(build(
349            context,
350            evidence,
351            command_kind(*intent),
352            CanonicalPredicate::new(command_predicate(*intent, &remainder)),
353            MemoryValue::Text(remainder.clone()),
354            command_statement(*intent, &remainder),
355            Explicitness::ExplicitCommand,
356            1.0,
357            ProposedPersistence::Durable,
358            TemporalScope::Persistent,
359            SensitivityClass::Normal,
360            Some(*intent),
361        ));
362        return observations;
363    }
364
365    // Every matching clause, not just the first. "I'm pescatarian and I
366    // prefer quiet places" is one utterance carrying two facts, and taking
367    // only the leading clause silently drops the other.
368    for (opener, kind) in SELF_STATEMENTS
369        .iter()
370        .filter(|(opener, _)| starts_clause(&normalized, opener))
371    {
372        let value = clause_after(&normalized, opener);
373        if value.is_empty() {
374            continue;
375        }
376
377        let episodic = EPISODIC_MARKERS.iter().any(|m| normalized.contains(m));
378        let (kind, scope, persistence) = if episodic {
379            (
380                MemoryKind::Episodic,
381                TemporalScope::Momentary,
382                ProposedPersistence::Episodic,
383            )
384        } else {
385            (
386                *kind,
387                TemporalScope::Persistent,
388                ProposedPersistence::Durable,
389            )
390        };
391
392        let sensitivity = if SENSITIVE_MARKERS.iter().any(|m| normalized.contains(m)) {
393            SensitivityClass::Sensitive
394        } else {
395            SensitivityClass::Normal
396        };
397
398        // Openers overlap: "I am allergic to nuts" matches both
399        // `i am allergic to` (value "nuts") and `i am` (value "allergic to
400        // nuts"). They describe the same clause, so only the more specific
401        // one is kept — otherwise a single fact is counted twice.
402        if observations.iter().any(|o: &MemoryObservation| {
403            let existing = o.value.display();
404            existing.contains(&value) || value.contains(&existing)
405        }) {
406            continue;
407        }
408        let predicate = CanonicalPredicate::new(predicate_for(opener, &value));
409        if observations
410            .iter()
411            .any(|o: &MemoryObservation| o.predicate == predicate)
412        {
413            continue;
414        }
415        observations.push(build(
416            context,
417            evidence,
418            kind,
419            predicate,
420            MemoryValue::Text(value.clone()),
421            statement_for(opener, &value),
422            Explicitness::ExplicitStatement,
423            0.9,
424            persistence,
425            scope,
426            sensitivity,
427            None,
428        ));
429    }
430
431    observations
432}
433
434/// The clause following `opener`, stopping at the next clause boundary.
435///
436/// Without the stop, "I am pescatarian and I prefer quiet places" would store
437/// the dietary fact with a value of "pescatarian and i prefer quiet places".
438fn clause_after(text: &str, opener: &str) -> String {
439    const SEPARATORS: [&str; 4] = [" and ", " but ", " so ", " because "];
440    let Some((_, rest)) = text.split_once(opener) else {
441        return String::new();
442    };
443    let mut clause = rest.trim();
444    for separator in SEPARATORS {
445        if let Some((head, _)) = clause.split_once(separator) {
446            clause = head.trim();
447        }
448    }
449    clause.to_string()
450}
451
452/// Whether `text` begins with `opener` on a clause boundary.
453///
454/// Guards against "i am" matching inside "what i am asking is" — which reads as
455/// a statement of identity only if you ignore the sentence around it.
456fn starts_clause(text: &str, opener: &str) -> bool {
457    if text.starts_with(opener) {
458        return true;
459    }
460    for separator in [" and ", " but ", " so ", " because "] {
461        if text.contains(&format!("{separator}{opener} ")) {
462            return true;
463        }
464    }
465    false
466}
467
468#[allow(clippy::too_many_arguments, reason = "an internal record constructor")]
469fn build(
470    context: &ObservationExtractionContext,
471    evidence: &TranscriptEvidence,
472    kind: MemoryKind,
473    predicate: CanonicalPredicate,
474    value: MemoryValue,
475    statement: String,
476    explicitness: Explicitness,
477    confidence: f32,
478    persistence: ProposedPersistence,
479    temporal_scope: TemporalScope,
480    sensitivity: SensitivityClass,
481    mutation_intent: Option<MutationIntent>,
482) -> MemoryObservation {
483    let expected_expiry =
484        crate::core::default_episodic_ttl(kind, temporal_scope).map(|ttl| context.now + ttl);
485    MemoryObservation {
486        observation_id: ObservationId::generate(),
487        session_id: context.session_id.clone(),
488        turn_id: context.turn_id,
489        subject: EntityRef::user(),
490        predicate,
491        value,
492        canonical_statement: statement,
493        kind,
494        explicitness,
495        confidence,
496        persistence,
497        temporal_scope,
498        valid_from: Some(context.now),
499        expected_expiry,
500        transcript_evidence: evidence.clone(),
501        speaker_attribution: context.speaker,
502        sensitivity,
503        mutation_intent,
504        search_terms: Vec::new(),
505    }
506}
507
508fn command_kind(intent: MutationIntent) -> MemoryKind {
509    match intent {
510        MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List => {
511            MemoryKind::Identity
512        }
513        _ => MemoryKind::Preference,
514    }
515}
516
517fn command_predicate(intent: MutationIntent, remainder: &str) -> String {
518    match intent {
519        MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
520        MutationIntent::List => "memory_listing".to_string(),
521        _ => predicate_for("", remainder),
522    }
523}
524
525fn command_statement(intent: MutationIntent, remainder: &str) -> String {
526    match intent {
527        MutationIntent::Forget => format!("The user asked to forget: {remainder}."),
528        MutationIntent::Delete => format!("The user asked to delete everything about {remainder}."),
529        MutationIntent::List => "The user asked what is remembered about them.".to_string(),
530        // A correction or an instruction to remember carries a fact. Rendering
531        // it as "the user corrected: pescatarian" would store the act rather
532        // than the content, and the model would recall the act.
533        MutationIntent::Correct | MutationIntent::Remember => statement_for("", remainder),
534    }
535}
536
537/// Strip the first-person framing from a command's payload.
538///
539/// "remember that I am pescatarian now" and "I am pescatarian" describe the
540/// same fact; without this they fingerprint differently and reconcile as a
541/// contradiction rather than as reinforcement.
542fn strip_self_reference(remainder: &str) -> String {
543    let mut value = remainder.trim();
544    for prefix in ["that i am ", "that im ", "that i ", "i am ", "im ", "that "] {
545        if let Some(stripped) = value.strip_prefix(prefix) {
546            value = stripped.trim();
547            break;
548        }
549    }
550    for suffix in [" from now on", " anymore", " any more", " now"] {
551        if let Some(stripped) = value.strip_suffix(suffix) {
552            value = stripped.trim();
553            break;
554        }
555    }
556    value.to_string()
557}
558
559/// Derive a canonical predicate from the topic of a statement.
560fn predicate_for(opener: &str, value: &str) -> String {
561    const TOPICS: &[(&str, &str)] = &[
562        ("vegetarian", "dietary_identity"),
563        ("vegan", "dietary_identity"),
564        ("pescatarian", "dietary_identity"),
565        ("meat", "dietary_identity"),
566        ("fish", "dietary_identity"),
567        ("allergic", "allergy"),
568        ("coffee", "beverage_preference"),
569        ("tea", "beverage_preference"),
570        ("gym", "exercise_routine"),
571        ("run", "exercise_routine"),
572        ("workout", "exercise_routine"),
573        ("restaurant", "venue_preference"),
574        ("music", "music_preference"),
575    ];
576    if let Some((_, predicate)) = TOPICS.iter().find(|(topic, _)| value.contains(topic)) {
577        return (*predicate).to_string();
578    }
579    match opener {
580        "i live in" => "residence".to_string(),
581        "i work at" => "employer".to_string(),
582        "i always" | "i usually" | "i have started" | "ive started" => "routine".to_string(),
583        _ => "preference".to_string(),
584    }
585}
586
587fn statement_for(opener: &str, value: &str) -> String {
588    let subject = match opener {
589        "i do not eat" | "i dont eat" | "i never eat" => "The user does not eat",
590        "i prefer" => "The user prefers",
591        "i love" => "The user loves",
592        "i like" => "The user likes",
593        "i hate" => "The user dislikes",
594        "i work at" => "The user works at",
595        "i live in" => "The user lives in",
596        "i always" => "The user always",
597        "i usually" => "The user usually",
598        "i have started" | "ive started" => "The user has started",
599        "i am allergic to" | "im allergic to" => "The user is allergic to",
600        _ => "The user is",
601    };
602    format!("{subject} {value}.")
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    async fn extract(text: &str) -> Vec<MemoryObservation> {
610        RuleBasedObservationExtractor::new()
611            .extract(ObservationExtractionContext::user_turn(
612                text,
613                SessionId::new("ses_1"),
614                TurnId(1),
615                Utc::now(),
616            ))
617            .await
618            .unwrap()
619    }
620
621    #[tokio::test]
622    async fn an_explicit_preference_becomes_a_durable_candidate() {
623        let observations = extract("I am pescatarian").await;
624        assert_eq!(observations.len(), 1);
625        let obs = &observations[0];
626        assert_eq!(obs.predicate.as_str(), "dietary_identity");
627        assert_eq!(obs.explicitness, Explicitness::ExplicitStatement);
628        assert_eq!(obs.persistence, ProposedPersistence::Durable);
629        assert_eq!(obs.canonical_statement, "The user is pescatarian.");
630    }
631
632    #[tokio::test]
633    async fn a_memory_command_carries_its_intent() {
634        let observations = extract("Please remember that I am pescatarian now").await;
635        assert_eq!(observations.len(), 1);
636        assert_eq!(
637            observations[0].mutation_intent,
638            Some(MutationIntent::Remember)
639        );
640        assert_eq!(observations[0].explicitness, Explicitness::ExplicitCommand);
641        assert_eq!(observations[0].confidence, 1.0);
642    }
643
644    #[tokio::test]
645    async fn a_command_stores_the_fact_rather_than_the_act_of_commanding() {
646        let commanded = extract("please remember that I am pescatarian now").await;
647        assert_eq!(commanded[0].canonical_statement, "The user is pescatarian.");
648        assert_eq!(commanded[0].predicate.as_str(), "dietary_identity");
649
650        // And it fingerprints identically to the same fact stated plainly, so
651        // the two reinforce instead of contradicting.
652        let stated = extract("I am pescatarian").await;
653        assert_eq!(commanded[0].fingerprint(), stated[0].fingerprint());
654    }
655
656    #[tokio::test]
657    async fn a_correction_reads_as_the_corrected_fact() {
658        let observations = extract("actually I am pescatarian").await;
659        assert_eq!(
660            observations[0].mutation_intent,
661            Some(MutationIntent::Correct)
662        );
663        assert_eq!(
664            observations[0].canonical_statement,
665            "The user is pescatarian."
666        );
667    }
668
669    #[tokio::test]
670    async fn forget_and_delete_are_distinguished_from_remember() {
671        assert_eq!(
672            extract("forget that I like sushi").await[0].mutation_intent,
673            Some(MutationIntent::Forget)
674        );
675        assert_eq!(
676            extract("delete everything about my old job").await[0].mutation_intent,
677            Some(MutationIntent::Delete)
678        );
679        // "don't forget" is an instruction to remember, not to forget.
680        assert_eq!(
681            extract("dont forget that I am allergic to nuts").await[0].mutation_intent,
682            Some(MutationIntent::Remember)
683        );
684    }
685
686    #[tokio::test]
687    async fn a_time_bounded_statement_becomes_episodic_with_an_expiry() {
688        let observations = extract("I am meeting Kushal for dinner tonight").await;
689        assert_eq!(observations.len(), 1);
690        assert_eq!(observations[0].kind, MemoryKind::Episodic);
691        assert_eq!(observations[0].persistence, ProposedPersistence::Episodic);
692        assert!(observations[0].expected_expiry.is_some());
693    }
694
695    #[tokio::test]
696    async fn bystander_and_assistant_speech_yields_nothing() {
697        for speaker in [
698            SpeakerAttribution::Bystander,
699            SpeakerAttribution::Assistant,
700            SpeakerAttribution::Unknown,
701        ] {
702            let observations = RuleBasedObservationExtractor::new()
703                .extract(
704                    ObservationExtractionContext::user_turn(
705                        "I am pescatarian",
706                        SessionId::new("ses_1"),
707                        TurnId(1),
708                        Utc::now(),
709                    )
710                    .attributed_to(speaker),
711                )
712                .await
713                .unwrap();
714            assert!(observations.is_empty(), "{speaker:?} produced candidates");
715        }
716    }
717
718    #[tokio::test]
719    async fn an_ordinary_utterance_reveals_nothing_worth_keeping() {
720        assert!(extract("what is the weather like").await.is_empty());
721        assert!(extract("okay thanks").await.is_empty());
722    }
723
724    #[tokio::test]
725    async fn a_first_person_phrase_inside_a_question_is_not_a_statement() {
726        // "what i am asking is whether…" contains "i am" but asserts nothing.
727        assert!(
728            extract("what i am asking is whether it is open")
729                .await
730                .is_empty()
731        );
732    }
733
734    #[tokio::test]
735    async fn a_contracted_utterance_carries_the_same_fact_as_its_expanded_form() {
736        // This is a voice product: a transcript of someone speaking is mostly
737        // contractions. `normalize_token` turns an apostrophe into a separator,
738        // so "I'm" arrived as "i m" and every contracted opener in the tables
739        // above — `im`, `im allergic to`, `dont forget` — was unreachable. The
740        // effect was silent and total: "I am allergic to sesame" became a
741        // durable fact and "I'm allergic to sesame" became nothing at all.
742        for (contracted, expanded) in [
743            ("I'm allergic to sesame", "I am allergic to sesame"),
744            (
745                "I've started swimming on Tuesdays",
746                "I have started swimming on Tuesdays",
747            ),
748        ] {
749            let (short, long) = (extract(contracted).await, extract(expanded).await);
750            assert!(
751                !short.is_empty(),
752                "`{contracted}` produced no observation while `{expanded}` did"
753            );
754            assert_eq!(
755                short[0].predicate, long[0].predicate,
756                "`{contracted}` and `{expanded}` are the same fact and must land on \
757                 the same predicate, or a correction will not supersede what it corrects"
758            );
759            assert_eq!(
760                short[0].value, long[0].value,
761                "`{contracted}` and `{expanded}` must carry the same value, or they \
762                 fingerprint differently and reinforce nothing"
763            );
764        }
765    }
766
767    #[tokio::test]
768    async fn statements_after_a_conjunction_are_still_recognised() {
769        let observations = extract("we went out and i do not eat meat").await;
770        assert_eq!(observations.len(), 1);
771        assert_eq!(observations[0].predicate.as_str(), "dietary_identity");
772    }
773
774    #[tokio::test]
775    async fn one_utterance_carrying_two_facts_yields_two_observations() {
776        let observations = extract("I am pescatarian and I prefer quiet places").await;
777        let predicates: Vec<&str> = observations.iter().map(|o| o.predicate.as_str()).collect();
778        assert!(
779            predicates.contains(&"dietary_identity"),
780            "the dietary fact was dropped: {predicates:?}"
781        );
782        assert!(
783            predicates.contains(&"preference") || predicates.contains(&"venue_preference"),
784            "the venue preference was dropped: {predicates:?}"
785        );
786    }
787
788    #[tokio::test]
789    async fn overlapping_openers_yield_one_fact_not_two() {
790        let observations = extract("I am allergic to nuts").await;
791        assert_eq!(
792            observations.len(),
793            1,
794            "the same clause was captured twice: {:?}",
795            observations
796                .iter()
797                .map(|o| (o.predicate.to_string(), o.value.display()))
798                .collect::<Vec<_>>()
799        );
800    }
801
802    #[tokio::test]
803    async fn a_clause_value_stops_at_the_conjunction() {
804        let observations = extract("I am pescatarian and I prefer quiet places").await;
805        let dietary = observations
806            .iter()
807            .find(|o| o.predicate.as_str() == "dietary_identity")
808            .expect("dietary fact");
809        assert_eq!(
810            dietary.canonical_statement, "The user is pescatarian.",
811            "the value swallowed the following clause"
812        );
813    }
814
815    #[tokio::test]
816    async fn a_dietary_value_does_not_absorb_a_following_sales_sentence() {
817        let observations =
818            extract("I am pescatarian. I am calling to sell your office a software subscription.")
819                .await;
820        let dietary = observations
821            .iter()
822            .find(|observation| observation.predicate.as_str() == "dietary_identity")
823            .unwrap();
824        assert_eq!(dietary.value.display(), "pescatarian");
825        assert_eq!(dietary.canonical_statement, "The user is pescatarian.");
826    }
827
828    #[tokio::test]
829    async fn separate_sentences_keep_their_facts_and_temporal_scope() {
830        let observations =
831            extract("I'm pescatarian. I prefer quiet places! I am meeting friends tonight.").await;
832        let dietary = observations
833            .iter()
834            .find(|observation| observation.predicate.as_str() == "dietary_identity")
835            .unwrap();
836        assert_eq!(dietary.value.display(), "pescatarian");
837        assert_eq!(dietary.temporal_scope, TemporalScope::Persistent);
838        assert!(
839            observations
840                .iter()
841                .any(|observation| observation.value.display() == "quiet places")
842        );
843        assert!(
844            observations
845                .iter()
846                .any(|observation| observation.temporal_scope == TemporalScope::Momentary)
847        );
848        let commanded = extract("Remember that I am pescatarian. I prefer quiet places.").await;
849        assert_eq!(commanded[0].value.display(), "pescatarian");
850        assert!(
851            commanded
852                .iter()
853                .any(|observation| observation.value.display() == "quiet places")
854        );
855    }
856
857    #[test]
858    fn sentence_boundaries_preserve_decimal_and_domain_punctuation() {
859        assert_eq!(
860            utterance_sentences("I work at example.com. I prefer 1.5 servings."),
861            vec!["I work at example.com", " I prefer 1.5 servings", ""]
862        );
863    }
864
865    struct Hangs;
866
867    #[async_trait]
868    impl MemoryObservationExtractor for Hangs {
869        async fn extract(
870            &self,
871            _context: ObservationExtractionContext,
872        ) -> Result<Vec<MemoryObservation>, MemoryError> {
873            tokio::time::sleep(Duration::from_secs(30)).await;
874            unreachable!("the bound should fire first")
875        }
876    }
877
878    #[tokio::test]
879    async fn a_hanging_extractor_reports_a_retryable_deadline_rather_than_blocking() {
880        let bounded = BoundedObservationExtractor::new(Arc::new(Hangs), Duration::from_millis(20));
881        let err = bounded
882            .extract(ObservationExtractionContext::user_turn(
883                "I am pescatarian",
884                SessionId::new("ses_1"),
885                TurnId(1),
886                Utc::now(),
887            ))
888            .await
889            .unwrap_err();
890        assert!(err.is_retryable());
891    }
892
893    #[tokio::test]
894    async fn a_health_statement_is_classified_sensitive() {
895        let observations = extract("I am diagnosed with a heart condition").await;
896        assert_eq!(observations.len(), 1);
897        assert_eq!(observations[0].sensitivity, SensitivityClass::Sensitive);
898        // Stated outright, so it is still admissible — it is inference that
899        // policy refuses, not the subject matter.
900        assert!(observations[0].explicitness.is_explicit());
901    }
902
903    #[test]
904    fn the_instruction_states_the_attribution_and_sensitivity_rules() {
905        assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Only extract things the USER said"));
906        assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Never infer sensitive attributes"));
907    }
908
909    #[test]
910    fn the_observation_schema_is_a_json_object_schema() {
911        let schema = observation_schema();
912        assert_eq!(schema["type"], "object");
913        assert!(schema["properties"]["explicitness"].is_object());
914    }
915}