Skip to main content

hippmem_engine/
retrieve_api.rs

1//! Engine::retrieve — retrieval API assembly.
2//!
3//! Corresponds to 05#retrieve, 09 §4.2. Wires seed recall→energy→spreading→rerank→warnings→explain.
4
5use crate::signals::is_positive_signal;
6use crate::{Engine, EngineResult, RetrieveInput, RetrieveOutput};
7use hippmem_core::hash::stable_hash64;
8use hippmem_core::ids::MemoryId;
9use hippmem_core::model::links::{ActivationStep, RecallChannel, RetrievalResult};
10use hippmem_core::model::unit::{GeneratedBy, MemoryLifecycle, MemoryUnit};
11use hippmem_core::time::Clock;
12use hippmem_model::deterministic::extract::DeterministicExtractor;
13use hippmem_model::lang::active_locales;
14use hippmem_retrieval::explain::deduce_dimensions;
15use hippmem_retrieval::seeds::{multi_channel_seeds, rrf_fuse};
16use hippmem_retrieval::spreading::spread_multi_hop_fused;
17use hippmem_retrieval::warnings::check_warnings;
18use hippmem_store::activation_log::ActivationLogger;
19use hippmem_store::kv::InvertedIndex;
20use hippmem_store::semantic::vector_index::BinaryIndex;
21use hippmem_store::semantic::vector_index::VectorIndex;
22use std::collections::HashMap;
23
24impl Engine {
25    /// Retrieves memories: multi-channel seeds→activation energy→spreading→rerank→warnings.
26    pub fn retrieve(&self, input: RetrieveInput) -> EngineResult<RetrieveOutput> {
27        let start = std::time::Instant::now();
28        let params = self.params.read();
29
30        // 1. Lightweight understanding of the query (extract entities/topics for index lookup)
31        let extractor = DeterministicExtractor;
32        let query_content = hippmem_core::model::unit::MemoryContent {
33            raw: input.query.clone(),
34            summary: None,
35            normalized: None,
36            language: hippmem_core::model::unit::Language::Zh,
37            content_type: hippmem_core::model::enums::ContentType::UserStatement,
38        };
39        let understanding = extractor
40            .extract_sync_immediate(&query_content)
41            .unwrap_or_else(|_| hippmem_model::traits::ImmediateExtraction {
42                entities: vec![],
43                topics: vec![],
44                explicit_causals: vec![],
45                language: hippmem_core::model::unit::Language::Zh,
46                content_type: None,
47                importance: hippmem_core::score::UnitScore::new(0.0),
48            });
49
50        // 2. Multi-channel seed recall: query candidate IDs from the store index
51        let inverted = InvertedIndex::new(self.store.db_arc());
52
53        // 2a. Entity: from query entities → entity_index
54        let entity_hits: Vec<(MemoryId, f32)> = understanding
55            .entities
56            .iter()
57            .filter_map(|em| {
58                let key = hippmem_core::hash::stable_hash64(&em.canonical);
59                inverted.get_entity(&key).ok().map(|ids| {
60                    ids.into_iter()
61                        .map(|id| (MemoryId(id), 0.2f32))
62                        .collect::<Vec<_>>()
63                })
64            })
65            .flatten()
66            .collect();
67
68        // 2b. Topic: from query topics → topic_index
69        let topic_hits: Vec<(MemoryId, f32)> = understanding
70            .topics
71            .iter()
72            .filter_map(|t| {
73                let key = hippmem_core::hash::stable_hash64(&t.label);
74                inverted.get_topic(&key).ok().map(|ids| {
75                    ids.into_iter()
76                        .map(|id| (MemoryId(id), 0.15f32))
77                        .collect::<Vec<_>>()
78                })
79            })
80            .flatten()
81            .collect();
82
83        // 2c. Temporal: from current time bucket keys → temporal_index
84        let now = hippmem_core::time::SystemClock.now();
85        let temporal_keys = temporal_bucket_keys(now);
86        let mut temporal_hit_ids = std::collections::HashSet::new();
87        for tk in &temporal_keys {
88            if let Ok(ids) = inverted.get_temporal(tk) {
89                for id in ids {
90                    temporal_hit_ids.insert(MemoryId(id));
91                }
92            }
93        }
94        // Determinism: HashSet iteration order is randomized; sort by id so tied
95        // scores within the channel resolve to a fixed rank (RRF depends on rank).
96        let mut temporal_hit_vec: Vec<MemoryId> = temporal_hit_ids.into_iter().collect();
97        temporal_hit_vec.sort();
98        let temporal_hits: Vec<(MemoryId, bool)> =
99            temporal_hit_vec.into_iter().map(|id| (id, true)).collect();
100
101        // 2d. BM25: Tantivy fulltext search (03 §4.5), score normalized to [0,1] via tanh
102        let mut bm25_hits: Vec<(MemoryId, f32)> = self
103            .fulltext_index
104            .lock()
105            .search(&input.query, params.seed_per_channel as usize)
106            .unwrap_or_default()
107            .into_iter()
108            .map(|(id, score)| {
109                let norm = (score / params.bm25_norm_factor).tanh();
110                (MemoryId(id), norm)
111            })
112            .collect();
113        // Determinism: Tantivy's top-k order is unspecified for tied scores;
114        // sort by score descending (id as tie-break) so channel ranks are stable.
115        bm25_hits.sort_by(|(a_id, a_s), (b_id, b_s)| {
116            b_s.partial_cmp(a_s)
117                .unwrap_or(std::cmp::Ordering::Equal)
118                .then_with(|| a_id.cmp(b_id))
119        });
120
121        // 2e. SemanticDense: dense vector HNSW/FlatVectorIndex recall (03 §4.5)
122        let semantic_hits: Vec<(MemoryId, f32)> = {
123            let query_texts = vec![input.query.clone()];
124            self.embedder
125                .embed_sync(&query_texts)
126                .ok()
127                .and_then(|vectors| vectors.first().cloned())
128                .map(|query_vec| {
129                    let idx = self.dense_vector_index.lock();
130                    idx.search(&query_vec, params.seed_per_channel as usize)
131                        .unwrap_or_default()
132                        .into_iter()
133                        .map(|(id, l2_dist)| {
134                            // L2 distance → cosine similarity: 1/(1+l2_dist), distance 0 → similarity 1
135                            let cos_sim = 1.0 / (1.0 + l2_dist);
136                            (MemoryId(id), cos_sim)
137                        })
138                        .filter(|(_, sim)| *sim > 0.0)
139                        .collect()
140                })
141                .unwrap_or_default()
142        };
143
144        // 2f. SemanticBinary: binary_code Hamming distance recall (03 §4.5)
145        let binary_hits: Vec<(MemoryId, f32)> = {
146            let query_bc = query_binary_code(&input.query);
147            let idx = self.binary_code_index.lock();
148            idx.search(&query_bc, params.seed_per_channel as usize)
149                .unwrap_or_default()
150                .into_iter()
151                .map(|(id, hamming)| {
152                    let sim = 1.0 - (hamming as f32 / 128.0);
153                    (MemoryId(id), sim.max(0.0))
154                })
155                .filter(|(_, sim)| *sim > 0.0)
156                .collect()
157        };
158
159        // 2g. Goal: from query goal keywords → goal_index (03 §4.5)
160        let query_goals = extract_query_goals(&input.query);
161        let goal_hits: Vec<(MemoryId, usize)> = query_goals
162            .iter()
163            .filter_map(|goal| {
164                let key = stable_hash64(goal);
165                inverted.get_goal(&key).ok().map(|ids| {
166                    ids.into_iter()
167                        .map(|id| (MemoryId(id), 1))
168                        .collect::<Vec<_>>()
169                })
170            })
171            .flatten()
172            .collect();
173
174        // 2h. Event: from query event keywords → event_index (03 §4.5)
175        let query_events = extract_query_events(&input.query);
176        let event_hits: Vec<(MemoryId, usize)> = query_events
177            .iter()
178            .filter_map(|event| {
179                let key = stable_hash64(event);
180                inverted.get_event(&key).ok().map(|ids| {
181                    ids.into_iter()
182                        .map(|id| (MemoryId(id), 1))
183                        .collect::<Vec<_>>()
184                })
185            })
186            .flatten()
187            .collect();
188
189        // 2i. Causal: from query explicit causals → causal_index (03 §4.5)
190        let causal_hits: Vec<(MemoryId, usize)> = understanding
191            .explicit_causals
192            .iter()
193            .filter_map(|c| {
194                let causal_str = format!("{} -> {}", c.cause, c.effect);
195                let key = stable_hash64(&causal_str);
196                inverted.get_causal(&key).ok().map(|ids| {
197                    ids.into_iter()
198                        .map(|id| (MemoryId(id), 1))
199                        .collect::<Vec<_>>()
200                })
201            })
202            .flatten()
203            .collect();
204
205        // 2j. RecentActivation: recent_memory_ids graph neighbors + activation_log (03 §4.5)
206        let recent_hits: Vec<(MemoryId, f32)> = {
207            let mut recent_map: HashMap<MemoryId, f32> = HashMap::new();
208
209            // Take directly from recent_memory_ids (each +0.3 base score)
210            for mid in &input.context.recent_memory_ids {
211                recent_map
212                    .entry(*mid)
213                    .and_modify(|s| *s = (*s + 0.3).min(1.0))
214                    .or_insert(0.3);
215            }
216
217            // Supplement with graph neighbors of recent_memory_ids (neighbor +0.15)
218            let graph = hippmem_store::graph::GraphStore::new(self.store.db_arc());
219            for mid in &input.context.recent_memory_ids {
220                if let Ok(links) = graph.get_outgoing(mid) {
221                    for link in links.iter().take(8) {
222                        recent_map
223                            .entry(link.target_id)
224                            .and_modify(|s| *s = (*s + 0.15).min(1.0))
225                            .or_insert(0.15);
226                    }
227                }
228            }
229
230            // Take recently frequent memories from activation_log
231            let act_log = ActivationLogger::new(self.store.db_arc());
232            if let Ok(records) = act_log.read_all() {
233                let mut freq: HashMap<MemoryId, u32> = HashMap::new();
234                for rec in records.iter() {
235                    // 0.3.0: UserRejected 不参与正向计数(05 §6,拒绝不得强化)
236                    // B2 (0.4.0): "retrieve" is no longer positive either —
237                    // only confirmed signals boost recency.
238                    if !is_positive_signal(&rec.signal) {
239                        continue;
240                    }
241                    for &mid in &rec.used_memory_ids {
242                        // P1 回归:used_memory_ids 是完整 u128(MemoryId 原值),禁止截断
243                        *freq.entry(MemoryId(mid)).or_default() += 1;
244                    }
245                }
246                let max_freq = freq.values().max().copied().unwrap_or(1) as f32;
247                for (mid, count) in freq {
248                    let score = (count as f32 / max_freq) * 0.25;
249                    recent_map
250                        .entry(mid)
251                        .and_modify(|s| *s = (*s + score).min(1.0))
252                        .or_insert(score);
253                }
254
255                // B4 (0.4.0) §4.2: result-set rejects suppress the recent
256                // channel. A reject with empty used_memory_ids means "the
257                // whole result set was wrong" (trap questions, noisy stores);
258                // those memories must not be lifted by recency at all.
259                let result_set_rejected: std::collections::HashSet<MemoryId> = records
260                    .iter()
261                    .filter(|r| r.signal == "UserRejected" && r.used_memory_ids.is_empty())
262                    .filter_map(|r| {
263                        records
264                            .iter()
265                            .find(|p| p.retrieval_id == r.retrieval_id && p.signal == "retrieve")
266                    })
267                    .flat_map(|p| p.used_memory_ids.iter().map(|id| MemoryId(*id)))
268                    .collect();
269                if !result_set_rejected.is_empty() {
270                    recent_map.retain(|id, _| !result_set_rejected.contains(id));
271                }
272            }
273
274            let mut hits: Vec<(MemoryId, f32)> = recent_map.into_iter().collect();
275            // Determinism: stable sort keeps tied scores in iteration order; sort by
276            // id first so tied scores resolve to a fixed rank (RRF depends on rank).
277            hits.sort_by_key(|(id, _)| *id);
278            hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
279            hits.truncate(params.seed_per_channel as usize);
280            hits
281        };
282
283        let seed_result = multi_channel_seeds(
284            &input.query,
285            &entity_hits,
286            &temporal_hits,
287            &semantic_hits,
288            &topic_hits,
289            &bm25_hits,
290            &binary_hits,
291            &goal_hits,
292            &event_hits,
293            &causal_hits,
294            &recent_hits,
295            params.seed_per_channel as usize,
296        );
297
298        // 3. RRF rank fusion (V9): multi-channel seeds → fuse into a single score per MemoryId
299        let mut fused_scores: HashMap<MemoryId, (f32, RecallChannel)> =
300            if seed_result.seeds.is_empty() {
301                // Fallback: no channel hits; take a few memories as RecentActivation seeds
302                // F3/B1 (0.4.0): exclude compressed sources AND summaries.
303                let fallback = load_limited_units(self.store.db_arc(), 50)
304                    .into_iter()
305                    .filter(|u| !is_retrieval_seed_excluded(u))
306                    .collect::<Vec<_>>();
307                fallback
308                    .into_iter()
309                    .map(|u| (u.id, (0.3_f32, RecallChannel::RecentActivation)))
310                    .collect()
311            } else {
312                rrf_fuse(&seed_result.seeds, &params)
313            };
314
315        // 4. Load on demand: seed units + seed outgoing edges + neighbor prefetch (supports 2-hop)
316        let mut unit_map: HashMap<MemoryId, MemoryUnit> = HashMap::new();
317        for unit in load_units_by_ids(
318            self.store.db_arc(),
319            &fused_scores.keys().cloned().collect::<Vec<_>>(),
320        ) {
321            unit_map.insert(unit.id, unit);
322        }
323        // F3/B1 (0.4.0): exclude compressed sources and summaries from the seed
324        // set, not just from the final results (7c). Compressed sources must not
325        // start a traversal (they would inject energy into their summary);
326        // summaries must not hit the direct channels at all (they crowd out
327        // concrete memories). Consistent with the fallback path above.
328        fused_scores.retain(|id, _| !unit_map.get(id).is_some_and(is_retrieval_seed_excluded));
329        let seed_ids: Vec<MemoryId> = fused_scores.keys().cloned().collect();
330
331        // 4a. Build importance map from the loaded seed units
332        let importance_map: HashMap<MemoryId, f32> = unit_map
333            .iter()
334            .map(|(id, unit)| (*id, unit.understanding.importance.value()))
335            .collect();
336        // 4a2. usage_map:feedback 驱动的 usage_score(0.5 = 中性)
337        let usage_map: HashMap<MemoryId, f32> = unit_map
338            .iter()
339            .map(|(id, unit)| (*id, unit.activation.usage_score.value()))
340            .collect();
341
342        let graph = hippmem_store::graph::GraphStore::new(self.store.db_arc());
343        let mut links_map: HashMap<MemoryId, Vec<hippmem_core::model::links::AssociationLink>> =
344            HashMap::new();
345
346        // Round 1: seed outgoing edges
347        for sid in &seed_ids {
348            if let Ok(links) = graph.get_outgoing(sid) {
349                links_map.insert(*sid, links);
350            }
351        }
352
353        // Round 2: prefetch outgoing edges of direct neighbors (GraphStore), and load their MemoryUnit (for rerank)
354        let neighbor_ids: Vec<MemoryId> = links_map
355            .values()
356            .flatten()
357            .map(|l| l.target_id)
358            .filter(|tid| !links_map.contains_key(tid))
359            .collect();
360        // Load neighbor units first: their lifecycle decides whether they may expand.
361        for unit in load_units_by_ids(self.store.db_arc(), &neighbor_ids) {
362            unit_map.entry(unit.id).or_insert(unit);
363        }
364        // F3 (0.3.1): a compressed neighbor is a dead end — load no outgoing edges
365        // for it, so it cannot propagate energy onward (it is still reachable as a
366        // result candidate and removed by the 7c filter).
367        for nid in &neighbor_ids {
368            // F3/B1: compressed sources and summaries are dead ends — load no
369            // outgoing edges, so they cannot propagate energy onward.
370            if unit_map.get(nid).is_some_and(is_retrieval_seed_excluded) {
371                continue;
372            }
373            if let Ok(links) = graph.get_outgoing(nid) {
374                links_map.insert(*nid, links);
375            }
376        }
377
378        // 5. Spreading activation (P2 回归:max_hops 必须透传;merged_count 如实报告)
379        let (activated, merged_count) = spread_multi_hop_fused(
380            &fused_scores,
381            &links_map,
382            &params,
383            &importance_map,
384            &usage_map,
385            input.max_hops.map(|h| h as u32),
386        );
387        let max_k = input.top_k.min(activated.len());
388
389        // 6. Load additional nodes discovered by spreading (for rerank)
390        let extra_ids: Vec<MemoryId> = activated
391            .iter()
392            .map(|(id, _, _)| *id)
393            .filter(|id| !unit_map.contains_key(id))
394            .collect();
395        for unit in load_units_by_ids(self.store.db_arc(), &extra_ids) {
396            unit_map.insert(unit.id, unit);
397        }
398
399        // 7. Rerank: requires the MemoryUnit of all activated nodes
400        let loaded_units: Vec<MemoryUnit> = activated
401            .iter()
402            .filter_map(|(id, _, _)| unit_map.get(id).cloned())
403            .collect();
404        let mut reranked = hippmem_retrieval::rerank::rerank_by_energy(&activated, &loaded_units);
405
406        // 7b. Question-type aware boost: detect the question type of the query, and apply a moderate score boost to matching answer patterns.
407        //     Compensates for the deterministic embedder's inability, under a bag-of-tokens mechanism, to capture the "why"↔"because" semantic relation.
408        apply_question_aware_boost(&input.query, &mut reranked, &params);
409
410        // 7c. 0.3.0: 过滤已压缩(Compressed)单元 —— 检索默认返回摘要,源记忆不直接命中(03 §8)
411        reranked.retain(|(_, _, _, unit)| {
412            !matches!(unit.lifecycle, MemoryLifecycle::Compressed { .. })
413        });
414
415        // 8. Build results
416        let results: Vec<RetrievalResult> = reranked
417            .iter()
418            .take(max_k)
419            .map(|(_id, energy, trace, unit)| {
420                let matched = deduce_dimensions(trace);
421                let warns = check_warnings(unit, *energy);
422                RetrievalResult {
423                    memory: unit.clone(),
424                    final_score: *energy,
425                    activation_trace: trace.clone(),
426                    matched_dimensions: matched,
427                    warnings: warns,
428                }
429            })
430            .collect();
431
432        // 9. Channel contributions
433        // F3 (0.3.1): report only the seeds actually used for spreading —
434        // compressed sources were dropped from fused_scores and must not be
435        // counted here either.
436        let channel_contributions: Vec<(RecallChannel, u32)> = {
437            let mut map: HashMap<RecallChannel, u32> = HashMap::new();
438            for seed in seed_result
439                .seeds
440                .iter()
441                .filter(|s| !unit_map.get(&s.id).is_some_and(is_retrieval_seed_excluded))
442            {
443                *map.entry(seed.channel).or_default() += 1;
444            }
445            map.into_iter().collect()
446        };
447
448        // 10. Record activation log (for the RecentActivation channel and Hebbian)
449        //     and surface the retrieval_id to the caller for feedback.
450        let retrieval_id = {
451            let act_log = ActivationLogger::new(self.store.db_arc());
452            // P1 回归:记录完整 u128 id(截断会导致 RecentActivation/Hebbian 指向幽灵 id)
453            let used_ids: Vec<u128> = results.iter().map(|r| r.memory.id.0).collect();
454            let now_ms =
455                if let Ok(t) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
456                    t.as_millis() as i64
457                } else {
458                    0
459                };
460            let _ = act_log.record(&hippmem_store::activation_log::ActivationRecord {
461                retrieval_id: now_ms as u64,
462                used_memory_ids: used_ids,
463                signal: "retrieve".into(),
464                recorded_at_ms: now_ms,
465            });
466            now_ms as u64
467        };
468
469        // 10b. E7 (0.4.0): update ActivationState of the returned memories —
470        // retrieval_count/last_retrieved_at/co_activations (03 §6: "每次检索后
471        // 由检索侧累加"). Bounded co-activation list (co_activation_keep);
472        // usage_score is owned by feedback and not touched here.
473        {
474            let kv = hippmem_store::kv::KvStore::new(self.store.db_arc());
475            let now_ts = hippmem_core::time::Timestamp::from_millis(retrieval_id as i64);
476            let max_co = params.co_activation_keep as usize;
477            for r in &results {
478                let raw = kv
479                    .get(&r.memory.id.0)
480                    .map_err(|e| crate::EngineError::Store(e.to_string()))?;
481                let Some(raw) = raw else { continue };
482                let (mut unit, _): (MemoryUnit, _) =
483                    bincode::serde::decode_from_slice(&raw, bincode::config::standard())
484                        .map_err(|e| crate::EngineError::Internal(e.to_string()))?;
485                unit.activation.retrieval_count = unit.activation.retrieval_count.saturating_add(1);
486                unit.activation.last_retrieved_at = Some(now_ts);
487                // Co-activation with the other memories of this result set.
488                let mut co: Vec<hippmem_core::model::links::CoActivationCount> =
489                    unit.activation.co_activations.clone();
490                for other in &results {
491                    if other.memory.id == r.memory.id {
492                        continue;
493                    }
494                    if let Some(existing) = co.iter_mut().find(|c| c.with == other.memory.id) {
495                        existing.count = existing.count.saturating_add(1);
496                        existing.last_at = now_ts;
497                    } else {
498                        co.push(hippmem_core::model::links::CoActivationCount {
499                            with: other.memory.id,
500                            count: 1,
501                            last_at: now_ts,
502                        });
503                    }
504                }
505                // Bound the list, dropping the least recently co-activated.
506                if co.len() > max_co {
507                    co.sort_by_key(|c| std::cmp::Reverse(c.last_at));
508                    co.truncate(max_co);
509                }
510                unit.activation.co_activations = co;
511                let encoded = bincode::serde::encode_to_vec(&unit, bincode::config::standard())
512                    .map_err(|e| crate::EngineError::Internal(e.to_string()))?;
513                kv.put(r.memory.id.0, &encoded)
514                    .map_err(|e| crate::EngineError::Store(e.to_string()))?;
515            }
516        }
517
518        Ok(RetrieveOutput {
519            retrieval_id,
520            results,
521            trace: crate::RetrievalTrace {
522                seeds: seed_result
523                    .seeds
524                    .iter()
525                    // F3/B1 (0.4.0): mirror the actual seed set used for spreading.
526                    .filter(|s| !unit_map.get(&s.id).is_some_and(is_retrieval_seed_excluded))
527                    .map(|s| crate::SeedRecord {
528                        id: s.id,
529                        channel: s.channel,
530                        initial_energy: s.score,
531                        rank_in_channel: s.rank_in_channel,
532                    })
533                    .collect(),
534                steps: activated
535                    .iter()
536                    .flat_map(|(_, _, trace)| trace.clone())
537                    .collect(),
538                hops_used: activated
539                    .iter()
540                    .flat_map(|(_, _, trace)| trace.iter())
541                    .map(|s| s.hop)
542                    .max()
543                    .unwrap_or(0),
544                merged_count,
545            },
546            diagnostics: crate::RetrievalDiagnostics {
547                channel_contributions,
548                reranked: true,
549                pruned_branches: 0,
550                backend_used: crate::BackendUsage {
551                    embedder: self.embedder.backend_id().to_string(),
552                    reranker: Some("rule".into()),
553                },
554                latency_ms: start.elapsed().as_millis() as u32,
555            },
556        })
557    }
558}
559
560// ── Helpers ──
561
562// ── Question-type aware boost (§4.5) ──
563
564/// Question type: detected from the query text, used to activate answer-pattern boosts.
565#[derive(Debug, Clone, Copy, PartialEq)]
566enum QuestionType {
567    /// Why-type queries: expects causal/explanatory answers
568    Why,
569    /// How-type queries: expects process/method answers
570    How,
571    /// What-type queries: expects factual/enumeration answers
572    What,
573    /// Correction/change queries: expects Correction-type memories
574    Correction,
575    /// Preference queries: expects Preference-type memories
576    Preference,
577    /// No clear question type detected
578    None,
579}
580
581/// Detects the question type from the query text using locale-parametrized patterns.
582///
583/// Patterns for each locale are tried in order (zh first, then en fallback).
584/// Within each locale, priority is Correction > Preference > Why > How > What.
585/// The first matching pattern wins.
586fn detect_question_type(query: &str) -> QuestionType {
587    let q = query.to_lowercase();
588
589    // Special case: change_pair signals a change/correction in any locale
590    for lang in active_locales() {
591        if let Some((before, after)) = lang.change_pair {
592            if q.contains(before) && q.contains(after) {
593                return QuestionType::Correction;
594            }
595        }
596    }
597
598    // Try each locale's patterns. Priority order preserved from active_locales().
599    // Chinese first (higher specificity for CJK queries), then English as a broad fallback.
600    // Within each priority category, zh patterns are checked before en.
601    for lang in active_locales() {
602        for keyword in lang.q_correction {
603            if q.contains(keyword) {
604                return QuestionType::Correction;
605            }
606        }
607    }
608    for lang in active_locales() {
609        for keyword in lang.q_preference {
610            if q.contains(keyword) {
611                return QuestionType::Preference;
612            }
613        }
614    }
615    for lang in active_locales() {
616        for keyword in lang.q_why {
617            if q.contains(keyword) {
618                return QuestionType::Why;
619            }
620        }
621    }
622    for lang in active_locales() {
623        for keyword in lang.q_how {
624            if q.contains(keyword) {
625                return QuestionType::How;
626            }
627        }
628    }
629    for lang in active_locales() {
630        for keyword in lang.q_what {
631            if q.contains(keyword) {
632                return QuestionType::What;
633            }
634        }
635    }
636    QuestionType::None
637}
638
639/// Detects the strength of explanatory patterns in the text (range [0, 0.20]).
640fn explanatory_pattern_score(text: &str) -> f32 {
641    let mut score = 0.0f32;
642    for lang in active_locales() {
643        for (pattern, boost) in lang.explanatory {
644            if text.contains(pattern) {
645                score += boost;
646            }
647        }
648    }
649    score.min(0.20) // Hard cap, prevents boost from over-dominating ranking
650}
651
652/// Returns a per-ContentType boost map based on the detected query intent.
653///
654/// Core idea: embedding cannot distinguish "decision" from "correction of a decision",
655/// nor "preference" from "identity description"; but ContentType is a strong signal fixed
656/// at write time. By detecting intent keywords in the query, a moderate energy boost is
657/// applied to memories of the matching ContentType, compensating for the granularity gap
658/// of pure semantic channels.
659///
660/// Boost cap 0.12, ensures the boost only flips borderline cases (#2→#1) without dominating ranking.
661fn content_type_boost(query: &str) -> Vec<(hippmem_core::model::unit::ContentType, f32)> {
662    let qt = detect_question_type(query);
663    let mut boosts = Vec::new();
664
665    match qt {
666        QuestionType::Correction => {
667            // Correction queries: Correction memory +0.12; can pull it back even if embedding ranks it behind the decision
668            boosts.push((hippmem_core::model::unit::ContentType::Correction, 0.12));
669        }
670        QuestionType::Preference => {
671            // Preference queries: Preference memory +0.08, enough to distinguish "prefers PostgreSQL" from "the project uses redb"
672            boosts.push((hippmem_core::model::unit::ContentType::Preference, 0.08));
673            // Decisions are often preference-related (+0.04)
674            boosts.push((hippmem_core::model::unit::ContentType::Decision, 0.04));
675        }
676        QuestionType::Why => {
677            // Causal: Decision and TaskState often explain the reason
678            boosts.push((hippmem_core::model::unit::ContentType::Decision, 0.08));
679            boosts.push((hippmem_core::model::unit::ContentType::TaskState, 0.08));
680        }
681        QuestionType::How => {
682            // Method: TaskState (contains process descriptions such as fix/resolve verbs)
683            boosts.push((hippmem_core::model::unit::ContentType::TaskState, 0.08));
684        }
685        QuestionType::What => {
686            // What-type ("what is") queries prefer project knowledge.
687            // V9 precision weight (rrf_w_topic=0.3) lowers the Topic channel contribution; definition memories need moderate compensation.
688            // Boost value 0.15: enough to flip adjacent weak differences, but not enough to let a RRF-bottom ProjectKnowledge
689            // overtake a strongly-matching memory of another type (e.g. the correct Decision answer for a "what is the license" query).
690            // The second stage also adds the precondition "query subject must appear in memory content" to further suppress false positives.
691            boosts.push((
692                hippmem_core::model::unit::ContentType::ProjectKnowledge,
693                0.15,
694            ));
695        }
696        QuestionType::None => {
697            // No question type detected: no per-type boost, rely on semantic channels
698        }
699    }
700
701    // Generic correction-keyword detection (even if the main intent is not Correction, give Correction a boost when correction words are present)
702    if qt != QuestionType::Correction {
703        let q = query.to_lowercase();
704        let has_correction_signal = active_locales().iter().any(|lang| {
705            lang.q_correction.iter().any(|kw| q.contains(kw))
706                || lang
707                    .change_pair
708                    .is_some_and(|(b, a)| q.contains(b) && q.contains(a))
709        });
710        if has_correction_signal {
711            boosts.push((hippmem_core::model::unit::ContentType::Correction, 0.10));
712        }
713    }
714
715    boosts
716}
717
718/// Applies question-type aware boosts to the reranked candidate list.
719///
720/// Currently supports:
721/// - Why queries → documents with explanatory markers receive an `explanatory_pattern_score` boost
722/// - Correction queries → Correction ContentType receives a content-type boost
723/// - Preference queries → Preference ContentType receives a content-type boost
724/// - How/What queries → reserved extension points
725///
726/// After boosts, re-sorts by adjusted energy descending.
727fn apply_question_aware_boost(
728    query: &str,
729    reranked: &mut [(MemoryId, f32, Vec<ActivationStep>, MemoryUnit)],
730    params: &hippmem_core::config::AlgoParams,
731) {
732    let qt = detect_question_type(query);
733    let ct_boosts = content_type_boost(query);
734    let cap = params.seed_energy_cap;
735    // Subject of the What query (used as the content-match precondition for the stage-2 PK boost)
736    let what_subject: Option<String> = if qt == QuestionType::What {
737        extract_subject_for_what_query(query)
738    } else {
739        None
740    };
741
742    // Stage 1: question-type logic boost
743    match qt {
744        QuestionType::Why => {
745            for (_, energy, _, unit) in reranked.iter_mut() {
746                let boost = explanatory_pattern_score(&unit.content.raw);
747                if boost > 0.0 {
748                    *energy = (*energy + boost).min(cap);
749                }
750            }
751        }
752        QuestionType::Correction
753        | QuestionType::Preference
754        | QuestionType::How
755        | QuestionType::What
756        | QuestionType::None => {
757            // Content-type boost is applied uniformly in stage 2
758        }
759    }
760
761    // Stage 2: ContentType-aware boost (applies to all question types)
762    // For the What-query ProjectKnowledge boost, require the query subject to appear in the memory content,
763    // to prevent a what-is-the-license query from pushing an unrelated project-definition memory to the top (false positive).
764    if !ct_boosts.is_empty() {
765        for (_, energy, _, unit) in reranked.iter_mut() {
766            for (ct, boost) in &ct_boosts {
767                if unit.content.content_type != *ct {
768                    continue;
769                }
770                // What + ProjectKnowledge: subject-match precondition
771                if qt == QuestionType::What
772                    && *ct == hippmem_core::model::unit::ContentType::ProjectKnowledge
773                {
774                    if let Some(ref subject) = what_subject {
775                        let content_lower = unit.content.raw.to_lowercase();
776                        if !content_lower.contains(&subject.to_lowercase()) {
777                            break; // Subject not in content; no boost
778                        }
779                    }
780                }
781                *energy = (*energy + boost).min(cap);
782                break; // At most one type boost per memory
783            }
784        }
785    }
786
787    // Stage 3: rare-keyword overlap bonus (+0.04 per keyword per memory, cap +0.08)
788    // Extracts high-information words from the query (English abbreviations / proper nouns),
789    // and gives a small boost to memories containing them.
790    // Used to distinguish a query mentioning a specific term (e.g. "OOM") → the OOM memory,
791    // vs a query that merely describes fixing something without naming the term.
792    let keywords = extract_discriminative_keywords(query);
793    if !keywords.is_empty() {
794        for (_, energy, _, unit) in reranked.iter_mut() {
795            let mut kw_bonus = 0.0f32;
796            let content_lower = unit.content.raw.to_lowercase();
797            for kw in &keywords {
798                if content_lower.contains(&kw.to_lowercase()) {
799                    kw_bonus += 0.04;
800                }
801            }
802            if kw_bonus > 0.0 {
803                *energy = (*energy + kw_bonus.min(0.08)).min(cap);
804            }
805        }
806    }
807
808    // Stage 4: definition-pattern detection (a "what is X" query → prefer "X is ..." definitions)
809    // When the query is a what-is-X form, detect whether results contain definition patterns
810    // (subject followed by a copular/usage/based-on/adopts verb). Apply a moderate +0.05 boost
811    // to matching memories; not enough to dominate ranking but enough to flip adjacent results.
812    if qt == QuestionType::What {
813        if let Some(ref subject) = extract_subject_for_what_query(query) {
814            let subject_lower = subject.to_lowercase();
815            for (_, energy, _, unit) in reranked.iter_mut() {
816                let content_lower = unit.content.raw.to_lowercase();
817                let has_definition = active_locales().iter().any(|lang| {
818                    lang.definition_patterns
819                        .iter()
820                        .any(|pat| content_lower.contains(&format!("{} {pat}", subject_lower)))
821                });
822                if has_definition {
823                    *energy = (*energy + 0.05).min(cap);
824                }
825            }
826        }
827    }
828
829    // Re-sort by adjusted energy descending
830    reranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
831}
832
833/// Extracts the subject X from a "what is X" query (locale-driven).
834///
835/// Uses locale-specific what-delimiters (e.g., "是什么" for zh, "what is" for en)
836/// and possessive particles ("的" for zh, None for en).
837/// For the "A's B is what" form, takes the last segment "B" as the subject
838/// (stripping the qualifier "A's"), to avoid merging the qualifier into the subject
839/// and breaking later content matching.
840/// Returns None when no what-is pattern is detected or the subject is too short (< 2 chars).
841fn extract_subject_for_what_query(query: &str) -> Option<String> {
842    let q = query.to_lowercase();
843    for lang in active_locales() {
844        for delimiter in lang.what_delimiters {
845            if let Some(pos) = q.find(delimiter) {
846                let prefix = &q[..pos];
847                let subject = if let Some(particle) = lang.possessive_particle {
848                    // First split on the possessive marker and take the last segment
849                    // (strip qualifier), then split on whitespace/question mark and take the last segment
850                    prefix
851                        .rsplit(particle)
852                        .next()
853                        .unwrap_or("")
854                        .rsplit(|c: char| c.is_whitespace() || c == '?' || c == '?')
855                        .next()
856                        .unwrap_or("")
857                        .trim()
858                        .to_string()
859                } else {
860                    prefix
861                        .rsplit(|c: char| c.is_whitespace() || c == '?' || c == '?')
862                        .next()
863                        .unwrap_or("")
864                        .trim()
865                        .to_string()
866                };
867                if subject.len() >= 2 {
868                    return Some(subject);
869                }
870                return None;
871            }
872        }
873    }
874    None
875}
876
877/// Extracts high-information keywords (English abbreviations, technical terms, proper nouns) from the query.
878///
879/// Filters out common question words and stop words, keeping only discriminative tokens.
880/// Returns a deduplicated keyword list (max 5).
881///
882/// Stop words are multilingual: Chinese (zh) function words and question particles
883/// are filtered alongside English equivalents so that CJK queries yield meaningful keywords.
884fn extract_discriminative_keywords(query: &str) -> Vec<String> {
885    // Multilingual stop words: collected from all active locales
886    let stop_words: Vec<&str> = active_locales()
887        .iter()
888        .flat_map(|lang| lang.stop_words.iter().copied())
889        .collect();
890
891    let mut keywords: Vec<String> = Vec::new();
892    let mut seen = std::collections::HashSet::new();
893
894    // 1. Extract English abbreviations/words (all-caps or camelCase, e.g. OOM/HNSW/BM25/redb/gRPC)
895    for word in query.split(|c: char| !c.is_alphanumeric()) {
896        let is_keyword = (word.len() >= 2 && word.chars().any(|c| c.is_uppercase()))
897            || (word.chars().all(|c| c.is_ascii_alphabetic()) && word.len() >= 3);
898        if is_keyword
899            && !stop_words.contains(&word.to_lowercase().as_str())
900            && seen.insert(word.to_string())
901        {
902            keywords.push(word.to_string());
903        }
904    }
905
906    // 2. Extract Chinese keywords (>=2 chars, not stop words, not question words)
907    for word in query
908        .split(|c: char| c.is_whitespace() || c.is_ascii_punctuation() || c == '?' || c == '?')
909    {
910        let trimmed = word.trim();
911        if trimmed.chars().count() >= 2
912            && trimmed.chars().all(|c| c as u32 > 0x2E80) // CJK range
913            && !stop_words.contains(&trimmed)
914            && seen.insert(trimmed.to_string())
915        {
916            keywords.push(trimmed.to_string());
917        }
918    }
919
920    keywords.truncate(5); // At most 5 keywords
921    keywords
922}
923
924/// Generates the 16 bytes of binary_code for the query text ([u64;2]→LE), isomorphic to write_api::build_semantic_signature.
925fn query_binary_code(text: &str) -> [u8; 16] {
926    let bc0 = stable_hash64(&format!("bc_0_{}", text));
927    let bc1 = stable_hash64(&format!("bc_1_{}", text));
928    let mut bytes = [0u8; 16];
929    bytes[..8].copy_from_slice(&bc0.to_le_bytes());
930    bytes[8..].copy_from_slice(&bc1.to_le_bytes());
931    bytes
932}
933
934/// Generates temporal bucket keys (hour/day/week) for the current time, consistent with write time.
935fn temporal_bucket_keys(ts: hippmem_core::time::Timestamp) -> Vec<u32> {
936    let ms = ts.0;
937    vec![
938        (ms / 3_600_000) as u32,   // Hour bucket
939        (ms / 86_400_000) as u32,  // Day bucket
940        (ms / 604_800_000) as u32, // Week bucket
941    ]
942}
943
944pub(crate) fn load_all_units(db: std::sync::Arc<redb::Database>) -> Vec<MemoryUnit> {
945    use redb::ReadableDatabase;
946    use redb::ReadableTable;
947    let mut units = Vec::new();
948    let read_txn = db.begin_read().expect("read transaction should succeed");
949    let table = read_txn
950        .open_table(hippmem_store::store::MEMORY_KV)
951        .expect("memory_kv table should exist");
952    let iter = table.iter().expect("iter should succeed");
953    for entry in iter.flatten() {
954        let (_key, value) = entry;
955        if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
956            value.value(),
957            bincode::config::standard(),
958        ) {
959            units.push(unit);
960        }
961    }
962    units
963}
964
965/// Batch-loads MemoryUnit entries from the MEMORY_KV table by an ID list (single transaction).
966fn load_units_by_ids(db: std::sync::Arc<redb::Database>, ids: &[MemoryId]) -> Vec<MemoryUnit> {
967    if ids.is_empty() {
968        return vec![];
969    }
970    use redb::ReadableDatabase;
971    let mut units = Vec::new();
972    let read_txn = db.begin_read().expect("read transaction should succeed");
973    let table = read_txn
974        .open_table(hippmem_store::store::MEMORY_KV)
975        .expect("memory_kv table should exist");
976    for id in ids {
977        if let Some(value) = table.get(id.0).expect("get should succeed") {
978            if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
979                value.value(),
980                bincode::config::standard(),
981            ) {
982                units.push(unit);
983            }
984        }
985    }
986    units
987}
988
989/// Extracts goal keywords from the query text (deterministic rules, locale-driven).
990fn extract_query_goals(text: &str) -> Vec<String> {
991    let mut goals = Vec::new();
992    for lang in active_locales() {
993        for m in lang.goal_markers {
994            if text.contains(m) {
995                goals.push(format!("goal_marker:{m}"));
996            }
997        }
998    }
999    goals
1000}
1001
1002/// Extracts event keywords from the query text (deterministic rules, locale-driven).
1003fn extract_query_events(text: &str) -> Vec<String> {
1004    let mut events = Vec::new();
1005    for lang in active_locales() {
1006        for m in lang.event_markers {
1007            if text.contains(m) {
1008                events.push(format!("event_marker:{m}"));
1009            }
1010        }
1011    }
1012    events
1013}
1014
1015/// Loads at most `limit` memories from the MEMORY_KV table (for fallback, not a full scan).
1016/// A unit that must not act as a retrieval seed (B1, 0.4.0):
1017/// - `Compressed`: summary sources (F3) — a compressed source must not start
1018///   a traversal, otherwise it injects energy into its summary via the
1019///   Elaboration edges;
1020/// - `GeneratedBy::Consolidation`: summaries themselves (B1) — a summary text
1021///   is a concatenation of its sources, so it matches every query about them
1022///   through the semantic/BM25/entity channels and crowds out the concrete
1023///   memories (2026-08-11 test report P1-2). Summaries stay reachable via
1024///   graph edges once an upward-rollout channel exists (planned with B5).
1025fn is_retrieval_seed_excluded(unit: &MemoryUnit) -> bool {
1026    matches!(unit.lifecycle, MemoryLifecycle::Compressed { .. })
1027        || unit.provenance.generated_by == GeneratedBy::Consolidation
1028}
1029
1030fn load_limited_units(db: std::sync::Arc<redb::Database>, limit: usize) -> Vec<MemoryUnit> {
1031    use redb::ReadableDatabase;
1032    use redb::ReadableTable;
1033    let mut units = Vec::new();
1034    let read_txn = db.begin_read().expect("read transaction should succeed");
1035    let table = read_txn
1036        .open_table(hippmem_store::store::MEMORY_KV)
1037        .expect("memory_kv table should exist");
1038    let iter = table.iter().expect("iter should succeed");
1039    for entry in iter.flatten().take(limit) {
1040        let (_key, value) = entry;
1041        if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
1042            value.value(),
1043            bincode::config::standard(),
1044        ) {
1045            units.push(unit);
1046        }
1047    }
1048    units
1049}