2026-01-25 01:31:03 -06:00
|
|
|
//! File relationship tracking for codebase memory
|
|
|
|
|
//!
|
|
|
|
|
//! This module tracks relationships between files:
|
|
|
|
|
//! - Co-edit patterns (files edited together)
|
|
|
|
|
//! - Import/dependency relationships
|
|
|
|
|
//! - Test-implementation relationships
|
|
|
|
|
//! - Domain groupings
|
|
|
|
|
//!
|
|
|
|
|
//! Understanding file relationships helps:
|
|
|
|
|
//! - Suggest related files when editing
|
|
|
|
|
//! - Provide better context for code generation
|
|
|
|
|
//! - Identify architectural boundaries
|
|
|
|
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
use super::types::{FileRelationship, RelationType, RelationshipSource};
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// ERRORS
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum RelationshipError {
|
|
|
|
|
#[error("Relationship not found: {0}")]
|
|
|
|
|
NotFound(String),
|
|
|
|
|
#[error("Invalid relationship: {0}")]
|
|
|
|
|
Invalid(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub type Result<T> = std::result::Result<T, RelationshipError>;
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// RELATED FILE
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/// A file that is related to another file
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct RelatedFile {
|
|
|
|
|
/// Path to the related file
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
/// Type of relationship
|
|
|
|
|
pub relationship_type: RelationType,
|
|
|
|
|
/// Strength of the relationship (0.0 - 1.0)
|
|
|
|
|
pub strength: f64,
|
|
|
|
|
/// Human-readable description
|
|
|
|
|
pub description: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// RELATIONSHIP GRAPH
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/// Graph structure for visualizing file relationships
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct RelationshipGraph {
|
|
|
|
|
/// Nodes (files) in the graph
|
|
|
|
|
pub nodes: Vec<GraphNode>,
|
|
|
|
|
/// Edges (relationships) in the graph
|
|
|
|
|
pub edges: Vec<GraphEdge>,
|
|
|
|
|
/// Graph metadata
|
|
|
|
|
pub metadata: GraphMetadata,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A node in the relationship graph
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct GraphNode {
|
|
|
|
|
/// Unique ID for this node
|
|
|
|
|
pub id: String,
|
|
|
|
|
/// File path
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
/// Display label
|
|
|
|
|
pub label: String,
|
|
|
|
|
/// Node type (for styling)
|
|
|
|
|
pub node_type: String,
|
|
|
|
|
/// Number of connections
|
|
|
|
|
pub degree: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An edge in the relationship graph
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct GraphEdge {
|
|
|
|
|
/// Source node ID
|
|
|
|
|
pub source: String,
|
|
|
|
|
/// Target node ID
|
|
|
|
|
pub target: String,
|
|
|
|
|
/// Relationship type
|
|
|
|
|
pub relationship_type: RelationType,
|
|
|
|
|
/// Edge weight (strength)
|
|
|
|
|
pub weight: f64,
|
|
|
|
|
/// Edge label
|
|
|
|
|
pub label: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Metadata about the graph
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct GraphMetadata {
|
|
|
|
|
/// Total number of nodes
|
|
|
|
|
pub node_count: usize,
|
|
|
|
|
/// Total number of edges
|
|
|
|
|
pub edge_count: usize,
|
|
|
|
|
/// When the graph was built
|
|
|
|
|
pub built_at: DateTime<Utc>,
|
|
|
|
|
/// Average relationship strength
|
|
|
|
|
pub average_strength: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// CO-EDIT SESSION
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/// Tracks files edited together in a session
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct CoEditSession {
|
|
|
|
|
/// Files in this session
|
|
|
|
|
files: HashSet<PathBuf>,
|
|
|
|
|
/// When the session started (for analytics/debugging)
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
started_at: DateTime<Utc>,
|
|
|
|
|
/// When the session was last updated
|
|
|
|
|
last_updated: DateTime<Utc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// RELATIONSHIP TRACKER
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/// Tracks relationships between files in a codebase
|
|
|
|
|
pub struct RelationshipTracker {
|
|
|
|
|
/// All relationships indexed by ID
|
|
|
|
|
relationships: HashMap<String, FileRelationship>,
|
|
|
|
|
/// Relationships indexed by file for fast lookup
|
|
|
|
|
file_relationships: HashMap<PathBuf, Vec<String>>,
|
|
|
|
|
/// Current co-edit session
|
|
|
|
|
current_session: Option<CoEditSession>,
|
|
|
|
|
/// Co-edit counts between file pairs
|
|
|
|
|
coedit_counts: HashMap<(PathBuf, PathBuf), u32>,
|
|
|
|
|
/// ID counter for new relationships
|
|
|
|
|
next_id: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RelationshipTracker {
|
|
|
|
|
/// Create a new relationship tracker
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
relationships: HashMap::new(),
|
|
|
|
|
file_relationships: HashMap::new(),
|
|
|
|
|
current_session: None,
|
|
|
|
|
coedit_counts: HashMap::new(),
|
|
|
|
|
next_id: 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Generate a new relationship ID
|
|
|
|
|
fn new_id(&mut self) -> String {
|
|
|
|
|
let id = format!("rel-{}", self.next_id);
|
|
|
|
|
self.next_id += 1;
|
|
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add a relationship
|
|
|
|
|
pub fn add_relationship(&mut self, relationship: FileRelationship) -> Result<String> {
|
|
|
|
|
if relationship.files.len() < 2 {
|
|
|
|
|
return Err(RelationshipError::Invalid(
|
|
|
|
|
"Relationship must have at least 2 files".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let id = relationship.id.clone();
|
|
|
|
|
|
|
|
|
|
// Index by each file
|
|
|
|
|
for file in &relationship.files {
|
|
|
|
|
self.file_relationships
|
|
|
|
|
.entry(file.clone())
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(id.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.relationships.insert(id.clone(), relationship);
|
|
|
|
|
|
|
|
|
|
Ok(id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Record that files were edited together
|
|
|
|
|
pub fn record_coedit(&mut self, files: &[PathBuf]) -> Result<()> {
|
|
|
|
|
if files.len() < 2 {
|
|
|
|
|
return Ok(()); // Need at least 2 files for a relationship
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
|
|
|
|
|
// Update or create session
|
|
|
|
|
match &mut self.current_session {
|
|
|
|
|
Some(session) => {
|
|
|
|
|
// Check if session is still active (within 30 minutes)
|
|
|
|
|
let elapsed = now.signed_duration_since(session.last_updated);
|
|
|
|
|
if elapsed.num_minutes() > 30 {
|
|
|
|
|
// Session expired, finalize it and start new
|
|
|
|
|
self.finalize_session()?;
|
|
|
|
|
self.current_session = Some(CoEditSession {
|
|
|
|
|
files: files.iter().cloned().collect(),
|
|
|
|
|
started_at: now,
|
|
|
|
|
last_updated: now,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Add files to current session
|
|
|
|
|
session.files.extend(files.iter().cloned());
|
|
|
|
|
session.last_updated = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
// Start new session
|
|
|
|
|
self.current_session = Some(CoEditSession {
|
|
|
|
|
files: files.iter().cloned().collect(),
|
|
|
|
|
started_at: now,
|
|
|
|
|
last_updated: now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update co-edit counts for each pair
|
|
|
|
|
for i in 0..files.len() {
|
|
|
|
|
for j in (i + 1)..files.len() {
|
|
|
|
|
let pair = if files[i] < files[j] {
|
|
|
|
|
(files[i].clone(), files[j].clone())
|
|
|
|
|
} else {
|
|
|
|
|
(files[j].clone(), files[i].clone())
|
|
|
|
|
};
|
|
|
|
|
*self.coedit_counts.entry(pair).or_insert(0) += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Finalize the current session and create relationships
|
|
|
|
|
fn finalize_session(&mut self) -> Result<()> {
|
|
|
|
|
if let Some(session) = self.current_session.take() {
|
|
|
|
|
let files: Vec<_> = session.files.into_iter().collect();
|
|
|
|
|
|
|
|
|
|
if files.len() >= 2 {
|
|
|
|
|
// Create relationships for frequent co-edits
|
|
|
|
|
for i in 0..files.len() {
|
|
|
|
|
for j in (i + 1)..files.len() {
|
|
|
|
|
let pair = if files[i] < files[j] {
|
|
|
|
|
(files[i].clone(), files[j].clone())
|
|
|
|
|
} else {
|
|
|
|
|
(files[j].clone(), files[i].clone())
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let count = self.coedit_counts.get(&pair).copied().unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
// Only create relationship if edited together multiple times
|
|
|
|
|
if count >= 3 {
|
|
|
|
|
let strength = (count as f64 / 10.0).min(1.0);
|
|
|
|
|
let id = self.new_id();
|
|
|
|
|
|
|
|
|
|
let relationship = FileRelationship {
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
files: vec![pair.0.clone(), pair.1.clone()],
|
|
|
|
|
relationship_type: RelationType::FrequentCochange,
|
|
|
|
|
strength,
|
|
|
|
|
description: format!(
|
|
|
|
|
"Edited together {} times in recent sessions",
|
|
|
|
|
count
|
|
|
|
|
),
|
|
|
|
|
created_at: Utc::now(),
|
|
|
|
|
last_confirmed: Some(Utc::now()),
|
|
|
|
|
source: RelationshipSource::UserDefined,
|
|
|
|
|
observation_count: count,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if relationship already exists
|
|
|
|
|
let exists = self
|
|
|
|
|
.relationships
|
|
|
|
|
.values()
|
|
|
|
|
.any(|r| r.files.contains(&pair.0) && r.files.contains(&pair.1));
|
|
|
|
|
|
|
|
|
|
if !exists {
|
|
|
|
|
self.add_relationship(relationship)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get files related to a given file
|
|
|
|
|
pub fn get_related_files(&self, file: &Path) -> Result<Vec<RelatedFile>> {
|
|
|
|
|
let path = file.to_path_buf();
|
|
|
|
|
|
|
|
|
|
let relationship_ids = self.file_relationships.get(&path);
|
|
|
|
|
|
|
|
|
|
let related: Vec<_> = relationship_ids
|
|
|
|
|
.map(|ids| {
|
|
|
|
|
ids.iter()
|
|
|
|
|
.filter_map(|id| self.relationships.get(id))
|
|
|
|
|
.flat_map(|rel| {
|
|
|
|
|
rel.files
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|f| *f != &path)
|
|
|
|
|
.map(|f| RelatedFile {
|
|
|
|
|
path: f.clone(),
|
|
|
|
|
relationship_type: rel.relationship_type,
|
|
|
|
|
strength: rel.strength,
|
|
|
|
|
description: rel.description.clone(),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
// Also check for test file relationships
|
|
|
|
|
let mut additional = self.infer_test_relationships(file);
|
|
|
|
|
additional.extend(related);
|
|
|
|
|
|
|
|
|
|
// Deduplicate by path
|
|
|
|
|
let mut seen = HashSet::new();
|
|
|
|
|
let deduped: Vec<_> = additional
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|r| seen.insert(r.path.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(deduped)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Infer test file relationships based on naming conventions
|
|
|
|
|
fn infer_test_relationships(&self, file: &Path) -> Vec<RelatedFile> {
|
|
|
|
|
let mut related = Vec::new();
|
|
|
|
|
|
|
|
|
|
let file_stem = file
|
|
|
|
|
.file_stem()
|
|
|
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let extension = file
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let parent = file.parent().unwrap_or(Path::new("."));
|
|
|
|
|
|
|
|
|
|
// Check for test file naming patterns
|
|
|
|
|
let is_test = file_stem.contains("test")
|
|
|
|
|
|| file_stem.contains("spec")
|
|
|
|
|
|| file_stem.ends_with("_test")
|
|
|
|
|
|| file_stem.starts_with("test_");
|
|
|
|
|
|
|
|
|
|
if is_test {
|
|
|
|
|
// This is a test file - find the implementation
|
|
|
|
|
let impl_stem = file_stem
|
|
|
|
|
.replace("_test", "")
|
|
|
|
|
.replace(".test", "")
|
|
|
|
|
.replace("_spec", "")
|
|
|
|
|
.replace(".spec", "")
|
|
|
|
|
.trim_start_matches("test_")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let impl_path = parent.join(format!("{}.{}", impl_stem, extension));
|
|
|
|
|
|
|
|
|
|
if impl_path.exists() {
|
|
|
|
|
related.push(RelatedFile {
|
|
|
|
|
path: impl_path,
|
|
|
|
|
relationship_type: RelationType::TestsImplementation,
|
|
|
|
|
strength: 0.9,
|
|
|
|
|
description: "Implementation file for this test".to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// This is an implementation - find the test file
|
|
|
|
|
let test_patterns = [
|
|
|
|
|
format!("{}_test.{}", file_stem, extension),
|
|
|
|
|
format!("{}.test.{}", file_stem, extension),
|
|
|
|
|
format!("test_{}.{}", file_stem, extension),
|
|
|
|
|
format!("{}_spec.{}", file_stem, extension),
|
|
|
|
|
format!("{}.spec.{}", file_stem, extension),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for pattern in &test_patterns {
|
|
|
|
|
let test_path = parent.join(pattern);
|
|
|
|
|
if test_path.exists() {
|
|
|
|
|
related.push(RelatedFile {
|
|
|
|
|
path: test_path,
|
|
|
|
|
relationship_type: RelationType::TestsImplementation,
|
|
|
|
|
strength: 0.9,
|
|
|
|
|
description: "Test file for this implementation".to_string(),
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check tests/ directory
|
|
|
|
|
if let Some(grandparent) = parent.parent() {
|
|
|
|
|
let tests_dir = grandparent.join("tests");
|
|
|
|
|
if tests_dir.exists() {
|
|
|
|
|
for pattern in &test_patterns {
|
|
|
|
|
let test_path = tests_dir.join(pattern);
|
|
|
|
|
if test_path.exists() {
|
|
|
|
|
related.push(RelatedFile {
|
|
|
|
|
path: test_path,
|
|
|
|
|
relationship_type: RelationType::TestsImplementation,
|
|
|
|
|
strength: 0.8,
|
|
|
|
|
description: "Test file in tests/ directory".to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
related
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a relationship graph for visualization
|
|
|
|
|
pub fn build_graph(&self) -> Result<RelationshipGraph> {
|
|
|
|
|
let mut nodes = Vec::new();
|
|
|
|
|
let mut edges = Vec::new();
|
|
|
|
|
let mut node_ids: HashMap<PathBuf, String> = HashMap::new();
|
|
|
|
|
let mut node_degrees: HashMap<String, usize> = HashMap::new();
|
|
|
|
|
|
|
|
|
|
// Build nodes from all files in relationships
|
|
|
|
|
for relationship in self.relationships.values() {
|
|
|
|
|
for file in &relationship.files {
|
|
|
|
|
if !node_ids.contains_key(file) {
|
|
|
|
|
let id = format!("node-{}", node_ids.len());
|
|
|
|
|
node_ids.insert(file.clone(), id.clone());
|
|
|
|
|
|
|
|
|
|
let label = file
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_else(|| file.to_string_lossy().to_string());
|
|
|
|
|
|
|
|
|
|
let node_type = file
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|e| e.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
|
|
|
|
|
|
nodes.push(GraphNode {
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
path: file.clone(),
|
|
|
|
|
label,
|
|
|
|
|
node_type,
|
|
|
|
|
degree: 0, // Will update later
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build edges from relationships
|
|
|
|
|
for relationship in self.relationships.values() {
|
|
|
|
|
if relationship.files.len() >= 2 {
|
|
|
|
|
// Skip relationships where files aren't in the node map
|
|
|
|
|
let Some(source_id) = node_ids.get(&relationship.files[0]).cloned() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Some(target_id) = node_ids.get(&relationship.files[1]).cloned() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update degrees
|
|
|
|
|
*node_degrees.entry(source_id.clone()).or_insert(0) += 1;
|
|
|
|
|
*node_degrees.entry(target_id.clone()).or_insert(0) += 1;
|
|
|
|
|
|
|
|
|
|
let label = format!("{:?}", relationship.relationship_type);
|
|
|
|
|
|
|
|
|
|
edges.push(GraphEdge {
|
|
|
|
|
source: source_id,
|
|
|
|
|
target: target_id,
|
|
|
|
|
relationship_type: relationship.relationship_type,
|
|
|
|
|
weight: relationship.strength,
|
|
|
|
|
label,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update node degrees
|
|
|
|
|
for node in &mut nodes {
|
|
|
|
|
node.degree = node_degrees.get(&node.id).copied().unwrap_or(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate metadata
|
|
|
|
|
let average_strength = if edges.is_empty() {
|
|
|
|
|
0.0
|
|
|
|
|
} else {
|
|
|
|
|
edges.iter().map(|e| e.weight).sum::<f64>() / edges.len() as f64
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let metadata = GraphMetadata {
|
|
|
|
|
node_count: nodes.len(),
|
|
|
|
|
edge_count: edges.len(),
|
|
|
|
|
built_at: Utc::now(),
|
|
|
|
|
average_strength,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(RelationshipGraph {
|
|
|
|
|
nodes,
|
|
|
|
|
edges,
|
|
|
|
|
metadata,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get a specific relationship by ID
|
|
|
|
|
pub fn get_relationship(&self, id: &str) -> Option<&FileRelationship> {
|
|
|
|
|
self.relationships.get(id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get all relationships
|
|
|
|
|
pub fn get_all_relationships(&self) -> Vec<&FileRelationship> {
|
|
|
|
|
self.relationships.values().collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Delete a relationship
|
|
|
|
|
pub fn delete_relationship(&mut self, id: &str) -> Result<()> {
|
|
|
|
|
if let Some(relationship) = self.relationships.remove(id) {
|
|
|
|
|
// Remove from file index
|
|
|
|
|
for file in &relationship.files {
|
|
|
|
|
if let Some(ids) = self.file_relationships.get_mut(file) {
|
|
|
|
|
ids.retain(|i| i != id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(RelationshipError::NotFound(id.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get relationships by type
|
|
|
|
|
pub fn get_relationships_by_type(&self, rel_type: RelationType) -> Vec<&FileRelationship> {
|
|
|
|
|
self.relationships
|
|
|
|
|
.values()
|
|
|
|
|
.filter(|r| r.relationship_type == rel_type)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update relationship strength
|
|
|
|
|
pub fn update_strength(&mut self, id: &str, delta: f64) -> Result<()> {
|
|
|
|
|
if let Some(relationship) = self.relationships.get_mut(id) {
|
|
|
|
|
relationship.strength = (relationship.strength + delta).clamp(0.0, 1.0);
|
|
|
|
|
relationship.last_confirmed = Some(Utc::now());
|
|
|
|
|
relationship.observation_count += 1;
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(RelationshipError::NotFound(id.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load relationships from storage
|
|
|
|
|
pub fn load_relationships(&mut self, relationships: Vec<FileRelationship>) -> Result<()> {
|
|
|
|
|
for relationship in relationships {
|
|
|
|
|
self.add_relationship(relationship)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Export all relationships for storage
|
|
|
|
|
pub fn export_relationships(&self) -> Vec<FileRelationship> {
|
|
|
|
|
self.relationships.values().cloned().collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get the most connected files (highest degree in graph)
|
|
|
|
|
pub fn get_hub_files(&self, limit: usize) -> Vec<(PathBuf, usize)> {
|
|
|
|
|
let mut file_degrees: HashMap<PathBuf, usize> = HashMap::new();
|
|
|
|
|
|
|
|
|
|
for relationship in self.relationships.values() {
|
|
|
|
|
for file in &relationship.files {
|
|
|
|
|
*file_degrees.entry(file.clone()).or_insert(0) += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut sorted: Vec<_> = file_degrees.into_iter().collect();
|
fix(clippy): Rust 1.95 compatibility — sort_by_key + collapsible_match
CI runs on stable Rust which advanced from 1.93 to 1.95, introducing
two newly-enforced lints under -D warnings:
- clippy::unnecessary_sort_by (12 sites) — rewrite sort_by closures
that only call .cmp on Copy keys as sort_by_key, using std::cmp::Reverse
for the descending ones.
- clippy::collapsible_match (1 site) — merge the MemoryState::Dormant
inner if into a match guard. The equivalent rewrite for the Ping/Pong
arm in websocket.rs is blocked by a move of non-Copy Bytes across the
match-guard boundary, so that one site gets an explicit #[allow].
Files touched:
- crates/vestige-core/src/advanced/{chains,compression,cross_project,reconsolidation}.rs
- crates/vestige-core/src/codebase/{git,relationships}.rs
- crates/vestige-core/src/neuroscience/{importance_signals,memory_states,predictive_retrieval}.rs
- crates/vestige-mcp/src/tools/{changelog,cross_reference}.rs
- crates/vestige-mcp/src/dashboard/websocket.rs
Verified: cargo clippy --workspace -- -D warnings green on rustc 1.95.0;
cargo test --workspace unchanged (1292 tests green).
2026-04-19 21:11:49 -05:00
|
|
|
sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
|
2026-01-25 01:31:03 -06:00
|
|
|
sorted.truncate(limit);
|
|
|
|
|
|
|
|
|
|
sorted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for RelationshipTracker {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self::new()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// TESTS
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn create_test_relationship() -> FileRelationship {
|
|
|
|
|
FileRelationship::new(
|
|
|
|
|
"test-rel-1".to_string(),
|
|
|
|
|
vec![PathBuf::from("src/main.rs"), PathBuf::from("src/lib.rs")],
|
|
|
|
|
RelationType::SharedDomain,
|
|
|
|
|
"Core entry points".to_string(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_add_relationship() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
let rel = create_test_relationship();
|
|
|
|
|
|
|
|
|
|
let result = tracker.add_relationship(rel);
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
|
|
|
|
|
let stored = tracker.get_relationship("test-rel-1");
|
|
|
|
|
assert!(stored.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_related_files() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
let rel = create_test_relationship();
|
|
|
|
|
tracker.add_relationship(rel).unwrap();
|
|
|
|
|
|
|
|
|
|
let related = tracker.get_related_files(Path::new("src/main.rs")).unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!related.is_empty());
|
feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control
First AI memory system to model forgetting as a neuroscience-grounded
PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24),
Rac1 cascade worker, migration V10, and dashboard forgetting indicators.
Based on:
- Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right
lateral PFC as the domain-general inhibitory controller; SIF
compounds with each stopping attempt.
- Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 —
Rac1 GTPase as the active synaptic destabilization mechanism.
What's new:
* `suppress` MCP tool — each call compounds `suppression_count` and
subtracts a `0.15 × count` penalty (saturating at 80%) from
retrieval scores during hybrid search. Distinct from delete
(removes) and demote (one-shot).
* Rac1 cascade worker — background sweep piggybacks the 6h
consolidation loop, walks `memory_connections` edges from
recently-suppressed seeds, applies attenuated FSRS decay to
co-activated neighbors. You don't just forget Jake — you fade
the café, the roommate, the birthday.
* 24h labile window — reversible via `suppress({id, reverse: true})`
within 24 hours. Matches Nader reconsolidation semantics.
* Migration V10 — additive-only (`suppression_count`, `suppressed_at`
+ partial indices). All v2.0.x DBs upgrade seamlessly on first launch.
* Dashboard: `ForgettingIndicator.svelte` pulses when suppressions
are active. 3D graph nodes dim to 20% opacity when suppressed.
New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`,
`Rac1CascadeSwept`. Heartbeat carries `suppressed_count`.
* Search pipeline: SIF penalty inserted into the accessibility stage
so it stacks on top of passive FSRS decay.
* Tool count bumped 23 → 24. Cognitive modules 29 → 30.
Memories persist — they are INHIBITED, not erased. `memory.get(id)`
returns full content through any number of suppressions. The 24h
labile window is a grace period for regret.
Also fixes issue #31 (dashboard graph view buggy) as a companion UI
bug discovered during the v2.0.5 audit cycle:
* Root cause: node glow `SpriteMaterial` had no `map`, so
`THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive
blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square
edges into hard-edged glowing cubes.
* Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used
as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog
density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a`
to brand violet `0x8b5cf6` with higher opacity. Added explicit
`scene.background` and a 2000-point starfield for depth.
* 21 regression tests added in `ui-fixes.test.ts` locking every
invariant in (shared texture singleton, depthWrite:false, scale
×6, bloom magic numbers via source regex, starfield presence).
Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed
Clippy: clean across all targets, zero warnings
Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green
Versions: workspace aligned at 2.0.5 across all 6 crates/packages
Closes #31
2026-04-14 17:30:30 -05:00
|
|
|
assert!(related.iter().any(|r| r.path == Path::new("src/lib.rs")));
|
2026-01-25 01:31:03 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_graph() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
let rel = create_test_relationship();
|
|
|
|
|
tracker.add_relationship(rel).unwrap();
|
|
|
|
|
|
|
|
|
|
let graph = tracker.build_graph().unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(graph.nodes.len(), 2);
|
|
|
|
|
assert_eq!(graph.edges.len(), 1);
|
|
|
|
|
assert_eq!(graph.metadata.node_count, 2);
|
|
|
|
|
assert_eq!(graph.metadata.edge_count, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_delete_relationship() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
let rel = create_test_relationship();
|
|
|
|
|
tracker.add_relationship(rel).unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(tracker.get_relationship("test-rel-1").is_some());
|
|
|
|
|
|
|
|
|
|
tracker.delete_relationship("test-rel-1").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(tracker.get_relationship("test-rel-1").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_record_coedit() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
|
|
|
|
|
let files = vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")];
|
|
|
|
|
|
|
|
|
|
// Record multiple coedits
|
|
|
|
|
for _ in 0..5 {
|
|
|
|
|
tracker.record_coedit(&files).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Finalize should create a relationship
|
|
|
|
|
tracker.finalize_session().unwrap();
|
|
|
|
|
|
|
|
|
|
// Should have a co-change relationship
|
|
|
|
|
let relationships = tracker.get_relationships_by_type(RelationType::FrequentCochange);
|
|
|
|
|
assert!(!relationships.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_hub_files() {
|
|
|
|
|
let mut tracker = RelationshipTracker::new();
|
|
|
|
|
|
|
|
|
|
// Create a hub file (main.rs) connected to multiple others
|
|
|
|
|
for i in 0..5 {
|
|
|
|
|
let rel = FileRelationship::new(
|
|
|
|
|
format!("rel-{}", i),
|
|
|
|
|
vec![
|
|
|
|
|
PathBuf::from("src/main.rs"),
|
|
|
|
|
PathBuf::from(format!("src/module{}.rs", i)),
|
|
|
|
|
],
|
|
|
|
|
RelationType::ImportsDependency,
|
|
|
|
|
"Import relationship".to_string(),
|
|
|
|
|
);
|
|
|
|
|
tracker.add_relationship(rel).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let hubs = tracker.get_hub_files(3);
|
|
|
|
|
|
|
|
|
|
assert!(!hubs.is_empty());
|
|
|
|
|
assert_eq!(hubs[0].0, PathBuf::from("src/main.rs"));
|
|
|
|
|
assert_eq!(hubs[0].1, 5);
|
|
|
|
|
}
|
|
|
|
|
}
|