lunaris_retrieve/planner.rs
1//! Query planner stub — RETRIEVE-13.
2//!
3//! v0 ships a heuristic that inspects the query text for entity-like
4//! capitalized tokens and returns `Plan::Hybrid` (vector + keyword) when
5//! present, `Plan::VectorOnly` otherwise. Phase 3 will extend with
6//! `Plan::HybridGraph` once the graph extractor lands.
7//!
8//! ## Heuristic
9//!
10//! Walk whitespace-delimited tokens; a token is "entity-like" if it starts with
11//! an uppercase ASCII letter AND it is NOT the first token of the query
12//! (sentence-initial capitalization is not a signal). When at least one such
13//! token is found, return `Plan::Hybrid`. Otherwise `Plan::VectorOnly`.
14//!
15//! v0 caveat: this is an English-only heuristic. Multilingual / case-less
16//! scripts (Chinese, Japanese, Korean) always pick `VectorOnly` — Phase 3
17//! replaces this with the full graph-anchored extractor.
18
19use serde::{Deserialize, Serialize};
20
21/// Retrieval plan picked by the v0 query planner.
22///
23/// `VectorOnly` and `Hybrid` are the only v0 variants; Phase 3 will extend
24/// with `HybridGraph` once the graph extractor is wired.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Plan {
27 /// Vector search only — sufficient for purely semantic queries with no
28 /// named entities.
29 VectorOnly,
30 /// Vector + keyword (BM25) hybrid — appropriate when the query mentions
31 /// entity-like capitalized tokens that BM25 can match exactly.
32 Hybrid,
33}
34
35/// Run the v0 planner heuristic.
36pub fn plan_query(text: &str) -> Plan {
37 if has_entity_like_capitalized_token(text) { Plan::Hybrid } else { Plan::VectorOnly }
38}
39
40/// Walk whitespace-delimited tokens; return `true` when at least one
41/// non-first token starts with an uppercase ASCII letter.
42fn has_entity_like_capitalized_token(text: &str) -> bool {
43 text.split_whitespace().enumerate().any(|(i, tok)| {
44 if i == 0 {
45 return false;
46 }
47 // strip leading punctuation that isn't a letter (e.g., "(Alice")
48 let first = tok.chars().find(|c| c.is_ascii_alphanumeric());
49 matches!(first, Some(c) if c.is_ascii_uppercase())
50 })
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 #[test]
58 fn picks_hybrid_for_capitalized_entity_token() {
59 assert_eq!(plan_query("what did Alice do at Acme last April?"), Plan::Hybrid);
60 }
61
62 #[test]
63 fn picks_vector_only_for_lowercase_phrase() {
64 assert_eq!(plan_query("show me everything"), Plan::VectorOnly);
65 assert_eq!(plan_query("how can i find this thing"), Plan::VectorOnly);
66 }
67
68 #[test]
69 fn first_word_capitalization_is_not_a_signal() {
70 // "What" is sentence-initial — should NOT trigger hybrid.
71 assert_eq!(plan_query("What is going on"), Plan::VectorOnly);
72 }
73
74 #[test]
75 fn punctuation_prefix_is_skipped() {
76 // "(Alice" — leading paren skipped, "A" still triggers.
77 assert_eq!(plan_query("hello (Alice) how are you"), Plan::Hybrid);
78 }
79
80 #[test]
81 fn empty_query_is_vector_only() {
82 assert_eq!(plan_query(""), Plan::VectorOnly);
83 }
84
85 /// Pins the documented v0 limitation (see the book's recall-anatomy
86 /// "CJK and other case-less scripts" note): the heuristic is
87 /// English-only, so CJK queries — even ones naming a proper entity —
88 /// can never produce `Plan::Hybrid` and the BM25 leg is never planned.
89 ///
90 /// This test is a tripwire, not an endorsement: when the Phase-3
91 /// graph-anchored planner (or any CJK-aware heuristic) lands, it SHOULD
92 /// fail — update it together with the book note.
93 #[test]
94 fn cjk_query_always_plans_vector_only() {
95 // zh: "What did Alice do at Beijing's Tsinghua University?"
96 assert_eq!(plan_query("爱丽丝在北京清华大学做了什么?"), Plan::VectorOnly);
97 // ja: "Where does Tanaka-san work at Toyota?" (whitespace-separated)
98 assert_eq!(plan_query("田中さん は トヨタ で どこで 働いていますか"), Plan::VectorOnly);
99 // ko: "What did Samsung announce in Seoul?"
100 assert_eq!(plan_query("삼성이 서울에서 무엇을 발표했나요?"), Plan::VectorOnly);
101 }
102}