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