Skip to main content

totalreclaw_core/
claims.rs

1//! Knowledge Graph claim types (Phase 1 Stage 1a).
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum ClaimCategory {
7    #[serde(rename = "fact")]
8    Fact,
9    #[serde(rename = "pref")]
10    Preference,
11    #[serde(rename = "dec")]
12    Decision,
13    #[serde(rename = "epi")]
14    Episodic,
15    #[serde(rename = "goal")]
16    Goal,
17    #[serde(rename = "ctx")]
18    Context,
19    #[serde(rename = "sum")]
20    Summary,
21    /// A reusable operational rule, non-obvious gotcha, or convention the user
22    /// wants to remember for next time. Distinct from decisions (which have
23    /// reasoning for a specific choice) and preferences (personal tastes):
24    /// rules are impersonal, actionable, and transferable. Phase 2.2 addition.
25    #[serde(rename = "rule")]
26    Rule,
27    #[serde(rename = "ent")]
28    Entity,
29    #[serde(rename = "dig")]
30    Digest,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum ClaimStatus {
35    #[serde(rename = "a")]
36    Active,
37    #[serde(rename = "s")]
38    Superseded,
39    #[serde(rename = "r")]
40    Retracted,
41    #[serde(rename = "c")]
42    Contradicted,
43    #[serde(rename = "p")]
44    Pinned,
45}
46
47impl Default for ClaimStatus {
48    fn default() -> Self {
49        ClaimStatus::Active
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum EntityType {
56    Person,
57    Project,
58    Tool,
59    Company,
60    Concept,
61    Place,
62}
63
64fn is_one(n: &u32) -> bool {
65    *n == 1
66}
67
68fn is_active(s: &ClaimStatus) -> bool {
69    matches!(s, ClaimStatus::Active)
70}
71
72fn is_empty_vec<T>(v: &Vec<T>) -> bool {
73    v.is_empty()
74}
75
76fn default_corroboration() -> u32 {
77    1
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct EntityRef {
82    #[serde(rename = "n")]
83    pub name: String,
84    #[serde(rename = "tp")]
85    pub entity_type: EntityType,
86    #[serde(rename = "r", skip_serializing_if = "Option::is_none", default)]
87    pub role: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct Claim {
92    #[serde(rename = "t")]
93    pub text: String,
94    #[serde(rename = "c")]
95    pub category: ClaimCategory,
96    #[serde(rename = "cf")]
97    pub confidence: f64,
98    #[serde(rename = "i")]
99    pub importance: u8,
100    #[serde(
101        rename = "cc",
102        skip_serializing_if = "is_one",
103        default = "default_corroboration"
104    )]
105    pub corroboration_count: u32,
106    #[serde(rename = "sa")]
107    pub source_agent: String,
108    #[serde(rename = "sc", skip_serializing_if = "Option::is_none", default)]
109    pub source_conversation: Option<String>,
110    #[serde(rename = "ea", skip_serializing_if = "Option::is_none", default)]
111    pub extracted_at: Option<String>,
112    #[serde(rename = "e", skip_serializing_if = "is_empty_vec", default)]
113    pub entities: Vec<EntityRef>,
114    #[serde(rename = "sup", skip_serializing_if = "Option::is_none", default)]
115    pub supersedes: Option<String>,
116    #[serde(rename = "sby", skip_serializing_if = "Option::is_none", default)]
117    pub superseded_by: Option<String>,
118    #[serde(rename = "vf", skip_serializing_if = "Option::is_none", default)]
119    pub valid_from: Option<String>,
120    #[serde(rename = "st", skip_serializing_if = "is_active", default)]
121    pub status: ClaimStatus,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct Entity {
126    pub id: String,
127    pub name: String,
128    #[serde(rename = "type")]
129    pub entity_type: EntityType,
130    pub aliases: Vec<String>,
131    pub claim_ids: Vec<String>,
132    pub first_seen: String,
133    pub last_seen: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct DigestClaim {
138    pub text: String,
139    pub category: ClaimCategory,
140    pub confidence: f64,
141    pub age: String,
142}
143
144/// Tie-zone score tolerance for contradiction resolution.
145///
146/// When the formula winner beats the loser by less than this amount, the
147/// decision is treated as a tie and both claims stay active. Calibrated against
148/// the 2026-04-14 false-positive where the gap was 9 parts per million.
149pub const TIE_ZONE_SCORE_TOLERANCE: f64 = 0.01;
150
151/// Check whether a claim has pinned status.
152pub fn is_pinned_claim(claim: &Claim) -> bool {
153    matches!(claim.status, ClaimStatus::Pinned)
154}
155
156/// Check whether a JSON-serialized claim has pinned status.
157///
158/// Returns false on parse error or missing status field (which defaults to Active).
159pub fn is_pinned_json(claim_json: &str) -> bool {
160    match serde_json::from_str::<Claim>(claim_json) {
161        Ok(claim) => is_pinned_claim(&claim),
162        Err(_) => false,
163    }
164}
165
166/// The action to take after checking pin status and tie-zone guard during
167/// contradiction resolution.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum ResolutionAction {
171    /// No contradiction detected — pass through.
172    NoContradiction,
173    /// New claim wins; supersede the existing claim.
174    SupersedeExisting {
175        existing_id: String,
176        new_id: String,
177        similarity: f64,
178        score_gap: f64,
179        /// Entity that triggered the contradiction (populated by orchestration).
180        #[serde(skip_serializing_if = "Option::is_none", default)]
181        entity_id: Option<String>,
182        /// Winner's score (populated by orchestration).
183        #[serde(skip_serializing_if = "Option::is_none", default)]
184        winner_score: Option<f64>,
185        /// Loser's score (populated by orchestration).
186        #[serde(skip_serializing_if = "Option::is_none", default)]
187        loser_score: Option<f64>,
188        /// Winner's per-component score breakdown (populated by orchestration).
189        #[serde(skip_serializing_if = "Option::is_none", default)]
190        winner_components: Option<crate::contradiction::ScoreComponents>,
191        /// Loser's per-component score breakdown (populated by orchestration).
192        #[serde(skip_serializing_if = "Option::is_none", default)]
193        loser_components: Option<crate::contradiction::ScoreComponents>,
194    },
195    /// Skip the new claim (existing wins or is pinned).
196    SkipNew {
197        reason: SkipReason,
198        existing_id: String,
199        new_id: String,
200        /// Entity that triggered the contradiction (populated by orchestration).
201        #[serde(skip_serializing_if = "Option::is_none", default)]
202        entity_id: Option<String>,
203        /// Similarity between the claims (populated by orchestration).
204        #[serde(skip_serializing_if = "Option::is_none", default)]
205        similarity: Option<f64>,
206        /// Winner's score (populated by orchestration).
207        #[serde(skip_serializing_if = "Option::is_none", default)]
208        winner_score: Option<f64>,
209        /// Loser's score (populated by orchestration).
210        #[serde(skip_serializing_if = "Option::is_none", default)]
211        loser_score: Option<f64>,
212        /// Winner's per-component score breakdown (populated by orchestration).
213        #[serde(skip_serializing_if = "Option::is_none", default)]
214        winner_components: Option<crate::contradiction::ScoreComponents>,
215        /// Loser's per-component score breakdown (populated by orchestration).
216        #[serde(skip_serializing_if = "Option::is_none", default)]
217        loser_components: Option<crate::contradiction::ScoreComponents>,
218    },
219    /// Score gap is within tie-zone tolerance; keep both claims.
220    TieLeaveBoth {
221        existing_id: String,
222        new_id: String,
223        similarity: f64,
224        score_gap: f64,
225        /// Entity that triggered the contradiction (populated by orchestration).
226        #[serde(skip_serializing_if = "Option::is_none", default)]
227        entity_id: Option<String>,
228        /// Winner's score (populated by orchestration).
229        #[serde(skip_serializing_if = "Option::is_none", default)]
230        winner_score: Option<f64>,
231        /// Loser's score (populated by orchestration).
232        #[serde(skip_serializing_if = "Option::is_none", default)]
233        loser_score: Option<f64>,
234        /// Winner's per-component score breakdown (populated by orchestration).
235        #[serde(skip_serializing_if = "Option::is_none", default)]
236        winner_components: Option<crate::contradiction::ScoreComponents>,
237        /// Loser's per-component score breakdown (populated by orchestration).
238        #[serde(skip_serializing_if = "Option::is_none", default)]
239        loser_components: Option<crate::contradiction::ScoreComponents>,
240    },
241}
242
243/// Why a new claim was skipped in favour of the existing one.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum SkipReason {
247    /// The existing claim is pinned and cannot be superseded.
248    ExistingPinned,
249    /// The existing claim scored higher than the new one.
250    ExistingWins,
251    /// The similarity was below the contradiction threshold.
252    BelowThreshold,
253}
254
255/// Apply pin-status and tie-zone checks to a resolution outcome.
256///
257/// - If the existing claim is pinned, returns `SkipNew { ExistingPinned }`.
258/// - If `resolution_winner` == `existing_claim_id`, returns `SkipNew { ExistingWins }`.
259/// - If `resolution_winner` == `new_claim_id` but `score_gap < tie_zone_tolerance`,
260///   returns `TieLeaveBoth`.
261/// - Otherwise returns `SupersedeExisting`.
262pub fn respect_pin_in_resolution(
263    existing_claim_json: &str,
264    new_claim_id: &str,
265    existing_claim_id: &str,
266    resolution_winner: &str,
267    score_gap: f64,
268    similarity: f64,
269    tie_zone_tolerance: f64,
270) -> ResolutionAction {
271    // Check if existing claim is pinned.
272    if is_pinned_json(existing_claim_json) {
273        return ResolutionAction::SkipNew {
274            reason: SkipReason::ExistingPinned,
275            existing_id: existing_claim_id.to_string(),
276            new_id: new_claim_id.to_string(),
277            entity_id: None,
278            similarity: None,
279            winner_score: None,
280            loser_score: None,
281            winner_components: None,
282            loser_components: None,
283        };
284    }
285
286    // If the existing claim wins the formula, skip the new one.
287    if resolution_winner == existing_claim_id {
288        return ResolutionAction::SkipNew {
289            reason: SkipReason::ExistingWins,
290            existing_id: existing_claim_id.to_string(),
291            new_id: new_claim_id.to_string(),
292            entity_id: None,
293            similarity: None,
294            winner_score: None,
295            loser_score: None,
296            winner_components: None,
297            loser_components: None,
298        };
299    }
300
301    // New claim wins — check tie zone.
302    if score_gap.abs() < tie_zone_tolerance {
303        return ResolutionAction::TieLeaveBoth {
304            existing_id: existing_claim_id.to_string(),
305            new_id: new_claim_id.to_string(),
306            similarity,
307            score_gap,
308            entity_id: None,
309            winner_score: None,
310            loser_score: None,
311            winner_components: None,
312            loser_components: None,
313        };
314    }
315
316    // New claim wins clearly.
317    ResolutionAction::SupersedeExisting {
318        existing_id: existing_claim_id.to_string(),
319        new_id: new_claim_id.to_string(),
320        similarity,
321        score_gap,
322        entity_id: None,
323        winner_score: None,
324        loser_score: None,
325        winner_components: None,
326        loser_components: None,
327    }
328}
329
330/// Normalize an entity name per §15.8: NFC(lowercase(trim(collapse_whitespace(name)))).
331pub fn normalize_entity_name(name: &str) -> String {
332    use unicode_normalization::UnicodeNormalization;
333    let mut collapsed = String::with_capacity(name.len());
334    let mut in_ws = false;
335    let mut any = false;
336    for ch in name.chars() {
337        if ch.is_whitespace() {
338            if any && !in_ws {
339                collapsed.push(' ');
340                in_ws = true;
341            }
342        } else {
343            collapsed.push(ch);
344            in_ws = false;
345            any = true;
346        }
347    }
348    let trimmed = collapsed.trim_end_matches(' ').to_string();
349    let lowered: String = trimmed.chars().flat_map(|c| c.to_lowercase()).collect();
350    lowered.nfc().collect()
351}
352
353/// Deterministic entity ID: first 8 bytes of SHA256(normalized name) as hex.
354pub fn deterministic_entity_id(name: &str) -> String {
355    use sha2::{Digest as _, Sha256};
356    let normalized = normalize_entity_name(name);
357    let hash = Sha256::digest(normalized.as_bytes());
358    hex::encode(&hash[..8])
359}
360
361/// Parse a decrypted blob as a Claim, falling back to legacy formats per §15.2.
362pub fn parse_claim_or_legacy(decrypted: &str) -> Claim {
363    if let Ok(claim) = serde_json::from_str::<Claim>(decrypted) {
364        return claim;
365    }
366    let (text, source_agent) =
367        if let Ok(value) = serde_json::from_str::<serde_json::Value>(decrypted) {
368            match value {
369                serde_json::Value::String(s) => (s, "unknown".to_string()),
370                serde_json::Value::Object(map) => {
371                    let text = map
372                        .get("t")
373                        .or_else(|| map.get("text"))
374                        .and_then(|v| v.as_str())
375                        .map(|s| s.to_string())
376                        .unwrap_or_else(|| decrypted.to_string());
377                    let agent = map
378                        .get("a")
379                        .or_else(|| {
380                            map.get("metadata")
381                                .and_then(|m| m.as_object())
382                                .and_then(|m| m.get("source"))
383                        })
384                        .and_then(|v| v.as_str())
385                        .map(|s| s.to_string())
386                        .unwrap_or_else(|| "unknown".to_string());
387                    (text, agent)
388                }
389                _ => (decrypted.to_string(), "unknown".to_string()),
390            }
391        } else {
392            (decrypted.to_string(), "unknown".to_string())
393        };
394    Claim {
395        text,
396        category: ClaimCategory::Fact,
397        confidence: 0.7,
398        importance: 5,
399        corroboration_count: 1,
400        source_agent,
401        source_conversation: None,
402        extracted_at: None,
403        entities: Vec::new(),
404        supersedes: None,
405        superseded_by: None,
406        valid_from: None,
407        status: ClaimStatus::Active,
408    }
409}
410
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412pub struct Digest {
413    pub version: u64,
414    pub compiled_at: String,
415    pub fact_count: u32,
416    pub entity_count: u32,
417    pub contradiction_count: u32,
418    pub identity: String,
419    pub top_claims: Vec<DigestClaim>,
420    pub recent_decisions: Vec<DigestClaim>,
421    pub active_projects: Vec<String>,
422    pub active_contradictions: u32,
423    pub prompt_text: String,
424}
425
426// ---------------------------------------------------------------------------
427// Memory Taxonomy v1 (spec: docs/specs/totalreclaw/memory-taxonomy-v1.md)
428//
429// These types are additive and coexist with the v0 `Claim` / `ClaimCategory`
430// types during the migration window. v0 types MUST NOT be removed until all
431// clients have migrated and v1 has locked post-WildChat validation.
432// ---------------------------------------------------------------------------
433
434/// The required `schema_version` value for all v1 claims.
435///
436/// Fixed at `"1.0"` per spec §schema. Receivers MUST refuse to read claims
437/// with unknown schema versions (fail-safe default).
438pub const MEMORY_CLAIM_V1_SCHEMA_VERSION: &str = "1.0";
439
440/// v1 memory type — closed enum of 6 speech-act-grounded categories.
441///
442/// Each value maps to one of Searle's illocutionary classes. See spec
443/// §type-semantics for boundary tests and legacy-type absorption.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
445#[serde(rename_all = "lowercase")]
446pub enum MemoryTypeV1 {
447    /// Assertive speech act — state of the world. Absorbs legacy
448    /// fact / context / decision.
449    Claim,
450    /// Expressive speech act — likes / dislikes / tastes.
451    Preference,
452    /// Imperative speech act — rules the user wants applied going forward
453    /// (absorbs legacy `rule`).
454    Directive,
455    /// Commissive speech act — future intent (absorbs legacy `goal`).
456    Commitment,
457    /// Narrative — notable past events (absorbs legacy `episodic`).
458    Episode,
459    /// Derived synthesis — only valid with source in {derived, assistant}.
460    Summary,
461}
462
463/// Provenance source for a memory claim.
464///
465/// Per spec §provenance-filter, `source` is a first-class ranking signal.
466/// The v1 retrieval Tier 1 pipeline applies a source-weighted multiplier
467/// to the final RRF score so assistant-authored facts don't drown out
468/// user-authored claims.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
470#[serde(rename_all = "kebab-case")]
471pub enum MemorySource {
472    /// User explicitly stated the claim (highest trust).
473    User,
474    /// Extractor confidently inferred from user signals.
475    UserInferred,
476    /// Assistant authored — heavy penalty at retrieval.
477    Assistant,
478    /// Imported from another system (e.g. Mem0, ChatGPT, Claude memory).
479    External,
480    /// Computed (digests, summaries, consolidation).
481    Derived,
482}
483
484/// Life-domain scope for a memory claim. Open-extensible per client,
485/// but every v1-compliant client MUST accept all values defined here
486/// when reading from a vault written by another client.
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
488#[serde(rename_all = "lowercase")]
489pub enum MemoryScope {
490    Work,
491    Personal,
492    Health,
493    Family,
494    Creative,
495    Finance,
496    Misc,
497    Unspecified,
498}
499
500/// Temporal stability of a memory claim. Assigned in the comparative
501/// rescoring pass, not at single-claim extraction.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
503#[serde(rename_all = "lowercase")]
504pub enum MemoryVolatility {
505    /// Unlikely to change for years (name, allergies, birthplace).
506    Stable,
507    /// Changes occasionally (job, active project, partner's name).
508    Updatable,
509    /// Short-lived (today's task, this week's itinerary).
510    Ephemeral,
511}
512
513/// Entity type for v1 structured entity references. Mirrors the v0
514/// [`EntityType`] enum and uses the same string-level encoding so
515/// v1 claims can cross-reference v0 entities.
516pub type MemoryEntityType = EntityType;
517
518/// Structured entity reference inside a v1 claim.
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
520pub struct MemoryEntityV1 {
521    /// Prefer proper nouns; specific not generic.
522    pub name: String,
523    #[serde(rename = "type")]
524    pub entity_type: MemoryEntityType,
525    /// Optional semantic role (e.g. "chooser", "employer", "rejected").
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub role: Option<String>,
528}
529
530fn default_schema_version_v1() -> String {
531    MEMORY_CLAIM_V1_SCHEMA_VERSION.to_string()
532}
533
534fn is_default_schema_version_v1(v: &str) -> bool {
535    v == MEMORY_CLAIM_V1_SCHEMA_VERSION
536}
537
538fn default_scope_v1() -> MemoryScope {
539    MemoryScope::Unspecified
540}
541
542fn is_default_scope_v1(s: &MemoryScope) -> bool {
543    matches!(s, MemoryScope::Unspecified)
544}
545
546fn default_volatility_v1() -> MemoryVolatility {
547    MemoryVolatility::Updatable
548}
549
550fn is_default_volatility_v1(v: &MemoryVolatility) -> bool {
551    matches!(v, MemoryVolatility::Updatable)
552}
553
554fn is_empty_entities_v1(v: &[MemoryEntityV1]) -> bool {
555    v.is_empty()
556}
557
558/// A v1 memory claim per the Memory Taxonomy v1 spec.
559///
560/// All required fields are always serialized. Optional fields default to the
561/// spec-defined sentinel (`scope` defaults to `Unspecified`, `volatility` to
562/// `Updatable`) and are preserved on round-trip.
563///
564/// See `docs/specs/totalreclaw/memory-taxonomy-v1.md` for field semantics.
565#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
566pub struct MemoryClaimV1 {
567    // ── REQUIRED ─────────────────────────────────────────────────
568    /// UUIDv7 (time-ordered, no separate created_at needed for sort).
569    pub id: String,
570    /// Human-readable, 5-512 UTF-8 chars.
571    pub text: String,
572    #[serde(rename = "type")]
573    pub memory_type: MemoryTypeV1,
574    pub source: MemorySource,
575    /// ISO8601 UTC (redundant w/ UUIDv7 but explicit per spec).
576    pub created_at: String,
577    #[serde(
578        default = "default_schema_version_v1",
579        skip_serializing_if = "is_default_schema_version_v1"
580    )]
581    pub schema_version: String,
582
583    // ── ORTHOGONAL AXES (defaults applied if absent) ─────────────
584    #[serde(
585        default = "default_scope_v1",
586        skip_serializing_if = "is_default_scope_v1"
587    )]
588    pub scope: MemoryScope,
589    #[serde(
590        default = "default_volatility_v1",
591        skip_serializing_if = "is_default_volatility_v1"
592    )]
593    pub volatility: MemoryVolatility,
594
595    // ── STRUCTURED FIELDS ────────────────────────────────────────
596    #[serde(default, skip_serializing_if = "is_empty_entities_v1")]
597    pub entities: Vec<MemoryEntityV1>,
598    /// Separate `reasoning` field for decision-style claims (replaces old
599    /// `decision` type). Populate for `type: claim` where the user expressed
600    /// a decision-with-reasoning.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub reasoning: Option<String>,
603    /// ISO8601 UTC expiration; set by extractor per type+volatility heuristic.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub expires_at: Option<String>,
606
607    // ── ADVISORY (receivers MAY recompute) ───────────────────────
608    /// 1-10, auto-ranked in comparative pass. Advisory.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub importance: Option<u8>,
611    /// 0-1, extractor self-assessment.
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub confidence: Option<f64>,
614    /// Claim ID that overrides this (tombstone chain).
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub superseded_by: Option<String>,
617}
618
619impl MemoryTypeV1 {
620    /// Case-insensitive parser that returns a fallback default for unknown input.
621    ///
622    /// Returns `MemoryTypeV1::Claim` for any unrecognised (or empty) string.
623    /// Used at boundaries where robustness beats strictness — e.g. parsing
624    /// decrypted blobs written by another client version.
625    pub fn from_str_lossy(s: &str) -> Self {
626        match s.trim().to_ascii_lowercase().as_str() {
627            "claim" => MemoryTypeV1::Claim,
628            "preference" => MemoryTypeV1::Preference,
629            "directive" => MemoryTypeV1::Directive,
630            "commitment" => MemoryTypeV1::Commitment,
631            "episode" => MemoryTypeV1::Episode,
632            "summary" => MemoryTypeV1::Summary,
633            _ => MemoryTypeV1::Claim,
634        }
635    }
636}
637
638impl MemorySource {
639    /// Case-insensitive parser that returns a fallback for unknown input.
640    ///
641    /// Returns `MemorySource::UserInferred` for any unrecognised string. This
642    /// choice matches the retrieval Tier 1 policy for legacy claims without a
643    /// `source` field — they receive a moderate fallback weight rather than
644    /// being penalised as hard as `assistant` or promoted as high as `user`.
645    pub fn from_str_lossy(s: &str) -> Self {
646        // Accept both kebab-case and underscored / space variants to be
647        // generous about cross-client serialization drift.
648        let normalized: String = s
649            .trim()
650            .to_ascii_lowercase()
651            .chars()
652            .map(|c| if c == '_' || c == ' ' { '-' } else { c })
653            .collect();
654        match normalized.as_str() {
655            "user" => MemorySource::User,
656            "user-inferred" => MemorySource::UserInferred,
657            "assistant" => MemorySource::Assistant,
658            "external" => MemorySource::External,
659            "derived" => MemorySource::Derived,
660            _ => MemorySource::UserInferred,
661        }
662    }
663}
664
665impl MemoryScope {
666    /// Case-insensitive parser that returns `Unspecified` for unknown input.
667    ///
668    /// The scope enum is open-extensible per spec §cross-client-guarantees, so
669    /// receivers MUST tolerate unknown scope values when reading from a vault
670    /// written by another client — we coerce to `Unspecified` rather than
671    /// guess which v1 bucket to funnel them into.
672    pub fn from_str_lossy(s: &str) -> Self {
673        match s.trim().to_ascii_lowercase().as_str() {
674            "work" => MemoryScope::Work,
675            "personal" => MemoryScope::Personal,
676            "health" => MemoryScope::Health,
677            "family" => MemoryScope::Family,
678            "creative" => MemoryScope::Creative,
679            "finance" => MemoryScope::Finance,
680            "misc" => MemoryScope::Misc,
681            "unspecified" => MemoryScope::Unspecified,
682            _ => MemoryScope::Unspecified,
683        }
684    }
685}
686
687impl MemoryVolatility {
688    /// Case-insensitive parser; returns `Updatable` (the spec default) for
689    /// unknown input.
690    pub fn from_str_lossy(s: &str) -> Self {
691        match s.trim().to_ascii_lowercase().as_str() {
692            "stable" => MemoryVolatility::Stable,
693            "updatable" => MemoryVolatility::Updatable,
694            "ephemeral" => MemoryVolatility::Ephemeral,
695            _ => MemoryVolatility::Updatable,
696        }
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    fn minimal_claim() -> Claim {
705        Claim {
706            text: "prefers PostgreSQL".to_string(),
707            category: ClaimCategory::Preference,
708            confidence: 0.9,
709            importance: 8,
710            corroboration_count: 1,
711            source_agent: "oc".to_string(),
712            source_conversation: None,
713            extracted_at: None,
714            entities: vec![EntityRef {
715                name: "PostgreSQL".to_string(),
716                entity_type: EntityType::Tool,
717                role: None,
718            }],
719            supersedes: None,
720            superseded_by: None,
721            valid_from: None,
722            status: ClaimStatus::Active,
723        }
724    }
725
726    fn full_claim() -> Claim {
727        Claim {
728            text: "Pedro chose PostgreSQL over MySQL because relational modeling is cleaner for our domain".to_string(),
729            category: ClaimCategory::Decision,
730            confidence: 0.92,
731            importance: 9,
732            corroboration_count: 3,
733            source_agent: "openclaw-plugin".to_string(),
734            source_conversation: Some("conv-abc-123".to_string()),
735            extracted_at: Some("2026-04-12T10:00:00Z".to_string()),
736            entities: vec![
737                EntityRef {
738                    name: "Pedro".to_string(),
739                    entity_type: EntityType::Person,
740                    role: Some("chooser".to_string()),
741                },
742                EntityRef {
743                    name: "PostgreSQL".to_string(),
744                    entity_type: EntityType::Tool,
745                    role: Some("chosen".to_string()),
746                },
747            ],
748            supersedes: Some("0xabc".to_string()),
749            superseded_by: None,
750            valid_from: Some("2026-04-01T00:00:00Z".to_string()),
751            status: ClaimStatus::Superseded,
752        }
753    }
754
755    // === Serde round-trip ===
756
757    #[test]
758    fn test_full_claim_round_trip() {
759        let c = full_claim();
760        let json = serde_json::to_string(&c).unwrap();
761        let back: Claim = serde_json::from_str(&json).unwrap();
762        assert_eq!(c, back);
763    }
764
765    #[test]
766    fn test_minimal_claim_round_trip() {
767        let c = minimal_claim();
768        let json = serde_json::to_string(&c).unwrap();
769        let back: Claim = serde_json::from_str(&json).unwrap();
770        assert_eq!(c, back);
771    }
772
773    #[test]
774    fn test_minimal_claim_omits_defaults() {
775        let c = minimal_claim();
776        let json = serde_json::to_string(&c).unwrap();
777        // status=Active -> omitted
778        assert!(
779            !json.contains("\"st\""),
780            "status should be omitted when Active: {}",
781            json
782        );
783        // corroboration_count=1 -> omitted
784        assert!(
785            !json.contains("\"cc\""),
786            "corroboration_count should be omitted when 1: {}",
787            json
788        );
789        // None options omitted
790        assert!(!json.contains("\"sup\""));
791        assert!(!json.contains("\"sby\""));
792        assert!(!json.contains("\"vf\""));
793        assert!(!json.contains("\"ea\""));
794        assert!(!json.contains("\"sc\""));
795    }
796
797    #[test]
798    fn test_minimal_claim_short_keys_present() {
799        let c = minimal_claim();
800        let json = serde_json::to_string(&c).unwrap();
801        assert!(json.contains("\"t\":"));
802        assert!(json.contains("\"c\":\"pref\""));
803        assert!(json.contains("\"cf\":"));
804        assert!(json.contains("\"i\":"));
805        assert!(json.contains("\"sa\":"));
806        assert!(json.contains("\"e\":"));
807        // Entity short keys
808        assert!(json.contains("\"n\":\"PostgreSQL\""));
809        assert!(json.contains("\"tp\":\"tool\""));
810        // role None -> omitted
811        assert!(!json.contains("\"r\":"));
812    }
813
814    #[test]
815    fn test_category_short_strings() {
816        let pairs = [
817            (ClaimCategory::Fact, "fact"),
818            (ClaimCategory::Preference, "pref"),
819            (ClaimCategory::Decision, "dec"),
820            (ClaimCategory::Episodic, "epi"),
821            (ClaimCategory::Goal, "goal"),
822            (ClaimCategory::Context, "ctx"),
823            (ClaimCategory::Summary, "sum"),
824            (ClaimCategory::Rule, "rule"),
825            (ClaimCategory::Entity, "ent"),
826            (ClaimCategory::Digest, "dig"),
827        ];
828        for (cat, expected) in pairs {
829            let json = serde_json::to_string(&cat).unwrap();
830            assert_eq!(json, format!("\"{}\"", expected));
831            let back: ClaimCategory = serde_json::from_str(&json).unwrap();
832            assert_eq!(cat, back);
833        }
834    }
835
836    #[test]
837    fn test_status_short_strings() {
838        let pairs = [
839            (ClaimStatus::Active, "a"),
840            (ClaimStatus::Superseded, "s"),
841            (ClaimStatus::Retracted, "r"),
842            (ClaimStatus::Contradicted, "c"),
843            (ClaimStatus::Pinned, "p"),
844        ];
845        for (st, expected) in pairs {
846            let json = serde_json::to_string(&st).unwrap();
847            assert_eq!(json, format!("\"{}\"", expected));
848            let back: ClaimStatus = serde_json::from_str(&json).unwrap();
849            assert_eq!(st, back);
850        }
851    }
852
853    #[test]
854    fn test_entity_type_short_strings() {
855        let pairs = [
856            (EntityType::Person, "person"),
857            (EntityType::Project, "project"),
858            (EntityType::Tool, "tool"),
859            (EntityType::Company, "company"),
860            (EntityType::Concept, "concept"),
861            (EntityType::Place, "place"),
862        ];
863        for (et, expected) in pairs {
864            let json = serde_json::to_string(&et).unwrap();
865            assert_eq!(json, format!("\"{}\"", expected));
866        }
867    }
868
869    #[test]
870    fn test_reference_claim_exact_bytes() {
871        // Byte-level canonical: lock down the exact JSON output of a fixed claim.
872        // This test MUST match what Python + TS produce for cross-language parity.
873        let c = Claim {
874            text: "prefers PostgreSQL".to_string(),
875            category: ClaimCategory::Preference,
876            confidence: 0.9,
877            importance: 8,
878            corroboration_count: 1,
879            source_agent: "oc".to_string(),
880            source_conversation: None,
881            extracted_at: None,
882            entities: vec![EntityRef {
883                name: "PostgreSQL".to_string(),
884                entity_type: EntityType::Tool,
885                role: None,
886            }],
887            supersedes: None,
888            superseded_by: None,
889            valid_from: None,
890            status: ClaimStatus::Active,
891        };
892        let json = serde_json::to_string(&c).unwrap();
893        let expected = r#"{"t":"prefers PostgreSQL","c":"pref","cf":0.9,"i":8,"sa":"oc","e":[{"n":"PostgreSQL","tp":"tool"}]}"#;
894        assert_eq!(json, expected);
895    }
896
897    #[test]
898    fn test_typical_claim_byte_size() {
899        // Spec §14d / §15.7 targets ~90 bytes metadata overhead (text excluded).
900        // Build a claim with exactly 120-byte text and verify metadata overhead stays near target.
901        let text = "a".repeat(120);
902        let c = Claim {
903            text: text.clone(),
904            category: ClaimCategory::Preference,
905            confidence: 0.9,
906            importance: 8,
907            corroboration_count: 1,
908            source_agent: "oc".to_string(),
909            source_conversation: None,
910            extracted_at: None,
911            entities: vec![EntityRef {
912                name: "PostgreSQL".to_string(),
913                entity_type: EntityType::Tool,
914                role: None,
915            }],
916            supersedes: None,
917            superseded_by: None,
918            valid_from: None,
919            status: ClaimStatus::Active,
920        };
921        let json = serde_json::to_string(&c).unwrap();
922        let metadata_overhead = json.len() - text.len();
923        assert!(
924            metadata_overhead <= 95,
925            "metadata overhead should be <=95 bytes, got {}: {}",
926            metadata_overhead,
927            json
928        );
929        // Total blob for this claim should comfortably stay under 220 bytes.
930        assert!(
931            json.len() <= 220,
932            "total claim JSON should be <=220 bytes, got {}: {}",
933            json.len(),
934            json
935        );
936    }
937
938    #[test]
939    fn test_deserialize_with_missing_defaults() {
940        // A minimal compact blob with no status/cc/options should parse cleanly.
941        let json = r#"{"t":"hi","c":"fact","cf":0.9,"i":5,"sa":"oc"}"#;
942        let c: Claim = serde_json::from_str(json).unwrap();
943        assert_eq!(c.status, ClaimStatus::Active);
944        assert_eq!(c.corroboration_count, 1);
945        assert!(c.entities.is_empty());
946        assert!(c.extracted_at.is_none());
947    }
948
949    // === Entity normalization ===
950
951    #[test]
952    fn test_normalize_simple_lowercase() {
953        assert_eq!(normalize_entity_name("PostgreSQL"), "postgresql");
954    }
955
956    #[test]
957    fn test_normalize_collapse_and_trim() {
958        assert_eq!(normalize_entity_name("  Node  JS  "), "node js");
959    }
960
961    #[test]
962    fn test_normalize_preserves_punctuation() {
963        assert_eq!(normalize_entity_name("Node.js"), "node.js");
964    }
965
966    #[test]
967    fn test_normalize_empty() {
968        assert_eq!(normalize_entity_name(""), "");
969    }
970
971    #[test]
972    fn test_normalize_whitespace_only() {
973        assert_eq!(normalize_entity_name("   \t  "), "");
974    }
975
976    #[test]
977    fn test_normalize_nfc_idempotent_on_precomposed() {
978        // Precomposed é (U+00E9)
979        assert_eq!(normalize_entity_name("José"), "josé");
980    }
981
982    #[test]
983    fn test_normalize_nfc_merges_combining() {
984        // NFD: 'e' + combining acute U+0301 -> precomposed é after NFC
985        let nfd = "Jose\u{0301}";
986        let nfc = "josé";
987        assert_eq!(normalize_entity_name(nfd), nfc);
988    }
989
990    #[test]
991    fn test_normalize_unicode_combining_same_id() {
992        // "PostgréSQL" with precomposed vs combining should yield same normalized and same ID
993        let a = "Postgre\u{0301}SQL"; // NFD
994        let b = "PostgréSQL"; // NFC precomposed
995        assert_eq!(normalize_entity_name(a), normalize_entity_name(b));
996        assert_eq!(deterministic_entity_id(a), deterministic_entity_id(b));
997    }
998
999    #[test]
1000    fn test_normalize_internal_multispace() {
1001        assert_eq!(normalize_entity_name("Foo\t\n Bar"), "foo bar");
1002    }
1003
1004    // === Deterministic entity ID ===
1005
1006    #[test]
1007    fn test_entity_id_case_insensitive() {
1008        let a = deterministic_entity_id("Pedro");
1009        let b = deterministic_entity_id("pedro");
1010        let c = deterministic_entity_id("  PEDRO  ");
1011        assert_eq!(a, b);
1012        assert_eq!(b, c);
1013    }
1014
1015    #[test]
1016    fn test_entity_id_different_names_differ() {
1017        let a = deterministic_entity_id("Pedro");
1018        let b = deterministic_entity_id("Sarah");
1019        assert_ne!(a, b);
1020    }
1021
1022    #[test]
1023    fn test_entity_id_format() {
1024        let id = deterministic_entity_id("anything");
1025        assert_eq!(id.len(), 16);
1026        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1027    }
1028
1029    #[test]
1030    fn test_entity_id_known_answer_pedro() {
1031        // Known answer for cross-language parity. SHA256("pedro")[..8] as hex.
1032        // Locked in as the canonical value — parity tests in TS/Python must match.
1033        let id = deterministic_entity_id("pedro");
1034        assert_eq!(id, "ee5cd7d5d96c8874");
1035    }
1036
1037    #[test]
1038    fn test_entity_id_known_answer_postgresql() {
1039        let id = deterministic_entity_id("PostgreSQL");
1040        // normalized -> "postgresql"
1041        let again = deterministic_entity_id("postgresql");
1042        assert_eq!(id, again);
1043    }
1044
1045    // === Legacy parser ===
1046
1047    #[test]
1048    fn test_parse_full_claim_json() {
1049        let c = full_claim();
1050        let json = serde_json::to_string(&c).unwrap();
1051        let parsed = parse_claim_or_legacy(&json);
1052        assert_eq!(parsed, c);
1053    }
1054
1055    #[test]
1056    fn test_parse_legacy_object_format() {
1057        let json = r#"{"t":"hello","a":"oc","s":"extract"}"#;
1058        let parsed = parse_claim_or_legacy(json);
1059        assert_eq!(parsed.text, "hello");
1060        assert_eq!(parsed.source_agent, "oc");
1061        assert_eq!(parsed.category, ClaimCategory::Fact);
1062        assert_eq!(parsed.confidence, 0.7);
1063        assert_eq!(parsed.importance, 5);
1064        assert_eq!(parsed.corroboration_count, 1);
1065        assert_eq!(parsed.status, ClaimStatus::Active);
1066        assert!(parsed.entities.is_empty());
1067        assert!(parsed.extracted_at.is_none());
1068    }
1069
1070    #[test]
1071    fn test_parse_legacy_string_format() {
1072        let json = r#""just text""#;
1073        let parsed = parse_claim_or_legacy(json);
1074        assert_eq!(parsed.text, "just text");
1075        assert_eq!(parsed.source_agent, "unknown");
1076        assert_eq!(parsed.category, ClaimCategory::Fact);
1077    }
1078
1079    #[test]
1080    fn test_parse_legacy_raw_text() {
1081        // Not JSON at all
1082        let parsed = parse_claim_or_legacy("hello world");
1083        assert_eq!(parsed.text, "hello world");
1084        assert_eq!(parsed.source_agent, "unknown");
1085    }
1086
1087    #[test]
1088    fn test_parse_legacy_malformed_json() {
1089        // Looks like JSON, isn't
1090        let parsed = parse_claim_or_legacy("{not valid json");
1091        assert_eq!(parsed.text, "{not valid json");
1092        assert_eq!(parsed.source_agent, "unknown");
1093    }
1094
1095    #[test]
1096    fn test_parse_legacy_missing_text() {
1097        // Legacy object with no text-like field falls back to the raw blob as text
1098        let json = r#"{"a":"oc"}"#;
1099        let parsed = parse_claim_or_legacy(json);
1100        assert_eq!(parsed.text, json);
1101        assert_eq!(parsed.source_agent, "oc");
1102    }
1103
1104    #[test]
1105    fn test_parse_plugin_legacy_doc_format() {
1106        // The OpenClaw plugin previously wrote blobs as {text, metadata}.
1107        // Upgrading users must still read their old facts correctly.
1108        let json = r#"{"text":"prefers PostgreSQL","metadata":{"type":"preference","importance":0.9,"source":"auto-extraction","created_at":"2026-03-01T00:00:00Z"}}"#;
1109        let parsed = parse_claim_or_legacy(json);
1110        assert_eq!(parsed.text, "prefers PostgreSQL");
1111        assert_eq!(parsed.source_agent, "auto-extraction");
1112        assert_eq!(parsed.category, ClaimCategory::Fact);
1113        assert_eq!(parsed.status, ClaimStatus::Active);
1114    }
1115
1116    #[test]
1117    fn test_parse_plugin_legacy_doc_without_metadata_source() {
1118        let json = r#"{"text":"lives in Lisbon"}"#;
1119        let parsed = parse_claim_or_legacy(json);
1120        assert_eq!(parsed.text, "lives in Lisbon");
1121        assert_eq!(parsed.source_agent, "unknown");
1122    }
1123
1124    #[test]
1125    fn test_legacy_round_trip_via_claim() {
1126        // Parse a legacy blob, re-serialize as a full claim, re-parse, must be equal.
1127        let parsed1 = parse_claim_or_legacy(r#"{"t":"hello","a":"oc","s":"extract"}"#);
1128        let json = serde_json::to_string(&parsed1).unwrap();
1129        let parsed2 = parse_claim_or_legacy(&json);
1130        assert_eq!(parsed1, parsed2);
1131    }
1132
1133    #[test]
1134    fn test_parse_never_panics_on_random_input() {
1135        for s in ["", "   ", "null", "[1,2,3]", "42", "true", "\"\""] {
1136            let _ = parse_claim_or_legacy(s);
1137        }
1138    }
1139
1140    #[test]
1141    fn test_claim_category_default_status_omitted_in_serialization() {
1142        // Sanity: build a claim with status Active and verify status absent
1143        let c = minimal_claim();
1144        let json = serde_json::to_string(&c).unwrap();
1145        assert!(!json.contains("\"st\":"));
1146    }
1147
1148    #[test]
1149    fn test_non_default_status_serialized() {
1150        let mut c = minimal_claim();
1151        c.status = ClaimStatus::Superseded;
1152        let json = serde_json::to_string(&c).unwrap();
1153        assert!(json.contains("\"st\":\"s\""));
1154    }
1155
1156    #[test]
1157    fn test_non_default_corroboration_serialized() {
1158        let mut c = minimal_claim();
1159        c.corroboration_count = 5;
1160        let json = serde_json::to_string(&c).unwrap();
1161        assert!(json.contains("\"cc\":5"));
1162    }
1163
1164    // === Pin status semantics ===
1165
1166    #[test]
1167    fn test_is_pinned_claim_true_for_pinned() {
1168        let mut c = minimal_claim();
1169        c.status = ClaimStatus::Pinned;
1170        assert!(is_pinned_claim(&c));
1171    }
1172
1173    #[test]
1174    fn test_is_pinned_claim_false_for_active() {
1175        let c = minimal_claim();
1176        assert!(!is_pinned_claim(&c));
1177    }
1178
1179    #[test]
1180    fn test_is_pinned_claim_false_for_superseded() {
1181        let mut c = minimal_claim();
1182        c.status = ClaimStatus::Superseded;
1183        assert!(!is_pinned_claim(&c));
1184    }
1185
1186    #[test]
1187    fn test_is_pinned_json_valid_pinned() {
1188        let mut c = minimal_claim();
1189        c.status = ClaimStatus::Pinned;
1190        let json = serde_json::to_string(&c).unwrap();
1191        assert!(is_pinned_json(&json));
1192    }
1193
1194    #[test]
1195    fn test_is_pinned_json_valid_active() {
1196        let c = minimal_claim();
1197        let json = serde_json::to_string(&c).unwrap();
1198        assert!(!is_pinned_json(&json));
1199    }
1200
1201    #[test]
1202    fn test_is_pinned_json_invalid_json() {
1203        assert!(!is_pinned_json("not json at all"));
1204    }
1205
1206    #[test]
1207    fn test_is_pinned_json_missing_status_field() {
1208        // Minimal JSON without status -> defaults to Active
1209        let json = r#"{"t":"hi","c":"fact","cf":0.9,"i":5,"sa":"oc"}"#;
1210        assert!(!is_pinned_json(json));
1211    }
1212
1213    #[test]
1214    fn test_is_pinned_json_empty_string() {
1215        assert!(!is_pinned_json(""));
1216    }
1217
1218    // === ResolutionAction / respect_pin_in_resolution ===
1219
1220    #[test]
1221    fn test_respect_pin_pinned_existing_returns_skip() {
1222        let mut c = minimal_claim();
1223        c.status = ClaimStatus::Pinned;
1224        let json = serde_json::to_string(&c).unwrap();
1225        let action = respect_pin_in_resolution(
1226            &json,
1227            "new_id",
1228            "existing_id",
1229            "new_id",
1230            0.5,
1231            0.7,
1232            TIE_ZONE_SCORE_TOLERANCE,
1233        );
1234        assert_eq!(
1235            action,
1236            ResolutionAction::SkipNew {
1237                reason: SkipReason::ExistingPinned,
1238                existing_id: "existing_id".to_string(),
1239                new_id: "new_id".to_string(),
1240                entity_id: None,
1241                similarity: None,
1242                winner_score: None,
1243                loser_score: None,
1244                winner_components: None,
1245                loser_components: None,
1246            }
1247        );
1248    }
1249
1250    #[test]
1251    fn test_respect_pin_existing_wins_returns_skip() {
1252        let c = minimal_claim();
1253        let json = serde_json::to_string(&c).unwrap();
1254        let action = respect_pin_in_resolution(
1255            &json,
1256            "new_id",
1257            "existing_id",
1258            "existing_id",
1259            0.5,
1260            0.7,
1261            TIE_ZONE_SCORE_TOLERANCE,
1262        );
1263        assert_eq!(
1264            action,
1265            ResolutionAction::SkipNew {
1266                reason: SkipReason::ExistingWins,
1267                existing_id: "existing_id".to_string(),
1268                new_id: "new_id".to_string(),
1269                entity_id: None,
1270                similarity: None,
1271                winner_score: None,
1272                loser_score: None,
1273                winner_components: None,
1274                loser_components: None,
1275            }
1276        );
1277    }
1278
1279    #[test]
1280    fn test_respect_pin_tie_zone_returns_tie() {
1281        let c = minimal_claim();
1282        let json = serde_json::to_string(&c).unwrap();
1283        let action = respect_pin_in_resolution(
1284            &json,
1285            "new_id",
1286            "existing_id",
1287            "new_id",
1288            0.005,
1289            0.7,
1290            TIE_ZONE_SCORE_TOLERANCE,
1291        );
1292        match &action {
1293            ResolutionAction::TieLeaveBoth { score_gap, .. } => {
1294                assert!(score_gap.abs() < TIE_ZONE_SCORE_TOLERANCE);
1295            }
1296            _ => panic!("expected TieLeaveBoth, got {:?}", action),
1297        }
1298    }
1299
1300    #[test]
1301    fn test_respect_pin_clear_win_returns_supersede() {
1302        let c = minimal_claim();
1303        let json = serde_json::to_string(&c).unwrap();
1304        let action = respect_pin_in_resolution(
1305            &json,
1306            "new_id",
1307            "existing_id",
1308            "new_id",
1309            0.15,
1310            0.7,
1311            TIE_ZONE_SCORE_TOLERANCE,
1312        );
1313        match &action {
1314            ResolutionAction::SupersedeExisting { score_gap, .. } => {
1315                assert!(*score_gap > TIE_ZONE_SCORE_TOLERANCE);
1316            }
1317            _ => panic!("expected SupersedeExisting, got {:?}", action),
1318        }
1319    }
1320
1321    #[test]
1322    fn test_resolution_action_serde_round_trip() {
1323        let action = ResolutionAction::SupersedeExisting {
1324            existing_id: "ex".to_string(),
1325            new_id: "nw".to_string(),
1326            similarity: 0.7,
1327            score_gap: 0.15,
1328            entity_id: None,
1329            winner_score: None,
1330            loser_score: None,
1331            winner_components: None,
1332            loser_components: None,
1333        };
1334        let json = serde_json::to_string(&action).unwrap();
1335        let back: ResolutionAction = serde_json::from_str(&json).unwrap();
1336        assert_eq!(action, back);
1337    }
1338
1339    #[test]
1340    fn test_skip_reason_serde() {
1341        let pairs = [
1342            (SkipReason::ExistingPinned, "\"existing_pinned\""),
1343            (SkipReason::ExistingWins, "\"existing_wins\""),
1344            (SkipReason::BelowThreshold, "\"below_threshold\""),
1345        ];
1346        for (reason, expected) in pairs {
1347            let json = serde_json::to_string(&reason).unwrap();
1348            assert_eq!(json, expected);
1349        }
1350    }
1351
1352    // === Memory Taxonomy v1 — enum serde & from_str_lossy ===
1353
1354    #[test]
1355    fn test_memory_type_v1_serde_round_trip() {
1356        let pairs = [
1357            (MemoryTypeV1::Claim, "\"claim\""),
1358            (MemoryTypeV1::Preference, "\"preference\""),
1359            (MemoryTypeV1::Directive, "\"directive\""),
1360            (MemoryTypeV1::Commitment, "\"commitment\""),
1361            (MemoryTypeV1::Episode, "\"episode\""),
1362            (MemoryTypeV1::Summary, "\"summary\""),
1363        ];
1364        for (variant, expected) in pairs {
1365            let json = serde_json::to_string(&variant).unwrap();
1366            assert_eq!(json, expected);
1367            let back: MemoryTypeV1 = serde_json::from_str(&json).unwrap();
1368            assert_eq!(variant, back);
1369        }
1370    }
1371
1372    #[test]
1373    fn test_memory_source_serde_round_trip() {
1374        let pairs = [
1375            (MemorySource::User, "\"user\""),
1376            (MemorySource::UserInferred, "\"user-inferred\""),
1377            (MemorySource::Assistant, "\"assistant\""),
1378            (MemorySource::External, "\"external\""),
1379            (MemorySource::Derived, "\"derived\""),
1380        ];
1381        for (variant, expected) in pairs {
1382            let json = serde_json::to_string(&variant).unwrap();
1383            assert_eq!(json, expected);
1384            let back: MemorySource = serde_json::from_str(&json).unwrap();
1385            assert_eq!(variant, back);
1386        }
1387    }
1388
1389    #[test]
1390    fn test_memory_scope_serde_round_trip() {
1391        let pairs = [
1392            (MemoryScope::Work, "\"work\""),
1393            (MemoryScope::Personal, "\"personal\""),
1394            (MemoryScope::Health, "\"health\""),
1395            (MemoryScope::Family, "\"family\""),
1396            (MemoryScope::Creative, "\"creative\""),
1397            (MemoryScope::Finance, "\"finance\""),
1398            (MemoryScope::Misc, "\"misc\""),
1399            (MemoryScope::Unspecified, "\"unspecified\""),
1400        ];
1401        for (variant, expected) in pairs {
1402            let json = serde_json::to_string(&variant).unwrap();
1403            assert_eq!(json, expected);
1404            let back: MemoryScope = serde_json::from_str(&json).unwrap();
1405            assert_eq!(variant, back);
1406        }
1407    }
1408
1409    #[test]
1410    fn test_memory_volatility_serde_round_trip() {
1411        let pairs = [
1412            (MemoryVolatility::Stable, "\"stable\""),
1413            (MemoryVolatility::Updatable, "\"updatable\""),
1414            (MemoryVolatility::Ephemeral, "\"ephemeral\""),
1415        ];
1416        for (variant, expected) in pairs {
1417            let json = serde_json::to_string(&variant).unwrap();
1418            assert_eq!(json, expected);
1419            let back: MemoryVolatility = serde_json::from_str(&json).unwrap();
1420            assert_eq!(variant, back);
1421        }
1422    }
1423
1424    #[test]
1425    fn test_memory_type_v1_from_str_lossy_known() {
1426        assert_eq!(MemoryTypeV1::from_str_lossy("claim"), MemoryTypeV1::Claim);
1427        assert_eq!(
1428            MemoryTypeV1::from_str_lossy("preference"),
1429            MemoryTypeV1::Preference
1430        );
1431        assert_eq!(
1432            MemoryTypeV1::from_str_lossy("directive"),
1433            MemoryTypeV1::Directive
1434        );
1435        assert_eq!(
1436            MemoryTypeV1::from_str_lossy("commitment"),
1437            MemoryTypeV1::Commitment
1438        );
1439        assert_eq!(
1440            MemoryTypeV1::from_str_lossy("episode"),
1441            MemoryTypeV1::Episode
1442        );
1443        assert_eq!(
1444            MemoryTypeV1::from_str_lossy("summary"),
1445            MemoryTypeV1::Summary
1446        );
1447    }
1448
1449    #[test]
1450    fn test_memory_type_v1_from_str_lossy_mixed_case() {
1451        assert_eq!(MemoryTypeV1::from_str_lossy("CLAIM"), MemoryTypeV1::Claim);
1452        assert_eq!(
1453            MemoryTypeV1::from_str_lossy("Preference"),
1454            MemoryTypeV1::Preference
1455        );
1456        assert_eq!(
1457            MemoryTypeV1::from_str_lossy("  directive  "),
1458            MemoryTypeV1::Directive
1459        );
1460    }
1461
1462    #[test]
1463    fn test_memory_type_v1_from_str_lossy_unknown_defaults_to_claim() {
1464        assert_eq!(
1465            MemoryTypeV1::from_str_lossy("nonsense"),
1466            MemoryTypeV1::Claim
1467        );
1468        assert_eq!(MemoryTypeV1::from_str_lossy(""), MemoryTypeV1::Claim);
1469        // Legacy v0 tokens must also fall through to Claim — they're handled
1470        // by the dedicated normalize_legacy_to_v1 adapter (not implemented
1471        // here), not by from_str_lossy.
1472        assert_eq!(MemoryTypeV1::from_str_lossy("fact"), MemoryTypeV1::Claim);
1473        assert_eq!(MemoryTypeV1::from_str_lossy("rule"), MemoryTypeV1::Claim);
1474    }
1475
1476    #[test]
1477    fn test_memory_source_from_str_lossy_known() {
1478        assert_eq!(MemorySource::from_str_lossy("user"), MemorySource::User);
1479        assert_eq!(
1480            MemorySource::from_str_lossy("user-inferred"),
1481            MemorySource::UserInferred
1482        );
1483        assert_eq!(
1484            MemorySource::from_str_lossy("assistant"),
1485            MemorySource::Assistant
1486        );
1487        assert_eq!(
1488            MemorySource::from_str_lossy("external"),
1489            MemorySource::External
1490        );
1491        assert_eq!(
1492            MemorySource::from_str_lossy("derived"),
1493            MemorySource::Derived
1494        );
1495    }
1496
1497    #[test]
1498    fn test_memory_source_from_str_lossy_underscore_variant() {
1499        // Some clients may serialize as user_inferred instead of user-inferred.
1500        assert_eq!(
1501            MemorySource::from_str_lossy("user_inferred"),
1502            MemorySource::UserInferred
1503        );
1504        assert_eq!(
1505            MemorySource::from_str_lossy("USER_INFERRED"),
1506            MemorySource::UserInferred
1507        );
1508    }
1509
1510    #[test]
1511    fn test_memory_source_from_str_lossy_unknown_defaults_to_user_inferred() {
1512        // Policy: unknown sources fall back to user-inferred (moderate weight).
1513        assert_eq!(
1514            MemorySource::from_str_lossy("bot"),
1515            MemorySource::UserInferred
1516        );
1517        assert_eq!(MemorySource::from_str_lossy(""), MemorySource::UserInferred);
1518    }
1519
1520    #[test]
1521    fn test_memory_scope_from_str_lossy_known_and_unknown() {
1522        assert_eq!(MemoryScope::from_str_lossy("work"), MemoryScope::Work);
1523        assert_eq!(
1524            MemoryScope::from_str_lossy("UNSPECIFIED"),
1525            MemoryScope::Unspecified
1526        );
1527        // Unknown scope values (the enum is open-extensible) coerce to Unspecified.
1528        assert_eq!(
1529            MemoryScope::from_str_lossy("gaming"),
1530            MemoryScope::Unspecified
1531        );
1532        assert_eq!(MemoryScope::from_str_lossy(""), MemoryScope::Unspecified);
1533    }
1534
1535    #[test]
1536    fn test_memory_volatility_from_str_lossy_known_and_unknown() {
1537        assert_eq!(
1538            MemoryVolatility::from_str_lossy("stable"),
1539            MemoryVolatility::Stable
1540        );
1541        assert_eq!(
1542            MemoryVolatility::from_str_lossy("EPHEMERAL"),
1543            MemoryVolatility::Ephemeral
1544        );
1545        // Unknown -> default Updatable.
1546        assert_eq!(
1547            MemoryVolatility::from_str_lossy("permanent"),
1548            MemoryVolatility::Updatable
1549        );
1550        assert_eq!(
1551            MemoryVolatility::from_str_lossy(""),
1552            MemoryVolatility::Updatable
1553        );
1554    }
1555
1556    // === MemoryClaimV1 struct round-trip & defaults ===
1557
1558    fn minimal_v1_claim() -> MemoryClaimV1 {
1559        MemoryClaimV1 {
1560            id: "01900000-0000-7000-8000-000000000000".to_string(),
1561            text: "prefers PostgreSQL".to_string(),
1562            memory_type: MemoryTypeV1::Preference,
1563            source: MemorySource::User,
1564            created_at: "2026-04-17T10:00:00Z".to_string(),
1565            schema_version: MEMORY_CLAIM_V1_SCHEMA_VERSION.to_string(),
1566            scope: MemoryScope::Unspecified,
1567            volatility: MemoryVolatility::Updatable,
1568            entities: Vec::new(),
1569            reasoning: None,
1570            expires_at: None,
1571            importance: None,
1572            confidence: None,
1573            superseded_by: None,
1574        }
1575    }
1576
1577    fn full_v1_claim() -> MemoryClaimV1 {
1578        MemoryClaimV1 {
1579            id: "01900000-0000-7000-8000-000000000001".to_string(),
1580            text: "Chose PostgreSQL for the analytics store".to_string(),
1581            memory_type: MemoryTypeV1::Claim,
1582            source: MemorySource::UserInferred,
1583            created_at: "2026-04-17T10:00:00Z".to_string(),
1584            schema_version: MEMORY_CLAIM_V1_SCHEMA_VERSION.to_string(),
1585            scope: MemoryScope::Work,
1586            volatility: MemoryVolatility::Stable,
1587            entities: vec![MemoryEntityV1 {
1588                name: "PostgreSQL".to_string(),
1589                entity_type: EntityType::Tool,
1590                role: Some("chosen".to_string()),
1591            }],
1592            reasoning: Some("data is relational and needs ACID".to_string()),
1593            expires_at: None,
1594            importance: Some(8),
1595            confidence: Some(0.92),
1596            superseded_by: None,
1597        }
1598    }
1599
1600    #[test]
1601    fn test_memory_claim_v1_minimal_round_trip() {
1602        let c = minimal_v1_claim();
1603        let json = serde_json::to_string(&c).unwrap();
1604        let back: MemoryClaimV1 = serde_json::from_str(&json).unwrap();
1605        assert_eq!(c, back);
1606    }
1607
1608    #[test]
1609    fn test_memory_claim_v1_full_round_trip() {
1610        let c = full_v1_claim();
1611        let json = serde_json::to_string(&c).unwrap();
1612        let back: MemoryClaimV1 = serde_json::from_str(&json).unwrap();
1613        assert_eq!(c, back);
1614    }
1615
1616    #[test]
1617    fn test_memory_claim_v1_minimal_omits_defaults() {
1618        let c = minimal_v1_claim();
1619        let json = serde_json::to_string(&c).unwrap();
1620        // Schema version at default ("1.0") MUST still be serialized explicitly? Spec says required.
1621        // Our serializer omits when equal to default to keep blobs tiny, but deserialization
1622        // must ALWAYS provide it via default. Confirm absence here, presence of default on parse below.
1623        assert!(!json.contains("schema_version"));
1624        // scope=Unspecified -> omitted
1625        assert!(!json.contains("scope"));
1626        // volatility=Updatable -> omitted
1627        assert!(!json.contains("volatility"));
1628        // None options omitted
1629        assert!(!json.contains("reasoning"));
1630        assert!(!json.contains("expires_at"));
1631        assert!(!json.contains("importance"));
1632        assert!(!json.contains("confidence"));
1633        assert!(!json.contains("superseded_by"));
1634        // Entity list empty -> omitted
1635        assert!(!json.contains("entities"));
1636    }
1637
1638    #[test]
1639    fn test_memory_claim_v1_deserialize_fills_defaults() {
1640        // Minimal JSON with only required fields (+no schema_version) must
1641        // parse and surface the spec-defined defaults.
1642        let json = r#"{
1643            "id":"01900000-0000-7000-8000-000000000000",
1644            "text":"prefers PostgreSQL",
1645            "type":"preference",
1646            "source":"user",
1647            "created_at":"2026-04-17T10:00:00Z"
1648        }"#;
1649        let c: MemoryClaimV1 = serde_json::from_str(json).unwrap();
1650        assert_eq!(c.schema_version, MEMORY_CLAIM_V1_SCHEMA_VERSION);
1651        assert_eq!(c.scope, MemoryScope::Unspecified);
1652        assert_eq!(c.volatility, MemoryVolatility::Updatable);
1653        assert!(c.entities.is_empty());
1654        assert!(c.reasoning.is_none());
1655        assert!(c.expires_at.is_none());
1656        assert!(c.importance.is_none());
1657        assert!(c.confidence.is_none());
1658        assert!(c.superseded_by.is_none());
1659    }
1660
1661    #[test]
1662    fn test_memory_claim_v1_full_keeps_non_default_fields() {
1663        let c = full_v1_claim();
1664        let json = serde_json::to_string(&c).unwrap();
1665        assert!(json.contains("\"scope\":\"work\""));
1666        assert!(json.contains("\"volatility\":\"stable\""));
1667        assert!(json.contains("\"reasoning\":"));
1668        assert!(json.contains("\"importance\":8"));
1669        assert!(json.contains("\"confidence\":0.92"));
1670        assert!(json.contains("\"entities\":"));
1671        assert!(json.contains("\"type\":\"claim\""));
1672        assert!(json.contains("\"source\":\"user-inferred\""));
1673    }
1674
1675    #[test]
1676    fn test_memory_claim_v1_reference_exact_bytes() {
1677        // Byte-level canonical — locks TS/Python parity.
1678        let c = MemoryClaimV1 {
1679            id: "01900000-0000-7000-8000-000000000000".to_string(),
1680            text: "prefers PostgreSQL".to_string(),
1681            memory_type: MemoryTypeV1::Preference,
1682            source: MemorySource::User,
1683            created_at: "2026-04-17T10:00:00Z".to_string(),
1684            schema_version: MEMORY_CLAIM_V1_SCHEMA_VERSION.to_string(),
1685            scope: MemoryScope::Unspecified,
1686            volatility: MemoryVolatility::Updatable,
1687            entities: Vec::new(),
1688            reasoning: None,
1689            expires_at: None,
1690            importance: None,
1691            confidence: None,
1692            superseded_by: None,
1693        };
1694        let json = serde_json::to_string(&c).unwrap();
1695        let expected = r#"{"id":"01900000-0000-7000-8000-000000000000","text":"prefers PostgreSQL","type":"preference","source":"user","created_at":"2026-04-17T10:00:00Z"}"#;
1696        assert_eq!(json, expected);
1697    }
1698
1699    #[test]
1700    fn test_memory_claim_v1_rejects_wrong_type_token() {
1701        // Legacy v0 token "fact" is invalid for v1's closed type enum.
1702        let json = r#"{
1703            "id":"01900000-0000-7000-8000-000000000000",
1704            "text":"hi",
1705            "type":"fact",
1706            "source":"user",
1707            "created_at":"2026-04-17T10:00:00Z"
1708        }"#;
1709        let result: std::result::Result<MemoryClaimV1, _> = serde_json::from_str(json);
1710        assert!(result.is_err(), "v1 must reject legacy token 'fact'");
1711    }
1712
1713    #[test]
1714    fn test_memory_claim_v1_schema_version_preserved_if_non_default() {
1715        // If a client serializes schema_version explicitly (e.g. "1.0" with
1716        // future "1.1" coming), we must preserve it verbatim on round-trip.
1717        let json = r#"{
1718            "id":"01900000-0000-7000-8000-000000000000",
1719            "text":"hi",
1720            "type":"claim",
1721            "source":"user",
1722            "created_at":"2026-04-17T10:00:00Z",
1723            "schema_version":"1.0"
1724        }"#;
1725        let c: MemoryClaimV1 = serde_json::from_str(json).unwrap();
1726        assert_eq!(c.schema_version, "1.0");
1727    }
1728}