Skip to main content

reddb_server/runtime/
ask_pipeline.rs

1//! AskPipeline — issue #121.
2//!
3//! 4-stage funnel that turns a natural-language ASK question into a
4//! scoped, filtered candidate set the LLM call can synthesise an
5//! answer over. Each stage is a pure function so the pipeline reads
6//! top-to-bottom in `execute` and individual stages can be unit-tested
7//! in isolation:
8//!
9//! 1. **`extract_tokens`** — heuristic NER. Splits the question into
10//!    `keywords` (alphanumeric words) and `literals` (uppercase ID-like
11//!    tokens, e.g. `FDD-12313`). LLM-NER is reserved for slice #123.
12//! 2. **`match_schema`** — `SchemaVocabulary::lookup` per keyword;
13//!    the resulting collection set is intersected with the caller's
14//!    `EffectiveScope.visible_collections` so out-of-scope tables
15//!    never enter the candidate pool. Issue #119 pre-filter.
16//! 3. **`vector_search_scoped`** — best-effort embedding + similarity
17//!    over the candidate collections via `AuthorizedSearch`. When no
18//!    embedding API is available (most unit-test fixtures), the stage
19//!    short-circuits with an empty match list — Stage 4 still runs.
20//! 4. **`filter_values`** — applies literal tokens as exact filters
21//!    over candidate-collection columns, returning the rows that
22//!    actually mention each literal.
23//!
24//! Output: typed [`AskContext`] holding all four stage outputs plus
25//! per-stage timing. Empty token-set short-circuits with a structured
26//! error so callers don't pay for an LLM round-trip on a query that
27//! contained nothing addressable.
28//!
29//! The legacy `RedDBRuntime::search_context` is now a Stage-2-internal
30//! helper for the broad-recall fallback; ASK no longer reaches for it
31//! directly.
32
33use std::collections::{BTreeSet, HashMap, HashSet};
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::time::Instant;
36
37use tracing::{debug, info, info_span, warn};
38
39use super::ai::ner::{
40    AuthContext as NerAuthContext, HeuristicFallback, LlmNer, NerError, NerProvider, NER_CAPABILITY,
41};
42use super::statement_frame::{EffectiveScope, ReadFrame};
43use super::RedDBRuntime;
44use crate::api::{RedDBError, RedDBResult};
45use crate::application::SearchContextInput;
46use crate::storage::schema::Value;
47use crate::storage::unified::entity::{EntityData, EntityKind, UnifiedEntity};
48
49/// Default cap for Stage 4 row output. Override per-call via
50/// [`AskPipeline::execute_with_limit`].
51pub const DEFAULT_ROW_CAP: usize = 20;
52
53/// Token bag produced by Stage 1.
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct TokenSet {
56    /// Lowercase keyword tokens (regex `[A-Za-z][A-Za-z0-9_]+`,
57    /// length ≥ 2). Used by Stage 2 to probe `SchemaVocabulary`.
58    pub keywords: Vec<String>,
59    /// Literal-id tokens kept in their original case so Stage 4 can
60    /// run case-sensitive equality / substring filters. Matches
61    /// `[A-Z0-9-]{3,}` containing at least one digit OR `[0-9]{6,}`.
62    pub literals: Vec<String>,
63}
64
65impl TokenSet {
66    pub fn is_empty(&self) -> bool {
67        self.keywords.is_empty() && self.literals.is_empty()
68    }
69}
70
71/// Stage 2 output: collections likely to contain the answer.
72#[derive(Debug, Clone, Default)]
73pub struct CandidateCollections {
74    /// Collection names, sorted, deduplicated, intersected with the
75    /// caller's `EffectiveScope.visible_collections`.
76    pub collections: Vec<String>,
77    /// Columns hinted by `SchemaVocabulary` for each candidate
78    /// collection. Used by Stage 4 to scope the literal filter to
79    /// promising columns first.
80    pub columns_by_collection: HashMap<String, Vec<String>>,
81}
82
83/// One Stage 3 BM25 text hit from the context index.
84#[derive(Debug, Clone)]
85pub struct TextHit {
86    pub collection: String,
87    pub entity_id: u64,
88    pub score: f32,
89}
90
91/// One Stage 3 vector hit, kept thin so the pipeline doesn't pull
92/// the full `ScoredMatch` shape from `dsl::QueryResult` through.
93#[derive(Debug, Clone)]
94pub struct VectorHit {
95    pub collection: String,
96    pub entity_id: u64,
97    pub score: f32,
98}
99
100/// One graph traversal hit for the ASK graph retrieval bucket.
101#[derive(Debug, Clone)]
102pub struct GraphHit {
103    pub collection: String,
104    pub entity_id: u64,
105    pub score: f32,
106    pub depth: usize,
107    pub kind: GraphHitKind,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum GraphHitKind {
112    Node,
113    Edge,
114}
115
116/// Stage 4 output: rows that match a literal filter.
117#[derive(Debug, Clone)]
118pub struct FilteredRow {
119    pub collection: String,
120    pub entity: UnifiedEntity,
121    /// Literal token that matched this row.
122    pub matched_literal: String,
123    /// Column where the match was found, when known.
124    pub matched_column: Option<String>,
125}
126
127/// Per-stage timing in microseconds. Logged via tracing on every run.
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct StageTimings {
130    pub extract_us: u64,
131    pub schema_us: u64,
132    pub text_us: u64,
133    pub vector_us: u64,
134    pub graph_us: u64,
135    pub filter_us: u64,
136}
137
138/// Typed context handed back to the ASK caller. Carries all four
139/// stage outputs so the LLM-formatting helper (slice #122) can pick
140/// what it needs without re-running the funnel.
141#[derive(Debug, Clone)]
142pub struct AskContext {
143    pub question: String,
144    pub tokens: TokenSet,
145    pub candidates: CandidateCollections,
146    pub text_hits: Vec<TextHit>,
147    pub vector_hits: Vec<VectorHit>,
148    pub graph_hits: Vec<GraphHit>,
149    pub filtered_rows: Vec<FilteredRow>,
150    pub source_limit: usize,
151    pub timings: StageTimings,
152}
153
154impl Default for AskContext {
155    fn default() -> Self {
156        Self {
157            question: String::new(),
158            tokens: TokenSet::default(),
159            candidates: CandidateCollections::default(),
160            text_hits: Vec::new(),
161            vector_hits: Vec::new(),
162            graph_hits: Vec::new(),
163            filtered_rows: Vec::new(),
164            source_limit: DEFAULT_ROW_CAP,
165            timings: StageTimings::default(),
166        }
167    }
168}
169
170/// One fused source reference in the final ASK context order.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum FusedSourceRef {
173    FilteredRow(usize),
174    TextHit(usize),
175    VectorHit(usize),
176    GraphHit(usize),
177}
178
179/// One fused source reference plus its RRF score.
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct FusedSource {
182    pub source: FusedSourceRef,
183    pub rrf_score: f64,
184}
185
186/// Pipeline entry point. Instances are stateless — kept as an empty
187/// enum so callers spell `AskPipeline::execute(...)` (matches the
188/// shape #121 calls out).
189pub enum AskPipeline {}
190
191impl AskPipeline {
192    /// Run all four stages with the default row cap.
193    pub fn execute(
194        runtime: &RedDBRuntime,
195        scope: &EffectiveScope,
196        question: &str,
197    ) -> RedDBResult<AskContext> {
198        Self::execute_with_limit(runtime, scope, question, DEFAULT_ROW_CAP)
199    }
200
201    /// Run all four stages with a configurable Stage 4 row cap.
202    pub fn execute_with_limit(
203        runtime: &RedDBRuntime,
204        scope: &EffectiveScope,
205        question: &str,
206        row_cap: usize,
207    ) -> RedDBResult<AskContext> {
208        Self::execute_with_limit_and_min_score(runtime, scope, question, row_cap, None, None)
209    }
210
211    /// Run all four stages with a configurable row cap and per-bucket
212    /// minimum score for retrieval stages that expose native scores.
213    pub fn execute_with_limit_and_min_score(
214        runtime: &RedDBRuntime,
215        scope: &EffectiveScope,
216        question: &str,
217        row_cap: usize,
218        min_score: Option<f32>,
219        graph_depth: Option<usize>,
220    ) -> RedDBResult<AskContext> {
221        let span = info_span!(
222            "ask_pipeline.execute",
223            tenant = ?scope.effective_scope(),
224            question_len = question.len(),
225            row_cap = row_cap,
226            min_score = ?min_score,
227            graph_depth = ?graph_depth,
228        );
229        let _enter = span.enter();
230
231        // Stage 1. Routed through `extract_tokens_routed` so the
232        // `ai.ner.backend` config knob can swap the heuristic for the
233        // opt-in LLM NER without converting the surrounding pipeline
234        // to async (see `extract_tokens_routed` for the rationale).
235        let stage1 = Instant::now();
236        let tokens = extract_tokens_routed(runtime, scope, question)?;
237        let extract_us = stage1.elapsed().as_micros() as u64;
238        debug!(
239            target: "ask_pipeline",
240            stage = "extract_tokens",
241            keywords = ?tokens.keywords,
242            literals = ?tokens.literals,
243            elapsed_us = extract_us,
244            "stage 1 done"
245        );
246        if tokens.is_empty() {
247            warn!(
248                target: "ask_pipeline",
249                question_len = question.len(),
250                "refused: empty token set"
251            );
252            return Err(RedDBError::Query(
253                "ASK question yielded no usable tokens (heuristic NER produced empty keyword + literal set)"
254                    .to_string(),
255            ));
256        }
257
258        // Stage 2.
259        let stage2 = Instant::now();
260        let candidates = match_schema(runtime, scope, &tokens)?;
261        let schema_us = stage2.elapsed().as_micros() as u64;
262        debug!(
263            target: "ask_pipeline",
264            stage = "match_schema",
265            collections = ?candidates.collections,
266            elapsed_us = schema_us,
267            "stage 2 done"
268        );
269
270        // Stage 3.
271        let stage3 = Instant::now();
272        let text_hits = text_search_bm25_scoped(runtime, scope, question, &candidates, row_cap);
273        let text_us = stage3.elapsed().as_micros() as u64;
274        debug!(
275            target: "ask_pipeline",
276            stage = "text_search_bm25_scoped",
277            hits = text_hits.len(),
278            elapsed_us = text_us,
279            "stage 3 done"
280        );
281
282        // Stage 3b.
283        let stage3b = Instant::now();
284        let vector_hits =
285            vector_search_scoped(runtime, scope, question, &candidates, row_cap, min_score);
286        let vector_us = stage3b.elapsed().as_micros() as u64;
287        debug!(
288            target: "ask_pipeline",
289            stage = "vector_search_scoped",
290            hits = vector_hits.len(),
291            elapsed_us = vector_us,
292            "stage 3b done"
293        );
294
295        // Stage 3c.
296        let stage3c = Instant::now();
297        let graph_hits = graph_search_scoped(
298            runtime,
299            scope,
300            question,
301            &candidates,
302            row_cap,
303            min_score,
304            graph_depth,
305        );
306        let graph_us = stage3c.elapsed().as_micros() as u64;
307        debug!(
308            target: "ask_pipeline",
309            stage = "graph_search_scoped",
310            hits = graph_hits.len(),
311            elapsed_us = graph_us,
312            "stage 3c done"
313        );
314
315        // Stage 4.
316        let stage4 = Instant::now();
317        let filtered_rows = filter_values(runtime, scope, &candidates, &tokens, row_cap);
318        let filter_us = stage4.elapsed().as_micros() as u64;
319        debug!(
320            target: "ask_pipeline",
321            stage = "filter_values",
322            rows = filtered_rows.len(),
323            elapsed_us = filter_us,
324            "stage 4 done"
325        );
326
327        Ok(AskContext {
328            question: question.to_string(),
329            tokens,
330            candidates,
331            text_hits,
332            vector_hits,
333            graph_hits,
334            filtered_rows,
335            source_limit: row_cap,
336            timings: StageTimings {
337                extract_us,
338                schema_us,
339                text_us,
340                vector_us,
341                graph_us,
342                filter_us,
343            },
344        })
345    }
346}
347
348/// Fuse row-filter and vector buckets into the single ranked source
349/// order used by prompt rendering and `sources_flat`.
350pub fn fused_source_order(ctx: &AskContext) -> Vec<FusedSourceRef> {
351    fused_sources(ctx)
352        .into_iter()
353        .map(|fused| fused.source)
354        .collect()
355}
356
357/// Fuse row-filter and vector buckets into the single ranked source
358/// order with the RRF score preserved for `EXPLAIN ASK`.
359pub fn fused_sources(ctx: &AskContext) -> Vec<FusedSource> {
360    use super::ai::rrf_fuser::{fuse, Bucket, Candidate, RRF_K_DEFAULT};
361
362    if ctx.source_limit == 0
363        || (ctx.filtered_rows.is_empty()
364            && ctx.text_hits.is_empty()
365            && ctx.vector_hits.is_empty()
366            && ctx.graph_hits.is_empty())
367    {
368        return Vec::new();
369    }
370
371    let mut refs: HashMap<String, FusedSourceRef> = HashMap::new();
372    let row_bucket = Bucket {
373        candidates: ctx
374            .filtered_rows
375            .iter()
376            .enumerate()
377            .map(|(idx, row)| {
378                let id = source_identity(&row.collection, row.entity.id.raw());
379                refs.entry(id.clone())
380                    .or_insert(FusedSourceRef::FilteredRow(idx));
381                Candidate { id, score: 1.0 }
382            })
383            .collect(),
384        min_score: None,
385    };
386    let text_bucket = Bucket {
387        candidates: ctx
388            .text_hits
389            .iter()
390            .enumerate()
391            .map(|(idx, hit)| {
392                let id = source_identity(&hit.collection, hit.entity_id);
393                refs.entry(id.clone())
394                    .or_insert(FusedSourceRef::TextHit(idx));
395                Candidate {
396                    id,
397                    score: hit.score as f64,
398                }
399            })
400            .collect(),
401        min_score: None,
402    };
403    let vector_bucket = Bucket {
404        candidates: ctx
405            .vector_hits
406            .iter()
407            .enumerate()
408            .map(|(idx, hit)| {
409                let id = source_identity(&hit.collection, hit.entity_id);
410                refs.entry(id.clone())
411                    .or_insert(FusedSourceRef::VectorHit(idx));
412                Candidate {
413                    id,
414                    score: hit.score as f64,
415                }
416            })
417            .collect(),
418        min_score: None,
419    };
420    let graph_bucket = Bucket {
421        candidates: ctx
422            .graph_hits
423            .iter()
424            .enumerate()
425            .map(|(idx, hit)| {
426                let id = source_identity(&hit.collection, hit.entity_id);
427                refs.entry(id.clone())
428                    .or_insert(FusedSourceRef::GraphHit(idx));
429                Candidate {
430                    id,
431                    score: hit.score as f64,
432                }
433            })
434            .collect(),
435        min_score: None,
436    };
437
438    fuse(
439        &[row_bucket, text_bucket, vector_bucket, graph_bucket],
440        RRF_K_DEFAULT,
441        ctx.source_limit,
442    )
443    .into_iter()
444    .filter_map(|item| {
445        refs.get(&item.id).copied().map(|source| FusedSource {
446            source,
447            rrf_score: item.rrf_score,
448        })
449    })
450    .collect()
451}
452
453fn source_identity(collection: &str, entity_id: u64) -> String {
454    format!("{collection}/{entity_id}")
455}
456
457// ---------------------------------------------------------------------------
458// Stage 3 — BM25 text search scoped to the candidate collections.
459// ---------------------------------------------------------------------------
460
461/// Run the context index's sparse BM25 ranker over the candidate
462/// collections. This is the ASK text bucket used by RRF; literal row
463/// filtering remains as a separate high-precision bucket.
464pub fn text_search_bm25_scoped(
465    runtime: &RedDBRuntime,
466    scope: &EffectiveScope,
467    question: &str,
468    candidates: &CandidateCollections,
469    top_k: usize,
470) -> Vec<TextHit> {
471    if candidates.collections.is_empty() || top_k == 0 {
472        return Vec::new();
473    }
474
475    let visible = scope.visible_collections();
476    let allowed: BTreeSet<String> = candidates
477        .collections
478        .iter()
479        .filter(|collection| visible.is_none_or(|set| set.contains(*collection)))
480        .cloned()
481        .collect();
482    if allowed.is_empty() {
483        return Vec::new();
484    }
485
486    let _scope_guard = AskScopeGuard::install(scope);
487    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
488    let mut rls_cache: HashMap<String, Option<crate::storage::query::ast::Filter>> = HashMap::new();
489    let store = runtime.inner.db.store();
490
491    runtime
492        .inner
493        .db
494        .store()
495        .context_index()
496        .search_bm25(question, top_k, Some(&allowed))
497        .into_iter()
498        .filter_map(|hit| {
499            let entity = store.get(&hit.collection, hit.entity_id)?;
500            if !ask_entity_allowed(
501                runtime,
502                scope,
503                &hit.collection,
504                &entity,
505                snap_ctx.as_ref(),
506                &mut rls_cache,
507            ) {
508                return None;
509            }
510            Some(TextHit {
511                collection: hit.collection,
512                entity_id: hit.entity_id.raw(),
513                score: hit.score,
514            })
515        })
516        .collect()
517}
518
519// ---------------------------------------------------------------------------
520// Stage 1 — token / entity extraction (heuristic v1) + opt-in LLM NER routing.
521// ---------------------------------------------------------------------------
522
523/// Stage-1 dispatcher honouring the `ai.ner.backend` config knob.
524///
525/// The pipeline (and `execute_with_limit` in particular) stays
526/// **sync** per #123 deepening to avoid an async cascade through the
527/// rest of `ask_pipeline`. When the operator turns on
528/// `ai.ner.backend = "llm"`, we contain the async surface here by
529/// using `tokio::runtime::Handle::current().block_on(...)` — works
530/// when called from inside an async context (the production HTTP
531/// handler path), falls back to the heuristic with a warn-log when
532/// no Tokio runtime is reachable (sync test contexts and embedded
533/// callers without a runtime).
534///
535/// Auth gate: `LlmNer::extract` checks `ai:ner:read`. Today
536/// `EffectiveScope::has_capability` is a placeholder that always
537/// returns `false`, so a LLM-backend-configured deployment will see
538/// every call deny at the gate and the configured `HeuristicFallback`
539/// fires. A one-shot info log makes that visible in operator logs.
540/// Wiring the real capability check into the auth engine is future
541/// work — the routing seam is in place so that landing the auth
542/// extension is a one-line `EffectiveScope` change.
543fn extract_tokens_routed(
544    runtime: &RedDBRuntime,
545    scope: &EffectiveScope,
546    question: &str,
547) -> RedDBResult<TokenSet> {
548    let backend = runtime.config_string("ai.ner.backend", "heuristic");
549    if backend != "llm" {
550        return Ok(extract_tokens(question));
551    }
552
553    let endpoint = runtime.config_string("ai.ner.endpoint", "");
554    let model = runtime.config_string("ai.ner.model", "");
555    let timeout_ms = runtime
556        .config_string("ai.ner.timeout_ms", "5000")
557        .parse::<u32>()
558        .unwrap_or(5000);
559    let fallback = match runtime
560        .config_string("ai.ner.fallback", "use_heuristic")
561        .as_str()
562    {
563        "empty_on_fail" => HeuristicFallback::EmptyOnFail,
564        "propagate" => HeuristicFallback::Propagate,
565        _ => HeuristicFallback::UseHeuristic,
566    };
567
568    // Endpoint shape decides provider; default to OpenAI-compat when
569    // unspecified — matches the documented config shape.
570    let provider = if endpoint.is_empty() && model.is_empty() {
571        // No network config provided — operator opted into "llm" but
572        // didn't wire a backend. Fall back to a Stub::Empty so the
573        // configured fallback policy fires deterministically.
574        NerProvider::Stub(super::ai::ner::StubBehavior::Empty)
575    } else {
576        NerProvider::OpenAiCompat { endpoint, model }
577    };
578
579    let mut ner = LlmNer::new(provider, fallback);
580    ner.timeout_ms = timeout_ms;
581
582    let auth = ScopeAuthAdapter(scope);
583    let llm_result = match tokio::runtime::Handle::try_current() {
584        Ok(handle) => {
585            // Use `block_on` from a thread that's not driving the
586            // current runtime — the typical caller is an Axum HTTP
587            // handler running on the multi-thread runtime, so we hop
588            // off via `block_in_place`.
589            tokio::task::block_in_place(|| handle.block_on(ner.extract(question, scope, &auth)))
590        }
591        Err(_) => {
592            warn!(
593                target: "ask_pipeline",
594                "ai.ner.backend=llm configured but no Tokio runtime reachable from extract_tokens; using heuristic fallback"
595            );
596            return Ok(extract_tokens(question));
597        }
598    };
599
600    match llm_result {
601        Ok(tokens) => Ok(tokens),
602        Err(NerError::AuthDenied) => {
603            log_auth_denial_once();
604            // Auth denials never honour fallback inside `LlmNer`, so
605            // the routing layer applies the configured fallback here
606            // — this is the bridge until the auth engine wires the
607            // capability for real.
608            apply_fallback(fallback, question)
609        }
610        Err(err) => {
611            warn!(
612                target: "ask_pipeline",
613                error = %err,
614                "LlmNer extract failed; honouring HeuristicFallback policy"
615            );
616            apply_fallback(fallback, question)
617        }
618    }
619}
620
621fn apply_fallback(fallback: HeuristicFallback, question: &str) -> RedDBResult<TokenSet> {
622    match fallback {
623        HeuristicFallback::UseHeuristic => Ok(extract_tokens(question)),
624        HeuristicFallback::EmptyOnFail => Ok(TokenSet::default()),
625        HeuristicFallback::Propagate => Err(RedDBError::Query(
626            "ai.ner.backend=llm: extract failed and ai.ner.fallback=propagate".to_string(),
627        )),
628    }
629}
630
631/// One-shot info log for the auth-gate placeholder. Until the auth
632/// engine learns to grant `ai:ner:read`, every routed call denies —
633/// we emit the explainer once per process so logs aren't spammed.
634fn log_auth_denial_once() {
635    static EMITTED: AtomicBool = AtomicBool::new(false);
636    if !EMITTED.swap(true, Ordering::Relaxed) {
637        info!(
638            target: "ask_pipeline",
639            capability = NER_CAPABILITY,
640            "LlmNer routing configured but capability `{}` not yet wired into auth engine; falling back to heuristic",
641            NER_CAPABILITY
642        );
643    }
644}
645
646/// Adapter wrapping `EffectiveScope` so it can drive the
647/// `LlmNer`-side `AuthContext` trait without leaking that trait
648/// across the rest of the runtime.
649struct ScopeAuthAdapter<'a>(&'a EffectiveScope);
650
651impl<'a> std::fmt::Debug for ScopeAuthAdapter<'a> {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        f.debug_struct("ScopeAuthAdapter").finish_non_exhaustive()
654    }
655}
656
657impl<'a> NerAuthContext for ScopeAuthAdapter<'a> {
658    fn has_capability(&self, capability: &str) -> bool {
659        self.0.has_capability(capability)
660    }
661}
662
663/// Split a question into a [`TokenSet`]. Pure function — no runtime
664/// lookups.
665///
666/// Rules (heuristic v1):
667/// - **Literals** match `[A-Z0-9-]{3,}` AND contain ≥1 digit, OR are
668///   pure digit runs of length ≥ 6. Captures id shapes like
669///   `FDD-12313`, `INV-2024-001`, `123456`.
670/// - **Keywords** match `[A-Za-z][A-Za-z0-9_]+` (length ≥ 2),
671///   normalised to lowercase. Stop words are dropped to avoid
672///   wasting Stage-2 lookups on noise.
673pub fn extract_tokens(question: &str) -> TokenSet {
674    let mut keywords: Vec<String> = Vec::new();
675    let mut literals: Vec<String> = Vec::new();
676
677    let mut chars = question.chars().peekable();
678    let mut buf = String::new();
679
680    let flush = |buf: &mut String, keywords: &mut Vec<String>, literals: &mut Vec<String>| {
681        if buf.is_empty() {
682            return;
683        }
684        let word = std::mem::take(buf);
685        classify_token(&word, keywords, literals);
686    };
687
688    while let Some(ch) = chars.next() {
689        if ch.is_alphanumeric() || ch == '_' || ch == '-' {
690            buf.push(ch);
691        } else {
692            flush(&mut buf, &mut keywords, &mut literals);
693            // Skip remaining whitespace / punctuation; loop continues.
694            let _ = ch;
695        }
696        // Look ahead to also flush on EOF.
697        if chars.peek().is_none() {
698            flush(&mut buf, &mut keywords, &mut literals);
699        }
700    }
701    // Final flush in case the loop body didn't see EOF (empty
702    // iterator path).
703    if !buf.is_empty() {
704        classify_token(&buf, &mut keywords, &mut literals);
705    }
706
707    // Dedup keywords + literals while preserving order.
708    let mut seen = HashSet::new();
709    keywords.retain(|tok| seen.insert(tok.clone()));
710    let mut seen_lit = HashSet::new();
711    literals.retain(|tok| seen_lit.insert(tok.clone()));
712
713    TokenSet { keywords, literals }
714}
715
716fn classify_token(word: &str, keywords: &mut Vec<String>, literals: &mut Vec<String>) {
717    // Literal: shape `[A-Z0-9-]{3,}` with at least one digit, OR
718    // pure-digit run of length ≥ 6.
719    let is_upper_id_shape = word.len() >= 3
720        && word
721            .chars()
722            .all(|c| c.is_ascii_digit() || c == '-' || c.is_ascii_uppercase())
723        && word.chars().any(|c| c.is_ascii_digit())
724        && word.chars().any(|c| c.is_ascii_uppercase() || c == '-');
725    let is_long_digit_run = word.len() >= 6 && word.chars().all(|c| c.is_ascii_digit());
726    if is_upper_id_shape || is_long_digit_run {
727        literals.push(word.to_string());
728        return;
729    }
730    // Keyword: starts with a letter, len ≥ 2, drop trailing/leading
731    // hyphens, lowercase.
732    let trimmed = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
733    if trimmed.len() < 2 {
734        return;
735    }
736    if !trimmed
737        .chars()
738        .next()
739        .map(|c| c.is_ascii_alphabetic())
740        .unwrap_or(false)
741    {
742        return;
743    }
744    if !trimmed
745        .chars()
746        .all(|c| c.is_ascii_alphanumeric() || c == '_')
747    {
748        // Hyphenated word that wasn't a literal — skip rather than
749        // index a fragmented token.
750        return;
751    }
752    let lower = trimmed.to_ascii_lowercase();
753    if STOP_WORDS.binary_search(&lower.as_str()).is_ok() {
754        return;
755    }
756    keywords.push(lower);
757}
758
759/// Sorted ascii lowercase stop-word list. Kept tiny and curated so a
760/// regression in Stage 2 candidate-narrowing surfaces fast.
761const STOP_WORDS: &[&str] = &[
762    "a", "about", "an", "and", "are", "as", "at", "be", "by", "do", "for", "from", "how", "in",
763    "is", "it", "of", "on", "or", "que", "qual", "quais", "sobre", "te", "the", "to", "what",
764    "where", "which", "with",
765];
766
767// ---------------------------------------------------------------------------
768// Stage 2 — schema-vocabulary match.
769// ---------------------------------------------------------------------------
770
771/// For each keyword, probe `SchemaVocabulary` and intersect the
772/// resulting collection set with `scope.visible_collections`. Returns
773/// the deduplicated, sorted candidate list plus a per-collection
774/// column hint set for Stage 4.
775pub fn match_schema(
776    runtime: &RedDBRuntime,
777    scope: &EffectiveScope,
778    tokens: &TokenSet,
779) -> RedDBResult<CandidateCollections> {
780    let visible = match scope.visible_collections() {
781        Some(set) => set.clone(),
782        None => {
783            // No scope wired = embedded mode. Fall back to "every
784            // collection in the DB" so the pipeline still runs;
785            // AuthorizedSearch is the seam that refuses the deny-
786            // default for AI commands.
787            runtime
788                .inner
789                .db
790                .store()
791                .list_collections()
792                .into_iter()
793                .collect()
794        }
795    };
796
797    let mut collections: BTreeSet<String> = BTreeSet::new();
798    let mut columns_by_collection: HashMap<String, BTreeSet<String>> = HashMap::new();
799    for keyword in &tokens.keywords {
800        let hits = runtime.schema_vocabulary_lookup(keyword);
801        for hit in hits {
802            if !visible.contains(&hit.collection) {
803                continue;
804            }
805            collections.insert(hit.collection.clone());
806            if let Some(column) = hit.column {
807                columns_by_collection
808                    .entry(hit.collection)
809                    .or_default()
810                    .insert(column);
811            }
812        }
813    }
814
815    Ok(CandidateCollections {
816        collections: collections.into_iter().collect(),
817        columns_by_collection: columns_by_collection
818            .into_iter()
819            .map(|(k, v)| (k, v.into_iter().collect()))
820            .collect(),
821    })
822}
823
824/// Keep the top `k` items under `cmp` without a full O(n log n) sort when it
825/// pays off. Output is **bit-identical** to `items.sort_by(cmp);
826/// items.truncate(k)` for any input: the small-`n` branch performs exactly
827/// that stable sort, and the quickselect branch appends the original index as
828/// the final tie-break, making the comparator a strict total order so the
829/// unstable partition/sort reproduces the stable sort's first `k` elements
830/// (including the caller's own tie-breaks and any NaN/None fallback baked into
831/// `cmp`). Mirrors `join_filter::ordering::top_k_records_by_order_by_with_db`.
832fn partial_top_k<T: Clone>(
833    items: &mut Vec<T>,
834    k: usize,
835    cmp: impl Fn(&T, &T) -> std::cmp::Ordering,
836) {
837    let n = items.len();
838    if k == 0 {
839        items.clear();
840        return;
841    }
842    // Quickselect only beats a full sort with headroom (n > 2k); below that,
843    // reproduce the original stable sort + truncate verbatim.
844    if n <= k.saturating_mul(2) {
845        items.sort_by(|a, b| cmp(a, b));
846        items.truncate(k);
847        return;
848    }
849    let mut idxs: Vec<usize> = (0..n).collect();
850    idxs.select_nth_unstable_by(k - 1, |&a, &b| {
851        cmp(&items[a], &items[b]).then_with(|| a.cmp(&b))
852    });
853    idxs.truncate(k);
854    idxs.sort_by(|&a, &b| cmp(&items[a], &items[b]).then_with(|| a.cmp(&b)));
855    let orig = std::mem::take(items);
856    *items = idxs.into_iter().map(|i| orig[i].clone()).collect();
857}
858
859// ---------------------------------------------------------------------------
860// Stage 3 — vector search scoped to the candidate collections.
861// ---------------------------------------------------------------------------
862
863/// Embed the question (best-effort — skipped if no provider is
864/// configured, see `embed_question`) and run
865/// `AuthorizedSearch::execute_similar` over each candidate
866/// collection. Returns at most `top_k` hits sorted by similarity.
867pub fn vector_search_scoped(
868    runtime: &RedDBRuntime,
869    scope: &EffectiveScope,
870    question: &str,
871    candidates: &CandidateCollections,
872    top_k: usize,
873    min_score: Option<f32>,
874) -> Vec<VectorHit> {
875    if candidates.collections.is_empty() {
876        return Vec::new();
877    }
878    let Some(embedding) = embed_question(runtime, question) else {
879        return Vec::new();
880    };
881    let per_collection = top_k.max(1);
882    let mut hits: Vec<VectorHit> = Vec::new();
883    let _scope_guard = AskScopeGuard::install(scope);
884    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
885    let mut rls_cache: HashMap<String, Option<crate::storage::query::ast::Filter>> = HashMap::new();
886    let store = runtime.inner.db.store();
887    for collection in &candidates.collections {
888        match super::authorized_search::AuthorizedSearch::execute_similar(
889            runtime,
890            scope,
891            collection,
892            &embedding,
893            per_collection,
894            min_score.unwrap_or(0.0),
895        ) {
896            Ok(results) => {
897                for result in results {
898                    let Some(entity) = store.get(collection, result.entity_id) else {
899                        continue;
900                    };
901                    if !ask_entity_allowed(
902                        runtime,
903                        scope,
904                        collection,
905                        &entity,
906                        snap_ctx.as_ref(),
907                        &mut rls_cache,
908                    ) {
909                        continue;
910                    }
911                    hits.push(VectorHit {
912                        collection: collection.clone(),
913                        entity_id: result.entity_id.raw(),
914                        score: result.score,
915                    });
916                }
917            }
918            Err(err) => {
919                debug!(
920                    target: "ask_pipeline",
921                    collection = collection,
922                    err = %err,
923                    "vector_search_scoped: collection skipped"
924                );
925            }
926        }
927    }
928    partial_top_k(&mut hits, top_k, |a, b| {
929        b.score
930            .partial_cmp(&a.score)
931            .unwrap_or(std::cmp::Ordering::Equal)
932            .then_with(|| a.entity_id.cmp(&b.entity_id))
933    });
934    hits
935}
936
937// ---------------------------------------------------------------------------
938// Stage 3b — graph traversal scoped to candidate collections.
939// ---------------------------------------------------------------------------
940
941/// Run context search with graph expansion enabled and keep only
942/// graph-traversal hits. `ASK ... DEPTH N` controls the traversal
943/// horizon; absent depth follows the existing ASK tool default.
944pub fn graph_search_scoped(
945    runtime: &RedDBRuntime,
946    scope: &EffectiveScope,
947    question: &str,
948    candidates: &CandidateCollections,
949    top_k: usize,
950    min_score: Option<f32>,
951    graph_depth: Option<usize>,
952) -> Vec<GraphHit> {
953    if candidates.collections.is_empty() || top_k == 0 {
954        return Vec::new();
955    }
956    let depth = graph_depth
957        .unwrap_or(crate::runtime::ai::mcp_ask_tool::DEPTH_DEFAULT as usize)
958        .max(1);
959    let _scope_guard = AskScopeGuard::install(scope);
960    let input = SearchContextInput {
961        query: question.to_string(),
962        field: None,
963        vector: None,
964        collections: Some(candidates.collections.clone()),
965        graph_depth: Some(depth),
966        graph_max_edges: None,
967        max_cross_refs: Some(0),
968        follow_cross_refs: Some(false),
969        expand_graph: Some(true),
970        global_scan: Some(true),
971        reindex: Some(false),
972        limit: Some(top_k),
973        min_score,
974    };
975    let result = match if scope.visible_collections().is_some() {
976        super::authorized_search::AuthorizedSearch::execute_context(runtime, scope, input)
977    } else {
978        runtime.search_context(input)
979    } {
980        Ok(result) => result,
981        Err(err) => {
982            debug!(
983                target: "ask_pipeline",
984                err = %err,
985                "graph_search_scoped: context search skipped"
986            );
987            return Vec::new();
988        }
989    };
990
991    let mut hits = Vec::new();
992    for entity in result.graph.nodes.into_iter().chain(result.graph.edges) {
993        let crate::runtime::DiscoveryMethod::GraphTraversal { depth, .. } = entity.discovery else {
994            continue;
995        };
996        let kind = match entity.entity.kind {
997            EntityKind::GraphNode(_) => GraphHitKind::Node,
998            EntityKind::GraphEdge(_) => GraphHitKind::Edge,
999            _ => continue,
1000        };
1001        hits.push(GraphHit {
1002            collection: entity.collection,
1003            entity_id: entity.entity.id.raw(),
1004            score: entity.score,
1005            depth,
1006            kind,
1007        });
1008    }
1009    partial_top_k(&mut hits, top_k, |a, b| {
1010        b.score
1011            .partial_cmp(&a.score)
1012            .unwrap_or(std::cmp::Ordering::Equal)
1013            .then_with(|| a.depth.cmp(&b.depth))
1014            .then_with(|| a.collection.cmp(&b.collection))
1015            .then_with(|| a.entity_id.cmp(&b.entity_id))
1016    });
1017    hits
1018}
1019
1020/// Best-effort embedding. Returns `None` when no embedding provider
1021/// is configured (the unit-test fixture path) — the caller treats
1022/// `None` as "Stage 3 yielded zero hits" and continues.
1023fn embed_question(runtime: &RedDBRuntime, question: &str) -> Option<Vec<f32>> {
1024    let kv_getter = |key: &str| -> RedDBResult<Option<String>> {
1025        match runtime.inner.db.get_kv("red_config", key) {
1026            Some((Value::Text(value), _)) => Ok(Some(value.to_string())),
1027            Some(_) => Ok(None),
1028            None => Ok(None),
1029        }
1030    };
1031    let provider = crate::ai::resolve_default_provider(&kv_getter);
1032    if !provider.is_openai_compatible() {
1033        return None;
1034    }
1035    let model = crate::ai::resolve_default_model(&provider, &kv_getter);
1036    let api_key = crate::ai::resolve_api_key(&provider, None, kv_getter).ok()?;
1037    let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
1038    let request = crate::ai::OpenAiEmbeddingRequest {
1039        api_key,
1040        model,
1041        inputs: vec![question.to_string()],
1042        dimensions: None,
1043        api_base: provider.resolve_api_base(),
1044    };
1045    let response = crate::runtime::ai::block_on_ai(async move {
1046        crate::ai::openai_embeddings_async(&transport, request).await
1047    })
1048    .and_then(|result| result)
1049    .ok()?;
1050    response.embeddings.into_iter().next()
1051}
1052
1053// ---------------------------------------------------------------------------
1054// Stage 4 — value filter using literal tokens.
1055// ---------------------------------------------------------------------------
1056
1057/// Walk every candidate collection looking for rows whose columns
1058/// contain any of the literal tokens. Caller-supplied `row_cap`
1059/// bounds the result count; column hints from Stage 2 are visited
1060/// first so promising columns find a match before a full scan.
1061pub fn filter_values(
1062    runtime: &RedDBRuntime,
1063    scope: &EffectiveScope,
1064    candidates: &CandidateCollections,
1065    tokens: &TokenSet,
1066    row_cap: usize,
1067) -> Vec<FilteredRow> {
1068    if tokens.literals.is_empty() || candidates.collections.is_empty() {
1069        return Vec::new();
1070    }
1071    let visible = scope.visible_collections();
1072    let store = runtime.inner.db.store();
1073    let mut out: Vec<FilteredRow> = Vec::new();
1074    let _scope_guard = AskScopeGuard::install(scope);
1075    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
1076    let mut rls_cache: HashMap<String, Option<crate::storage::query::ast::Filter>> = HashMap::new();
1077
1078    'collection: for collection in &candidates.collections {
1079        // Defence-in-depth: redo the visibility check here so a Stage
1080        // 2 regression can't smuggle an out-of-scope collection
1081        // through.
1082        if let Some(set) = visible {
1083            if !set.contains(collection) {
1084                continue;
1085            }
1086        }
1087        let Some(manager) = store.get_collection(collection) else {
1088            continue;
1089        };
1090        let hint_columns: &[String] = candidates
1091            .columns_by_collection
1092            .get(collection)
1093            .map(|v| v.as_slice())
1094            .unwrap_or(&[]);
1095
1096        for entity in manager.query_all(|_| true) {
1097            if !ask_entity_allowed(
1098                runtime,
1099                scope,
1100                collection,
1101                &entity,
1102                snap_ctx.as_ref(),
1103                &mut rls_cache,
1104            ) {
1105                continue;
1106            }
1107            if let Some(hit) = literal_match_in_entity(&entity, &tokens.literals, hint_columns) {
1108                out.push(FilteredRow {
1109                    collection: collection.clone(),
1110                    entity,
1111                    matched_literal: hit.0,
1112                    matched_column: hit.1,
1113                });
1114                if out.len() >= row_cap {
1115                    break 'collection;
1116                }
1117            }
1118        }
1119    }
1120    out
1121}
1122
1123fn ask_entity_allowed(
1124    runtime: &RedDBRuntime,
1125    scope: &EffectiveScope,
1126    collection: &str,
1127    entity: &UnifiedEntity,
1128    snap_ctx: Option<&crate::runtime::impl_core::SnapshotContext>,
1129    rls_cache: &mut HashMap<String, Option<crate::storage::query::ast::Filter>>,
1130) -> bool {
1131    if scope
1132        .visible_collections()
1133        .is_some_and(|visible| !visible.contains(collection))
1134    {
1135        return false;
1136    }
1137    runtime.search_entity_allowed(collection, entity, snap_ctx, rls_cache)
1138}
1139
1140struct AskScopeGuard {
1141    prev_tenant: Option<String>,
1142    prev_auth: Option<(String, crate::auth::Role)>,
1143}
1144
1145impl AskScopeGuard {
1146    fn install(scope: &EffectiveScope) -> Self {
1147        let prev_tenant = crate::runtime::impl_core::current_tenant();
1148        let prev_auth = crate::runtime::impl_core::current_auth_identity();
1149
1150        match scope.effective_scope() {
1151            Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant.to_string()),
1152            None => crate::runtime::impl_core::clear_current_tenant(),
1153        }
1154        match scope.identity() {
1155            Some((user, role)) => {
1156                crate::runtime::impl_core::set_current_auth_identity(user.to_string(), role)
1157            }
1158            None => crate::runtime::impl_core::clear_current_auth_identity(),
1159        }
1160
1161        Self {
1162            prev_tenant,
1163            prev_auth,
1164        }
1165    }
1166}
1167
1168impl Drop for AskScopeGuard {
1169    fn drop(&mut self) {
1170        match self.prev_tenant.take() {
1171            Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant),
1172            None => crate::runtime::impl_core::clear_current_tenant(),
1173        }
1174        match self.prev_auth.take() {
1175            Some((user, role)) => crate::runtime::impl_core::set_current_auth_identity(user, role),
1176            None => crate::runtime::impl_core::clear_current_auth_identity(),
1177        }
1178    }
1179}
1180
1181/// Look for any literal in any column value of `entity`. Hint
1182/// columns are checked first; a positive hit short-circuits.
1183fn literal_match_in_entity(
1184    entity: &UnifiedEntity,
1185    literals: &[String],
1186    hint_columns: &[String],
1187) -> Option<(String, Option<String>)> {
1188    let row = match &entity.data {
1189        EntityData::Row(row) => row,
1190        _ => return None,
1191    };
1192
1193    // Pass 1: hint columns first.
1194    for column in hint_columns {
1195        if let Some(value) = row.get_field(column) {
1196            if let Some(lit) = first_literal_in_value(value, literals) {
1197                return Some((lit, Some(column.clone())));
1198            }
1199        }
1200    }
1201    // Pass 2: every other column.
1202    for (name, value) in row.iter_fields() {
1203        if hint_columns.iter().any(|c| c == name) {
1204            continue;
1205        }
1206        if let Some(lit) = first_literal_in_value(value, literals) {
1207            return Some((lit, Some(name.to_string())));
1208        }
1209    }
1210    None
1211}
1212
1213fn first_literal_in_value(value: &Value, literals: &[String]) -> Option<String> {
1214    let rendered = match value {
1215        Value::Text(s) => s.to_string(),
1216        Value::Integer(i) => i.to_string(),
1217        Value::Float(f) => f.to_string(),
1218        Value::Boolean(b) => b.to_string(),
1219        Value::Json(j) => String::from_utf8_lossy(j).to_string(),
1220        _ => return None,
1221    };
1222    for lit in literals {
1223        // Case-sensitive substring match: literals are id-shaped, so
1224        // we want `FDD-12313` to find embedded `FDD-12313` in a free-
1225        // form description column too.
1226        if rendered.contains(lit) {
1227            return Some(lit.clone());
1228        }
1229    }
1230    None
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236
1237    // -- Stage 1 ------------------------------------------------------
1238
1239    #[test]
1240    fn extract_tokens_splits_keywords_and_literals() {
1241        let tokens = extract_tokens("quais as novidades sobre o passport FDD-12313?");
1242        // `quais`, `as`, `sobre`, `o` are stop words; `novidades`,
1243        // `passport` survive.
1244        assert!(tokens.keywords.contains(&"novidades".to_string()));
1245        assert!(tokens.keywords.contains(&"passport".to_string()));
1246        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1247        assert!(!tokens.is_empty());
1248    }
1249
1250    #[test]
1251    fn extract_tokens_returns_empty_for_punctuation_only() {
1252        let tokens = extract_tokens("???   ...");
1253        assert!(tokens.is_empty());
1254    }
1255
1256    #[test]
1257    fn extract_tokens_long_digit_run_is_a_literal() {
1258        let tokens = extract_tokens("show order 987654321 details");
1259        assert!(tokens.literals.contains(&"987654321".to_string()));
1260        assert!(tokens.keywords.contains(&"order".to_string()));
1261        assert!(tokens.keywords.contains(&"details".to_string()));
1262        assert!(tokens.keywords.contains(&"show".to_string()));
1263    }
1264
1265    #[test]
1266    fn extract_tokens_short_uppercase_word_is_keyword_not_literal() {
1267        // "USA" is uppercase but lacks a digit, so it stays a keyword
1268        // (lowercased) — Stage 2 still gets to probe it.
1269        let tokens = extract_tokens("USA exports report");
1270        assert!(tokens.keywords.contains(&"usa".to_string()));
1271        assert!(tokens.literals.is_empty());
1272    }
1273
1274    #[test]
1275    fn extract_tokens_dedups() {
1276        let tokens = extract_tokens("passport passport FDD-1 FDD-1");
1277        assert_eq!(
1278            tokens.keywords.iter().filter(|k| *k == "passport").count(),
1279            1
1280        );
1281        assert_eq!(tokens.literals.iter().filter(|l| *l == "FDD-1").count(), 1);
1282    }
1283
1284    // -- Stage 4 helper ----------------------------------------------
1285
1286    #[test]
1287    fn first_literal_in_value_substring_match() {
1288        let lit = first_literal_in_value(
1289            &Value::text("issue FDD-12313 reported by user"),
1290            &["FDD-12313".to_string()],
1291        );
1292        assert_eq!(lit.as_deref(), Some("FDD-12313"));
1293    }
1294
1295    #[test]
1296    fn first_literal_in_value_no_match_returns_none() {
1297        assert!(
1298            first_literal_in_value(&Value::text("nothing here"), &["FDD-12313".to_string()],)
1299                .is_none()
1300        );
1301    }
1302
1303    // -- Pipeline-wide -----------------------------------------------
1304
1305    use crate::api::RedDBOptions;
1306    use crate::auth::Role;
1307    use crate::runtime::statement_frame::EffectiveScope;
1308    use crate::runtime::RedDBRuntime;
1309    use crate::storage::schema::Value;
1310    use crate::storage::transaction::snapshot::Snapshot;
1311    use crate::storage::unified::entity::{
1312        EntityData, EntityId, EntityKind, RowData, UnifiedEntity,
1313    };
1314    use std::sync::Arc;
1315
1316    fn make_scope(visible: HashSet<String>) -> EffectiveScope {
1317        EffectiveScope {
1318            tenant: Some("acme".to_string()),
1319            identity: Some(("alice".to_string(), Role::Read)),
1320            snapshot: Snapshot {
1321                xid: 0,
1322                in_progress: HashSet::new(),
1323            },
1324            visible_collections: Some(visible),
1325        }
1326    }
1327
1328    fn fresh_runtime() -> RedDBRuntime {
1329        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime boots")
1330    }
1331
1332    fn test_row(collection: &str, id: u64) -> FilteredRow {
1333        FilteredRow {
1334            collection: collection.to_string(),
1335            entity: UnifiedEntity::new(
1336                EntityId::new(id),
1337                EntityKind::TableRow {
1338                    table: Arc::from(collection),
1339                    row_id: id,
1340                },
1341                EntityData::Row(RowData {
1342                    columns: Vec::new(),
1343                    named: Some(
1344                        [("body".to_string(), Value::text("ticket FDD-1".to_string()))]
1345                            .into_iter()
1346                            .collect(),
1347                    ),
1348                    schema: None,
1349                }),
1350            ),
1351            matched_literal: "FDD-1".to_string(),
1352            matched_column: Some("body".to_string()),
1353        }
1354    }
1355
1356    fn test_graph_hit(collection: &str, id: u64, score: f32, depth: usize) -> GraphHit {
1357        GraphHit {
1358            collection: collection.to_string(),
1359            entity_id: id,
1360            score,
1361            depth,
1362            kind: GraphHitKind::Node,
1363        }
1364    }
1365
1366    fn test_text_hit(collection: &str, id: u64, score: f32) -> TextHit {
1367        TextHit {
1368            collection: collection.to_string(),
1369            entity_id: id,
1370            score,
1371        }
1372    }
1373
1374    struct TenantGuard;
1375
1376    impl TenantGuard {
1377        fn set(tenant: &str) -> Self {
1378            crate::runtime::impl_core::set_current_tenant(tenant.to_string());
1379            Self
1380        }
1381    }
1382
1383    impl Drop for TenantGuard {
1384        fn drop(&mut self) {
1385            crate::runtime::impl_core::clear_current_tenant();
1386        }
1387    }
1388
1389    fn row_text<'a>(entity: &'a UnifiedEntity, field: &str) -> Option<&'a str> {
1390        let row = entity.data.as_row()?;
1391        match row.get_field(field)? {
1392            Value::Text(value) => Some(value.as_ref()),
1393            _ => None,
1394        }
1395    }
1396
1397    #[test]
1398    fn fused_source_order_uses_rrf_and_total_limit() {
1399        let ctx = AskContext {
1400            source_limit: 2,
1401            filtered_rows: vec![test_row("incidents", 2), test_row("incidents", 1)],
1402            vector_hits: vec![
1403                VectorHit {
1404                    collection: "incidents".to_string(),
1405                    entity_id: 1,
1406                    score: 0.91,
1407                },
1408                VectorHit {
1409                    collection: "docs".to_string(),
1410                    entity_id: 9,
1411                    score: 0.88,
1412                },
1413            ],
1414            ..AskContext::default()
1415        };
1416
1417        let order = fused_source_order(&ctx);
1418
1419        assert_eq!(
1420            order,
1421            vec![
1422                FusedSourceRef::FilteredRow(1),
1423                FusedSourceRef::FilteredRow(0)
1424            ]
1425        );
1426    }
1427
1428    #[test]
1429    fn fused_source_order_includes_graph_bucket() {
1430        let ctx = AskContext {
1431            source_limit: 4,
1432            filtered_rows: vec![test_row("incidents", 1)],
1433            text_hits: vec![test_text_hit("articles", 5, 1.2)],
1434            vector_hits: vec![
1435                VectorHit {
1436                    collection: "incidents".to_string(),
1437                    entity_id: 1,
1438                    score: 0.91,
1439                },
1440                VectorHit {
1441                    collection: "docs".to_string(),
1442                    entity_id: 9,
1443                    score: 0.88,
1444                },
1445            ],
1446            graph_hits: vec![test_graph_hit("topology", 7, 0.80, 1)],
1447            ..AskContext::default()
1448        };
1449
1450        let order = fused_source_order(&ctx);
1451
1452        assert_eq!(
1453            order,
1454            vec![
1455                FusedSourceRef::FilteredRow(0),
1456                FusedSourceRef::TextHit(0),
1457                FusedSourceRef::GraphHit(0),
1458                FusedSourceRef::VectorHit(1),
1459            ]
1460        );
1461    }
1462
1463    #[test]
1464    fn text_search_bm25_scoped_ranks_specific_document_first() {
1465        let rt = fresh_runtime();
1466        rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1467            .expect("create docs");
1468        rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1469            .expect("insert specific doc");
1470        rt.execute_query(
1471            "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1472        )
1473        .expect("insert broad doc");
1474
1475        let scope = make_scope(["docs".to_string()].into_iter().collect());
1476        let candidates = CandidateCollections {
1477            collections: vec!["docs".to_string()],
1478            columns_by_collection: HashMap::new(),
1479        };
1480        let hits = text_search_bm25_scoped(&rt, &scope, "passport renewal", &candidates, 10);
1481
1482        assert_eq!(hits.len(), 2);
1483        assert!(
1484            hits[0].score > hits[1].score,
1485            "BM25 text bucket should prefer the shorter exact match: {hits:?}"
1486        );
1487    }
1488
1489    #[test]
1490    fn text_search_bm25_scoped_filters_rls_denied_hits() {
1491        let rt = fresh_runtime();
1492        rt.execute_query(
1493            "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) WITH CONTEXT INDEX ON (body)",
1494        )
1495        .expect("create docs");
1496        rt.execute_query(
1497            "INSERT INTO docs (id, tenant_id, body) VALUES \
1498             (1, 'acme', 'shared launch plan'), \
1499             (2, 'globex', 'shared launch plan')",
1500        )
1501        .expect("seed docs");
1502        rt.execute_query(
1503            "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1504        )
1505        .expect("create policy");
1506        rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1507            .expect("enable rls");
1508
1509        let _tenant = TenantGuard::set("acme");
1510        let scope = make_scope(["docs".to_string()].into_iter().collect());
1511        let candidates = CandidateCollections {
1512            collections: vec!["docs".to_string()],
1513            columns_by_collection: HashMap::new(),
1514        };
1515
1516        let hits = text_search_bm25_scoped(&rt, &scope, "shared launch", &candidates, 10);
1517
1518        assert_eq!(hits.len(), 1, "RLS should hide the globex hit: {hits:?}");
1519        let entity = rt
1520            .inner
1521            .db
1522            .store()
1523            .get(
1524                "docs",
1525                crate::storage::unified::entity::EntityId::new(hits[0].entity_id),
1526            )
1527            .expect("hit entity exists");
1528        assert_eq!(row_text(&entity, "tenant_id"), Some("acme"));
1529    }
1530
1531    #[test]
1532    fn execute_pipeline_retrieves_known_good_bm25_source_order() {
1533        let rt = fresh_runtime();
1534        rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1535            .expect("create docs");
1536        rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1537            .expect("insert specific doc");
1538        rt.execute_query(
1539            "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1540        )
1541        .expect("insert broad doc");
1542        rt.schema_vocabulary_apply(
1543            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1544                collection: "docs".to_string(),
1545                columns: vec!["body".into()],
1546                type_tags: Vec::new(),
1547                description: None,
1548            },
1549        );
1550
1551        let scope = make_scope(["docs".to_string()].into_iter().collect());
1552        let ctx = AskPipeline::execute_with_limit_and_min_score(
1553            &rt,
1554            &scope,
1555            "body passport renewal",
1556            2,
1557            None,
1558            Some(1),
1559        )
1560        .expect("pipeline executes");
1561
1562        assert_eq!(ctx.text_hits.len(), 2);
1563        assert!(
1564            ctx.text_hits[0].score > ctx.text_hits[1].score,
1565            "BM25 source order should prefer the shorter exact match: {:?}",
1566            ctx.text_hits
1567        );
1568        assert!(matches!(
1569            fused_source_order(&ctx).first(),
1570            Some(FusedSourceRef::TextHit(0))
1571        ));
1572    }
1573
1574    #[test]
1575    fn graph_search_scoped_honors_depth() {
1576        let rt = fresh_runtime();
1577        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('alice', 'Alice')")
1578            .expect("insert alice");
1579        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('bob', 'Bob')")
1580            .expect("insert bob");
1581        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('carol', 'Carol')")
1582            .expect("insert carol");
1583        rt.execute_query(
1584            "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'alice', 'bob')",
1585        )
1586        .expect("insert alice-bob edge");
1587        rt.execute_query(
1588            "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'bob', 'carol')",
1589        )
1590        .expect("insert bob-carol edge");
1591
1592        let scope = make_scope(["tales".to_string()].into_iter().collect());
1593        let candidates = CandidateCollections {
1594            collections: vec!["tales".to_string()],
1595            columns_by_collection: HashMap::new(),
1596        };
1597        let depth1 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(1));
1598        let depth2 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(2));
1599
1600        assert!(
1601            depth1.iter().all(|hit| hit.depth <= 1),
1602            "DEPTH 1 returned hits beyond one hop: {depth1:?}"
1603        );
1604        assert!(
1605            depth2.iter().any(|hit| hit.depth == 2),
1606            "DEPTH 2 should include the second-hop graph hit: {depth2:?}"
1607        );
1608    }
1609
1610    #[test]
1611    fn filter_values_filters_rls_denied_rows() {
1612        let rt = fresh_runtime();
1613        rt.execute_query("CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT)")
1614            .expect("create docs");
1615        rt.execute_query(
1616            "INSERT INTO docs (id, tenant_id, body) VALUES \
1617             (1, 'acme', 'incident FDD-12313'), \
1618             (2, 'globex', 'incident FDD-12313')",
1619        )
1620        .expect("seed docs");
1621        rt.execute_query(
1622            "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1623        )
1624        .expect("create policy");
1625        rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1626            .expect("enable rls");
1627
1628        let _tenant = TenantGuard::set("acme");
1629        let scope = make_scope(["docs".to_string()].into_iter().collect());
1630        let candidates = CandidateCollections {
1631            collections: vec!["docs".to_string()],
1632            columns_by_collection: HashMap::from([("docs".to_string(), vec!["body".to_string()])]),
1633        };
1634        let tokens = TokenSet {
1635            keywords: vec!["incident".to_string()],
1636            literals: vec!["FDD-12313".to_string()],
1637        };
1638
1639        let rows = filter_values(&rt, &scope, &candidates, &tokens, 10);
1640
1641        assert_eq!(rows.len(), 1, "RLS should hide the globex row: {rows:?}");
1642        assert_eq!(row_text(&rows[0].entity, "tenant_id"), Some("acme"));
1643    }
1644
1645    /// Empty token sets short-circuit with a structured error before
1646    /// any LLM round-trip.
1647    #[test]
1648    fn execute_refuses_empty_token_set() {
1649        let rt = fresh_runtime();
1650        let scope = make_scope(HashSet::new());
1651        let err = AskPipeline::execute(&rt, &scope, "??? ...")
1652            .expect_err("empty token set must short-circuit");
1653        let msg = format!("{err}");
1654        assert!(
1655            msg.contains("yielded no usable tokens"),
1656            "expected structured empty-token error, got: {msg}"
1657        );
1658    }
1659
1660    /// match_schema drops every collection that's outside
1661    /// `scope.visible_collections`. Using a synthetic vocab via DDL
1662    /// events on the live runtime so the assertion drives real
1663    /// `RedDBRuntime::schema_vocabulary_lookup`.
1664    #[test]
1665    fn match_schema_intersects_with_visible_set() {
1666        let rt = fresh_runtime();
1667        // Two collections both carry a `passport` column. Caller's
1668        // scope only includes `travel`, so the `passport` column hit
1669        // on `secrets` must be dropped.
1670        rt.schema_vocabulary_apply(
1671            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1672                collection: "travel".to_string(),
1673                columns: vec!["id".into(), "passport".into()],
1674                type_tags: Vec::new(),
1675                description: None,
1676            },
1677        );
1678        rt.schema_vocabulary_apply(
1679            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1680                collection: "secrets".to_string(),
1681                columns: vec!["passport".into()],
1682                type_tags: Vec::new(),
1683                description: None,
1684            },
1685        );
1686        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1687        let scope = make_scope(visible.clone());
1688        let tokens = TokenSet {
1689            keywords: vec!["passport".to_string()],
1690            literals: Vec::new(),
1691        };
1692        let candidates = match_schema(&rt, &scope, &tokens).expect("ok");
1693        assert_eq!(candidates.collections, vec!["travel".to_string()]);
1694        assert!(!candidates.collections.contains(&"secrets".to_string()));
1695        // Column hint surfaces for the surviving collection.
1696        let cols = candidates
1697            .columns_by_collection
1698            .get("travel")
1699            .expect("hint columns");
1700        assert!(cols.contains(&"passport".to_string()));
1701    }
1702
1703    // -- Property test (issue #121 acceptance row) -------------------
1704    //
1705    // For 256 random (question, scope) pairs: every Stage 4 row's
1706    // collection MUST be inside `scope.visible_collections`. Drives
1707    // `filter_values` directly with synthetic candidate sets so the
1708    // invariant is pinned without an embedding API.
1709
1710    use proptest::prelude::*;
1711
1712    fn arb_collection() -> impl Strategy<Value = String> {
1713        "[a-z]{1,4}"
1714    }
1715
1716    fn arb_visible() -> impl Strategy<Value = HashSet<String>> {
1717        prop::collection::hash_set(arb_collection(), 0..6)
1718    }
1719
1720    fn arb_candidates() -> impl Strategy<Value = Vec<String>> {
1721        prop::collection::vec(arb_collection(), 0..8)
1722    }
1723
1724    proptest! {
1725        #![proptest_config(ProptestConfig::with_cases(256))]
1726        #[test]
1727        fn stage4_rows_subset_of_visible_collections(
1728            visible in arb_visible(),
1729            candidate_names in arb_candidates(),
1730            literal_count in 0usize..3,
1731        ) {
1732            // Single runtime shared across cases — `filter_values`
1733            // only reads (no mutation), and we need the empty-store
1734            // path so the invariant we want to pin is "no row escapes
1735            // visible_collections" rather than "any specific row
1736            // surfaces".
1737            let rt = PROPTEST_RUNTIME.get_or_init(fresh_runtime);
1738            let candidates = CandidateCollections {
1739                collections: candidate_names,
1740                columns_by_collection: HashMap::new(),
1741            };
1742            let literals: Vec<String> = (0..literal_count)
1743                .map(|i| format!("ID-{i}"))
1744                .collect();
1745            let tokens = TokenSet {
1746                keywords: vec!["passport".to_string()],
1747                literals,
1748            };
1749            let scope = make_scope(visible.clone());
1750            let rows = filter_values(rt, &scope, &candidates, &tokens, DEFAULT_ROW_CAP);
1751            for row in &rows {
1752                prop_assert!(
1753                    visible.contains(&row.collection),
1754                    "Stage 4 leaked row collection={} not in visible={:?}",
1755                    row.collection, visible
1756                );
1757            }
1758        }
1759    }
1760
1761    static PROPTEST_RUNTIME: std::sync::OnceLock<RedDBRuntime> = std::sync::OnceLock::new();
1762
1763    // -- Integration test (issue #121 acceptance row) ----------------
1764    //
1765    // Drives the four stages end-to-end through `AskPipeline::execute`
1766    // with the question the issue calls out:
1767    //   "quais as novidades sobre o passport FDD-12313?"
1768    //
1769    // Stage 1 must extract `passport` + `FDD-12313`. Stage 2 must
1770    // narrow to the `passports` collection (visible to the caller).
1771    // Stage 3 silently yields zero hits without an embedding provider
1772    // — that's expected for the test fixture path. Stage 4 must surface
1773    // the row whose `notes` column embeds `FDD-12313`.
1774
1775    #[test]
1776    fn integration_passport_fdd_12313_funnels_through_four_stages() {
1777        let rt = fresh_runtime();
1778        // The collection name itself + a `passport` column both feed
1779        // Stage 2's vocabulary; either one is enough for the question
1780        // "passport FDD-12313" to land here.
1781        rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1782            .expect("CREATE TABLE travel");
1783        rt.execute_query(
1784            "INSERT INTO travel (id, passport, notes) VALUES \
1785             (1, 'BR-001', 'unrelated note'), \
1786             (2, 'PT-002', 'incident FDD-12313 escalated'), \
1787             (3, 'US-003', 'standard renewal')",
1788        )
1789        .expect("seed rows");
1790        // Out-of-scope collection — must NEVER surface in Stage 4.
1791        rt.execute_query("CREATE TABLE secrets (id INT, passport TEXT)")
1792            .expect("CREATE TABLE secrets");
1793        rt.execute_query("INSERT INTO secrets (id, passport) VALUES (99, 'FDD-12313')")
1794            .expect("seed secrets");
1795
1796        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1797        let scope = make_scope(visible);
1798
1799        let ctx = AskPipeline::execute(
1800            &rt,
1801            &scope,
1802            "quais as novidades sobre o passport FDD-12313?",
1803        )
1804        .expect("pipeline runs");
1805
1806        // Stage 1: passport + FDD-12313 surfaced.
1807        assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1808        assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1809
1810        // Stage 2: candidates narrowed to `travel` (the `passport`
1811        // column on `secrets` is dropped by the visible-set
1812        // intersection).
1813        assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1814
1815        // Stage 3: best-effort embedding — without a provider
1816        // configured, Stage 3 silently returns []; the rest of the
1817        // funnel still runs.
1818        let _ = &ctx.vector_hits;
1819
1820        // Stage 4: the row whose `notes` mentions `FDD-12313`
1821        // surfaces; the out-of-scope `secrets` row does NOT.
1822        assert!(
1823            ctx.filtered_rows
1824                .iter()
1825                .any(|r| r.collection == "travel" && r.matched_literal == "FDD-12313"),
1826            "expected travel row with FDD-12313 match, got: {:?}",
1827            ctx.filtered_rows
1828        );
1829        for row in &ctx.filtered_rows {
1830            assert_ne!(
1831                row.collection, "secrets",
1832                "secrets row leaked into Stage 4 output"
1833            );
1834        }
1835
1836        // Per-stage timing recorded.
1837        // (The Instant-based measurements may be 0 on very fast hosts;
1838        // we only check the field exists and was populated.)
1839        let _ = ctx.timings.extract_us
1840            + ctx.timings.schema_us
1841            + ctx.timings.vector_us
1842            + ctx.timings.filter_us;
1843    }
1844
1845    // -- Stage 1 routing (Lane 4/5: LlmNer wiring) -------------------
1846    //
1847    // The routing dispatcher is an `extract_tokens_routed` helper that
1848    // reads `ai.ner.backend` and either passes through to the heuristic
1849    // (default) or routes through `LlmNer::extract`. Today the
1850    // capability gate (`EffectiveScope::has_capability`) is a placeholder
1851    // that always returns `false`, so the LLM path always denies and
1852    // the configured `HeuristicFallback` policy fires. The tests below
1853    // pin every observable: heuristic stays the default, `llm + auth
1854    // denied` honours each fallback mode, and the one-shot info log is
1855    // best-effort (we don't assert it directly to avoid a coupling to
1856    // the global `tracing` subscriber).
1857
1858    fn write_config(rt: &RedDBRuntime, key: &str, value: &str) {
1859        let store = rt.inner.db.store();
1860        store.set_config_tree(key, &crate::serde_json::Value::String(value.to_string()));
1861    }
1862
1863    /// Default backend stays heuristic — even with an open scope, the
1864    /// pipeline returns the same tokens it would without any config.
1865    #[test]
1866    fn routed_default_backend_runs_heuristic() {
1867        let rt = fresh_runtime();
1868        let scope = make_scope(HashSet::new());
1869        let tokens = extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1870            .expect("heuristic path is infallible");
1871        assert!(tokens.keywords.contains(&"passport".to_string()));
1872        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1873    }
1874
1875    /// `backend = llm` with `fallback = use_heuristic`: capability
1876    /// denies (placeholder) → fallback → heuristic tokens surface.
1877    #[tokio::test(flavor = "multi_thread")]
1878    async fn routed_llm_auth_denied_uses_heuristic_fallback() {
1879        let rt = fresh_runtime();
1880        write_config(&rt, "ai.ner.backend", "llm");
1881        write_config(&rt, "ai.ner.fallback", "use_heuristic");
1882        let scope = make_scope(HashSet::new());
1883        let tokens = tokio::task::spawn_blocking(move || {
1884            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1885        })
1886        .await
1887        .unwrap()
1888        .expect("fallback policy keeps the call OK");
1889        assert!(tokens.keywords.contains(&"passport".to_string()));
1890        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1891    }
1892
1893    /// `backend = llm` with `fallback = empty_on_fail`: auth denies →
1894    /// fallback returns an empty `TokenSet`.
1895    #[tokio::test(flavor = "multi_thread")]
1896    async fn routed_llm_auth_denied_empty_on_fail() {
1897        let rt = fresh_runtime();
1898        write_config(&rt, "ai.ner.backend", "llm");
1899        write_config(&rt, "ai.ner.fallback", "empty_on_fail");
1900        let scope = make_scope(HashSet::new());
1901        let tokens = tokio::task::spawn_blocking(move || {
1902            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1903        })
1904        .await
1905        .unwrap()
1906        .expect("empty_on_fail returns Ok with empty TokenSet");
1907        assert!(tokens.is_empty(), "expected empty TokenSet, got {tokens:?}");
1908    }
1909
1910    /// `backend = llm` with `fallback = propagate`: auth denies →
1911    /// `extract_tokens_routed` surfaces a `RedDBError::Query` so the
1912    /// caller can decide.
1913    #[tokio::test(flavor = "multi_thread")]
1914    async fn routed_llm_auth_denied_propagate_returns_error() {
1915        let rt = fresh_runtime();
1916        write_config(&rt, "ai.ner.backend", "llm");
1917        write_config(&rt, "ai.ner.fallback", "propagate");
1918        let scope = make_scope(HashSet::new());
1919        let err = tokio::task::spawn_blocking(move || {
1920            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1921        })
1922        .await
1923        .unwrap()
1924        .expect_err("propagate must surface the error");
1925        let msg = format!("{err}");
1926        assert!(
1927            msg.contains("propagate") || msg.contains("ai.ner.backend"),
1928            "expected propagate error message, got: {msg}"
1929        );
1930    }
1931
1932    /// AskPipeline end-to-end with `backend = llm` and the default
1933    /// `use_heuristic` fallback: the pipeline still returns tokens (via
1934    /// fallback), Stage 1 is the only routed stage, and the rest of the
1935    /// funnel runs unchanged.
1936    #[tokio::test(flavor = "multi_thread")]
1937    async fn execute_with_llm_backend_falls_back_and_completes_pipeline() {
1938        let rt = fresh_runtime();
1939        write_config(&rt, "ai.ner.backend", "llm");
1940        // default fallback is use_heuristic — leave it implicit.
1941        rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1942            .expect("CREATE TABLE travel");
1943        rt.execute_query(
1944            "INSERT INTO travel (id, passport, notes) VALUES \
1945             (2, 'PT-002', 'incident FDD-12313 escalated')",
1946        )
1947        .expect("seed rows");
1948        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1949        let scope = make_scope(visible);
1950        let ctx = tokio::task::spawn_blocking(move || {
1951            AskPipeline::execute(&rt, &scope, "passport FDD-12313")
1952        })
1953        .await
1954        .unwrap()
1955        .expect("pipeline runs");
1956        assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1957        assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1958        assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1959        assert!(
1960            ctx.filtered_rows
1961                .iter()
1962                .any(|r| r.matched_literal == "FDD-12313"),
1963            "Stage 4 still runs after Stage 1 fallback"
1964        );
1965    }
1966}