gemini_memory_rs/core/
domain.rs

1//! The memory domain model — the vocabulary shared by every stage of the
2//! pipeline (capture → staging → retrieval → reconciliation → promotion).
3//!
4//! Types here are deliberately serialization-stable: they are the schema of the
5//! OKF front matter, the event log, and the structured-output contracts handed
6//! to out-of-band extraction models.
7
8use chrono::{DateTime, Duration, Utc};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13use super::ids::{EntityId, MemoryId, ObservationId, SessionId, TurnId, UserId};
14
15/// What sort of thing a memory records.
16///
17/// Kind drives retrieval scoping, default persistence and promotion policy —
18/// an `Episodic` memory expires, an `Identity` memory does not.
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(rename_all = "snake_case")]
23pub enum MemoryKind {
24    /// A stable fact about who the user is.
25    Identity,
26    /// A durable like, dislike or standing choice.
27    Preference,
28    /// A person the user is connected to.
29    Relationship,
30    /// A preference held by someone the user is connected to.
31    RelationshipPreference,
32    /// A recurring behaviour.
33    Routine,
34    /// Something the user has undertaken to do.
35    Commitment,
36    /// A piece of ongoing work.
37    Project,
38    /// A time-bounded event, condition or situation.
39    Episodic,
40    /// How the user prefers to be spoken to.
41    CommunicationStyle,
42    /// A place-scoped preference.
43    LocationPreference,
44    /// An inferred pattern awaiting reinforcement.
45    StagedPattern,
46}
47
48impl MemoryKind {
49    /// Whether this kind is inherently time-bounded.
50    pub fn is_episodic(self) -> bool {
51        matches!(self, Self::Episodic | Self::Commitment)
52    }
53
54    /// The retrieval scopes a plan may name to reach this kind.
55    pub fn scope_label(self) -> &'static str {
56        match self {
57            Self::Identity => "profile",
58            Self::Preference | Self::LocationPreference => "preferences",
59            Self::Relationship | Self::RelationshipPreference => "relationships",
60            Self::Routine => "routines",
61            Self::Commitment => "commitments",
62            Self::Project => "projects",
63            Self::Episodic => "episodes",
64            Self::CommunicationStyle => "communication",
65            Self::StagedPattern => "staged",
66        }
67    }
68}
69
70impl fmt::Display for MemoryKind {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        let raw = serde_json::to_value(self).map_err(|_| fmt::Error)?;
73        f.write_str(raw.as_str().unwrap_or("unknown"))
74    }
75}
76
77/// How directly the user stated the thing being remembered.
78///
79/// Explicitness is the primary authority ordering in the engine: an explicit
80/// command outranks an explicit statement, which outranks any inference,
81/// regardless of how often the inference recurs.
82#[derive(
83    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
84)]
85#[serde(rename_all = "snake_case")]
86pub enum Explicitness {
87    /// A single weak inference from indirect evidence.
88    WeakInference,
89    /// A strong implication the user did not state outright.
90    StrongImplication,
91    /// The user stated the fact directly.
92    ExplicitStatement,
93    /// The user issued a memory command ("remember that…", "forget…").
94    ExplicitCommand,
95}
96
97impl Explicitness {
98    /// The maximum aggregated confidence evidence at this level may reach.
99    ///
100    /// Repetition of weak evidence must not manufacture certainty, so each
101    /// level carries a hard ceiling (§18.4).
102    pub fn confidence_ceiling(self, distinct_evidence: u32) -> f32 {
103        match self {
104            Self::ExplicitCommand => 1.0,
105            Self::ExplicitStatement => 0.95,
106            Self::StrongImplication => {
107                if distinct_evidence > 1 {
108                    0.85
109                } else {
110                    0.70
111                }
112            }
113            Self::WeakInference => {
114                if distinct_evidence > 1 {
115                    0.75
116                } else {
117                    0.55
118                }
119            }
120        }
121    }
122
123    /// Whether evidence at this level may be committed without reinforcement.
124    pub fn is_explicit(self) -> bool {
125        matches!(self, Self::ExplicitStatement | Self::ExplicitCommand)
126    }
127}
128
129/// Lifecycle state of a canonical memory record.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum MemoryStatus {
133    /// Retrievable and current.
134    #[default]
135    Active,
136    /// Held pending reinforcement; not retrievable as a durable fact.
137    Staged,
138    /// Replaced by a newer contradicting record.
139    Superseded,
140    /// Past its validity window.
141    Expired,
142    /// Removed at the user's request; retained only as a tombstone.
143    Deleted,
144}
145
146impl MemoryStatus {
147    /// Whether records in this state participate in normal retrieval.
148    pub fn is_retrievable(self) -> bool {
149        matches!(self, Self::Active)
150    }
151}
152
153/// How long a fact is expected to hold.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum TemporalScope {
157    /// Expected to hold until explicitly changed.
158    #[default]
159    Persistent,
160    /// A recent event worth recalling for days.
161    RecentHistory,
162    /// A transient state measured in hours.
163    Momentary,
164    /// Tied to a specific future time.
165    Scheduled,
166}
167
168/// Privacy classification, gating automatic promotion.
169///
170/// Ordered least to most restrictive, so merging evidence can take the
171/// strictest classification any of it carried.
172#[derive(
173    Debug,
174    Clone,
175    Copy,
176    PartialEq,
177    Eq,
178    Hash,
179    PartialOrd,
180    Ord,
181    Serialize,
182    Deserialize,
183    JsonSchema,
184    Default,
185)]
186#[serde(rename_all = "snake_case")]
187pub enum SensitivityClass {
188    /// Ordinary personal context.
189    #[default]
190    Normal,
191    /// Health, religion, politics, sexuality and similar categories.
192    Sensitive,
193    /// Never stored durably by this engine.
194    Restricted,
195}
196
197/// Who actually said the thing an observation was drawn from.
198///
199/// Only [`SpeakerAttribution::User`] speech may become memory. Bystander and
200/// assistant-originated content is discarded at ingestion, not filtered later.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum SpeakerAttribution {
204    /// The enrolled user, speaking to B.
205    User,
206    /// Someone else within microphone range.
207    Bystander,
208    /// B's own output, echoed back through a transcript.
209    Assistant,
210    /// Attribution could not be established.
211    #[default]
212    Unknown,
213}
214
215impl SpeakerAttribution {
216    /// Whether observations from this speaker may be stored at all.
217    pub fn may_be_stored(self) -> bool {
218        matches!(self, Self::User)
219    }
220}
221
222/// What the extractor proposes should happen to a candidate over time.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
224#[serde(rename_all = "snake_case")]
225pub enum ProposedPersistence {
226    /// Long-lived semantic memory.
227    Durable,
228    /// Time-bounded episodic memory.
229    Episodic,
230    /// Useful for this conversation only.
231    SessionOnly,
232    /// Insufficient evidence; hold for reinforcement.
233    Staged,
234    /// Should not be retained.
235    Discard,
236}
237
238/// The value side of a memory triple.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "snake_case", tag = "type", content = "value")]
241pub enum MemoryValue {
242    /// Free text — the common case.
243    Text(String),
244    /// A boolean assertion.
245    Bool(bool),
246    /// A numeric quantity.
247    Number(f64),
248    /// An unordered set of values.
249    List(Vec<String>),
250}
251
252impl MemoryValue {
253    /// A normalized, lowercase rendering used for fingerprinting.
254    pub fn normalized(&self) -> String {
255        match self {
256            Self::Text(t) => normalize_token(t),
257            Self::Bool(b) => b.to_string(),
258            Self::Number(n) => format!("{n}"),
259            Self::List(items) => {
260                let mut normalized: Vec<String> =
261                    items.iter().map(|i| normalize_token(i)).collect();
262                normalized.sort();
263                normalized.join(",")
264            }
265        }
266    }
267
268    /// A human-readable rendering for statements and search text.
269    pub fn display(&self) -> String {
270        match self {
271            Self::Text(t) => t.clone(),
272            Self::Bool(b) => b.to_string(),
273            Self::Number(n) => format!("{n}"),
274            Self::List(items) => items.join(", "),
275        }
276    }
277}
278
279impl From<&str> for MemoryValue {
280    fn from(value: &str) -> Self {
281        Self::Text(value.to_string())
282    }
283}
284
285/// A canonicalized predicate name such as `dietary_identity`.
286///
287/// Predicates are normalized on construction so `"Dietary Identity"` and
288/// `"dietary_identity"` fingerprint identically.
289#[derive(
290    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
291)]
292#[serde(transparent)]
293pub struct CanonicalPredicate(String);
294
295impl CanonicalPredicate {
296    /// Normalize and wrap a predicate name.
297    pub fn new(raw: impl AsRef<str>) -> Self {
298        let normalized: String = raw
299            .as_ref()
300            .trim()
301            .chars()
302            .map(|c| {
303                if c.is_ascii_alphanumeric() {
304                    c.to_ascii_lowercase()
305                } else {
306                    '_'
307                }
308            })
309            .collect();
310        let collapsed = normalized
311            .split('_')
312            .filter(|s| !s.is_empty())
313            .collect::<Vec<_>>()
314            .join("_");
315        Self(collapsed)
316    }
317
318    /// Borrow the normalized predicate.
319    pub fn as_str(&self) -> &str {
320        &self.0
321    }
322}
323
324impl fmt::Display for CanonicalPredicate {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        f.write_str(&self.0)
327    }
328}
329
330/// A reference to the subject of a memory.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
332pub struct EntityRef {
333    /// Stable identifier, normalized from the display name when unknown.
334    pub id: EntityId,
335    /// How the user refers to this entity.
336    pub display: String,
337    /// Alternative names ("wife", "Rhea", "my partner").
338    #[serde(default)]
339    pub aliases: Vec<String>,
340}
341
342impl EntityRef {
343    /// The user themselves — the subject of most memories.
344    pub fn user() -> Self {
345        Self {
346            id: EntityId::new("user"),
347            display: "user".to_string(),
348            aliases: vec!["I".into(), "me".into(), "my".into()],
349        }
350    }
351
352    /// A named third party, with the id derived from the normalized name.
353    pub fn named(display: impl Into<String>) -> Self {
354        let display = display.into();
355        Self {
356            id: EntityId::new(normalize_token(&display)),
357            display,
358            aliases: Vec::new(),
359        }
360    }
361
362    /// Add an alias, returning the modified reference.
363    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
364        self.aliases.push(alias.into());
365        self
366    }
367
368    /// Every surface form this entity can be matched by, normalized.
369    pub fn surface_forms(&self) -> Vec<String> {
370        let mut forms = vec![normalize_token(&self.display)];
371        forms.extend(self.aliases.iter().map(|a| normalize_token(a)));
372        forms.retain(|f| !f.is_empty());
373        forms.sort();
374        forms.dedup();
375        forms
376    }
377}
378
379/// A deduplication key for "the same fact stated twice".
380///
381/// A fingerprint is a *hint*, not an identity: reconciliation may still decide
382/// two differently-fingerprinted candidates are semantically equivalent, or
383/// that two identically-fingerprinted ones differ by context.
384#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
385#[serde(transparent)]
386pub struct FactFingerprint(String);
387
388impl FactFingerprint {
389    /// Build a fingerprint from the normalized triple plus temporal scope.
390    pub fn new(
391        subject: &EntityRef,
392        predicate: &CanonicalPredicate,
393        value: &MemoryValue,
394        scope: TemporalScope,
395    ) -> Self {
396        let scope_part = match scope {
397            TemporalScope::Persistent => "",
398            TemporalScope::RecentHistory => "|recent",
399            TemporalScope::Momentary => "|momentary",
400            TemporalScope::Scheduled => "|scheduled",
401        };
402        Self(format!(
403            "{}|{}|{}{}",
404            normalize_token(&subject.display),
405            predicate.as_str(),
406            value.normalized(),
407            scope_part
408        ))
409    }
410
411    /// Borrow the fingerprint string.
412    pub fn as_str(&self) -> &str {
413        &self.0
414    }
415
416    /// The subject alone, used to widen the reconciliation candidate window.
417    pub fn subject(&self) -> &str {
418        self.0.split('|').next().unwrap_or(&self.0)
419    }
420
421    /// The subject-and-predicate prefix, used to find contradiction candidates.
422    pub fn subject_predicate(&self) -> &str {
423        let mut parts = self.0.match_indices('|');
424        match (parts.next(), parts.next()) {
425            (Some(_), Some((second, _))) => &self.0[..second],
426            _ => &self.0,
427        }
428    }
429}
430
431impl fmt::Display for FactFingerprint {
432    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433        f.write_str(&self.0)
434    }
435}
436
437/// Where a memory came from.
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
439pub struct MemorySource {
440    /// Provenance class, e.g. `explicit_user_statement`.
441    pub source_type: String,
442    /// The logical session the evidence was gathered in.
443    pub session_id: Option<SessionId>,
444    /// The turn the evidence was gathered on.
445    pub turn_id: Option<TurnId>,
446}
447
448impl MemorySource {
449    /// Build a source record from an explicitness level and location.
450    pub fn from_explicitness(
451        explicitness: Explicitness,
452        session_id: SessionId,
453        turn_id: TurnId,
454    ) -> Self {
455        let source_type = match explicitness {
456            Explicitness::ExplicitCommand => "explicit_user_command",
457            Explicitness::ExplicitStatement => "explicit_user_statement",
458            Explicitness::StrongImplication => "strong_implication",
459            Explicitness::WeakInference => "weak_inference",
460        };
461        Self {
462            source_type: source_type.to_string(),
463            session_id: Some(session_id),
464            turn_id: Some(turn_id),
465        }
466    }
467
468    /// Whether this source represents something the user said outright.
469    pub fn is_explicit(&self) -> bool {
470        self.source_type.starts_with("explicit_")
471    }
472}
473
474/// Validity and freshness metadata.
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct TemporalMetadata {
477    /// When the record was first written.
478    pub created_at: DateTime<Utc>,
479    /// When the record was last modified.
480    pub updated_at: DateTime<Utc>,
481    /// When evidence last confirmed the record.
482    pub last_confirmed_at: DateTime<Utc>,
483    /// When the fact started holding.
484    pub valid_from: DateTime<Utc>,
485    /// When the fact stopped holding, if superseded.
486    #[serde(default)]
487    pub valid_to: Option<DateTime<Utc>>,
488    /// When an episodic record should stop being retrieved.
489    #[serde(default)]
490    pub expires_at: Option<DateTime<Utc>>,
491}
492
493impl TemporalMetadata {
494    /// Fresh metadata for a record created now.
495    pub fn created_at(now: DateTime<Utc>) -> Self {
496        Self {
497            created_at: now,
498            updated_at: now,
499            last_confirmed_at: now,
500            valid_from: now,
501            valid_to: None,
502            expires_at: None,
503        }
504    }
505
506    /// Apply an expiry `ttl` from `now`.
507    pub fn with_ttl(mut self, ttl: Duration) -> Self {
508        self.expires_at = Some(self.valid_from + ttl);
509        self
510    }
511
512    /// Whether the record has passed its expiry at `now`.
513    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
514        self.expires_at.is_some_and(|e| e <= now) || self.valid_to.is_some_and(|v| v <= now)
515    }
516
517    /// Whole days since the record was last confirmed.
518    pub fn days_since_confirmed(&self, now: DateTime<Utc>) -> i64 {
519        (now - self.last_confirmed_at).num_days().max(0)
520    }
521}
522
523/// The fields a memory is matched on during lexical retrieval.
524#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
525pub struct RetrievalMetadata {
526    /// Normalized subject surface form.
527    pub subject: String,
528    /// Topical tags.
529    #[serde(default)]
530    pub tags: Vec<String>,
531    /// Paraphrases the fact may be asked for by.
532    #[serde(default)]
533    pub aliases: Vec<String>,
534    /// Other entities the fact mentions.
535    #[serde(default)]
536    pub entities: Vec<String>,
537    /// Place the fact is scoped to, if any.
538    #[serde(default)]
539    pub location: Option<String>,
540}
541
542/// How much evidence stands behind a memory.
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
544pub struct EvidenceCounters {
545    /// Total supporting observations.
546    pub count: u32,
547    /// Logical sessions that produced supporting evidence.
548    pub distinct_sessions: u32,
549    /// Calendar days that produced supporting evidence.
550    pub distinct_days: u32,
551}
552
553impl EvidenceCounters {
554    /// A single first observation.
555    pub fn first() -> Self {
556        Self {
557            count: 1,
558            distinct_sessions: 1,
559            distinct_days: 1,
560        }
561    }
562}
563
564/// User-facing data rights for a record.
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
566pub struct PrivacyMetadata {
567    /// Whether the user may delete this record.
568    pub deletable: bool,
569    /// Whether the record is included in data export.
570    pub exportable: bool,
571    /// Category gating automatic promotion.
572    pub sensitivity: SensitivityClass,
573}
574
575impl Default for PrivacyMetadata {
576    fn default() -> Self {
577        Self {
578            deletable: true,
579            exportable: true,
580            sensitivity: SensitivityClass::Normal,
581        }
582    }
583}
584
585/// A reconciled, durable memory — the canonical unit of the OKF repository.
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct CanonicalMemory {
588    /// Stable record identifier.
589    pub id: MemoryId,
590    /// Owning user namespace.
591    pub owner: UserId,
592    /// What sort of memory this is.
593    pub kind: MemoryKind,
594    /// Canonical predicate.
595    pub predicate: CanonicalPredicate,
596    /// Lifecycle state.
597    pub status: MemoryStatus,
598    /// Aggregated confidence in `[0, 1]`.
599    pub confidence: f32,
600    /// The subject of the memory.
601    pub subject: EntityRef,
602    /// The value side of the triple.
603    pub value: MemoryValue,
604    /// One-sentence natural-language rendering, shown to the model.
605    pub statement: String,
606    /// Why the engine believes this.
607    pub evidence_summary: String,
608    /// Provenance.
609    pub source: MemorySource,
610    /// Validity window.
611    pub temporal: TemporalMetadata,
612    /// Lexical retrieval surface.
613    pub retrieval: RetrievalMetadata,
614    /// Evidence counters.
615    pub evidence: EvidenceCounters,
616    /// Data rights.
617    pub privacy: PrivacyMetadata,
618    /// How long the fact is expected to hold.
619    pub temporal_scope: TemporalScope,
620    /// Records this one replaced.
621    #[serde(default)]
622    pub supersedes: Vec<MemoryId>,
623    /// The record that replaced this one.
624    #[serde(default)]
625    pub superseded_by: Option<MemoryId>,
626    /// Context qualifier distinguishing coexisting facts ("with family").
627    #[serde(default)]
628    pub qualifier: Option<String>,
629}
630
631impl CanonicalMemory {
632    /// The fingerprint this record would produce.
633    pub fn fingerprint(&self) -> FactFingerprint {
634        FactFingerprint::new(
635            &self.subject,
636            &self.predicate,
637            &self.value,
638            self.temporal_scope,
639        )
640    }
641
642    /// Whether the record may be returned to the model at `now`.
643    pub fn is_retrievable(&self, now: DateTime<Utc>) -> bool {
644        self.status.is_retrievable() && !self.temporal.is_expired(now)
645    }
646}
647
648/// A structured interpretation of one specific user statement.
649///
650/// An observation is *evidence*, not a fact. It becomes a fact only after
651/// consolidation and reconciliation accept it.
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
653pub struct MemoryObservation {
654    /// Identifier for this observation.
655    pub observation_id: ObservationId,
656    /// Logical session it was drawn from.
657    pub session_id: SessionId,
658    /// Turn it was drawn from.
659    pub turn_id: TurnId,
660    /// Who or what the statement is about.
661    pub subject: EntityRef,
662    /// Canonical predicate.
663    pub predicate: CanonicalPredicate,
664    /// Value side of the triple.
665    pub value: MemoryValue,
666    /// One-sentence natural-language rendering.
667    pub canonical_statement: String,
668    /// Proposed memory kind.
669    pub kind: MemoryKind,
670    /// How directly it was stated.
671    pub explicitness: Explicitness,
672    /// Extractor confidence in `[0, 1]`.
673    pub confidence: f32,
674    /// Proposed retention.
675    pub persistence: ProposedPersistence,
676    /// Expected temporal scope.
677    pub temporal_scope: TemporalScope,
678    /// When the fact started holding, if stated.
679    #[serde(default)]
680    pub valid_from: Option<DateTime<Utc>>,
681    /// When the fact is expected to stop holding.
682    #[serde(default)]
683    pub expected_expiry: Option<DateTime<Utc>>,
684    /// The transcript span this was drawn from.
685    pub transcript_evidence: TranscriptEvidence,
686    /// Who said it.
687    pub speaker_attribution: SpeakerAttribution,
688    /// Privacy classification.
689    pub sensitivity: SensitivityClass,
690    /// Whether this observation is a memory *command* rather than a statement.
691    #[serde(default)]
692    pub mutation_intent: Option<MutationIntent>,
693    /// Terms a user might later search this fact by, in any language they use.
694    ///
695    /// Lexical retrieval can only match words that are present. A fact stored
696    /// as "The user is vegetarian" is unreachable from "mera khaana ka
697    /// preference kya hai" — the languages share no token — unless the fact
698    /// carries the vocabulary the question will use. This is that vocabulary.
699    #[serde(default)]
700    pub search_terms: Vec<String>,
701}
702
703impl MemoryObservation {
704    /// The fingerprint this observation would produce.
705    pub fn fingerprint(&self) -> FactFingerprint {
706        FactFingerprint::new(
707            &self.subject,
708            &self.predicate,
709            &self.value,
710            self.temporal_scope,
711        )
712    }
713}
714
715/// An explicit user instruction about memory itself.
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717#[serde(rename_all = "snake_case")]
718pub enum MutationIntent {
719    /// "Remember that…"
720    Remember,
721    /// "Actually, it's…"
722    Correct,
723    /// "Forget…"
724    Forget,
725    /// "Delete everything about…"
726    Delete,
727    /// "What do you remember about me?"
728    List,
729}
730
731/// The evidence span an observation was drawn from.
732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
733pub struct TranscriptEvidence {
734    /// The finalized user utterance.
735    pub utterance: String,
736    /// Stable hash of the utterance, for idempotency.
737    pub utterance_hash: String,
738}
739
740impl TranscriptEvidence {
741    /// Build evidence from a finalized utterance.
742    pub fn new(utterance: impl Into<String>) -> Self {
743        let utterance = utterance.into();
744        let hash = stable_hash(&utterance);
745        Self {
746            utterance,
747            utterance_hash: hash,
748        }
749    }
750}
751
752/// Lowercase a string and collapse everything non-alphanumeric to single spaces.
753pub fn normalize_token(raw: &str) -> String {
754    let lowered: String = raw
755        .chars()
756        .map(|c| {
757            if c.is_alphanumeric() {
758                c.to_lowercase().next().unwrap_or(c)
759            } else {
760                ' '
761            }
762        })
763        .collect();
764    lowered.split_whitespace().collect::<Vec<_>>().join(" ")
765}
766
767/// A stable, dependency-free 64-bit content hash rendered as hex.
768///
769/// Used for idempotency keys and transcript fingerprints, never for security.
770pub fn stable_hash(input: &str) -> String {
771    // FNV-1a, 64-bit.
772    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
773    for byte in input.as_bytes() {
774        hash ^= u64::from(*byte);
775        hash = hash.wrapping_mul(0x100_0000_01b3);
776    }
777    format!("{hash:016x}")
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn predicates_normalize_to_snake_case() {
786        assert_eq!(
787            CanonicalPredicate::new("Dietary Identity").as_str(),
788            "dietary_identity"
789        );
790        assert_eq!(
791            CanonicalPredicate::new("  dietary--identity  ").as_str(),
792            "dietary_identity"
793        );
794    }
795
796    #[test]
797    fn identical_facts_fingerprint_identically() {
798        let subject = EntityRef::user();
799        let predicate = CanonicalPredicate::new("dietary_identity");
800        let a = FactFingerprint::new(
801            &subject,
802            &predicate,
803            &MemoryValue::Text("Pescatarian".into()),
804            TemporalScope::Persistent,
805        );
806        let b = FactFingerprint::new(
807            &subject,
808            &predicate,
809            &MemoryValue::Text("pescatarian".into()),
810            TemporalScope::Persistent,
811        );
812        assert_eq!(a, b);
813        assert_eq!(a.as_str(), "user|dietary_identity|pescatarian");
814    }
815
816    #[test]
817    fn fingerprints_expose_their_subject() {
818        let fp = FactFingerprint::new(
819            &EntityRef::named("Rhea"),
820            &CanonicalPredicate::new("venue_preference"),
821            &MemoryValue::Text("quiet".into()),
822            TemporalScope::Persistent,
823        );
824        assert_eq!(fp.subject(), "rhea");
825    }
826
827    #[test]
828    fn fingerprints_expose_the_subject_predicate_prefix() {
829        let fp = FactFingerprint::new(
830            &EntityRef::user(),
831            &CanonicalPredicate::new("dietary_identity"),
832            &MemoryValue::Text("vegetarian".into()),
833            TemporalScope::Persistent,
834        );
835        assert_eq!(fp.subject_predicate(), "user|dietary_identity");
836    }
837
838    #[test]
839    fn temporal_scope_separates_fingerprints() {
840        let persistent = FactFingerprint::new(
841            &EntityRef::user(),
842            &CanonicalPredicate::new("activity"),
843            &MemoryValue::Text("travelling".into()),
844            TemporalScope::Persistent,
845        );
846        let momentary = FactFingerprint::new(
847            &EntityRef::user(),
848            &CanonicalPredicate::new("activity"),
849            &MemoryValue::Text("travelling".into()),
850            TemporalScope::Momentary,
851        );
852        assert_ne!(persistent, momentary);
853    }
854
855    #[test]
856    fn repetition_of_weak_evidence_cannot_manufacture_certainty() {
857        assert_eq!(Explicitness::WeakInference.confidence_ceiling(9), 0.75);
858        assert_eq!(Explicitness::ExplicitCommand.confidence_ceiling(1), 1.0);
859    }
860
861    #[test]
862    fn only_user_speech_may_be_stored() {
863        assert!(SpeakerAttribution::User.may_be_stored());
864        assert!(!SpeakerAttribution::Bystander.may_be_stored());
865        assert!(!SpeakerAttribution::Assistant.may_be_stored());
866        assert!(!SpeakerAttribution::Unknown.may_be_stored());
867    }
868
869    #[test]
870    fn expiry_is_evaluated_against_both_windows() {
871        let now = Utc::now();
872        let meta = TemporalMetadata::created_at(now).with_ttl(Duration::hours(6));
873        assert!(!meta.is_expired(now));
874        assert!(meta.is_expired(now + Duration::hours(7)));
875    }
876
877    #[test]
878    fn entity_surface_forms_are_normalized_and_deduped() {
879        let entity = EntityRef::named("Rhea")
880            .with_alias("my wife")
881            .with_alias("Rhea");
882        assert_eq!(entity.surface_forms(), vec!["my wife", "rhea"]);
883    }
884
885    #[test]
886    fn stable_hash_is_deterministic_and_distinguishing() {
887        assert_eq!(stable_hash("hello"), stable_hash("hello"));
888        assert_ne!(stable_hash("hello"), stable_hash("hellp"));
889    }
890}