Skip to main content

lunaris_retrieve/operators/
modifiers.rs

1//! `top(n)` modifier + the v0 string-DSL `filter_str` parser.
2//!
3//! `filter` and `as_of` modifiers don't need their own retriever wrappers —
4//! they set fields on the `Query` carried by [`super::QueryContext`]. Those
5//! setters live on [`crate::builder::RetrievalBuilder`]. Only `top` needs a
6//! retriever wrapper because it operates on the post-tree result list.
7//!
8//! ## `filter_str` v0 grammar
9//!
10//! Supported (T-02-02-06 mitigation — anything else returns `Err`):
11//!
12//! - `field = 'value'` → `Filter::Eq { field, value: Value::String(value) }`
13//! - `field LIKE 'prefix%'` → `Filter::StartsWith { field, prefix }`
14//!   (the `%` MUST be the last character — no embedded `%` allowed)
15//!
16//! NOT supported in v0 (parser returns `Err`):
17//! - Boolean composition: callers using complex predicates construct
18//!   `Filter::And` / `Filter::Or` programmatically.
19//! - Glob `%` in the middle of a pattern (e.g., `'foo%bar%'`).
20//! - LIKE without a trailing `%` (we expose a single semantic: prefix match).
21
22use std::any::Any;
23
24use async_trait::async_trait;
25use lunaris_core::LunarisError;
26use lunaris_core::storage::types::Filter;
27use thiserror::Error;
28
29use super::{QueryContext, Retriever};
30use crate::types::RawHit;
31
32/// Cap the result list to `n` hits after the upstream operator resolves.
33///
34/// Sort by descending score before truncating so callers always get the
35/// top-N by score (regardless of how the upstream returned them).
36pub struct TopRetriever {
37    pub(crate) inner: Box<dyn Retriever>,
38    pub(crate) n: usize,
39}
40
41impl TopRetriever {
42    pub fn new(inner: Box<dyn Retriever>, n: usize) -> Self {
43        Self { inner, n }
44    }
45}
46
47#[async_trait]
48impl Retriever for TopRetriever {
49    async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
50        let mut hits = self.inner.retrieve(ctx).await?;
51        hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
52        hits.truncate(self.n);
53        Ok(hits)
54    }
55
56    fn as_any(&self) -> &dyn Any {
57        self
58    }
59}
60
61/// Chunk-superset floored top-k selection (pure function; the policy behind
62/// [`FlooredTopRetriever`]).
63///
64/// LME N=125 A/B diagnosis (2026-07-29, graph-ON 83.2% vs graph-OFF 88.0%):
65/// with a plain `top(n)` cut, reranked fact/Navigate hits that outrank
66/// mid-value evidence chunks EVICT them from the final context (q251: the
67/// date-bearing chunk that was graph-OFF's `hit[1]` at 0.513 vanished from
68/// graph-ON's entire list). Because the chunk legs and cross-encoder scores
69/// are identical across arms, reserving `floor_n` slots for floor-leg hits
70/// makes the graph-ON chunk context a strict superset of graph-OFF's — extra
71/// legs can only ADD context, never displace it.
72///
73/// Selection contract:
74/// - Output is at most `n` hits, in descending score order.
75/// - At least `min(floor_n, n, available_floor_hits)` of them match the
76///   floor: metadata `"index"` equals `floor_index`, or the hit carries NO
77///   `"index"` tag at all (untagged hits predate per-leg tagging — they come
78///   from chunk-era legs, so they fail open to the floor rather than being
79///   silently demoted to headroom candidates).
80/// - The remaining slots are filled by global score order regardless of leg.
81/// - `hits.len() <= n` degenerates to a plain sort (nothing to displace).
82pub fn floored_top(
83    mut hits: Vec<RawHit>,
84    n: usize,
85    floor_index: &str,
86    floor_n: usize,
87) -> Vec<RawHit> {
88    hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
89    if hits.len() <= n {
90        return hits;
91    }
92    let is_floor = |h: &RawHit| match h.metadata.get("index") {
93        Some(serde_json::Value::String(ix)) => ix == floor_index,
94        _ => true,
95    };
96    let mut selected = vec![false; hits.len()];
97    let mut taken = 0usize;
98    let floor_target = floor_n.min(n);
99    for (i, h) in hits.iter().enumerate() {
100        if taken == floor_target {
101            break;
102        }
103        if is_floor(h) {
104            selected[i] = true;
105            taken += 1;
106        }
107    }
108    for flag in selected.iter_mut() {
109        if taken == n {
110            break;
111        }
112        if !*flag {
113            *flag = true;
114            taken += 1;
115        }
116    }
117    hits.into_iter().zip(selected).filter_map(|(h, keep)| keep.then_some(h)).collect()
118}
119
120/// [`TopRetriever`] with a reserved per-leg floor — see [`floored_top`] for
121/// the selection contract and the displacement regression it closes.
122///
123/// GA-1 scope note (2026-08-17): this operator is a **bench/DSL-level
124/// primitive only** — its sole consumer is the LongMemEval harness
125/// (`lunaris-bench`). It is deliberately NOT part of the unified production
126/// root (`crate::composition::production_root`); wiring it into a
127/// production surface is a future, separately-validated decision.
128pub struct FlooredTopRetriever {
129    inner: Box<dyn Retriever>,
130    n: usize,
131    floor_index: String,
132    floor_n: usize,
133}
134
135impl FlooredTopRetriever {
136    pub fn new(inner: Box<dyn Retriever>, n: usize, floor_index: &str, floor_n: usize) -> Self {
137        Self { inner, n, floor_index: floor_index.to_owned(), floor_n }
138    }
139
140    pub fn n(&self) -> usize {
141        self.n
142    }
143
144    pub fn floor_index(&self) -> &str {
145        &self.floor_index
146    }
147
148    pub fn floor_n(&self) -> usize {
149        self.floor_n
150    }
151}
152
153#[async_trait]
154impl Retriever for FlooredTopRetriever {
155    async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
156        let hits = self.inner.retrieve(ctx).await?;
157        Ok(floored_top(hits, self.n, &self.floor_index, self.floor_n))
158    }
159
160    fn as_any(&self) -> &dyn Any {
161        self
162    }
163}
164
165// --------------------------------------------------------------------- filter_str
166
167/// Errors from the v0 [`filter_str`] parser. Returned at builder time so
168/// callers see the failure before any backend IO.
169#[derive(Debug, Error)]
170pub enum FilterParseError {
171    #[error(
172        "filter_str: unsupported syntax — v0 supports only `field = 'value'` and `field LIKE 'prefix%'`"
173    )]
174    Unsupported,
175    #[error("filter_str: missing closing quote")]
176    UnclosedQuote,
177    #[error("filter_str: LIKE pattern must end with `%` and not contain other `%`")]
178    UnsupportedGlob,
179}
180
181/// Parse the v0 string DSL into a [`Filter`] AST.
182///
183/// See module rustdoc for the supported grammar.
184pub fn filter_str(s: &str) -> Result<Filter, FilterParseError> {
185    let s = s.trim();
186    if s.is_empty() {
187        return Err(FilterParseError::Unsupported);
188    }
189
190    // Try `field LIKE '...'` first (longer keyword).
191    if let Some((field, rest)) = split_keyword_ci(s, " LIKE ") {
192        let pattern = read_quoted(rest)?;
193        // Must end with `%` and contain NO other `%`.
194        let body = pattern.strip_suffix('%').ok_or(FilterParseError::UnsupportedGlob)?;
195        if body.contains('%') {
196            return Err(FilterParseError::UnsupportedGlob);
197        }
198        return Ok(Filter::StartsWith {
199            field: field.trim().to_string(),
200            prefix: body.to_string(),
201        });
202    }
203
204    // Try `field = '...'` next.
205    if let Some((field, rest)) = split_keyword_ci(s, " = ") {
206        let value = read_quoted(rest)?;
207        return Ok(Filter::Eq {
208            field: field.trim().to_string(),
209            value: serde_json::Value::String(value.to_string()),
210        });
211    }
212
213    Err(FilterParseError::Unsupported)
214}
215
216/// Split `s` on the first case-sensitive occurrence of `sep` (with surrounding
217/// whitespace). Returns `(left, right)` where `right` is past `sep`.
218fn split_keyword_ci<'a>(s: &'a str, sep: &str) -> Option<(&'a str, &'a str)> {
219    s.find(sep).map(|idx| (&s[..idx], &s[idx + sep.len()..]))
220}
221
222/// Strip a single-quoted string literal from the head of `s`.
223fn read_quoted(s: &str) -> Result<&str, FilterParseError> {
224    let s = s.trim_start();
225    let s = s.strip_prefix('\'').ok_or(FilterParseError::Unsupported)?;
226    let close = s.find('\'').ok_or(FilterParseError::UnclosedQuote)?;
227    Ok(&s[..close])
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn filter_str_parses_starts_with() {
236        // v0 grammar: pattern MUST end with `%`. The leading body
237        // becomes the StartsWith prefix.
238        let f = filter_str("source LIKE 'helios:fs/%'").unwrap();
239        match f {
240            Filter::StartsWith { field, prefix } => {
241                assert_eq!(field, "source");
242                assert_eq!(prefix, "helios:fs/");
243            }
244            other => panic!("unexpected: {other:?}"),
245        }
246    }
247
248    #[test]
249    fn filter_str_parses_eq_string() {
250        let f = filter_str("source = 'notes.md'").unwrap();
251        match f {
252            Filter::Eq { field, value } => {
253                assert_eq!(field, "source");
254                assert_eq!(value, serde_json::Value::String("notes.md".into()));
255            }
256            other => panic!("unexpected: {other:?}"),
257        }
258    }
259
260    #[test]
261    fn filter_str_rejects_unsupported_glob() {
262        let r = filter_str("source LIKE 'helios:fs/%.md'");
263        assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
264    }
265
266    #[test]
267    fn filter_str_rejects_like_without_trailing_percent() {
268        // 'helios:fs/' has NO trailing % → UnsupportedGlob.
269        let r = filter_str("source LIKE 'helios:fs/'");
270        assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
271        // Same for a bare word.
272        let r = filter_str("source LIKE 'helios'");
273        assert!(matches!(r, Err(FilterParseError::UnsupportedGlob)));
274    }
275
276    #[test]
277    fn filter_str_rejects_unclosed_quote() {
278        let r = filter_str("source = 'oops");
279        assert!(matches!(r, Err(FilterParseError::UnclosedQuote)));
280    }
281
282    #[test]
283    fn filter_str_rejects_garbage() {
284        assert!(filter_str("").is_err());
285        assert!(filter_str("DELETE FROM chunks").is_err());
286        assert!(filter_str("source").is_err());
287    }
288}