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::time::Instant;
20
21impl Engine {
22    /// Runs consolidation: Hebbian→decay→compaction→summary, covering the specified scope.
23    /// Reindex scope: rebuilds all secondary indexes from memory_log (no data loss).
24    pub fn consolidate(&self, scope: ConsolidationScope) -> EngineResult<ConsolidationReport> {
25        if matches!(scope, ConsolidationScope::Reindex) {
26            return self.consolidate_reindex();
27        }
28        self.consolidate_incremental()
29    }
30
31    /// Standard incremental consolidation (Hebbian→decay→compaction→summary).
32    fn consolidate_incremental(&self) -> EngineResult<ConsolidationReport> {
33        let start = Instant::now();
34        let clock = SystemClock;
35        let now = clock.now();
36
37        // 1. Load all data in the store
38        let mut units = crate::retrieve_api::load_all_units(self.store.db_arc());
39
40        // 2. Read activation_log and build co-activation pairs
41        //    0.3.0: only positive signals contribute (UserRejected excluded, 05 §6)
42        let logger = ActivationLogger::new(self.store.db_arc());
43        let mut activation_log = ActivationLog::default();
44        if let Ok(records) = logger.read_all() {
45            for rec in &records {
46                if !is_positive_signal(&rec.signal) {
47                    continue;
48                }
49                for i in 0..rec.used_memory_ids.len() {
50                    for j in (i + 1)..rec.used_memory_ids.len() {
51                        // P1 回归:used_memory_ids 是完整 u128(MemoryId 原值),禁止截断
52                        let a = MemoryId(rec.used_memory_ids[i]);
53                        let b = MemoryId(rec.used_memory_ids[j]);
54                        let ts = hippmem_core::time::Timestamp::from_millis(rec.recorded_at_ms);
55                        activation_log.record(a, ts, 0.5);
56                        activation_log.record(b, ts, 0.5);
57                    }
58                }
59            }
60        }
61        let co_activations = activation_log.co_activation_pairs(3_600_000);
62
63        // 3. Run consolidation cycle (Hebbian→decay→compaction)
64        let mut worker = ConsolidationWorker::default();
65        let cycle_stats = worker.run_cycle(&mut units, &co_activations, now);
66
67        // 3b. Summary planning (03 §8) — 由 Engine 层负责:按 simhash 相似簇触发,
68        //     covers 去重,源单元标记 Compressed{into: summary.id}
69        let params = self.params.read();
70        let clusters = plan_summary_clusters(
71            &units,
72            params.summary_similarity_threshold,
73            params.summary_trigger_count,
74            params.summary_low_importance_threshold,
75        );
76        let mut summaries: Vec<MemoryUnit> = Vec::new();
77        for cluster in &clusters {
78            let members: Vec<MemoryUnit> = cluster
79                .iter()
80                .filter_map(|id| units.iter().find(|u| u.id == *id).cloned())
81                .collect();
82            if members.len() != cluster.len() {
83                continue; // 防御:簇成员必须全部可解析
84            }
85            let summary_unit = build_summary_unit(&members, &DeterministicSummarizer);
86            // Confidence gating: low confidence (<0.35) does not create a summary (Constitution C7)
87            if summary_unit.understanding.confidence.value() >= 0.35 {
88                summaries.push(summary_unit);
89            }
90        }
91        // 源单元标记 Compressed(随下方持久化循环一起落库)
92        for summary_unit in &summaries {
93            for unit in units.iter_mut() {
94                if summary_unit.context.preceding_memory_ids.contains(&unit.id) {
95                    unit.lifecycle = MemoryLifecycle::Compressed {
96                        into: summary_unit.id,
97                    };
98                }
99            }
100        }
101
102        // 4. Persist the modified units back to the store
103        let kv = KvStore::new(self.store.db_arc());
104        for unit in &units {
105            let bincode_unit = bincode::serde::encode_to_vec(unit, bincode::config::standard())
106                .map_err(|e| EngineError::Internal(e.to_string()))?;
107            kv.put(unit.id.0, &bincode_unit)
108                .map_err(|e| EngineError::Store(e.to_string()))?;
109        }
110
111        // 4b. Persist summary memories — 全索引写入(P3 回归:摘要必须可被检索,
112        //     且写入 memory_log,reindex 不丢失)
113        for summary_unit in &summaries {
114            let input = crate::WriteMemoryInput {
115                content: summary_unit.content.raw.clone(),
116                content_type: Some(summary_unit.content.content_type),
117                context: summary_unit.context.clone(),
118                importance_hint: Some(summary_unit.understanding.importance.value()),
119                source_refs: summary_unit.context.source_refs.clone(),
120            };
121            crate::write_api::write_internal(self, summary_unit.id, input, false, None)?;
122
123            // 保留摘要的身份与 covers 链:
124            // write_internal 按相似度重建了普通边/元数据,这里以摘要单元自身的
125            // Elaboration 出边 + provenance/stage/content.summary 覆盖写回的单元,
126            // 保持图、单元、身份一致(索引仍用 write_internal 生成的键)。
127            let graph = hippmem_store::graph::GraphStore::new(self.store.db_arc());
128            graph
129                .put_outgoing(summary_unit.id, &summary_unit.links)
130                .map_err(EngineError::Store)?;
131            if let Some(raw) = kv
132                .get(&summary_unit.id.0)
133                .map_err(|e| EngineError::Store(e.to_string()))?
134            {
135                let (mut patched, _): (MemoryUnit, _) =
136                    bincode::serde::decode_from_slice(&raw, bincode::config::standard())
137                        .map_err(|e| EngineError::Internal(e.to_string()))?;
138                patched.links = summary_unit.links.clone();
139                patched.provenance = summary_unit.provenance.clone();
140                patched.stage = summary_unit.stage;
141                patched.content.summary = summary_unit.content.summary.clone();
142                let re_bincode =
143                    bincode::serde::encode_to_vec(&patched, bincode::config::standard())
144                        .map_err(|e| EngineError::Internal(e.to_string()))?;
145                kv.put(summary_unit.id.0, &re_bincode)
146                    .map_err(|e| EngineError::Store(e.to_string()))?;
147            }
148        }
149
150        let elapsed_ms = start.elapsed().as_millis() as u64;
151
152        Ok(ConsolidationReport {
153            memories_processed: units.len() as u64 + summaries.len() as u64,
154            edges_decayed: cycle_stats.edges_decayed,
155            edges_archived: cycle_stats.edges_archived,
156            edges_merged: cycle_stats.hebbian_applied,
157            observation_promoted: 0,
158            summaries_created: summaries.len() as u64,
159            contradictions_found: 0,
160            reindexed: false,
161            elapsed_ms,
162        })
163    }
164
165    /// Reindex: rebuilds all secondary indexes from memory_log (no data loss, MemoryId unchanged).
166    fn consolidate_reindex(&self) -> EngineResult<ConsolidationReport> {
167        let start = Instant::now();
168
169        // 1. Read all raw records from memory_log
170        let log = MemoryLog::new(self.store.db_arc());
171        let raw_records = log
172            .read_all()
173            .map_err(|e| EngineError::Store(e.to_string()))?;
174        let mut units: Vec<(u128, MemoryUnit)> = Vec::with_capacity(raw_records.len());
175        for (id, data) in &raw_records {
176            let (unit, _): (MemoryUnit, _) =
177                bincode::serde::decode_from_slice(data, bincode::config::standard()).map_err(
178                    |e| EngineError::Internal(format!("failed to deserialize MemoryUnit: {}", e)),
179                )?;
180            units.push((*id, unit));
181        }
182        let total = units.len() as u64;
183
184        // 2. Clear all secondary tables (preserve MEMORY_LOG)
185        clear_all_secondary_tables(self.store.db_arc())
186            .map_err(|e| EngineError::Store(e.to_string()))?;
187
188        // 3. Clear the Tantivy fulltext index (rebuild after deleting the directory)
189        {
190            let mut ft = self.fulltext_index.lock();
191            let _ = ft.commit();
192            drop(ft);
193            if self.fulltext_dir.exists() {
194                std::fs::remove_dir_all(&self.fulltext_dir).map_err(|e| {
195                    EngineError::Store(format!("failed to delete fulltext directory: {}", e))
196                })?;
197            }
198            let new_ft = hippmem_store::fulltext::FulltextIndex::create(&self.fulltext_dir)
199                .map_err(|e| {
200                    EngineError::Store(format!("failed to rebuild Tantivy index: {}", e))
201                })?;
202            *self.fulltext_index.lock() = new_ft;
203        }
204
205        // 4. Clear the vector indexes
206        {
207            use hippmem_store::semantic::binary::BinaryCodeIndex;
208            use hippmem_store::semantic::hnsw::FlatVectorIndex;
209            *self.binary_code_index.lock() = BinaryCodeIndex::new();
210            *self.dense_vector_index.lock() = FlatVectorIndex::new();
211        }
212
213        // 5. Re-write each entry with its original MemoryId
214        for (id, unit) in &units {
215            self.reindex_one(MemoryId(*id), unit)?;
216        }
217
218        let elapsed_ms = start.elapsed().as_millis() as u64;
219
220        Ok(ConsolidationReport {
221            memories_processed: total,
222            edges_decayed: 0,
223            edges_archived: 0,
224            edges_merged: 0,
225            observation_promoted: 0,
226            summaries_created: 0,
227            contradictions_found: 0,
228            reindexed: true,
229            elapsed_ms,
230        })
231    }
232
233    /// Re-processes a memory with its original MemoryId (used internally by Reindex).
234    fn reindex_one(&self, id: MemoryId, unit: &MemoryUnit) -> EngineResult<()> {
235        use crate::write_api::write_internal;
236
237        let input = crate::WriteMemoryInput {
238            content: unit.content.raw.clone(),
239            content_type: Some(unit.content.content_type),
240            context: unit.context.clone(),
241            importance_hint: Some(unit.understanding.importance.value()),
242            source_refs: unit.context.source_refs.clone(),
243        };
244        // skip_memory_log=true: the record already exists in MEMORY_LOG (constitution C7)
245        write_internal(self, id, input, true, None)?;
246        Ok(())
247    }
248}
249
250// ── Table cleanup helpers ──
251
252/// Clears all secondary tables, preserving MEMORY_LOG (constitution C7).
253fn clear_all_secondary_tables(
254    db: std::sync::Arc<redb::Database>,
255) -> Result<(), hippmem_store::store::StoreError> {
256    use redb::ReadableTable;
257
258    let txn = db.begin_write()?;
259
260    // u128 tables (excluding MEMORY_LOG)
261    let u128_tables: &[redb::TableDefinition<u128, &[u8]>] = &[
262        MEMORY_KV,
263        LINK_OVERLAY,
264        SUMMARY_OVERLAY,
265        CORRECTION_OVERLAY,
266        ACTIVATION_LOG,
267        CONSOLIDATION_QUEUE,
268    ];
269    for def in u128_tables {
270        let keys: Vec<u128> = {
271            let table = txn.open_table(*def)?;
272            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
273        };
274        if !keys.is_empty() {
275            let mut table = txn.open_table(*def)?;
276            for k in &keys {
277                let _ = table.remove(*k);
278            }
279        }
280    }
281
282    // u64 tables
283    let u64_tables: &[redb::TableDefinition<u64, &[u8]>] = &[
284        ENTITY_INDEX,
285        TOPIC_INDEX,
286        GOAL_INDEX,
287        EVENT_INDEX,
288        CAUSAL_INDEX,
289    ];
290    for def in u64_tables {
291        let keys: Vec<u64> = {
292            let table = txn.open_table(*def)?;
293            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
294        };
295        if !keys.is_empty() {
296            let mut table = txn.open_table(*def)?;
297            for k in &keys {
298                let _ = table.remove(*k);
299            }
300        }
301    }
302
303    // u32 tables
304    let u32_tables: &[redb::TableDefinition<u32, &[u8]>] = &[TEMPORAL_INDEX];
305    for def in u32_tables {
306        let keys: Vec<u32> = {
307            let table = txn.open_table(*def)?;
308            table.iter()?.flatten().map(|(k, _)| k.value()).collect()
309        };
310        if !keys.is_empty() {
311            let mut table = txn.open_table(*def)?;
312            for k in &keys {
313                let _ = table.remove(*k);
314            }
315        }
316    }
317
318    txn.commit()?;
319    Ok(())
320}