Skip to main content

hippmem_engine/
write_api.rs

1//! Engine::write — write API assembly.
2//!
3//! Corresponds to 05#write, 09 §4.1. Wires the pure functions of the write pipeline
4//! together with store persistence.
5
6use crate::{Engine, EngineError, EngineResult, WriteMemoryInput, WriteMemoryOutput, WriteWarning};
7use hippmem_core::hash::stable_hash64;
8use hippmem_core::ids::{MemoryId, VectorId};
9use hippmem_core::model::enums::ContentType;
10use hippmem_core::model::links::SemanticSignature;
11use hippmem_core::model::understanding::MemoryUnderstanding;
12use hippmem_core::model::unit::{Language, MemoryContent, MemoryStage, MemoryUnit};
13use hippmem_core::score::UnitScore;
14use hippmem_core::time::{Clock, SystemClock};
15use hippmem_model::deterministic::extract::DeterministicExtractor;
16use hippmem_store::kv::InvertedIndex;
17use hippmem_store::semantic::vector_index::BinaryIndex;
18use hippmem_store::semantic::vector_index::VectorIndex;
19use hippmem_write::edges::EdgeBuildParams;
20use hippmem_write::keys::generate_keys;
21use hippmem_write::staged::{raw_to_indexed, StagedWriteInput};
22use hippmem_write::understanding::index_enriched_keys;
23
24impl Engine {
25    /// Writes a memory.
26    pub fn write(&self, input: WriteMemoryInput) -> EngineResult<WriteMemoryOutput> {
27        let memory_id = MemoryId::generate();
28        write_internal(self, memory_id, input, false, None)
29    }
30
31    /// Batch write: chunks calls to the embedding API, then processes each entry.
32    ///
33    /// Significantly reduces API round trips compared to per-entry write
34    /// (DashScope limits each batch to ≤10 entries).
35    pub fn write_batch(
36        &self,
37        inputs: Vec<WriteMemoryInput>,
38    ) -> EngineResult<Vec<WriteMemoryOutput>> {
39        if inputs.is_empty() {
40            return Ok(vec![]);
41        }
42
43        let n = inputs.len();
44        const CHUNK_SIZE: usize = 10; // DashScope text-embedding-v4 batch limit
45
46        // Chunked embedding: one API call per CHUNK_SIZE entries
47        let mut embeddings: Vec<Option<Vec<f32>>> = Vec::with_capacity(n);
48        let texts: Vec<String> = inputs.iter().map(|inp| inp.content.clone()).collect();
49
50        for chunk in texts.chunks(CHUNK_SIZE) {
51            match self.embedder.embed_sync(chunk) {
52                Ok(vectors) => {
53                    for v in vectors {
54                        embeddings.push(Some(v));
55                    }
56                }
57                Err(_e) => {
58                    // Embedding failure: do not block write; degrade to no dense vector
59                    embeddings.resize(embeddings.len() + chunk.len(), None);
60                }
61            }
62        }
63
64        let mut outputs = Vec::with_capacity(inputs.len());
65        for (input, embedding) in inputs.into_iter().zip(embeddings) {
66            let memory_id = MemoryId::generate();
67            let output = write_internal(self, memory_id, input, false, embedding)?;
68            outputs.push(output);
69        }
70        Ok(outputs)
71    }
72}
73
74/// Write pipeline core logic (shared by write/reindex/write_batch).
75///
76/// - `id`: the MemoryId to use (write generates a new ID, reindex reuses the original ID).
77/// - `input`: write input.
78/// - `skip_memory_log`: true when called by Reindex (the record already exists in MEMORY_LOG, constitution C7).
79/// - `precomputed_embedding`: precomputed vector provided for batch writes, avoiding duplicate API calls.
80pub(crate) fn write_internal(
81    engine: &Engine,
82    memory_id: MemoryId,
83    input: WriteMemoryInput,
84    skip_memory_log: bool,
85    precomputed_embedding: Option<Vec<f32>>,
86) -> EngineResult<WriteMemoryOutput> {
87    let clock = SystemClock;
88    let _now = clock.now();
89
90    // 2. Build MemoryContent
91    let content = MemoryContent {
92        raw: input.content.clone(),
93        summary: None,
94        normalized: None,
95        language: Language::Zh,
96        content_type: input.content_type.unwrap_or(ContentType::UserStatement),
97    };
98
99    // 3. Extract understanding (degraded backend, synchronous, uses the global JIEBA instance)
100    let extractor = DeterministicExtractor;
101    let (understanding, mut warnings) = match extractor.extract_sync_immediate(&content) {
102        Ok(imm) => {
103            let u = MemoryUnderstanding {
104                entities: imm.entities,
105                topics: imm.topics,
106                causal_claims: imm.explicit_causals,
107                goals: vec![],
108                decisions: vec![],
109                preferences: vec![],
110                emotions: vec![],
111                events: vec![],
112                contradictions: vec![],
113                importance: input
114                    .importance_hint
115                    .map(UnitScore::new)
116                    .unwrap_or(imm.importance),
117                confidence: UnitScore::new(0.5),
118            };
119            (u, vec![])
120        }
121        Err(_e) => (
122            MemoryUnderstanding {
123                entities: vec![],
124                topics: vec![],
125                causal_claims: vec![],
126                goals: vec![],
127                decisions: vec![],
128                preferences: vec![],
129                emotions: vec![],
130                events: vec![],
131                contradictions: vec![],
132                importance: UnitScore::new(0.0),
133                confidence: UnitScore::new(0.0),
134            },
135            vec![WriteWarning::ExtractorDegraded],
136        ),
137    };
138
139    // 4. Embedding (config-driven Embedder backend, synchronous)
140    let mut semantic = build_semantic_signature(&input.content);
141    // Generate dense vector and insert into FlatVectorIndex (SemanticDense channel, 03 §4.5)
142    {
143        if let Some(vector) = precomputed_embedding {
144            // Batch mode: use precomputed embedding, skip API call
145            let vector_id = memory_id.0;
146            let mut idx = engine.dense_vector_index.lock();
147            let _ = idx.insert(vector_id, &vector);
148            semantic.dense_embedding_ref = Some(VectorId(vector_id));
149        } else {
150            // Per-entry mode: standalone embedding API call
151            let texts = vec![input.content.clone()];
152            if let Ok(vectors) = engine.embedder.embed_sync(&texts) {
153                if let Some(vector) = vectors.first() {
154                    let vector_id = memory_id.0;
155                    let mut idx = engine.dense_vector_index.lock();
156                    let _ = idx.insert(vector_id, vector);
157                    semantic.dense_embedding_ref = Some(VectorId(vector_id));
158                }
159            }
160        }
161    }
162    // If embedder fails, keep dense_embedding_ref=None (SemanticDense channel empty, write not blocked)
163    if semantic.dense_embedding_ref.is_none() {
164        warnings.push(WriteWarning::EmbeddingDeferred);
165    }
166
167    // 4b. Insert binary_code into the binary code index (for SemanticBinary channel Hamming recall)
168    {
169        let bc_bytes = binary_code_to_bytes(&semantic.binary_code);
170        let mut idx = engine.binary_code_index.lock();
171        let _ = idx.insert(memory_id.0, &bc_bytes);
172    }
173
174    // 5. Generate AssociationKeys (4 args: content, understanding, context, semantic)
175    let keys = generate_keys(&content, &understanding, &input.context, &semantic)
176        .map_err(|e| EngineError::Internal(format!("generate_keys: {}", e)))?;
177
178    // 6. Recall candidate existing memories from the store index
179    let inverted = InvertedIndex::new(engine.store.db_arc());
180    let candidate_ids = discover_candidates(&keys, &inverted);
181    let existing_units = load_memory_units(&engine.store.db_arc(), &candidate_ids);
182
183    // 7. Call raw_to_indexed
184    let staged_input = StagedWriteInput {
185        id: memory_id,
186        content: content.clone(),
187        understanding: understanding.clone(),
188        context: input.context.clone(),
189        semantic,
190    };
191
192    // Build edge params from AlgoParams (configurable, not hardcoded)
193    let algo = engine.params.read();
194    let edge_params = EdgeBuildParams {
195        strong_threshold: algo.strong_edge_threshold,
196        strong_max: algo.strong_edge_max as usize,
197        weak_max: algo.weak_edge_max as usize,
198        min_score: algo.edge_build_min_score,
199        observation_max: algo.observation_enter_max,
200        max_candidates: 30, // Limit the number of edge-building candidates to control O(n²) cost
201    };
202    let staged_output = raw_to_indexed(staged_input, &existing_units, &edge_params, &algo)
203        .map_err(|e| EngineError::Internal(format!("raw_to_indexed: {}", e)))?;
204
205    let unit = staged_output.unit;
206
207    // 8. Persist to store (single redb transaction: memory_log + kv + inverted index + graph)
208    let bincode_unit = bincode::serde::encode_to_vec(&unit, bincode::config::standard())
209        .map_err(|e| EngineError::Internal(e.to_string()))?;
210    let bincode_links =
211        bincode::serde::encode_to_vec(&staged_output.created_links, bincode::config::standard())
212            .map_err(|e| EngineError::Internal(e.to_string()))?;
213
214    hippmem_store::kv::persist_memory_unit(
215        engine.store.db_arc(),
216        memory_id.0,
217        &bincode_unit,
218        &bincode_links,
219        &keys.entity_keys,
220        &keys.topic_keys,
221        &keys.temporal_keys,
222        &keys.goal_keys,
223        &keys.event_keys,
224        &keys.causal_keys,
225        skip_memory_log,
226    )
227    .map_err(|e| EngineError::Store(e.to_string()))?;
228
229    // Write to the Tantivy fulltext index (for BM25 channel recall)
230    // commit is auto-batched by FulltextIndex internally based on commit_every
231    {
232        let tokens = hippmem_core::hash::tokenize(&input.content, "zh");
233        let mut ft = engine.fulltext_index.lock();
234        ft.add_document_tokenized(memory_id.0, &tokens)
235            .map_err(|e| EngineError::Store(format!("Tantivy add_document: {}", e)))?;
236    }
237
238    // link_overlay was already written in persist_memory_unit within the single transaction
239
240    // Mark strong semantic dimensions as deferred
241    warnings.push(WriteWarning::StrongDimsDeferred);
242
243    // Synchronous enrich: complete strong semantic dimensions
244    let mut enriched_unit = unit.clone();
245    crate::runtime::run_enrich_sync(&mut enriched_unit);
246
247    // enriched→index closure: write the newly produced goal/event/causal keys from enrich into the inverted index
248    index_enriched_keys(&enriched_unit, &inverted, memory_id.0)
249        .map_err(|e| EngineError::Internal(format!("index_enriched_keys: {}", e)))?;
250
251    let re_bincode = bincode::serde::encode_to_vec(&enriched_unit, bincode::config::standard())
252        .map_err(|e| EngineError::Internal(e.to_string()))?;
253    // After enrich, only update memory_kv (do not rewrite memory_log/link_overlay/inverted index)
254    hippmem_store::kv::KvStore::new(engine.store.db_arc())
255        .put(memory_id.0, &re_bincode)
256        .map_err(|e| EngineError::Store(e.to_string()))?;
257
258    Ok(WriteMemoryOutput {
259        memory_id,
260        stage_reached: MemoryStage::Indexed,
261        created_links: staged_output.created_links,
262        understanding,
263        warnings,
264    })
265}
266
267// ── Helpers ──
268
269/// Converts binary_code [u64;2] to 16 bytes (Little Endian) for BinaryCodeIndex Hamming search.
270fn binary_code_to_bytes(bc: &[u64; 2]) -> [u8; 16] {
271    let mut bytes = [0u8; 16];
272    bytes[..8].copy_from_slice(&bc[0].to_le_bytes());
273    bytes[8..].copy_from_slice(&bc[1].to_le_bytes());
274    bytes
275}
276
277fn build_semantic_signature(text: &str) -> SemanticSignature {
278    let sim0 = stable_hash64(text);
279    let sim1 = stable_hash64(&format!("{}_1", text));
280    let sim2 = stable_hash64(&format!("{}_2", text));
281    let sim3 = stable_hash64(&format!("{}_3", text));
282    let bc0 = stable_hash64(&format!("bc_0_{}", text));
283    let bc1 = stable_hash64(&format!("bc_1_{}", text));
284
285    let mut minhash = [0u32; 16];
286    for (i, v) in minhash.iter_mut().enumerate() {
287        *v = stable_hash64(&format!("mh_{}_{}", i, text)) as u32;
288    }
289
290    SemanticSignature {
291        lexical_simhash: [sim0, sim1, sim2, sim3],
292        dense_embedding_ref: None,
293        binary_code: [bc0, bc1],
294        topic_minhash: minhash,
295    }
296}
297
298fn discover_candidates(
299    keys: &hippmem_core::model::links::AssociationKeys,
300    inverted: &InvertedIndex,
301) -> Vec<MemoryId> {
302    let mut ids = std::collections::HashSet::new();
303    for ek in &keys.entity_keys {
304        if let Ok(hits) = inverted.get_entity(ek) {
305            for id in hits {
306                ids.insert(MemoryId(id));
307            }
308        }
309    }
310    for tk in &keys.topic_keys {
311        if let Ok(hits) = inverted.get_topic(tk) {
312            for id in hits {
313                ids.insert(MemoryId(id));
314            }
315        }
316    }
317    for tk in &keys.temporal_keys {
318        if let Ok(hits) = inverted.get_temporal(tk) {
319            for id in hits {
320                ids.insert(MemoryId(id));
321            }
322        }
323    }
324    ids.into_iter().collect()
325}
326
327fn load_memory_units(db: &std::sync::Arc<redb::Database>, ids: &[MemoryId]) -> Vec<MemoryUnit> {
328    let kv = hippmem_store::kv::KvStore::new(std::sync::Arc::clone(db));
329    ids.iter()
330        .filter_map(|mid| {
331            kv.get(&mid.0).ok().flatten().and_then(|data| {
332                bincode::serde::decode_from_slice::<MemoryUnit, _>(
333                    &data,
334                    bincode::config::standard(),
335                )
336                .ok()
337                .map(|(unit, _)| unit)
338            })
339        })
340        .collect()
341}