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}