Skip to main content

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}