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