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:
parent
b0603fd1ef
commit
fcd77fb79e
8 changed files with 109 additions and 64 deletions
|
|
@ -662,13 +662,19 @@ impl Agent {
|
|||
_ => unreachable!(),
|
||||
};
|
||||
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()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| *v));
|
||||
let label = match score {
|
||||
Some(v) => format!("{} (importance: {:.1})", key, v),
|
||||
None => key.to_string(),
|
||||
let label = match (graph_weight, matrix_score) {
|
||||
(Some(w), Some(s)) => format!("{} (w:{:.2} score:{:.1})", key, w, s),
|
||||
(Some(w), None) => format!("{} (w:{:.2})", key, w),
|
||||
(None, Some(s)) => format!("{} (score:{:.1})", key, s),
|
||||
(None, None) => key.to_string(),
|
||||
};
|
||||
ContextSection {
|
||||
name: label,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────
|
||||
|
||||
/// Score how important a single memory is to the conversation.
|
||||
|
|
@ -266,7 +279,7 @@ pub async fn score_memory(
|
|||
client: &ApiClient,
|
||||
ui_tx: &UiSender,
|
||||
) -> anyhow::Result<f64> {
|
||||
const WINDOW: usize = 50;
|
||||
const RESPONSE_WINDOW: usize = 50;
|
||||
|
||||
let first_pos = match context.entries.iter().position(|e| {
|
||||
matches!(e, ConversationEntry::Memory { key: k, .. } if k == key)
|
||||
|
|
@ -275,7 +288,8 @@ pub async fn score_memory(
|
|||
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) {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
|
@ -290,63 +304,71 @@ pub async fn score_memory(
|
|||
|
||||
// ── 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`.
|
||||
/// For each memory with a full WINDOW after it, calls score_memory()
|
||||
/// and yields the result. Stops at the first memory that doesn't have
|
||||
/// enough messages yet — the conversation needs to grow before we can
|
||||
/// score it.
|
||||
/// Checks the graph for each memory's last_scored timestamp. Scores
|
||||
/// nodes that haven't been scored within `max_age_secs`, oldest first.
|
||||
/// Updates the graph weight (EWMA) and last_scored after each.
|
||||
///
|
||||
/// Returns the updated cursor (entry index to resume from next time)
|
||||
/// and the scores for each memory that was scored this round.
|
||||
/// Returns the number of nodes scored and their (key, score) pairs.
|
||||
pub async fn score_memories_incremental(
|
||||
context: &ContextState,
|
||||
cursor: usize,
|
||||
max_age_secs: i64,
|
||||
response_window: usize,
|
||||
client: &ApiClient,
|
||||
ui_tx: &UiSender,
|
||||
) -> anyhow::Result<(usize, Vec<(String, f64)>)> {
|
||||
const WINDOW: usize = 50;
|
||||
) -> anyhow::Result<Vec<(String, f64)>> {
|
||||
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 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 seen.insert(key.clone()) {
|
||||
to_score.push((i, key.clone()));
|
||||
if !seen.insert(key.clone()) { continue; }
|
||||
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 mut new_cursor = cursor;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (pos, key) in &to_score {
|
||||
let end = pos + WINDOW;
|
||||
let total_entries = context.entries.len();
|
||||
let first_quarter = total_entries / 4;
|
||||
|
||||
// Not enough conversation after this memory yet — stop here
|
||||
if end > context.entries.len() {
|
||||
break;
|
||||
for (pos, key, _) in &candidates {
|
||||
let (end, full_window) = nth_response_end(&context.entries, *pos, response_window);
|
||||
// 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;
|
||||
if !context.entries[range.clone()].iter().any(|e| e.message().role == Role::Assistant) {
|
||||
new_cursor = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = ui_tx.send(UiMessage::Activity(format!("scoring memory: {}...", key)));
|
||||
match score_divergence(&http, client, context, range, Filter::SkipKey(key)).await {
|
||||
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!(
|
||||
"[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) => {
|
||||
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()));
|
||||
Ok((new_cursor, results))
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ── Fine-tuning scoring ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ fn default_true() -> bool { true }
|
|||
fn default_context_window() -> usize { 128_000 }
|
||||
fn default_stream_timeout() -> u64 { 60 }
|
||||
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 {
|
||||
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.
|
||||
#[serde(default = "default_scoring_chunk_tokens")]
|
||||
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 agent_types: Vec<String>,
|
||||
/// Surface agent timeout in seconds.
|
||||
|
|
@ -145,6 +153,8 @@ impl Default for Config {
|
|||
api_context_window: default_context_window(),
|
||||
api_stream_timeout_secs: default_stream_timeout(),
|
||||
scoring_chunk_tokens: default_scoring_chunk_tokens(),
|
||||
scoring_interval_secs: default_scoring_interval_secs(),
|
||||
scoring_response_window: default_scoring_response_window(),
|
||||
agent_model: None,
|
||||
api_reasoning: "high".to_string(),
|
||||
agent_types: vec![
|
||||
|
|
|
|||
|
|
@ -296,6 +296,22 @@ impl Store {
|
|||
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
|
||||
/// 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> {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,10 @@ pub struct Node {
|
|||
#[serde(default)]
|
||||
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)
|
||||
#[serde(default)]
|
||||
pub community_id: Option<u32>,
|
||||
|
|
@ -342,7 +346,7 @@ capnp_message!(Node,
|
|||
uuid: [uuid],
|
||||
prim: [version, timestamp, weight, emotion, deleted,
|
||||
retrievals, uses, wrongs, last_replayed,
|
||||
spaced_repetition_interval, position, created_at],
|
||||
spaced_repetition_interval, position, created_at, last_scored],
|
||||
enm: [node_type: NodeType],
|
||||
skip: [community_id, clustering_coefficient, degree],
|
||||
);
|
||||
|
|
@ -531,6 +535,7 @@ pub fn new_node(key: &str, content: &str) -> Node {
|
|||
spaced_repetition_interval: 1,
|
||||
position: 0,
|
||||
created_at: now_epoch(),
|
||||
last_scored: 0,
|
||||
community_id: None,
|
||||
clustering_coefficient: None,
|
||||
degree: None,
|
||||
|
|
|
|||
|
|
@ -219,41 +219,31 @@ impl Session {
|
|||
fn start_memory_scoring(&self) {
|
||||
let agent = self.agent.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 {
|
||||
// Check + snapshot under one brief lock
|
||||
let (context, client, cursor) = {
|
||||
let (context, client) = {
|
||||
let mut agent = agent.lock().await;
|
||||
if agent.agent_cycles.memory_scoring_in_flight {
|
||||
return;
|
||||
}
|
||||
let cursor = agent.agent_cycles.memory_score_cursor;
|
||||
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()));
|
||||
(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(
|
||||
&context, cursor, &client, &ui_tx,
|
||||
&context, max_age as i64, response_window, &client, &ui_tx,
|
||||
).await;
|
||||
|
||||
// Brief lock — just update fields, no heavy work
|
||||
{
|
||||
let mut agent = agent.lock().await;
|
||||
agent.agent_cycles.memory_scoring_in_flight = false;
|
||||
if let Ok((new_cursor, ref scores)) = result {
|
||||
agent.agent_cycles.memory_score_cursor = new_cursor;
|
||||
agent.agent_cycles.memory_scores.extend(scores.clone());
|
||||
if let Ok(ref scores) = result {
|
||||
agent.agent_cycles.memory_scores = scores.clone();
|
||||
}
|
||||
}
|
||||
// Snapshot and log outside the lock
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let agent = agent.lock().await;
|
||||
|
|
|
|||
|
|
@ -104,14 +104,10 @@ pub struct AgentCycleState {
|
|||
log_file: Option<File>,
|
||||
pub agents: Vec<AgentInfo>,
|
||||
pub last_output: AgentCycleOutput,
|
||||
/// Incremental memory scoring — entry index to resume from.
|
||||
pub memory_score_cursor: usize,
|
||||
/// Whether incremental memory scoring is currently running.
|
||||
pub memory_scoring_in_flight: bool,
|
||||
/// Latest per-memory scores from incremental scoring.
|
||||
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"];
|
||||
|
|
@ -138,10 +134,8 @@ impl AgentCycleState {
|
|||
reflection: None,
|
||||
sleep_secs: None,
|
||||
},
|
||||
memory_score_cursor: 0,
|
||||
memory_scoring_in_flight: false,
|
||||
memory_scores: Vec::new(),
|
||||
memory_total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,11 +186,11 @@ impl AgentCycleState {
|
|||
name: "memory-scoring".to_string(),
|
||||
pid: None,
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{}/{} scored", self.memory_scores.len(), self.memory_total))
|
||||
Some(format!("{} scored", self.memory_scores.len()))
|
||||
},
|
||||
log_path: None,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue