// Consolidation scoring, replay queues, interference detection, and // graph health metrics. Pure analysis — no store mutations. use crate::store::{Store, now_epoch}; use crate::graph::{self, Graph}; use crate::spectral::{self, SpectralEmbedding, SpectralPosition}; use std::collections::HashMap; const SECS_PER_DAY: f64 = 86400.0; /// Consolidation priority: how urgently a node needs attention. /// /// With spectral data: /// priority = spectral_displacement × overdue × emotion /// Without: /// priority = (1 - cc) × overdue × emotion /// /// Spectral displacement is the outlier_score clamped and normalized — /// it measures how far a node sits from its community center in the /// eigenspace. This is a global signal (considers all graph structure) /// vs CC which is local (only immediate neighbors). pub fn consolidation_priority( store: &Store, key: &str, graph: &Graph, spectral_outlier: Option, ) -> f64 { let node = match store.nodes.get(key) { Some(n) => n, None => return 0.0, }; // Integration factor: how poorly integrated is this node? let displacement = if let Some(outlier) = spectral_outlier { // outlier_score = dist_to_center / median_dist_in_community // 1.0 = typical position, >2 = unusual, >5 = extreme outlier // Use log scale for dynamic range: the difference between // outlier=5 and outlier=10 matters less than 1 vs 2. (outlier / 3.0).min(3.0) } else { let cc = graph.clustering_coefficient(key) as f64; 1.0 - cc }; // Spaced repetition: how overdue is this node for replay? let interval_secs = node.spaced_repetition_interval as f64 * SECS_PER_DAY; let time_since_replay = if node.last_replayed > 0 { (now_epoch() - node.last_replayed).max(0) as f64 } else { interval_secs * 3.0 }; let overdue_ratio = (time_since_replay / interval_secs).min(5.0); // Emotional intensity: higher emotion = higher priority let emotion_factor = 1.0 + (node.emotion as f64 / 10.0); displacement * overdue_ratio * emotion_factor } /// Item in the replay queue pub struct ReplayItem { pub key: String, pub priority: f64, pub interval_days: u32, pub emotion: f32, pub cc: f32, /// Spectral classification: "bridge", "outlier", "core", "peripheral" pub classification: &'static str, /// Raw spectral outlier score (distance / median) pub outlier_score: f64, } /// Generate the replay queue: nodes ordered by consolidation priority. /// Automatically loads spectral embedding if available. pub fn replay_queue(store: &Store, count: usize) -> Vec { let graph = store.build_graph(); let emb = spectral::load_embedding().ok(); replay_queue_with_graph(store, count, &graph, emb.as_ref()) } /// Generate the replay queue using pre-built graph and optional spectral data. pub fn replay_queue_with_graph( store: &Store, count: usize, graph: &Graph, emb: Option<&SpectralEmbedding>, ) -> Vec { // Build spectral position map if embedding is available let positions: HashMap = if let Some(emb) = emb { let communities = graph.communities().clone(); spectral::analyze_positions(emb, &communities) .into_iter() .map(|p| (p.key.clone(), p)) .collect() } else { HashMap::new() }; let mut items: Vec = store.nodes.iter() .map(|(key, node)| { let pos = positions.get(key); let outlier_score = pos.map(|p| p.outlier_score).unwrap_or(0.0); let classification = pos .map(|p| spectral::classify_position(p)) .unwrap_or("unknown"); let priority = consolidation_priority( store, key, graph, pos.map(|p| p.outlier_score), ); ReplayItem { key: key.clone(), priority, interval_days: node.spaced_repetition_interval, emotion: node.emotion, cc: graph.clustering_coefficient(key), classification, outlier_score, } }) .collect(); items.sort_by(|a, b| b.priority.total_cmp(&a.priority)); items.truncate(count); items } /// Detect interfering memory pairs: high text similarity but different communities pub fn detect_interference( store: &Store, graph: &Graph, threshold: f32, ) -> Vec<(String, String, f32)> { use crate::similarity; let communities = graph.communities(); // Only compare nodes within a reasonable set — take the most active ones let mut docs: Vec<(String, String)> = store.nodes.iter() .filter(|(_, n)| n.content.len() > 50) // skip tiny nodes .map(|(k, n)| (k.clone(), n.content.clone())) .collect(); // For large stores, sample to keep pairwise comparison feasible if docs.len() > 200 { docs.sort_by(|a, b| b.1.len().cmp(&a.1.len())); docs.truncate(200); } let similar = similarity::pairwise_similar(&docs, threshold); // Filter to pairs in different communities similar.into_iter() .filter(|(a, b, _)| { let ca = communities.get(a); let cb = communities.get(b); match (ca, cb) { (Some(a), Some(b)) => a != b, _ => true, // if community unknown, flag it } }) .collect() } /// Schema assimilation scoring for a new node. /// Returns how easily the node integrates into existing structure. /// /// High fit (>0.5): auto-link, done /// Medium fit (0.2-0.5): agent reviews, proposes links /// Low fit (<0.2): deep examination needed — new schema seed, bridge, or noise? pub fn schema_assimilation(store: &Store, key: &str) -> (f32, &'static str) { let graph = store.build_graph(); let fit = graph.clustering_coefficient(key); let recommendation = if fit > 0.5 { "auto-integrate" } else if fit > 0.2 { "agent-review" } else if graph.degree(key) > 0 { "deep-examine-bridge" } else { "deep-examine-orphan" }; (fit, recommendation) } /// Agent allocation from the control loop pub struct ConsolidationPlan { pub replay_count: usize, pub linker_count: usize, pub separator_count: usize, pub transfer_count: usize, pub run_health: bool, pub rationale: Vec, } /// Analyze metrics and decide how much each agent needs to run. /// /// This is the control loop: metrics → error signal → agent allocation. /// Target values are based on healthy small-world networks. pub fn consolidation_plan(store: &Store) -> ConsolidationPlan { let graph = store.build_graph(); let alpha = graph.degree_power_law_exponent(); let gini = graph.degree_gini(); let avg_cc = graph.avg_clustering_coefficient(); let interference_pairs = detect_interference(store, &graph, 0.5); let interference_count = interference_pairs.len(); // Count episodic vs semantic nodes let episodic_count = store.nodes.iter() .filter(|(k, _)| k.contains("journal") || k.contains("session")) .count(); let _semantic_count = store.nodes.len() - episodic_count; let episodic_ratio = if store.nodes.is_empty() { 0.0 } else { episodic_count as f32 / store.nodes.len() as f32 }; let mut plan = ConsolidationPlan { replay_count: 0, linker_count: 0, separator_count: 0, transfer_count: 0, run_health: true, // always run health first rationale: Vec::new(), }; // Target: α ≥ 2.5 (healthy scale-free) if alpha < 2.0 { plan.replay_count += 10; plan.linker_count += 5; plan.rationale.push(format!( "α={:.2} (target ≥2.5): extreme hub dominance → 10 replay + 5 linker for lateral links", alpha)); } else if alpha < 2.5 { plan.replay_count += 5; plan.linker_count += 3; plan.rationale.push(format!( "α={:.2} (target ≥2.5): moderate hub dominance → 5 replay + 3 linker", alpha)); } else { plan.replay_count += 3; plan.rationale.push(format!( "α={:.2}: healthy — 3 replay for maintenance", alpha)); } // Target: Gini ≤ 0.4 if gini > 0.5 { plan.replay_count += 3; plan.rationale.push(format!( "Gini={:.3} (target ≤0.4): high inequality → +3 replay (lateral focus)", gini)); } // Target: avg CC ≥ 0.2 if avg_cc < 0.1 { plan.replay_count += 5; plan.rationale.push(format!( "CC={:.3} (target ≥0.2): very poor integration → +5 replay", avg_cc)); } else if avg_cc < 0.2 { plan.replay_count += 2; plan.rationale.push(format!( "CC={:.3} (target ≥0.2): low integration → +2 replay", avg_cc)); } // Interference: >100 pairs is a lot, <10 is clean if interference_count > 100 { plan.separator_count += 10; plan.rationale.push(format!( "Interference: {} pairs (target <50) → 10 separator", interference_count)); } else if interference_count > 20 { plan.separator_count += 5; plan.rationale.push(format!( "Interference: {} pairs (target <50) → 5 separator", interference_count)); } else if interference_count > 0 { plan.separator_count += interference_count.min(3); plan.rationale.push(format!( "Interference: {} pairs → {} separator", interference_count, plan.separator_count)); } // Episodic → semantic transfer if episodic_ratio > 0.6 { plan.transfer_count += 10; plan.rationale.push(format!( "Episodic ratio: {:.0}% ({}/{}) → 10 transfer (knowledge extraction needed)", episodic_ratio * 100.0, episodic_count, store.nodes.len())); } else if episodic_ratio > 0.4 { plan.transfer_count += 5; plan.rationale.push(format!( "Episodic ratio: {:.0}% → 5 transfer", episodic_ratio * 100.0)); } plan } /// Format the consolidation plan for display pub fn format_plan(plan: &ConsolidationPlan) -> String { let mut out = String::from("Consolidation Plan\n==================\n\n"); out.push_str("Analysis:\n"); for r in &plan.rationale { out.push_str(&format!(" • {}\n", r)); } out.push_str("\nAgent allocation:\n"); if plan.run_health { out.push_str(" 1. health — system audit\n"); } let mut step = 2; if plan.replay_count > 0 { out.push_str(&format!(" {}. replay ×{:2} — schema assimilation + lateral linking\n", step, plan.replay_count)); step += 1; } if plan.linker_count > 0 { out.push_str(&format!(" {}. linker ×{:2} — relational binding from episodes\n", step, plan.linker_count)); step += 1; } if plan.separator_count > 0 { out.push_str(&format!(" {}. separator ×{} — pattern separation\n", step, plan.separator_count)); step += 1; } if plan.transfer_count > 0 { out.push_str(&format!(" {}. transfer ×{:2} — episodic→semantic extraction\n", step, plan.transfer_count)); } let total = plan.replay_count + plan.linker_count + plan.separator_count + plan.transfer_count + if plan.run_health { 1 } else { 0 }; out.push_str(&format!("\nTotal agent runs: {}\n", total)); out } /// Brief daily check: compare current metrics to last snapshot pub fn daily_check(store: &Store) -> String { let graph_obj = store.build_graph(); let snap = graph::current_metrics(&graph_obj); let history = graph::load_metrics_history(); let prev = history.last(); let mut out = String::from("Memory daily check\n"); // Current state out.push_str(&format!(" σ={:.1} α={:.2} gini={:.3} cc={:.4}\n", snap.sigma, snap.alpha, snap.gini, snap.avg_cc)); // Trend if let Some(p) = prev { let d_sigma = snap.sigma - p.sigma; let d_alpha = snap.alpha - p.alpha; let d_gini = snap.gini - p.gini; out.push_str(&format!(" Δσ={:+.1} Δα={:+.2} Δgini={:+.3}\n", d_sigma, d_alpha, d_gini)); // Assessment let mut issues = Vec::new(); if snap.alpha < 2.0 { issues.push("hub dominance critical"); } if snap.gini > 0.5 { issues.push("high inequality"); } if snap.avg_cc < 0.1 { issues.push("poor integration"); } if d_sigma < -5.0 { issues.push("σ declining"); } if d_alpha < -0.1 { issues.push("α declining"); } if d_gini > 0.02 { issues.push("inequality increasing"); } if issues.is_empty() { out.push_str(" Status: healthy\n"); } else { out.push_str(&format!(" Status: needs attention — {}\n", issues.join(", "))); out.push_str(" Run: poc-memory consolidate-session\n"); } } else { out.push_str(" (first snapshot, no trend data yet)\n"); } // Persist the snapshot graph::save_metrics_snapshot(&snap); out }