1use 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 #[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
144pub const TIE_ZONE_SCORE_TOLERANCE: f64 = 0.01;
150
151pub fn is_pinned_claim(claim: &Claim) -> bool {
153 matches!(claim.status, ClaimStatus::Pinned)
154}
155
156pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum ResolutionAction {
171 NoContradiction,
173 SupersedeExisting {
175 existing_id: String,
176 new_id: String,
177 similarity: f64,
178 score_gap: f64,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
181 entity_id: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none", default)]
184 winner_score: Option<f64>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
187 loser_score: Option<f64>,
188 #[serde(skip_serializing_if = "Option::is_none", default)]
190 winner_components: Option<crate::contradiction::ScoreComponents>,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
193 loser_components: Option<crate::contradiction::ScoreComponents>,
194 },
195 SkipNew {
197 reason: SkipReason,
198 existing_id: String,
199 new_id: String,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
202 entity_id: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
205 similarity: Option<f64>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
208 winner_score: Option<f64>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
211 loser_score: Option<f64>,
212 #[serde(skip_serializing_if = "Option::is_none", default)]
214 winner_components: Option<crate::contradiction::ScoreComponents>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
217 loser_components: Option<crate::contradiction::ScoreComponents>,
218 },
219 TieLeaveBoth {
221 existing_id: String,
222 new_id: String,
223 similarity: f64,
224 score_gap: f64,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
227 entity_id: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none", default)]
230 winner_score: Option<f64>,
231 #[serde(skip_serializing_if = "Option::is_none", default)]
233 loser_score: Option<f64>,
234 #[serde(skip_serializing_if = "Option::is_none", default)]
236 winner_components: Option<crate::contradiction::ScoreComponents>,
237 #[serde(skip_serializing_if = "Option::is_none", default)]
239 loser_components: Option<crate::contradiction::ScoreComponents>,
240 },
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum SkipReason {
247 ExistingPinned,
249 ExistingWins,
251 BelowThreshold,
253}
254
255pub 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 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 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 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 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
330pub 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
353pub 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
361pub 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
426pub const MEMORY_CLAIM_V1_SCHEMA_VERSION: &str = "1.0";
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
445#[serde(rename_all = "lowercase")]
446pub enum MemoryTypeV1 {
447 Claim,
450 Preference,
452 Directive,
455 Commitment,
457 Episode,
459 Summary,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
470#[serde(rename_all = "kebab-case")]
471pub enum MemorySource {
472 User,
474 UserInferred,
476 Assistant,
478 External,
480 Derived,
482}
483
484#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
503#[serde(rename_all = "lowercase")]
504pub enum MemoryVolatility {
505 Stable,
507 Updatable,
509 Ephemeral,
511}
512
513pub type MemoryEntityType = EntityType;
517
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
520pub struct MemoryEntityV1 {
521 pub name: String,
523 #[serde(rename = "type")]
524 pub entity_type: MemoryEntityType,
525 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
566pub struct MemoryClaimV1 {
567 pub id: String,
570 pub text: String,
572 #[serde(rename = "type")]
573 pub memory_type: MemoryTypeV1,
574 pub source: MemorySource,
575 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 #[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 #[serde(default, skip_serializing_if = "is_empty_entities_v1")]
597 pub entities: Vec<MemoryEntityV1>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub reasoning: Option<String>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub expires_at: Option<String>,
606
607 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub importance: Option<u8>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub confidence: Option<f64>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub superseded_by: Option<String>,
617}
618
619impl MemoryTypeV1 {
620 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 pub fn from_str_lossy(s: &str) -> Self {
646 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 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 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 #[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 assert!(
779 !json.contains("\"st\""),
780 "status should be omitted when Active: {}",
781 json
782 );
783 assert!(
785 !json.contains("\"cc\""),
786 "corroboration_count should be omitted when 1: {}",
787 json
788 );
789 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 assert!(json.contains("\"n\":\"PostgreSQL\""));
809 assert!(json.contains("\"tp\":\"tool\""));
810 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 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 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 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 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 #[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 assert_eq!(normalize_entity_name("José"), "josé");
980 }
981
982 #[test]
983 fn test_normalize_nfc_merges_combining() {
984 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 let a = "Postgre\u{0301}SQL"; let b = "PostgréSQL"; 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 #[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 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 let again = deterministic_entity_id("postgresql");
1042 assert_eq!(id, again);
1043 }
1044
1045 #[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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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 assert!(!json.contains("schema_version"));
1624 assert!(!json.contains("scope"));
1626 assert!(!json.contains("volatility"));
1628 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 assert!(!json.contains("entities"));
1636 }
1637
1638 #[test]
1639 fn test_memory_claim_v1_deserialize_fills_defaults() {
1640 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 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 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 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}