use crate::{
extensions::util::generate_id,
utils::time::{now_iso, now_ms, parse_iso_ms},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::{HashMap, HashSet, VecDeque},
fs, io,
path::Path,
};
use strum_macros::{EnumString, IntoStaticStr};
pub(super) const GRAPH_VERSION: u32 = 2;
pub(super) const MAX_ENTRIES: usize = 1000;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub(super) enum MemoryCategory {
Fact,
Preference,
Entity,
Correction,
}
impl MemoryCategory {
pub(super) fn as_label(self) -> &'static str {
self.into()
}
pub(super) fn parse_lenient(raw: &str) -> Self {
let trimmed = raw.trim();
match trimmed.parse::<Self>() {
Ok(category) => category,
Err(_) => match trimmed.to_ascii_lowercase().as_str() {
"facts" => Self::Fact,
"preferences" | "pref" => Self::Preference,
"entities" => Self::Entity,
"corrections" | "bugs" | "fix" | "bug" => Self::Correction,
"事实" => Self::Fact,
"偏好" => Self::Preference,
"实体" => Self::Entity,
"纠正" | "修正" => Self::Correction,
_ => Self::Fact,
},
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub(super) enum TrustLevel {
High,
#[default]
Medium,
Low,
}
impl TrustLevel {
fn weight(self) -> f64 {
match self {
Self::High => 1.5,
Self::Medium => 1.0,
Self::Low => 0.7,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub(super) enum MemoryScope {
Project,
Global,
#[default]
All,
}
impl MemoryScope {
pub(super) fn includes_project(self) -> bool {
matches!(self, Self::Project | Self::All)
}
pub(super) fn includes_global(self) -> bool {
matches!(self, Self::Global | Self::All)
}
pub(super) fn as_label(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct Reinforcement {
pub(super) session_id: String,
#[serde(default)]
pub(super) message_index: usize,
pub(super) timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct MemoryEntry {
pub(super) id: String,
pub(super) category: MemoryCategory,
pub(super) content: String,
#[serde(default)]
pub(super) tags: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(super) search_text: String,
pub(super) created_at: String,
pub(super) updated_at: String,
#[serde(default)]
pub(super) access_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) source: Option<String>,
#[serde(default)]
pub(super) trust: TrustLevel,
#[serde(default)]
pub(super) strength: u32,
#[serde(default = "default_active")]
pub(super) active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) superseded_by: Option<String>,
#[serde(default)]
pub(super) reinforcements: Vec<Reinforcement>,
#[serde(default = "default_confidence")]
pub(super) confidence: f32,
}
fn default_active() -> bool {
true
}
fn default_confidence() -> f32 {
1.0
}
impl MemoryEntry {
pub(super) fn new(category: MemoryCategory, content: impl Into<String>) -> Self {
let now = now_iso();
let content = content.into();
let mut entry = Self {
id: format!("mem_{}", generate_id()),
category,
search_text: String::new(),
content,
tags: Vec::new(),
created_at: now.clone(),
updated_at: now,
access_count: 0,
source: None,
trust: TrustLevel::default(),
strength: 1,
active: true,
superseded_by: None,
reinforcements: Vec::new(),
confidence: 1.0,
};
entry.refresh_search_text();
entry
}
pub(super) fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
pub(super) fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self.refresh_search_text();
self
}
pub(super) fn with_trust(mut self, trust: TrustLevel) -> Self {
self.trust = trust;
self
}
pub(super) fn refresh_search_text(&mut self) {
self.search_text = normalize_search_text(&self.content, &self.tags);
}
pub(super) fn searchable_text(&self) -> String {
if self.search_text.is_empty() {
normalize_search_text(&self.content, &self.tags)
} else {
self.search_text.clone()
}
}
pub(super) fn created_ms(&self) -> u64 {
parse_iso_ms(&self.created_at).unwrap_or_else(now_ms)
}
pub(super) fn updated_ms(&self) -> u64 {
parse_iso_ms(&self.updated_at).unwrap_or_else(now_ms)
}
pub(super) fn effective_confidence(&self) -> f32 {
let age_days = (now_ms().saturating_sub(self.created_ms()) as f64) / 86_400_000.0;
let half_life = match self.category {
MemoryCategory::Correction => 365.0,
MemoryCategory::Preference => 90.0,
MemoryCategory::Entity => 60.0,
MemoryCategory::Fact => 30.0,
};
let decay = (-age_days / half_life * std::f64::consts::LN_2).exp();
let boost = 1.0 + 0.1 * ((self.access_count as f64) + 1.0).ln();
(f64::from(self.confidence) * decay * boost).min(1.0) as f32
}
pub(super) fn memory_score(&self) -> f64 {
if !self.active {
return 0.0;
}
let age_hours = (now_ms().saturating_sub(self.updated_ms()) as f64) / 3_600_000.0;
let mut score = 100.0 / (1.0 + age_hours / 24.0);
score += (f64::from(self.access_count)).sqrt() * 10.0;
score += match self.category {
MemoryCategory::Correction => 50.0,
MemoryCategory::Preference => 30.0,
MemoryCategory::Fact => 20.0,
MemoryCategory::Entity => 10.0,
};
score *= self.trust.weight();
score *= 0.5 + 0.5 * f64::from(self.effective_confidence());
score += (f64::from(self.strength)).ln() * 5.0;
score
}
pub(super) fn touch(&mut self) {
self.access_count = self.access_count.saturating_add(1);
self.updated_at = now_iso();
self.confidence = (self.confidence + 0.05).min(1.0);
}
pub(super) fn reinforce(&mut self, session_id: &str) {
self.strength = self.strength.saturating_add(1);
self.updated_at = now_iso();
self.reinforcements.push(Reinforcement {
session_id: session_id.to_string(),
message_index: 0,
timestamp: now_iso(),
});
}
pub(super) fn supersede(&mut self, new_id: &str) {
self.active = false;
self.superseded_by = Some(new_id.to_string());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TagEntry {
pub(super) id: String,
pub(super) name: String,
#[serde(default)]
pub(super) count: u32,
pub(super) created_at: String,
}
impl TagEntry {
fn new(name: &str) -> Self {
Self {
id: format!("tag:{name}"),
name: name.to_string(),
count: 0,
created_at: now_iso(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(super) enum EdgeKind {
HasTag,
InCluster,
RelatesTo {
#[serde(default = "default_weight")]
weight: f32,
},
Supersedes,
Contradicts,
}
fn default_weight() -> f32 {
1.0
}
impl EdgeKind {
fn traversal_weight(&self) -> f32 {
match self {
Self::HasTag => 0.8,
Self::InCluster => 0.6,
Self::RelatesTo { weight } => *weight,
Self::Supersedes => 0.9,
Self::Contradicts => 0.3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct Edge {
pub(super) target: String,
#[serde(flatten)]
pub(super) kind: EdgeKind,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct GraphMetadata {
#[serde(default)]
pub(super) retrieval_count: u64,
#[serde(default)]
pub(super) link_discovery_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct MemoryGraph {
pub(super) graph_version: u32,
pub(super) memories: HashMap<String, MemoryEntry>,
#[serde(default)]
pub(super) tags: HashMap<String, TagEntry>,
#[serde(default)]
pub(super) clusters: HashMap<String, Value>,
#[serde(default)]
pub(super) edges: HashMap<String, Vec<Edge>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub(super) reverse_edges: HashMap<String, Vec<String>>,
#[serde(default)]
pub(super) metadata: GraphMetadata,
}
impl Default for MemoryGraph {
fn default() -> Self {
Self::new()
}
}
impl MemoryGraph {
pub(super) fn new() -> Self {
Self {
graph_version: GRAPH_VERSION,
memories: HashMap::new(),
tags: HashMap::new(),
clusters: HashMap::new(),
edges: HashMap::new(),
reverse_edges: HashMap::new(),
metadata: GraphMetadata::default(),
}
}
#[allow(dead_code)]
pub(super) fn memory_count(&self) -> usize {
self.memories.len()
}
pub(super) fn edge_count(&self) -> usize {
self.edges.values().map(|v| v.len()).sum()
}
pub(super) fn add_memory(&mut self, mut entry: MemoryEntry) -> String {
entry.refresh_search_text();
let id = entry.id.clone();
for tag in entry.tags.clone() {
self.ensure_tag(&tag);
let tag_id = format!("tag:{tag}");
self.add_edge_internal(&id, &tag_id, EdgeKind::HasTag);
if let Some(entry) = self.tags.get_mut(&tag_id) {
entry.count = entry.count.saturating_add(1);
}
}
self.memories.insert(id.clone(), entry);
self.prune_to_limit(&id);
id
}
fn prune_to_limit(&mut self, keep: &str) {
if self.memories.len() <= MAX_ENTRIES {
return;
}
let mut victims: Vec<(String, f64)> = self
.memories
.values()
.filter(|entry| entry.id != keep)
.map(|entry| (entry.id.clone(), entry.memory_score()))
.collect();
victims.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
let overflow = self.memories.len() - MAX_ENTRIES;
for (id, _) in victims.into_iter().take(overflow) {
self.remove_memory(&id);
}
}
pub(super) fn get(&self, id: &str) -> Option<&MemoryEntry> {
self.memories.get(id)
}
pub(super) fn get_mut(&mut self, id: &str) -> Option<&mut MemoryEntry> {
self.memories.get_mut(id)
}
pub(super) fn remove_memory(&mut self, id: &str) -> Option<MemoryEntry> {
if let Some(edges) = self.edges.remove(id) {
for edge in edges {
if let Some(reverse) = self.reverse_edges.get_mut(&edge.target) {
reverse.retain(|src| src != id);
}
if matches!(edge.kind, EdgeKind::HasTag)
&& let Some(tag) = self.tags.get_mut(&edge.target)
{
tag.count = tag.count.saturating_sub(1);
}
}
}
if let Some(sources) = self.reverse_edges.remove(id) {
for source in sources {
if let Some(edges) = self.edges.get_mut(&source) {
edges.retain(|e| e.target != id);
}
}
}
self.memories.remove(id)
}
pub(super) fn active_memories(&self) -> impl Iterator<Item = &MemoryEntry> {
self.memories.values().filter(|m| m.active)
}
pub(super) fn ensure_tag(&mut self, name: &str) -> &TagEntry {
let tag_id = format!("tag:{name}");
self.tags
.entry(tag_id.clone())
.or_insert_with(|| TagEntry::new(name))
}
pub(super) fn tag_memory(&mut self, memory_id: &str, tag_name: &str) {
self.ensure_tag(tag_name);
let tag_id = format!("tag:{tag_name}");
let already = self.edges.get(memory_id).is_some_and(|edges| {
edges
.iter()
.any(|e| e.target == tag_id && matches!(e.kind, EdgeKind::HasTag))
});
if already {
return;
}
self.add_edge_internal(memory_id, &tag_id, EdgeKind::HasTag);
if let Some(tag) = self.tags.get_mut(&tag_id) {
tag.count = tag.count.saturating_add(1);
}
if let Some(memory) = self.memories.get_mut(memory_id)
&& !memory.tags.iter().any(|t| t == tag_name)
{
memory.tags.push(tag_name.to_string());
memory.refresh_search_text();
}
}
pub(super) fn untag_memory(&mut self, memory_id: &str, tag_name: &str) {
let tag_id = format!("tag:{tag_name}");
if let Some(edges) = self.edges.get_mut(memory_id) {
edges.retain(|e| !(e.target == tag_id && matches!(e.kind, EdgeKind::HasTag)));
}
if let Some(sources) = self.reverse_edges.get_mut(&tag_id) {
sources.retain(|s| s != memory_id);
}
if let Some(tag) = self.tags.get_mut(&tag_id) {
tag.count = tag.count.saturating_sub(1);
}
if let Some(memory) = self.memories.get_mut(memory_id) {
memory.tags.retain(|t| t != tag_name);
memory.refresh_search_text();
}
}
pub(super) fn sorted_tags(&self) -> Vec<&TagEntry> {
let mut tags: Vec<&TagEntry> = self.tags.values().filter(|t| t.count > 0).collect();
tags.sort_by(|a, b| a.name.cmp(&b.name));
tags
}
fn add_edge_internal(&mut self, from: &str, to: &str, kind: EdgeKind) {
self.edges.entry(from.to_string()).or_default().push(Edge {
target: to.to_string(),
kind,
});
self.reverse_edges
.entry(to.to_string())
.or_default()
.push(from.to_string());
}
pub(super) fn link_memories(&mut self, from: &str, to: &str, weight: f32) {
let kind = EdgeKind::RelatesTo { weight };
let exists = self
.edges
.get(from)
.is_some_and(|edges| edges.iter().any(|e| e.target == to && e.kind == kind));
if exists {
return;
}
self.add_edge_internal(from, to, kind);
self.metadata.link_discovery_count = self.metadata.link_discovery_count.saturating_add(1);
}
pub(super) fn supersede(&mut self, newer_id: &str, older_id: &str) {
self.link_if_absent(newer_id, older_id, EdgeKind::Supersedes);
if let Some(older) = self.memories.get_mut(older_id) {
older.supersede(newer_id);
}
}
fn link_if_absent(&mut self, from: &str, to: &str, kind: EdgeKind) {
let exists = self
.edges
.get(from)
.is_some_and(|edges| edges.iter().any(|e| e.target == to && e.kind == kind));
if !exists {
self.add_edge_internal(from, to, kind);
}
}
pub(super) fn related(&mut self, root: &str, depth: usize) -> Vec<(MemoryEntry, f32)> {
self.metadata.retrieval_count = self.metadata.retrieval_count.saturating_add(1);
if depth == 0 || !self.memories.contains_key(root) {
return Vec::new();
}
let mut visited: HashSet<String> = HashSet::new();
visited.insert(root.to_string());
let mut best: HashMap<String, f32> = HashMap::new();
let mut queue: VecDeque<(String, f32, usize)> = VecDeque::new();
queue.push_back((root.to_string(), 1.0, 0));
while let Some((node, score, level)) = queue.pop_front() {
if level >= depth {
continue;
}
let edges = self.edges.get(&node).cloned().unwrap_or_default();
for edge in edges {
let weight = edge.kind.traversal_weight();
let next_score = score * weight * 0.7;
if edge.target.starts_with("tag:") {
let sources = self
.reverse_edges
.get(&edge.target)
.cloned()
.unwrap_or_default();
for source in sources {
if visited.contains(&source) {
continue;
}
if !self.memories.contains_key(&source) {
continue;
}
visited.insert(source.clone());
best.insert(source.clone(), next_score);
queue.push_back((source, next_score, level + 1));
}
} else if self.memories.contains_key(&edge.target)
&& visited.insert(edge.target.clone())
{
best.insert(edge.target.clone(), next_score);
queue.push_back((edge.target, next_score, level + 1));
}
}
}
let mut related: Vec<(MemoryEntry, f32)> = best
.into_iter()
.filter_map(|(id, score)| {
self.memories
.get(&id)
.filter(|entry| entry.active)
.map(|entry| (entry.clone(), score))
})
.collect();
related.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
related
}
}
pub(super) fn normalize_search_text(content: &str, tags: &[String]) -> String {
let mut normalized = normalize_text_part(content);
for tag in tags {
let tag = normalize_text_part(tag);
if tag.is_empty() {
continue;
}
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(&tag);
}
normalized
}
fn normalize_text_part(text: &str) -> String {
let lowered = text.trim().to_lowercase();
let mut normalized = String::with_capacity(lowered.len());
let mut last_space = true;
for ch in lowered.chars() {
let mapped = if ch.is_whitespace() || matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | ',')
{
' '
} else {
ch
};
if mapped == ' ' {
if !last_space {
normalized.push(' ');
last_space = true;
}
} else {
normalized.push(mapped);
last_space = false;
}
}
normalized.trim_end().to_string()
}
pub(super) fn load_graph(path: &Path) -> io::Result<MemoryGraph> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(MemoryGraph::new()),
Err(err) => return Err(err),
};
let mut graph: MemoryGraph = serde_json::from_str(&text).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid memory graph {}: {err}", path.display()),
)
})?;
graph.graph_version = GRAPH_VERSION;
for entry in graph.memories.values_mut() {
if entry.search_text.is_empty() {
entry.refresh_search_text();
}
}
Ok(graph)
}
pub(super) fn save_graph(path: &Path, graph: &MemoryGraph) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(graph)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, format!("{text}\n"))?;
fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::AgentDirGuard;
fn entry(category: MemoryCategory, content: &str) -> MemoryEntry {
MemoryEntry::new(category, content)
}
#[test]
fn category_and_scope_parse_round_trip() {
assert_eq!("fact".parse::<MemoryCategory>(), Ok(MemoryCategory::Fact));
assert_eq!(
"PREFERENCE".parse::<MemoryCategory>(),
Ok(MemoryCategory::Preference)
);
assert_eq!(
MemoryCategory::Correction.as_label(),
"correction",
"命名空间内的展示名"
);
assert_eq!(
MemoryCategory::parse_lenient("bugs"),
MemoryCategory::Correction,
"无法识别的分类归入事实,但常见别名要认"
);
assert_eq!(MemoryCategory::parse_lenient("weird"), MemoryCategory::Fact);
assert_eq!(
MemoryCategory::parse_lenient("纠正"),
MemoryCategory::Correction,
"中文分类名同样识别"
);
assert_eq!(
MemoryCategory::parse_lenient("偏好"),
MemoryCategory::Preference
);
assert_eq!("global".parse::<MemoryScope>(), Ok(MemoryScope::Global));
assert!(MemoryScope::All.includes_project() && MemoryScope::All.includes_global());
assert!(!MemoryScope::Project.includes_global());
}
#[test]
fn add_memory_creates_tag_nodes_and_edges() {
let mut graph = MemoryGraph::new();
let id = graph.add_memory(
entry(MemoryCategory::Fact, "uses pnpm")
.with_tags(vec!["tooling".to_string(), "package-manager".to_string()]),
);
assert_eq!(graph.memory_count(), 1);
assert_eq!(graph.tags.len(), 2, "两个标签节点");
assert_eq!(graph.active_memories().count(), 1);
assert_eq!(graph.get(&id).unwrap().tags.len(), 2);
assert!(graph.get(&id).unwrap().searchable_text().contains("pnpm"));
assert!(
graph
.get(&id)
.unwrap()
.searchable_text()
.contains("tooling")
);
}
#[test]
fn tag_memory_is_idempotent_and_untag_cleans_counts() {
let mut graph = MemoryGraph::new();
let id = graph.add_memory(entry(MemoryCategory::Fact, "note"));
graph.tag_memory(&id, "x");
graph.tag_memory(&id, "x");
assert_eq!(graph.tags["tag:x"].count, 1, "重复打同一标签只计一次");
assert_eq!(graph.get(&id).unwrap().tags, vec!["x".to_string()]);
graph.untag_memory(&id, "x");
assert_eq!(graph.tags["tag:x"].count, 0);
assert!(graph.get(&id).unwrap().tags.is_empty());
}
#[test]
fn remove_memory_drops_edges_and_tag_counts() {
let mut graph = MemoryGraph::new();
let id = graph
.add_memory(entry(MemoryCategory::Fact, "note").with_tags(vec!["keep-me".to_string()]));
assert_eq!(graph.tags["tag:keep-me"].count, 1);
graph.remove_memory(&id);
assert_eq!(graph.memory_count(), 0);
assert_eq!(graph.tags["tag:keep-me"].count, 0);
assert!(graph.edges.is_empty() || !graph.edges.contains_key(&id));
}
#[test]
fn related_walks_tags_and_links_with_decay() {
let mut graph = MemoryGraph::new();
let a = graph
.add_memory(entry(MemoryCategory::Fact, "a").with_tags(vec!["shared".to_string()]));
let b = graph
.add_memory(entry(MemoryCategory::Fact, "b").with_tags(vec!["shared".to_string()]));
let c = graph.add_memory(entry(MemoryCategory::Fact, "c"));
graph.link_memories(&b, &c, 0.9);
let from_a = graph.related(&a, 2);
let ids: Vec<&str> = from_a.iter().map(|(e, _)| e.id.as_str()).collect();
assert!(ids.contains(&b.as_str()), "同标签应当关联: {ids:?}");
assert!(!ids.contains(&a.as_str()), "不包含自身");
let score_b = from_a.iter().find(|(e, _)| e.id == b).unwrap().1;
let score_c = from_a.iter().find(|(e, _)| e.id == c).unwrap().1;
assert!(score_b > score_c, "关联分数随跳数衰减");
assert!(graph.related(&a, 0).is_empty(), "深度 0 不返回任何内容");
}
#[test]
fn supersede_deactivates_old_entry() {
let mut graph = MemoryGraph::new();
let older = graph.add_memory(entry(MemoryCategory::Fact, "old"));
let newer = graph.add_memory(entry(MemoryCategory::Fact, "new"));
graph.supersede(&newer, &older);
assert!(!graph.get(&older).unwrap().active);
assert_eq!(
graph.get(&older).unwrap().superseded_by.as_deref(),
Some(newer.as_str())
);
assert_eq!(graph.active_memories().count(), 1);
assert_eq!(
graph.get(&older).unwrap().memory_score(),
0.0,
"失效条目不参与打分"
);
}
#[test]
fn graph_round_trips_through_disk() {
let _guard = AgentDirGuard::temp();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.json");
let mut graph = MemoryGraph::new();
let id = graph.add_memory(
entry(MemoryCategory::Preference, "prefers pnpm over npm")
.with_tags(vec!["tooling".to_string()])
.with_source("session-1"),
);
graph.link_memories(&id, &id, 0.5);
save_graph(&path, &graph).unwrap();
let loaded = load_graph(&path).unwrap();
assert_eq!(loaded.memory_count(), 1);
assert_eq!(loaded.graph_version, GRAPH_VERSION);
assert_eq!(loaded.tags["tag:tooling"].count, 1);
assert_eq!(loaded.get(&id).unwrap().content, "prefers pnpm over npm");
assert_eq!(
loaded.get(&id).unwrap().source.as_deref(),
Some("session-1")
);
assert!(loaded.get(&id).unwrap().active);
}
#[test]
fn missing_file_loads_empty_and_corrupt_file_errors() {
let dir = tempfile::tempdir().unwrap();
assert!(
load_graph(&dir.path().join("nope.json"))
.unwrap()
.memories
.is_empty()
);
let path = dir.path().join("bad.json");
std::fs::write(&path, "{not json").unwrap();
let err = load_graph(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn hand_written_graph_without_optional_fields_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("minimal.json");
std::fs::write(
&path,
r#"{
"graph_version": 1,
"memories": {
"mem_x": {
"id": "mem_x",
"category": "fact",
"content": "repo runs make check",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
}
}"#,
)
.unwrap();
let graph = load_graph(&path).unwrap();
let entry = graph.get("mem_x").unwrap();
assert_eq!(graph.graph_version, GRAPH_VERSION, "旧版本号被就地升级");
assert!(entry.active, "缺省 active = true");
assert_eq!(entry.confidence, 1.0);
assert!(entry.tags.is_empty());
assert!(
entry.search_text.contains("repo runs make check"),
"缺失的 search_text 被补算"
);
}
#[test]
fn entries_beyond_capacity_are_pruned_by_score() {
let mut graph = MemoryGraph::new();
let mut ids = Vec::new();
for i in 0..MAX_ENTRIES {
ids.push(graph.add_memory(entry(MemoryCategory::Fact, &format!("note {i}"))));
}
if let Some(first) = graph.get_mut(&ids[0]) {
first.updated_at = "2000-01-01T00:00:00.000Z".to_string();
first.active = false;
}
let newest = graph.add_memory(entry(MemoryCategory::Fact, "overflow"));
assert_eq!(graph.memory_count(), MAX_ENTRIES, "容量被守住");
assert!(!graph.memories.contains_key(&ids[0]), "最低分的条目被淘汰");
assert!(graph.memories.contains_key(&newest));
}
#[test]
fn effective_confidence_decays_and_is_boosted_by_access() {
let mut fresh = entry(MemoryCategory::Fact, "x");
fresh.created_at = now_iso();
fresh.updated_at = now_iso();
let fresh_confidence = fresh.effective_confidence();
let mut old = entry(MemoryCategory::Fact, "y");
old.created_at = "2020-01-01T00:00:00.000Z".to_string();
old.updated_at = "2020-01-01T00:00:00.000Z".to_string();
assert!(
old.effective_confidence() < fresh_confidence,
"旧条目置信度更低"
);
let before = old.effective_confidence();
old.access_count = 50;
assert!(old.effective_confidence() > before, "被用过的条目获得加成");
assert!(fresh.effective_confidence() <= 1.0);
}
#[test]
fn reinforce_and_touch_update_timestamps() {
let mut item = entry(MemoryCategory::Fact, "x");
let created = item.created_at.clone();
let initial_updated = item.updated_at.clone();
item.reinforce("session-1");
assert_eq!(item.strength, 2);
assert_eq!(item.reinforcements.len(), 1);
assert_eq!(item.reinforcements[0].session_id, "session-1");
assert_eq!(item.created_at, created, "创建时间不随强化改变");
item.touch();
assert_eq!(item.access_count, 1);
assert!(item.confidence > 1.0 - f32::EPSILON);
assert_eq!(item.created_at, created);
let _ = initial_updated;
}
}