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..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 @@ -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!(