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