From 3da2d05729aba2b829a6e33c054693e6b072743f Mon Sep 17 00:00:00 2001 From: Lior Cohen Date: Fri, 10 Apr 2026 05:08:26 +0300 Subject: [PATCH 1/2] =?UTF-8?q?KS78:=20Relative=20supersession=20demotion?= =?UTF-8?q?=20=E2=80=94=20multiplicative=200.40=20default=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change supersession demotion from absolute penalty to multiplicative factor: score *= (1 - factor)^count instead of score -= constant - Default changed from 0.15 (absolute) to 0.40 (multiplicative, retains 60%) - Combines direct Supersedes edges and parent supersession into one factor - Fixes case where old memory with high cosine sim still outranked new memory despite being superseded (e.g., 0.85 - 0.15 = 0.70 > 0.65) - Updated tests to verify multiplicative math Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/shrimpk-core/src/config.rs | 6 ++-- crates/shrimpk-memory/src/echo.rs | 50 +++++++++++++++++-------------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/crates/shrimpk-core/src/config.rs b/crates/shrimpk-core/src/config.rs index a84db27..55db32b 100644 --- a/crates/shrimpk-core/src/config.rs +++ b/crates/shrimpk-core/src/config.rs @@ -239,8 +239,8 @@ pub struct EchoConfig { /// Default: 0.0 (no penalty). Negative values demote children relative to parents. #[serde(default)] pub child_memory_penalty: f32, - /// Demotion applied to older (superseded) memories when a Supersedes edge exists. - /// Default: 0.0 (disabled). Positive values penalize stale facts. + /// Supersession demotion factor (multiplicative). 0.40 = retain 60% of score. + /// Applied as `score *= (1 - factor)^count` for each supersession edge. #[serde(default)] pub supersedes_demotion: f32, /// Custom system prompt for the consolidator LLM fact extraction. @@ -478,7 +478,7 @@ impl Default for EchoConfig { recency_weight: default_recency_weight(), child_rescue_only: default_child_rescue_only(), child_memory_penalty: 0.0, - supersedes_demotion: 0.15, + supersedes_demotion: 0.40, fact_extraction_prompt: None, query_expansion_enabled: false, reranker_enabled: false, diff --git a/crates/shrimpk-memory/src/echo.rs b/crates/shrimpk-memory/src/echo.rs index 3164c77..41a671b 100644 --- a/crates/shrimpk-memory/src/echo.rs +++ b/crates/shrimpk-memory/src/echo.rs @@ -1424,7 +1424,7 @@ impl EchoEngine { if idx > *neighbor { boost += 0.1; } else { - demotion -= self.config.supersedes_demotion as f64; + superseded_count += 1; } } crate::hebbian::RelationshipType::CoActivation => {} @@ -1448,7 +1448,7 @@ impl EchoEngine { if idx > other { boost += 0.1; } else { - demotion -= self.config.supersedes_demotion as f64; + superseded_count += 1; } } crate::hebbian::RelationshipType::CoActivation => {} @@ -1460,18 +1460,17 @@ impl EchoEngine { } } - boost.min(0.4) + demotion + (boost.min(0.4), superseded_count) }) .collect() }; // 7b2. Parent supersession demotion (KS68 KU-1): if a parent entry has // children with Supersedes edges (child is the older/superseded side), - // apply a flat demotion to the parent. This propagates child-level + // apply a multiplicative demotion to the parent. This propagates child-level // supersession to parent ranking in Pipe A. - let parent_demotions: std::collections::HashMap = { + let parent_demotions: std::collections::HashMap = { let hebbian = self.hebbian.read().await; - let demotion = self.config.supersedes_demotion as f64; let mut demotions = std::collections::HashMap::new(); for &(idx, _) in &top { if let Some(entry) = store.entry_at(idx) { @@ -1492,7 +1491,7 @@ impl EchoEngine { } } if has_superseded_child { - demotions.insert(idx, -demotion); + demotions.insert(idx, 1); } } } @@ -1505,7 +1504,7 @@ impl EchoEngine { let mut results: Vec = top .iter() .zip(hebbian_boosts.iter()) - .filter_map(|(&(idx, score), &boost)| { + .filter_map(|(&(idx, score), &(boost, direct_superseded_count))| { let entry = store.entry_at(idx)?; // Apply category-aware decay: older memories score lower (F-02 fix) @@ -1556,9 +1555,13 @@ impl EchoEngine { // Co-occurrence bonus (KS68 ME-4) final_score += co_occurrence_boost(&entry.content); - // Parent supersession demotion (KS68 KU-1) - if let Some(&demotion) = parent_demotions.get(&idx) { - final_score += demotion; + // Supersession demotion -- multiplicative (KS78 #11) + // Combines direct Supersedes edges + parent supersession into one factor + let total_superseded = + direct_superseded_count + parent_demotions.get(&idx).copied().unwrap_or(0); + if total_superseded > 0 { + let retain = 1.0 - self.config.supersedes_demotion as f64; + final_score *= retain.powi(total_superseded as i32); } // Child memory penalty (KS69): demote children to prevent hallucination inflation @@ -4430,38 +4433,41 @@ mod tests { ); } - // --- KU-1: Parent supersession flat demotion --- + // --- KU-1: Parent supersession multiplicative demotion (KS78) --- #[test] - fn supersession_flat_demotion_closes_gap() { + fn supersession_multiplicative_demotion_closes_gap() { // Simulate: M4 (Shopify, old job) final_score = 1.027 // M5 (Stripe, new job) final_score = 1.001 - // With full demotion of 0.15: M4 drops to 0.877, well below M5. - let demotion: f64 = 0.15; + // With multiplicative demotion of 0.40: M4 retains 60%. + // 1.027 * 0.60 = 0.6162, well below 1.001. + let demotion_factor: f64 = 0.40; let mut old_parent_score: f64 = 1.027; let new_parent_score: f64 = 1.001; - old_parent_score += -demotion; + old_parent_score *= 1.0 - demotion_factor; + let expected = 1.027 * 0.6; assert!( old_parent_score < new_parent_score, "Old parent ({old_parent_score}) must rank below new parent ({new_parent_score})" ); assert!( - (old_parent_score - 0.877).abs() < 1e-10, - "Old parent should be demoted to 0.877, got {old_parent_score}" + (old_parent_score - expected).abs() < 1e-10, + "Old parent should be demoted to {expected}, got {old_parent_score}" ); } #[test] - fn supersession_flat_demotion_no_op_without_superseded_child() { + fn supersession_demotion_no_op_without_superseded_child() { // If parent has no superseded children, no demotion is applied let original: f64 = 1.027; - let demotions: std::collections::HashMap = std::collections::HashMap::new(); + let demotions: std::collections::HashMap = std::collections::HashMap::new(); let mut score = original; - if let Some(&d) = demotions.get(&0) { - score += d; + let total_superseded = demotions.get(&0).copied().unwrap_or(0); + if total_superseded > 0 { + score *= (1.0 - 0.40_f64).powi(total_superseded as i32); } assert!( From 48d3bc24632c164cd6b28cbd2cac5cb7b0d2bc08 Mon Sep 17 00:00:00 2001 From: Lior Cohen Date: Fri, 10 Apr 2026 05:24:22 +0300 Subject: [PATCH 2/2] fix: declare superseded_count in both temporal and standard code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hebbian_boosts closure referenced `superseded_count` but only declared `demotion: f64` — renamed variable + fixed Vec to Vec<(f64, u32)> to match downstream destructuring at line 1507. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/shrimpk-memory/src/echo.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/shrimpk-memory/src/echo.rs b/crates/shrimpk-memory/src/echo.rs index 41a671b..9905c66 100644 --- a/crates/shrimpk-memory/src/echo.rs +++ b/crates/shrimpk-memory/src/echo.rs @@ -1402,13 +1402,13 @@ impl EchoEngine { // - Typed relationships: small extra boost when relationship type is relevant // When at_time is provided (KS63), use get_valid_associations to filter // expired/not-yet-valid edges for point-in-time queries. - let hebbian_boosts: Vec = { + let hebbian_boosts: Vec<(f64, u32)> = { let hebbian = self.hebbian.read().await; top.iter() .map(|&(idx, _)| { let idx = idx as u32; let mut boost: f64 = 0.0; - let mut demotion: f64 = 0.0; + let mut superseded_count: u32 = 0; if let Some(at) = at_time { // Temporal query: only consider edges valid at the given timestamp