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