training: per-node scoring with graph weight updates

Memory scoring now uses the graph as source of truth:
- last_scored timestamp on each node (new capnp field @22)
- Nodes scored when older than scoring_interval_secs (default 1hr)
- Oldest-scored-first ordering
- Window: scoring_response_window assistant responses (default 100)
- First-quarter memories scored even without full window
- Per-response normalization (raw divergence / response count)
- Asymmetric weight update: alpha=0.5 up, alpha=0.1 down
  (responds fast to importance, decays slowly — memories stay
  surfaced even if only useful 1/4 of the time)

Graph writes disabled pending normalization calibration.

Also: configurable scoring_interval_secs and scoring_response_window.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-04 05:01:49 -04:00 committed by Kent Overstreet
parent b0603fd1ef
commit fcd77fb79e
8 changed files with 109 additions and 64 deletions

View file

@ -42,6 +42,9 @@ struct ContentNode {
# Freeform provenance string: "extractor:write", "rename:tombstone", etc. # Freeform provenance string: "extractor:write", "rename:tombstone", etc.
provenance @21 :Text; provenance @21 :Text;
# Memory importance scoring
lastScored @22 :Int64; # unix epoch seconds, 0 = never scored
} }
enum NodeType { enum NodeType {

View file

@ -662,13 +662,19 @@ impl Agent {
_ => unreachable!(), _ => unreachable!(),
}; };
let text = entry.message().content_text(); let text = entry.message().content_text();
let score = memory_scores // Show node weight from graph (updated by incremental scorer)
let graph_weight = crate::hippocampus::store::Store::load().ok()
.and_then(|s| s.nodes.get(key).map(|n| n.weight));
// Show full matrix score if available
let matrix_score = memory_scores
.and_then(|s| s.memory_weights.iter() .and_then(|s| s.memory_weights.iter()
.find(|(k, _)| k == key) .find(|(k, _)| k == key)
.map(|(_, v)| *v)); .map(|(_, v)| *v));
let label = match score { let label = match (graph_weight, matrix_score) {
Some(v) => format!("{} (importance: {:.1})", key, v), (Some(w), Some(s)) => format!("{} (w:{:.2} score:{:.1})", key, w, s),
None => key.to_string(), (Some(w), None) => format!("{} (w:{:.2})", key, w),
(None, Some(s)) => format!("{} (score:{:.1})", key, s),
(None, None) => key.to_string(),
}; };
ContextSection { ContextSection {
name: label, name: label,

View file

@ -253,6 +253,19 @@ pub async fn score_memories(
}) })
} }
/// Find the entry index after `start` that contains the Nth assistant response.
/// Returns (end_index, true) if N responses were found, (entries.len(), false) if not.
fn nth_response_end(entries: &[ConversationEntry], start: usize, n: usize) -> (usize, bool) {
let mut count = 0;
for i in start..entries.len() {
if entries[i].message().role == Role::Assistant {
count += 1;
if count >= n { return (i + 1, true); }
}
}
(entries.len(), false)
}
// ── Single memory scoring ─────────────────────────────────────── // ── Single memory scoring ───────────────────────────────────────
/// Score how important a single memory is to the conversation. /// Score how important a single memory is to the conversation.
@ -266,7 +279,7 @@ pub async fn score_memory(
client: &ApiClient, client: &ApiClient,
ui_tx: &UiSender, ui_tx: &UiSender,
) -> anyhow::Result<f64> { ) -> anyhow::Result<f64> {
const WINDOW: usize = 50; const RESPONSE_WINDOW: usize = 50;
let first_pos = match context.entries.iter().position(|e| { let first_pos = match context.entries.iter().position(|e| {
matches!(e, ConversationEntry::Memory { key: k, .. } if k == key) matches!(e, ConversationEntry::Memory { key: k, .. } if k == key)
@ -275,7 +288,8 @@ pub async fn score_memory(
None => return Ok(0.0), None => return Ok(0.0),
}; };
let range = first_pos..(first_pos + WINDOW).min(context.entries.len()); let (end, _) = nth_response_end(&context.entries, first_pos, RESPONSE_WINDOW);
let range = first_pos..end;
if !context.entries[range.clone()].iter().any(|e| e.message().role == Role::Assistant) { if !context.entries[range.clone()].iter().any(|e| e.message().role == Role::Assistant) {
return Ok(0.0); return Ok(0.0);
} }
@ -290,63 +304,71 @@ pub async fn score_memory(
// ── Background memory scoring ─────────────────────────────────── // ── Background memory scoring ───────────────────────────────────
/// Incrementally score memories through the conversation. /// Score memories in the conversation that are due for re-scoring.
/// ///
/// Walks memory entries in conversation order starting from `cursor`. /// Checks the graph for each memory's last_scored timestamp. Scores
/// For each memory with a full WINDOW after it, calls score_memory() /// nodes that haven't been scored within `max_age_secs`, oldest first.
/// and yields the result. Stops at the first memory that doesn't have /// Updates the graph weight (EWMA) and last_scored after each.
/// enough messages yet — the conversation needs to grow before we can
/// score it.
/// ///
/// Returns the updated cursor (entry index to resume from next time) /// Returns the number of nodes scored and their (key, score) pairs.
/// and the scores for each memory that was scored this round.
pub async fn score_memories_incremental( pub async fn score_memories_incremental(
context: &ContextState, context: &ContextState,
cursor: usize, max_age_secs: i64,
response_window: usize,
client: &ApiClient, client: &ApiClient,
ui_tx: &UiSender, ui_tx: &UiSender,
) -> anyhow::Result<(usize, Vec<(String, f64)>)> { ) -> anyhow::Result<Vec<(String, f64)>> {
const WINDOW: usize = 50; let now = chrono::Utc::now().timestamp();
// Collect unique memory keys with their first position, starting from cursor // Collect unique memory keys with their first position
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut to_score: Vec<(usize, String)> = Vec::new(); let mut candidates: Vec<(usize, String, i64)> = Vec::new(); // (pos, key, last_scored)
for (i, entry) in context.entries.iter().enumerate().skip(cursor) { let store = crate::hippocampus::store::Store::load().unwrap_or_default();
for (i, entry) in context.entries.iter().enumerate() {
if let ConversationEntry::Memory { key, .. } = entry { if let ConversationEntry::Memory { key, .. } = entry {
if seen.insert(key.clone()) { if !seen.insert(key.clone()) { continue; }
to_score.push((i, key.clone())); let last_scored = store.nodes.get(key.as_str())
.map(|n| n.last_scored)
.unwrap_or(0);
if now - last_scored >= max_age_secs {
candidates.push((i, key.clone(), last_scored));
} }
} }
} }
// Score oldest-first
candidates.sort_by_key(|&(_, _, last)| last);
let http = http_client(); let http = http_client();
let mut new_cursor = cursor;
let mut results = Vec::new(); let mut results = Vec::new();
for (pos, key) in &to_score { let total_entries = context.entries.len();
let end = pos + WINDOW; let first_quarter = total_entries / 4;
// Not enough conversation after this memory yet — stop here for (pos, key, _) in &candidates {
if end > context.entries.len() { let (end, full_window) = nth_response_end(&context.entries, *pos, response_window);
break; // Skip memories without a full window, unless they're in the
// first quarter of the conversation (always score those).
if !full_window && *pos >= first_quarter {
continue;
} }
// Need at least one assistant response in the window
let range = *pos..end; let range = *pos..end;
if !context.entries[range.clone()].iter().any(|e| e.message().role == Role::Assistant) { if !context.entries[range.clone()].iter().any(|e| e.message().role == Role::Assistant) {
new_cursor = end;
continue; continue;
} }
let _ = ui_tx.send(UiMessage::Activity(format!("scoring memory: {}...", key))); let _ = ui_tx.send(UiMessage::Activity(format!("scoring memory: {}...", key)));
match score_divergence(&http, client, context, range, Filter::SkipKey(key)).await { match score_divergence(&http, client, context, range, Filter::SkipKey(key)).await {
Ok((divs, _)) => { Ok((divs, _)) => {
let importance: f64 = divs.iter().sum(); let n_responses = divs.len();
let max_div = divs.iter().cloned().fold(0.0f64, f64::max);
let _ = ui_tx.send(UiMessage::Debug(format!( let _ = ui_tx.send(UiMessage::Debug(format!(
"[scoring] {} → {:.2}", key, importance, "[scoring] {} max:{:.3} ({} responses)", key, max_div, n_responses,
))); )));
results.push((key.clone(), importance)); // TODO: update graph weight once normalization is figured out
results.push((key.clone(), max_div));
} }
Err(e) => { Err(e) => {
let _ = ui_tx.send(UiMessage::Debug(format!( let _ = ui_tx.send(UiMessage::Debug(format!(
@ -354,11 +376,10 @@ pub async fn score_memories_incremental(
))); )));
} }
} }
new_cursor = end;
} }
let _ = ui_tx.send(UiMessage::Activity(String::new())); let _ = ui_tx.send(UiMessage::Activity(String::new()));
Ok((new_cursor, results)) Ok(results)
} }
// ── Fine-tuning scoring ───────────────────────────────────────── // ── Fine-tuning scoring ─────────────────────────────────────────

View file

@ -56,6 +56,8 @@ fn default_true() -> bool { true }
fn default_context_window() -> usize { 128_000 } fn default_context_window() -> usize { 128_000 }
fn default_stream_timeout() -> u64 { 60 } fn default_stream_timeout() -> u64 { 60 }
fn default_scoring_chunk_tokens() -> usize { 50_000 } fn default_scoring_chunk_tokens() -> usize { 50_000 }
fn default_scoring_interval_secs() -> u64 { 3600 } // 1 hour
fn default_scoring_response_window() -> usize { 100 }
fn default_identity_dir() -> PathBuf { fn default_identity_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/identity") dirs::home_dir().unwrap_or_default().join(".consciousness/identity")
} }
@ -97,6 +99,12 @@ pub struct Config {
/// Max tokens per chunk for memory scoring logprobs calls. /// Max tokens per chunk for memory scoring logprobs calls.
#[serde(default = "default_scoring_chunk_tokens")] #[serde(default = "default_scoring_chunk_tokens")]
pub scoring_chunk_tokens: usize, pub scoring_chunk_tokens: usize,
/// How often to re-score memory nodes (seconds). Default: 3600 (1 hour).
#[serde(default = "default_scoring_interval_secs")]
pub scoring_interval_secs: u64,
/// Number of assistant responses to score per memory. Default: 50.
#[serde(default = "default_scoring_response_window")]
pub scoring_response_window: usize,
pub api_reasoning: String, pub api_reasoning: String,
pub agent_types: Vec<String>, pub agent_types: Vec<String>,
/// Surface agent timeout in seconds. /// Surface agent timeout in seconds.
@ -145,6 +153,8 @@ impl Default for Config {
api_context_window: default_context_window(), api_context_window: default_context_window(),
api_stream_timeout_secs: default_stream_timeout(), api_stream_timeout_secs: default_stream_timeout(),
scoring_chunk_tokens: default_scoring_chunk_tokens(), scoring_chunk_tokens: default_scoring_chunk_tokens(),
scoring_interval_secs: default_scoring_interval_secs(),
scoring_response_window: default_scoring_response_window(),
agent_model: None, agent_model: None,
api_reasoning: "high".to_string(), api_reasoning: "high".to_string(),
agent_types: vec![ agent_types: vec![

View file

@ -296,6 +296,22 @@ impl Store {
Ok((old, weight)) Ok((old, weight))
} }
/// Update a node's weight with a new score and record the scoring
/// timestamp. Uses asymmetric smoothing: responds quickly to high
/// scores (alpha=0.5) but decays slowly on low scores (alpha=0.1).
/// This keeps memories surfaced even if they're only useful 1 in 4 times.
/// Returns (old_weight, new_weight).
pub fn score_weight(&mut self, key: &str, score: f64) -> Result<(f32, f32), String> {
let node = self.nodes.get_mut(key)
.ok_or_else(|| format!("node not found: {}", key))?;
let old = node.weight;
let alpha = if score > old as f64 { 0.5 } else { 0.1 };
let new = (alpha * score + (1.0 - alpha) * old as f64) as f32;
node.weight = new.clamp(0.01, 1.0);
node.last_scored = chrono::Utc::now().timestamp();
Ok((old, node.weight))
}
/// Set the strength of a link between two nodes. Deduplicates if /// Set the strength of a link between two nodes. Deduplicates if
/// multiple links exist. Returns the old strength, or error if no link. /// multiple links exist. Returns the old strength, or error if no link.
pub fn set_link_strength(&mut self, source: &str, target: &str, strength: f32) -> Result<f32, String> { pub fn set_link_strength(&mut self, source: &str, target: &str, strength: f32) -> Result<f32, String> {

View file

@ -214,6 +214,10 @@ pub struct Node {
#[serde(default)] #[serde(default)]
pub created_at: i64, pub created_at: i64,
// Memory importance scoring — unix epoch seconds, 0 = never scored.
#[serde(default)]
pub last_scored: i64,
// Derived fields (not in capnp, computed from graph) // Derived fields (not in capnp, computed from graph)
#[serde(default)] #[serde(default)]
pub community_id: Option<u32>, pub community_id: Option<u32>,
@ -342,7 +346,7 @@ capnp_message!(Node,
uuid: [uuid], uuid: [uuid],
prim: [version, timestamp, weight, emotion, deleted, prim: [version, timestamp, weight, emotion, deleted,
retrievals, uses, wrongs, last_replayed, retrievals, uses, wrongs, last_replayed,
spaced_repetition_interval, position, created_at], spaced_repetition_interval, position, created_at, last_scored],
enm: [node_type: NodeType], enm: [node_type: NodeType],
skip: [community_id, clustering_coefficient, degree], skip: [community_id, clustering_coefficient, degree],
); );
@ -531,6 +535,7 @@ pub fn new_node(key: &str, content: &str) -> Node {
spaced_repetition_interval: 1, spaced_repetition_interval: 1,
position: 0, position: 0,
created_at: now_epoch(), created_at: now_epoch(),
last_scored: 0,
community_id: None, community_id: None,
clustering_coefficient: None, clustering_coefficient: None,
degree: None, degree: None,

View file

@ -219,41 +219,31 @@ impl Session {
fn start_memory_scoring(&self) { fn start_memory_scoring(&self) {
let agent = self.agent.clone(); let agent = self.agent.clone();
let ui_tx = self.ui_tx.clone(); let ui_tx = self.ui_tx.clone();
let cfg = crate::config::get();
let max_age = cfg.scoring_interval_secs;
let response_window = cfg.scoring_response_window;
tokio::spawn(async move { tokio::spawn(async move {
// Check + snapshot under one brief lock let (context, client) = {
let (context, client, cursor) = {
let mut agent = agent.lock().await; let mut agent = agent.lock().await;
if agent.agent_cycles.memory_scoring_in_flight { if agent.agent_cycles.memory_scoring_in_flight {
return; return;
} }
let cursor = agent.agent_cycles.memory_score_cursor;
agent.agent_cycles.memory_scoring_in_flight = true; agent.agent_cycles.memory_scoring_in_flight = true;
// Count total unique memories
let mut seen = std::collections::HashSet::new();
for entry in &agent.context.entries {
if let crate::agent::context::ConversationEntry::Memory { key, .. } = entry {
seen.insert(key.clone());
}
}
agent.agent_cycles.memory_total = seen.len();
let _ = ui_tx.send(UiMessage::AgentUpdate(agent.agent_cycles.snapshots())); let _ = ui_tx.send(UiMessage::AgentUpdate(agent.agent_cycles.snapshots()));
(agent.context.clone(), agent.client_clone(), cursor) (agent.context.clone(), agent.client_clone())
}; };
// Lock released — event loop is free
let result = crate::agent::training::score_memories_incremental( let result = crate::agent::training::score_memories_incremental(
&context, cursor, &client, &ui_tx, &context, max_age as i64, response_window, &client, &ui_tx,
).await; ).await;
// Brief lock — just update fields, no heavy work
{ {
let mut agent = agent.lock().await; let mut agent = agent.lock().await;
agent.agent_cycles.memory_scoring_in_flight = false; agent.agent_cycles.memory_scoring_in_flight = false;
if let Ok((new_cursor, ref scores)) = result { if let Ok(ref scores) = result {
agent.agent_cycles.memory_score_cursor = new_cursor; agent.agent_cycles.memory_scores = scores.clone();
agent.agent_cycles.memory_scores.extend(scores.clone());
} }
} }
// Snapshot and log outside the lock
match result { match result {
Ok(_) => { Ok(_) => {
let agent = agent.lock().await; let agent = agent.lock().await;

View file

@ -104,14 +104,10 @@ pub struct AgentCycleState {
log_file: Option<File>, log_file: Option<File>,
pub agents: Vec<AgentInfo>, pub agents: Vec<AgentInfo>,
pub last_output: AgentCycleOutput, pub last_output: AgentCycleOutput,
/// Incremental memory scoring — entry index to resume from.
pub memory_score_cursor: usize,
/// Whether incremental memory scoring is currently running. /// Whether incremental memory scoring is currently running.
pub memory_scoring_in_flight: bool, pub memory_scoring_in_flight: bool,
/// Latest per-memory scores from incremental scoring. /// Latest per-memory scores from incremental scoring.
pub memory_scores: Vec<(String, f64)>, pub memory_scores: Vec<(String, f64)>,
/// Total unique memories in the context (updated when scoring starts).
pub memory_total: usize,
} }
const AGENT_CYCLE_NAMES: &[&str] = &["surface-observe", "journal", "reflect"]; const AGENT_CYCLE_NAMES: &[&str] = &["surface-observe", "journal", "reflect"];
@ -138,10 +134,8 @@ impl AgentCycleState {
reflection: None, reflection: None,
sleep_secs: None, sleep_secs: None,
}, },
memory_score_cursor: 0,
memory_scoring_in_flight: false, memory_scoring_in_flight: false,
memory_scores: Vec::new(), memory_scores: Vec::new(),
memory_total: 0,
} }
} }
@ -192,11 +186,11 @@ impl AgentCycleState {
name: "memory-scoring".to_string(), name: "memory-scoring".to_string(),
pid: None, pid: None,
phase: if self.memory_scoring_in_flight { phase: if self.memory_scoring_in_flight {
Some(format!("scoring {}/{}", self.memory_scores.len(), self.memory_total)) Some("scoring...".into())
} else if self.memory_scores.is_empty() { } else if self.memory_scores.is_empty() {
None None
} else { } else {
Some(format!("{}/{} scored", self.memory_scores.len(), self.memory_total)) Some(format!("{} scored", self.memory_scores.len()))
}, },
log_path: None, log_path: None,
}); });