Skip to main content

mnemara_core/
model.rs

1use crate::error::{Error, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5pub const EPISODE_SCHEMA_VERSION: u32 = 1;
6
7fn default_episode_schema_version() -> u32 {
8    EPISODE_SCHEMA_VERSION
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
12pub struct MemoryScope {
13    pub tenant_id: String,
14    pub namespace: String,
15    pub actor_id: String,
16    pub conversation_id: Option<String>,
17    pub session_id: Option<String>,
18    pub source: String,
19    pub labels: Vec<String>,
20    pub trust_level: MemoryTrustLevel,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
24pub enum MemoryTrustLevel {
25    Untrusted,
26    Observed,
27    #[default]
28    Derived,
29    Verified,
30    Pinned,
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
34pub enum MemoryRecordKind {
35    Episodic,
36    Summary,
37    Fact,
38    Preference,
39    Task,
40    Artifact,
41    Hypothesis,
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
45pub enum MemoryQualityState {
46    Draft,
47    Active,
48    Verified,
49    Archived,
50    Suppressed,
51    Deleted,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct ArtifactPointer {
56    pub uri: String,
57    pub media_type: Option<String>,
58    pub checksum: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62pub enum EpisodeContinuityState {
63    #[default]
64    Open,
65    Resolved,
66    Superseded,
67    Abandoned,
68}
69
70impl EpisodeContinuityState {
71    pub fn is_unresolved(self) -> bool {
72        matches!(self, Self::Open | Self::Abandoned)
73    }
74}
75
76#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
77pub enum MemoryHistoricalState {
78    #[default]
79    Current,
80    Historical,
81    Superseded,
82}
83
84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
85pub enum LineageRelationKind {
86    #[default]
87    DerivedFrom,
88    ConsolidatedFrom,
89    Supersedes,
90    SupersededBy,
91    ConflictsWith,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
95pub enum ConflictReviewState {
96    #[default]
97    None,
98    PotentialConflict,
99    UnderReview,
100    Resolved,
101    Dismissed,
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
105pub enum ConflictResolutionKind {
106    #[default]
107    None,
108    Accepted,
109    Rejected,
110    Superseded,
111    Merged,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct ConflictAnnotation {
116    pub state: ConflictReviewState,
117    pub conflicting_record_ids: Vec<String>,
118    pub drift_score: f32,
119    pub resolution: ConflictResolutionKind,
120    pub resolved_by: Option<String>,
121    pub resolved_at_unix_ms: Option<u64>,
122    pub note: Option<String>,
123}
124
125impl Default for ConflictAnnotation {
126    fn default() -> Self {
127        Self {
128            state: ConflictReviewState::None,
129            conflicting_record_ids: Vec::new(),
130            drift_score: 0.0,
131            resolution: ConflictResolutionKind::None,
132            resolved_by: None,
133            resolved_at_unix_ms: None,
134            note: None,
135        }
136    }
137}
138
139impl ConflictAnnotation {
140    pub fn validate_for_record(&self, record_id: &str) -> Result<()> {
141        if !(0.0..=1.0).contains(&self.drift_score) {
142            return Err(Error::InvalidRequest(
143                "conflict drift_score must be within 0.0..=1.0".to_string(),
144            ));
145        }
146        if self
147            .conflicting_record_ids
148            .iter()
149            .any(|value| value.trim().is_empty())
150        {
151            return Err(Error::InvalidRequest(
152                "conflicting_record_ids cannot contain empty ids".to_string(),
153            ));
154        }
155        if self
156            .conflicting_record_ids
157            .iter()
158            .any(|value| value == record_id)
159        {
160            return Err(Error::InvalidRequest(
161                "conflicting_record_ids cannot reference the current record".to_string(),
162            ));
163        }
164        if self
165            .resolved_by
166            .as_ref()
167            .is_some_and(|value| value.trim().is_empty())
168        {
169            return Err(Error::InvalidRequest(
170                "conflict resolved_by cannot be empty when provided".to_string(),
171            ));
172        }
173        if self
174            .note
175            .as_ref()
176            .is_some_and(|value| value.trim().is_empty())
177        {
178            return Err(Error::InvalidRequest(
179                "conflict note cannot be empty when provided".to_string(),
180            ));
181        }
182        if matches!(
183            self.state,
184            ConflictReviewState::Resolved | ConflictReviewState::Dismissed
185        ) && matches!(self.resolution, ConflictResolutionKind::None)
186        {
187            return Err(Error::InvalidRequest(
188                "resolved or dismissed conflicts require a resolution kind".to_string(),
189            ));
190        }
191        Ok(())
192    }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
196pub struct LineageLink {
197    pub record_id: String,
198    pub relation: LineageRelationKind,
199    pub confidence: f32,
200}
201
202impl Default for LineageLink {
203    fn default() -> Self {
204        Self {
205            record_id: String::new(),
206            relation: LineageRelationKind::default(),
207            confidence: 1.0,
208        }
209    }
210}
211
212#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
213pub enum AffectiveAnnotationProvenance {
214    #[default]
215    Authored,
216    Imported,
217    Derived,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221pub struct AffectiveAnnotation {
222    pub tone: Option<String>,
223    pub sentiment: Option<String>,
224    pub urgency: f32,
225    pub confidence: f32,
226    pub tension: f32,
227    pub provenance: AffectiveAnnotationProvenance,
228}
229
230impl Default for AffectiveAnnotation {
231    fn default() -> Self {
232        Self {
233            tone: None,
234            sentiment: None,
235            urgency: 0.0,
236            confidence: 1.0,
237            tension: 0.0,
238            provenance: AffectiveAnnotationProvenance::default(),
239        }
240    }
241}
242
243impl AffectiveAnnotation {
244    pub fn validate(&self) -> Result<()> {
245        if self
246            .tone
247            .as_ref()
248            .is_some_and(|value| value.trim().is_empty())
249        {
250            return Err(Error::InvalidRequest(
251                "affective tone cannot be empty when provided".to_string(),
252            ));
253        }
254        if self
255            .sentiment
256            .as_ref()
257            .is_some_and(|value| value.trim().is_empty())
258        {
259            return Err(Error::InvalidRequest(
260                "affective sentiment cannot be empty when provided".to_string(),
261            ));
262        }
263        if !(0.0..=1.0).contains(&self.urgency) {
264            return Err(Error::InvalidRequest(
265                "affective urgency must be within 0.0..=1.0".to_string(),
266            ));
267        }
268        if !(0.0..=1.0).contains(&self.confidence) {
269            return Err(Error::InvalidRequest(
270                "affective confidence must be within 0.0..=1.0".to_string(),
271            ));
272        }
273        if !(0.0..=1.0).contains(&self.tension) {
274            return Err(Error::InvalidRequest(
275                "affective tension must be within 0.0..=1.0".to_string(),
276            ));
277        }
278        if matches!(self.provenance, AffectiveAnnotationProvenance::Derived)
279            && self.confidence >= 1.0
280        {
281            return Err(Error::InvalidRequest(
282                "derived affective confidence must remain below certainty".to_string(),
283            ));
284        }
285        Ok(())
286    }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
290pub struct EpisodeSalience {
291    pub reuse_count: u32,
292    pub novelty_score: f32,
293    pub goal_relevance: f32,
294    pub unresolved_weight: f32,
295}
296
297impl Default for EpisodeSalience {
298    fn default() -> Self {
299        Self {
300            reuse_count: 0,
301            novelty_score: 0.0,
302            goal_relevance: 0.0,
303            unresolved_weight: 0.0,
304        }
305    }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct EpisodeContext {
310    #[serde(default = "default_episode_schema_version")]
311    pub schema_version: u32,
312    pub episode_id: String,
313    pub summary: Option<String>,
314    pub continuity_state: EpisodeContinuityState,
315    pub actor_ids: Vec<String>,
316    pub goal: Option<String>,
317    pub outcome: Option<String>,
318    pub started_at_unix_ms: Option<u64>,
319    pub ended_at_unix_ms: Option<u64>,
320    pub last_active_unix_ms: Option<u64>,
321    #[serde(default)]
322    pub recurrence_key: Option<String>,
323    #[serde(default)]
324    pub recurrence_interval_ms: Option<u64>,
325    #[serde(default)]
326    pub boundary_label: Option<String>,
327    pub previous_record_id: Option<String>,
328    pub next_record_id: Option<String>,
329    pub causal_record_ids: Vec<String>,
330    pub related_record_ids: Vec<String>,
331    pub linked_artifact_uris: Vec<String>,
332    pub salience: EpisodeSalience,
333    pub affective: Option<AffectiveAnnotation>,
334}
335
336impl Default for EpisodeContext {
337    fn default() -> Self {
338        Self {
339            schema_version: EPISODE_SCHEMA_VERSION,
340            episode_id: String::new(),
341            summary: None,
342            continuity_state: EpisodeContinuityState::Open,
343            actor_ids: Vec::new(),
344            goal: None,
345            outcome: None,
346            started_at_unix_ms: None,
347            ended_at_unix_ms: None,
348            last_active_unix_ms: None,
349            recurrence_key: None,
350            recurrence_interval_ms: None,
351            boundary_label: None,
352            previous_record_id: None,
353            next_record_id: None,
354            causal_record_ids: Vec::new(),
355            related_record_ids: Vec::new(),
356            linked_artifact_uris: Vec::new(),
357            salience: EpisodeSalience::default(),
358            affective: None,
359        }
360    }
361}
362
363impl EpisodeContext {
364    pub fn duration_hint_ms(&self) -> Option<u64> {
365        match (
366            self.started_at_unix_ms,
367            self.ended_at_unix_ms,
368            self.last_active_unix_ms,
369        ) {
370            (Some(started), Some(ended), _) if ended >= started => Some(ended - started),
371            (Some(started), None, Some(last_active)) if last_active >= started => {
372                Some(last_active - started)
373            }
374            _ => None,
375        }
376    }
377
378    pub fn validate_for_record(&self, record_id: &str, actor_id: &str) -> Result<()> {
379        if self.schema_version != EPISODE_SCHEMA_VERSION {
380            return Err(Error::Unsupported(format!(
381                "unsupported episode schema version {}; expected {}",
382                self.schema_version, EPISODE_SCHEMA_VERSION
383            )));
384        }
385        if self.episode_id.trim().is_empty() {
386            return Err(Error::InvalidRequest(
387                "episode_id is required when episode context is present".to_string(),
388            ));
389        }
390        if self.previous_record_id.as_deref() == Some(record_id) {
391            return Err(Error::InvalidRequest(
392                "episode previous_record_id cannot reference the current record".to_string(),
393            ));
394        }
395        if self.next_record_id.as_deref() == Some(record_id) {
396            return Err(Error::InvalidRequest(
397                "episode next_record_id cannot reference the current record".to_string(),
398            ));
399        }
400        if self
401            .causal_record_ids
402            .iter()
403            .any(|value| value == record_id)
404        {
405            return Err(Error::InvalidRequest(
406                "episode causal_record_ids cannot reference the current record".to_string(),
407            ));
408        }
409        if self
410            .related_record_ids
411            .iter()
412            .any(|value| value == record_id)
413        {
414            return Err(Error::InvalidRequest(
415                "episode related_record_ids cannot reference the current record".to_string(),
416            ));
417        }
418        if let (Some(started_at_unix_ms), Some(ended_at_unix_ms)) =
419            (self.started_at_unix_ms, self.ended_at_unix_ms)
420            && ended_at_unix_ms < started_at_unix_ms
421        {
422            return Err(Error::InvalidRequest(
423                "episode ended_at_unix_ms cannot be earlier than started_at_unix_ms".to_string(),
424            ));
425        }
426        if let (Some(started_at_unix_ms), Some(last_active_unix_ms)) =
427            (self.started_at_unix_ms, self.last_active_unix_ms)
428            && last_active_unix_ms < started_at_unix_ms
429        {
430            return Err(Error::InvalidRequest(
431                "episode last_active_unix_ms cannot be earlier than started_at_unix_ms".to_string(),
432            ));
433        }
434        if let (Some(last_active_unix_ms), Some(ended_at_unix_ms)) =
435            (self.last_active_unix_ms, self.ended_at_unix_ms)
436            && last_active_unix_ms > ended_at_unix_ms
437        {
438            return Err(Error::InvalidRequest(
439                "episode last_active_unix_ms cannot be later than ended_at_unix_ms".to_string(),
440            ));
441        }
442        if self
443            .recurrence_key
444            .as_ref()
445            .is_some_and(|value| value.trim().is_empty())
446        {
447            return Err(Error::InvalidRequest(
448                "episode recurrence_key cannot be empty when provided".to_string(),
449            ));
450        }
451        if self
452            .boundary_label
453            .as_ref()
454            .is_some_and(|value| value.trim().is_empty())
455        {
456            return Err(Error::InvalidRequest(
457                "episode boundary_label cannot be empty when provided".to_string(),
458            ));
459        }
460        if self.recurrence_interval_ms == Some(0) {
461            return Err(Error::InvalidRequest(
462                "episode recurrence_interval_ms must be greater than zero".to_string(),
463            ));
464        }
465        if self.recurrence_interval_ms.is_some() && self.recurrence_key.is_none() {
466            return Err(Error::InvalidRequest(
467                "episode recurrence_key is required when recurrence_interval_ms is present"
468                    .to_string(),
469            ));
470        }
471        if !self.actor_ids.is_empty() && !self.actor_ids.iter().any(|value| value == actor_id) {
472            return Err(Error::InvalidRequest(
473                "episode actor_ids must include the owning record actor when provided".to_string(),
474            ));
475        }
476        if let Some(affective) = &self.affective {
477            affective.validate()?;
478        }
479        Ok(())
480    }
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
484pub struct MemoryRecord {
485    pub id: String,
486    pub scope: MemoryScope,
487    pub kind: MemoryRecordKind,
488    pub content: String,
489    pub summary: Option<String>,
490    pub source_id: Option<String>,
491    pub metadata: BTreeMap<String, String>,
492    pub quality_state: MemoryQualityState,
493    pub created_at_unix_ms: u64,
494    pub updated_at_unix_ms: u64,
495    pub expires_at_unix_ms: Option<u64>,
496    pub importance_score: f32,
497    pub artifact: Option<ArtifactPointer>,
498    #[serde(default)]
499    pub episode: Option<EpisodeContext>,
500    #[serde(default)]
501    pub historical_state: MemoryHistoricalState,
502    #[serde(default)]
503    pub lineage: Vec<LineageLink>,
504    #[serde(default)]
505    pub conflict: Option<ConflictAnnotation>,
506}
507
508impl MemoryRecord {
509    pub fn validate(&self) -> Result<()> {
510        if self.id.trim().is_empty() {
511            return Err(Error::InvalidRequest(
512                "memory record id is required".to_string(),
513            ));
514        }
515        if self.scope.tenant_id.trim().is_empty() {
516            return Err(Error::InvalidRequest(
517                "memory record tenant_id is required".to_string(),
518            ));
519        }
520        if self.scope.namespace.trim().is_empty() {
521            return Err(Error::InvalidRequest(
522                "memory record namespace is required".to_string(),
523            ));
524        }
525        if self.scope.actor_id.trim().is_empty() {
526            return Err(Error::InvalidRequest(
527                "memory record actor_id is required".to_string(),
528            ));
529        }
530        if self.content.trim().is_empty() && self.artifact.is_none() {
531            return Err(Error::InvalidRequest(
532                "memory record content or artifact is required".to_string(),
533            ));
534        }
535        if let Some(episode) = &self.episode {
536            episode.validate_for_record(&self.id, &self.scope.actor_id)?;
537        }
538        if !(0.0..=1.0).contains(&self.importance_score) {
539            return Err(Error::InvalidRequest(
540                "memory record importance_score must be within 0.0..=1.0".to_string(),
541            ));
542        }
543        if let Some(conflict) = &self.conflict {
544            conflict.validate_for_record(&self.id)?;
545        }
546        Ok(())
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::{
553        AffectiveAnnotation, AffectiveAnnotationProvenance, EPISODE_SCHEMA_VERSION, EpisodeContext,
554        EpisodeContinuityState, LineageLink, MemoryHistoricalState, MemoryQualityState,
555        MemoryRecord, MemoryRecordKind, MemoryScope, MemoryTrustLevel,
556    };
557    use std::collections::BTreeMap;
558
559    fn scope() -> MemoryScope {
560        MemoryScope {
561            tenant_id: "default".to_string(),
562            namespace: "conversation".to_string(),
563            actor_id: "ava".to_string(),
564            conversation_id: Some("thread-a".to_string()),
565            session_id: Some("session-a".to_string()),
566            source: "test".to_string(),
567            labels: vec!["shared-fixture".to_string()],
568            trust_level: MemoryTrustLevel::Verified,
569        }
570    }
571
572    #[test]
573    fn episode_defaults_and_unresolved_states_are_explicit() {
574        let episode = EpisodeContext::default();
575
576        assert_eq!(episode.schema_version, EPISODE_SCHEMA_VERSION);
577        assert!(episode.episode_id.is_empty());
578        assert_eq!(episode.continuity_state, EpisodeContinuityState::Open);
579        assert!(EpisodeContinuityState::Open.is_unresolved());
580        assert!(EpisodeContinuityState::Abandoned.is_unresolved());
581        assert!(!EpisodeContinuityState::Resolved.is_unresolved());
582        assert!(!EpisodeContinuityState::Superseded.is_unresolved());
583    }
584
585    #[test]
586    fn memory_record_deserializes_missing_additive_fields_with_safe_defaults() {
587        let record: MemoryRecord = serde_json::from_value(serde_json::json!({
588            "id": "record-1",
589            "scope": {
590                "tenant_id": "default",
591                "namespace": "conversation",
592                "actor_id": "ava",
593                "conversation_id": "thread-a",
594                "session_id": "session-a",
595                "source": "test",
596                "labels": ["shared-fixture"],
597                "trust_level": "Verified"
598            },
599            "kind": "Fact",
600            "content": "Prompt: repair\nAnswer: ok",
601            "summary": "ok",
602            "source_id": null,
603            "metadata": {},
604            "quality_state": "Active",
605            "created_at_unix_ms": 1,
606            "updated_at_unix_ms": 1,
607            "expires_at_unix_ms": null,
608            "importance_score": 0.5,
609            "artifact": null
610        }))
611        .unwrap();
612
613        assert_eq!(record.historical_state, MemoryHistoricalState::Current);
614        assert!(record.episode.is_none());
615        assert!(record.lineage.is_empty());
616    }
617
618    #[test]
619    fn episodic_fields_roundtrip_through_json_serialization() {
620        let record = MemoryRecord {
621            id: "record-episode".to_string(),
622            scope: scope(),
623            kind: MemoryRecordKind::Episodic,
624            content: "Open follow-up for reconnect storm".to_string(),
625            summary: Some("Storm follow-up".to_string()),
626            source_id: Some("source-1".to_string()),
627            metadata: BTreeMap::new(),
628            quality_state: MemoryQualityState::Verified,
629            created_at_unix_ms: 10,
630            updated_at_unix_ms: 20,
631            expires_at_unix_ms: None,
632            importance_score: 0.9,
633            artifact: None,
634            episode: Some(EpisodeContext {
635                schema_version: EPISODE_SCHEMA_VERSION,
636                episode_id: "storm-episode".to_string(),
637                summary: Some("Storm remediation episode".to_string()),
638                continuity_state: EpisodeContinuityState::Open,
639                actor_ids: vec!["ava".to_string(), "ops-bot".to_string()],
640                goal: Some("close the reconnect storm follow-up list".to_string()),
641                outcome: None,
642                started_at_unix_ms: Some(1),
643                ended_at_unix_ms: None,
644                last_active_unix_ms: Some(20),
645                recurrence_key: None,
646                recurrence_interval_ms: None,
647                boundary_label: None,
648                previous_record_id: Some("incident-root".to_string()),
649                next_record_id: Some("incident-next".to_string()),
650                causal_record_ids: vec!["incident-root".to_string()],
651                related_record_ids: vec!["incident-next".to_string()],
652                linked_artifact_uris: vec!["file:///tmp/storm.md".to_string()],
653                salience: super::EpisodeSalience {
654                    reuse_count: 4,
655                    novelty_score: 0.3,
656                    goal_relevance: 0.95,
657                    unresolved_weight: 0.9,
658                },
659                affective: Some(AffectiveAnnotation {
660                    tone: Some("urgent".to_string()),
661                    sentiment: Some("concerned".to_string()),
662                    urgency: 0.9,
663                    confidence: 0.7,
664                    tension: 0.6,
665                    provenance: AffectiveAnnotationProvenance::Derived,
666                }),
667            }),
668            historical_state: MemoryHistoricalState::Historical,
669            lineage: vec![LineageLink {
670                record_id: "incident-root".to_string(),
671                relation: super::LineageRelationKind::DerivedFrom,
672                confidence: 0.8,
673            }],
674            conflict: None,
675        };
676
677        let encoded = serde_json::to_string(&record).unwrap();
678        let decoded: MemoryRecord = serde_json::from_str(&encoded).unwrap();
679
680        assert_eq!(decoded, record);
681    }
682
683    #[test]
684    fn memory_record_rejects_invalid_episode_association_rules() {
685        let mut record = MemoryRecord {
686            id: "record-episode".to_string(),
687            scope: scope(),
688            kind: MemoryRecordKind::Episodic,
689            content: "Open follow-up for reconnect storm".to_string(),
690            summary: Some("Storm follow-up".to_string()),
691            source_id: Some("source-1".to_string()),
692            metadata: BTreeMap::new(),
693            quality_state: MemoryQualityState::Verified,
694            created_at_unix_ms: 10,
695            updated_at_unix_ms: 20,
696            expires_at_unix_ms: None,
697            importance_score: 0.9,
698            artifact: None,
699            episode: Some(EpisodeContext {
700                schema_version: EPISODE_SCHEMA_VERSION,
701                episode_id: "storm-episode".to_string(),
702                summary: Some("Storm remediation episode".to_string()),
703                continuity_state: EpisodeContinuityState::Open,
704                actor_ids: vec!["ava".to_string()],
705                goal: Some("close the reconnect storm follow-up list".to_string()),
706                outcome: None,
707                started_at_unix_ms: Some(1),
708                ended_at_unix_ms: None,
709                last_active_unix_ms: Some(20),
710                recurrence_key: None,
711                recurrence_interval_ms: None,
712                boundary_label: None,
713                previous_record_id: Some("record-episode".to_string()),
714                next_record_id: None,
715                causal_record_ids: vec![],
716                related_record_ids: vec![],
717                linked_artifact_uris: vec![],
718                salience: super::EpisodeSalience::default(),
719                affective: None,
720            }),
721            historical_state: MemoryHistoricalState::Current,
722            lineage: vec![],
723            conflict: None,
724        };
725
726        let error = record.validate().unwrap_err();
727        assert!(
728            error
729                .to_string()
730                .contains("previous_record_id cannot reference the current record")
731        );
732
733        record.episode.as_mut().unwrap().previous_record_id = Some("incident-root".to_string());
734        record.episode.as_mut().unwrap().actor_ids = vec!["ops-bot".to_string()];
735        let error = record.validate().unwrap_err();
736        assert!(
737            error
738                .to_string()
739                .contains("actor_ids must include the owning record actor")
740        );
741    }
742
743    #[test]
744    fn episode_duration_and_recurrence_require_coherent_values() {
745        let mut episode = EpisodeContext {
746            schema_version: EPISODE_SCHEMA_VERSION,
747            episode_id: "release-retro".to_string(),
748            summary: Some("Recurring release retrospective".to_string()),
749            continuity_state: EpisodeContinuityState::Open,
750            actor_ids: vec!["ava".to_string()],
751            goal: Some("review each release boundary".to_string()),
752            outcome: None,
753            started_at_unix_ms: Some(10),
754            ended_at_unix_ms: Some(40),
755            last_active_unix_ms: Some(40),
756            recurrence_key: Some("release-retro-weekly".to_string()),
757            recurrence_interval_ms: Some(7 * 24 * 60 * 60 * 1000),
758            boundary_label: Some("weekly-release-boundary".to_string()),
759            previous_record_id: None,
760            next_record_id: None,
761            causal_record_ids: vec![],
762            related_record_ids: vec![],
763            linked_artifact_uris: vec![],
764            salience: super::EpisodeSalience::default(),
765            affective: None,
766        };
767
768        assert_eq!(episode.duration_hint_ms(), Some(30));
769        episode.validate_for_record("retro-1", "ava").unwrap();
770
771        episode.recurrence_key = None;
772        let error = episode.validate_for_record("retro-1", "ava").unwrap_err();
773        assert!(
774            error
775                .to_string()
776                .contains("recurrence_key is required when recurrence_interval_ms is present")
777        );
778    }
779
780    #[test]
781    fn derived_affective_annotations_require_bounded_confidence() {
782        let annotation = AffectiveAnnotation {
783            tone: Some("urgent".to_string()),
784            sentiment: Some("concerned".to_string()),
785            urgency: 0.8,
786            confidence: 1.0,
787            tension: 0.6,
788            provenance: AffectiveAnnotationProvenance::Derived,
789        };
790
791        let error = annotation.validate().unwrap_err();
792        assert!(
793            error
794                .to_string()
795                .contains("derived affective confidence must remain below certainty")
796        );
797    }
798
799    #[test]
800    fn conflict_annotations_validate_review_workflow_shape() {
801        let conflict = super::ConflictAnnotation {
802            state: super::ConflictReviewState::Resolved,
803            conflicting_record_ids: vec!["record-a".to_string()],
804            drift_score: 0.7,
805            resolution: super::ConflictResolutionKind::None,
806            resolved_by: Some("operator".to_string()),
807            resolved_at_unix_ms: Some(42),
808            note: Some("accepted newer fact".to_string()),
809        };
810
811        let error = conflict.validate_for_record("record-b").unwrap_err();
812        assert!(error.to_string().contains("require a resolution kind"));
813
814        let self_reference = super::ConflictAnnotation {
815            resolution: super::ConflictResolutionKind::Accepted,
816            conflicting_record_ids: vec!["record-b".to_string()],
817            ..conflict
818        };
819        let error = self_reference.validate_for_record("record-b").unwrap_err();
820        assert!(
821            error
822                .to_string()
823                .contains("cannot reference the current record")
824        );
825    }
826}