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 normalized = normalize_utterance(&context.transcript);
303        let evidence = TranscriptEvidence::new(&context.transcript);
304        let mut observations = Vec::new();
305
306        if let Some((phrase, intent)) = COMMANDS
307            .iter()
308            .find(|(phrase, _)| normalized.contains(phrase))
309        {
310            let raw_remainder = normalized
311                .split_once(phrase)
312                .map(|(_, rest)| rest.trim().to_string())
313                .unwrap_or_default();
314            // "remember that I am pescatarian now" carries the fact "the user
315            // is pescatarian" — stripping the self-reference is what lets it
316            // fingerprint against the same fact stated plainly.
317            let remainder = strip_self_reference(&raw_remainder);
318            observations.push(build(
319                &context,
320                &evidence,
321                command_kind(*intent),
322                CanonicalPredicate::new(command_predicate(*intent, &remainder)),
323                MemoryValue::Text(remainder.clone()),
324                command_statement(*intent, &remainder),
325                Explicitness::ExplicitCommand,
326                1.0,
327                ProposedPersistence::Durable,
328                TemporalScope::Persistent,
329                SensitivityClass::Normal,
330                Some(*intent),
331            ));
332            return Ok(observations);
333        }
334
335        // Every matching clause, not just the first. "I'm pescatarian and I
336        // prefer quiet places" is one utterance carrying two facts, and taking
337        // only the leading clause silently drops the other.
338        for (opener, kind) in SELF_STATEMENTS
339            .iter()
340            .filter(|(opener, _)| starts_clause(&normalized, opener))
341        {
342            let value = clause_after(&normalized, opener);
343            if value.is_empty() {
344                continue;
345            }
346
347            let episodic = EPISODIC_MARKERS.iter().any(|m| normalized.contains(m));
348            let (kind, scope, persistence) = if episodic {
349                (
350                    MemoryKind::Episodic,
351                    TemporalScope::Momentary,
352                    ProposedPersistence::Episodic,
353                )
354            } else {
355                (
356                    *kind,
357                    TemporalScope::Persistent,
358                    ProposedPersistence::Durable,
359                )
360            };
361
362            let sensitivity = if SENSITIVE_MARKERS.iter().any(|m| normalized.contains(m)) {
363                SensitivityClass::Sensitive
364            } else {
365                SensitivityClass::Normal
366            };
367
368            // Openers overlap: "I am allergic to nuts" matches both
369            // `i am allergic to` (value "nuts") and `i am` (value "allergic to
370            // nuts"). They describe the same clause, so only the more specific
371            // one is kept — otherwise a single fact is counted twice.
372            if observations.iter().any(|o: &MemoryObservation| {
373                let existing = o.value.display();
374                existing.contains(&value) || value.contains(&existing)
375            }) {
376                continue;
377            }
378            let predicate = CanonicalPredicate::new(predicate_for(opener, &value));
379            if observations
380                .iter()
381                .any(|o: &MemoryObservation| o.predicate == predicate)
382            {
383                continue;
384            }
385            observations.push(build(
386                &context,
387                &evidence,
388                kind,
389                predicate,
390                MemoryValue::Text(value.clone()),
391                statement_for(opener, &value),
392                Explicitness::ExplicitStatement,
393                0.9,
394                persistence,
395                scope,
396                sensitivity,
397                None,
398            ));
399        }
400
401        Ok(observations)
402    }
403}
404
405/// The clause following `opener`, stopping at the next clause boundary.
406///
407/// Without the stop, "I am pescatarian and I prefer quiet places" would store
408/// the dietary fact with a value of "pescatarian and i prefer quiet places".
409fn clause_after(text: &str, opener: &str) -> String {
410    const SEPARATORS: [&str; 4] = [" and ", " but ", " so ", " because "];
411    let Some((_, rest)) = text.split_once(opener) else {
412        return String::new();
413    };
414    let mut clause = rest.trim();
415    for separator in SEPARATORS {
416        if let Some((head, _)) = clause.split_once(separator) {
417            clause = head.trim();
418        }
419    }
420    clause.to_string()
421}
422
423/// Whether `text` begins with `opener` on a clause boundary.
424///
425/// Guards against "i am" matching inside "what i am asking is" — which reads as
426/// a statement of identity only if you ignore the sentence around it.
427fn starts_clause(text: &str, opener: &str) -> bool {
428    if text.starts_with(opener) {
429        return true;
430    }
431    for separator in [" and ", " but ", " so ", " because "] {
432        if text.contains(&format!("{separator}{opener} ")) {
433            return true;
434        }
435    }
436    false
437}
438
439#[allow(clippy::too_many_arguments, reason = "an internal record constructor")]
440fn build(
441    context: &ObservationExtractionContext,
442    evidence: &TranscriptEvidence,
443    kind: MemoryKind,
444    predicate: CanonicalPredicate,
445    value: MemoryValue,
446    statement: String,
447    explicitness: Explicitness,
448    confidence: f32,
449    persistence: ProposedPersistence,
450    temporal_scope: TemporalScope,
451    sensitivity: SensitivityClass,
452    mutation_intent: Option<MutationIntent>,
453) -> MemoryObservation {
454    let expected_expiry =
455        crate::core::default_episodic_ttl(kind, temporal_scope).map(|ttl| context.now + ttl);
456    MemoryObservation {
457        observation_id: ObservationId::generate(),
458        session_id: context.session_id.clone(),
459        turn_id: context.turn_id,
460        subject: EntityRef::user(),
461        predicate,
462        value,
463        canonical_statement: statement,
464        kind,
465        explicitness,
466        confidence,
467        persistence,
468        temporal_scope,
469        valid_from: Some(context.now),
470        expected_expiry,
471        transcript_evidence: evidence.clone(),
472        speaker_attribution: context.speaker,
473        sensitivity,
474        mutation_intent,
475        search_terms: Vec::new(),
476    }
477}
478
479fn command_kind(intent: MutationIntent) -> MemoryKind {
480    match intent {
481        MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List => {
482            MemoryKind::Identity
483        }
484        _ => MemoryKind::Preference,
485    }
486}
487
488fn command_predicate(intent: MutationIntent, remainder: &str) -> String {
489    match intent {
490        MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
491        MutationIntent::List => "memory_listing".to_string(),
492        _ => predicate_for("", remainder),
493    }
494}
495
496fn command_statement(intent: MutationIntent, remainder: &str) -> String {
497    match intent {
498        MutationIntent::Forget => format!("The user asked to forget: {remainder}."),
499        MutationIntent::Delete => format!("The user asked to delete everything about {remainder}."),
500        MutationIntent::List => "The user asked what is remembered about them.".to_string(),
501        // A correction or an instruction to remember carries a fact. Rendering
502        // it as "the user corrected: pescatarian" would store the act rather
503        // than the content, and the model would recall the act.
504        MutationIntent::Correct | MutationIntent::Remember => statement_for("", remainder),
505    }
506}
507
508/// Strip the first-person framing from a command's payload.
509///
510/// "remember that I am pescatarian now" and "I am pescatarian" describe the
511/// same fact; without this they fingerprint differently and reconcile as a
512/// contradiction rather than as reinforcement.
513fn strip_self_reference(remainder: &str) -> String {
514    let mut value = remainder.trim();
515    for prefix in ["that i am ", "that im ", "that i ", "i am ", "im ", "that "] {
516        if let Some(stripped) = value.strip_prefix(prefix) {
517            value = stripped.trim();
518            break;
519        }
520    }
521    for suffix in [" from now on", " anymore", " any more", " now"] {
522        if let Some(stripped) = value.strip_suffix(suffix) {
523            value = stripped.trim();
524            break;
525        }
526    }
527    value.to_string()
528}
529
530/// Derive a canonical predicate from the topic of a statement.
531fn predicate_for(opener: &str, value: &str) -> String {
532    const TOPICS: &[(&str, &str)] = &[
533        ("vegetarian", "dietary_identity"),
534        ("vegan", "dietary_identity"),
535        ("pescatarian", "dietary_identity"),
536        ("meat", "dietary_identity"),
537        ("fish", "dietary_identity"),
538        ("allergic", "allergy"),
539        ("coffee", "beverage_preference"),
540        ("tea", "beverage_preference"),
541        ("gym", "exercise_routine"),
542        ("run", "exercise_routine"),
543        ("workout", "exercise_routine"),
544        ("restaurant", "venue_preference"),
545        ("music", "music_preference"),
546    ];
547    if let Some((_, predicate)) = TOPICS.iter().find(|(topic, _)| value.contains(topic)) {
548        return (*predicate).to_string();
549    }
550    match opener {
551        "i live in" => "residence".to_string(),
552        "i work at" => "employer".to_string(),
553        "i always" | "i usually" | "i have started" | "ive started" => "routine".to_string(),
554        _ => "preference".to_string(),
555    }
556}
557
558fn statement_for(opener: &str, value: &str) -> String {
559    let subject = match opener {
560        "i do not eat" | "i dont eat" | "i never eat" => "The user does not eat",
561        "i prefer" => "The user prefers",
562        "i love" => "The user loves",
563        "i like" => "The user likes",
564        "i hate" => "The user dislikes",
565        "i work at" => "The user works at",
566        "i live in" => "The user lives in",
567        "i always" => "The user always",
568        "i usually" => "The user usually",
569        "i have started" | "ive started" => "The user has started",
570        "i am allergic to" | "im allergic to" => "The user is allergic to",
571        _ => "The user is",
572    };
573    format!("{subject} {value}.")
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    async fn extract(text: &str) -> Vec<MemoryObservation> {
581        RuleBasedObservationExtractor::new()
582            .extract(ObservationExtractionContext::user_turn(
583                text,
584                SessionId::new("ses_1"),
585                TurnId(1),
586                Utc::now(),
587            ))
588            .await
589            .unwrap()
590    }
591
592    #[tokio::test]
593    async fn an_explicit_preference_becomes_a_durable_candidate() {
594        let observations = extract("I am pescatarian").await;
595        assert_eq!(observations.len(), 1);
596        let obs = &observations[0];
597        assert_eq!(obs.predicate.as_str(), "dietary_identity");
598        assert_eq!(obs.explicitness, Explicitness::ExplicitStatement);
599        assert_eq!(obs.persistence, ProposedPersistence::Durable);
600        assert_eq!(obs.canonical_statement, "The user is pescatarian.");
601    }
602
603    #[tokio::test]
604    async fn a_memory_command_carries_its_intent() {
605        let observations = extract("Please remember that I am pescatarian now").await;
606        assert_eq!(observations.len(), 1);
607        assert_eq!(
608            observations[0].mutation_intent,
609            Some(MutationIntent::Remember)
610        );
611        assert_eq!(observations[0].explicitness, Explicitness::ExplicitCommand);
612        assert_eq!(observations[0].confidence, 1.0);
613    }
614
615    #[tokio::test]
616    async fn a_command_stores_the_fact_rather_than_the_act_of_commanding() {
617        let commanded = extract("please remember that I am pescatarian now").await;
618        assert_eq!(commanded[0].canonical_statement, "The user is pescatarian.");
619        assert_eq!(commanded[0].predicate.as_str(), "dietary_identity");
620
621        // And it fingerprints identically to the same fact stated plainly, so
622        // the two reinforce instead of contradicting.
623        let stated = extract("I am pescatarian").await;
624        assert_eq!(commanded[0].fingerprint(), stated[0].fingerprint());
625    }
626
627    #[tokio::test]
628    async fn a_correction_reads_as_the_corrected_fact() {
629        let observations = extract("actually I am pescatarian").await;
630        assert_eq!(
631            observations[0].mutation_intent,
632            Some(MutationIntent::Correct)
633        );
634        assert_eq!(
635            observations[0].canonical_statement,
636            "The user is pescatarian."
637        );
638    }
639
640    #[tokio::test]
641    async fn forget_and_delete_are_distinguished_from_remember() {
642        assert_eq!(
643            extract("forget that I like sushi").await[0].mutation_intent,
644            Some(MutationIntent::Forget)
645        );
646        assert_eq!(
647            extract("delete everything about my old job").await[0].mutation_intent,
648            Some(MutationIntent::Delete)
649        );
650        // "don't forget" is an instruction to remember, not to forget.
651        assert_eq!(
652            extract("dont forget that I am allergic to nuts").await[0].mutation_intent,
653            Some(MutationIntent::Remember)
654        );
655    }
656
657    #[tokio::test]
658    async fn a_time_bounded_statement_becomes_episodic_with_an_expiry() {
659        let observations = extract("I am meeting Kushal for dinner tonight").await;
660        assert_eq!(observations.len(), 1);
661        assert_eq!(observations[0].kind, MemoryKind::Episodic);
662        assert_eq!(observations[0].persistence, ProposedPersistence::Episodic);
663        assert!(observations[0].expected_expiry.is_some());
664    }
665
666    #[tokio::test]
667    async fn bystander_and_assistant_speech_yields_nothing() {
668        for speaker in [
669            SpeakerAttribution::Bystander,
670            SpeakerAttribution::Assistant,
671            SpeakerAttribution::Unknown,
672        ] {
673            let observations = RuleBasedObservationExtractor::new()
674                .extract(
675                    ObservationExtractionContext::user_turn(
676                        "I am pescatarian",
677                        SessionId::new("ses_1"),
678                        TurnId(1),
679                        Utc::now(),
680                    )
681                    .attributed_to(speaker),
682                )
683                .await
684                .unwrap();
685            assert!(observations.is_empty(), "{speaker:?} produced candidates");
686        }
687    }
688
689    #[tokio::test]
690    async fn an_ordinary_utterance_reveals_nothing_worth_keeping() {
691        assert!(extract("what is the weather like").await.is_empty());
692        assert!(extract("okay thanks").await.is_empty());
693    }
694
695    #[tokio::test]
696    async fn a_first_person_phrase_inside_a_question_is_not_a_statement() {
697        // "what i am asking is whether…" contains "i am" but asserts nothing.
698        assert!(
699            extract("what i am asking is whether it is open")
700                .await
701                .is_empty()
702        );
703    }
704
705    #[tokio::test]
706    async fn a_contracted_utterance_carries_the_same_fact_as_its_expanded_form() {
707        // This is a voice product: a transcript of someone speaking is mostly
708        // contractions. `normalize_token` turns an apostrophe into a separator,
709        // so "I'm" arrived as "i m" and every contracted opener in the tables
710        // above — `im`, `im allergic to`, `dont forget` — was unreachable. The
711        // effect was silent and total: "I am allergic to sesame" became a
712        // durable fact and "I'm allergic to sesame" became nothing at all.
713        for (contracted, expanded) in [
714            ("I'm allergic to sesame", "I am allergic to sesame"),
715            (
716                "I've started swimming on Tuesdays",
717                "I have started swimming on Tuesdays",
718            ),
719        ] {
720            let (short, long) = (extract(contracted).await, extract(expanded).await);
721            assert!(
722                !short.is_empty(),
723                "`{contracted}` produced no observation while `{expanded}` did"
724            );
725            assert_eq!(
726                short[0].predicate, long[0].predicate,
727                "`{contracted}` and `{expanded}` are the same fact and must land on \
728                 the same predicate, or a correction will not supersede what it corrects"
729            );
730            assert_eq!(
731                short[0].value, long[0].value,
732                "`{contracted}` and `{expanded}` must carry the same value, or they \
733                 fingerprint differently and reinforce nothing"
734            );
735        }
736    }
737
738    #[tokio::test]
739    async fn statements_after_a_conjunction_are_still_recognised() {
740        let observations = extract("we went out and i do not eat meat").await;
741        assert_eq!(observations.len(), 1);
742        assert_eq!(observations[0].predicate.as_str(), "dietary_identity");
743    }
744
745    #[tokio::test]
746    async fn one_utterance_carrying_two_facts_yields_two_observations() {
747        let observations = extract("I am pescatarian and I prefer quiet places").await;
748        let predicates: Vec<&str> = observations.iter().map(|o| o.predicate.as_str()).collect();
749        assert!(
750            predicates.contains(&"dietary_identity"),
751            "the dietary fact was dropped: {predicates:?}"
752        );
753        assert!(
754            predicates.contains(&"preference") || predicates.contains(&"venue_preference"),
755            "the venue preference was dropped: {predicates:?}"
756        );
757    }
758
759    #[tokio::test]
760    async fn overlapping_openers_yield_one_fact_not_two() {
761        let observations = extract("I am allergic to nuts").await;
762        assert_eq!(
763            observations.len(),
764            1,
765            "the same clause was captured twice: {:?}",
766            observations
767                .iter()
768                .map(|o| (o.predicate.to_string(), o.value.display()))
769                .collect::<Vec<_>>()
770        );
771    }
772
773    #[tokio::test]
774    async fn a_clause_value_stops_at_the_conjunction() {
775        let observations = extract("I am pescatarian and I prefer quiet places").await;
776        let dietary = observations
777            .iter()
778            .find(|o| o.predicate.as_str() == "dietary_identity")
779            .expect("dietary fact");
780        assert_eq!(
781            dietary.canonical_statement, "The user is pescatarian.",
782            "the value swallowed the following clause"
783        );
784    }
785
786    struct Hangs;
787
788    #[async_trait]
789    impl MemoryObservationExtractor for Hangs {
790        async fn extract(
791            &self,
792            _context: ObservationExtractionContext,
793        ) -> Result<Vec<MemoryObservation>, MemoryError> {
794            tokio::time::sleep(Duration::from_secs(30)).await;
795            unreachable!("the bound should fire first")
796        }
797    }
798
799    #[tokio::test]
800    async fn a_hanging_extractor_reports_a_retryable_deadline_rather_than_blocking() {
801        let bounded = BoundedObservationExtractor::new(Arc::new(Hangs), Duration::from_millis(20));
802        let err = bounded
803            .extract(ObservationExtractionContext::user_turn(
804                "I am pescatarian",
805                SessionId::new("ses_1"),
806                TurnId(1),
807                Utc::now(),
808            ))
809            .await
810            .unwrap_err();
811        assert!(err.is_retryable());
812    }
813
814    #[tokio::test]
815    async fn a_health_statement_is_classified_sensitive() {
816        let observations = extract("I am diagnosed with a heart condition").await;
817        assert_eq!(observations.len(), 1);
818        assert_eq!(observations[0].sensitivity, SensitivityClass::Sensitive);
819        // Stated outright, so it is still admissible — it is inference that
820        // policy refuses, not the subject matter.
821        assert!(observations[0].explicitness.is_explicit());
822    }
823
824    #[test]
825    fn the_instruction_states_the_attribution_and_sensitivity_rules() {
826        assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Only extract things the USER said"));
827        assert!(OBSERVATION_EXTRACTION_INSTRUCTION.contains("Never infer sensitive attributes"));
828    }
829
830    #[test]
831    fn the_observation_schema_is_a_json_object_schema() {
832        let schema = observation_schema();
833        assert_eq!(schema["type"], "object");
834        assert!(schema["properties"]["explicitness"].is_object());
835    }
836}