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        {
364            let act_log = ActivationLogger::new(self.store.db_arc());
365            let used_ids: Vec<u64> = results.iter().map(|r| r.memory.id.0 as u64).collect();
366            let now_ms =
367                if let Ok(t) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
368                    t.as_millis() as i64
369                } else {
370                    0
371                };
372            let _ = act_log.record(&hippmem_store::activation_log::ActivationRecord {
373                retrieval_id: now_ms as u64,
374                used_memory_ids: used_ids,
375                signal: "retrieve".into(),
376                recorded_at_ms: now_ms,
377            });
378        }
379
380        Ok(RetrieveOutput {
381            results,
382            trace: crate::RetrievalTrace {
383                seeds: seed_result
384                    .seeds
385                    .iter()
386                    .map(|s| crate::SeedRecord {
387                        id: s.id,
388                        channel: s.channel,
389                        initial_energy: s.score,
390                        rank_in_channel: s.rank_in_channel,
391                    })
392                    .collect(),
393                steps: activated
394                    .iter()
395                    .flat_map(|(_, _, trace)| trace.clone())
396                    .collect(),
397                hops_used: 0,
398                merged_count: 0,
399            },
400            diagnostics: crate::RetrievalDiagnostics {
401                channel_contributions,
402                reranked: true,
403                pruned_branches: 0,
404                backend_used: crate::BackendUsage {
405                    embedder: self.embedder.backend_id().to_string(),
406                    reranker: Some("rule".into()),
407                },
408                latency_ms: 0,
409            },
410        })
411    }
412}
413
414// ── Helpers ──
415
416// ── Question-type aware boost (§4.5) ──
417
418/// Question type: detected from the query text, used to activate answer-pattern boosts.
419#[derive(Debug, Clone, Copy, PartialEq)]
420enum QuestionType {
421    /// Why-type queries: expects causal/explanatory answers
422    Why,
423    /// How-type queries: expects process/method answers
424    How,
425    /// What-type queries: expects factual/enumeration answers
426    What,
427    /// Correction/change queries: expects Correction-type memories
428    Correction,
429    /// Preference queries: expects Preference-type memories
430    Preference,
431    /// No clear question type detected
432    None,
433}
434
435/// Detects the question type from the query text using locale-parametrized patterns.
436///
437/// Patterns for each locale are tried in order (zh first, then en fallback).
438/// Within each locale, priority is Correction > Preference > Why > How > What.
439/// The first matching pattern wins.
440fn detect_question_type(query: &str) -> QuestionType {
441    let q = query.to_lowercase();
442
443    // Special case: change_pair signals a change/correction in any locale
444    for lang in active_locales() {
445        if let Some((before, after)) = lang.change_pair {
446            if q.contains(before) && q.contains(after) {
447                return QuestionType::Correction;
448            }
449        }
450    }
451
452    // Try each locale's patterns. Priority order preserved from active_locales().
453    // Chinese first (higher specificity for CJK queries), then English as a broad fallback.
454    // Within each priority category, zh patterns are checked before en.
455    for lang in active_locales() {
456        for keyword in lang.q_correction {
457            if q.contains(keyword) {
458                return QuestionType::Correction;
459            }
460        }
461    }
462    for lang in active_locales() {
463        for keyword in lang.q_preference {
464            if q.contains(keyword) {
465                return QuestionType::Preference;
466            }
467        }
468    }
469    for lang in active_locales() {
470        for keyword in lang.q_why {
471            if q.contains(keyword) {
472                return QuestionType::Why;
473            }
474        }
475    }
476    for lang in active_locales() {
477        for keyword in lang.q_how {
478            if q.contains(keyword) {
479                return QuestionType::How;
480            }
481        }
482    }
483    for lang in active_locales() {
484        for keyword in lang.q_what {
485            if q.contains(keyword) {
486                return QuestionType::What;
487            }
488        }
489    }
490    QuestionType::None
491}
492
493/// Detects the strength of explanatory patterns in the text (range [0, 0.20]).
494fn explanatory_pattern_score(text: &str) -> f32 {
495    let mut score = 0.0f32;
496    for lang in active_locales() {
497        for (pattern, boost) in lang.explanatory {
498            if text.contains(pattern) {
499                score += boost;
500            }
501        }
502    }
503    score.min(0.20) // Hard cap, prevents boost from over-dominating ranking
504}
505
506/// Returns a per-ContentType boost map based on the detected query intent.
507///
508/// Core idea: embedding cannot distinguish "decision" from "correction of a decision",
509/// nor "preference" from "identity description"; but ContentType is a strong signal fixed
510/// at write time. By detecting intent keywords in the query, a moderate energy boost is
511/// applied to memories of the matching ContentType, compensating for the granularity gap
512/// of pure semantic channels.
513///
514/// Boost cap 0.12, ensures the boost only flips borderline cases (#2→#1) without dominating ranking.
515fn content_type_boost(query: &str) -> Vec<(hippmem_core::model::unit::ContentType, f32)> {
516    let qt = detect_question_type(query);
517    let mut boosts = Vec::new();
518
519    match qt {
520        QuestionType::Correction => {
521            // Correction queries: Correction memory +0.12; can pull it back even if embedding ranks it behind the decision
522            boosts.push((hippmem_core::model::unit::ContentType::Correction, 0.12));
523        }
524        QuestionType::Preference => {
525            // Preference queries: Preference memory +0.08, enough to distinguish "prefers PostgreSQL" from "the project uses redb"
526            boosts.push((hippmem_core::model::unit::ContentType::Preference, 0.08));
527            // Decisions are often preference-related (+0.04)
528            boosts.push((hippmem_core::model::unit::ContentType::Decision, 0.04));
529        }
530        QuestionType::Why => {
531            // Causal: Decision and TaskState often explain the reason
532            boosts.push((hippmem_core::model::unit::ContentType::Decision, 0.08));
533            boosts.push((hippmem_core::model::unit::ContentType::TaskState, 0.08));
534        }
535        QuestionType::How => {
536            // Method: TaskState (contains process descriptions such as fix/resolve verbs)
537            boosts.push((hippmem_core::model::unit::ContentType::TaskState, 0.08));
538        }
539        QuestionType::What => {
540            // What-type ("what is") queries prefer project knowledge.
541            // V9 precision weight (rrf_w_topic=0.3) lowers the Topic channel contribution; definition memories need moderate compensation.
542            // Boost value 0.15: enough to flip adjacent weak differences, but not enough to let a RRF-bottom ProjectKnowledge
543            // overtake a strongly-matching memory of another type (e.g. the correct Decision answer for a "what is the license" query).
544            // The second stage also adds the precondition "query subject must appear in memory content" to further suppress false positives.
545            boosts.push((
546                hippmem_core::model::unit::ContentType::ProjectKnowledge,
547                0.15,
548            ));
549        }
550        QuestionType::None => {
551            // No question type detected: no per-type boost, rely on semantic channels
552        }
553    }
554
555    // Generic correction-keyword detection (even if the main intent is not Correction, give Correction a boost when correction words are present)
556    if qt != QuestionType::Correction {
557        let q = query.to_lowercase();
558        let has_correction_signal = active_locales().iter().any(|lang| {
559            lang.q_correction.iter().any(|kw| q.contains(kw))
560                || lang
561                    .change_pair
562                    .is_some_and(|(b, a)| q.contains(b) && q.contains(a))
563        });
564        if has_correction_signal {
565            boosts.push((hippmem_core::model::unit::ContentType::Correction, 0.10));
566        }
567    }
568
569    boosts
570}
571
572/// Applies question-type aware boosts to the reranked candidate list.
573///
574/// Currently supports:
575/// - Why queries → documents with explanatory markers receive an `explanatory_pattern_score` boost
576/// - Correction queries → Correction ContentType receives a content-type boost
577/// - Preference queries → Preference ContentType receives a content-type boost
578/// - How/What queries → reserved extension points
579///
580/// After boosts, re-sorts by adjusted energy descending.
581fn apply_question_aware_boost(
582    query: &str,
583    reranked: &mut [(MemoryId, f32, Vec<ActivationStep>, MemoryUnit)],
584    params: &hippmem_core::config::AlgoParams,
585) {
586    let qt = detect_question_type(query);
587    let ct_boosts = content_type_boost(query);
588    let cap = params.seed_energy_cap;
589    // Subject of the What query (used as the content-match precondition for the stage-2 PK boost)
590    let what_subject: Option<String> = if qt == QuestionType::What {
591        extract_subject_for_what_query(query)
592    } else {
593        None
594    };
595
596    // Stage 1: question-type logic boost
597    match qt {
598        QuestionType::Why => {
599            for (_, energy, _, unit) in reranked.iter_mut() {
600                let boost = explanatory_pattern_score(&unit.content.raw);
601                if boost > 0.0 {
602                    *energy = (*energy + boost).min(cap);
603                }
604            }
605        }
606        QuestionType::Correction
607        | QuestionType::Preference
608        | QuestionType::How
609        | QuestionType::What
610        | QuestionType::None => {
611            // Content-type boost is applied uniformly in stage 2
612        }
613    }
614
615    // Stage 2: ContentType-aware boost (applies to all question types)
616    // For the What-query ProjectKnowledge boost, require the query subject to appear in the memory content,
617    // to prevent a what-is-the-license query from pushing an unrelated project-definition memory to the top (false positive).
618    if !ct_boosts.is_empty() {
619        for (_, energy, _, unit) in reranked.iter_mut() {
620            for (ct, boost) in &ct_boosts {
621                if unit.content.content_type != *ct {
622                    continue;
623                }
624                // What + ProjectKnowledge: subject-match precondition
625                if qt == QuestionType::What
626                    && *ct == hippmem_core::model::unit::ContentType::ProjectKnowledge
627                {
628                    if let Some(ref subject) = what_subject {
629                        let content_lower = unit.content.raw.to_lowercase();
630                        if !content_lower.contains(&subject.to_lowercase()) {
631                            break; // Subject not in content; no boost
632                        }
633                    }
634                }
635                *energy = (*energy + boost).min(cap);
636                break; // At most one type boost per memory
637            }
638        }
639    }
640
641    // Stage 3: rare-keyword overlap bonus (+0.04 per keyword per memory, cap +0.08)
642    // Extracts high-information words from the query (English abbreviations / proper nouns),
643    // and gives a small boost to memories containing them.
644    // Used to distinguish a query mentioning a specific term (e.g. "OOM") → the OOM memory,
645    // vs a query that merely describes fixing something without naming the term.
646    let keywords = extract_discriminative_keywords(query);
647    if !keywords.is_empty() {
648        for (_, energy, _, unit) in reranked.iter_mut() {
649            let mut kw_bonus = 0.0f32;
650            let content_lower = unit.content.raw.to_lowercase();
651            for kw in &keywords {
652                if content_lower.contains(&kw.to_lowercase()) {
653                    kw_bonus += 0.04;
654                }
655            }
656            if kw_bonus > 0.0 {
657                *energy = (*energy + kw_bonus.min(0.08)).min(cap);
658            }
659        }
660    }
661
662    // Stage 4: definition-pattern detection (a "what is X" query → prefer "X is ..." definitions)
663    // When the query is a what-is-X form, detect whether results contain definition patterns
664    // (subject followed by a copular/usage/based-on/adopts verb). Apply a moderate +0.05 boost
665    // to matching memories; not enough to dominate ranking but enough to flip adjacent results.
666    if qt == QuestionType::What {
667        if let Some(ref subject) = extract_subject_for_what_query(query) {
668            let subject_lower = subject.to_lowercase();
669            for (_, energy, _, unit) in reranked.iter_mut() {
670                let content_lower = unit.content.raw.to_lowercase();
671                let has_definition = active_locales().iter().any(|lang| {
672                    lang.definition_patterns
673                        .iter()
674                        .any(|pat| content_lower.contains(&format!("{} {pat}", subject_lower)))
675                });
676                if has_definition {
677                    *energy = (*energy + 0.05).min(cap);
678                }
679            }
680        }
681    }
682
683    // Re-sort by adjusted energy descending
684    reranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
685}
686
687/// Extracts the subject X from a "what is X" query (locale-driven).
688///
689/// Uses locale-specific what-delimiters (e.g., "是什么" for zh, "what is" for en)
690/// and possessive particles ("的" for zh, None for en).
691/// For the "A's B is what" form, takes the last segment "B" as the subject
692/// (stripping the qualifier "A's"), to avoid merging the qualifier into the subject
693/// and breaking later content matching.
694/// Returns None when no what-is pattern is detected or the subject is too short (< 2 chars).
695fn extract_subject_for_what_query(query: &str) -> Option<String> {
696    let q = query.to_lowercase();
697    for lang in active_locales() {
698        for delimiter in lang.what_delimiters {
699            if let Some(pos) = q.find(delimiter) {
700                let prefix = &q[..pos];
701                let subject = if let Some(particle) = lang.possessive_particle {
702                    // First split on the possessive marker and take the last segment
703                    // (strip qualifier), then split on whitespace/question mark and take the last segment
704                    prefix
705                        .rsplit(particle)
706                        .next()
707                        .unwrap_or("")
708                        .rsplit(|c: char| c.is_whitespace() || c == '?' || c == '?')
709                        .next()
710                        .unwrap_or("")
711                        .trim()
712                        .to_string()
713                } else {
714                    prefix
715                        .rsplit(|c: char| c.is_whitespace() || c == '?' || c == '?')
716                        .next()
717                        .unwrap_or("")
718                        .trim()
719                        .to_string()
720                };
721                if subject.len() >= 2 {
722                    return Some(subject);
723                }
724                return None;
725            }
726        }
727    }
728    None
729}
730
731/// Extracts high-information keywords (English abbreviations, technical terms, proper nouns) from the query.
732///
733/// Filters out common question words and stop words, keeping only discriminative tokens.
734/// Returns a deduplicated keyword list (max 5).
735///
736/// Stop words are multilingual: Chinese (zh) function words and question particles
737/// are filtered alongside English equivalents so that CJK queries yield meaningful keywords.
738fn extract_discriminative_keywords(query: &str) -> Vec<String> {
739    // Multilingual stop words: collected from all active locales
740    let stop_words: Vec<&str> = active_locales()
741        .iter()
742        .flat_map(|lang| lang.stop_words.iter().copied())
743        .collect();
744
745    let mut keywords: Vec<String> = Vec::new();
746    let mut seen = std::collections::HashSet::new();
747
748    // 1. Extract English abbreviations/words (all-caps or camelCase, e.g. OOM/HNSW/BM25/redb/gRPC)
749    for word in query.split(|c: char| !c.is_alphanumeric()) {
750        let is_keyword = (word.len() >= 2 && word.chars().any(|c| c.is_uppercase()))
751            || (word.chars().all(|c| c.is_ascii_alphabetic()) && word.len() >= 3);
752        if is_keyword
753            && !stop_words.contains(&word.to_lowercase().as_str())
754            && seen.insert(word.to_string())
755        {
756            keywords.push(word.to_string());
757        }
758    }
759
760    // 2. Extract Chinese keywords (>=2 chars, not stop words, not question words)
761    for word in query
762        .split(|c: char| c.is_whitespace() || c.is_ascii_punctuation() || c == '?' || c == '?')
763    {
764        let trimmed = word.trim();
765        if trimmed.chars().count() >= 2
766            && trimmed.chars().all(|c| c as u32 > 0x2E80) // CJK range
767            && !stop_words.contains(&trimmed)
768            && seen.insert(trimmed.to_string())
769        {
770            keywords.push(trimmed.to_string());
771        }
772    }
773
774    keywords.truncate(5); // At most 5 keywords
775    keywords
776}
777
778/// Generates the 16 bytes of binary_code for the query text ([u64;2]→LE), isomorphic to write_api::build_semantic_signature.
779fn query_binary_code(text: &str) -> [u8; 16] {
780    let bc0 = stable_hash64(&format!("bc_0_{}", text));
781    let bc1 = stable_hash64(&format!("bc_1_{}", text));
782    let mut bytes = [0u8; 16];
783    bytes[..8].copy_from_slice(&bc0.to_le_bytes());
784    bytes[8..].copy_from_slice(&bc1.to_le_bytes());
785    bytes
786}
787
788/// Generates temporal bucket keys (hour/day/week) for the current time, consistent with write time.
789fn temporal_bucket_keys(ts: hippmem_core::time::Timestamp) -> Vec<u32> {
790    let ms = ts.0;
791    vec![
792        (ms / 3_600_000) as u32,   // Hour bucket
793        (ms / 86_400_000) as u32,  // Day bucket
794        (ms / 604_800_000) as u32, // Week bucket
795    ]
796}
797
798pub(crate) fn load_all_units(db: std::sync::Arc<redb::Database>) -> Vec<MemoryUnit> {
799    use redb::ReadableDatabase;
800    use redb::ReadableTable;
801    let mut units = Vec::new();
802    let read_txn = db.begin_read().expect("read transaction should succeed");
803    let table = read_txn
804        .open_table(hippmem_store::store::MEMORY_KV)
805        .expect("memory_kv table should exist");
806    let iter = table.iter().expect("iter should succeed");
807    for entry in iter.flatten() {
808        let (_key, value) = entry;
809        if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
810            value.value(),
811            bincode::config::standard(),
812        ) {
813            units.push(unit);
814        }
815    }
816    units
817}
818
819/// Batch-loads MemoryUnit entries from the MEMORY_KV table by an ID list (single transaction).
820fn load_units_by_ids(db: std::sync::Arc<redb::Database>, ids: &[MemoryId]) -> Vec<MemoryUnit> {
821    if ids.is_empty() {
822        return vec![];
823    }
824    use redb::ReadableDatabase;
825    let mut units = Vec::new();
826    let read_txn = db.begin_read().expect("read transaction should succeed");
827    let table = read_txn
828        .open_table(hippmem_store::store::MEMORY_KV)
829        .expect("memory_kv table should exist");
830    for id in ids {
831        if let Some(value) = table.get(id.0).expect("get should succeed") {
832            if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
833                value.value(),
834                bincode::config::standard(),
835            ) {
836                units.push(unit);
837            }
838        }
839    }
840    units
841}
842
843/// Extracts goal keywords from the query text (deterministic rules, locale-driven).
844fn extract_query_goals(text: &str) -> Vec<String> {
845    let mut goals = Vec::new();
846    for lang in active_locales() {
847        for m in lang.goal_markers {
848            if text.contains(m) {
849                goals.push(format!("goal_marker:{m}"));
850            }
851        }
852    }
853    goals
854}
855
856/// Extracts event keywords from the query text (deterministic rules, locale-driven).
857fn extract_query_events(text: &str) -> Vec<String> {
858    let mut events = Vec::new();
859    for lang in active_locales() {
860        for m in lang.event_markers {
861            if text.contains(m) {
862                events.push(format!("event_marker:{m}"));
863            }
864        }
865    }
866    events
867}
868
869/// Loads at most `limit` memories from the MEMORY_KV table (for fallback, not a full scan).
870fn load_limited_units(db: std::sync::Arc<redb::Database>, limit: usize) -> Vec<MemoryUnit> {
871    use redb::ReadableDatabase;
872    use redb::ReadableTable;
873    let mut units = Vec::new();
874    let read_txn = db.begin_read().expect("read transaction should succeed");
875    let table = read_txn
876        .open_table(hippmem_store::store::MEMORY_KV)
877        .expect("memory_kv table should exist");
878    let iter = table.iter().expect("iter should succeed");
879    for entry in iter.flatten().take(limit) {
880        let (_key, value) = entry;
881        if let Ok((unit, _)) = bincode::serde::decode_from_slice::<MemoryUnit, _>(
882            value.value(),
883            bincode::config::standard(),
884        ) {
885            units.push(unit);
886        }
887    }
888    units
889}