use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::default_metadata;
const KIND_OBSERVATION: &str = "observation";
const KIND_REFLECTION: &str = "reflection";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryKind {
#[default]
Observation,
Reflection,
Persona,
Concept,
Entity,
Claim,
Relation,
Event,
Conversation,
Decision,
}
impl MemoryKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Observation => KIND_OBSERVATION,
Self::Reflection => KIND_REFLECTION,
Self::Persona => "persona",
Self::Concept => "concept",
Self::Entity => "entity",
Self::Claim => "claim",
Self::Relation => "relation",
Self::Event => "event",
Self::Conversation => "conversation",
Self::Decision => "decision",
}
}
#[must_use]
pub fn from_str(s: &str) -> Option<Self> {
match s {
KIND_OBSERVATION => Some(Self::Observation),
KIND_REFLECTION => Some(Self::Reflection),
"persona" => Some(Self::Persona),
"concept" => Some(Self::Concept),
"entity" => Some(Self::Entity),
"claim" => Some(Self::Claim),
"relation" => Some(Self::Relation),
"event" => Some(Self::Event),
"conversation" => Some(Self::Conversation),
"decision" => Some(Self::Decision),
_ => None,
}
}
#[must_use]
pub fn all() -> &'static [Self] {
&[
Self::Observation,
Self::Reflection,
Self::Persona,
Self::Concept,
Self::Entity,
Self::Claim,
Self::Relation,
Self::Event,
Self::Conversation,
Self::Decision,
]
}
#[must_use]
pub fn parse_csv(s: &str) -> Option<Vec<Self>> {
let mut out: Vec<Self> = Vec::new();
let mut saw_any_token = false;
for tok in s.split(',') {
let t = tok.trim();
if t.is_empty() {
continue;
}
saw_any_token = true;
if let Some(k) = Self::from_str(t)
&& !out.contains(&k)
{
out.push(k);
}
}
if !saw_any_token {
None
} else {
Some(out)
}
}
}
impl std::fmt::Display for MemoryKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceSource {
#[default]
CallerProvided,
AutoDerived,
Calibrated,
Decayed,
CuratorDerived,
Default,
}
impl ConfidenceSource {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::CallerProvided => "caller_provided",
Self::AutoDerived => "auto_derived",
Self::Calibrated => "calibrated",
Self::Decayed => "decayed",
Self::CuratorDerived => "curator_derived",
Self::Default => "default",
}
}
#[must_use]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"caller_provided" => Some(Self::CallerProvided),
"auto_derived" => Some(Self::AutoDerived),
"calibrated" => Some(Self::Calibrated),
"decayed" => Some(Self::Decayed),
"curator_derived" => Some(Self::CuratorDerived),
"default" => Some(Self::Default),
_ => None,
}
}
}
impl std::fmt::Display for ConfidenceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConfidenceSignals {
pub source_age_days: f64,
pub atom_derivation: bool,
pub prior_corroboration_count: i64,
pub freshness_factor: f64,
pub baseline_per_source: f64,
}
impl Default for ConfidenceSignals {
fn default() -> Self {
Self {
source_age_days: 0.0,
atom_derivation: false,
prior_corroboration_count: 0,
freshness_factor: 1.0,
baseline_per_source: 0.5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
Short,
Mid,
Long,
}
impl Tier {
pub fn as_str(&self) -> &'static str {
match self {
Self::Short => "short",
Self::Mid => "mid",
Self::Long => "long",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"short" => Some(Self::Short),
"mid" => Some(Self::Mid),
"long" => Some(Self::Long),
_ => None,
}
}
#[cfg(test)]
pub fn rank(&self) -> u8 {
match self {
Self::Short => 0,
Self::Mid => 1,
Self::Long => 2,
}
}
pub fn default_ttl_secs(&self) -> Option<i64> {
match self {
Self::Short => Some(6 * crate::SECS_PER_HOUR),
Self::Mid => Some(crate::SECS_PER_WEEK),
Self::Long => None,
}
}
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
pub id: String,
pub tier: Tier,
pub namespace: String,
pub title: String,
pub content: String,
pub tags: Vec<String>,
pub priority: i32,
pub confidence: f64,
pub source: String,
pub access_count: i64,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_accessed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default = "default_metadata")]
pub metadata: Value,
#[serde(default)]
pub reflection_depth: i32,
#[serde(default)]
pub memory_kind: MemoryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_version: Option<i32>,
#[serde(default)]
pub citations: Vec<Citation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_span: Option<SourceSpan>,
#[serde(default)]
pub confidence_source: ConfidenceSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_signals: Option<ConfidenceSignals>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_decayed_at: Option<String>,
#[serde(default = "default_memory_version")]
pub version: i64,
}
impl Memory {
pub const FIELD_COUNT: usize = 26;
#[must_use]
pub fn effective_expires_at(&self) -> Option<String> {
if self.expires_at.is_some() {
return self.expires_at.clone();
}
let ttl = self.tier.default_ttl_secs()?;
let base = chrono::DateTime::parse_from_rfc3339(&self.created_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
Some((base + chrono::Duration::seconds(ttl)).to_rfc3339())
}
}
#[must_use]
pub fn default_memory_version() -> i64 {
1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EditSource {
#[default]
Human,
Llm,
Hook,
Agent,
}
impl EditSource {
pub const ALL: [Self; 4] = [Self::Human, Self::Llm, Self::Hook, Self::Agent];
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Human => "human",
Self::Llm => "llm",
Self::Hook => "hook",
Self::Agent => "agent",
}
}
#[must_use]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"human" => Some(Self::Human),
"llm" => Some(Self::Llm),
"hook" => Some(Self::Hook),
"agent" => Some(Self::Agent),
_ => None,
}
}
#[must_use]
pub fn default_for_agent_id(agent_id: &str) -> Self {
if agent_id.starts_with(crate::identity::sentinels::AI_AGENT_ID_PREFIX) {
Self::Agent
} else {
Self::Human
}
}
#[must_use]
pub fn appends_and_archives(&self) -> bool {
matches!(self, Self::Llm | Self::Hook)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Citation {
pub uri: String,
pub accessed_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<SourceSpan>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceSpan {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceTier {
Confirmed,
Likely,
Ambiguous,
}
impl ConfidenceTier {
pub const CONFIRMED_MIN: f64 = 0.95;
pub const LIKELY_MIN: f64 = 0.7;
#[must_use]
pub fn from_confidence(c: f64) -> Self {
if c.is_nan() {
return Self::Ambiguous;
}
if c >= Self::CONFIRMED_MIN {
Self::Confirmed
} else if c >= Self::LIKELY_MIN {
Self::Likely
} else {
Self::Ambiguous
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Confirmed => "confirmed",
Self::Likely => "likely",
Self::Ambiguous => "ambiguous",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"confirmed" => Some(Self::Confirmed),
"likely" => Some(Self::Likely),
"ambiguous" => Some(Self::Ambiguous),
_ => None,
}
}
}
impl Memory {
#[must_use]
pub fn confidence_tier(&self) -> ConfidenceTier {
ConfidenceTier::from_confidence(self.confidence)
}
}
impl Default for Memory {
fn default() -> Self {
Self {
id: String::new(),
tier: Tier::Mid,
namespace: crate::DEFAULT_NAMESPACE.to_string(),
title: String::new(),
content: String::new(),
tags: Vec::new(),
priority: 5,
confidence: DEFAULT_CONFIDENCE,
source: "api".to_string(),
access_count: 0,
created_at: String::new(),
updated_at: String::new(),
last_accessed_at: None,
expires_at: None,
metadata: default_metadata(),
reflection_depth: 0,
memory_kind: MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: default_memory_version(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateMemory {
#[serde(default = "default_tier")]
pub tier: Tier,
#[serde(default = "default_namespace")]
pub namespace: String,
pub title: String,
pub content: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default = "default_source")]
pub source: String,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub ttl_secs: Option<i64>,
#[serde(default = "default_metadata")]
pub metadata: Value,
#[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub on_conflict: Option<String>,
#[serde(default)]
pub detect_conflicts: Option<bool>,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub citations: Vec<Citation>,
#[serde(default)]
pub source_uri: Option<String>,
#[serde(default)]
pub source_span: Option<SourceSpan>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub signature: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
pub const DEFAULT_CONFIDENCE: f64 = 1.0;
impl CreateMemory {
#[must_use]
pub fn resolved_confidence(&self) -> f64 {
self.confidence.unwrap_or(DEFAULT_CONFIDENCE)
}
#[must_use]
pub fn resolved_confidence_source(&self) -> ConfidenceSource {
if self.confidence.is_some() {
ConfidenceSource::CallerProvided
} else {
ConfidenceSource::Default
}
}
}
fn default_tier() -> Tier {
Tier::Mid
}
fn default_namespace() -> String {
crate::config::configured_default_namespace()
.unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
}
fn default_priority() -> i32 {
5
}
fn default_source() -> String {
"api".to_string()
}
#[derive(Debug, Deserialize)]
pub struct UpdateMemory {
pub title: Option<String>,
pub content: Option<String>,
pub tier: Option<Tier>,
pub namespace: Option<String>,
pub tags: Option<Vec<String>>,
pub priority: Option<i32>,
pub confidence: Option<f64>,
pub expires_at: Option<String>,
pub metadata: Option<Value>,
pub source_uri: Option<String>,
#[serde(default)]
pub agent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
#[serde(default)]
pub q: String,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub tier: Option<Tier>,
#[serde(default = "default_limit")]
pub limit: Option<usize>,
#[serde(default)]
pub min_priority: Option<i32>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub tags: Option<String>, #[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub as_agent: Option<String>,
#[serde(default)]
pub source_uri: Option<String>,
#[serde(default)]
pub format: Option<String>,
}
#[allow(clippy::unnecessary_wraps)]
fn default_limit() -> Option<usize> {
Some(20)
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub tier: Option<Tier>,
#[serde(default = "default_limit")]
pub limit: Option<usize>,
#[serde(default)]
pub offset: Option<usize>,
#[serde(default)]
pub min_priority: Option<i32>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub agent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RecallQuery {
pub context: Option<String>,
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default = "default_recall_limit")]
pub limit: Option<usize>,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub as_agent: Option<String>,
#[serde(default)]
pub budget_tokens: Option<usize>,
#[serde(default)]
pub context_tokens: Option<String>,
#[serde(default)]
pub session_default: Option<bool>,
#[serde(default)]
pub has_citations: Option<bool>,
#[serde(default)]
pub source_uri_prefix: Option<String>,
#[serde(default)]
pub kinds: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub include_archived: Option<bool>,
#[serde(default)]
pub confidence_tier: Option<String>,
#[serde(default)]
pub verbose_provenance: Option<bool>,
#[serde(default)]
pub format: Option<String>,
}
#[allow(clippy::unnecessary_wraps)]
fn default_recall_limit() -> Option<usize> {
Some(10)
}
#[derive(Debug, Deserialize)]
pub struct RecallBody {
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default = "default_recall_limit")]
pub limit: Option<usize>,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub as_agent: Option<String>,
#[serde(default)]
pub budget_tokens: Option<usize>,
#[serde(default)]
pub context_tokens: Option<Vec<String>>,
#[serde(default)]
pub session_default: Option<bool>,
#[serde(default)]
pub has_citations: Option<bool>,
#[serde(default)]
pub source_uri_prefix: Option<String>,
#[serde(default)]
pub kinds: Option<serde_json::Value>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub include_archived: Option<bool>,
#[serde(default)]
pub confidence_tier: Option<String>,
#[serde(default)]
pub verbose_provenance: Option<bool>,
#[serde(default)]
pub format: Option<String>,
}
impl RecallBody {
#[must_use]
pub fn resolved_query(&self) -> String {
self.context
.as_deref()
.or(self.query.as_deref())
.or(self.q.as_deref())
.unwrap_or("")
.trim()
.to_string()
}
#[must_use]
pub fn resolved_kinds(&self) -> Option<Vec<MemoryKind>> {
let raw = self.kinds.as_ref()?;
if let Some(s) = raw.as_str() {
if s.trim().eq_ignore_ascii_case("all") {
return None;
}
return MemoryKind::parse_csv(s);
}
if let Some(arr) = raw.as_array() {
if arr.is_empty() {
return None;
}
let mut out: Vec<MemoryKind> = Vec::new();
for v in arr {
if let Some(name) = v.as_str()
&& let Some(k) = MemoryKind::from_str(name.trim())
&& !out.contains(&k)
{
out.push(k);
}
}
Some(out)
} else {
None
}
}
}
impl RecallQuery {
#[must_use]
pub fn resolved_kinds(&self) -> Option<Vec<MemoryKind>> {
let s = self.kinds.as_deref()?;
if s.trim().eq_ignore_ascii_case("all") {
return None;
}
MemoryKind::parse_csv(s)
}
}
#[derive(Debug, Deserialize)]
pub struct ForgetQuery {
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub pattern: Option<String>, #[serde(default)]
pub tier: Option<Tier>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RecallMeta {
pub recall_mode: String,
pub reranker_used: String,
pub candidate_counts: CandidateCounts,
pub blend_weight: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct CandidateCounts {
pub fts: usize,
pub hnsw: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RecallTelemetry {
pub fts_candidates: usize,
pub hnsw_candidates: usize,
pub blend_weight_avg: f64,
pub embedding_dim_mismatch: usize,
}
#[derive(Debug, Serialize)]
pub struct Stats {
pub total: usize,
pub by_tier: Vec<TierCount>,
pub by_namespace: Vec<NamespaceCount>,
pub expiring_soon: usize,
pub links_count: usize,
pub db_size_bytes: u64,
#[serde(default)]
pub dim_violations: u64,
#[serde(default)]
pub index_evictions_total: u64,
}
#[derive(Debug, Serialize)]
pub struct TierCount {
pub tier: String,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct NamespaceCount {
pub namespace: String,
pub count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_round_trips_strings() {
for (s, v) in [
("short", Tier::Short),
("mid", Tier::Mid),
("long", Tier::Long),
] {
assert_eq!(Tier::from_str(s), Some(v.clone()));
assert_eq!(v.as_str(), s);
assert_eq!(format!("{v}"), s);
}
}
#[test]
fn tier_from_str_returns_none_for_unknown() {
assert_eq!(Tier::from_str("unknown"), None);
assert_eq!(Tier::from_str(""), None);
assert_eq!(Tier::from_str("SHORT"), None); }
#[test]
fn tier_default_ttl_secs_short_is_six_hours() {
assert_eq!(
Tier::Short.default_ttl_secs(),
Some(6 * crate::SECS_PER_HOUR)
);
}
#[test]
fn tier_default_ttl_secs_mid_is_seven_days() {
assert_eq!(Tier::Mid.default_ttl_secs(), Some(crate::SECS_PER_WEEK));
}
#[test]
fn tier_default_ttl_secs_long_is_none() {
assert_eq!(Tier::Long.default_ttl_secs(), None);
}
#[test]
fn tier_rank_orders_short_mid_long() {
assert!(Tier::Short.rank() < Tier::Mid.rank());
assert!(Tier::Mid.rank() < Tier::Long.rank());
}
#[test]
fn effective_expires_at_backfills_mid_at_created_plus_one_week() {
let mut m = Memory::default();
m.tier = Tier::Mid;
m.created_at = "2026-01-01T00:00:00+00:00".to_string();
m.expires_at = None;
let got = m.effective_expires_at().expect("mid must backfill");
let parsed = chrono::DateTime::parse_from_rfc3339(&got).unwrap();
let base = chrono::DateTime::parse_from_rfc3339(&m.created_at).unwrap();
assert_eq!(
(parsed - base).num_seconds(),
crate::SECS_PER_WEEK,
"mid backfill must equal created_at + SECS_PER_WEEK"
);
}
#[test]
fn effective_expires_at_backfills_short_at_created_plus_six_hours() {
let mut m = Memory::default();
m.tier = Tier::Short;
m.created_at = "2026-01-01T00:00:00+00:00".to_string();
m.expires_at = None;
let got = m.effective_expires_at().expect("short must backfill");
let parsed = chrono::DateTime::parse_from_rfc3339(&got).unwrap();
let base = chrono::DateTime::parse_from_rfc3339(&m.created_at).unwrap();
assert_eq!(
(parsed - base).num_seconds(),
6 * crate::SECS_PER_HOUR,
"short backfill must equal created_at + 6h"
);
}
#[test]
fn effective_expires_at_long_stays_none() {
let mut m = Memory::default();
m.tier = Tier::Long;
m.created_at = "2026-01-01T00:00:00+00:00".to_string();
m.expires_at = None;
assert_eq!(
m.effective_expires_at(),
None,
"long has no TTL — must stay immortal"
);
}
#[test]
fn effective_expires_at_preserves_explicit_value() {
let explicit = "2027-06-15T12:00:00+00:00".to_string();
for tier in [Tier::Short, Tier::Mid, Tier::Long] {
let mut m = Memory::default();
m.tier = tier;
m.created_at = "2026-01-01T00:00:00+00:00".to_string();
m.expires_at = Some(explicit.clone());
assert_eq!(
m.effective_expires_at(),
Some(explicit.clone()),
"an explicit expiry must win over the tier default"
);
}
}
#[test]
fn effective_expires_at_output_is_rfc3339_for_lexical_gc_compare() {
let mut m = Memory::default();
m.tier = Tier::Mid;
m.created_at = "2026-01-01T00:00:00+00:00".to_string();
m.expires_at = None;
let got = m.effective_expires_at().unwrap();
assert!(got.contains('T'), "must be ISO 'T'-separated: {got}");
assert!(!got.contains(' '), "must not contain a space: {got}");
assert!(
chrono::DateTime::parse_from_rfc3339(&got).is_ok(),
"must round-trip through rfc3339 parse: {got}"
);
}
#[test]
fn tier_serializes_to_snake_case() {
let v = serde_json::to_value(Tier::Short).unwrap();
assert_eq!(v, serde_json::Value::String("short".to_string()));
let v = serde_json::to_value(Tier::Mid).unwrap();
assert_eq!(v, serde_json::Value::String("mid".to_string()));
let v = serde_json::to_value(Tier::Long).unwrap();
assert_eq!(v, serde_json::Value::String("long".to_string()));
}
#[test]
fn memory_default_uses_mid_tier_and_global_namespace() {
let m = Memory::default();
assert_eq!(m.tier, Tier::Mid);
assert_eq!(m.namespace, "global");
assert_eq!(m.priority, 5);
assert!((m.confidence - 1.0).abs() < f64::EPSILON);
assert_eq!(m.source, "api");
assert_eq!(m.access_count, 0);
assert_eq!(m.reflection_depth, 0);
assert!(m.last_accessed_at.is_none());
assert!(m.expires_at.is_none());
}
#[test]
fn memory_round_trips_through_serde_with_reflection_depth() {
let mut m = Memory::default();
m.id = "mem-1".to_string();
m.title = "test".to_string();
m.content = "body".to_string();
m.created_at = "2026-01-01T00:00:00Z".to_string();
m.updated_at = "2026-01-01T00:00:00Z".to_string();
m.reflection_depth = 3;
let s = serde_json::to_string(&m).unwrap();
let back: Memory = serde_json::from_str(&s).unwrap();
assert_eq!(back.id, "mem-1");
assert_eq!(back.reflection_depth, 3);
}
#[test]
fn memory_deserialises_pre_v070_payload_without_reflection_depth() {
let json = serde_json::json!({
"id": "old-mem",
"tier": Tier::Mid.as_str(),
"namespace": "ns",
"title": "t",
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"metadata": {},
});
let m: Memory = serde_json::from_value(json).unwrap();
assert_eq!(m.reflection_depth, 0);
}
fn cm_minimal() -> serde_json::Value {
serde_json::json!({
"title": "t",
"content": "c",
})
}
#[test]
fn create_memory_defaults_tier_to_mid() {
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.tier, Tier::Mid);
}
#[test]
fn create_memory_defaults_namespace_to_global() {
let _gate = crate::config::lock_configured_default_namespace_for_test();
crate::config::set_configured_default_namespace(None);
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.namespace, "global");
}
#[test]
fn create_memory_namespace_default_honours_configured_1590() {
let _gate = crate::config::lock_configured_default_namespace_for_test();
crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.namespace, "alphaone", "#1590: configured default wins");
let mut v = cm_minimal();
v["namespace"] = serde_json::json!("explicit-ns");
let cm: CreateMemory = serde_json::from_value(v).unwrap();
assert_eq!(cm.namespace, "explicit-ns", "explicit body value wins");
crate::config::set_configured_default_namespace(None);
}
#[test]
fn create_memory_defaults_priority_to_5() {
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.priority, 5);
}
#[test]
fn create_memory_defaults_confidence_to_one() {
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.confidence, None, "omitted confidence must be None");
assert!((cm.resolved_confidence() - DEFAULT_CONFIDENCE).abs() < f64::EPSILON);
assert_eq!(
cm.resolved_confidence_source(),
ConfidenceSource::Default,
"#1591: omitted confidence must stamp source=default"
);
}
#[test]
fn create_memory_explicit_confidence_is_caller_provided_1591() {
let mut v = cm_minimal();
v["confidence"] = serde_json::json!(0.8);
let cm: CreateMemory = serde_json::from_value(v).unwrap();
assert_eq!(cm.confidence, Some(0.8));
assert!((cm.resolved_confidence() - 0.8).abs() < f64::EPSILON);
assert_eq!(
cm.resolved_confidence_source(),
ConfidenceSource::CallerProvided
);
}
#[test]
fn create_memory_defaults_source_to_api() {
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.source, "api");
}
#[test]
fn create_memory_defaults_metadata_to_empty_object() {
let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
assert_eq!(cm.metadata, serde_json::json!({}));
}
#[test]
fn recall_body_resolved_query_prefers_context() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"context": "c-value",
"query": "q-value",
"q": "qq-value",
}))
.unwrap();
assert_eq!(body.resolved_query(), "c-value");
}
#[test]
fn recall_body_resolved_query_falls_back_to_query_then_q() {
let body: RecallBody =
serde_json::from_value(serde_json::json!({"query": "q-value", "q": "qq"})).unwrap();
assert_eq!(body.resolved_query(), "q-value");
let body: RecallBody = serde_json::from_value(serde_json::json!({"q": "qq"})).unwrap();
assert_eq!(body.resolved_query(), "qq");
}
#[test]
fn recall_body_resolved_query_empty_when_all_absent() {
let body: RecallBody = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(body.resolved_query(), "");
}
#[test]
fn recall_body_resolved_query_trims_whitespace() {
let body: RecallBody =
serde_json::from_value(serde_json::json!({"context": " spaced "})).unwrap();
assert_eq!(body.resolved_query(), "spaced");
}
#[test]
fn search_query_defaults_limit_to_20() {
let q: SearchQuery = serde_json::from_value(serde_json::json!({"q": "x"})).unwrap();
assert_eq!(q.limit, Some(20));
}
#[test]
fn recall_query_defaults_limit_to_10() {
let q: RecallQuery = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(q.limit, Some(10));
}
#[test]
fn list_query_defaults_limit_to_20() {
let q: ListQuery = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(q.limit, Some(20));
}
#[test]
fn memory_kind_round_trips_every_variant_string() {
for (s, v) in [
("observation", MemoryKind::Observation),
("reflection", MemoryKind::Reflection),
("persona", MemoryKind::Persona),
("concept", MemoryKind::Concept),
("entity", MemoryKind::Entity),
("claim", MemoryKind::Claim),
("relation", MemoryKind::Relation),
("event", MemoryKind::Event),
("conversation", MemoryKind::Conversation),
("decision", MemoryKind::Decision),
] {
assert_eq!(MemoryKind::from_str(s), Some(v));
assert_eq!(v.as_str(), s);
assert_eq!(format!("{v}"), s);
}
}
#[test]
fn memory_kind_from_str_returns_none_for_unknown() {
assert_eq!(MemoryKind::from_str("unknown"), None);
assert_eq!(MemoryKind::from_str(""), None);
assert_eq!(MemoryKind::from_str("OBSERVATION"), None); }
#[test]
fn memory_kind_all_enumerates_in_declaration_order() {
let all = MemoryKind::all();
assert_eq!(all.len(), 10);
assert_eq!(all[0], MemoryKind::Observation);
assert_eq!(all[1], MemoryKind::Reflection);
assert_eq!(all[2], MemoryKind::Persona);
assert_eq!(all[9], MemoryKind::Decision);
}
#[test]
fn memory_kind_default_is_observation() {
let k: MemoryKind = MemoryKind::default();
assert_eq!(k, MemoryKind::Observation);
}
#[test]
fn memory_kind_parse_csv_empty_string_returns_none() {
assert_eq!(MemoryKind::parse_csv(""), None);
assert_eq!(MemoryKind::parse_csv(" "), None);
assert_eq!(MemoryKind::parse_csv(",,, "), None);
}
#[test]
fn memory_kind_parse_csv_all_unknown_returns_empty_vec() {
let parsed = MemoryKind::parse_csv("reflektion,observetion");
assert_eq!(parsed, Some(Vec::new()));
}
#[test]
fn memory_kind_parse_csv_mixed_known_and_unknown_drops_unknown() {
let parsed = MemoryKind::parse_csv("reflection,bogus,concept");
assert_eq!(
parsed,
Some(vec![MemoryKind::Reflection, MemoryKind::Concept])
);
}
#[test]
fn memory_kind_parse_csv_dedups_repeated_tokens() {
let parsed = MemoryKind::parse_csv("claim,claim,event,claim");
assert_eq!(parsed, Some(vec![MemoryKind::Claim, MemoryKind::Event]));
}
#[test]
fn memory_kind_parse_csv_trims_whitespace() {
let parsed = MemoryKind::parse_csv(" concept , entity ");
assert_eq!(parsed, Some(vec![MemoryKind::Concept, MemoryKind::Entity]));
}
#[test]
fn memory_kind_serialises_to_snake_case() {
let v = serde_json::to_value(MemoryKind::Conversation).unwrap();
assert_eq!(v, serde_json::Value::String("conversation".to_string()));
}
#[test]
fn confidence_source_round_trips_every_variant_string() {
for (s, v) in [
("caller_provided", ConfidenceSource::CallerProvided),
("auto_derived", ConfidenceSource::AutoDerived),
("calibrated", ConfidenceSource::Calibrated),
("decayed", ConfidenceSource::Decayed),
("curator_derived", ConfidenceSource::CuratorDerived),
("default", ConfidenceSource::Default),
] {
assert_eq!(ConfidenceSource::from_str(s), Some(v));
assert_eq!(v.as_str(), s);
assert_eq!(format!("{v}"), s);
}
}
#[test]
fn edit_source_agent_variant_wire_and_semantics_1600() {
for v in EditSource::ALL {
assert_eq!(
EditSource::from_str(v.as_str()),
Some(v),
"EditSource wire string must round-trip"
);
}
assert_eq!(EditSource::from_str("agent"), Some(EditSource::Agent));
assert_eq!(EditSource::Agent.as_str(), "agent");
assert!(
!EditSource::Agent.appends_and_archives(),
"#1600: Agent mutates in place exactly like Human"
);
assert!(EditSource::Llm.appends_and_archives());
assert!(EditSource::Hook.appends_and_archives());
assert_eq!(
serde_json::to_value(EditSource::Agent).unwrap(),
serde_json::Value::String("agent".to_string())
);
assert_eq!(EditSource::from_str("robot"), None, "unknown stays None");
}
#[test]
fn edit_source_default_for_agent_id_matrix_1600() {
assert_eq!(
EditSource::default_for_agent_id("ai:claude-code@host:pid-1"),
EditSource::Agent
);
assert_eq!(
EditSource::default_for_agent_id("host:box:pid-2-abcd1234"),
EditSource::Human
);
assert_eq!(
EditSource::default_for_agent_id("anonymous:pid-3-ffff0000"),
EditSource::Human
);
assert_eq!(EditSource::default_for_agent_id("alice"), EditSource::Human);
}
#[test]
fn confidence_source_from_str_returns_none_for_unknown() {
assert_eq!(ConfidenceSource::from_str("unknown"), None);
assert_eq!(ConfidenceSource::from_str(""), None);
}
#[test]
fn confidence_source_default_is_caller_provided() {
let v: ConfidenceSource = ConfidenceSource::default();
assert_eq!(v, ConfidenceSource::CallerProvided);
}
#[test]
fn confidence_source_serialises_to_snake_case() {
let v = serde_json::to_value(ConfidenceSource::AutoDerived).unwrap();
assert_eq!(v, serde_json::Value::String("auto_derived".to_string()));
}
#[test]
fn confidence_signals_default_has_expected_values() {
let s = ConfidenceSignals::default();
assert!((s.source_age_days - 0.0).abs() < f64::EPSILON);
assert!(!s.atom_derivation);
assert_eq!(s.prior_corroboration_count, 0);
assert!((s.freshness_factor - 1.0).abs() < f64::EPSILON);
assert!((s.baseline_per_source - 0.5).abs() < f64::EPSILON);
}
#[test]
fn confidence_signals_round_trips_through_serde() {
let s = ConfidenceSignals {
source_age_days: 12.5,
atom_derivation: true,
prior_corroboration_count: 3,
freshness_factor: 0.75,
baseline_per_source: 0.62,
};
let v = serde_json::to_value(&s).unwrap();
let back: ConfidenceSignals = serde_json::from_value(v).unwrap();
assert_eq!(back, s);
}
#[test]
fn source_span_round_trips_through_serde() {
let span = SourceSpan { start: 12, end: 34 };
let v = serde_json::to_value(span).unwrap();
let back: SourceSpan = serde_json::from_value(v.clone()).unwrap();
assert_eq!(back, span);
assert_eq!(v["start"], 12);
assert_eq!(v["end"], 34);
}
#[test]
fn citation_round_trips_through_serde_with_optional_fields_unset() {
let c = Citation {
uri: "doc:abc123".to_string(),
accessed_at: "2026-01-01T00:00:00Z".to_string(),
hash: None,
span: None,
};
let s = serde_json::to_string(&c).unwrap();
assert!(!s.contains("hash"));
assert!(!s.contains("span"));
let back: Citation = serde_json::from_str(&s).unwrap();
assert_eq!(back, c);
}
#[test]
fn citation_round_trips_with_hash_and_span_set() {
let c = Citation {
uri: "uri:https://example.com/paper".to_string(),
accessed_at: "2026-02-03T04:05:06Z".to_string(),
hash: Some("a".repeat(64)),
span: Some(SourceSpan { start: 0, end: 100 }),
};
let v = serde_json::to_value(&c).unwrap();
let back: Citation = serde_json::from_value(v).unwrap();
assert_eq!(back, c);
}
#[test]
fn memory_default_populates_form4_and_form5_defaults() {
let m = Memory::default();
assert!(m.citations.is_empty());
assert!(m.source_uri.is_none());
assert!(m.source_span.is_none());
assert_eq!(m.confidence_source, ConfidenceSource::CallerProvided);
assert!(m.confidence_signals.is_none());
assert!(m.confidence_decayed_at.is_none());
assert_eq!(m.memory_kind, MemoryKind::Observation);
assert!(m.entity_id.is_none());
assert!(m.persona_version.is_none());
}
#[test]
fn memory_round_trips_with_all_v070_form_fields_populated() {
let mut m = Memory::default();
m.id = "mem-form".to_string();
m.title = "fact-bearer".to_string();
m.content = "the build broke at 14:32".to_string();
m.created_at = "2026-05-01T00:00:00Z".to_string();
m.updated_at = "2026-05-01T00:00:00Z".to_string();
m.memory_kind = MemoryKind::Claim;
m.entity_id = Some("entity-xyz".to_string());
m.persona_version = Some(7);
m.citations = vec![Citation {
uri: "doc:src-1".to_string(),
accessed_at: "2026-05-01T00:00:00Z".to_string(),
hash: None,
span: None,
}];
m.source_uri = Some("uri:https://example.com".to_string());
m.source_span = Some(SourceSpan { start: 5, end: 10 });
m.confidence_source = ConfidenceSource::Calibrated;
m.confidence_signals = Some(ConfidenceSignals::default());
m.confidence_decayed_at = Some("2026-04-01T00:00:00Z".to_string());
let s = serde_json::to_string(&m).unwrap();
let back: Memory = serde_json::from_str(&s).unwrap();
assert_eq!(back.id, m.id);
assert_eq!(back.memory_kind, MemoryKind::Claim);
assert_eq!(back.entity_id.as_deref(), Some("entity-xyz"));
assert_eq!(back.persona_version, Some(7));
assert_eq!(back.citations.len(), 1);
assert_eq!(back.citations[0].uri, "doc:src-1");
assert_eq!(back.source_uri.as_deref(), Some("uri:https://example.com"));
assert_eq!(back.source_span, Some(SourceSpan { start: 5, end: 10 }));
assert_eq!(back.confidence_source, ConfidenceSource::Calibrated);
assert!(back.confidence_signals.is_some());
assert_eq!(
back.confidence_decayed_at.as_deref(),
Some("2026-04-01T00:00:00Z")
);
}
#[test]
fn memory_deserialises_pre_form4_payload_without_form4_fields() {
let json = serde_json::json!({
"id": "old-mem",
"tier": Tier::Long.as_str(),
"namespace": "ns",
"title": "t",
"content": "c",
"tags": [],
"priority": 5,
"confidence": 1.0,
"source": "api",
"access_count": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"metadata": {},
});
let m: Memory = serde_json::from_value(json).unwrap();
assert!(m.citations.is_empty());
assert!(m.source_uri.is_none());
assert!(m.source_span.is_none());
assert_eq!(m.confidence_source, ConfidenceSource::CallerProvided);
assert!(m.confidence_signals.is_none());
assert!(m.confidence_decayed_at.is_none());
assert!(m.entity_id.is_none());
assert!(m.persona_version.is_none());
assert_eq!(m.memory_kind, MemoryKind::Observation);
}
#[test]
fn recall_body_resolved_kinds_handles_all_keyword() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": "ALL",
}))
.unwrap();
assert_eq!(body.resolved_kinds(), None);
}
#[test]
fn recall_body_resolved_kinds_csv_parses_known_tokens() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": "concept,claim",
}))
.unwrap();
let kinds = body.resolved_kinds().unwrap();
assert!(kinds.contains(&MemoryKind::Concept));
assert!(kinds.contains(&MemoryKind::Claim));
}
#[test]
fn recall_body_resolved_kinds_array_parses_known_tokens() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": ["event", "entity", "bogus", "entity"],
}))
.unwrap();
let kinds = body.resolved_kinds().unwrap();
assert_eq!(kinds, vec![MemoryKind::Event, MemoryKind::Entity]);
}
#[test]
fn recall_body_resolved_kinds_empty_array_returns_none() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": [],
}))
.unwrap();
assert_eq!(body.resolved_kinds(), None);
}
#[test]
fn recall_body_resolved_kinds_only_unknown_array_returns_empty_vec() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": ["reflektion"],
}))
.unwrap();
assert_eq!(body.resolved_kinds(), Some(Vec::new()));
}
#[test]
fn recall_body_resolved_kinds_absent_returns_none() {
let body: RecallBody = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(body.resolved_kinds(), None);
}
#[test]
fn recall_body_resolved_kinds_non_string_non_array_returns_none() {
let body: RecallBody = serde_json::from_value(serde_json::json!({
"kinds": 42,
}))
.unwrap();
assert_eq!(body.resolved_kinds(), None);
}
#[test]
fn recall_query_resolved_kinds_handles_all_keyword() {
let q: RecallQuery = serde_json::from_value(serde_json::json!({
"kinds": "all",
}))
.unwrap();
assert_eq!(q.resolved_kinds(), None);
}
#[test]
fn recall_query_resolved_kinds_parses_csv() {
let q: RecallQuery = serde_json::from_value(serde_json::json!({
"kinds": "decision,relation",
}))
.unwrap();
let kinds = q.resolved_kinds().unwrap();
assert!(kinds.contains(&MemoryKind::Decision));
assert!(kinds.contains(&MemoryKind::Relation));
}
#[test]
fn recall_query_resolved_kinds_absent_returns_none() {
let q: RecallQuery = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(q.resolved_kinds(), None);
}
#[test]
fn create_memory_accepts_form4_fields_when_present() {
let cm: CreateMemory = serde_json::from_value(serde_json::json!({
"title": "t",
"content": "c",
"citations": [{
"uri": "doc:abc",
"accessed_at": "2026-01-01T00:00:00Z",
}],
"source_uri": "uri:https://example.com",
"source_span": {"start": 0, "end": 5},
}))
.unwrap();
assert_eq!(cm.citations.len(), 1);
assert_eq!(cm.source_uri.as_deref(), Some("uri:https://example.com"));
assert_eq!(cm.source_span, Some(SourceSpan { start: 0, end: 5 }));
}
#[test]
fn create_memory_kind_field_deserialises_known_tokens() {
for token in [
"observation",
"reflection",
"persona",
"concept",
"entity",
"claim",
"relation",
"event",
"conversation",
"decision",
] {
let cm: CreateMemory = serde_json::from_value(serde_json::json!({
"title": "t",
"content": "c",
"kind": token,
}))
.unwrap();
assert_eq!(
cm.kind.as_deref(),
Some(token),
"kind={token} must round-trip on the wire"
);
let parsed = cm.kind.as_deref().and_then(MemoryKind::from_str);
assert_eq!(
parsed.map(|k| k.as_str()),
Some(token),
"kind={token} must parse back into MemoryKind",
);
}
}
#[test]
fn create_memory_kind_field_absent_defaults_to_none() {
let cm: CreateMemory = serde_json::from_value(serde_json::json!({
"title": "t",
"content": "c",
}))
.unwrap();
assert_eq!(cm.kind, None);
let resolved = cm
.kind
.as_deref()
.and_then(MemoryKind::from_str)
.unwrap_or_default();
assert_eq!(resolved, MemoryKind::Observation);
}
#[test]
fn create_memory_kind_field_unknown_token_silently_falls_through_to_observation() {
let cm: CreateMemory = serde_json::from_value(serde_json::json!({
"title": "t",
"content": "c",
"kind": "future_variant_v100",
}))
.unwrap();
assert_eq!(cm.kind.as_deref(), Some("future_variant_v100"));
let resolved = cm
.kind
.as_deref()
.and_then(MemoryKind::from_str)
.unwrap_or_default();
assert_eq!(
resolved,
MemoryKind::Observation,
"unknown kind token must silently fall through to Observation \
for forward-compat with future-variant clients",
);
}
}