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::unified::entity::{EntityData, EntityKind, UnifiedEntity};
47use reddb_types::Value;
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(runtime, scope);
487    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
488    let mut rls_cache: HashMap<String, Option<reddb_rql::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(runtime, scope);
884    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
885    let mut rls_cache: HashMap<String, Option<reddb_rql::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(runtime, 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_embeddings_provider(&kv_getter).ok()?;
1032    if !provider.is_openai_compatible() {
1033        return None;
1034    }
1035    let model = crate::ai::resolve_embeddings_model(&provider, &kv_getter);
1036    let crate::ai::AiConnection { api_key, api_base } =
1037        crate::ai::resolve_connection_from_runtime(&provider, None, runtime).ok()?;
1038    let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
1039    let request = crate::ai::OpenAiEmbeddingRequest {
1040        api_key,
1041        model,
1042        inputs: vec![question.to_string()],
1043        dimensions: None,
1044        api_base,
1045    };
1046    let response = crate::runtime::ai::block_on_ai(async move {
1047        crate::ai::openai_embeddings_async(&transport, request).await
1048    })
1049    .and_then(|result| result)
1050    .ok()?;
1051    response.embeddings.into_iter().next()
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Stage 4 — value filter using literal tokens.
1056// ---------------------------------------------------------------------------
1057
1058/// Walk every candidate collection looking for rows whose columns
1059/// contain any of the literal tokens. Caller-supplied `row_cap`
1060/// bounds the result count; column hints from Stage 2 are visited
1061/// first so promising columns find a match before a full scan.
1062pub fn filter_values(
1063    runtime: &RedDBRuntime,
1064    scope: &EffectiveScope,
1065    candidates: &CandidateCollections,
1066    tokens: &TokenSet,
1067    row_cap: usize,
1068) -> Vec<FilteredRow> {
1069    if tokens.literals.is_empty() || candidates.collections.is_empty() {
1070        return Vec::new();
1071    }
1072    let visible = scope.visible_collections();
1073    let store = runtime.inner.db.store();
1074    let mut out: Vec<FilteredRow> = Vec::new();
1075    let _scope_guard = AskScopeGuard::install(runtime, scope);
1076    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
1077    let mut rls_cache: HashMap<String, Option<reddb_rql::ast::Filter>> = HashMap::new();
1078
1079    'collection: for collection in &candidates.collections {
1080        // Defence-in-depth: redo the visibility check here so a Stage
1081        // 2 regression can't smuggle an out-of-scope collection
1082        // through.
1083        if let Some(set) = visible {
1084            if !set.contains(collection) {
1085                continue;
1086            }
1087        }
1088        let Some(manager) = store.get_collection(collection) else {
1089            continue;
1090        };
1091        let hint_columns: &[String] = candidates
1092            .columns_by_collection
1093            .get(collection)
1094            .map(|v| v.as_slice())
1095            .unwrap_or(&[]);
1096
1097        for entity in manager.scan(snap_ctx.as_ref(), |_| true) {
1098            if !ask_scanned_entity_allowed(runtime, scope, collection, &entity, &mut rls_cache) {
1099                continue;
1100            }
1101            if let Some(hit) = literal_match_in_entity(&entity, &tokens.literals, hint_columns) {
1102                out.push(FilteredRow {
1103                    collection: collection.clone(),
1104                    entity,
1105                    matched_literal: hit.0,
1106                    matched_column: hit.1,
1107                });
1108                if out.len() >= row_cap {
1109                    break 'collection;
1110                }
1111            }
1112        }
1113    }
1114    out
1115}
1116
1117fn ask_entity_allowed(
1118    runtime: &RedDBRuntime,
1119    scope: &EffectiveScope,
1120    collection: &str,
1121    entity: &UnifiedEntity,
1122    snap_ctx: Option<&crate::runtime::impl_core::SnapshotContext>,
1123    rls_cache: &mut HashMap<String, Option<reddb_rql::ast::Filter>>,
1124) -> bool {
1125    if scope
1126        .visible_collections()
1127        .is_some_and(|visible| !visible.contains(collection))
1128    {
1129        return false;
1130    }
1131    runtime.search_entity_allowed(collection, entity, snap_ctx, rls_cache)
1132}
1133
1134fn ask_scanned_entity_allowed(
1135    runtime: &RedDBRuntime,
1136    scope: &EffectiveScope,
1137    collection: &str,
1138    entity: &UnifiedEntity,
1139    rls_cache: &mut HashMap<String, Option<reddb_rql::ast::Filter>>,
1140) -> bool {
1141    if scope
1142        .visible_collections()
1143        .is_some_and(|visible| !visible.contains(collection))
1144    {
1145        return false;
1146    }
1147    runtime.search_entity_rls_allowed(collection, entity, rls_cache)
1148}
1149
1150struct AskScopeGuard {
1151    _snapshot: super::execution_context::CurrentSnapshotGuard,
1152    prev_tenant: Option<String>,
1153    prev_auth: Option<(String, crate::auth::Role)>,
1154    prev_principal: Option<crate::auth::UserId>,
1155}
1156
1157impl AskScopeGuard {
1158    fn install(runtime: &RedDBRuntime, scope: &EffectiveScope) -> Self {
1159        let prev_tenant = crate::runtime::impl_core::current_tenant();
1160        let prev_auth = crate::runtime::impl_core::current_auth_identity();
1161        let prev_principal = super::execution_context::current_auth_principal();
1162
1163        match scope.effective_scope() {
1164            Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant.to_string()),
1165            None => crate::runtime::impl_core::clear_current_tenant(),
1166        }
1167        match scope.identity() {
1168            Some((user, role)) => {
1169                super::execution_context::set_current_auth_projection(user.to_string(), role)
1170            }
1171            None => crate::runtime::impl_core::clear_current_auth_identity(),
1172        }
1173
1174        Self {
1175            _snapshot: super::execution_context::CurrentSnapshotGuard::install(
1176                scope.snapshot_context(runtime),
1177            ),
1178            prev_tenant,
1179            prev_auth,
1180            prev_principal,
1181        }
1182    }
1183}
1184
1185impl Drop for AskScopeGuard {
1186    fn drop(&mut self) {
1187        match self.prev_tenant.take() {
1188            Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant),
1189            None => crate::runtime::impl_core::clear_current_tenant(),
1190        }
1191        match (self.prev_principal.take(), self.prev_auth.take()) {
1192            (Some(principal), Some((_, role))) => {
1193                crate::runtime::impl_core::set_current_auth_principal(principal, role)
1194            }
1195            _ => crate::runtime::impl_core::clear_current_auth_identity(),
1196        }
1197    }
1198}
1199
1200/// Look for any literal in any column value of `entity`. Hint
1201/// columns are checked first; a positive hit short-circuits.
1202fn literal_match_in_entity(
1203    entity: &UnifiedEntity,
1204    literals: &[String],
1205    hint_columns: &[String],
1206) -> Option<(String, Option<String>)> {
1207    let row = match &entity.data {
1208        EntityData::Row(row) => row,
1209        _ => return None,
1210    };
1211
1212    // Pass 1: hint columns first.
1213    for column in hint_columns {
1214        if let Some(value) = row.get_field(column) {
1215            if let Some(lit) = first_literal_in_value(value, literals) {
1216                return Some((lit, Some(column.clone())));
1217            }
1218        }
1219    }
1220    // Pass 2: every other column.
1221    for (name, value) in row.iter_fields() {
1222        if hint_columns.iter().any(|c| c == name) {
1223            continue;
1224        }
1225        if let Some(lit) = first_literal_in_value(value, literals) {
1226            return Some((lit, Some(name.to_string())));
1227        }
1228    }
1229    None
1230}
1231
1232fn first_literal_in_value(value: &Value, literals: &[String]) -> Option<String> {
1233    let rendered = match value {
1234        Value::Text(s) => s.to_string(),
1235        Value::Integer(i) => i.to_string(),
1236        Value::Float(f) => f.to_string(),
1237        Value::Boolean(b) => b.to_string(),
1238        Value::Json(j) => String::from_utf8_lossy(j).to_string(),
1239        _ => return None,
1240    };
1241    for lit in literals {
1242        // Case-sensitive substring match: literals are id-shaped, so
1243        // we want `FDD-12313` to find embedded `FDD-12313` in a free-
1244        // form description column too.
1245        if rendered.contains(lit) {
1246            return Some(lit.clone());
1247        }
1248    }
1249    None
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255
1256    // -- Stage 1 ------------------------------------------------------
1257
1258    #[test]
1259    fn extract_tokens_splits_keywords_and_literals() {
1260        let tokens = extract_tokens("quais as novidades sobre o passport FDD-12313?");
1261        // `quais`, `as`, `sobre`, `o` are stop words; `novidades`,
1262        // `passport` survive.
1263        assert!(tokens.keywords.contains(&"novidades".to_string()));
1264        assert!(tokens.keywords.contains(&"passport".to_string()));
1265        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1266        assert!(!tokens.is_empty());
1267    }
1268
1269    #[test]
1270    fn extract_tokens_returns_empty_for_punctuation_only() {
1271        let tokens = extract_tokens("???   ...");
1272        assert!(tokens.is_empty());
1273    }
1274
1275    #[test]
1276    fn extract_tokens_long_digit_run_is_a_literal() {
1277        let tokens = extract_tokens("show order 987654321 details");
1278        assert!(tokens.literals.contains(&"987654321".to_string()));
1279        assert!(tokens.keywords.contains(&"order".to_string()));
1280        assert!(tokens.keywords.contains(&"details".to_string()));
1281        assert!(tokens.keywords.contains(&"show".to_string()));
1282    }
1283
1284    #[test]
1285    fn extract_tokens_short_uppercase_word_is_keyword_not_literal() {
1286        // "USA" is uppercase but lacks a digit, so it stays a keyword
1287        // (lowercased) — Stage 2 still gets to probe it.
1288        let tokens = extract_tokens("USA exports report");
1289        assert!(tokens.keywords.contains(&"usa".to_string()));
1290        assert!(tokens.literals.is_empty());
1291    }
1292
1293    #[test]
1294    fn extract_tokens_dedups() {
1295        let tokens = extract_tokens("passport passport FDD-1 FDD-1");
1296        assert_eq!(
1297            tokens.keywords.iter().filter(|k| *k == "passport").count(),
1298            1
1299        );
1300        assert_eq!(tokens.literals.iter().filter(|l| *l == "FDD-1").count(), 1);
1301    }
1302
1303    // -- Stage 4 helper ----------------------------------------------
1304
1305    #[test]
1306    fn first_literal_in_value_substring_match() {
1307        let lit = first_literal_in_value(
1308            &Value::text("issue FDD-12313 reported by user"),
1309            &["FDD-12313".to_string()],
1310        );
1311        assert_eq!(lit.as_deref(), Some("FDD-12313"));
1312    }
1313
1314    #[test]
1315    fn first_literal_in_value_no_match_returns_none() {
1316        assert!(
1317            first_literal_in_value(&Value::text("nothing here"), &["FDD-12313".to_string()],)
1318                .is_none()
1319        );
1320    }
1321
1322    // -- Pipeline-wide -----------------------------------------------
1323
1324    use crate::api::RedDBOptions;
1325    use crate::auth::Role;
1326    use crate::runtime::statement_frame::EffectiveScope;
1327    use crate::runtime::RedDBRuntime;
1328    use crate::storage::unified::entity::{
1329        EntityData, EntityId, EntityKind, RowData, UnifiedEntity,
1330    };
1331    use reddb_types::Value;
1332    use std::sync::Arc;
1333
1334    fn make_scope(rt: &RedDBRuntime, visible: HashSet<String>) -> EffectiveScope {
1335        EffectiveScope {
1336            captured_snapshot: None,
1337            tenant: Some("acme".to_string()),
1338            identity: Some(("alice".to_string(), Role::Read)),
1339            snapshot: rt.current_snapshot(),
1340            visible_collections: Some(visible),
1341        }
1342    }
1343
1344    fn fresh_runtime() -> RedDBRuntime {
1345        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime boots")
1346    }
1347
1348    fn test_row(collection: &str, id: u64) -> FilteredRow {
1349        FilteredRow {
1350            collection: collection.to_string(),
1351            entity: UnifiedEntity::new(
1352                EntityId::new(id),
1353                EntityKind::TableRow {
1354                    table: Arc::from(collection),
1355                    row_id: id,
1356                },
1357                EntityData::Row(RowData {
1358                    columns: Vec::new(),
1359                    named: Some(
1360                        [("body".to_string(), Value::text("ticket FDD-1".to_string()))]
1361                            .into_iter()
1362                            .collect(),
1363                    ),
1364                    schema: None,
1365                }),
1366            ),
1367            matched_literal: "FDD-1".to_string(),
1368            matched_column: Some("body".to_string()),
1369        }
1370    }
1371
1372    fn test_graph_hit(collection: &str, id: u64, score: f32, depth: usize) -> GraphHit {
1373        GraphHit {
1374            collection: collection.to_string(),
1375            entity_id: id,
1376            score,
1377            depth,
1378            kind: GraphHitKind::Node,
1379        }
1380    }
1381
1382    fn test_text_hit(collection: &str, id: u64, score: f32) -> TextHit {
1383        TextHit {
1384            collection: collection.to_string(),
1385            entity_id: id,
1386            score,
1387        }
1388    }
1389
1390    struct TenantGuard;
1391
1392    impl TenantGuard {
1393        fn set(tenant: &str) -> Self {
1394            crate::runtime::impl_core::set_current_tenant(tenant.to_string());
1395            Self
1396        }
1397    }
1398
1399    impl Drop for TenantGuard {
1400        fn drop(&mut self) {
1401            crate::runtime::impl_core::clear_current_tenant();
1402        }
1403    }
1404
1405    fn row_text<'a>(entity: &'a UnifiedEntity, field: &str) -> Option<&'a str> {
1406        let row = entity.data.as_row()?;
1407        match row.get_field(field)? {
1408            Value::Text(value) => Some(value.as_ref()),
1409            _ => None,
1410        }
1411    }
1412
1413    #[test]
1414    fn fused_source_order_uses_rrf_and_total_limit() {
1415        let ctx = AskContext {
1416            source_limit: 2,
1417            filtered_rows: vec![test_row("incidents", 2), test_row("incidents", 1)],
1418            vector_hits: vec![
1419                VectorHit {
1420                    collection: "incidents".to_string(),
1421                    entity_id: 1,
1422                    score: 0.91,
1423                },
1424                VectorHit {
1425                    collection: "docs".to_string(),
1426                    entity_id: 9,
1427                    score: 0.88,
1428                },
1429            ],
1430            ..AskContext::default()
1431        };
1432
1433        let order = fused_source_order(&ctx);
1434
1435        assert_eq!(
1436            order,
1437            vec![
1438                FusedSourceRef::FilteredRow(1),
1439                FusedSourceRef::FilteredRow(0)
1440            ]
1441        );
1442    }
1443
1444    #[test]
1445    fn fused_source_order_includes_graph_bucket() {
1446        let ctx = AskContext {
1447            source_limit: 4,
1448            filtered_rows: vec![test_row("incidents", 1)],
1449            text_hits: vec![test_text_hit("articles", 5, 1.2)],
1450            vector_hits: vec![
1451                VectorHit {
1452                    collection: "incidents".to_string(),
1453                    entity_id: 1,
1454                    score: 0.91,
1455                },
1456                VectorHit {
1457                    collection: "docs".to_string(),
1458                    entity_id: 9,
1459                    score: 0.88,
1460                },
1461            ],
1462            graph_hits: vec![test_graph_hit("topology", 7, 0.80, 1)],
1463            ..AskContext::default()
1464        };
1465
1466        let order = fused_source_order(&ctx);
1467
1468        assert_eq!(
1469            order,
1470            vec![
1471                FusedSourceRef::FilteredRow(0),
1472                FusedSourceRef::TextHit(0),
1473                FusedSourceRef::GraphHit(0),
1474                FusedSourceRef::VectorHit(1),
1475            ]
1476        );
1477    }
1478
1479    #[test]
1480    fn text_search_bm25_scoped_ranks_specific_document_first() {
1481        let rt = fresh_runtime();
1482        rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1483            .expect("create docs");
1484        rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1485            .expect("insert specific doc");
1486        rt.execute_query(
1487            "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1488        )
1489        .expect("insert broad doc");
1490
1491        let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1492        let candidates = CandidateCollections {
1493            collections: vec!["docs".to_string()],
1494            columns_by_collection: HashMap::new(),
1495        };
1496        let hits = text_search_bm25_scoped(&rt, &scope, "passport renewal", &candidates, 10);
1497
1498        assert_eq!(hits.len(), 2);
1499        assert!(
1500            hits[0].score > hits[1].score,
1501            "BM25 text bucket should prefer the shorter exact match: {hits:?}"
1502        );
1503    }
1504
1505    #[test]
1506    fn text_search_bm25_scoped_filters_rls_denied_hits() {
1507        let rt = fresh_runtime();
1508        rt.execute_query(
1509            "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) WITH CONTEXT INDEX ON (body)",
1510        )
1511        .expect("create docs");
1512        rt.execute_query(
1513            "INSERT INTO docs (id, tenant_id, body) VALUES \
1514             (1, 'acme', 'shared launch plan'), \
1515             (2, 'globex', 'shared launch plan')",
1516        )
1517        .expect("seed docs");
1518        rt.execute_query(
1519            "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1520        )
1521        .expect("create policy");
1522        rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1523            .expect("enable rls");
1524
1525        let _tenant = TenantGuard::set("acme");
1526        let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1527        let candidates = CandidateCollections {
1528            collections: vec!["docs".to_string()],
1529            columns_by_collection: HashMap::new(),
1530        };
1531
1532        let hits = text_search_bm25_scoped(&rt, &scope, "shared launch", &candidates, 10);
1533
1534        assert_eq!(hits.len(), 1, "RLS should hide the globex hit: {hits:?}");
1535        let entity = rt
1536            .inner
1537            .db
1538            .store()
1539            .get(
1540                "docs",
1541                crate::storage::unified::entity::EntityId::new(hits[0].entity_id),
1542            )
1543            .expect("hit entity exists");
1544        assert_eq!(row_text(&entity, "tenant_id"), Some("acme"));
1545    }
1546
1547    #[test]
1548    fn execute_pipeline_retrieves_known_good_bm25_source_order() {
1549        let rt = fresh_runtime();
1550        rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1551            .expect("create docs");
1552        rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1553            .expect("insert specific doc");
1554        rt.execute_query(
1555            "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1556        )
1557        .expect("insert broad doc");
1558        rt.schema_vocabulary_apply(
1559            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1560                collection: "docs".to_string(),
1561                columns: vec!["body".into()],
1562                type_tags: Vec::new(),
1563                description: None,
1564            },
1565        );
1566
1567        let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1568        let ctx = AskPipeline::execute_with_limit_and_min_score(
1569            &rt,
1570            &scope,
1571            "body passport renewal",
1572            2,
1573            None,
1574            Some(1),
1575        )
1576        .expect("pipeline executes");
1577
1578        assert_eq!(ctx.text_hits.len(), 2);
1579        assert!(
1580            ctx.text_hits[0].score > ctx.text_hits[1].score,
1581            "BM25 source order should prefer the shorter exact match: {:?}",
1582            ctx.text_hits
1583        );
1584        assert!(matches!(
1585            fused_source_order(&ctx).first(),
1586            Some(FusedSourceRef::TextHit(0))
1587        ));
1588    }
1589
1590    #[test]
1591    fn graph_search_scoped_honors_depth() {
1592        let rt = fresh_runtime();
1593        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('alice', 'Alice')")
1594            .expect("insert alice");
1595        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('bob', 'Bob')")
1596            .expect("insert bob");
1597        rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('carol', 'Carol')")
1598            .expect("insert carol");
1599        rt.execute_query(
1600            "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'alice', 'bob')",
1601        )
1602        .expect("insert alice-bob edge");
1603        rt.execute_query(
1604            "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'bob', 'carol')",
1605        )
1606        .expect("insert bob-carol edge");
1607
1608        let scope = make_scope(&rt, ["tales".to_string()].into_iter().collect());
1609        let candidates = CandidateCollections {
1610            collections: vec!["tales".to_string()],
1611            columns_by_collection: HashMap::new(),
1612        };
1613        let depth1 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(1));
1614        let depth2 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(2));
1615
1616        assert!(
1617            depth1.iter().all(|hit| hit.depth <= 1),
1618            "DEPTH 1 returned hits beyond one hop: {depth1:?}"
1619        );
1620        assert!(
1621            depth2.iter().any(|hit| hit.depth == 2),
1622            "DEPTH 2 should include the second-hop graph hit: {depth2:?}"
1623        );
1624    }
1625
1626    #[test]
1627    fn filter_values_filters_rls_denied_rows() {
1628        let rt = fresh_runtime();
1629        rt.execute_query("CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT)")
1630            .expect("create docs");
1631        rt.execute_query(
1632            "INSERT INTO docs (id, tenant_id, body) VALUES \
1633             (1, 'acme', 'incident FDD-12313'), \
1634             (2, 'globex', 'incident FDD-12313')",
1635        )
1636        .expect("seed docs");
1637        rt.execute_query(
1638            "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1639        )
1640        .expect("create policy");
1641        rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1642            .expect("enable rls");
1643
1644        let _tenant = TenantGuard::set("acme");
1645        let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1646        let candidates = CandidateCollections {
1647            collections: vec!["docs".to_string()],
1648            columns_by_collection: HashMap::from([("docs".to_string(), vec!["body".to_string()])]),
1649        };
1650        let tokens = TokenSet {
1651            keywords: vec!["incident".to_string()],
1652            literals: vec!["FDD-12313".to_string()],
1653        };
1654
1655        let rows = filter_values(&rt, &scope, &candidates, &tokens, 10);
1656
1657        assert_eq!(rows.len(), 1, "RLS should hide the globex row: {rows:?}");
1658        assert_eq!(row_text(&rows[0].entity, "tenant_id"), Some("acme"));
1659    }
1660
1661    /// Empty token sets short-circuit with a structured error before
1662    /// any LLM round-trip.
1663    #[test]
1664    fn execute_refuses_empty_token_set() {
1665        let rt = fresh_runtime();
1666        let scope = make_scope(&rt, HashSet::new());
1667        let err = AskPipeline::execute(&rt, &scope, "??? ...")
1668            .expect_err("empty token set must short-circuit");
1669        let msg = format!("{err}");
1670        assert!(
1671            msg.contains("yielded no usable tokens"),
1672            "expected structured empty-token error, got: {msg}"
1673        );
1674    }
1675
1676    /// match_schema drops every collection that's outside
1677    /// `scope.visible_collections`. Using a synthetic vocab via DDL
1678    /// events on the live runtime so the assertion drives real
1679    /// `RedDBRuntime::schema_vocabulary_lookup`.
1680    #[test]
1681    fn match_schema_intersects_with_visible_set() {
1682        let rt = fresh_runtime();
1683        // Two collections both carry a `passport` column. Caller's
1684        // scope only includes `travel`, so the `passport` column hit
1685        // on `secrets` must be dropped.
1686        rt.schema_vocabulary_apply(
1687            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1688                collection: "travel".to_string(),
1689                columns: vec!["id".into(), "passport".into()],
1690                type_tags: Vec::new(),
1691                description: None,
1692            },
1693        );
1694        rt.schema_vocabulary_apply(
1695            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1696                collection: "secrets".to_string(),
1697                columns: vec!["passport".into()],
1698                type_tags: Vec::new(),
1699                description: None,
1700            },
1701        );
1702        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1703        let scope = make_scope(&rt, visible.clone());
1704        let tokens = TokenSet {
1705            keywords: vec!["passport".to_string()],
1706            literals: Vec::new(),
1707        };
1708        let candidates = match_schema(&rt, &scope, &tokens).expect("ok");
1709        assert_eq!(candidates.collections, vec!["travel".to_string()]);
1710        assert!(!candidates.collections.contains(&"secrets".to_string()));
1711        // Column hint surfaces for the surviving collection.
1712        let cols = candidates
1713            .columns_by_collection
1714            .get("travel")
1715            .expect("hint columns");
1716        assert!(cols.contains(&"passport".to_string()));
1717    }
1718
1719    // -- Property test (issue #121 acceptance row) -------------------
1720    //
1721    // For 256 random (question, scope) pairs: every Stage 4 row's
1722    // collection MUST be inside `scope.visible_collections`. Drives
1723    // `filter_values` directly with synthetic candidate sets so the
1724    // invariant is pinned without an embedding API.
1725
1726    use proptest::prelude::*;
1727
1728    fn arb_collection() -> impl Strategy<Value = String> {
1729        "[a-z]{1,4}"
1730    }
1731
1732    fn arb_visible() -> impl Strategy<Value = HashSet<String>> {
1733        prop::collection::hash_set(arb_collection(), 0..6)
1734    }
1735
1736    fn arb_candidates() -> impl Strategy<Value = Vec<String>> {
1737        prop::collection::vec(arb_collection(), 0..8)
1738    }
1739
1740    proptest! {
1741        #![proptest_config(ProptestConfig::with_cases(256))]
1742        #[test]
1743        fn stage4_rows_subset_of_visible_collections(
1744            visible in arb_visible(),
1745            candidate_names in arb_candidates(),
1746            literal_count in 0usize..3,
1747        ) {
1748            // Single runtime shared across cases — `filter_values`
1749            // only reads (no mutation), and we need the empty-store
1750            // path so the invariant we want to pin is "no row escapes
1751            // visible_collections" rather than "any specific row
1752            // surfaces".
1753            let rt = PROPTEST_RUNTIME.get_or_init(fresh_runtime);
1754            let candidates = CandidateCollections {
1755                collections: candidate_names,
1756                columns_by_collection: HashMap::new(),
1757            };
1758            let literals: Vec<String> = (0..literal_count)
1759                .map(|i| format!("ID-{i}"))
1760                .collect();
1761            let tokens = TokenSet {
1762                keywords: vec!["passport".to_string()],
1763                literals,
1764            };
1765            let scope = make_scope(&rt, visible.clone());
1766            let rows = filter_values(rt, &scope, &candidates, &tokens, DEFAULT_ROW_CAP);
1767            for row in &rows {
1768                prop_assert!(
1769                    visible.contains(&row.collection),
1770                    "Stage 4 leaked row collection={} not in visible={:?}",
1771                    row.collection, visible
1772                );
1773            }
1774        }
1775    }
1776
1777    static PROPTEST_RUNTIME: std::sync::OnceLock<RedDBRuntime> = std::sync::OnceLock::new();
1778
1779    // -- Integration test (issue #121 acceptance row) ----------------
1780    //
1781    // Drives the four stages end-to-end through `AskPipeline::execute`
1782    // with the question the issue calls out:
1783    //   "quais as novidades sobre o passport FDD-12313?"
1784    //
1785    // Stage 1 must extract `passport` + `FDD-12313`. Stage 2 must
1786    // narrow to the `passports` collection (visible to the caller).
1787    // Stage 3 silently yields zero hits without an embedding provider
1788    // — that's expected for the test fixture path. Stage 4 must surface
1789    // the row whose `notes` column embeds `FDD-12313`.
1790
1791    #[test]
1792    fn integration_passport_fdd_12313_funnels_through_four_stages() {
1793        let rt = fresh_runtime();
1794        // The collection name itself + a `passport` column both feed
1795        // Stage 2's vocabulary; either one is enough for the question
1796        // "passport FDD-12313" to land here.
1797        rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1798            .expect("CREATE TABLE travel");
1799        rt.execute_query(
1800            "INSERT INTO travel (id, passport, notes) VALUES \
1801             (1, 'BR-001', 'unrelated note'), \
1802             (2, 'PT-002', 'incident FDD-12313 escalated'), \
1803             (3, 'US-003', 'standard renewal')",
1804        )
1805        .expect("seed rows");
1806        // Out-of-scope collection — must NEVER surface in Stage 4.
1807        rt.execute_query("CREATE TABLE secrets (id INT, passport TEXT)")
1808            .expect("CREATE TABLE secrets");
1809        rt.execute_query("INSERT INTO secrets (id, passport) VALUES (99, 'FDD-12313')")
1810            .expect("seed secrets");
1811
1812        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1813        let scope = make_scope(&rt, visible);
1814
1815        let ctx = AskPipeline::execute(
1816            &rt,
1817            &scope,
1818            "quais as novidades sobre o passport FDD-12313?",
1819        )
1820        .expect("pipeline runs");
1821
1822        // Stage 1: passport + FDD-12313 surfaced.
1823        assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1824        assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1825
1826        // Stage 2: candidates narrowed to `travel` (the `passport`
1827        // column on `secrets` is dropped by the visible-set
1828        // intersection).
1829        assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1830
1831        // Stage 3: best-effort embedding — without a provider
1832        // configured, Stage 3 silently returns []; the rest of the
1833        // funnel still runs.
1834        let _ = &ctx.vector_hits;
1835
1836        // Stage 4: the row whose `notes` mentions `FDD-12313`
1837        // surfaces; the out-of-scope `secrets` row does NOT.
1838        assert!(
1839            ctx.filtered_rows
1840                .iter()
1841                .any(|r| r.collection == "travel" && r.matched_literal == "FDD-12313"),
1842            "expected travel row with FDD-12313 match, got: {:?}",
1843            ctx.filtered_rows
1844        );
1845        for row in &ctx.filtered_rows {
1846            assert_ne!(
1847                row.collection, "secrets",
1848                "secrets row leaked into Stage 4 output"
1849            );
1850        }
1851
1852        // Per-stage timing recorded.
1853        // (The Instant-based measurements may be 0 on very fast hosts;
1854        // we only check the field exists and was populated.)
1855        let _ = ctx.timings.extract_us
1856            + ctx.timings.schema_us
1857            + ctx.timings.vector_us
1858            + ctx.timings.filter_us;
1859    }
1860
1861    // -- Stage 1 routing (Lane 4/5: LlmNer wiring) -------------------
1862    //
1863    // The routing dispatcher is an `extract_tokens_routed` helper that
1864    // reads `ai.ner.backend` and either passes through to the heuristic
1865    // (default) or routes through `LlmNer::extract`. Today the
1866    // capability gate (`EffectiveScope::has_capability`) is a placeholder
1867    // that always returns `false`, so the LLM path always denies and
1868    // the configured `HeuristicFallback` policy fires. The tests below
1869    // pin every observable: heuristic stays the default, `llm + auth
1870    // denied` honours each fallback mode, and the one-shot info log is
1871    // best-effort (we don't assert it directly to avoid a coupling to
1872    // the global `tracing` subscriber).
1873
1874    fn write_config(rt: &RedDBRuntime, key: &str, value: &str) {
1875        let store = rt.inner.db.store();
1876        store.set_config_tree(key, &crate::serde_json::Value::String(value.to_string()));
1877    }
1878
1879    /// Default backend stays heuristic — even with an open scope, the
1880    /// pipeline returns the same tokens it would without any config.
1881    #[test]
1882    fn routed_default_backend_runs_heuristic() {
1883        let rt = fresh_runtime();
1884        let scope = make_scope(&rt, HashSet::new());
1885        let tokens = extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1886            .expect("heuristic path is infallible");
1887        assert!(tokens.keywords.contains(&"passport".to_string()));
1888        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1889    }
1890
1891    /// `backend = llm` with `fallback = use_heuristic`: capability
1892    /// denies (placeholder) → fallback → heuristic tokens surface.
1893    #[tokio::test(flavor = "multi_thread")]
1894    async fn routed_llm_auth_denied_uses_heuristic_fallback() {
1895        let rt = fresh_runtime();
1896        write_config(&rt, "ai.ner.backend", "llm");
1897        write_config(&rt, "ai.ner.fallback", "use_heuristic");
1898        let scope = make_scope(&rt, HashSet::new());
1899        let tokens = tokio::task::spawn_blocking(move || {
1900            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1901        })
1902        .await
1903        .unwrap()
1904        .expect("fallback policy keeps the call OK");
1905        assert!(tokens.keywords.contains(&"passport".to_string()));
1906        assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1907    }
1908
1909    /// `backend = llm` with `fallback = empty_on_fail`: auth denies →
1910    /// fallback returns an empty `TokenSet`.
1911    #[tokio::test(flavor = "multi_thread")]
1912    async fn routed_llm_auth_denied_empty_on_fail() {
1913        let rt = fresh_runtime();
1914        write_config(&rt, "ai.ner.backend", "llm");
1915        write_config(&rt, "ai.ner.fallback", "empty_on_fail");
1916        let scope = make_scope(&rt, HashSet::new());
1917        let tokens = tokio::task::spawn_blocking(move || {
1918            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1919        })
1920        .await
1921        .unwrap()
1922        .expect("empty_on_fail returns Ok with empty TokenSet");
1923        assert!(tokens.is_empty(), "expected empty TokenSet, got {tokens:?}");
1924    }
1925
1926    /// `backend = llm` with `fallback = propagate`: auth denies →
1927    /// `extract_tokens_routed` surfaces a `RedDBError::Query` so the
1928    /// caller can decide.
1929    #[tokio::test(flavor = "multi_thread")]
1930    async fn routed_llm_auth_denied_propagate_returns_error() {
1931        let rt = fresh_runtime();
1932        write_config(&rt, "ai.ner.backend", "llm");
1933        write_config(&rt, "ai.ner.fallback", "propagate");
1934        let scope = make_scope(&rt, HashSet::new());
1935        let err = tokio::task::spawn_blocking(move || {
1936            extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1937        })
1938        .await
1939        .unwrap()
1940        .expect_err("propagate must surface the error");
1941        let msg = format!("{err}");
1942        assert!(
1943            msg.contains("propagate") || msg.contains("ai.ner.backend"),
1944            "expected propagate error message, got: {msg}"
1945        );
1946    }
1947
1948    /// AskPipeline end-to-end with `backend = llm` and the default
1949    /// `use_heuristic` fallback: the pipeline still returns tokens (via
1950    /// fallback), Stage 1 is the only routed stage, and the rest of the
1951    /// funnel runs unchanged.
1952    #[tokio::test(flavor = "multi_thread")]
1953    async fn execute_with_llm_backend_falls_back_and_completes_pipeline() {
1954        let rt = fresh_runtime();
1955        write_config(&rt, "ai.ner.backend", "llm");
1956        // default fallback is use_heuristic — leave it implicit.
1957        rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1958            .expect("CREATE TABLE travel");
1959        rt.execute_query(
1960            "INSERT INTO travel (id, passport, notes) VALUES \
1961             (2, 'PT-002', 'incident FDD-12313 escalated')",
1962        )
1963        .expect("seed rows");
1964        let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1965        let scope = make_scope(&rt, visible);
1966        let ctx = tokio::task::spawn_blocking(move || {
1967            AskPipeline::execute(&rt, &scope, "passport FDD-12313")
1968        })
1969        .await
1970        .unwrap()
1971        .expect("pipeline runs");
1972        assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1973        assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1974        assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1975        assert!(
1976            ctx.filtered_rows
1977                .iter()
1978                .any(|r| r.matched_literal == "FDD-12313"),
1979            "Stage 4 still runs after Stage 1 fallback"
1980        );
1981    }
1982}