use anyhow::Result;
use chrono::{DateTime, Utc};
use rocksdb::{IteratorMode, DB};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use uuid::Uuid;
use super::types::{ExperienceType, Memory, MemoryId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CausalRelation {
Caused,
ResolvedBy,
InformedBy,
SupersededBy,
TriggeredBy,
BranchedFrom,
RelatedTo,
}
impl CausalRelation {
pub fn inverse(&self) -> Self {
match self {
CausalRelation::Caused => CausalRelation::ResolvedBy,
CausalRelation::ResolvedBy => CausalRelation::Caused,
CausalRelation::InformedBy => CausalRelation::InformedBy,
CausalRelation::TriggeredBy => CausalRelation::TriggeredBy,
CausalRelation::SupersededBy => CausalRelation::SupersededBy,
CausalRelation::BranchedFrom => CausalRelation::BranchedFrom,
CausalRelation::RelatedTo => CausalRelation::RelatedTo,
}
}
pub fn description(&self) -> &'static str {
match self {
CausalRelation::Caused => "caused",
CausalRelation::ResolvedBy => "was resolved by",
CausalRelation::InformedBy => "was informed by",
CausalRelation::SupersededBy => "was superseded by",
CausalRelation::TriggeredBy => "triggered",
CausalRelation::BranchedFrom => "branched from",
CausalRelation::RelatedTo => "is related to",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LineageSource {
Inferred,
Confirmed,
Explicit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageEdge {
pub id: String,
pub from: MemoryId,
pub to: MemoryId,
pub relation: CausalRelation,
pub confidence: f32,
pub source: LineageSource,
pub branch_id: Option<String>,
pub created_at: DateTime<Utc>,
pub last_reinforced: DateTime<Utc>,
pub reinforcement_count: u32,
}
impl LineageEdge {
pub fn inferred(
from: MemoryId,
to: MemoryId,
relation: CausalRelation,
confidence: f32,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
from,
to,
relation,
confidence,
source: LineageSource::Inferred,
branch_id: None,
created_at: now,
last_reinforced: now,
reinforcement_count: 1,
}
}
pub fn explicit(from: MemoryId, to: MemoryId, relation: CausalRelation) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
from,
to,
relation,
confidence: 1.0, source: LineageSource::Explicit,
branch_id: None,
created_at: now,
last_reinforced: now,
reinforcement_count: 1,
}
}
pub fn confirm(&mut self) {
self.source = LineageSource::Confirmed;
self.confidence = 1.0;
self.last_reinforced = Utc::now();
self.reinforcement_count += 1;
}
pub fn reinforce(&mut self) {
self.confidence = (self.confidence + 0.1).min(1.0);
self.last_reinforced = Utc::now();
self.reinforcement_count += 1;
}
pub fn weaken(&mut self) -> bool {
self.confidence *= 0.90;
self.last_reinforced = Utc::now();
self.confidence < 0.05
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageBranch {
pub id: String,
pub name: String,
pub description: Option<String>,
pub parent_branch: Option<String>,
pub branch_point: Option<MemoryId>,
pub created_at: DateTime<Utc>,
pub active: bool,
pub tags: Vec<String>,
}
impl LineageBranch {
pub fn main() -> Self {
Self {
id: "main".to_string(),
name: "Main".to_string(),
description: Some("Primary project lineage".to_string()),
parent_branch: None,
branch_point: None,
created_at: Utc::now(),
active: true,
tags: vec![],
}
}
pub fn new(
name: &str,
parent: &str,
branch_point: MemoryId,
description: Option<&str>,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
description: description.map(|s| s.to_string()),
parent_branch: Some(parent.to_string()),
branch_point: Some(branch_point),
created_at: Utc::now(),
active: true,
tags: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageTrace {
pub root: MemoryId,
pub direction: TraceDirection,
pub edges: Vec<LineageEdge>,
pub path: Vec<MemoryId>,
pub depth: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TraceDirection {
Backward,
Forward,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferenceConfig {
pub max_temporal_gap_days: i64,
pub min_entity_overlap: f32,
pub relation_confidence: HashMap<CausalRelation, f32>,
}
impl Default for InferenceConfig {
fn default() -> Self {
use crate::constants::*;
let mut relation_confidence = HashMap::new();
relation_confidence.insert(CausalRelation::Caused, LINEAGE_CONFIDENCE_CAUSED);
relation_confidence.insert(CausalRelation::ResolvedBy, LINEAGE_CONFIDENCE_RESOLVED_BY);
relation_confidence.insert(CausalRelation::InformedBy, LINEAGE_CONFIDENCE_INFORMED_BY);
relation_confidence.insert(
CausalRelation::SupersededBy,
LINEAGE_CONFIDENCE_SUPERSEDED_BY,
);
relation_confidence.insert(CausalRelation::TriggeredBy, LINEAGE_CONFIDENCE_TRIGGERED_BY);
relation_confidence.insert(
CausalRelation::BranchedFrom,
LINEAGE_CONFIDENCE_BRANCHED_FROM,
);
relation_confidence.insert(CausalRelation::RelatedTo, LINEAGE_CONFIDENCE_RELATED_TO);
Self {
max_temporal_gap_days: LINEAGE_MAX_TEMPORAL_GAP_DAYS,
min_entity_overlap: LINEAGE_MIN_ENTITY_OVERLAP,
relation_confidence,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineageStats {
pub total_edges: usize,
pub inferred_edges: usize,
pub confirmed_edges: usize,
pub explicit_edges: usize,
pub total_branches: usize,
pub active_branches: usize,
pub edges_by_relation: HashMap<String, usize>,
pub avg_confidence: f32,
}
pub struct LineageGraph {
db: Arc<DB>,
config: InferenceConfig,
}
impl LineageGraph {
pub fn new(db: Arc<DB>) -> Self {
Self {
db,
config: InferenceConfig::default(),
}
}
pub fn with_config(db: Arc<DB>, config: InferenceConfig) -> Self {
Self { db, config }
}
pub fn store_edge(&self, user_id: &str, edge: &LineageEdge) -> Result<()> {
let key = format!("lineage:edges:{}:{}", user_id, edge.id);
let value = crate::serialization::encode(edge)?;
self.db.put(key.as_bytes(), &value)?;
let from_key = format!("lineage:by_from:{}:{}:{}", user_id, edge.from.0, edge.id);
self.db.put(from_key.as_bytes(), edge.id.as_bytes())?;
let to_key = format!("lineage:by_to:{}:{}:{}", user_id, edge.to.0, edge.id);
self.db.put(to_key.as_bytes(), edge.id.as_bytes())?;
Ok(())
}
pub fn get_edge(&self, user_id: &str, edge_id: &str) -> Result<Option<LineageEdge>> {
let key = format!("lineage:edges:{}:{}", user_id, edge_id);
match self.db.get(key.as_bytes())? {
Some(data) => {
let (edge, _) = crate::serialization::try_decode::<LineageEdge>(&data)?;
Ok(Some(edge))
}
None => Ok(None),
}
}
pub fn delete_edge(&self, user_id: &str, edge_id: &str) -> Result<bool> {
if let Some(edge) = self.get_edge(user_id, edge_id)? {
let from_key = format!("lineage:by_from:{}:{}:{}", user_id, edge.from.0, edge_id);
self.db.delete(from_key.as_bytes())?;
let to_key = format!("lineage:by_to:{}:{}:{}", user_id, edge.to.0, edge_id);
self.db.delete(to_key.as_bytes())?;
let key = format!("lineage:edges:{}:{}", user_id, edge_id);
self.db.delete(key.as_bytes())?;
Ok(true)
} else {
Ok(false)
}
}
pub fn get_edges_from(&self, user_id: &str, memory_id: &MemoryId) -> Result<Vec<LineageEdge>> {
let prefix = format!("lineage:by_from:{}:{}:", user_id, memory_id.0);
self.get_edges_by_prefix(user_id, &prefix)
}
pub fn get_edges_to(&self, user_id: &str, memory_id: &MemoryId) -> Result<Vec<LineageEdge>> {
let prefix = format!("lineage:by_to:{}:{}:", user_id, memory_id.0);
self.get_edges_by_prefix(user_id, &prefix)
}
fn get_edges_by_prefix(&self, user_id: &str, prefix: &str) -> Result<Vec<LineageEdge>> {
let mut edges = Vec::new();
let iter = self.db.iterator(IteratorMode::From(
prefix.as_bytes(),
rocksdb::Direction::Forward,
));
for item in iter {
let (key, value) = item?;
let key_str = String::from_utf8_lossy(&key);
if !key_str.starts_with(prefix) {
break;
}
let edge_id = String::from_utf8_lossy(&value);
if let Some(edge) = self.get_edge(user_id, &edge_id)? {
edges.push(edge);
}
}
Ok(edges)
}
pub fn list_edges(&self, user_id: &str, limit: usize) -> Result<Vec<LineageEdge>> {
let prefix = format!("lineage:edges:{}:", user_id);
let mut edges = Vec::new();
let iter = self.db.iterator(IteratorMode::From(
prefix.as_bytes(),
rocksdb::Direction::Forward,
));
for item in iter {
let (key, value) = item?;
let key_str = String::from_utf8_lossy(&key);
if !key_str.starts_with(&prefix) {
break;
}
if let Ok((edge, _)) = crate::serialization::try_decode::<LineageEdge>(&value) {
edges.push(edge);
if edges.len() >= limit {
break;
}
}
}
edges.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(edges)
}
pub fn store_branch(&self, user_id: &str, branch: &LineageBranch) -> Result<()> {
let key = format!("lineage:branches:{}:{}", user_id, branch.id);
let value = crate::serialization::encode(branch)?;
self.db.put(key.as_bytes(), &value)?;
Ok(())
}
pub fn get_branch(&self, user_id: &str, branch_id: &str) -> Result<Option<LineageBranch>> {
let key = format!("lineage:branches:{}:{}", user_id, branch_id);
match self.db.get(key.as_bytes())? {
Some(data) => {
let (branch, _) = crate::serialization::try_decode::<LineageBranch>(&data)?;
Ok(Some(branch))
}
None => Ok(None),
}
}
pub fn list_branches(&self, user_id: &str) -> Result<Vec<LineageBranch>> {
let prefix = format!("lineage:branches:{}:", user_id);
let mut branches = Vec::new();
let iter = self.db.iterator(IteratorMode::From(
prefix.as_bytes(),
rocksdb::Direction::Forward,
));
for item in iter {
let (key, value) = item?;
let key_str = String::from_utf8_lossy(&key);
if !key_str.starts_with(&prefix) {
break;
}
if let Ok((branch, _)) = crate::serialization::try_decode::<LineageBranch>(&value) {
branches.push(branch);
}
}
branches.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(branches)
}
pub fn create_branch(
&self,
user_id: &str,
name: &str,
parent_branch: &str,
branch_point: MemoryId,
description: Option<&str>,
) -> Result<LineageBranch> {
let branch = LineageBranch::new(name, parent_branch, branch_point, description);
self.store_branch(user_id, &branch)?;
Ok(branch)
}
pub fn ensure_main_branch(&self, user_id: &str) -> Result<()> {
if self.get_branch(user_id, "main")?.is_none() {
self.store_branch(user_id, &LineageBranch::main())?;
}
Ok(())
}
pub fn infer_relation(&self, from: &Memory, to: &Memory) -> Option<(CausalRelation, f32)> {
if from.created_at >= to.created_at {
return None;
}
let gap = to.created_at.signed_duration_since(from.created_at);
if gap.num_days() > self.config.max_temporal_gap_days {
return None;
}
let entity_overlap =
Self::calculate_entity_overlap(&from.experience.entities, &to.experience.entities);
let embedding_sim = match (&from.experience.embeddings, &to.experience.embeddings) {
(Some(emb_a), Some(emb_b)) if emb_a.len() == emb_b.len() && !emb_a.is_empty() => {
Self::cosine_similarity(emb_a, emb_b).max(0.0) }
_ => 0.0,
};
let semantic_signal = entity_overlap.max(embedding_sim);
let has_entities =
!from.experience.entities.is_empty() && !to.experience.entities.is_empty();
let has_embeddings =
from.experience.embeddings.is_some() && to.experience.embeddings.is_some();
if has_entities && !has_embeddings && entity_overlap < self.config.min_entity_overlap {
return None;
}
if has_embeddings && !has_entities && embedding_sim < self.config.min_entity_overlap {
return None;
}
if has_entities && has_embeddings && semantic_signal < self.config.min_entity_overlap {
return None;
}
let (relation, base_confidence) = self.infer_by_types(
&from.experience.experience_type,
&to.experience.experience_type,
)?;
let effective_overlap = if semantic_signal > 0.0 {
semantic_signal
} else if has_entities {
entity_overlap
} else {
0.3 };
let temporal_factor =
1.0 - (gap.num_days() as f32 / self.config.max_temporal_gap_days as f32);
let confidence = base_confidence * effective_overlap * (0.5 + 0.5 * temporal_factor);
Some((relation, confidence))
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
crate::similarity::cosine_similarity(a, b)
}
fn infer_by_types(
&self,
from_type: &ExperienceType,
to_type: &ExperienceType,
) -> Option<(CausalRelation, f32)> {
use ExperienceType::*;
match (from_type, to_type) {
(Error, Task) => Some((
CausalRelation::Caused,
*self
.config
.relation_confidence
.get(&CausalRelation::Caused)
.unwrap_or(&0.8),
)),
(Task, Learning) => Some((
CausalRelation::ResolvedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::ResolvedBy)
.unwrap_or(&0.85),
)),
(Learning, Decision) | (Discovery, Decision) => Some((
CausalRelation::InformedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::InformedBy)
.unwrap_or(&0.7),
)),
(Decision, Decision) => Some((
CausalRelation::SupersededBy,
*self
.config
.relation_confidence
.get(&CausalRelation::SupersededBy)
.unwrap_or(&0.6),
)),
(Discovery, Task) | (Learning, Task) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75),
)),
(Pattern, Learning) | (Pattern, Decision) => Some((
CausalRelation::InformedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::InformedBy)
.unwrap_or(&0.7),
)),
(Error, Learning) => Some((
CausalRelation::ResolvedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::ResolvedBy)
.unwrap_or(&0.85),
)),
(Observation, Discovery) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75),
)),
(Observation, Task) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75)
* 0.85, )),
(Observation, Decision) => Some((
CausalRelation::InformedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::InformedBy)
.unwrap_or(&0.7)
* 0.85,
)),
(Observation, Error) => Some((
CausalRelation::Caused,
*self
.config
.relation_confidence
.get(&CausalRelation::Caused)
.unwrap_or(&0.8)
* 0.75, )),
(Observation, Learning) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75)
* 0.85,
)),
(Conversation, Decision) => Some((
CausalRelation::InformedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::InformedBy)
.unwrap_or(&0.7)
* 0.9, )),
(Conversation, Task) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75)
* 0.85,
)),
(Conversation, Learning) => Some((
CausalRelation::InformedBy,
*self
.config
.relation_confidence
.get(&CausalRelation::InformedBy)
.unwrap_or(&0.7)
* 0.85,
)),
(Conversation, Error) => Some((
CausalRelation::Caused,
*self
.config
.relation_confidence
.get(&CausalRelation::Caused)
.unwrap_or(&0.8)
* 0.7, )),
(Conversation, Discovery) => Some((
CausalRelation::TriggeredBy,
*self
.config
.relation_confidence
.get(&CausalRelation::TriggeredBy)
.unwrap_or(&0.75)
* 0.85,
)),
_ => {
if Self::are_types_related(from_type, to_type) {
Some((
CausalRelation::RelatedTo,
*self
.config
.relation_confidence
.get(&CausalRelation::RelatedTo)
.unwrap_or(&0.5),
))
} else {
None
}
}
}
}
fn are_types_related(a: &ExperienceType, b: &ExperienceType) -> bool {
use ExperienceType::*;
if std::mem::discriminant(a) == std::mem::discriminant(b) {
return true;
}
let is_bridge = |t: &ExperienceType| matches!(t, Observation | Conversation);
if is_bridge(a) || is_bridge(b) {
return true;
}
let knowledge_types = [Learning, Discovery, Pattern];
let action_types = [Task, Decision, Command, CodeEdit];
let context_types = [Context, FileAccess, Search];
let in_knowledge = |t: &ExperienceType| {
knowledge_types
.iter()
.any(|k| std::mem::discriminant(k) == std::mem::discriminant(t))
};
let in_action = |t: &ExperienceType| {
action_types
.iter()
.any(|k| std::mem::discriminant(k) == std::mem::discriminant(t))
};
let in_context = |t: &ExperienceType| {
context_types
.iter()
.any(|k| std::mem::discriminant(k) == std::mem::discriminant(t))
};
(in_knowledge(a) && in_knowledge(b))
|| (in_action(a) && in_action(b))
|| (in_context(a) && in_context(b))
}
fn calculate_entity_overlap(tags_a: &[String], tags_b: &[String]) -> f32 {
if tags_a.is_empty() && tags_b.is_empty() {
return 0.0;
}
let set_a: HashSet<&str> = tags_a.iter().map(|s| s.as_str()).collect();
let set_b: HashSet<&str> = tags_b.iter().map(|s| s.as_str()).collect();
let intersection = set_a.intersection(&set_b).count();
let union = set_a.union(&set_b).count();
if union == 0 {
0.0
} else {
intersection as f32 / union as f32
}
}
pub fn detect_branch_signal(content: &str) -> bool {
let content_lower = content.to_lowercase();
let strong_signals = [
"pivot to",
"change direction",
"start fresh",
"start over",
"complete rewrite",
"should rewrite",
"need to rewrite",
"scrap this",
"scrap the",
"different strategy",
"new strategy",
"abandon",
];
let weak_signals = ["instead", "new approach", "rethink", "rewrite", "pivot"];
let strong_count = strong_signals
.iter()
.filter(|s| content_lower.contains(*s))
.count();
let weak_count = weak_signals
.iter()
.filter(|s| content_lower.contains(*s))
.count();
strong_count >= 1 || weak_count >= 2
}
pub fn trace(
&self,
user_id: &str,
memory_id: &MemoryId,
direction: TraceDirection,
max_depth: usize,
) -> Result<LineageTrace> {
let mut visited = HashSet::new();
let mut edges = Vec::new();
let mut path = vec![memory_id.clone()];
let mut queue: VecDeque<(MemoryId, usize)> = VecDeque::new();
queue.push_back((memory_id.clone(), 0));
visited.insert(memory_id.clone());
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let next_edges = match direction {
TraceDirection::Backward => self.get_edges_to(user_id, ¤t_id)?,
TraceDirection::Forward => self.get_edges_from(user_id, ¤t_id)?,
TraceDirection::Both => {
let mut all = self.get_edges_to(user_id, ¤t_id)?;
all.extend(self.get_edges_from(user_id, ¤t_id)?);
all
}
};
for edge in next_edges {
let next_id = match direction {
TraceDirection::Backward => edge.from.clone(),
TraceDirection::Forward => edge.to.clone(),
TraceDirection::Both => {
if edge.from == current_id {
edge.to.clone()
} else {
edge.from.clone()
}
}
};
if !visited.contains(&next_id) {
visited.insert(next_id.clone());
path.push(next_id.clone());
edges.push(edge);
queue.push_back((next_id, depth + 1));
}
}
}
let depth = path.len().saturating_sub(1);
Ok(LineageTrace {
root: memory_id.clone(),
direction,
edges,
path,
depth,
})
}
pub fn find_root_cause(&self, user_id: &str, memory_id: &MemoryId) -> Result<Option<MemoryId>> {
let trace = self.trace(user_id, memory_id, TraceDirection::Backward, 100)?;
if trace.path.len() <= 1 {
Ok(None)
} else {
Ok(trace.path.last().cloned())
}
}
pub fn find_effects(
&self,
user_id: &str,
memory_id: &MemoryId,
max_depth: usize,
) -> Result<Vec<MemoryId>> {
let trace = self.trace(user_id, memory_id, TraceDirection::Forward, max_depth)?;
Ok(trace.path.into_iter().skip(1).collect())
}
pub fn confirm_edge(&self, user_id: &str, edge_id: &str) -> Result<bool> {
if let Some(mut edge) = self.get_edge(user_id, edge_id)? {
edge.confirm();
self.store_edge(user_id, &edge)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn reject_edge(&self, user_id: &str, edge_id: &str) -> Result<bool> {
self.delete_edge(user_id, edge_id)
}
pub fn add_explicit_edge(
&self,
user_id: &str,
from: MemoryId,
to: MemoryId,
relation: CausalRelation,
) -> Result<LineageEdge> {
let edge = LineageEdge::explicit(from, to, relation);
self.store_edge(user_id, &edge)?;
Ok(edge)
}
pub fn edge_exists(&self, user_id: &str, from: &MemoryId, to: &MemoryId) -> Result<bool> {
let edges = self.get_edges_from(user_id, from)?;
Ok(edges.iter().any(|e| &e.to == to))
}
pub fn stats(&self, user_id: &str) -> Result<LineageStats> {
const STATS_SCAN_LIMIT: usize = 10_000;
let edges = self.list_edges(user_id, STATS_SCAN_LIMIT)?;
let branches = self.list_branches(user_id)?;
let mut stats = LineageStats {
total_edges: edges.len(),
total_branches: branches.len(),
active_branches: branches.iter().filter(|b| b.active).count(),
..Default::default()
};
let mut total_confidence: f32 = 0.0;
for edge in &edges {
match edge.source {
LineageSource::Inferred => stats.inferred_edges += 1,
LineageSource::Confirmed => stats.confirmed_edges += 1,
LineageSource::Explicit => stats.explicit_edges += 1,
}
let relation_name = format!("{:?}", edge.relation);
*stats.edges_by_relation.entry(relation_name).or_insert(0) += 1;
total_confidence += edge.confidence;
}
if !edges.is_empty() {
stats.avg_confidence = total_confidence / edges.len() as f32;
}
Ok(stats)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::types::Experience;
use chrono::Duration;
use tempfile::TempDir;
fn create_test_graph() -> (LineageGraph, TempDir) {
let temp_dir = TempDir::new().unwrap();
let db = Arc::new(DB::open_default(temp_dir.path()).unwrap());
(LineageGraph::new(db), temp_dir)
}
fn create_test_memory(exp_type: ExperienceType, entities: Vec<&str>) -> Memory {
let experience = Experience {
experience_type: exp_type,
content: "Test memory".to_string(),
entities: entities.into_iter().map(|s| s.to_string()).collect(),
..Default::default()
};
Memory::new(
MemoryId(Uuid::new_v4()),
experience,
0.5, None, None, None, None, )
}
#[test]
fn test_store_and_get_edge() {
let (graph, _dir) = create_test_graph();
let from = MemoryId(Uuid::new_v4());
let to = MemoryId(Uuid::new_v4());
let edge = LineageEdge::explicit(from.clone(), to.clone(), CausalRelation::Caused);
graph.store_edge("user-1", &edge).unwrap();
let retrieved = graph.get_edge("user-1", &edge.id).unwrap();
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().relation, CausalRelation::Caused);
}
#[test]
fn test_get_edges_from_and_to() {
let (graph, _dir) = create_test_graph();
let from = MemoryId(Uuid::new_v4());
let to1 = MemoryId(Uuid::new_v4());
let to2 = MemoryId(Uuid::new_v4());
let edge1 = LineageEdge::explicit(from.clone(), to1.clone(), CausalRelation::Caused);
let edge2 = LineageEdge::explicit(from.clone(), to2.clone(), CausalRelation::TriggeredBy);
graph.store_edge("user-1", &edge1).unwrap();
graph.store_edge("user-1", &edge2).unwrap();
let from_edges = graph.get_edges_from("user-1", &from).unwrap();
assert_eq!(from_edges.len(), 2);
let to_edges = graph.get_edges_to("user-1", &to1).unwrap();
assert_eq!(to_edges.len(), 1);
}
#[test]
fn test_infer_error_to_task() {
let (graph, _dir) = create_test_graph();
let error = create_test_memory(ExperienceType::Error, vec!["auth", "login"]);
let mut task = create_test_memory(ExperienceType::Task, vec!["auth", "login"]);
task.created_at = error.created_at + Duration::days(1);
let result = graph.infer_relation(&error, &task);
assert!(result.is_some());
let (relation, confidence) = result.unwrap();
assert_eq!(relation, CausalRelation::Caused);
assert!(confidence > 0.4, "confidence was {}", confidence);
}
#[test]
fn test_infer_learning_to_decision() {
let (graph, _dir) = create_test_graph();
let learning = create_test_memory(ExperienceType::Learning, vec!["react", "hooks"]);
let mut decision =
create_test_memory(ExperienceType::Decision, vec!["react", "hooks", "state"]);
decision.created_at = learning.created_at + Duration::days(2);
let result = graph.infer_relation(&learning, &decision);
assert!(result.is_some());
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::InformedBy);
}
#[test]
fn test_no_inference_wrong_order() {
let (graph, _dir) = create_test_graph();
let task = create_test_memory(ExperienceType::Task, vec!["auth"]);
let mut error = create_test_memory(ExperienceType::Error, vec!["auth"]);
error.created_at = task.created_at - Duration::days(1);
let result = graph.infer_relation(&task, &error);
assert!(result.is_none());
}
#[test]
fn test_branch_creation() {
let (graph, _dir) = create_test_graph();
let branch_point = MemoryId(Uuid::new_v4());
graph.ensure_main_branch("user-1").unwrap();
let branch = graph
.create_branch(
"user-1",
"v2-rewrite",
"main",
branch_point,
Some("Complete rewrite"),
)
.unwrap();
let retrieved = graph.get_branch("user-1", &branch.id).unwrap();
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().name, "v2-rewrite");
}
#[test]
fn test_detect_branch_signal() {
assert!(LineageGraph::detect_branch_signal(
"Let's pivot to a new approach"
));
assert!(LineageGraph::detect_branch_signal(
"Actually, we should rewrite this"
));
assert!(LineageGraph::detect_branch_signal(
"I think we need to start fresh"
));
assert!(!LineageGraph::detect_branch_signal("Fixed the bug in auth"));
}
#[test]
fn test_confirm_and_reject_edge() {
let (graph, _dir) = create_test_graph();
let from = MemoryId(Uuid::new_v4());
let to = MemoryId(Uuid::new_v4());
let edge = LineageEdge::inferred(from.clone(), to.clone(), CausalRelation::Caused, 0.7);
graph.store_edge("user-1", &edge).unwrap();
assert!(graph.confirm_edge("user-1", &edge.id).unwrap());
let confirmed = graph.get_edge("user-1", &edge.id).unwrap().unwrap();
assert_eq!(confirmed.source, LineageSource::Confirmed);
assert_eq!(confirmed.confidence, 1.0);
let edge2 = LineageEdge::inferred(from, to, CausalRelation::RelatedTo, 0.5);
graph.store_edge("user-1", &edge2).unwrap();
assert!(graph.reject_edge("user-1", &edge2.id).unwrap());
assert!(graph.get_edge("user-1", &edge2.id).unwrap().is_none());
}
#[test]
fn test_lineage_stats() {
let (graph, _dir) = create_test_graph();
let from = MemoryId(Uuid::new_v4());
let to = MemoryId(Uuid::new_v4());
graph
.store_edge(
"user-1",
&LineageEdge::inferred(from.clone(), to.clone(), CausalRelation::Caused, 0.8),
)
.unwrap();
graph
.store_edge(
"user-1",
&LineageEdge::explicit(from.clone(), to.clone(), CausalRelation::InformedBy),
)
.unwrap();
graph.ensure_main_branch("user-1").unwrap();
let stats = graph.stats("user-1").unwrap();
assert_eq!(stats.total_edges, 2);
assert_eq!(stats.inferred_edges, 1);
assert_eq!(stats.explicit_edges, 1);
assert_eq!(stats.total_branches, 1);
}
#[test]
fn test_infer_observation_to_task() {
let (graph, _dir) = create_test_graph();
let obs = create_test_memory(ExperienceType::Observation, vec!["auth", "login"]);
let mut task = create_test_memory(ExperienceType::Task, vec!["auth", "login"]);
task.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &task);
assert!(result.is_some(), "Observation → Task should infer");
let (relation, confidence) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
assert!(confidence > 0.3, "confidence was {}", confidence);
}
#[test]
fn test_infer_observation_to_decision() {
let (graph, _dir) = create_test_graph();
let obs = create_test_memory(ExperienceType::Observation, vec!["perf", "latency"]);
let mut decision =
create_test_memory(ExperienceType::Decision, vec!["perf", "latency", "cache"]);
decision.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &decision);
assert!(result.is_some(), "Observation → Decision should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::InformedBy);
}
#[test]
fn test_infer_observation_to_error() {
let (graph, _dir) = create_test_graph();
let obs = create_test_memory(ExperienceType::Observation, vec!["mutex", "deadlock"]);
let mut error = create_test_memory(ExperienceType::Error, vec!["mutex", "deadlock"]);
error.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &error);
assert!(result.is_some(), "Observation → Error should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::Caused);
}
#[test]
fn test_infer_observation_to_learning() {
let (graph, _dir) = create_test_graph();
let obs = create_test_memory(ExperienceType::Observation, vec!["rocksdb", "column"]);
let mut learning = create_test_memory(
ExperienceType::Learning,
vec!["rocksdb", "column", "family"],
);
learning.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &learning);
assert!(result.is_some(), "Observation → Learning should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
}
#[test]
fn test_infer_conversation_to_decision() {
let (graph, _dir) = create_test_graph();
let conv = create_test_memory(ExperienceType::Conversation, vec!["api", "design"]);
let mut decision =
create_test_memory(ExperienceType::Decision, vec!["api", "design", "rest"]);
decision.created_at = conv.created_at + Duration::days(1);
let result = graph.infer_relation(&conv, &decision);
assert!(result.is_some(), "Conversation → Decision should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::InformedBy);
}
#[test]
fn test_infer_conversation_to_task() {
let (graph, _dir) = create_test_graph();
let conv = create_test_memory(ExperienceType::Conversation, vec!["bug", "deploy"]);
let mut task = create_test_memory(ExperienceType::Task, vec!["bug", "deploy"]);
task.created_at = conv.created_at + Duration::days(1);
let result = graph.infer_relation(&conv, &task);
assert!(result.is_some(), "Conversation → Task should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
}
#[test]
fn test_infer_conversation_to_learning() {
let (graph, _dir) = create_test_graph();
let conv = create_test_memory(ExperienceType::Conversation, vec!["hebbian", "memory"]);
let mut learning =
create_test_memory(ExperienceType::Learning, vec!["hebbian", "memory", "decay"]);
learning.created_at = conv.created_at + Duration::days(1);
let result = graph.infer_relation(&conv, &learning);
assert!(result.is_some(), "Conversation → Learning should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::InformedBy);
}
#[test]
fn test_infer_conversation_to_error() {
let (graph, _dir) = create_test_graph();
let conv = create_test_memory(ExperienceType::Conversation, vec!["auth", "token"]);
let mut error = create_test_memory(ExperienceType::Error, vec!["auth", "token"]);
error.created_at = conv.created_at + Duration::days(1);
let result = graph.infer_relation(&conv, &error);
assert!(result.is_some(), "Conversation → Error should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::Caused);
}
#[test]
fn test_infer_conversation_to_discovery() {
let (graph, _dir) = create_test_graph();
let conv = create_test_memory(ExperienceType::Conversation, vec!["graph", "edge"]);
let mut discovery =
create_test_memory(ExperienceType::Discovery, vec!["graph", "edge", "weight"]);
discovery.created_at = conv.created_at + Duration::days(1);
let result = graph.infer_relation(&conv, &discovery);
assert!(result.is_some(), "Conversation → Discovery should infer");
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
}
#[test]
fn test_infer_decision_to_observation_related() {
let (graph, _dir) = create_test_graph();
let decision = create_test_memory(ExperienceType::Decision, vec!["cache", "redis"]);
let mut obs = create_test_memory(ExperienceType::Observation, vec!["cache", "redis"]);
obs.created_at = decision.created_at + Duration::days(1);
let result = graph.infer_relation(&decision, &obs);
assert!(
result.is_some(),
"Decision → Observation should produce RelatedTo via bridge"
);
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::RelatedTo);
}
#[test]
fn test_infer_task_to_conversation_related() {
let (graph, _dir) = create_test_graph();
let task = create_test_memory(ExperienceType::Task, vec!["deploy", "staging"]);
let mut conv = create_test_memory(ExperienceType::Conversation, vec!["deploy", "staging"]);
conv.created_at = task.created_at + Duration::days(1);
let result = graph.infer_relation(&task, &conv);
assert!(
result.is_some(),
"Task → Conversation should produce RelatedTo via bridge"
);
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::RelatedTo);
}
#[test]
fn test_observation_no_entities_still_bridges() {
let (graph, _dir) = create_test_graph();
let obs = create_test_memory(ExperienceType::Observation, vec![]);
let mut task = create_test_memory(ExperienceType::Task, vec![]);
task.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &task);
assert!(
result.is_some(),
"Observation → Task should infer even without entities"
);
let (relation, confidence) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
assert!(
confidence > 0.1,
"low-entity confidence should still be nonzero, was {}",
confidence
);
}
fn create_test_memory_with_embeddings(
exp_type: ExperienceType,
entities: Vec<&str>,
embeddings: Option<Vec<f32>>,
) -> Memory {
let experience = Experience {
experience_type: exp_type,
content: "Test memory".to_string(),
entities: entities.into_iter().map(|s| s.to_string()).collect(),
embeddings,
..Default::default()
};
Memory::new(
MemoryId(Uuid::new_v4()),
experience,
0.5,
None,
None,
None,
None,
)
}
#[test]
fn test_infer_with_high_embedding_similarity() {
let (graph, _dir) = create_test_graph();
let emb = vec![0.1, 0.5, 0.8, 0.3, 0.9];
let obs = create_test_memory_with_embeddings(
ExperienceType::Observation,
vec![],
Some(emb.clone()),
);
let mut task = create_test_memory_with_embeddings(ExperienceType::Task, vec![], Some(emb));
task.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &task);
assert!(
result.is_some(),
"High embedding similarity should produce inference even without entities"
);
let (relation, confidence) = result.unwrap();
assert_eq!(relation, CausalRelation::TriggeredBy);
assert!(confidence > 0.4, "confidence was {}", confidence);
}
#[test]
fn test_infer_embedding_rescues_low_entity_overlap() {
let (graph, _dir) = create_test_graph();
let emb_a = vec![0.1, 0.5, 0.8, 0.3, 0.9];
let emb_b = vec![0.12, 0.48, 0.82, 0.28, 0.88]; let learning =
create_test_memory_with_embeddings(ExperienceType::Learning, vec!["rust"], Some(emb_a));
let mut decision = create_test_memory_with_embeddings(
ExperienceType::Decision,
vec!["go", "lang", "rust"],
Some(emb_b),
);
decision.created_at = learning.created_at + Duration::days(1);
let result = graph.infer_relation(&learning, &decision);
assert!(
result.is_some(),
"Embedding similarity should rescue low entity overlap"
);
let (relation, _) = result.unwrap();
assert_eq!(relation, CausalRelation::InformedBy);
}
#[test]
fn test_infer_low_embedding_similarity_blocked() {
let (graph, _dir) = create_test_graph();
let emb_a = vec![1.0, 0.0, 0.0, 0.0, 0.0];
let emb_b = vec![0.0, 0.0, 0.0, 0.0, 1.0]; let obs =
create_test_memory_with_embeddings(ExperienceType::Observation, vec![], Some(emb_a));
let mut task =
create_test_memory_with_embeddings(ExperienceType::Task, vec![], Some(emb_b));
task.created_at = obs.created_at + Duration::days(1);
let result = graph.infer_relation(&obs, &task);
assert!(
result.is_none(),
"Orthogonal embeddings with no entities should block inference"
);
}
#[test]
fn test_weaken_uses_090_multiplier() {
let mut edge = LineageEdge::inferred(
MemoryId(Uuid::new_v4()),
MemoryId(Uuid::new_v4()),
CausalRelation::Caused,
1.0,
);
let should_prune = edge.weaken();
assert!(!should_prune);
assert!(
(edge.confidence - 0.90).abs() < 0.001,
"expected ~0.90, got {}",
edge.confidence
);
for _ in 0..50 {
edge.weaken();
}
assert!(
edge.confidence < 0.05,
"should be below prune threshold after 50 weakenings"
);
}
}