use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
Contradicted,
Uncertain,
Inferred,
Verified,
}
impl Confidence {
pub fn decay(self) -> Self {
match self {
Confidence::Verified => Confidence::Verified,
Confidence::Inferred => Confidence::Uncertain,
Confidence::Uncertain => Confidence::Contradicted,
Confidence::Contradicted => Confidence::Contradicted,
}
}
pub fn label(&self) -> &'static str {
match self {
Confidence::Verified => "verified",
Confidence::Inferred => "inferred",
Confidence::Uncertain => "uncertain",
Confidence::Contradicted => "contradicted",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
pub origin: String,
pub recorded_at: DateTime<Utc>,
pub last_verified: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Belief {
pub key: String,
pub value: String,
pub confidence: Confidence,
pub source: Source,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default)]
pub hits: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct EpistemicStore {
#[serde(default)]
pub beliefs: HashMap<String, Belief>,
#[serde(default = "default_version")]
pub version: u32,
}
fn default_version() -> u32 {
1
}
impl EpistemicStore {
pub fn new() -> Self {
Self {
beliefs: HashMap::new(),
version: 1,
}
}
pub fn add_belief(
&mut self,
key: &str,
value: &str,
confidence: Confidence,
origin: &str,
) -> ContradictionResult {
let now = Utc::now();
let existing = self.beliefs.get(key).cloned();
if let Some(existing) = existing
&& existing.value != value
&& existing.confidence != Confidence::Contradicted
{
let old_value = existing.value.clone();
let archive_key = format!("contradicted:{}:{}", now.timestamp(), key);
let mut contradicted = existing.clone();
contradicted.key = archive_key.clone();
contradicted.confidence = Confidence::Contradicted;
contradicted.notes = Some(format!(
"Contradicted by new value '{}' from {} at {}",
value,
origin,
now.format("%Y-%m-%d %H:%M:%S UTC")
));
self.beliefs.insert(archive_key, contradicted);
let belief = Belief {
key: key.to_string(),
value: value.to_string(),
confidence,
source: Source {
origin: origin.to_string(),
recorded_at: now,
last_verified: now,
},
notes: None,
hits: 0,
last_used: None,
};
self.beliefs.insert(key.to_string(), belief);
return ContradictionResult::Contradicted {
old_value,
new_value: value.to_string(),
};
}
let belief = Belief {
key: key.to_string(),
value: value.to_string(),
confidence,
source: Source {
origin: origin.to_string(),
recorded_at: now,
last_verified: now,
},
notes: None,
hits: 0,
last_used: None,
};
self.beliefs.insert(key.to_string(), belief);
ContradictionResult::NoContradiction
}
pub fn get_belief(&self, key: &str) -> Option<&Belief> {
self.beliefs.get(key)
}
pub fn verify_belief(&mut self, key: &str) -> bool {
if let Some(belief) = self.beliefs.get_mut(key) {
belief.source.last_verified = Utc::now();
belief.confidence = Confidence::Verified;
true
} else {
false
}
}
pub fn touch_belief(&mut self, key: &str) -> bool {
if let Some(belief) = self.beliefs.get_mut(key) {
belief.hits += 1;
belief.last_used = Some(Utc::now());
true
} else {
false
}
}
pub fn apply_decay(&mut self, decay_days: i64) -> Vec<String> {
let now = Utc::now();
let mut decayed = Vec::new();
for belief in self.beliefs.values_mut() {
if belief.confidence == Confidence::Verified {
continue; }
let last_seen = belief.last_used.unwrap_or(belief.source.recorded_at);
let age_days = (now - last_seen).num_days();
if age_days >= decay_days {
let old = belief.confidence;
belief.confidence = belief.confidence.decay();
if belief.confidence != old {
decayed.push(format!(
"{}: {} → {} ({} days since last use)",
belief.key,
old.label(),
belief.confidence.label(),
age_days
));
}
}
}
decayed
}
pub fn list_by_confidence(&self, confidence: Confidence) -> Vec<&Belief> {
self.beliefs
.values()
.filter(|b| b.confidence == confidence)
.collect()
}
pub fn list_contradictions(&self) -> Vec<&Belief> {
self.list_by_confidence(Confidence::Contradicted)
}
pub fn list_by_key_prefix(&self, prefix: &str) -> Vec<&Belief> {
self.beliefs
.values()
.filter(|b| b.key.starts_with(prefix))
.collect()
}
pub fn save(&self, path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(path, content)
}
pub fn load(path: &PathBuf) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => match toml::from_str(&content) {
Ok(store) => store,
Err(e) => {
tracing::warn!("Epistemic store parse error: {}", e);
Self::new()
}
},
Err(_) => Self::new(),
}
}
pub fn backfill_from_content(&mut self, content: &str) -> usize {
let sections = crate::brain::brain_sections::split_sections(content);
let now = Utc::now();
let mut added = 0usize;
for section in §ions {
let trimmed_heading = section.heading.trim();
if trimmed_heading.is_empty()
|| (trimmed_heading.starts_with('#') && !trimmed_heading.starts_with("##"))
{
continue;
}
if section.body.trim().len() < 20 {
continue;
}
let heading = normalize_heading(§ion.heading);
let key = format!("MEMORY.md##{}", heading);
if self.beliefs.contains_key(&key) {
continue;
}
let belief = Belief {
key: key.clone(),
value: section.body.trim().chars().take(200).collect(),
confidence: Confidence::Inferred,
source: Source {
origin: format!("MEMORY.md backfill ({})", now.format("%Y-%m-%d")),
recorded_at: now,
last_verified: now,
},
notes: None,
hits: 0,
last_used: None,
};
self.beliefs.insert(key, belief);
added += 1;
}
added
}
pub fn delete_cold_beliefs(&mut self, max_age_days: i64) -> Vec<String> {
let now = Utc::now();
let cold_keys: Vec<String> = self
.beliefs
.iter()
.filter(|(_, b)| {
b.hits == 0 && {
let last_seen = b.last_used.unwrap_or(b.source.recorded_at);
(now - last_seen).num_days() >= max_age_days
}
})
.map(|(k, _)| k.clone())
.collect();
for key in &cold_keys {
self.beliefs.remove(key);
}
cold_keys
}
pub fn list_cold_beliefs(&self, max_age_days: i64) -> Vec<&Belief> {
let now = Utc::now();
self.beliefs
.values()
.filter(|b| {
b.hits == 0 && {
let last_seen = b.last_used.unwrap_or(b.source.recorded_at);
(now - last_seen).num_days() >= max_age_days
}
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchivedSection {
pub heading: String,
pub title: String,
pub belief_key: String,
pub rendered: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ArchiveReport {
pub archived: Vec<ArchivedSection>,
pub archive_path: Option<PathBuf>,
pub backup: Option<PathBuf>,
pub removed_beliefs: Vec<String>,
pub skipped: bool,
}
pub(crate) fn normalize_heading(raw: &str) -> String {
raw.trim().trim_start_matches('#').trim().to_string()
}
pub(crate) fn select_archive_candidates(
store: &EpistemicStore,
content: &str,
max_age_days: i64,
now: DateTime<Utc>,
) -> Vec<ArchivedSection> {
let mut cold = Vec::new();
for section in crate::brain::brain_sections::split_sections(content) {
let trimmed = section.heading.trim();
if trimmed.is_empty() || (trimmed.starts_with('#') && !trimmed.starts_with("##")) {
continue;
}
let title = normalize_heading(§ion.heading);
let key = format!("MEMORY.md##{title}");
let Some(belief) = store.beliefs.get(&key) else {
continue;
};
let last_seen = belief.last_used.unwrap_or(belief.source.recorded_at);
if belief.hits == 0 && (now - last_seen).num_days() >= max_age_days {
cold.push(ArchivedSection {
heading: trimmed.to_string(),
title,
belief_key: key,
rendered: section.render(),
});
}
}
cold
}
pub(crate) fn archive_cold_sections_at(
store: &mut EpistemicStore,
memory_path: &std::path::Path,
archive_dir: &std::path::Path,
max_age_days: i64,
now: DateTime<Utc>,
) -> Result<ArchiveReport, String> {
let content = std::fs::read_to_string(memory_path)
.map_err(|e| format!("archive: reading {}: {e}", memory_path.display()))?;
let candidates = select_archive_candidates(store, &content, max_age_days, now);
let mut report = ArchiveReport {
skipped: candidates.is_empty(),
..Default::default()
};
if report.skipped {
return Ok(report);
}
std::fs::create_dir_all(archive_dir)
.map_err(|e| format!("archive: creating {}: {e}", archive_dir.display()))?;
let archive_path = archive_dir.join(format!("{}.md", now.format("%Y-%m")));
let payload: String = candidates.iter().map(|c| c.rendered.as_str()).collect();
use std::io::Write as _;
let mut archive_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&archive_path)
.map_err(|e| format!("archive: opening {}: {e}", archive_path.display()))?;
archive_file
.write_all(payload.as_bytes())
.map_err(|e| format!("archive: writing {}: {e}", archive_path.display()))?;
drop(archive_file);
let backup = crate::brain::tools::brain_file_safety::backup_before_write(memory_path)
.map_err(|e| format!("archive: backing up MEMORY.md: {e}"))?;
let survivors: String = crate::brain::brain_sections::split_sections(&content)
.into_iter()
.filter(|s| !candidates.iter().any(|c| c.heading == s.heading.trim()))
.map(|s| s.render())
.collect();
std::fs::write(memory_path, survivors)
.map_err(|e| format!("archive: rewriting {}: {e}", memory_path.display()))?;
for c in &candidates {
store.beliefs.remove(&c.belief_key);
}
report.removed_beliefs = candidates.iter().map(|c| c.belief_key.clone()).collect();
report.archive_path = Some(archive_path);
report.backup = backup;
report.archived = candidates;
Ok(report)
}
#[derive(Debug, Clone, PartialEq)]
pub enum ContradictionResult {
NoContradiction,
Contradicted {
old_value: String,
new_value: String,
},
}
fn epistemic_store_path() -> Option<PathBuf> {
let home = crate::config::profile::resolve_profile_home();
Some(home.join("brain/epistemic/beliefs.toml"))
}
static STORE: OnceLock<std::sync::Mutex<EpistemicStore>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DecaySettings {
pub(crate) enabled: bool,
pub(crate) days: i64,
}
pub(crate) fn decay_settings_from(cfg: &super::plan_tool::EpistemicConfig) -> DecaySettings {
let days = (cfg.decay_interval_hours / 24).max(1) as i64;
DecaySettings {
enabled: cfg.decay_enabled,
days,
}
}
pub(crate) fn resolve_decay_settings() -> DecaySettings {
let home = crate::config::profile::resolve_profile_home();
match super::plan_tool::ralph_loop_config(&home) {
Some(cfg) => decay_settings_from(&cfg.epistemic),
None => DecaySettings {
enabled: true,
days: 30,
},
}
}
fn get_store() -> &'static std::sync::Mutex<EpistemicStore> {
STORE.get_or_init(|| {
let path = epistemic_store_path().expect("home dir must exist");
let mut store = EpistemicStore::load(&path);
let policy = resolve_decay_settings();
let decayed = if policy.enabled {
store.apply_decay(policy.days)
} else {
Vec::new()
};
if !decayed.is_empty() {
tracing::info!("Epistemic session start: {} beliefs decayed", decayed.len());
}
let memory_path = crate::config::profile::resolve_profile_home().join("MEMORY.md");
let mut added = 0;
if let Ok(content) = std::fs::read_to_string(&memory_path) {
added = store.backfill_from_content(&content);
if added > 0 {
tracing::info!(
"Epistemic session start: {} new beliefs backfilled from MEMORY.md",
added
);
}
}
if (!decayed.is_empty() || added > 0)
&& let Err(e) = store.save(&path)
{
tracing::warn!("Failed to save epistemic store after session start: {}", e);
}
std::sync::Mutex::new(store)
})
}
pub fn add_belief(
key: &str,
value: &str,
confidence: Confidence,
origin: &str,
) -> ContradictionResult {
let store = get_store();
let mut guard = store.lock().expect("epistemic store lock poisoned");
let result = guard.add_belief(key, value, confidence, origin);
if let Some(path) = epistemic_store_path()
&& let Err(e) = guard.save(&path)
{
tracing::warn!("Failed to save epistemic store: {}", e);
}
result
}
pub fn get_belief(key: &str) -> Option<Belief> {
let store = get_store();
let guard = store.lock().expect("epistemic store lock poisoned");
guard.get_belief(key).cloned()
}
pub fn verify_belief(key: &str) -> bool {
let store = get_store();
let mut guard = store.lock().expect("epistemic store lock poisoned");
let result = guard.verify_belief(key);
if result
&& let Some(path) = epistemic_store_path()
&& let Err(e) = guard.save(&path)
{
tracing::warn!("Failed to save epistemic store: {}", e);
}
result
}
pub fn touch_belief(key: &str) -> bool {
let store = get_store();
let mut guard = store.lock().expect("epistemic store lock poisoned");
let result = guard.touch_belief(key);
if result
&& let Some(path) = epistemic_store_path()
&& let Err(e) = guard.save(&path)
{
tracing::warn!("Failed to save epistemic store: {}", e);
}
result
}
pub fn list_contradictions() -> Vec<Belief> {
let store = get_store();
let guard = store.lock().expect("epistemic store lock poisoned");
guard.list_contradictions().into_iter().cloned().collect()
}
pub fn list_by_prefix(prefix: &str) -> Vec<Belief> {
let store = get_store();
let guard = store.lock().expect("epistemic store lock poisoned");
guard
.list_by_key_prefix(prefix)
.into_iter()
.cloned()
.collect()
}
pub fn delete_cold_beliefs(max_age_days: i64) -> Vec<String> {
let store = get_store();
let mut guard = store.lock().expect("epistemic store lock poisoned");
let deleted = guard.delete_cold_beliefs(max_age_days);
if !deleted.is_empty()
&& let Some(path) = epistemic_store_path()
&& let Err(e) = guard.save(&path)
{
tracing::warn!("Failed to save epistemic store after cold delete: {}", e);
}
deleted
}
pub fn list_cold_beliefs(max_age_days: i64) -> Vec<Belief> {
let store = get_store();
let guard = store.lock().expect("epistemic store lock poisoned");
guard
.list_cold_beliefs(max_age_days)
.into_iter()
.cloned()
.collect()
}
pub fn list_cold_sections(max_age_days: i64) -> Vec<ArchivedSection> {
let home = crate::config::profile::resolve_profile_home();
let memory_path = home.join("MEMORY.md");
let Ok(content) = std::fs::read_to_string(&memory_path) else {
return Vec::new();
};
let store = get_store();
let guard = store.lock().expect("epistemic store lock poisoned");
select_archive_candidates(&guard, &content, max_age_days, Utc::now())
}
pub fn archive_cold_sections(max_age_days: i64) -> Result<ArchiveReport, String> {
let home = crate::config::profile::resolve_profile_home();
let memory_path = home.join("MEMORY.md");
let archive_dir = home.join("memory").join("archive");
let report = {
let store = get_store();
let mut guard = store.lock().expect("epistemic store lock poisoned");
let r = archive_cold_sections_at(
&mut guard,
&memory_path,
&archive_dir,
max_age_days,
Utc::now(),
)?;
if !r.skipped
&& let Some(path) = epistemic_store_path()
&& let Err(e) = guard.save(&path)
{
tracing::warn!("archive: saving epistemic store: {e}");
}
r
};
if report.skipped {
return Ok(report);
}
let archive_rel = report
.archive_path
.as_ref()
.and_then(|p| p.strip_prefix(&home).ok())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "memory/archive".to_string());
let mut pruned = crate::brain::rsi_pruned::PrunedState::load();
pruned.record_moved(
"MEMORY.md",
report
.archived
.iter()
.map(|c| crate::brain::rsi_pruned::MovedEntry {
header: c.heading.clone(),
dest: archive_rel.clone(),
})
.collect(),
);
if let Err(e) = pruned.save() {
tracing::warn!("archive: saving pruned ledger: {e}");
}
let reindex_targets: Vec<PathBuf> = report
.archive_path
.iter()
.cloned()
.chain(std::iter::once(memory_path))
.collect();
if let Ok(handle) = tokio::runtime::Handle::try_current()
&& let Ok(store) = crate::memory::get_store()
{
for path in reindex_targets {
handle.spawn(async move {
if let Err(e) = crate::memory::index_file_fts_only(store, &path).await {
tracing::warn!("archive: reindexing {}: {e}", path.display());
}
});
}
} else {
tracing::debug!("archive: no tokio runtime, reindex deferred to startup");
}
Ok(report)
}