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 {
157 matches!(claim.status, ClaimStatus::Pinned)
158}
159
160pub fn is_pinned_memory_claim_v1(claim: &MemoryClaimV1) -> bool {
165 matches!(claim.pin_status, Some(PinStatus::Pinned))
166}
167
168pub fn is_pinned_json(claim_json: &str) -> bool {
182 if let Ok(v1) = serde_json::from_str::<MemoryClaimV1>(claim_json) {
185 if is_pinned_memory_claim_v1(&v1) {
186 return true;
187 }
188 return false;
191 }
192 match serde_json::from_str::<Claim>(claim_json) {
194 Ok(claim) => is_pinned_claim(&claim),
195 Err(_) => false,
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(tag = "type", rename_all = "snake_case")]
203pub enum ResolutionAction {
204 NoContradiction,
206 SupersedeExisting {
208 existing_id: String,
209 new_id: String,
210 similarity: f64,
211 score_gap: f64,
212 #[serde(skip_serializing_if = "Option::is_none", default)]
214 entity_id: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
217 winner_score: Option<f64>,
218 #[serde(skip_serializing_if = "Option::is_none", default)]
220 loser_score: Option<f64>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
223 winner_components: Option<crate::contradiction::ScoreComponents>,
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 loser_components: Option<crate::contradiction::ScoreComponents>,
227 },
228 SkipNew {
230 reason: SkipReason,
231 existing_id: String,
232 new_id: String,
233 #[serde(skip_serializing_if = "Option::is_none", default)]
235 entity_id: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none", default)]
238 similarity: Option<f64>,
239 #[serde(skip_serializing_if = "Option::is_none", default)]
241 winner_score: Option<f64>,
242 #[serde(skip_serializing_if = "Option::is_none", default)]
244 loser_score: Option<f64>,
245 #[serde(skip_serializing_if = "Option::is_none", default)]
247 winner_components: Option<crate::contradiction::ScoreComponents>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
250 loser_components: Option<crate::contradiction::ScoreComponents>,
251 },
252 TieLeaveBoth {
254 existing_id: String,
255 new_id: String,
256 similarity: f64,
257 score_gap: f64,
258 #[serde(skip_serializing_if = "Option::is_none", default)]
260 entity_id: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none", default)]
263 winner_score: Option<f64>,
264 #[serde(skip_serializing_if = "Option::is_none", default)]
266 loser_score: Option<f64>,
267 #[serde(skip_serializing_if = "Option::is_none", default)]
269 winner_components: Option<crate::contradiction::ScoreComponents>,
270 #[serde(skip_serializing_if = "Option::is_none", default)]
272 loser_components: Option<crate::contradiction::ScoreComponents>,
273 },
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum SkipReason {
280 ExistingPinned,
282 ExistingWins,
284 BelowThreshold,
286}
287
288pub 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 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 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 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 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
363pub 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
386pub 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
394pub 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
459pub const MEMORY_CLAIM_V1_SCHEMA_VERSION: &str = "1.0";
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
478#[serde(rename_all = "lowercase")]
479pub enum MemoryTypeV1 {
480 Claim,
483 Preference,
485 Directive,
488 Commitment,
490 Episode,
492 Summary,
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
503#[serde(rename_all = "kebab-case")]
504pub enum MemorySource {
505 User,
507 UserInferred,
509 Assistant,
511 External,
513 Derived,
515}
516
517#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
536#[serde(rename_all = "lowercase")]
537pub enum MemoryVolatility {
538 Stable,
540 Updatable,
542 Ephemeral,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
558#[serde(rename_all = "snake_case")]
559pub enum PinStatus {
560 Pinned,
562 Unpinned,
564}
565
566impl PinStatus {
567 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
577pub type MemoryEntityType = EntityType;
581
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584pub struct MemoryEntityV1 {
585 pub name: String,
587 #[serde(rename = "type")]
588 pub entity_type: MemoryEntityType,
589 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
630pub struct MemoryClaimV1 {
631 pub id: String,
634 pub text: String,
636 #[serde(rename = "type")]
637 pub memory_type: MemoryTypeV1,
638 pub source: MemorySource,
639 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 #[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 #[serde(default, skip_serializing_if = "is_empty_entities_v1")]
661 pub entities: Vec<MemoryEntityV1>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub reasoning: Option<String>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub expires_at: Option<String>,
670
671 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub importance: Option<u8>,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub confidence: Option<f64>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub superseded_by: Option<String>,
681
682 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub pin_status: Option<PinStatus>,
691}
692
693impl MemoryTypeV1 {
694 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 pub fn from_str_lossy(s: &str) -> Self {
720 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 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 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 #[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 assert!(
853 !json.contains("\"st\""),
854 "status should be omitted when Active: {}",
855 json
856 );
857 assert!(
859 !json.contains("\"cc\""),
860 "corroboration_count should be omitted when 1: {}",
861 json
862 );
863 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 assert!(json.contains("\"n\":\"PostgreSQL\""));
883 assert!(json.contains("\"tp\":\"tool\""));
884 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 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 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 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 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 #[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 assert_eq!(normalize_entity_name("José"), "josé");
1054 }
1055
1056 #[test]
1057 fn test_normalize_nfc_merges_combining() {
1058 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 let a = "Postgre\u{0301}SQL"; let b = "PostgréSQL"; 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 #[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 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 let again = deterministic_entity_id("postgresql");
1116 assert_eq!(id, again);
1117 }
1118
1119 #[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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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 assert!(!json.contains("schema_version"));
1700 assert!(!json.contains("scope"));
1702 assert!(!json.contains("volatility"));
1704 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 assert!(!json.contains("entities"));
1712 assert!(!json.contains("pin_status"));
1714 }
1715
1716 #[test]
1717 fn test_memory_claim_v1_deserialize_fills_defaults() {
1718 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 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 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 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 #[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 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 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 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 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 #[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 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 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 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 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}