Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/mcp-brain-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async-stream = "0.3"
urlencoding = "2"

# RuVector Cognitive Stack
ruvector-core = { path = "../ruvector-core", default-features = false }
sona = { package = "ruvector-sona", path = "../sona", features = ["serde-support"] }
ruvector-mincut = { path = "../ruvector-mincut", features = ["canonical"] }
ruvector-nervous-system = { path = "../ruvector-nervous-system" }
Expand Down
7 changes: 2 additions & 5 deletions crates/mcp-brain-server/src/bin/ruvbrain_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

use mcp_brain_server::routes;
use mcp_brain_server::types::AppState;
use mcp_brain_server::graph::KnowledgeGraph;
use mcp_brain_server::midstream;
use ruvector_domain_expansion::DomainId;
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -146,10 +145,8 @@ fn run_action(action: &str, state: &AppState) -> (bool, String) {
"rebuild_graph" => {
let all_mems = state.store.all_memories();
let mut graph = state.graph.write();
*graph = KnowledgeGraph::new();
for mem in &all_mems {
graph.add_memory(mem);
}
// ADR-149 P3: batch rebuild instead of one-at-a-time add_memory loop
graph.rebuild_from_batch(&all_mems);
graph.rebuild_sparsifier();
(
true,
Expand Down
132 changes: 126 additions & 6 deletions crates/mcp-brain-server/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub struct KnowledgeGraph {
struct GraphNode {
embedding: Vec<f32>,
category: BrainCategory,
/// Mean quality score at insertion time (ADR-149 P2).
/// Used to skip low-quality nodes when building edges.
quality: f64,
}

struct GraphEdge {
Expand All @@ -62,16 +65,133 @@ impl KnowledgeGraph {
}
}

/// Rebuild the entire graph from a batch of memories (ADR-149 P3).
///
/// Much faster than adding one at a time because:
/// 1. All nodes inserted first (no per-insert similarity scan)
/// 2. All-pairs similarity computed in a single pass (cache-friendly)
/// 3. Edges collected and stored in one allocation
///
/// On cold start with ~10K memories this avoids ~53M sequential similarity
/// checks done incrementally (the i-th add_memory scans i-1 nodes) and
/// instead performs them in a tight loop over contiguous embedding slices.
pub fn rebuild_from_batch(&mut self, memories: &[BrainMemory]) {
self.nodes.clear();
self.edges.clear();
self.node_ids.clear();
self.node_index.clear();
self.csr_dirty = true;
self.csr_cache = None;
self.mincut = None;
self.sparsifier = None;

let n = memories.len();
if n == 0 {
return;
}

// Pre-allocate
self.nodes.reserve(n);
self.node_ids.reserve(n);
self.node_index.reserve(n);
// Heuristic: ~20 edges per node on average
self.edges.reserve(n * 20);

// 1. Insert all nodes and collect quality scores
let mut qualities = Vec::with_capacity(n);
for (idx, m) in memories.iter().enumerate() {
let quality = m.quality_score.mean();
let node = GraphNode {
embedding: m.embedding.clone(),
category: m.category.clone(),
quality,
};
self.nodes.insert(m.id, node);
self.node_index.insert(m.id, idx);
self.node_ids.push(m.id);
qualities.push(quality);
}

// ADR-149 P2: quality floor for edge building (same as add_memory)
const EDGE_QUALITY_FLOOR: f64 = 0.01;

// 2. Collect embeddings as slices for cache-friendly access
// (avoids HashMap lookups in the hot loop)
let embeddings: Vec<&[f32]> = memories.iter().map(|m| m.embedding.as_slice()).collect();
let threshold = self.similarity_threshold;

// Determine dimension for early-exit heuristic
let dim = embeddings.first().map(|e| e.len()).unwrap_or(0);
// Use first quarter of dimensions for a quick rejection test.
// For normalised vectors, partial_dot / full_dot ~ prefix_len / dim.
// The factor 0.5 is conservative to avoid false negatives.
let prefix = dim / 4;
let early_exit_bound = threshold * 0.5;

// 3. Compute all edges in a single pass — O(n^2/2) pairs
for i in 0..n {
// Skip low-quality source nodes
if qualities[i] < EDGE_QUALITY_FLOOR {
continue;
}
let emb_i = embeddings[i];
for j in (i + 1)..n {
// Skip low-quality target nodes
if qualities[j] < EDGE_QUALITY_FLOOR {
continue;
}
let emb_j = embeddings[j];

// Early-exit: cheap partial dot product on first `prefix` dims
if prefix > 0 {
let quick_dot: f64 = emb_i[..prefix]
.iter()
.zip(&emb_j[..prefix])
.map(|(a, b)| (*a as f64) * (*b as f64))
.sum();
if quick_dot < early_exit_bound {
continue;
}
}

let sim = cosine_similarity(emb_i, emb_j);
if sim >= threshold {
self.edges.push(GraphEdge {
source: memories[i].id,
target: memories[j].id,
weight: sim,
});
}
}
}

tracing::info!(
nodes = self.nodes.len(),
edges = self.edges.len(),
"Graph rebuilt from batch (ADR-149 P3)"
);
}

/// Add a memory as a graph node, creating edges to similar nodes
pub fn add_memory(&mut self, memory: &BrainMemory) {
let quality = memory.quality_score.mean();
let new_node = GraphNode {
embedding: memory.embedding.clone(),
category: memory.category.clone(),
quality,
};

// ADR-149 P2: quality floor for edge building — skip low-quality nodes
// to reduce noisy edges and speed up graph operations.
const EDGE_QUALITY_FLOOR: f64 = 0.01;

// Compute edges to existing nodes
let mut new_edges = Vec::new();
for (existing_id, existing_node) in &self.nodes {
// Skip low-quality neighbors when building edges
if existing_node.quality < EDGE_QUALITY_FLOOR {
continue;
}
let sim = cosine_similarity(&new_node.embedding, &existing_node.embedding);
if sim >= self.similarity_threshold {
new_edges.push(GraphEdge {
Expand Down Expand Up @@ -740,11 +860,11 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| (*x as f64) * (*y as f64)).sum();
let norm_a: f64 = a.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
let norm_b: f64 = b.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
if norm_a < 1e-10 || norm_b < 1e-10 {
return 0.0;
let sim = ruvector_core::simd_intrinsics::cosine_similarity_simd(a, b);
// The SIMD path can return NaN/Inf for zero-norm vectors; clamp to 0.0.
if sim.is_finite() {
sim as f64
} else {
0.0
}
dot / (norm_a * norm_b)
}
18 changes: 12 additions & 6 deletions crates/mcp-brain-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Wait 30s before first cycle (let startup finish, data load)
tokio::time::sleep(std::time::Duration::from_secs(30)).await;

// Run an initial enhanced cycle on startup to bootstrap cognitive state
let result = routes::run_enhanced_training_cycle(&train_state);
// Run an initial enhanced cycle on startup to bootstrap cognitive state (full retrain)
let result = routes::run_enhanced_training_cycle(&train_state, true);
tracing::info!(
"Initial cognitive bootstrap: props={}, inferences={}, voice={}, curiosity={}, strange_loop={:.4}",
result.propositions_extracted, result.inferences_derived,
Expand Down Expand Up @@ -102,16 +102,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

// Run enhanced cycle if there's new data, or every 3rd full cycle regardless
// (keeps curiosity + self-reflection active even during quiet periods)
// ADR-149 P4: The incremental filter inside run_enhanced_training_cycle
// handles skipping unchanged memories automatically. Pass force_full=false
// to benefit from incremental processing; the function auto-forces a full
// retrain every 24h.
if new_memories > 0 || new_votes > 0 || tick_count % 15 == 0 {
let result = routes::run_enhanced_training_cycle(&train_state);
let result = routes::run_enhanced_training_cycle(&train_state, false);
tracing::info!(
"Cognitive cycle #{}: props={}, inferences={}, voice={}, auto_votes={}, \
curiosity={}, sona_patterns={}, strange_loop={:.4}, lora_auto={}",
"Cognitive cycle #{} ({}): props={}, inferences={}, voice={}, auto_votes={}, \
curiosity={}, sona_patterns={}, strange_loop={:.4}, lora_auto={}, processed={}/{}",
tick_count / 5,
if result.was_full_retrain { "full" } else { "incremental" },
result.propositions_extracted, result.inferences_derived,
result.voice_thoughts, result.auto_votes,
result.curiosity_triggered, result.sona_patterns,
result.strange_loop_score, result.lora_auto_submitted
result.strange_loop_score, result.lora_auto_submitted,
result.memories_processed, result.memory_count
);
last_memory_count = current_memories;
last_vote_count = current_votes;
Expand Down
Loading
Loading