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