use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const EPISODE_SCHEMA_VERSION: u32 = 1;
fn default_episode_schema_version() -> u32 {
EPISODE_SCHEMA_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct MemoryScope {
pub tenant_id: String,
pub namespace: String,
pub actor_id: String,
pub conversation_id: Option<String>,
pub session_id: Option<String>,
pub source: String,
pub labels: Vec<String>,
pub trust_level: MemoryTrustLevel,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum MemoryTrustLevel {
Untrusted,
Observed,
#[default]
Derived,
Verified,
Pinned,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum MemoryRecordKind {
Episodic,
Summary,
Fact,
Preference,
Task,
Artifact,
Hypothesis,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum MemoryQualityState {
Draft,
Active,
Verified,
Archived,
Suppressed,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactPointer {
pub uri: String,
pub media_type: Option<String>,
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum EpisodeContinuityState {
#[default]
Open,
Resolved,
Superseded,
Abandoned,
}
impl EpisodeContinuityState {
pub fn is_unresolved(self) -> bool {
matches!(self, Self::Open | Self::Abandoned)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum MemoryHistoricalState {
#[default]
Current,
Historical,
Superseded,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum LineageRelationKind {
#[default]
DerivedFrom,
ConsolidatedFrom,
Supersedes,
SupersededBy,
ConflictsWith,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum ConflictReviewState {
#[default]
None,
PotentialConflict,
UnderReview,
Resolved,
Dismissed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum ConflictResolutionKind {
#[default]
None,
Accepted,
Rejected,
Superseded,
Merged,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConflictAnnotation {
pub state: ConflictReviewState,
pub conflicting_record_ids: Vec<String>,
pub drift_score: f32,
pub resolution: ConflictResolutionKind,
pub resolved_by: Option<String>,
pub resolved_at_unix_ms: Option<u64>,
pub note: Option<String>,
}
impl Default for ConflictAnnotation {
fn default() -> Self {
Self {
state: ConflictReviewState::None,
conflicting_record_ids: Vec::new(),
drift_score: 0.0,
resolution: ConflictResolutionKind::None,
resolved_by: None,
resolved_at_unix_ms: None,
note: None,
}
}
}
impl ConflictAnnotation {
pub fn validate_for_record(&self, record_id: &str) -> Result<()> {
if !(0.0..=1.0).contains(&self.drift_score) {
return Err(Error::InvalidRequest(
"conflict drift_score must be within 0.0..=1.0".to_string(),
));
}
if self
.conflicting_record_ids
.iter()
.any(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"conflicting_record_ids cannot contain empty ids".to_string(),
));
}
if self
.conflicting_record_ids
.iter()
.any(|value| value == record_id)
{
return Err(Error::InvalidRequest(
"conflicting_record_ids cannot reference the current record".to_string(),
));
}
if self
.resolved_by
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"conflict resolved_by cannot be empty when provided".to_string(),
));
}
if self
.note
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"conflict note cannot be empty when provided".to_string(),
));
}
if matches!(
self.state,
ConflictReviewState::Resolved | ConflictReviewState::Dismissed
) && matches!(self.resolution, ConflictResolutionKind::None)
{
return Err(Error::InvalidRequest(
"resolved or dismissed conflicts require a resolution kind".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LineageLink {
pub record_id: String,
pub relation: LineageRelationKind,
pub confidence: f32,
}
impl Default for LineageLink {
fn default() -> Self {
Self {
record_id: String::new(),
relation: LineageRelationKind::default(),
confidence: 1.0,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum AffectiveAnnotationProvenance {
#[default]
Authored,
Imported,
Derived,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AffectiveAnnotation {
pub tone: Option<String>,
pub sentiment: Option<String>,
pub urgency: f32,
pub confidence: f32,
pub tension: f32,
pub provenance: AffectiveAnnotationProvenance,
}
impl Default for AffectiveAnnotation {
fn default() -> Self {
Self {
tone: None,
sentiment: None,
urgency: 0.0,
confidence: 1.0,
tension: 0.0,
provenance: AffectiveAnnotationProvenance::default(),
}
}
}
impl AffectiveAnnotation {
pub fn validate(&self) -> Result<()> {
if self
.tone
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"affective tone cannot be empty when provided".to_string(),
));
}
if self
.sentiment
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"affective sentiment cannot be empty when provided".to_string(),
));
}
if !(0.0..=1.0).contains(&self.urgency) {
return Err(Error::InvalidRequest(
"affective urgency must be within 0.0..=1.0".to_string(),
));
}
if !(0.0..=1.0).contains(&self.confidence) {
return Err(Error::InvalidRequest(
"affective confidence must be within 0.0..=1.0".to_string(),
));
}
if !(0.0..=1.0).contains(&self.tension) {
return Err(Error::InvalidRequest(
"affective tension must be within 0.0..=1.0".to_string(),
));
}
if matches!(self.provenance, AffectiveAnnotationProvenance::Derived)
&& self.confidence >= 1.0
{
return Err(Error::InvalidRequest(
"derived affective confidence must remain below certainty".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EpisodeSalience {
pub reuse_count: u32,
pub novelty_score: f32,
pub goal_relevance: f32,
pub unresolved_weight: f32,
}
impl Default for EpisodeSalience {
fn default() -> Self {
Self {
reuse_count: 0,
novelty_score: 0.0,
goal_relevance: 0.0,
unresolved_weight: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EpisodeContext {
#[serde(default = "default_episode_schema_version")]
pub schema_version: u32,
pub episode_id: String,
pub summary: Option<String>,
pub continuity_state: EpisodeContinuityState,
pub actor_ids: Vec<String>,
pub goal: Option<String>,
pub outcome: Option<String>,
pub started_at_unix_ms: Option<u64>,
pub ended_at_unix_ms: Option<u64>,
pub last_active_unix_ms: Option<u64>,
#[serde(default)]
pub recurrence_key: Option<String>,
#[serde(default)]
pub recurrence_interval_ms: Option<u64>,
#[serde(default)]
pub boundary_label: Option<String>,
pub previous_record_id: Option<String>,
pub next_record_id: Option<String>,
pub causal_record_ids: Vec<String>,
pub related_record_ids: Vec<String>,
pub linked_artifact_uris: Vec<String>,
pub salience: EpisodeSalience,
pub affective: Option<AffectiveAnnotation>,
}
impl Default for EpisodeContext {
fn default() -> Self {
Self {
schema_version: EPISODE_SCHEMA_VERSION,
episode_id: String::new(),
summary: None,
continuity_state: EpisodeContinuityState::Open,
actor_ids: Vec::new(),
goal: None,
outcome: None,
started_at_unix_ms: None,
ended_at_unix_ms: None,
last_active_unix_ms: None,
recurrence_key: None,
recurrence_interval_ms: None,
boundary_label: None,
previous_record_id: None,
next_record_id: None,
causal_record_ids: Vec::new(),
related_record_ids: Vec::new(),
linked_artifact_uris: Vec::new(),
salience: EpisodeSalience::default(),
affective: None,
}
}
}
impl EpisodeContext {
pub fn duration_hint_ms(&self) -> Option<u64> {
match (
self.started_at_unix_ms,
self.ended_at_unix_ms,
self.last_active_unix_ms,
) {
(Some(started), Some(ended), _) if ended >= started => Some(ended - started),
(Some(started), None, Some(last_active)) if last_active >= started => {
Some(last_active - started)
}
_ => None,
}
}
pub fn validate_for_record(&self, record_id: &str, actor_id: &str) -> Result<()> {
if self.schema_version != EPISODE_SCHEMA_VERSION {
return Err(Error::Unsupported(format!(
"unsupported episode schema version {}; expected {}",
self.schema_version, EPISODE_SCHEMA_VERSION
)));
}
if self.episode_id.trim().is_empty() {
return Err(Error::InvalidRequest(
"episode_id is required when episode context is present".to_string(),
));
}
if self.previous_record_id.as_deref() == Some(record_id) {
return Err(Error::InvalidRequest(
"episode previous_record_id cannot reference the current record".to_string(),
));
}
if self.next_record_id.as_deref() == Some(record_id) {
return Err(Error::InvalidRequest(
"episode next_record_id cannot reference the current record".to_string(),
));
}
if self
.causal_record_ids
.iter()
.any(|value| value == record_id)
{
return Err(Error::InvalidRequest(
"episode causal_record_ids cannot reference the current record".to_string(),
));
}
if self
.related_record_ids
.iter()
.any(|value| value == record_id)
{
return Err(Error::InvalidRequest(
"episode related_record_ids cannot reference the current record".to_string(),
));
}
if let (Some(started_at_unix_ms), Some(ended_at_unix_ms)) =
(self.started_at_unix_ms, self.ended_at_unix_ms)
&& ended_at_unix_ms < started_at_unix_ms
{
return Err(Error::InvalidRequest(
"episode ended_at_unix_ms cannot be earlier than started_at_unix_ms".to_string(),
));
}
if let (Some(started_at_unix_ms), Some(last_active_unix_ms)) =
(self.started_at_unix_ms, self.last_active_unix_ms)
&& last_active_unix_ms < started_at_unix_ms
{
return Err(Error::InvalidRequest(
"episode last_active_unix_ms cannot be earlier than started_at_unix_ms".to_string(),
));
}
if let (Some(last_active_unix_ms), Some(ended_at_unix_ms)) =
(self.last_active_unix_ms, self.ended_at_unix_ms)
&& last_active_unix_ms > ended_at_unix_ms
{
return Err(Error::InvalidRequest(
"episode last_active_unix_ms cannot be later than ended_at_unix_ms".to_string(),
));
}
if self
.recurrence_key
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"episode recurrence_key cannot be empty when provided".to_string(),
));
}
if self
.boundary_label
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidRequest(
"episode boundary_label cannot be empty when provided".to_string(),
));
}
if self.recurrence_interval_ms == Some(0) {
return Err(Error::InvalidRequest(
"episode recurrence_interval_ms must be greater than zero".to_string(),
));
}
if self.recurrence_interval_ms.is_some() && self.recurrence_key.is_none() {
return Err(Error::InvalidRequest(
"episode recurrence_key is required when recurrence_interval_ms is present"
.to_string(),
));
}
if !self.actor_ids.is_empty() && !self.actor_ids.iter().any(|value| value == actor_id) {
return Err(Error::InvalidRequest(
"episode actor_ids must include the owning record actor when provided".to_string(),
));
}
if let Some(affective) = &self.affective {
affective.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryRecord {
pub id: String,
pub scope: MemoryScope,
pub kind: MemoryRecordKind,
pub content: String,
pub summary: Option<String>,
pub source_id: Option<String>,
pub metadata: BTreeMap<String, String>,
pub quality_state: MemoryQualityState,
pub created_at_unix_ms: u64,
pub updated_at_unix_ms: u64,
pub expires_at_unix_ms: Option<u64>,
pub importance_score: f32,
pub artifact: Option<ArtifactPointer>,
#[serde(default)]
pub episode: Option<EpisodeContext>,
#[serde(default)]
pub historical_state: MemoryHistoricalState,
#[serde(default)]
pub lineage: Vec<LineageLink>,
#[serde(default)]
pub conflict: Option<ConflictAnnotation>,
}
impl MemoryRecord {
pub fn validate(&self) -> Result<()> {
if self.id.trim().is_empty() {
return Err(Error::InvalidRequest(
"memory record id is required".to_string(),
));
}
if self.scope.tenant_id.trim().is_empty() {
return Err(Error::InvalidRequest(
"memory record tenant_id is required".to_string(),
));
}
if self.scope.namespace.trim().is_empty() {
return Err(Error::InvalidRequest(
"memory record namespace is required".to_string(),
));
}
if self.scope.actor_id.trim().is_empty() {
return Err(Error::InvalidRequest(
"memory record actor_id is required".to_string(),
));
}
if self.content.trim().is_empty() && self.artifact.is_none() {
return Err(Error::InvalidRequest(
"memory record content or artifact is required".to_string(),
));
}
if let Some(episode) = &self.episode {
episode.validate_for_record(&self.id, &self.scope.actor_id)?;
}
if !(0.0..=1.0).contains(&self.importance_score) {
return Err(Error::InvalidRequest(
"memory record importance_score must be within 0.0..=1.0".to_string(),
));
}
if let Some(conflict) = &self.conflict {
conflict.validate_for_record(&self.id)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
AffectiveAnnotation, AffectiveAnnotationProvenance, EPISODE_SCHEMA_VERSION, EpisodeContext,
EpisodeContinuityState, LineageLink, MemoryHistoricalState, MemoryQualityState,
MemoryRecord, MemoryRecordKind, MemoryScope, MemoryTrustLevel,
};
use std::collections::BTreeMap;
fn scope() -> MemoryScope {
MemoryScope {
tenant_id: "default".to_string(),
namespace: "conversation".to_string(),
actor_id: "ava".to_string(),
conversation_id: Some("thread-a".to_string()),
session_id: Some("session-a".to_string()),
source: "test".to_string(),
labels: vec!["shared-fixture".to_string()],
trust_level: MemoryTrustLevel::Verified,
}
}
#[test]
fn episode_defaults_and_unresolved_states_are_explicit() {
let episode = EpisodeContext::default();
assert_eq!(episode.schema_version, EPISODE_SCHEMA_VERSION);
assert!(episode.episode_id.is_empty());
assert_eq!(episode.continuity_state, EpisodeContinuityState::Open);
assert!(EpisodeContinuityState::Open.is_unresolved());
assert!(EpisodeContinuityState::Abandoned.is_unresolved());
assert!(!EpisodeContinuityState::Resolved.is_unresolved());
assert!(!EpisodeContinuityState::Superseded.is_unresolved());
}
#[test]
fn memory_record_deserializes_missing_additive_fields_with_safe_defaults() {
let record: MemoryRecord = serde_json::from_value(serde_json::json!({
"id": "record-1",
"scope": {
"tenant_id": "default",
"namespace": "conversation",
"actor_id": "ava",
"conversation_id": "thread-a",
"session_id": "session-a",
"source": "test",
"labels": ["shared-fixture"],
"trust_level": "Verified"
},
"kind": "Fact",
"content": "Prompt: repair\nAnswer: ok",
"summary": "ok",
"source_id": null,
"metadata": {},
"quality_state": "Active",
"created_at_unix_ms": 1,
"updated_at_unix_ms": 1,
"expires_at_unix_ms": null,
"importance_score": 0.5,
"artifact": null
}))
.unwrap();
assert_eq!(record.historical_state, MemoryHistoricalState::Current);
assert!(record.episode.is_none());
assert!(record.lineage.is_empty());
}
#[test]
fn episodic_fields_roundtrip_through_json_serialization() {
let record = MemoryRecord {
id: "record-episode".to_string(),
scope: scope(),
kind: MemoryRecordKind::Episodic,
content: "Open follow-up for reconnect storm".to_string(),
summary: Some("Storm follow-up".to_string()),
source_id: Some("source-1".to_string()),
metadata: BTreeMap::new(),
quality_state: MemoryQualityState::Verified,
created_at_unix_ms: 10,
updated_at_unix_ms: 20,
expires_at_unix_ms: None,
importance_score: 0.9,
artifact: None,
episode: Some(EpisodeContext {
schema_version: EPISODE_SCHEMA_VERSION,
episode_id: "storm-episode".to_string(),
summary: Some("Storm remediation episode".to_string()),
continuity_state: EpisodeContinuityState::Open,
actor_ids: vec!["ava".to_string(), "ops-bot".to_string()],
goal: Some("close the reconnect storm follow-up list".to_string()),
outcome: None,
started_at_unix_ms: Some(1),
ended_at_unix_ms: None,
last_active_unix_ms: Some(20),
recurrence_key: None,
recurrence_interval_ms: None,
boundary_label: None,
previous_record_id: Some("incident-root".to_string()),
next_record_id: Some("incident-next".to_string()),
causal_record_ids: vec!["incident-root".to_string()],
related_record_ids: vec!["incident-next".to_string()],
linked_artifact_uris: vec!["file:///tmp/storm.md".to_string()],
salience: super::EpisodeSalience {
reuse_count: 4,
novelty_score: 0.3,
goal_relevance: 0.95,
unresolved_weight: 0.9,
},
affective: Some(AffectiveAnnotation {
tone: Some("urgent".to_string()),
sentiment: Some("concerned".to_string()),
urgency: 0.9,
confidence: 0.7,
tension: 0.6,
provenance: AffectiveAnnotationProvenance::Derived,
}),
}),
historical_state: MemoryHistoricalState::Historical,
lineage: vec![LineageLink {
record_id: "incident-root".to_string(),
relation: super::LineageRelationKind::DerivedFrom,
confidence: 0.8,
}],
conflict: None,
};
let encoded = serde_json::to_string(&record).unwrap();
let decoded: MemoryRecord = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, record);
}
#[test]
fn memory_record_rejects_invalid_episode_association_rules() {
let mut record = MemoryRecord {
id: "record-episode".to_string(),
scope: scope(),
kind: MemoryRecordKind::Episodic,
content: "Open follow-up for reconnect storm".to_string(),
summary: Some("Storm follow-up".to_string()),
source_id: Some("source-1".to_string()),
metadata: BTreeMap::new(),
quality_state: MemoryQualityState::Verified,
created_at_unix_ms: 10,
updated_at_unix_ms: 20,
expires_at_unix_ms: None,
importance_score: 0.9,
artifact: None,
episode: Some(EpisodeContext {
schema_version: EPISODE_SCHEMA_VERSION,
episode_id: "storm-episode".to_string(),
summary: Some("Storm remediation episode".to_string()),
continuity_state: EpisodeContinuityState::Open,
actor_ids: vec!["ava".to_string()],
goal: Some("close the reconnect storm follow-up list".to_string()),
outcome: None,
started_at_unix_ms: Some(1),
ended_at_unix_ms: None,
last_active_unix_ms: Some(20),
recurrence_key: None,
recurrence_interval_ms: None,
boundary_label: None,
previous_record_id: Some("record-episode".to_string()),
next_record_id: None,
causal_record_ids: vec![],
related_record_ids: vec![],
linked_artifact_uris: vec![],
salience: super::EpisodeSalience::default(),
affective: None,
}),
historical_state: MemoryHistoricalState::Current,
lineage: vec![],
conflict: None,
};
let error = record.validate().unwrap_err();
assert!(
error
.to_string()
.contains("previous_record_id cannot reference the current record")
);
record.episode.as_mut().unwrap().previous_record_id = Some("incident-root".to_string());
record.episode.as_mut().unwrap().actor_ids = vec!["ops-bot".to_string()];
let error = record.validate().unwrap_err();
assert!(
error
.to_string()
.contains("actor_ids must include the owning record actor")
);
}
#[test]
fn episode_duration_and_recurrence_require_coherent_values() {
let mut episode = EpisodeContext {
schema_version: EPISODE_SCHEMA_VERSION,
episode_id: "release-retro".to_string(),
summary: Some("Recurring release retrospective".to_string()),
continuity_state: EpisodeContinuityState::Open,
actor_ids: vec!["ava".to_string()],
goal: Some("review each release boundary".to_string()),
outcome: None,
started_at_unix_ms: Some(10),
ended_at_unix_ms: Some(40),
last_active_unix_ms: Some(40),
recurrence_key: Some("release-retro-weekly".to_string()),
recurrence_interval_ms: Some(7 * 24 * 60 * 60 * 1000),
boundary_label: Some("weekly-release-boundary".to_string()),
previous_record_id: None,
next_record_id: None,
causal_record_ids: vec![],
related_record_ids: vec![],
linked_artifact_uris: vec![],
salience: super::EpisodeSalience::default(),
affective: None,
};
assert_eq!(episode.duration_hint_ms(), Some(30));
episode.validate_for_record("retro-1", "ava").unwrap();
episode.recurrence_key = None;
let error = episode.validate_for_record("retro-1", "ava").unwrap_err();
assert!(
error
.to_string()
.contains("recurrence_key is required when recurrence_interval_ms is present")
);
}
#[test]
fn derived_affective_annotations_require_bounded_confidence() {
let annotation = AffectiveAnnotation {
tone: Some("urgent".to_string()),
sentiment: Some("concerned".to_string()),
urgency: 0.8,
confidence: 1.0,
tension: 0.6,
provenance: AffectiveAnnotationProvenance::Derived,
};
let error = annotation.validate().unwrap_err();
assert!(
error
.to_string()
.contains("derived affective confidence must remain below certainty")
);
}
#[test]
fn conflict_annotations_validate_review_workflow_shape() {
let conflict = super::ConflictAnnotation {
state: super::ConflictReviewState::Resolved,
conflicting_record_ids: vec!["record-a".to_string()],
drift_score: 0.7,
resolution: super::ConflictResolutionKind::None,
resolved_by: Some("operator".to_string()),
resolved_at_unix_ms: Some(42),
note: Some("accepted newer fact".to_string()),
};
let error = conflict.validate_for_record("record-b").unwrap_err();
assert!(error.to_string().contains("require a resolution kind"));
let self_reference = super::ConflictAnnotation {
resolution: super::ConflictResolutionKind::Accepted,
conflicting_record_ids: vec!["record-b".to_string()],
..conflict
};
let error = self_reference.validate_for_record("record-b").unwrap_err();
assert!(
error
.to_string()
.contains("cannot reference the current record")
);
}
}