neuro: split into scoring, prompts, and rewrite modules
neuro.rs was 1164 lines wearing three hats: - scoring.rs (401 lines): pure analysis — priority, replay queues, interference detection, consolidation planning - prompts.rs (396 lines): agent prompt generation and formatting - rewrite.rs (363 lines): graph topology mutations — hub differentiation, triangle closure, orphan linking The split follows safety profiles: scoring never mutates, prompts only reads, rewrite takes &mut Store. All public API re-exported from neuro/mod.rs so callers don't change.
This commit is contained in:
parent
4747004b36
commit
2f455ba29d
5 changed files with 1178 additions and 1163 deletions
1163
src/neuro.rs
1163
src/neuro.rs
File diff suppressed because it is too large
Load diff
29
src/neuro/mod.rs
Normal file
29
src/neuro/mod.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Neuroscience-inspired memory algorithms, split by concern:
|
||||||
|
//
|
||||||
|
// scoring — pure analysis: priority, replay queues, interference, plans
|
||||||
|
// prompts — agent prompt generation and formatting
|
||||||
|
// rewrite — graph topology mutations: differentiation, closure, linking
|
||||||
|
|
||||||
|
mod scoring;
|
||||||
|
mod prompts;
|
||||||
|
mod rewrite;
|
||||||
|
|
||||||
|
// Re-export public API so `neuro::` paths continue to work.
|
||||||
|
|
||||||
|
pub use scoring::{
|
||||||
|
replay_queue, detect_interference,
|
||||||
|
consolidation_plan, format_plan,
|
||||||
|
daily_check,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use prompts::{
|
||||||
|
load_prompt,
|
||||||
|
consolidation_batch, agent_prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use rewrite::{
|
||||||
|
refine_target, LinkMove,
|
||||||
|
differentiate_hub,
|
||||||
|
apply_differentiation, find_differentiable_hubs,
|
||||||
|
triangle_close, link_orphans,
|
||||||
|
};
|
||||||
396
src/neuro/prompts.rs
Normal file
396
src/neuro/prompts.rs
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
// Agent prompt generation and formatting. Presentation logic —
|
||||||
|
// builds text prompts from store data for consolidation agents.
|
||||||
|
|
||||||
|
use crate::store::Store;
|
||||||
|
use crate::graph::Graph;
|
||||||
|
use crate::similarity;
|
||||||
|
use crate::spectral;
|
||||||
|
|
||||||
|
use super::scoring::{
|
||||||
|
ReplayItem, consolidation_priority,
|
||||||
|
replay_queue, replay_queue_with_graph, detect_interference,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Prompt template directory
|
||||||
|
pub fn prompts_dir() -> std::path::PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
std::path::PathBuf::from(home).join("poc/memory/prompts")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a prompt template, replacing {{PLACEHOLDER}} with data
|
||||||
|
pub fn load_prompt(name: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
|
||||||
|
let path = prompts_dir().join(format!("{}.md", name));
|
||||||
|
let mut content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("load prompt {}: {}", path.display(), e))?;
|
||||||
|
for (placeholder, data) in replacements {
|
||||||
|
content = content.replace(placeholder, data);
|
||||||
|
}
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format topology header for agent prompts — current graph health metrics
|
||||||
|
fn format_topology_header(graph: &Graph) -> String {
|
||||||
|
let sigma = graph.small_world_sigma();
|
||||||
|
let alpha = graph.degree_power_law_exponent();
|
||||||
|
let gini = graph.degree_gini();
|
||||||
|
let avg_cc = graph.avg_clustering_coefficient();
|
||||||
|
let n = graph.nodes().len();
|
||||||
|
let e = graph.edge_count();
|
||||||
|
|
||||||
|
// Identify saturated hubs — nodes with degree well above threshold
|
||||||
|
let threshold = graph.hub_threshold();
|
||||||
|
let mut hubs: Vec<_> = graph.nodes().iter()
|
||||||
|
.map(|k| (k.clone(), graph.degree(k)))
|
||||||
|
.filter(|(_, d)| *d >= threshold)
|
||||||
|
.collect();
|
||||||
|
hubs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
hubs.truncate(15);
|
||||||
|
|
||||||
|
let hub_list = if hubs.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let lines: Vec<String> = hubs.iter()
|
||||||
|
.map(|(k, d)| format!(" - {} (degree {})", k, d))
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"### SATURATED HUBS — DO NOT LINK TO THESE\n\
|
||||||
|
The following nodes are already over-connected. Adding more links\n\
|
||||||
|
to them makes the graph worse (star topology). Find lateral\n\
|
||||||
|
connections between peripheral nodes instead.\n\n{}\n\n\
|
||||||
|
Only link to a hub if it is genuinely the ONLY reasonable target.\n\n",
|
||||||
|
lines.join("\n"))
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"## Current graph topology\n\
|
||||||
|
Nodes: {} Edges: {} Communities: {}\n\
|
||||||
|
Small-world σ: {:.1} Power-law α: {:.2} Degree Gini: {:.3}\n\
|
||||||
|
Avg clustering coefficient: {:.4}\n\n\
|
||||||
|
{}\
|
||||||
|
Each node below shows its hub-link ratio (fraction of edges to top-5% degree nodes).\n\
|
||||||
|
Use `poc-memory link-impact SOURCE TARGET` to evaluate proposed links.\n\n",
|
||||||
|
n, e, graph.community_count(), sigma, alpha, gini, avg_cc, hub_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format node data section for prompt templates
|
||||||
|
fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> String {
|
||||||
|
let hub_thresh = graph.hub_threshold();
|
||||||
|
let mut out = String::new();
|
||||||
|
for item in items {
|
||||||
|
let node = match store.nodes.get(&item.key) {
|
||||||
|
Some(n) => n,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push_str(&format!("## {} \n", item.key));
|
||||||
|
out.push_str(&format!("Priority: {:.3} CC: {:.3} Emotion: {:.1} ",
|
||||||
|
item.priority, item.cc, item.emotion));
|
||||||
|
out.push_str(&format!("Category: {} Interval: {}d\n",
|
||||||
|
node.category.label(), node.spaced_repetition_interval));
|
||||||
|
if item.outlier_score > 0.0 {
|
||||||
|
out.push_str(&format!("Spectral: {} (outlier={:.1})\n",
|
||||||
|
item.classification, item.outlier_score));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(community) = node.community_id {
|
||||||
|
out.push_str(&format!("Community: {} ", community));
|
||||||
|
}
|
||||||
|
let deg = graph.degree(&item.key);
|
||||||
|
let cc = graph.clustering_coefficient(&item.key);
|
||||||
|
|
||||||
|
// Hub-link ratio: what fraction of this node's edges go to hubs?
|
||||||
|
let neighbors = graph.neighbors(&item.key);
|
||||||
|
let hub_links = neighbors.iter()
|
||||||
|
.filter(|(n, _)| graph.degree(n) >= hub_thresh)
|
||||||
|
.count();
|
||||||
|
let hub_ratio = if deg > 0 { hub_links as f32 / deg as f32 } else { 0.0 };
|
||||||
|
let is_hub = deg >= hub_thresh;
|
||||||
|
|
||||||
|
out.push_str(&format!("Degree: {} CC: {:.3} Hub-link ratio: {:.0}% ({}/{})",
|
||||||
|
deg, cc, hub_ratio * 100.0, hub_links, deg));
|
||||||
|
if is_hub {
|
||||||
|
out.push_str(" ← THIS IS A HUB");
|
||||||
|
} else if hub_ratio > 0.6 {
|
||||||
|
out.push_str(" ← mostly hub-connected, needs lateral links");
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
// Content (truncated for large nodes)
|
||||||
|
let content = &node.content;
|
||||||
|
if content.len() > 1500 {
|
||||||
|
let end = content.floor_char_boundary(1500);
|
||||||
|
out.push_str(&format!("\nContent ({} chars, truncated):\n{}\n[...]\n\n",
|
||||||
|
content.len(), &content[..end]));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("\nContent:\n{}\n\n", content));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbors
|
||||||
|
let neighbors = graph.neighbors(&item.key);
|
||||||
|
if !neighbors.is_empty() {
|
||||||
|
out.push_str("Neighbors:\n");
|
||||||
|
for (n, strength) in neighbors.iter().take(15) {
|
||||||
|
let n_cc = graph.clustering_coefficient(n);
|
||||||
|
let n_community = store.nodes.get(n.as_str())
|
||||||
|
.and_then(|n| n.community_id);
|
||||||
|
out.push_str(&format!(" - {} (str={:.2}, cc={:.3}",
|
||||||
|
n, strength, n_cc));
|
||||||
|
if let Some(c) = n_community {
|
||||||
|
out.push_str(&format!(", c{}", c));
|
||||||
|
}
|
||||||
|
out.push_str(")\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggested link targets: text-similar semantic nodes not already neighbors
|
||||||
|
let neighbor_keys: std::collections::HashSet<&str> = neighbors.iter()
|
||||||
|
.map(|(k, _)| k.as_str()).collect();
|
||||||
|
let mut candidates: Vec<(&str, f32)> = store.nodes.iter()
|
||||||
|
.filter(|(k, _)| {
|
||||||
|
// Only semantic/topic file nodes, not episodic
|
||||||
|
!k.starts_with("journal.") && !k.starts_with("deep-index.")
|
||||||
|
&& !k.starts_with("daily-") && !k.starts_with("weekly-")
|
||||||
|
&& !k.starts_with("monthly-") && !k.starts_with("session-")
|
||||||
|
&& *k != &item.key
|
||||||
|
&& !neighbor_keys.contains(k.as_str())
|
||||||
|
})
|
||||||
|
.map(|(k, n)| {
|
||||||
|
let sim = similarity::cosine_similarity(content, &n.content);
|
||||||
|
(k.as_str(), sim)
|
||||||
|
})
|
||||||
|
.filter(|(_, sim)| *sim > 0.1)
|
||||||
|
.collect();
|
||||||
|
candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
candidates.truncate(8);
|
||||||
|
|
||||||
|
if !candidates.is_empty() {
|
||||||
|
out.push_str("\nSuggested link targets (by text similarity, not yet linked):\n");
|
||||||
|
for (k, sim) in &candidates {
|
||||||
|
let is_hub = graph.degree(k) >= hub_thresh;
|
||||||
|
out.push_str(&format!(" - {} (sim={:.3}{})\n",
|
||||||
|
k, sim, if is_hub { ", HUB" } else { "" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("\n---\n\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format health data for the health agent prompt
|
||||||
|
fn format_health_section(store: &Store, graph: &Graph) -> String {
|
||||||
|
use crate::graph;
|
||||||
|
|
||||||
|
let health = graph::health_report(graph, store);
|
||||||
|
|
||||||
|
let mut out = health;
|
||||||
|
out.push_str("\n\n## Weight distribution\n");
|
||||||
|
|
||||||
|
// Weight histogram
|
||||||
|
let mut buckets = [0u32; 10]; // 0.0-0.1, 0.1-0.2, ..., 0.9-1.0
|
||||||
|
for node in store.nodes.values() {
|
||||||
|
let bucket = ((node.weight * 10.0) as usize).min(9);
|
||||||
|
buckets[bucket] += 1;
|
||||||
|
}
|
||||||
|
for (i, &count) in buckets.iter().enumerate() {
|
||||||
|
let lo = i as f32 / 10.0;
|
||||||
|
let hi = (i + 1) as f32 / 10.0;
|
||||||
|
let bar = "█".repeat((count as usize) / 10);
|
||||||
|
out.push_str(&format!(" {:.1}-{:.1}: {:4} {}\n", lo, hi, count, bar));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near-prune nodes
|
||||||
|
let near_prune: Vec<_> = store.nodes.iter()
|
||||||
|
.filter(|(_, n)| n.weight < 0.15)
|
||||||
|
.map(|(k, n)| (k.clone(), n.weight))
|
||||||
|
.collect();
|
||||||
|
if !near_prune.is_empty() {
|
||||||
|
out.push_str(&format!("\n## Near-prune nodes ({} total)\n", near_prune.len()));
|
||||||
|
for (k, w) in near_prune.iter().take(20) {
|
||||||
|
out.push_str(&format!(" [{:.3}] {}\n", w, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community sizes
|
||||||
|
let communities = graph.communities();
|
||||||
|
let mut comm_sizes: std::collections::HashMap<u32, Vec<String>> = std::collections::HashMap::new();
|
||||||
|
for (key, &label) in communities {
|
||||||
|
comm_sizes.entry(label).or_default().push(key.clone());
|
||||||
|
}
|
||||||
|
let mut sizes: Vec<_> = comm_sizes.iter()
|
||||||
|
.map(|(id, members)| (*id, members.len(), members.clone()))
|
||||||
|
.collect();
|
||||||
|
sizes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
out.push_str("\n## Largest communities\n");
|
||||||
|
for (id, size, members) in sizes.iter().take(10) {
|
||||||
|
out.push_str(&format!(" Community {} ({} nodes): ", id, size));
|
||||||
|
let sample: Vec<_> = members.iter().take(5).map(|s| s.as_str()).collect();
|
||||||
|
out.push_str(&sample.join(", "));
|
||||||
|
if *size > 5 { out.push_str(", ..."); }
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format interference pairs for the separator agent prompt
|
||||||
|
fn format_pairs_section(
|
||||||
|
pairs: &[(String, String, f32)],
|
||||||
|
store: &Store,
|
||||||
|
graph: &Graph,
|
||||||
|
) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let communities = graph.communities();
|
||||||
|
|
||||||
|
for (a, b, sim) in pairs {
|
||||||
|
out.push_str(&format!("## Pair: similarity={:.3}\n", sim));
|
||||||
|
|
||||||
|
let ca = communities.get(a).map(|c| format!("c{}", c)).unwrap_or_else(|| "?".into());
|
||||||
|
let cb = communities.get(b).map(|c| format!("c{}", c)).unwrap_or_else(|| "?".into());
|
||||||
|
|
||||||
|
// Node A
|
||||||
|
out.push_str(&format!("\n### {} ({})\n", a, ca));
|
||||||
|
if let Some(node) = store.nodes.get(a) {
|
||||||
|
let content = if node.content.len() > 500 {
|
||||||
|
let end = node.content.floor_char_boundary(500);
|
||||||
|
format!("{}...", &node.content[..end])
|
||||||
|
} else {
|
||||||
|
node.content.clone()
|
||||||
|
};
|
||||||
|
out.push_str(&format!("Category: {} Weight: {:.2}\n{}\n",
|
||||||
|
node.category.label(), node.weight, content));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node B
|
||||||
|
out.push_str(&format!("\n### {} ({})\n", b, cb));
|
||||||
|
if let Some(node) = store.nodes.get(b) {
|
||||||
|
let content = if node.content.len() > 500 {
|
||||||
|
let end = node.content.floor_char_boundary(500);
|
||||||
|
format!("{}...", &node.content[..end])
|
||||||
|
} else {
|
||||||
|
node.content.clone()
|
||||||
|
};
|
||||||
|
out.push_str(&format!("Category: {} Weight: {:.2}\n{}\n",
|
||||||
|
node.category.label(), node.weight, content));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("\n---\n\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run agent consolidation on top-priority nodes
|
||||||
|
pub fn consolidation_batch(store: &Store, count: usize, auto: bool) -> Result<(), String> {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
let items = replay_queue(store, count);
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("No nodes to consolidate.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes_section = format_nodes_section(store, &items, &graph);
|
||||||
|
|
||||||
|
if auto {
|
||||||
|
let prompt = load_prompt("replay", &[("{{NODES}}", &nodes_section)])?;
|
||||||
|
println!("{}", prompt);
|
||||||
|
} else {
|
||||||
|
// Interactive: show what needs attention and available agent types
|
||||||
|
println!("Consolidation batch ({} nodes):\n", items.len());
|
||||||
|
for item in &items {
|
||||||
|
let node_type = store.nodes.get(&item.key)
|
||||||
|
.map(|n| if n.key.contains("journal") { "episodic" } else { "semantic" })
|
||||||
|
.unwrap_or("?");
|
||||||
|
println!(" [{:.3}] {} (cc={:.3}, interval={}d, type={})",
|
||||||
|
item.priority, item.key, item.cc, item.interval_days, node_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also show interference pairs
|
||||||
|
let pairs = detect_interference(store, &graph, 0.6);
|
||||||
|
if !pairs.is_empty() {
|
||||||
|
println!("\nInterfering pairs ({}):", pairs.len());
|
||||||
|
for (a, b, sim) in pairs.iter().take(5) {
|
||||||
|
println!(" [{:.3}] {} ↔ {}", sim, a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nAgent prompts:");
|
||||||
|
println!(" --auto Generate replay agent prompt");
|
||||||
|
println!(" --agent replay Replay agent (schema assimilation)");
|
||||||
|
println!(" --agent linker Linker agent (relational binding)");
|
||||||
|
println!(" --agent separator Separator agent (pattern separation)");
|
||||||
|
println!(" --agent transfer Transfer agent (CLS episodic→semantic)");
|
||||||
|
println!(" --agent health Health agent (synaptic homeostasis)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a specific agent prompt with filled-in data
|
||||||
|
pub fn agent_prompt(store: &Store, agent: &str, count: usize) -> Result<String, String> {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
let topology = format_topology_header(&graph);
|
||||||
|
|
||||||
|
let emb = spectral::load_embedding().ok();
|
||||||
|
|
||||||
|
match agent {
|
||||||
|
"replay" => {
|
||||||
|
let items = replay_queue_with_graph(store, count, &graph, emb.as_ref());
|
||||||
|
let nodes_section = format_nodes_section(store, &items, &graph);
|
||||||
|
load_prompt("replay", &[("{{TOPOLOGY}}", &topology), ("{{NODES}}", &nodes_section)])
|
||||||
|
}
|
||||||
|
"linker" => {
|
||||||
|
// Filter to episodic entries
|
||||||
|
let mut items = replay_queue_with_graph(store, count * 2, &graph, emb.as_ref());
|
||||||
|
items.retain(|item| {
|
||||||
|
store.nodes.get(&item.key)
|
||||||
|
.map(|n| matches!(n.node_type, crate::store::NodeType::EpisodicSession))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| item.key.contains("journal")
|
||||||
|
|| item.key.contains("session")
|
||||||
|
});
|
||||||
|
items.truncate(count);
|
||||||
|
let nodes_section = format_nodes_section(store, &items, &graph);
|
||||||
|
load_prompt("linker", &[("{{TOPOLOGY}}", &topology), ("{{NODES}}", &nodes_section)])
|
||||||
|
}
|
||||||
|
"separator" => {
|
||||||
|
let mut pairs = detect_interference(store, &graph, 0.5);
|
||||||
|
pairs.truncate(count);
|
||||||
|
let pairs_section = format_pairs_section(&pairs, store, &graph);
|
||||||
|
load_prompt("separator", &[("{{TOPOLOGY}}", &topology), ("{{PAIRS}}", &pairs_section)])
|
||||||
|
}
|
||||||
|
"transfer" => {
|
||||||
|
// Recent episodic entries
|
||||||
|
let mut episodes: Vec<_> = store.nodes.iter()
|
||||||
|
.filter(|(k, _)| k.contains("journal") || k.contains("session"))
|
||||||
|
.map(|(k, n)| (k.clone(), n.timestamp))
|
||||||
|
.collect();
|
||||||
|
episodes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
episodes.truncate(count);
|
||||||
|
|
||||||
|
let episode_keys: Vec<_> = episodes.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
let items: Vec<ReplayItem> = episode_keys.iter()
|
||||||
|
.filter_map(|k| {
|
||||||
|
let node = store.nodes.get(k)?;
|
||||||
|
Some(ReplayItem {
|
||||||
|
key: k.clone(),
|
||||||
|
priority: consolidation_priority(store, k, &graph, None),
|
||||||
|
interval_days: node.spaced_repetition_interval,
|
||||||
|
emotion: node.emotion,
|
||||||
|
cc: graph.clustering_coefficient(k),
|
||||||
|
classification: "unknown",
|
||||||
|
outlier_score: 0.0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let episodes_section = format_nodes_section(store, &items, &graph);
|
||||||
|
load_prompt("transfer", &[("{{TOPOLOGY}}", &topology), ("{{EPISODES}}", &episodes_section)])
|
||||||
|
}
|
||||||
|
"health" => {
|
||||||
|
let health_section = format_health_section(store, &graph);
|
||||||
|
load_prompt("health", &[("{{TOPOLOGY}}", &topology), ("{{HEALTH}}", &health_section)])
|
||||||
|
}
|
||||||
|
_ => Err(format!("Unknown agent: {}. Use: replay, linker, separator, transfer, health", agent)),
|
||||||
|
}
|
||||||
|
}
|
||||||
363
src/neuro/rewrite.rs
Normal file
363
src/neuro/rewrite.rs
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
// Graph topology mutations: hub differentiation, triangle closure,
|
||||||
|
// orphan linking, and link refinement. These modify the store.
|
||||||
|
|
||||||
|
use crate::store::{Store, new_relation};
|
||||||
|
use crate::graph::Graph;
|
||||||
|
use crate::similarity;
|
||||||
|
|
||||||
|
/// Refine a link target: if the target is a file-level node with section
|
||||||
|
/// children, find the best-matching section by cosine similarity against
|
||||||
|
/// the source content. Returns the original key if no sections exist or
|
||||||
|
/// no section matches above threshold.
|
||||||
|
///
|
||||||
|
/// This prevents hub formation at link creation time — every new link
|
||||||
|
/// targets the most specific available node.
|
||||||
|
pub fn refine_target(store: &Store, source_content: &str, target_key: &str) -> String {
|
||||||
|
// Only refine file-level nodes (no # in key)
|
||||||
|
if target_key.contains('#') { return target_key.to_string(); }
|
||||||
|
|
||||||
|
let prefix = format!("{}#", target_key);
|
||||||
|
let sections: Vec<(&str, &str)> = store.nodes.iter()
|
||||||
|
.filter(|(k, _)| k.starts_with(&prefix))
|
||||||
|
.map(|(k, n)| (k.as_str(), n.content.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if sections.is_empty() { return target_key.to_string(); }
|
||||||
|
|
||||||
|
let mut best_section = "";
|
||||||
|
let mut best_sim = 0.0f32;
|
||||||
|
|
||||||
|
for (section_key, section_content) in §ions {
|
||||||
|
let sim = similarity::cosine_similarity(source_content, section_content);
|
||||||
|
if sim > best_sim {
|
||||||
|
best_sim = sim;
|
||||||
|
best_section = section_key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold: only refine if there's a meaningful match
|
||||||
|
if best_sim > 0.05 && !best_section.is_empty() {
|
||||||
|
best_section.to_string()
|
||||||
|
} else {
|
||||||
|
target_key.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposed link move: from hub→neighbor to section→neighbor
|
||||||
|
pub struct LinkMove {
|
||||||
|
pub neighbor_key: String,
|
||||||
|
pub from_hub: String,
|
||||||
|
pub to_section: String,
|
||||||
|
pub similarity: f32,
|
||||||
|
pub neighbor_snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyze a hub node and propose redistributing its links to child sections.
|
||||||
|
///
|
||||||
|
/// Returns None if the node isn't a hub or has no sections to redistribute to.
|
||||||
|
pub fn differentiate_hub(store: &Store, hub_key: &str) -> Option<Vec<LinkMove>> {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
differentiate_hub_with_graph(store, hub_key, &graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like differentiate_hub but uses a pre-built graph.
|
||||||
|
pub fn differentiate_hub_with_graph(store: &Store, hub_key: &str, graph: &Graph) -> Option<Vec<LinkMove>> {
|
||||||
|
let degree = graph.degree(hub_key);
|
||||||
|
|
||||||
|
// Only differentiate actual hubs
|
||||||
|
if degree < 20 { return None; }
|
||||||
|
|
||||||
|
// Only works on file-level nodes that have section children
|
||||||
|
if hub_key.contains('#') { return None; }
|
||||||
|
|
||||||
|
let prefix = format!("{}#", hub_key);
|
||||||
|
let sections: Vec<(&str, &str)> = store.nodes.iter()
|
||||||
|
.filter(|(k, _)| k.starts_with(&prefix))
|
||||||
|
.map(|(k, n)| (k.as_str(), n.content.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if sections.is_empty() { return None; }
|
||||||
|
|
||||||
|
// Get all neighbors of the hub
|
||||||
|
let neighbors = graph.neighbors(hub_key);
|
||||||
|
|
||||||
|
let mut moves = Vec::new();
|
||||||
|
|
||||||
|
for (neighbor_key, _strength) in &neighbors {
|
||||||
|
// Skip section children — they should stay linked to parent
|
||||||
|
if neighbor_key.starts_with(&prefix) { continue; }
|
||||||
|
|
||||||
|
let neighbor_content = match store.nodes.get(neighbor_key.as_str()) {
|
||||||
|
Some(n) => &n.content,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find best-matching section by content similarity
|
||||||
|
let mut best_section = "";
|
||||||
|
let mut best_sim = 0.0f32;
|
||||||
|
|
||||||
|
for (section_key, section_content) in §ions {
|
||||||
|
let sim = similarity::cosine_similarity(neighbor_content, section_content);
|
||||||
|
if sim > best_sim {
|
||||||
|
best_sim = sim;
|
||||||
|
best_section = section_key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only propose move if there's a reasonable match
|
||||||
|
if best_sim > 0.05 && !best_section.is_empty() {
|
||||||
|
let snippet = neighbor_content.lines()
|
||||||
|
.find(|l| !l.is_empty() && !l.starts_with("<!--") && !l.starts_with("##"))
|
||||||
|
.unwrap_or("")
|
||||||
|
.chars().take(80).collect::<String>();
|
||||||
|
|
||||||
|
moves.push(LinkMove {
|
||||||
|
neighbor_key: neighbor_key.to_string(),
|
||||||
|
from_hub: hub_key.to_string(),
|
||||||
|
to_section: best_section.to_string(),
|
||||||
|
similarity: best_sim,
|
||||||
|
neighbor_snippet: snippet,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moves.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
|
||||||
|
Some(moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply link moves: soft-delete hub→neighbor, create section→neighbor.
|
||||||
|
pub fn apply_differentiation(
|
||||||
|
store: &mut Store,
|
||||||
|
moves: &[LinkMove],
|
||||||
|
) -> (usize, usize) {
|
||||||
|
let mut applied = 0usize;
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
|
for mv in moves {
|
||||||
|
// Check that section→neighbor doesn't already exist
|
||||||
|
let exists = store.relations.iter().any(|r|
|
||||||
|
((r.source_key == mv.to_section && r.target_key == mv.neighbor_key)
|
||||||
|
|| (r.source_key == mv.neighbor_key && r.target_key == mv.to_section))
|
||||||
|
&& !r.deleted
|
||||||
|
);
|
||||||
|
if exists { skipped += 1; continue; }
|
||||||
|
|
||||||
|
let section_uuid = match store.nodes.get(&mv.to_section) {
|
||||||
|
Some(n) => n.uuid,
|
||||||
|
None => { skipped += 1; continue; }
|
||||||
|
};
|
||||||
|
let neighbor_uuid = match store.nodes.get(&mv.neighbor_key) {
|
||||||
|
Some(n) => n.uuid,
|
||||||
|
None => { skipped += 1; continue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Soft-delete old hub→neighbor relation
|
||||||
|
for rel in &mut store.relations {
|
||||||
|
if ((rel.source_key == mv.from_hub && rel.target_key == mv.neighbor_key)
|
||||||
|
|| (rel.source_key == mv.neighbor_key && rel.target_key == mv.from_hub))
|
||||||
|
&& !rel.deleted
|
||||||
|
{
|
||||||
|
rel.deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new section→neighbor relation
|
||||||
|
let new_rel = new_relation(
|
||||||
|
section_uuid, neighbor_uuid,
|
||||||
|
crate::store::RelationType::Auto,
|
||||||
|
0.5,
|
||||||
|
&mv.to_section, &mv.neighbor_key,
|
||||||
|
);
|
||||||
|
if store.add_relation(new_rel).is_ok() {
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(applied, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all file-level hubs that have section children to split into.
|
||||||
|
pub fn find_differentiable_hubs(store: &Store) -> Vec<(String, usize, usize)> {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
let threshold = graph.hub_threshold();
|
||||||
|
|
||||||
|
let mut hubs = Vec::new();
|
||||||
|
for key in graph.nodes() {
|
||||||
|
let deg = graph.degree(key);
|
||||||
|
if deg < threshold { continue; }
|
||||||
|
if key.contains('#') { continue; }
|
||||||
|
|
||||||
|
let prefix = format!("{}#", key);
|
||||||
|
let section_count = store.nodes.keys()
|
||||||
|
.filter(|k| k.starts_with(&prefix))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if section_count > 0 {
|
||||||
|
hubs.push((key.clone(), deg, section_count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hubs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
hubs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triangle closure: for each node with degree >= min_degree, find pairs
|
||||||
|
/// of its neighbors that aren't directly connected and have cosine
|
||||||
|
/// similarity above sim_threshold. Add links between them.
|
||||||
|
///
|
||||||
|
/// This turns hub-spoke patterns into triangles, directly improving
|
||||||
|
/// clustering coefficient and schema fit.
|
||||||
|
pub fn triangle_close(
|
||||||
|
store: &mut Store,
|
||||||
|
min_degree: usize,
|
||||||
|
sim_threshold: f32,
|
||||||
|
max_links_per_hub: usize,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
let mut added = 0usize;
|
||||||
|
let mut hubs_processed = 0usize;
|
||||||
|
|
||||||
|
// Get nodes sorted by degree (highest first)
|
||||||
|
let mut candidates: Vec<(String, usize)> = graph.nodes().iter()
|
||||||
|
.map(|k| (k.clone(), graph.degree(k)))
|
||||||
|
.filter(|(_, d)| *d >= min_degree)
|
||||||
|
.collect();
|
||||||
|
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
for (hub_key, hub_deg) in &candidates {
|
||||||
|
let neighbors = graph.neighbor_keys(hub_key);
|
||||||
|
if neighbors.len() < 2 { continue; }
|
||||||
|
|
||||||
|
// Collect neighbor content for similarity
|
||||||
|
let neighbor_docs: Vec<(String, String)> = neighbors.iter()
|
||||||
|
.filter_map(|&k| {
|
||||||
|
store.nodes.get(k).map(|n| (k.to_string(), n.content.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Find unconnected pairs with high similarity
|
||||||
|
let mut pair_scores: Vec<(String, String, f32)> = Vec::new();
|
||||||
|
for i in 0..neighbor_docs.len() {
|
||||||
|
for j in (i + 1)..neighbor_docs.len() {
|
||||||
|
// Check if already connected
|
||||||
|
let n_i = graph.neighbor_keys(&neighbor_docs[i].0);
|
||||||
|
if n_i.contains(neighbor_docs[j].0.as_str()) { continue; }
|
||||||
|
|
||||||
|
let sim = similarity::cosine_similarity(
|
||||||
|
&neighbor_docs[i].1, &neighbor_docs[j].1);
|
||||||
|
if sim >= sim_threshold {
|
||||||
|
pair_scores.push((
|
||||||
|
neighbor_docs[i].0.clone(),
|
||||||
|
neighbor_docs[j].0.clone(),
|
||||||
|
sim,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pair_scores.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||||
|
let to_add = pair_scores.len().min(max_links_per_hub);
|
||||||
|
|
||||||
|
if to_add > 0 {
|
||||||
|
println!(" {} (deg={}) — {} triangles to close (top {})",
|
||||||
|
hub_key, hub_deg, pair_scores.len(), to_add);
|
||||||
|
|
||||||
|
for (a, b, sim) in pair_scores.iter().take(to_add) {
|
||||||
|
let uuid_a = match store.nodes.get(a) { Some(n) => n.uuid, None => continue };
|
||||||
|
let uuid_b = match store.nodes.get(b) { Some(n) => n.uuid, None => continue };
|
||||||
|
|
||||||
|
let rel = new_relation(
|
||||||
|
uuid_a, uuid_b,
|
||||||
|
crate::store::RelationType::Auto,
|
||||||
|
sim * 0.5, // scale by similarity
|
||||||
|
a, b,
|
||||||
|
);
|
||||||
|
if let Ok(()) = store.add_relation(rel) {
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hubs_processed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if added > 0 {
|
||||||
|
let _ = store.save();
|
||||||
|
}
|
||||||
|
(hubs_processed, added)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link orphan nodes (degree < min_degree) to their most textually similar
|
||||||
|
/// connected nodes. For each orphan, finds top-K nearest neighbors by
|
||||||
|
/// cosine similarity and creates Auto links.
|
||||||
|
/// Returns (orphans_linked, total_links_added).
|
||||||
|
pub fn link_orphans(
|
||||||
|
store: &mut Store,
|
||||||
|
min_degree: usize,
|
||||||
|
links_per_orphan: usize,
|
||||||
|
sim_threshold: f32,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
let graph = store.build_graph();
|
||||||
|
let mut added = 0usize;
|
||||||
|
let mut orphans_linked = 0usize;
|
||||||
|
|
||||||
|
// Separate orphans from connected nodes
|
||||||
|
let orphans: Vec<String> = graph.nodes().iter()
|
||||||
|
.filter(|k| graph.degree(k) < min_degree)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build candidate pool: connected nodes with their content
|
||||||
|
let candidates: Vec<(String, String)> = graph.nodes().iter()
|
||||||
|
.filter(|k| graph.degree(k) >= min_degree)
|
||||||
|
.filter_map(|k| store.nodes.get(k).map(|n| (k.clone(), n.content.clone())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if candidates.is_empty() { return (0, 0); }
|
||||||
|
|
||||||
|
for orphan_key in &orphans {
|
||||||
|
let orphan_content = match store.nodes.get(orphan_key) {
|
||||||
|
Some(n) => n.content.clone(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if orphan_content.len() < 20 { continue; } // skip near-empty nodes
|
||||||
|
|
||||||
|
// Score against all candidates
|
||||||
|
let mut scores: Vec<(usize, f32)> = candidates.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (_, content))| {
|
||||||
|
(i, similarity::cosine_similarity(&orphan_content, content))
|
||||||
|
})
|
||||||
|
.filter(|(_, s)| *s >= sim_threshold)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
scores.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
let to_link = scores.len().min(links_per_orphan);
|
||||||
|
if to_link == 0 { continue; }
|
||||||
|
|
||||||
|
let orphan_uuid = store.nodes.get(orphan_key).unwrap().uuid;
|
||||||
|
|
||||||
|
for &(idx, sim) in scores.iter().take(to_link) {
|
||||||
|
let target_key = &candidates[idx].0;
|
||||||
|
let target_uuid = match store.nodes.get(target_key) {
|
||||||
|
Some(n) => n.uuid,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rel = new_relation(
|
||||||
|
orphan_uuid, target_uuid,
|
||||||
|
crate::store::RelationType::Auto,
|
||||||
|
sim * 0.5,
|
||||||
|
orphan_key, target_key,
|
||||||
|
);
|
||||||
|
if store.add_relation(rel).is_ok() {
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orphans_linked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if added > 0 {
|
||||||
|
let _ = store.save();
|
||||||
|
}
|
||||||
|
(orphans_linked, added)
|
||||||
|
}
|
||||||
390
src/neuro/scoring.rs
Normal file
390
src/neuro/scoring.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
// 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>,
|
||||||
|
) -> 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<ReplayItem> {
|
||||||
|
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<ReplayItem> {
|
||||||
|
// Build spectral position map if embedding is available
|
||||||
|
let positions: HashMap<String, SpectralPosition> = 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<ReplayItem> = 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue