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