Skip to main content

hippmem_engine/
consolidate_api.rs

1//! Engine::consolidate — consolidation API (05 §5, 09 §4.4).
2
3use crate::signals::is_positive_signal;
4use crate::{ConsolidationReport, ConsolidationScope, Engine, EngineError, EngineResult};
5use hippmem_consolidation::hebbian::ActivationLog;
6use hippmem_consolidation::summarize::{build_summary_unit, plan_summary_clusters};
7use hippmem_consolidation::worker::ConsolidationWorker;
8use hippmem_core::ids::MemoryId;
9use hippmem_core::model::unit::{MemoryLifecycle, MemoryUnit};
10use hippmem_core::time::{Clock, SystemClock};
11use hippmem_model::deterministic::summarize::DeterministicSummarizer;
12use hippmem_store::activation_log::ActivationLogger;
13use hippmem_store::kv::KvStore;
14use hippmem_store::memory_log::MemoryLog;
15use hippmem_store::store::{
16    ACTIVATION_LOG, CAUSAL_INDEX, CONSOLIDATION_QUEUE, CORRECTION_OVERLAY, ENTITY_INDEX,
17    EVENT_INDEX, GOAL_INDEX, LINK_OVERLAY, MEMORY_KV, SUMMARY_OVERLAY, TEMPORAL_INDEX, TOPIC_INDEX,
18};
19use std::collections::HashMap;
20use std::time::Instant;
21
22impl Engine {
23    /// Runs consolidation: Hebbian→decay→compaction→summary, covering the specified scope.
24    /// Reindex scope: rebuilds all secondary indexes from memory_log (no data loss).
25    pub fn consolidate(&self, scope: ConsolidationScope) -> EngineResult<ConsolidationReport> {
26        if matches!(scope, ConsolidationScope::Reindex) {
27            return self.consolidate_reindex();
28        }
29        self.consolidate_incremental()
30    }
31
32    /// Standard incremental consolidation (Hebbian→decay→compaction→summary).
33    fn consolidate_incremental(&self) -> EngineResult<ConsolidationReport> {
34        let start = Instant::now();
35        let clock = SystemClock;
36        let now = clock.now();
37
38        // 1. Load all data in the store
39        let mut units = crate::retrieve_api::load_all_units(self.store.db_arc());
40
41        // 2. Read activation_log and build co-activation pairs
42        //    0.3.0: only positive signals contribute (UserRejected excluded, 05 §6)
43        //    E8 (0.4.0): records carry their signal weight (referenced 0.5 /
44        //    confirmed 1.0 / succeeded 0.8), so edge reinforcement scales with
45        //    signal strength per 03 §6.
46        //    B4 (0.4.0): targeted rejects (non-empty used_memory_ids) feed the
47        //    reverse-Hebbian step instead of strengthening anything.
48        let logger = ActivationLogger::new(self.store.db_arc());
49        let mut activation_log = ActivationLog::default();
50        let mut rejected_ids: Vec<MemoryId> = Vec::new();
51        if let Ok(records) = logger.read_all() {
52            for rec in &records {
53                if is_positive_signal(&rec.signal) {
54                    let weight = match rec.signal.as_str() {
55                        "Referenced" => 0.5,
56                        "TaskSucceeded" => 0.8,
57                        _ => 1.0, // UserConfirmedCorrect
58                    };
59                    for i in 0..rec.used_memory_ids.len() {
60                        for j in (i + 1)..rec.used_memory_ids.len() {
61                            // P1 回归:used_memory_ids 是完整 u128(MemoryId 原值),禁止截断
62                            let a = MemoryId(rec.used_memory_ids[i]);
63                            let b = MemoryId(rec.used_memory_ids[j]);
64                            let ts = hippmem_core::time::Timestamp::from_millis(rec.recorded_at_ms);
65                            activation_log.record(a, ts, weight);
66                            activation_log.record(b, ts, weight);
67                        }
68                    }
69                } else if rec.signal == "UserRejected" && !rec.used_memory_ids.is_empty() {
70                    // Targeted reject: the named memories get their edges weakened.
71                    rejected_ids.extend(rec.used_memory_ids.iter().map(|id| MemoryId(*id)));
72                }
73            }
74        }
75        let co_activations = activation_log.co_activation_pairs(3_600_000);
76
77        // 3. Run consolidation cycle (Hebbian→reverse Hebbian→decay→compaction)
78        let mut worker = ConsolidationWorker::default();
79        let cycle_stats = worker.run_cycle(&mut units, &co_activations, &rejected_ids, now);
80
81        // 3b. Summary planning (03 §8) — 由 Engine 层负责:按 simhash 相似簇触发,
82        //     covers 去重,源单元标记 Compressed{into: summary.id}
83        let params = self.params.read();
84        let clusters = plan_summary_clusters(
85            &units,
86            params.summary_similarity_threshold,
87            params.summary_trigger_count,
88            params.summary_low_importance_threshold,
89        );
90        let mut summaries: Vec<MemoryUnit> = Vec::new();
91        for cluster in &clusters {
92            let members: Vec<MemoryUnit> = cluster
93                .iter()
94                .filter_map(|id| units.iter().find(|u| u.id == *id).cloned())
95                .collect();
96            if members.len() != cluster.len() {
97                continue; // 防御:簇成员必须全部可解析
98            }
99            let summary_unit = build_summary_unit(&members, &DeterministicSummarizer);
100            // Confidence gating: low confidence (<0.35) does not create a summary (Constitution C7)
101            if summary_unit.understanding.confidence.value() >= 0.35 {
102                summaries.push(summary_unit);
103            }
104        }
105        // 源单元标记 Compressed(随下方持久化循环一起落库)
106        for summary_unit in &summaries {
107            for unit in units.iter_mut() {
108                if summary_unit.context.preceding_memory_ids.contains(&unit.id) {
109                    unit.lifecycle = MemoryLifecycle::Compressed {
110                        into: summary_unit.id,
111                    };
112                }
113            }
114        }
115
116        // B5 (0.4.0): redirect in-edges pointing at compressed sources to their
117        // summary. Without this, other memories keep "ghost edges" to a source
118        // that no longer expands (F3) — and the associations that used to flow
119        // through the source are lost. After redirection the graph stays
120        // connected and the summary becomes reachable as an upward view via
121        // graph edges (the channel B1 reserved). The redirected edge keeps its
122        // strength and type.
123        let mut compressed_into: HashMap<MemoryId, MemoryId> = HashMap::new();
124        for summary_unit in &summaries {
125            for source_id in &summary_unit.context.preceding_memory_ids {
126                compressed_into.insert(*source_id, summary_unit.id);
127            }
128        }
129        if !compressed_into.is_empty() {
130            for unit in units.iter_mut() {
131                for link in unit.links.iter_mut() {
132                    if let Some(&summary_id) = compressed_into.get(&link.target_id) {
133                        link.target_id = summary_id;
134                        match link.evidence.note.as_mut() {
135                            Some(note) => note.push_str(" [redirected to summary]"),
136                            None => link.evidence.note = Some("redirected to summary".into()),
137                        }
138                    }
139                }
140            }
141        }
142
143        // 4. Persist the modified units back to the store — MEMORY_KV (unit
144        //    bodies) AND LINK_OVERLAY (graph edges). Retrieval reads edges from
145        //    the graph table, so every edge mutation in this cycle (Hebbian
146        //    reinforcement, reverse Hebbian, B5 redirection) must be synced
147        //    there, otherwise the changes are invisible to retrieval.
148        let kv = KvStore::new(self.store.db_arc());
149        let graph = hippmem_store::graph::GraphStore::new(self.store.db_arc());
150        for unit in &units {
151            let bincode_unit = bincode::serde::encode_to_vec(unit, bincode::config::standard())
152                .map_err(|e| EngineError::Internal(e.to_string()))?;
153            kv.put(unit.id.0, &bincode_unit)
154                .map_err(|e| EngineError::Store(e.to_string()))?;
155            graph
156                .put_outgoing(unit.id, &unit.links)
157                .map_err(EngineError::Store)?;
158        }
159
160        // 4b. Persist summary memories — 全索引写入(P3 回归:摘要必须可被检索,
161        //     且写入 memory_log,reindex 不丢失)
162        for summary_unit in &summaries {
163            let input = crate::WriteMemoryInput {
164                content: summary_unit.content.raw.clone(),
165                content_type: Some(summary_unit.content.content_type),
166                context: summary_unit.context.clone(),
167                importance_hint: Some(summary_unit.understanding.importance.value()),
168                source_refs: summary_unit.context.source_refs.clone(),
169            };
170            crate::write_api::write_internal(self, summary_unit.id, input, false, None)?;
171
172            // 保留摘要的身份与 covers 链:
173            // write_internal 按相似度重建了普通边/元数据,这里以摘要单元自身的
174            // Elaboration 出边 + provenance/stage/content.summary 覆盖写回的单元,
175            // 保持图、单元、身份一致(索引仍用 write_internal 生成的键)。
176            // B5: 摘要的 Elaboration 出边指向已压缩的源——从图中移除(源不可达,
177            // 幽灵边不得留存);covers 链保留在 context.preceding_memory_ids 供下钻。
178            let graph = hippmem_store::graph::GraphStore::new(self.store.db_arc());
179            let summary_links: Vec<hippmem_core::model::links::AssociationLink> = summary_unit
180                .links
181                .iter()
182                .filter(|l| !compressed_into.contains_key(&l.target_id))
183                .cloned()
184                .collect();
185            graph
186                .put_outgoing(summary_unit.id, &summary_links)
187                .map_err(EngineError::Store)?;
188            if let Some(raw) = kv
189                .get(&summary_unit.id.0)
190                .map_err(|e| EngineError::Store(e.to_string()))?
191            {
192                let (mut patched, _): (MemoryUnit, _) =
193                    bincode::serde::decode_from_slice(&raw, bincode::config::standard())
194                        .map_err(|e| EngineError::Internal(e.to_string()))?;
195                patched.links = summary_links.clone();
196                patched.provenance = summary_unit.provenance.clone();
197                patched.stage = summary_unit.stage;
198                patched.content.summary = summary_unit.content.summary.clone();
199                let re_bincode =
200                    bincode::serde::encode_to_vec(&patched, bincode::config::standard())
201                        .map_err(|e| EngineError::Internal(e.to_string()))?;
202                kv.put(summary_unit.id.0, &re_bincode)
203                    .map_err(|e| EngineError::Store(e.to_string()))?;
204            }
205        }
206
207        let elapsed_ms = start.elapsed().as_millis() as u64;
208
209        Ok(ConsolidationReport {
210            memories_processed: units.len() as u64 + summaries.len() as u64,
211            edges_decayed: cycle_stats.edges_decayed,
212            edges_archived: cycle_stats.edges_archived,
213            edges_merged: cycle_stats.hebbian_applied,
214            observation_promoted: 0,
215            summaries_created: summaries.len() as u64,
216            contradictions_found: 0,
217            reindexed: false,
218            elapsed_ms,
219        })
220    }
221
222    /// Reindex: rebuilds all secondary indexes from memory_log (no data loss, MemoryId unchanged).
223    fn consolidate_reindex(&self) -> EngineResult<ConsolidationReport> {
224        let start = Instant::now();
225
226        // 1. Read all raw records from memory_log
227        let log = MemoryLog::new(self.store.db_arc());
228        let raw_records = log
229            .read_all()
230            .map_err(|e| EngineError::Store(e.to_string()))?;
231        let mut units: Vec<(u128, MemoryUnit)> = Vec::with_capacity(raw_records.len());
232        for (id, data) in &raw_records {
233            let (unit, _): (MemoryUnit, _) =
234                bincode::serde::decode_from_slice(data, bincode::config::standard()).map_err(
235                    |e| EngineError::Internal(format!("failed to deserialize MemoryUnit: {}", e)),
236                )?;
237            units.push((*id, unit));
238        }
239        let total = units.len() as u64;
240
241        // 2. Clear all secondary tables (preserve MEMORY_LOG)
242        clear_all_secondary_tables(self.store.db_arc())
243            .map_err(|e| EngineError::Store(e.to_string()))?;
244
245        // 3. Clear the Tantivy fulltext index (rebuild after deleting the directory)
246        {
247            let mut ft = self.fulltext_index.lock();
248            let _ = ft.commit();
249            drop(ft);
250            if self.fulltext_dir.exists() {
251                std::fs::remove_dir_all(&self.fulltext_dir).map_err(|e| {
252                    EngineError::Store(format!("failed to delete fulltext directory: {}", e))
253                })?;
254            }
255            let new_ft = hippmem_store::fulltext::FulltextIndex::create(&self.fulltext_dir)
256                .map_err(|e| {
257                    EngineError::Store(format!("failed to rebuild Tantivy index: {}", e))
258                })?;
259            *self.fulltext_index.lock() = new_ft;
260        }
261
262        // 4. Clear the vector indexes
263        {
264            use hippmem_store::semantic::binary::BinaryCodeIndex;
265            use hippmem_store::semantic::hnsw::FlatVectorIndex;
266            *self.binary_code_index.lock() = BinaryCodeIndex::new();
267            *self.dense_vector_index.lock() = FlatVectorIndex::new();
268        }
269
270        // 5. Re-write each entry with its original MemoryId
271        for (id, unit) in &units {
272            self.reindex_one(MemoryId(*id), unit)?;
273        }
274
275        let elapsed_ms = start.elapsed().as_millis() as u64;
276
277        Ok(ConsolidationReport {
278            memories_processed: total,
279            edges_decayed: 0,
280            edges_archived: 0,
281            edges_merged: 0,
282            observation_promoted: 0,
283            summaries_created: 0,
284            contradictions_found: 0,
285            reindexed: true,
286            elapsed_ms,
287        })
288    }
289
290    /// Re-processes a memory with its original MemoryId (used internally by Reindex).
291    fn reindex_one(&self, id: MemoryId, unit: &MemoryUnit) -> EngineResult<()> {
292        use crate::write_api::write_internal;
293
294        let input = crate::WriteMemoryInput {
295            content: unit.content.raw.clone(),
296            content_type: Some(unit.content.content_type),
297            context: unit.context.clone(),
298            importance_hint: Some(unit.understanding.importance.value()),
299            source_refs: unit.context.source_refs.clone(),
300        };
301        // skip_memory_log=true: the record already exists in MEMORY_LOG (constitution C7)
302        write_internal(self, id, input, true, None)?;
303        Ok(())
304    }
305}
306
307// ── Table cleanup helpers ──
308
309/// Clears all secondary tables, preserving MEMORY_LOG (constitution C7).
310fn clear_all_secondary_tables(
311    db: std::sync::Arc<redb::Database>,
312) -> Result<(), hippmem_store::store::StoreError> {
313    use redb::ReadableTable;
314
315    let txn = db.begin_write()?;
316
317    // u128 tables (excluding MEMORY_LOG)
318    let u128_tables: &[redb::TableDefinition<u128, &[u8]>] = &[
319        MEMORY_KV,
320        LINK_OVERLAY,
321        SUMMARY_OVERLAY,
322        CORRECTION_OVERLAY,
323        ACTIVATION_LOG,
324        CONSOLIDATION_QUEUE,
325    ];
326    for def in u128_tables {
327        let keys: Vec<u128> = {
328            let table = txn.open_table(*def)?;
329            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
330        };
331        if !keys.is_empty() {
332            let mut table = txn.open_table(*def)?;
333            for k in &keys {
334                let _ = table.remove(*k);
335            }
336        }
337    }
338
339    // u64 tables
340    let u64_tables: &[redb::TableDefinition<u64, &[u8]>] = &[
341        ENTITY_INDEX,
342        TOPIC_INDEX,
343        GOAL_INDEX,
344        EVENT_INDEX,
345        CAUSAL_INDEX,
346    ];
347    for def in u64_tables {
348        let keys: Vec<u64> = {
349            let table = txn.open_table(*def)?;
350            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
351        };
352        if !keys.is_empty() {
353            let mut table = txn.open_table(*def)?;
354            for k in &keys {
355                let _ = table.remove(*k);
356            }
357        }
358    }
359
360    // u32 tables
361    let u32_tables: &[redb::TableDefinition<u32, &[u8]>] = &[TEMPORAL_INDEX];
362    for def in u32_tables {
363        let keys: Vec<u32> = {
364            let table = txn.open_table(*def)?;
365            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
366        };
367        if !keys.is_empty() {
368            let mut table = txn.open_table(*def)?;
369            for k in &keys {
370                let _ = table.remove(*k);
371            }
372        }
373    }
374
375    txn.commit()?;
376    Ok(())
377}