diff --git a/poc-memory/.claude/analysis/2026-03-14-link-strength-feedback.md b/poc-memory/.claude/analysis/2026-03-14-link-strength-feedback.md new file mode 100644 index 0000000..142aaab --- /dev/null +++ b/poc-memory/.claude/analysis/2026-03-14-link-strength-feedback.md @@ -0,0 +1,98 @@ +# Link Strength Feedback Design +_2026-03-14, designed with Kent_ + +## The two signals + +### "Not relevant" → weaken the EDGE +The routing failed. Search followed a link and arrived at a node that +doesn't relate to what I was looking for. The edge carried activation +where it shouldn't have. + +- Trace back through memory-search's recorded activation path +- Identify which edge(s) carried activation to the bad result +- Weaken those edges by a conscious-scale delta (0.01) + +### "Not useful" → weaken the NODE +The routing was correct but the content is bad. The node itself isn't +valuable — stale, wrong, poorly written, duplicate. + +- Downweight the node (existing `poc-memory wrong` behavior) +- Don't touch the edges — the path was correct, the destination was bad + +## Three tiers of adjustment + +### Tier 1: Agent automatic (0.00001 per event) +- Agent follows edge A→B during a run +- If the run produces output that gets `used` → strengthen A→B +- If the run produces nothing useful → weaken A→B +- The agent doesn't know this is happening — daemon tracks it +- Clamped to [0.05, 0.95] — edges can never hit 0 or 1 +- Logged: every adjustment recorded with (agent, edge, delta, timestamp) + +### Tier 2: Conscious feedback (0.01 per event) +- `poc-memory not-relevant KEY` → trace activation path, weaken edges +- `poc-memory not-useful KEY` → downweight node +- `poc-memory used KEY` → strengthen edges in the path that got here +- 100x stronger than agent signal — deliberate judgment +- Still clamped, still logged + +### Tier 3: Manual override (direct set) +- `poc-memory graph link-strength SRC DST VALUE` → set directly +- For when we know exactly what a strength should be +- Rare, but needed for bootstrapping / correction + +## Implementation: recording the path + +memory-search already computes the spread activation trace. Need to: +1. Record the activation path for each result (which edges carried how + much activation to arrive at this node) +2. Persist this per-session so `not-relevant` can look it up +3. The `record-hits` RPC already sends keys to the daemon — extend + to include (key, activation_path) pairs + +## Implementation: agent tracking + +In the daemon's job functions: +1. Before LLM call: record which nodes and edges the agent received +2. After LLM call: parse output for LINK/WRITE_NODE actions +3. If actions are created and later get `used` → the input edges were useful +4. If no actions or actions never used → the input edges weren't useful +5. This is a delayed signal — requires tracking across time + +Simpler first pass: just track co-occurrence. If two nodes appear +together in a successful agent run, strengthen the edge between them. +No need to track which specific edge was "followed." + +## Clamping + +```rust +fn adjust_strength(current: f32, delta: f32) -> f32 { + (current + delta).clamp(0.05, 0.95) +} +``` + +Edges can asymptotically approach 0 or 1 but never reach them. +This prevents dead edges (can always be revived by strong signal) +and prevents edges from becoming unweakenable. + +## Logging + +Every adjustment logged as JSON event: +```json +{"ts": "...", "event": "strength_adjust", "source": "agent|conscious|manual", + "edge": ["nodeA", "nodeB"], "old": 0.45, "new": 0.4501, "delta": 0.0001, + "reason": "co-retrieval in linker run c-linker-42"} +``` + +This lets us: +- Watch the distribution shift over time +- Identify edges that are oscillating (being pulled both ways) +- Tune the delta values based on observed behavior +- Roll back if something goes wrong + +## Migration from current commands + +- `poc-memory wrong KEY [CTX]` → splits into `not-relevant` and `not-useful` +- `poc-memory used KEY` → additionally strengthens edges in activation path +- Both old commands continue to work for backward compat, mapped to the + most likely intent (wrong → not-useful, used → strengthen path) diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index bbe2384..b17c64b 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -175,6 +175,18 @@ EXAMPLES: /// Optional context context: Vec, }, + /// Mark a search result as not relevant (weakens edges that led to it) + #[command(name = "not-relevant")] + NotRelevant { + /// Node key that was not relevant + key: String, + }, + /// Mark a node as not useful (weakens node weight, not edges) + #[command(name = "not-useful")] + NotUseful { + /// Node key + key: String, + }, /// Record a gap in memory coverage Gap { /// Gap description @@ -717,6 +729,8 @@ fn main() { Command::Query { expr } => cmd_query(&expr), Command::Used { key } => cmd_used(&key), Command::Wrong { key, context } => cmd_wrong(&key, &context), + Command::NotRelevant { key } => cmd_not_relevant(&key), + Command::NotUseful { key } => cmd_not_useful(&key), Command::Gap { description } => cmd_gap(&description), // Node @@ -1342,8 +1356,24 @@ fn cmd_used(key: &[String]) -> Result<(), String> { let mut store = store::Store::load()?; let resolved = store.resolve_key(&key)?; store.mark_used(&resolved); + + // Also strengthen edges to this node — conscious-tier delta. + const DELTA: f32 = 0.01; + let mut strengthened = 0; + for rel in &mut store.relations { + if rel.deleted { continue; } + if rel.source_key == resolved || rel.target_key == resolved { + let old = rel.strength; + rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95); + if (rel.strength - old).abs() > 0.001 { + rel.version += 1; + strengthened += 1; + } + } + } + store.save()?; - println!("Marked '{}' as used", resolved); + println!("Marked '{}' as used (strengthened {} edges)", resolved, strengthened); Ok(()) } @@ -1357,6 +1387,40 @@ fn cmd_wrong(key: &str, context: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_not_relevant(key: &str) -> Result<(), String> { + let mut store = store::Store::load()?; + let resolved = store.resolve_key(key)?; + + // Weaken all edges to this node — it was routed to incorrectly. + // Conscious-tier delta: 0.01 per edge. + const DELTA: f32 = -0.01; + let mut adjusted = 0; + for rel in &mut store.relations { + if rel.deleted { continue; } + if rel.source_key == resolved || rel.target_key == resolved { + let old = rel.strength; + rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95); + if (rel.strength - old).abs() > 0.001 { + rel.version += 1; + adjusted += 1; + } + } + } + store.save()?; + println!("Not relevant: '{}' — weakened {} edges by {}", resolved, adjusted, DELTA.abs()); + Ok(()) +} + +fn cmd_not_useful(key: &str) -> Result<(), String> { + let mut store = store::Store::load()?; + let resolved = store.resolve_key(key)?; + // Same as wrong but with clearer semantics: node content is bad, edges are fine. + store.mark_wrong(&resolved, Some("not-useful")); + store.save()?; + println!("Not useful: '{}' — node weight reduced", resolved); + Ok(()) +} + fn cmd_gap(description: &[String]) -> Result<(), String> { if description.is_empty() { return Err("gap requires a description".into()); diff --git a/poc-memory/src/store/ops.rs b/poc-memory/src/store/ops.rs index c3c7cb6..06477da 100644 --- a/poc-memory/src/store/ops.rs +++ b/poc-memory/src/store/ops.rs @@ -184,6 +184,27 @@ impl Store { }); } + /// Adjust edge strength between two nodes by a delta. + /// Clamps to [0.05, 0.95]. Returns (old_strength, new_strength, edges_modified). + pub fn adjust_edge_strength(&mut self, key_a: &str, key_b: &str, delta: f32) -> (f32, f32, usize) { + let mut old = 0.0f32; + let mut new = 0.0f32; + let mut count = 0; + for rel in &mut self.relations { + if rel.deleted { continue; } + if (rel.source_key == key_a && rel.target_key == key_b) + || (rel.source_key == key_b && rel.target_key == key_a) + { + old = rel.strength; + rel.strength = (rel.strength + delta).clamp(0.05, 0.95); + new = rel.strength; + rel.version += 1; + count += 1; + } + } + (old, new, count) + } + pub fn record_gap(&mut self, desc: &str) { self.gaps.push(GapRecord { description: desc.to_string(),