use chrono::{DateTime, FixedOffset};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Scope {
pub agent_id: String,
pub org_id: String,
pub user_id: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ScopeError {
#[error("scope: agent_id, org_id, and user_id must all be non-empty")]
Empty,
}
impl Scope {
pub fn validate(&self) -> Result<(), ScopeError> {
if self.agent_id.is_empty() || self.org_id.is_empty() || self.user_id.is_empty() {
return Err(ScopeError::Empty);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString, strum::AsRefStr)]
#[strum(serialize_all = "lowercase")]
pub enum MemoryKind {
Episodic,
Semantic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString, strum::AsRefStr)]
#[strum(serialize_all = "lowercase")]
pub enum RetirementReason {
Rejected,
Stale,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct StatsFilter {
pub agent_id: Option<String>,
pub org_id: Option<String>,
pub user_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionStat {
pub provider: String,
pub model: String,
pub total: u64,
pub rejected: u64,
}
impl ExtractionStat {
#[must_use]
pub fn accuracy(&self) -> f64 {
if self.total == 0 {
return 1.0;
}
1.0 - (self.rejected as f64 / self.total as f64)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Confidence(i8);
impl Confidence {
pub const MAX: Confidence = Confidence(100);
pub const MIN: Confidence = Confidence(0);
#[must_use]
pub fn new(percent: i8) -> Self {
Self(percent.clamp(0, 100))
}
#[must_use]
pub fn from_unit_scale(score: f32) -> Self {
if score.is_nan() {
return Self::MIN;
}
let percent = (score * 100.0).round();
Self(percent.clamp(0.0, 100.0) as i8)
}
#[must_use]
pub fn get(self) -> i8 {
self.0
}
}
impl Default for Confidence {
fn default() -> Self {
Self::MAX
}
}
impl std::fmt::Display for Confidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KindSelector {
pub episodic: bool,
pub semantic: bool,
}
impl Default for KindSelector {
fn default() -> Self {
Self {
episodic: true,
semantic: true,
}
}
}
impl KindSelector {
pub fn included_kinds(&self) -> Vec<MemoryKind> {
let mut out = Vec::with_capacity(2);
if self.episodic {
out.push(MemoryKind::Episodic);
}
if self.semantic {
out.push(MemoryKind::Semantic);
}
out
}
pub fn includes_all(&self) -> bool {
self.episodic && self.semantic
}
pub fn is_empty(&self) -> bool {
!self.episodic && !self.semantic
}
}
#[derive(Debug, Clone)]
pub struct Memory {
pub pid: String,
pub scope: Scope,
pub content: String,
pub metadata: serde_json::Value,
pub kind: MemoryKind,
pub source_pid: Option<String>,
pub supersession: Option<SupersessionInfo>,
pub created_at: DateTime<FixedOffset>,
pub updated_at: DateTime<FixedOffset>,
pub event_at: Option<DateTime<FixedOffset>>,
pub score: Option<f32>,
pub status: crate::store::IndexStatus,
pub confidence: Confidence,
pub category: Option<String>,
pub retirement: Option<RetirementReason>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupersessionInfo {
pub winner_pid: String,
pub at: DateTime<FixedOffset>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupersessionEvent {
pub winner_pid: Option<String>,
pub decided_at: DateTime<FixedOffset>,
}
#[derive(Debug, Clone)]
pub enum ForgetTarget {
Pid(String),
Scope(Scope),
}
#[derive(Debug, Clone)]
pub struct Memories {
list: Vec<Memory>,
system_prompt: Option<String>,
graph: crate::graph::GraphContext,
}
impl Memories {
pub fn new(list: Vec<Memory>, system_prompt: Option<String>) -> Self {
Self {
list,
system_prompt,
graph: crate::graph::GraphContext::default(),
}
}
#[must_use]
pub fn with_graph_context(mut self, graph: crate::graph::GraphContext) -> Self {
self.graph = graph;
self
}
pub fn list(&self) -> &[Memory] {
&self.list
}
pub fn system_prompt(&self) -> Option<&str> {
self.system_prompt.as_deref()
}
pub fn graph(&self) -> &crate::graph::GraphContext {
&self.graph
}
}
impl std::fmt::Display for Memories {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(prompt) = &self.system_prompt {
writeln!(f, "{prompt}")?;
}
for memory in &self.list {
writeln!(f, "- {}", memory.content)?;
}
Ok(())
}
}
impl std::ops::Deref for Memories {
type Target = [Memory];
fn deref(&self) -> &[Memory] {
&self.list
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn fixture(content: &str) -> Memory {
let now: DateTime<FixedOffset> = Utc::now().into();
Memory {
pid: "test".into(),
scope: Scope {
agent_id: "a".into(),
org_id: "o".into(),
user_id: "u".into(),
},
content: content.into(),
metadata: serde_json::json!({}),
kind: MemoryKind::Episodic,
source_pid: None,
supersession: None,
created_at: now,
updated_at: now,
event_at: None,
score: None,
status: crate::store::IndexStatus::Pending,
confidence: Confidence::default(),
category: None,
retirement: None,
}
}
#[test]
fn should_render_memory_kind_as_lowercase_string() {
assert_eq!(MemoryKind::Episodic.as_ref(), "episodic");
assert_eq!(MemoryKind::Semantic.as_ref(), "semantic");
}
#[test]
fn should_display_memory_kind_matching_as_ref() {
assert_eq!(MemoryKind::Episodic.to_string(), "episodic");
assert_eq!(MemoryKind::Semantic.to_string(), "semantic");
}
#[test]
fn should_render_retirement_reason_as_lowercase_string() {
assert_eq!(RetirementReason::Rejected.as_ref(), "rejected");
assert_eq!(RetirementReason::Stale.as_ref(), "stale");
}
#[test]
fn should_round_trip_retirement_reason_through_str() {
use std::str::FromStr as _;
assert_eq!(RetirementReason::from_str("rejected").unwrap(), RetirementReason::Rejected);
assert_eq!(RetirementReason::from_str("stale").unwrap(), RetirementReason::Stale);
assert!(RetirementReason::from_str("superseded").is_err());
assert!(RetirementReason::from_str("nonsense").is_err());
}
#[test]
fn should_compute_accuracy_as_one_minus_rejected_over_total() {
let stat = ExtractionStat {
provider: "ollama".to_string(),
model: "qwen3:14b".to_string(),
total: 100,
rejected: 3,
};
assert!((stat.accuracy() - 0.97).abs() < f64::EPSILON);
}
#[test]
fn should_report_perfect_accuracy_when_no_extractions() {
let stat = ExtractionStat {
provider: String::new(),
model: String::new(),
total: 0,
rejected: 0,
};
assert_eq!(stat.accuracy(), 1.0, "zero extractions means nothing to get wrong");
}
#[test]
fn should_parse_memory_kind_from_str() {
use std::str::FromStr as _;
assert_eq!(MemoryKind::from_str("episodic").unwrap(), MemoryKind::Episodic);
assert_eq!(MemoryKind::from_str("semantic").unwrap(), MemoryKind::Semantic);
assert!(MemoryKind::from_str("nonsense").is_err());
}
#[test]
fn should_keep_in_range_confidence_unchanged() {
assert_eq!(Confidence::new(0).get(), 0);
assert_eq!(Confidence::new(73).get(), 73);
assert_eq!(Confidence::new(100).get(), 100);
}
#[test]
fn should_clamp_out_of_range_confidence() {
assert_eq!(Confidence::new(127).get(), 100);
assert_eq!(Confidence::new(-1).get(), 0);
assert_eq!(Confidence::new(-128).get(), 0);
}
#[test]
fn should_scale_unit_confidence_to_percentage() {
assert_eq!(Confidence::from_unit_scale(0.0).get(), 0);
assert_eq!(Confidence::from_unit_scale(0.42).get(), 42);
assert_eq!(Confidence::from_unit_scale(1.0).get(), 100);
}
#[test]
fn should_clamp_unit_confidence_above_one() {
assert_eq!(Confidence::from_unit_scale(1.7).get(), 100);
assert_eq!(Confidence::from_unit_scale(-0.5).get(), 0);
}
#[test]
fn should_map_nan_confidence_to_min() {
assert_eq!(Confidence::from_unit_scale(f32::NAN), Confidence::MIN);
}
#[test]
fn should_default_confidence_to_max() {
assert_eq!(Confidence::default(), Confidence::MAX);
assert_eq!(Confidence::default().get(), 100);
}
#[test]
fn should_display_memories_with_system_prompt_and_bullets() {
let memories = Memories::new(vec![fixture("first"), fixture("second")], Some("Context:".into()));
assert_eq!(memories.to_string(), "Context:\n- first\n- second\n");
}
#[test]
fn should_display_memories_without_system_prompt_as_bullets_only() {
let memories = Memories::new(vec![fixture("only")], None);
assert_eq!(memories.to_string(), "- only\n");
}
#[test]
fn should_display_empty_memories_as_empty_string() {
let memories = Memories::new(Vec::new(), None);
assert_eq!(memories.to_string(), "");
}
#[test]
fn should_deref_memories_to_slice() {
let memories = Memories::new(vec![fixture("a"), fixture("b")], None);
assert_eq!(memories.len(), 2);
assert_eq!(memories[0].content, "a");
}
#[test]
fn should_default_event_at_to_none_in_fixture() {
let memory = fixture("hello");
assert!(
memory.event_at.is_none(),
"fixture default event_at must be None — most memories have no meaningful event-time"
);
}
#[test]
fn should_reject_scope_with_empty_agent_id() {
let scope = Scope {
agent_id: "".to_string(),
org_id: "o".to_string(),
user_id: "u".to_string(),
};
assert_eq!(scope.validate(), Err(ScopeError::Empty));
}
#[test]
fn should_reject_scope_with_empty_org_id() {
let scope = Scope {
agent_id: "a".to_string(),
org_id: "".to_string(),
user_id: "u".to_string(),
};
assert_eq!(scope.validate(), Err(ScopeError::Empty));
}
#[test]
fn should_reject_scope_with_empty_user_id() {
let scope = Scope {
agent_id: "a".to_string(),
org_id: "o".to_string(),
user_id: "".to_string(),
};
assert_eq!(scope.validate(), Err(ScopeError::Empty));
}
#[test]
fn should_accept_scope_with_all_non_empty_fields() {
let scope = Scope {
agent_id: "a".to_string(),
org_id: "o".to_string(),
user_id: "u".to_string(),
};
assert!(scope.validate().is_ok());
}
}