Skip to main content

fathomdb/
search.rs

1//! Tethered query builders for the Phase 1 adaptive search surface.
2//!
3//! These builders wrap the AST-only [`fathomdb_query::QueryBuilder`] and carry
4//! a borrow of the [`Engine`] so that a zero-arg `.execute()` terminal can
5//! route to the right coordinator entry point by type. Non-search chains
6//! return [`QueryRows`]; `.text_search(...).execute()` returns [`SearchRows`].
7
8use fathomdb_engine::{EngineError, GroupedQueryRows, QueryRows};
9use fathomdb_query::{
10    BuilderValidationError, CompileError, CompiledGroupedQuery, CompiledQuery,
11    CompiledRawVectorSearch, CompiledRetrievalPlan, CompiledSearchPlan, CompiledSemanticSearch,
12    CompiledVectorSearch, QueryAst, QueryBuilder, QueryStep, SearchRows, TextQuery,
13    compile_grouped_query, compile_search, compile_search_plan_from_queries, compile_vector_search,
14};
15
16use crate::Engine;
17
18/// Shared fusion gate implementation used by every tethered builder.
19///
20/// Resolves the FTS property schema for `kind` via the engine's admin
21/// handle and verifies that `path` is in the registered include list.
22/// On success the caller may safely append a fused predicate variant to
23/// the underlying AST builder.
24///
25/// # Errors
26/// - [`BuilderValidationError::KindRequiredForFusion`] if `kind` is empty.
27/// - [`BuilderValidationError::MissingPropertyFtsSchema`] if no schema
28///   is registered for the kind.
29/// - [`BuilderValidationError::PathNotIndexed`] if a schema exists but
30///   does not include `path`.
31fn validate_fusable_property_path(
32    engine: &Engine,
33    kind: &str,
34    path: &str,
35    method: &str,
36) -> Result<(), BuilderValidationError> {
37    if kind.is_empty() {
38        return Err(BuilderValidationError::KindRequiredForFusion {
39            method: method.to_owned(),
40        });
41    }
42    let schema = engine.describe_fts_property_schema(kind).map_err(|_| {
43        BuilderValidationError::MissingPropertyFtsSchema {
44            kind: kind.to_owned(),
45        }
46    })?;
47    let schema = schema.ok_or_else(|| BuilderValidationError::MissingPropertyFtsSchema {
48        kind: kind.to_owned(),
49    })?;
50    if !schema.property_paths.iter().any(|p| p == path) {
51        return Err(BuilderValidationError::PathNotIndexed {
52            kind: kind.to_owned(),
53            path: path.to_owned(),
54        });
55    }
56    Ok(())
57}
58
59/// Resolve the kind currently bound on a filter accumulator's `KindEq`
60/// predicate, if any. The adaptive `FallbackSearchBuilder` path has no
61/// explicit `root_kind` field — its kind comes from a chained
62/// `filter_kind_eq`. This helper walks the accumulator's AST steps and
63/// returns the most recent `KindEq` value.
64fn filter_builder_kind(builder: &QueryBuilder) -> Option<&str> {
65    for step in &builder.ast().steps {
66        if let QueryStep::Filter(fathomdb_query::Predicate::KindEq(kind)) = step {
67            return Some(kind.as_str());
68        }
69    }
70    None
71}
72
73/// Tethered node query builder.
74///
75/// Returned by [`Engine::query`]. Carries an `&Engine` so that terminal
76/// methods can dispatch directly to the coordinator. The underlying AST is
77/// the same [`QueryBuilder`] the query crate has always produced — this is
78/// purely an execution tether, not a new AST.
79#[must_use]
80pub struct NodeQueryBuilder<'e> {
81    engine: &'e Engine,
82    inner: QueryBuilder,
83}
84
85impl<'e> NodeQueryBuilder<'e> {
86    pub(crate) fn new(engine: &'e Engine, kind: impl Into<String>) -> Self {
87        Self {
88            engine,
89            inner: QueryBuilder::nodes(kind),
90        }
91    }
92
93    /// Transition this chain into the unified Phase 12 retrieval builder.
94    ///
95    /// `search()` is the primary client-facing retrieval entry point per
96    /// `dev/design-adaptive-text-search-surface-addendum-1-vec.md` §Public
97    /// Surface. Subsequent filters accumulate on the returned
98    /// [`SearchBuilder`] and `.execute()` returns [`SearchRows`] populated
99    /// from the unified retrieval planner: text strict, optional text
100    /// relaxed, and (in a future phase) vector retrieval, fused under the
101    /// addendum's block precedence rules.
102    ///
103    /// **v1 scope**: the planner's vector branch slot is wired
104    /// architecturally but never fires through `search()` because read-time
105    /// embedding of natural-language queries is deferred. Callers who need
106    /// vector retrieval today should use the advanced `vector_search()`
107    /// override directly with a caller-provided vector literal.
108    pub fn search(self, query: impl Into<String>, limit: usize) -> SearchBuilder<'e> {
109        SearchBuilder::new(
110            self.engine,
111            self.inner.ast().root_kind.clone(),
112            query,
113            limit,
114        )
115    }
116
117    /// Transition this chain into a text-search builder. Subsequent filters
118    /// accumulate on the search builder and `.execute()` returns
119    /// [`SearchRows`] rather than [`QueryRows`].
120    pub fn text_search(self, query: impl Into<String>, limit: usize) -> TextSearchBuilder<'e> {
121        TextSearchBuilder {
122            engine: self.engine,
123            inner: self.inner.text_search(query, limit),
124            attribution_requested: false,
125        }
126    }
127
128    /// Transition this chain into a vector-search builder. Subsequent
129    /// filters accumulate on the vector-search builder and `.execute()`
130    /// returns [`SearchRows`] populated with the vector retrieval block.
131    ///
132    /// Phase 11 (HITL-Q5 closure): this method switches to a type-state
133    /// terminal returning [`VectorSearchBuilder`], mirroring
134    /// [`NodeQueryBuilder::text_search`]. The old self-returning form is
135    /// no longer available on the facade surface; advanced callers that
136    /// need the flat `vector_search` AST step alongside other pipeline
137    /// steps can still reach it via [`QueryBuilder::vector_search`] on
138    /// the untethered builder.
139    pub fn vector_search(self, query: impl Into<String>, limit: usize) -> VectorSearchBuilder<'e> {
140        VectorSearchBuilder::new(
141            self.engine,
142            self.inner.ast().root_kind.clone(),
143            query,
144            limit,
145        )
146    }
147
148    /// Pack F1: start a semantic-search chain. The engine embeds `text` at
149    /// query time using the database-wide active profile embedder and runs
150    /// KNN against `vec_<kind>`. See the design doc §Query API and
151    /// §Failure And Degradation Semantics for the full contract.
152    pub fn semantic_search(
153        self,
154        text: impl Into<String>,
155        limit: usize,
156    ) -> SemanticSearchBuilder<'e> {
157        SemanticSearchBuilder {
158            engine: self.engine,
159            root_kind: self.inner.ast().root_kind.clone(),
160            text: text.into(),
161            limit,
162        }
163    }
164
165    /// Pack F1: start a raw-vector-search chain. The caller supplies a
166    /// dense vector; the engine skips the read-time embedder and binds
167    /// `vec` directly to the per-kind `vec_<kind>` KNN scan. The vector's
168    /// length must match the active embedding profile's dimension.
169    pub fn raw_vector_search(self, vec: Vec<f32>, limit: usize) -> RawVectorSearchBuilder<'e> {
170        RawVectorSearchBuilder {
171            engine: self.engine,
172            root_kind: self.inner.ast().root_kind.clone(),
173            vec,
174            limit,
175        }
176    }
177
178    /// Add a graph traversal step.
179    pub fn traverse(
180        mut self,
181        direction: fathomdb_query::TraverseDirection,
182        label: impl Into<String>,
183        max_depth: usize,
184    ) -> Self {
185        self.inner = self.inner.traverse(direction, label, max_depth);
186        self
187    }
188
189    /// Filter results to a single logical ID.
190    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
191        self.inner = self.inner.filter_logical_id_eq(logical_id);
192        self
193    }
194
195    /// Filter results to nodes matching the given kind.
196    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
197        self.inner = self.inner.filter_kind_eq(kind);
198        self
199    }
200
201    /// Filter results to nodes matching the given `source_ref`.
202    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
203        self.inner = self.inner.filter_source_ref_eq(source_ref);
204        self
205    }
206
207    /// Filter results to nodes where `content_ref` is not NULL.
208    pub fn filter_content_ref_not_null(mut self) -> Self {
209        self.inner = self.inner.filter_content_ref_not_null();
210        self
211    }
212
213    /// Filter results to nodes matching the given `content_ref` URI.
214    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
215        self.inner = self.inner.filter_content_ref_eq(content_ref);
216        self
217    }
218
219    /// Filter results where a JSON property at `path` equals the given text value.
220    pub fn filter_json_text_eq(
221        mut self,
222        path: impl Into<String>,
223        value: impl Into<String>,
224    ) -> Self {
225        self.inner = self.inner.filter_json_text_eq(path, value);
226        self
227    }
228
229    /// Filter results where a JSON property at `path` equals the given boolean value.
230    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
231        self.inner = self.inner.filter_json_bool_eq(path, value);
232        self
233    }
234
235    /// Filter results where a JSON integer at `path` is greater than `value`.
236    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
237        self.inner = self.inner.filter_json_integer_gt(path, value);
238        self
239    }
240
241    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
242    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
243        self.inner = self.inner.filter_json_integer_gte(path, value);
244        self
245    }
246
247    /// Filter results where a JSON integer at `path` is less than `value`.
248    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
249        self.inner = self.inner.filter_json_integer_lt(path, value);
250        self
251    }
252
253    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
254    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
255        self.inner = self.inner.filter_json_integer_lte(path, value);
256        self
257    }
258
259    /// Filter results where a JSON timestamp at `path` is after `value`.
260    pub fn filter_json_timestamp_gt(mut self, path: impl Into<String>, value: i64) -> Self {
261        self.inner = self.inner.filter_json_timestamp_gt(path, value);
262        self
263    }
264
265    /// Filter results where a JSON timestamp at `path` is at or after `value`.
266    pub fn filter_json_timestamp_gte(mut self, path: impl Into<String>, value: i64) -> Self {
267        self.inner = self.inner.filter_json_timestamp_gte(path, value);
268        self
269    }
270
271    /// Filter results where a JSON timestamp at `path` is before `value`.
272    pub fn filter_json_timestamp_lt(mut self, path: impl Into<String>, value: i64) -> Self {
273        self.inner = self.inner.filter_json_timestamp_lt(path, value);
274        self
275    }
276
277    /// Filter results where a JSON timestamp at `path` is at or before `value`.
278    pub fn filter_json_timestamp_lte(mut self, path: impl Into<String>, value: i64) -> Self {
279        self.inner = self.inner.filter_json_timestamp_lte(path, value);
280        self
281    }
282
283    /// Filter results where a JSON text property at `path` equals
284    /// `value`, pushing the predicate into the inner search CTE so the
285    /// CTE `LIMIT` applies *after* the filter runs.
286    ///
287    /// # Errors
288    /// Returns [`BuilderValidationError`] if the root kind has no
289    /// registered property-FTS schema or the schema does not cover
290    /// `path`.
291    pub fn filter_json_fused_text_eq(
292        mut self,
293        path: impl Into<String>,
294        value: impl Into<String>,
295    ) -> Result<Self, BuilderValidationError> {
296        let path = path.into();
297        let kind = self.inner.ast().root_kind.clone();
298        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_eq")?;
299        self.inner = self.inner.filter_json_fused_text_eq_unchecked(path, value);
300        Ok(self)
301    }
302
303    /// Filter results where a JSON text property at `path` is one of
304    /// `values`, with fusion semantics. See
305    /// [`Self::filter_json_fused_text_eq`] for the contract.
306    ///
307    /// # Errors
308    /// Returns [`BuilderValidationError`] if the root kind has no
309    /// registered property-FTS schema or the schema does not cover
310    /// `path`.
311    pub fn filter_json_fused_text_in(
312        mut self,
313        path: impl Into<String>,
314        values: Vec<String>,
315    ) -> Result<Self, BuilderValidationError> {
316        let path = path.into();
317        let kind = self.inner.ast().root_kind.clone();
318        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_in")?;
319        self.inner = self.inner.filter_json_fused_text_in_unchecked(path, values);
320        Ok(self)
321    }
322
323    /// Filter results where a JSON text property at `path` is one of
324    /// `values`. Non-fused; no FTS schema required.
325    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
326        self.inner = self.inner.filter_json_text_in(path, values);
327        self
328    }
329
330    /// Filter results where a JSON timestamp at `path` is strictly
331    /// greater than `value`, with fusion semantics.
332    ///
333    /// # Errors
334    /// See [`Self::filter_json_fused_text_eq`].
335    pub fn filter_json_fused_timestamp_gt(
336        mut self,
337        path: impl Into<String>,
338        value: i64,
339    ) -> Result<Self, BuilderValidationError> {
340        let path = path.into();
341        let kind = self.inner.ast().root_kind.clone();
342        validate_fusable_property_path(
343            self.engine,
344            &kind,
345            &path,
346            "filter_json_fused_timestamp_gt",
347        )?;
348        self.inner = self
349            .inner
350            .filter_json_fused_timestamp_gt_unchecked(path, value);
351        Ok(self)
352    }
353
354    /// Filter results where a JSON timestamp at `path` is at or after
355    /// `value`, with fusion semantics.
356    ///
357    /// # Errors
358    /// See [`Self::filter_json_fused_text_eq`].
359    pub fn filter_json_fused_timestamp_gte(
360        mut self,
361        path: impl Into<String>,
362        value: i64,
363    ) -> Result<Self, BuilderValidationError> {
364        let path = path.into();
365        let kind = self.inner.ast().root_kind.clone();
366        validate_fusable_property_path(
367            self.engine,
368            &kind,
369            &path,
370            "filter_json_fused_timestamp_gte",
371        )?;
372        self.inner = self
373            .inner
374            .filter_json_fused_timestamp_gte_unchecked(path, value);
375        Ok(self)
376    }
377
378    /// Filter results where a JSON timestamp at `path` is strictly
379    /// before `value`, with fusion semantics.
380    ///
381    /// # Errors
382    /// See [`Self::filter_json_fused_text_eq`].
383    pub fn filter_json_fused_timestamp_lt(
384        mut self,
385        path: impl Into<String>,
386        value: i64,
387    ) -> Result<Self, BuilderValidationError> {
388        let path = path.into();
389        let kind = self.inner.ast().root_kind.clone();
390        validate_fusable_property_path(
391            self.engine,
392            &kind,
393            &path,
394            "filter_json_fused_timestamp_lt",
395        )?;
396        self.inner = self
397            .inner
398            .filter_json_fused_timestamp_lt_unchecked(path, value);
399        Ok(self)
400    }
401
402    /// Filter results where a JSON timestamp at `path` is at or before
403    /// `value`, with fusion semantics.
404    ///
405    /// # Errors
406    /// See [`Self::filter_json_fused_text_eq`].
407    pub fn filter_json_fused_timestamp_lte(
408        mut self,
409        path: impl Into<String>,
410        value: i64,
411    ) -> Result<Self, BuilderValidationError> {
412        let path = path.into();
413        let kind = self.inner.ast().root_kind.clone();
414        validate_fusable_property_path(
415            self.engine,
416            &kind,
417            &path,
418            "filter_json_fused_timestamp_lte",
419        )?;
420        self.inner = self
421            .inner
422            .filter_json_fused_timestamp_lte_unchecked(path, value);
423        Ok(self)
424    }
425
426    /// Filter results where a JSON boolean property at `path` equals
427    /// `value`, with fusion semantics. See
428    /// [`Self::filter_json_fused_text_eq`] for the contract.
429    ///
430    /// # Errors
431    /// See [`Self::filter_json_fused_text_eq`].
432    pub fn filter_json_fused_bool_eq(
433        mut self,
434        path: impl Into<String>,
435        value: bool,
436    ) -> Result<Self, BuilderValidationError> {
437        let path = path.into();
438        let kind = self.inner.ast().root_kind.clone();
439        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_bool_eq")?;
440        self.inner = self.inner.filter_json_fused_bool_eq_unchecked(path, value);
441        Ok(self)
442    }
443
444    /// Add an expansion slot that traverses edges per root result.
445    ///
446    /// Pass `filter: None` to preserve the existing behavior. `filter: Some(_)` is
447    /// accepted by the AST but the compilation is not yet implemented (Pack 3).
448    /// Pass `edge_filter: None` to preserve pre-Pack-D behavior.
449    pub fn expand(
450        mut self,
451        slot: impl Into<String>,
452        direction: fathomdb_query::TraverseDirection,
453        label: impl Into<String>,
454        max_depth: usize,
455        filter: Option<fathomdb_query::Predicate>,
456        edge_filter: Option<fathomdb_query::Predicate>,
457    ) -> Self {
458        self.inner = self
459            .inner
460            .expand(slot, direction, label, max_depth, filter, edge_filter);
461        self
462    }
463
464    /// Set the final row limit.
465    pub fn limit(mut self, limit: usize) -> Self {
466        self.inner = self.inner.limit(limit);
467        self
468    }
469
470    /// Borrow the underlying [`QueryBuilder`].
471    #[must_use]
472    pub fn as_builder(&self) -> &QueryBuilder {
473        &self.inner
474    }
475
476    /// Consume the tether and return the underlying AST-only builder.
477    #[must_use]
478    pub fn into_builder(self) -> QueryBuilder {
479        self.inner
480    }
481
482    /// Consume the tether and return the underlying [`QueryAst`].
483    #[must_use]
484    pub fn into_ast(self) -> fathomdb_query::QueryAst {
485        self.inner.into_ast()
486    }
487
488    /// Compile this query to a [`CompiledQuery`]. Mirrors
489    /// [`QueryBuilder::compile`].
490    ///
491    /// # Errors
492    /// Returns [`CompileError`] if compilation fails.
493    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
494        self.inner.compile()
495    }
496
497    /// Compile this query into a grouped plan. Mirrors
498    /// [`QueryBuilder::compile_grouped`].
499    ///
500    /// # Errors
501    /// Returns [`CompileError`] if grouped compilation fails.
502    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
503        self.inner.compile_grouped()
504    }
505
506    /// Execute the query and return matching node rows.
507    ///
508    /// # Errors
509    /// Returns [`EngineError`] if compilation or execution fails.
510    pub fn execute(&self) -> Result<QueryRows, EngineError> {
511        let compiled = self
512            .inner
513            .compile()
514            .map_err(|e| EngineError::InvalidConfig(format!("query compilation failed: {e}")))?;
515        self.engine.coordinator().execute_compiled_read(&compiled)
516    }
517
518    /// Execute the grouped query and return root rows plus named expansion slots.
519    ///
520    /// # Errors
521    /// Returns [`EngineError`] if compilation or execution fails.
522    pub fn execute_grouped(self) -> Result<GroupedQueryRows, EngineError> {
523        let compiled = self.inner.compile_grouped().map_err(|e| {
524            EngineError::InvalidConfig(format!("grouped query compilation failed: {e}"))
525        })?;
526        self.engine
527            .coordinator()
528            .execute_compiled_grouped_read(&compiled)
529    }
530}
531
532/// Tethered text-search builder returned from
533/// [`NodeQueryBuilder::text_search`].
534///
535/// Accumulates filter predicates alongside the text-search step and dispatches
536/// `.execute()` through [`fathomdb_engine::ExecutionCoordinator::execute_compiled_search`],
537/// returning [`SearchRows`] populated with score, source, snippet, and
538/// active-version `written_at` values.
539#[must_use]
540pub struct TextSearchBuilder<'e> {
541    engine: &'e Engine,
542    inner: QueryBuilder,
543    attribution_requested: bool,
544}
545
546impl TextSearchBuilder<'_> {
547    /// Request per-hit match attribution.
548    ///
549    /// When set, the coordinator populates
550    /// [`SearchHit::attribution`](fathomdb_query::SearchHit::attribution) on
551    /// every hit with the set of property paths (or `"text_content"` for
552    /// chunk hits) that contributed to the match. Without this flag (the
553    /// default), attribution stays `None` and the Phase 4 position map is not
554    /// read at all — it is a pay-as-you-go feature.
555    pub fn with_match_attribution(mut self) -> Self {
556        self.attribution_requested = true;
557        self
558    }
559
560    /// Filter results to a single logical ID.
561    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
562        self.inner = self.inner.filter_logical_id_eq(logical_id);
563        self
564    }
565
566    /// Filter results to nodes matching the given kind.
567    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
568        self.inner = self.inner.filter_kind_eq(kind);
569        self
570    }
571
572    /// Filter results to nodes matching the given `source_ref`.
573    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
574        self.inner = self.inner.filter_source_ref_eq(source_ref);
575        self
576    }
577
578    /// Filter results to nodes where `content_ref` is not NULL.
579    pub fn filter_content_ref_not_null(mut self) -> Self {
580        self.inner = self.inner.filter_content_ref_not_null();
581        self
582    }
583
584    /// Filter results to nodes matching the given `content_ref` URI.
585    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
586        self.inner = self.inner.filter_content_ref_eq(content_ref);
587        self
588    }
589
590    /// Filter results where a JSON property at `path` equals the given text value.
591    pub fn filter_json_text_eq(
592        mut self,
593        path: impl Into<String>,
594        value: impl Into<String>,
595    ) -> Self {
596        self.inner = self.inner.filter_json_text_eq(path, value);
597        self
598    }
599
600    /// Filter results where a JSON property at `path` equals the given boolean value.
601    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
602        self.inner = self.inner.filter_json_bool_eq(path, value);
603        self
604    }
605
606    /// Filter results where a JSON integer at `path` is greater than `value`.
607    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
608        self.inner = self.inner.filter_json_integer_gt(path, value);
609        self
610    }
611
612    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
613    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
614        self.inner = self.inner.filter_json_integer_gte(path, value);
615        self
616    }
617
618    /// Filter results where a JSON integer at `path` is less than `value`.
619    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
620        self.inner = self.inner.filter_json_integer_lt(path, value);
621        self
622    }
623
624    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
625    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
626        self.inner = self.inner.filter_json_integer_lte(path, value);
627        self
628    }
629
630    /// Filter results where a JSON timestamp at `path` is after `value`.
631    pub fn filter_json_timestamp_gt(mut self, path: impl Into<String>, value: i64) -> Self {
632        self.inner = self.inner.filter_json_timestamp_gt(path, value);
633        self
634    }
635
636    /// Filter results where a JSON timestamp at `path` is at or after `value`.
637    pub fn filter_json_timestamp_gte(mut self, path: impl Into<String>, value: i64) -> Self {
638        self.inner = self.inner.filter_json_timestamp_gte(path, value);
639        self
640    }
641
642    /// Filter results where a JSON timestamp at `path` is before `value`.
643    pub fn filter_json_timestamp_lt(mut self, path: impl Into<String>, value: i64) -> Self {
644        self.inner = self.inner.filter_json_timestamp_lt(path, value);
645        self
646    }
647
648    /// Filter results where a JSON timestamp at `path` is at or before `value`.
649    pub fn filter_json_timestamp_lte(mut self, path: impl Into<String>, value: i64) -> Self {
650        self.inner = self.inner.filter_json_timestamp_lte(path, value);
651        self
652    }
653
654    /// Filter results where a JSON text property at `path` equals
655    /// `value`, with fusion semantics. See
656    /// [`NodeQueryBuilder::filter_json_fused_text_eq`] for the contract.
657    ///
658    /// # Errors
659    /// Returns [`BuilderValidationError`] if the root kind has no
660    /// registered property-FTS schema or the schema does not cover
661    /// `path`.
662    pub fn filter_json_fused_text_eq(
663        mut self,
664        path: impl Into<String>,
665        value: impl Into<String>,
666    ) -> Result<Self, BuilderValidationError> {
667        let path = path.into();
668        let kind = self.inner.ast().root_kind.clone();
669        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_eq")?;
670        self.inner = self.inner.filter_json_fused_text_eq_unchecked(path, value);
671        Ok(self)
672    }
673
674    /// Filter results where a JSON text property at `path` is one of
675    /// `values`, with fusion semantics.
676    ///
677    /// # Errors
678    /// See [`Self::filter_json_fused_text_eq`].
679    pub fn filter_json_fused_text_in(
680        mut self,
681        path: impl Into<String>,
682        values: Vec<String>,
683    ) -> Result<Self, BuilderValidationError> {
684        let path = path.into();
685        let kind = self.inner.ast().root_kind.clone();
686        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_in")?;
687        self.inner = self.inner.filter_json_fused_text_in_unchecked(path, values);
688        Ok(self)
689    }
690
691    /// Filter results where a JSON text property at `path` is one of
692    /// `values`. Non-fused; no FTS schema required.
693    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
694        self.inner = self.inner.filter_json_text_in(path, values);
695        self
696    }
697
698    /// Filter results where a JSON timestamp at `path` is strictly
699    /// greater than `value`, with fusion semantics.
700    ///
701    /// # Errors
702    /// See [`Self::filter_json_fused_text_eq`].
703    pub fn filter_json_fused_timestamp_gt(
704        mut self,
705        path: impl Into<String>,
706        value: i64,
707    ) -> Result<Self, BuilderValidationError> {
708        let path = path.into();
709        let kind = self.inner.ast().root_kind.clone();
710        validate_fusable_property_path(
711            self.engine,
712            &kind,
713            &path,
714            "filter_json_fused_timestamp_gt",
715        )?;
716        self.inner = self
717            .inner
718            .filter_json_fused_timestamp_gt_unchecked(path, value);
719        Ok(self)
720    }
721
722    /// Filter results where a JSON timestamp at `path` is at or after
723    /// `value`, with fusion semantics.
724    ///
725    /// # Errors
726    /// See [`Self::filter_json_fused_text_eq`].
727    pub fn filter_json_fused_timestamp_gte(
728        mut self,
729        path: impl Into<String>,
730        value: i64,
731    ) -> Result<Self, BuilderValidationError> {
732        let path = path.into();
733        let kind = self.inner.ast().root_kind.clone();
734        validate_fusable_property_path(
735            self.engine,
736            &kind,
737            &path,
738            "filter_json_fused_timestamp_gte",
739        )?;
740        self.inner = self
741            .inner
742            .filter_json_fused_timestamp_gte_unchecked(path, value);
743        Ok(self)
744    }
745
746    /// Filter results where a JSON timestamp at `path` is strictly
747    /// before `value`, with fusion semantics.
748    ///
749    /// # Errors
750    /// See [`Self::filter_json_fused_text_eq`].
751    pub fn filter_json_fused_timestamp_lt(
752        mut self,
753        path: impl Into<String>,
754        value: i64,
755    ) -> Result<Self, BuilderValidationError> {
756        let path = path.into();
757        let kind = self.inner.ast().root_kind.clone();
758        validate_fusable_property_path(
759            self.engine,
760            &kind,
761            &path,
762            "filter_json_fused_timestamp_lt",
763        )?;
764        self.inner = self
765            .inner
766            .filter_json_fused_timestamp_lt_unchecked(path, value);
767        Ok(self)
768    }
769
770    /// Filter results where a JSON timestamp at `path` is at or before
771    /// `value`, with fusion semantics.
772    ///
773    /// # Errors
774    /// See [`Self::filter_json_fused_text_eq`].
775    pub fn filter_json_fused_timestamp_lte(
776        mut self,
777        path: impl Into<String>,
778        value: i64,
779    ) -> Result<Self, BuilderValidationError> {
780        let path = path.into();
781        let kind = self.inner.ast().root_kind.clone();
782        validate_fusable_property_path(
783            self.engine,
784            &kind,
785            &path,
786            "filter_json_fused_timestamp_lte",
787        )?;
788        self.inner = self
789            .inner
790            .filter_json_fused_timestamp_lte_unchecked(path, value);
791        Ok(self)
792    }
793
794    /// Filter results where a JSON boolean property at `path` equals
795    /// `value`, with fusion semantics. See
796    /// [`Self::filter_json_fused_text_eq`] for the contract.
797    ///
798    /// # Errors
799    /// See [`Self::filter_json_fused_text_eq`].
800    pub fn filter_json_fused_bool_eq(
801        mut self,
802        path: impl Into<String>,
803        value: bool,
804    ) -> Result<Self, BuilderValidationError> {
805        let path = path.into();
806        let kind = self.inner.ast().root_kind.clone();
807        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_bool_eq")?;
808        self.inner = self.inner.filter_json_fused_bool_eq_unchecked(path, value);
809        Ok(self)
810    }
811
812    /// Set the final row limit on the underlying AST.
813    ///
814    /// Phase 1 note: [`CompiledSearch`](fathomdb_query::CompiledSearch) derives
815    /// its effective limit from the `text_search` step, not from this field.
816    /// `limit` is still delegated to the inner builder so callers that later
817    /// fall back to [`TextSearchBuilder::compile_query`] keep the same shape.
818    pub fn limit(mut self, limit: usize) -> Self {
819        self.inner = self.inner.limit(limit);
820        self
821    }
822
823    /// Add a graph traversal step. Applied after the text-search step when
824    /// the inner AST is compiled via [`TextSearchBuilder::compile_query`].
825    /// The Phase 1 [`TextSearchBuilder::execute`] path ignores traversals.
826    pub fn traverse(
827        mut self,
828        direction: fathomdb_query::TraverseDirection,
829        label: impl Into<String>,
830        max_depth: usize,
831    ) -> Self {
832        self.inner = self.inner.traverse(direction, label, max_depth);
833        self
834    }
835
836    /// Add an expansion slot. Applied when compiling via
837    /// [`TextSearchBuilder::compile_grouped_query`]; ignored by
838    /// [`TextSearchBuilder::execute`] in Phase 1.
839    ///
840    /// Pass `filter: None` to preserve the existing behavior. `filter: Some(_)` is
841    /// accepted by the AST but the compilation is not yet implemented (Pack 3).
842    /// Pass `edge_filter: None` to preserve pre-Pack-D behavior.
843    pub fn expand(
844        mut self,
845        slot: impl Into<String>,
846        direction: fathomdb_query::TraverseDirection,
847        label: impl Into<String>,
848        max_depth: usize,
849        filter: Option<fathomdb_query::Predicate>,
850        edge_filter: Option<fathomdb_query::Predicate>,
851    ) -> Self {
852        self.inner = self
853            .inner
854            .expand(slot, direction, label, max_depth, filter, edge_filter);
855        self
856    }
857
858    /// Borrow the underlying [`QueryBuilder`].
859    #[must_use]
860    pub fn as_builder(&self) -> &QueryBuilder {
861        &self.inner
862    }
863
864    /// Compile the underlying AST as a flat [`CompiledQuery`]. Provided for
865    /// call sites that mix search with traversal steps and still need to run
866    /// the flat node-row pipeline.
867    ///
868    /// # Errors
869    /// Returns [`CompileError`] if compilation fails.
870    pub fn compile(&self) -> Result<CompiledQuery, CompileError> {
871        self.inner.compile()
872    }
873
874    /// Compile the underlying AST as a [`CompiledGroupedQuery`].
875    ///
876    /// # Errors
877    /// Returns [`CompileError`] if compilation fails.
878    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
879        self.inner.compile_grouped()
880    }
881
882    /// Consume the tether and return the underlying [`QueryAst`].
883    #[must_use]
884    pub fn into_ast(self) -> fathomdb_query::QueryAst {
885        self.inner.into_ast()
886    }
887
888    /// Execute the text search and return matching hits.
889    ///
890    /// # Errors
891    /// Returns [`EngineError`] if compilation or execution fails.
892    pub fn execute(&self) -> Result<SearchRows, EngineError> {
893        let mut compiled = compile_search(self.inner.ast())
894            .map_err(|e| EngineError::InvalidConfig(format!("search compilation failed: {e}")))?;
895        compiled.attribution_requested = self.attribution_requested;
896        self.engine.coordinator().execute_compiled_search(&compiled)
897    }
898}
899
900/// Tethered two-shape fallback search builder returned from
901/// [`Engine::fallback_search`].
902///
903/// `fallback_search(strict, Some(relaxed))` is the "advanced caller who
904/// wants explicit control over the relaxed shape" surface. The strict and
905/// relaxed queries are both caller-provided — neither is passed through
906/// [`fathomdb_query::derive_relaxed`] — and the resulting
907/// [`SearchRows`] flows through the same retrieval, merge, and dedup
908/// machinery as the adaptive [`TextSearchBuilder`] path.
909///
910/// `fallback_search(strict, None)` is the strict-only "dedup-on-write"
911/// form: it runs the strict branch through the same plan shape (with no
912/// relaxed sibling) so callers share the same retrieval and result surface
913/// as adaptive `text_search()` rather than an ad hoc path.
914///
915/// Filters mirror [`TextSearchBuilder`]. There is intentionally no `.nodes`
916/// or `.traverse` entry point — this helper is narrow. Its only job is to
917/// run one or two search shapes through the shared policy.
918#[must_use]
919pub struct FallbackSearchBuilder<'e> {
920    engine: &'e Engine,
921    strict: TextQuery,
922    relaxed: Option<TextQuery>,
923    limit: usize,
924    attribution_requested: bool,
925    // Reuse a QueryBuilder as a filter accumulator so the fusion helper
926    // partitions exactly the same predicates as TextSearchBuilder.
927    filter_builder: QueryBuilder,
928}
929
930impl<'e> FallbackSearchBuilder<'e> {
931    pub(crate) fn new(
932        engine: &'e Engine,
933        strict: impl Into<String>,
934        relaxed: Option<&str>,
935        limit: usize,
936    ) -> Self {
937        let strict = TextQuery::parse(&strict.into());
938        let relaxed = relaxed.map(TextQuery::parse);
939        // The filter accumulator's root kind is a placeholder — fallback_search
940        // is kind-agnostic until the caller adds `.filter_kind_eq(...)`. We
941        // pick an empty string so `partition_search_filters` ignores it (it
942        // only inspects Filter steps).
943        //
944        // The accumulator is seeded with a no-op `text_search` step so that
945        // `partition_search_filters` treats subsequent `.filter_*` calls as
946        // post-search filters (the partitioner only fuses predicates that
947        // appear after a TextSearch/VectorSearch step). The dummy step's
948        // query text and limit are never executed — `compile_plan` pulls
949        // the real strict/relaxed text queries and limit from the
950        // `FallbackSearchBuilder` fields directly when it assembles the
951        // `CompiledSearchPlan`.
952        let filter_builder = QueryBuilder::nodes(String::new()).text_search("", 0);
953        Self {
954            engine,
955            strict,
956            relaxed,
957            limit,
958            attribution_requested: false,
959            filter_builder,
960        }
961    }
962
963    /// Request per-hit match attribution. Mirrors
964    /// [`TextSearchBuilder::with_match_attribution`].
965    pub fn with_match_attribution(mut self) -> Self {
966        self.attribution_requested = true;
967        self
968    }
969
970    /// Filter results to a single logical ID.
971    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
972        self.filter_builder = self.filter_builder.filter_logical_id_eq(logical_id);
973        self
974    }
975
976    /// Filter results to nodes matching the given kind.
977    ///
978    /// P6-P2-4: unlike the adaptive `TextSearchBuilder` path (which pins
979    /// `root_kind` from `Engine::query(kind)`), the narrow fallback helper
980    /// applies the kind check through the fusable filter list only. The
981    /// fusion pass pushes the resulting `KindEq` predicate into the
982    /// `search_hits` CTE's WHERE clause, which is sufficient to narrow
983    /// the result set and keeps the emitted SQL free of the redundant
984    /// `src.kind = ?` / `fp.kind = ?` checks inside the inner UNION arms.
985    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
986        self.filter_builder = self.filter_builder.filter_kind_eq(kind);
987        self
988    }
989
990    /// Filter results to nodes matching the given `source_ref`.
991    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
992        self.filter_builder = self.filter_builder.filter_source_ref_eq(source_ref);
993        self
994    }
995
996    /// Filter results to nodes where `content_ref` is not NULL.
997    pub fn filter_content_ref_not_null(mut self) -> Self {
998        self.filter_builder = self.filter_builder.filter_content_ref_not_null();
999        self
1000    }
1001
1002    /// Filter results to nodes matching the given `content_ref` URI.
1003    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
1004        self.filter_builder = self.filter_builder.filter_content_ref_eq(content_ref);
1005        self
1006    }
1007
1008    /// Filter results where a JSON property at `path` equals the given text value.
1009    pub fn filter_json_text_eq(
1010        mut self,
1011        path: impl Into<String>,
1012        value: impl Into<String>,
1013    ) -> Self {
1014        self.filter_builder = self.filter_builder.filter_json_text_eq(path, value);
1015        self
1016    }
1017
1018    /// Filter results where a JSON property at `path` equals the given boolean value.
1019    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
1020        self.filter_builder = self.filter_builder.filter_json_bool_eq(path, value);
1021        self
1022    }
1023
1024    /// Filter results where a JSON integer at `path` is greater than `value`.
1025    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1026        self.filter_builder = self.filter_builder.filter_json_integer_gt(path, value);
1027        self
1028    }
1029
1030    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
1031    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1032        self.filter_builder = self.filter_builder.filter_json_integer_gte(path, value);
1033        self
1034    }
1035
1036    /// Filter results where a JSON integer at `path` is less than `value`.
1037    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1038        self.filter_builder = self.filter_builder.filter_json_integer_lt(path, value);
1039        self
1040    }
1041
1042    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
1043    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1044        self.filter_builder = self.filter_builder.filter_json_integer_lte(path, value);
1045        self
1046    }
1047
1048    /// Filter results where a JSON timestamp at `path` is after `value`.
1049    pub fn filter_json_timestamp_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1050        self.filter_builder = self.filter_builder.filter_json_timestamp_gt(path, value);
1051        self
1052    }
1053
1054    /// Filter results where a JSON timestamp at `path` is at or after `value`.
1055    pub fn filter_json_timestamp_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1056        self.filter_builder = self.filter_builder.filter_json_timestamp_gte(path, value);
1057        self
1058    }
1059
1060    /// Filter results where a JSON timestamp at `path` is before `value`.
1061    pub fn filter_json_timestamp_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1062        self.filter_builder = self.filter_builder.filter_json_timestamp_lt(path, value);
1063        self
1064    }
1065
1066    /// Filter results where a JSON timestamp at `path` is at or before `value`.
1067    pub fn filter_json_timestamp_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1068        self.filter_builder = self.filter_builder.filter_json_timestamp_lte(path, value);
1069        self
1070    }
1071
1072    /// Filter results where a JSON text property at `path` equals
1073    /// `value`, with fusion semantics.
1074    ///
1075    /// # Errors
1076    /// Returns [`BuilderValidationError::KindRequiredForFusion`] if no
1077    /// `filter_kind_eq` has been chained on this builder. The fallback
1078    /// builder is kind-agnostic by default and cannot resolve a
1079    /// property-FTS schema without an explicit kind binding.
1080    pub fn filter_json_fused_text_eq(
1081        mut self,
1082        path: impl Into<String>,
1083        value: impl Into<String>,
1084    ) -> Result<Self, BuilderValidationError> {
1085        let path = path.into();
1086        let kind = filter_builder_kind(&self.filter_builder)
1087            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1088                method: "filter_json_fused_text_eq".to_owned(),
1089            })?
1090            .to_owned();
1091        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_eq")?;
1092        self.filter_builder = self
1093            .filter_builder
1094            .filter_json_fused_text_eq_unchecked(path, value);
1095        Ok(self)
1096    }
1097
1098    /// Filter results where a JSON text property at `path` is one of
1099    /// `values`, with fusion semantics.
1100    ///
1101    /// # Errors
1102    /// See [`Self::filter_json_fused_text_eq`].
1103    pub fn filter_json_fused_text_in(
1104        mut self,
1105        path: impl Into<String>,
1106        values: Vec<String>,
1107    ) -> Result<Self, BuilderValidationError> {
1108        let path = path.into();
1109        let kind = filter_builder_kind(&self.filter_builder)
1110            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1111                method: "filter_json_fused_text_in".to_owned(),
1112            })?
1113            .to_owned();
1114        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_text_in")?;
1115        self.filter_builder = self
1116            .filter_builder
1117            .filter_json_fused_text_in_unchecked(path, values);
1118        Ok(self)
1119    }
1120
1121    /// Filter results where a JSON text property at `path` is one of
1122    /// `values`. Non-fused; no FTS schema required.
1123    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
1124        self.filter_builder = self.filter_builder.filter_json_text_in(path, values);
1125        self
1126    }
1127
1128    /// Filter results where a JSON timestamp at `path` is strictly
1129    /// greater than `value`, with fusion semantics.
1130    ///
1131    /// # Errors
1132    /// See [`Self::filter_json_fused_text_eq`].
1133    pub fn filter_json_fused_timestamp_gt(
1134        mut self,
1135        path: impl Into<String>,
1136        value: i64,
1137    ) -> Result<Self, BuilderValidationError> {
1138        let path = path.into();
1139        let kind = filter_builder_kind(&self.filter_builder)
1140            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1141                method: "filter_json_fused_timestamp_gt".to_owned(),
1142            })?
1143            .to_owned();
1144        validate_fusable_property_path(
1145            self.engine,
1146            &kind,
1147            &path,
1148            "filter_json_fused_timestamp_gt",
1149        )?;
1150        self.filter_builder = self
1151            .filter_builder
1152            .filter_json_fused_timestamp_gt_unchecked(path, value);
1153        Ok(self)
1154    }
1155
1156    /// Filter results where a JSON timestamp at `path` is at or after
1157    /// `value`, with fusion semantics.
1158    ///
1159    /// # Errors
1160    /// See [`Self::filter_json_fused_text_eq`].
1161    pub fn filter_json_fused_timestamp_gte(
1162        mut self,
1163        path: impl Into<String>,
1164        value: i64,
1165    ) -> Result<Self, BuilderValidationError> {
1166        let path = path.into();
1167        let kind = filter_builder_kind(&self.filter_builder)
1168            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1169                method: "filter_json_fused_timestamp_gte".to_owned(),
1170            })?
1171            .to_owned();
1172        validate_fusable_property_path(
1173            self.engine,
1174            &kind,
1175            &path,
1176            "filter_json_fused_timestamp_gte",
1177        )?;
1178        self.filter_builder = self
1179            .filter_builder
1180            .filter_json_fused_timestamp_gte_unchecked(path, value);
1181        Ok(self)
1182    }
1183
1184    /// Filter results where a JSON timestamp at `path` is strictly
1185    /// before `value`, with fusion semantics.
1186    ///
1187    /// # Errors
1188    /// See [`Self::filter_json_fused_text_eq`].
1189    pub fn filter_json_fused_timestamp_lt(
1190        mut self,
1191        path: impl Into<String>,
1192        value: i64,
1193    ) -> Result<Self, BuilderValidationError> {
1194        let path = path.into();
1195        let kind = filter_builder_kind(&self.filter_builder)
1196            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1197                method: "filter_json_fused_timestamp_lt".to_owned(),
1198            })?
1199            .to_owned();
1200        validate_fusable_property_path(
1201            self.engine,
1202            &kind,
1203            &path,
1204            "filter_json_fused_timestamp_lt",
1205        )?;
1206        self.filter_builder = self
1207            .filter_builder
1208            .filter_json_fused_timestamp_lt_unchecked(path, value);
1209        Ok(self)
1210    }
1211
1212    /// Filter results where a JSON timestamp at `path` is at or before
1213    /// `value`, with fusion semantics.
1214    ///
1215    /// # Errors
1216    /// See [`Self::filter_json_fused_text_eq`].
1217    pub fn filter_json_fused_timestamp_lte(
1218        mut self,
1219        path: impl Into<String>,
1220        value: i64,
1221    ) -> Result<Self, BuilderValidationError> {
1222        let path = path.into();
1223        let kind = filter_builder_kind(&self.filter_builder)
1224            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1225                method: "filter_json_fused_timestamp_lte".to_owned(),
1226            })?
1227            .to_owned();
1228        validate_fusable_property_path(
1229            self.engine,
1230            &kind,
1231            &path,
1232            "filter_json_fused_timestamp_lte",
1233        )?;
1234        self.filter_builder = self
1235            .filter_builder
1236            .filter_json_fused_timestamp_lte_unchecked(path, value);
1237        Ok(self)
1238    }
1239
1240    /// Filter results where a JSON boolean property at `path` equals
1241    /// `value`, with fusion semantics. See
1242    /// [`Self::filter_json_fused_text_eq`] for the contract.
1243    ///
1244    /// # Errors
1245    /// See [`Self::filter_json_fused_text_eq`].
1246    pub fn filter_json_fused_bool_eq(
1247        mut self,
1248        path: impl Into<String>,
1249        value: bool,
1250    ) -> Result<Self, BuilderValidationError> {
1251        let path = path.into();
1252        let kind = filter_builder_kind(&self.filter_builder)
1253            .ok_or_else(|| BuilderValidationError::KindRequiredForFusion {
1254                method: "filter_json_fused_bool_eq".to_owned(),
1255            })?
1256            .to_owned();
1257        validate_fusable_property_path(self.engine, &kind, &path, "filter_json_fused_bool_eq")?;
1258        self.filter_builder = self
1259            .filter_builder
1260            .filter_json_fused_bool_eq_unchecked(path, value);
1261        Ok(self)
1262    }
1263
1264    /// Compile the builder into a [`CompiledSearchPlan`] without executing
1265    /// it. Useful for tests and introspection.
1266    ///
1267    /// # Errors
1268    /// Returns [`CompileError`] if filter partitioning fails.
1269    pub fn compile_plan(&self) -> Result<CompiledSearchPlan, CompileError> {
1270        // `FallbackSearchBuilder` is kind-agnostic at the UNION level:
1271        // when `root_kind` is empty, the coordinator's `run_search_branch`
1272        // omits the `src.kind = ?` / `fp.kind = ?` predicates from the
1273        // inner UNION arms entirely, so the search runs across all node
1274        // kinds. Callers that want kind filtering chain
1275        // `.filter_kind_eq(kind)`, which adds a fusable `KindEq`
1276        // predicate (P6-P2-4: the fusion pass then pushes the check into
1277        // the outer `search_hits` CTE's WHERE clause — a single kind
1278        // check, not three). The narrow fallback helper therefore always
1279        // uses an empty root kind on this path.
1280        let mut ast = self.filter_builder.clone().into_ast();
1281        ast.root_kind = String::new();
1282        compile_search_plan_from_queries(
1283            &ast,
1284            self.strict.clone(),
1285            self.relaxed.clone(),
1286            self.limit,
1287            self.attribution_requested,
1288        )
1289    }
1290
1291    /// Execute the fallback search and return matching hits.
1292    ///
1293    /// # Errors
1294    /// Returns [`EngineError`] if compilation or execution fails.
1295    pub fn execute(&self) -> Result<SearchRows, EngineError> {
1296        let plan = self
1297            .compile_plan()
1298            .map_err(|e| EngineError::InvalidConfig(format!("search compilation failed: {e}")))?;
1299        self.engine
1300            .coordinator()
1301            .execute_compiled_search_plan(&plan)
1302    }
1303}
1304
1305/// Tethered vector-search builder returned from
1306/// [`NodeQueryBuilder::vector_search`].
1307///
1308/// Accumulates filter predicates alongside a caller-provided vector query
1309/// and dispatches `.execute()` through
1310/// [`fathomdb_engine::ExecutionCoordinator::execute_compiled_vector_search`],
1311/// returning [`SearchRows`] whose hits carry
1312/// `modality = RetrievalModality::Vector`, `source = SearchHitSource::Vector`,
1313/// `match_mode = None`, and `vector_distance = Some(raw_distance)`. The
1314/// higher-is-better `score` field is the negated distance.
1315///
1316/// See `dev/design-adaptive-text-search-surface-addendum-1-vec.md` §Public
1317/// Surface for the full surface contract and degradation semantics.
1318#[must_use]
1319pub struct VectorSearchBuilder<'e> {
1320    engine: &'e Engine,
1321    root_kind: String,
1322    query: String,
1323    limit: usize,
1324    attribution_requested: bool,
1325    // Reuse a QueryBuilder as a filter accumulator so the fusion helper
1326    // partitions exactly the same predicates as TextSearchBuilder.
1327    filter_builder: QueryBuilder,
1328}
1329
1330impl<'e> VectorSearchBuilder<'e> {
1331    pub(crate) fn new(
1332        engine: &'e Engine,
1333        root_kind: impl Into<String>,
1334        query: impl Into<String>,
1335        limit: usize,
1336    ) -> Self {
1337        let root_kind = root_kind.into();
1338        // Mirror FallbackSearchBuilder: the filter accumulator is seeded
1339        // with a no-op `vector_search("", 0)` step so that
1340        // `partition_search_filters` treats subsequent `.filter_*` calls
1341        // as post-search predicates. The P2-N2 fix tightened the
1342        // partitioner to only collect filters AFTER a search-step marker;
1343        // without this seed, `.filter_kind_eq("Goal")` would land in
1344        // neither bucket and would be silently dropped. The dummy step's
1345        // query text and limit are never executed — `compile_plan` pulls
1346        // the real vector query string and limit from the builder's
1347        // fields directly when it assembles the `CompiledVectorSearch`.
1348        #[allow(deprecated)]
1349        let filter_builder = QueryBuilder::nodes(root_kind.clone()).vector_search("", 0);
1350        Self {
1351            engine,
1352            root_kind,
1353            query: query.into(),
1354            limit,
1355            attribution_requested: false,
1356            filter_builder,
1357        }
1358    }
1359
1360    /// Request per-hit match attribution.
1361    ///
1362    /// When set, every returned hit carries
1363    /// `attribution: Some(HitAttribution { matched_paths: vec![] })` per
1364    /// addendum 1 §Attribution on vector hits. The empty `matched_paths`
1365    /// list is intentional — vector matches have no per-field provenance
1366    /// to attribute, but the `Some(...)` sentinel lets downstream code
1367    /// distinguish "attribution was requested and produced no paths" from
1368    /// "attribution was not requested at all".
1369    pub fn with_match_attribution(mut self) -> Self {
1370        self.attribution_requested = true;
1371        self
1372    }
1373
1374    /// Filter results to a single logical ID.
1375    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
1376        self.filter_builder = self.filter_builder.filter_logical_id_eq(logical_id);
1377        self
1378    }
1379
1380    /// Filter results to nodes matching the given kind.
1381    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
1382        self.filter_builder = self.filter_builder.filter_kind_eq(kind);
1383        self
1384    }
1385
1386    /// Filter results to nodes matching the given `source_ref`.
1387    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
1388        self.filter_builder = self.filter_builder.filter_source_ref_eq(source_ref);
1389        self
1390    }
1391
1392    /// Filter results to nodes where `content_ref` is not NULL.
1393    pub fn filter_content_ref_not_null(mut self) -> Self {
1394        self.filter_builder = self.filter_builder.filter_content_ref_not_null();
1395        self
1396    }
1397
1398    /// Filter results to nodes matching the given `content_ref` URI.
1399    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
1400        self.filter_builder = self.filter_builder.filter_content_ref_eq(content_ref);
1401        self
1402    }
1403
1404    /// Filter results where a JSON property at `path` equals the given text value.
1405    pub fn filter_json_text_eq(
1406        mut self,
1407        path: impl Into<String>,
1408        value: impl Into<String>,
1409    ) -> Self {
1410        self.filter_builder = self.filter_builder.filter_json_text_eq(path, value);
1411        self
1412    }
1413
1414    /// Filter results where a JSON property at `path` equals the given boolean value.
1415    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
1416        self.filter_builder = self.filter_builder.filter_json_bool_eq(path, value);
1417        self
1418    }
1419
1420    /// Filter results where a JSON integer at `path` is greater than `value`.
1421    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1422        self.filter_builder = self.filter_builder.filter_json_integer_gt(path, value);
1423        self
1424    }
1425
1426    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
1427    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1428        self.filter_builder = self.filter_builder.filter_json_integer_gte(path, value);
1429        self
1430    }
1431
1432    /// Filter results where a JSON integer at `path` is less than `value`.
1433    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1434        self.filter_builder = self.filter_builder.filter_json_integer_lt(path, value);
1435        self
1436    }
1437
1438    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
1439    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1440        self.filter_builder = self.filter_builder.filter_json_integer_lte(path, value);
1441        self
1442    }
1443
1444    /// Filter results where a JSON timestamp at `path` is after `value`.
1445    pub fn filter_json_timestamp_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1446        self.filter_builder = self.filter_builder.filter_json_timestamp_gt(path, value);
1447        self
1448    }
1449
1450    /// Filter results where a JSON timestamp at `path` is at or after `value`.
1451    pub fn filter_json_timestamp_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1452        self.filter_builder = self.filter_builder.filter_json_timestamp_gte(path, value);
1453        self
1454    }
1455
1456    /// Filter results where a JSON timestamp at `path` is before `value`.
1457    pub fn filter_json_timestamp_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1458        self.filter_builder = self.filter_builder.filter_json_timestamp_lt(path, value);
1459        self
1460    }
1461
1462    /// Filter results where a JSON timestamp at `path` is at or before `value`.
1463    pub fn filter_json_timestamp_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1464        self.filter_builder = self.filter_builder.filter_json_timestamp_lte(path, value);
1465        self
1466    }
1467
1468    /// Filter results where a JSON text property at `path` equals
1469    /// `value`, with fusion semantics.
1470    ///
1471    /// # Errors
1472    /// Returns [`BuilderValidationError`] if the root kind has no
1473    /// registered property-FTS schema or the schema does not cover
1474    /// `path`.
1475    pub fn filter_json_fused_text_eq(
1476        mut self,
1477        path: impl Into<String>,
1478        value: impl Into<String>,
1479    ) -> Result<Self, BuilderValidationError> {
1480        let path = path.into();
1481        validate_fusable_property_path(
1482            self.engine,
1483            &self.root_kind,
1484            &path,
1485            "filter_json_fused_text_eq",
1486        )?;
1487        self.filter_builder = self
1488            .filter_builder
1489            .filter_json_fused_text_eq_unchecked(path, value);
1490        Ok(self)
1491    }
1492
1493    /// Filter results where a JSON text property at `path` is one of
1494    /// `values`, with fusion semantics.
1495    ///
1496    /// # Errors
1497    /// See [`Self::filter_json_fused_text_eq`].
1498    pub fn filter_json_fused_text_in(
1499        mut self,
1500        path: impl Into<String>,
1501        values: Vec<String>,
1502    ) -> Result<Self, BuilderValidationError> {
1503        let path = path.into();
1504        validate_fusable_property_path(
1505            self.engine,
1506            &self.root_kind,
1507            &path,
1508            "filter_json_fused_text_in",
1509        )?;
1510        self.filter_builder = self
1511            .filter_builder
1512            .filter_json_fused_text_in_unchecked(path, values);
1513        Ok(self)
1514    }
1515
1516    /// Filter results where a JSON text property at `path` is one of
1517    /// `values`. Non-fused; no FTS schema required.
1518    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
1519        self.filter_builder = self.filter_builder.filter_json_text_in(path, values);
1520        self
1521    }
1522
1523    /// Filter results where a JSON timestamp at `path` is strictly
1524    /// greater than `value`, with fusion semantics.
1525    ///
1526    /// # Errors
1527    /// See [`Self::filter_json_fused_text_eq`].
1528    pub fn filter_json_fused_timestamp_gt(
1529        mut self,
1530        path: impl Into<String>,
1531        value: i64,
1532    ) -> Result<Self, BuilderValidationError> {
1533        let path = path.into();
1534        validate_fusable_property_path(
1535            self.engine,
1536            &self.root_kind,
1537            &path,
1538            "filter_json_fused_timestamp_gt",
1539        )?;
1540        self.filter_builder = self
1541            .filter_builder
1542            .filter_json_fused_timestamp_gt_unchecked(path, value);
1543        Ok(self)
1544    }
1545
1546    /// Filter results where a JSON timestamp at `path` is at or after
1547    /// `value`, with fusion semantics.
1548    ///
1549    /// # Errors
1550    /// See [`Self::filter_json_fused_text_eq`].
1551    pub fn filter_json_fused_timestamp_gte(
1552        mut self,
1553        path: impl Into<String>,
1554        value: i64,
1555    ) -> Result<Self, BuilderValidationError> {
1556        let path = path.into();
1557        validate_fusable_property_path(
1558            self.engine,
1559            &self.root_kind,
1560            &path,
1561            "filter_json_fused_timestamp_gte",
1562        )?;
1563        self.filter_builder = self
1564            .filter_builder
1565            .filter_json_fused_timestamp_gte_unchecked(path, value);
1566        Ok(self)
1567    }
1568
1569    /// Filter results where a JSON timestamp at `path` is strictly
1570    /// before `value`, with fusion semantics.
1571    ///
1572    /// # Errors
1573    /// See [`Self::filter_json_fused_text_eq`].
1574    pub fn filter_json_fused_timestamp_lt(
1575        mut self,
1576        path: impl Into<String>,
1577        value: i64,
1578    ) -> Result<Self, BuilderValidationError> {
1579        let path = path.into();
1580        validate_fusable_property_path(
1581            self.engine,
1582            &self.root_kind,
1583            &path,
1584            "filter_json_fused_timestamp_lt",
1585        )?;
1586        self.filter_builder = self
1587            .filter_builder
1588            .filter_json_fused_timestamp_lt_unchecked(path, value);
1589        Ok(self)
1590    }
1591
1592    /// Filter results where a JSON timestamp at `path` is at or before
1593    /// `value`, with fusion semantics.
1594    ///
1595    /// # Errors
1596    /// See [`Self::filter_json_fused_text_eq`].
1597    pub fn filter_json_fused_timestamp_lte(
1598        mut self,
1599        path: impl Into<String>,
1600        value: i64,
1601    ) -> Result<Self, BuilderValidationError> {
1602        let path = path.into();
1603        validate_fusable_property_path(
1604            self.engine,
1605            &self.root_kind,
1606            &path,
1607            "filter_json_fused_timestamp_lte",
1608        )?;
1609        self.filter_builder = self
1610            .filter_builder
1611            .filter_json_fused_timestamp_lte_unchecked(path, value);
1612        Ok(self)
1613    }
1614
1615    /// Filter results where a JSON boolean property at `path` equals
1616    /// `value`, with fusion semantics. See
1617    /// [`Self::filter_json_fused_text_eq`] for the contract.
1618    ///
1619    /// # Errors
1620    /// See [`Self::filter_json_fused_text_eq`].
1621    pub fn filter_json_fused_bool_eq(
1622        mut self,
1623        path: impl Into<String>,
1624        value: bool,
1625    ) -> Result<Self, BuilderValidationError> {
1626        let path = path.into();
1627        validate_fusable_property_path(
1628            self.engine,
1629            &self.root_kind,
1630            &path,
1631            "filter_json_fused_bool_eq",
1632        )?;
1633        self.filter_builder = self
1634            .filter_builder
1635            .filter_json_fused_bool_eq_unchecked(path, value);
1636        Ok(self)
1637    }
1638
1639    /// Compile the builder into a [`CompiledVectorSearch`] without executing
1640    /// it. Useful for tests and introspection.
1641    ///
1642    /// # Errors
1643    /// Returns [`CompileError`] if filter partitioning fails.
1644    pub fn compile_plan(&self) -> Result<CompiledVectorSearch, CompileError> {
1645        let mut ast = self.filter_builder.clone().into_ast();
1646        ast.root_kind.clone_from(&self.root_kind);
1647        let mut compiled = compile_vector_search(&ast)?;
1648        // The seed `.vector_search("", 0)` step on the filter accumulator
1649        // is an artifact of the partition workaround; `compile_plan` pulls
1650        // the caller's real query text and limit from `self` directly.
1651        compiled.query_text.clone_from(&self.query);
1652        compiled.limit = self.limit;
1653        compiled.attribution_requested = self.attribution_requested;
1654        Ok(compiled)
1655    }
1656
1657    /// Execute the vector search and return matching hits.
1658    ///
1659    /// # Errors
1660    /// Returns [`EngineError`] if compilation or execution fails. A
1661    /// capability miss (sqlite-vec unavailable) is NOT an error: it
1662    /// returns an empty [`SearchRows`] with `was_degraded = true`.
1663    pub fn execute(&self) -> Result<SearchRows, EngineError> {
1664        let plan = self
1665            .compile_plan()
1666            .map_err(|e| EngineError::InvalidConfig(format!("search compilation failed: {e}")))?;
1667        self.engine
1668            .coordinator()
1669            .execute_compiled_vector_search(&plan)
1670    }
1671}
1672
1673/// Tethered unified retrieval builder returned from
1674/// [`NodeQueryBuilder::search`].
1675///
1676/// `SearchBuilder` is the Phase 12 primary retrieval entry point per
1677/// `dev/design-adaptive-text-search-surface-addendum-1-vec.md` §Public
1678/// Surface. It accumulates filter predicates alongside a caller-provided
1679/// raw query string, compiles into a [`CompiledRetrievalPlan`] via
1680/// [`fathomdb_query::compile_retrieval_plan`], and dispatches `.execute()`
1681/// through [`fathomdb_engine::ExecutionCoordinator::execute_retrieval_plan`]
1682/// to return [`SearchRows`] with the strict/relaxed/vector blocks fused
1683/// under the addendum's block precedence rules.
1684///
1685/// **v1 scope**: the unified planner's vector branch slot is wired
1686/// architecturally but never fires through `search()` because read-time
1687/// embedding of natural-language queries is deferred. Until that future
1688/// phase lands, every `SearchBuilder::execute()` result has
1689/// `vector_hit_count == 0` regardless of vector capability availability.
1690/// Callers who want vector retrieval today must use the advanced
1691/// `vector_search()` override directly with a caller-provided vector
1692/// literal.
1693#[must_use]
1694pub struct SearchBuilder<'e> {
1695    engine: &'e Engine,
1696    root_kind: String,
1697    query: String,
1698    limit: usize,
1699    attribution_requested: bool,
1700    // Reuse a QueryBuilder as a filter accumulator so the fusion helper
1701    // partitions exactly the same predicates as TextSearchBuilder /
1702    // FallbackSearchBuilder / VectorSearchBuilder. The accumulator is
1703    // seeded with a no-op `text_search("", 0)` step so `partition_search_filters`
1704    // treats subsequent `.filter_*` calls as post-search predicates;
1705    // without that seed the predicates would land in neither bucket and
1706    // would be silently dropped. The dummy step's query text and limit
1707    // are never executed — `compile_plan` rewrites the AST with the real
1708    // `Search { query, limit }` step before calling
1709    // `compile_retrieval_plan`.
1710    filter_builder: QueryBuilder,
1711}
1712
1713impl<'e> SearchBuilder<'e> {
1714    pub(crate) fn new(
1715        engine: &'e Engine,
1716        root_kind: impl Into<String>,
1717        query: impl Into<String>,
1718        limit: usize,
1719    ) -> Self {
1720        let root_kind = root_kind.into();
1721        let filter_builder = QueryBuilder::nodes(root_kind.clone()).text_search("", 0);
1722        Self {
1723            engine,
1724            root_kind,
1725            query: query.into(),
1726            limit,
1727            attribution_requested: false,
1728            filter_builder,
1729        }
1730    }
1731
1732    /// Request per-hit match attribution on the resulting [`SearchRows`].
1733    /// Mirrors [`TextSearchBuilder::with_match_attribution`] semantics for
1734    /// text hits and [`VectorSearchBuilder::with_match_attribution`] for
1735    /// vector hits.
1736    pub fn with_match_attribution(mut self) -> Self {
1737        self.attribution_requested = true;
1738        self
1739    }
1740
1741    /// Filter results to a single logical ID.
1742    pub fn filter_logical_id_eq(mut self, logical_id: impl Into<String>) -> Self {
1743        self.filter_builder = self.filter_builder.filter_logical_id_eq(logical_id);
1744        self
1745    }
1746
1747    /// Filter results to nodes matching the given kind.
1748    pub fn filter_kind_eq(mut self, kind: impl Into<String>) -> Self {
1749        self.filter_builder = self.filter_builder.filter_kind_eq(kind);
1750        self
1751    }
1752
1753    /// Filter results to nodes matching the given `source_ref`.
1754    pub fn filter_source_ref_eq(mut self, source_ref: impl Into<String>) -> Self {
1755        self.filter_builder = self.filter_builder.filter_source_ref_eq(source_ref);
1756        self
1757    }
1758
1759    /// Filter results to nodes where `content_ref` is not NULL.
1760    pub fn filter_content_ref_not_null(mut self) -> Self {
1761        self.filter_builder = self.filter_builder.filter_content_ref_not_null();
1762        self
1763    }
1764
1765    /// Filter results to nodes matching the given `content_ref` URI.
1766    pub fn filter_content_ref_eq(mut self, content_ref: impl Into<String>) -> Self {
1767        self.filter_builder = self.filter_builder.filter_content_ref_eq(content_ref);
1768        self
1769    }
1770
1771    /// Filter results where a JSON property at `path` equals the given text value.
1772    pub fn filter_json_text_eq(
1773        mut self,
1774        path: impl Into<String>,
1775        value: impl Into<String>,
1776    ) -> Self {
1777        self.filter_builder = self.filter_builder.filter_json_text_eq(path, value);
1778        self
1779    }
1780
1781    /// Filter results where a JSON property at `path` equals the given boolean value.
1782    pub fn filter_json_bool_eq(mut self, path: impl Into<String>, value: bool) -> Self {
1783        self.filter_builder = self.filter_builder.filter_json_bool_eq(path, value);
1784        self
1785    }
1786
1787    /// Filter results where a JSON integer at `path` is greater than `value`.
1788    pub fn filter_json_integer_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1789        self.filter_builder = self.filter_builder.filter_json_integer_gt(path, value);
1790        self
1791    }
1792
1793    /// Filter results where a JSON integer at `path` is greater than or equal to `value`.
1794    pub fn filter_json_integer_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1795        self.filter_builder = self.filter_builder.filter_json_integer_gte(path, value);
1796        self
1797    }
1798
1799    /// Filter results where a JSON integer at `path` is less than `value`.
1800    pub fn filter_json_integer_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1801        self.filter_builder = self.filter_builder.filter_json_integer_lt(path, value);
1802        self
1803    }
1804
1805    /// Filter results where a JSON integer at `path` is less than or equal to `value`.
1806    pub fn filter_json_integer_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1807        self.filter_builder = self.filter_builder.filter_json_integer_lte(path, value);
1808        self
1809    }
1810
1811    /// Filter results where a JSON timestamp at `path` is after `value`.
1812    pub fn filter_json_timestamp_gt(mut self, path: impl Into<String>, value: i64) -> Self {
1813        self.filter_builder = self.filter_builder.filter_json_timestamp_gt(path, value);
1814        self
1815    }
1816
1817    /// Filter results where a JSON timestamp at `path` is at or after `value`.
1818    pub fn filter_json_timestamp_gte(mut self, path: impl Into<String>, value: i64) -> Self {
1819        self.filter_builder = self.filter_builder.filter_json_timestamp_gte(path, value);
1820        self
1821    }
1822
1823    /// Filter results where a JSON timestamp at `path` is before `value`.
1824    pub fn filter_json_timestamp_lt(mut self, path: impl Into<String>, value: i64) -> Self {
1825        self.filter_builder = self.filter_builder.filter_json_timestamp_lt(path, value);
1826        self
1827    }
1828
1829    /// Filter results where a JSON timestamp at `path` is at or before `value`.
1830    pub fn filter_json_timestamp_lte(mut self, path: impl Into<String>, value: i64) -> Self {
1831        self.filter_builder = self.filter_builder.filter_json_timestamp_lte(path, value);
1832        self
1833    }
1834
1835    /// Filter results where a JSON text property at `path` equals
1836    /// `value`, with fusion semantics.
1837    ///
1838    /// # Errors
1839    /// Returns [`BuilderValidationError`] if the root kind has no
1840    /// registered property-FTS schema or the schema does not cover
1841    /// `path`.
1842    pub fn filter_json_fused_text_eq(
1843        mut self,
1844        path: impl Into<String>,
1845        value: impl Into<String>,
1846    ) -> Result<Self, BuilderValidationError> {
1847        let path = path.into();
1848        validate_fusable_property_path(
1849            self.engine,
1850            &self.root_kind,
1851            &path,
1852            "filter_json_fused_text_eq",
1853        )?;
1854        self.filter_builder = self
1855            .filter_builder
1856            .filter_json_fused_text_eq_unchecked(path, value);
1857        Ok(self)
1858    }
1859
1860    /// Filter results where a JSON text property at `path` is one of
1861    /// `values`, with fusion semantics.
1862    ///
1863    /// # Errors
1864    /// See [`Self::filter_json_fused_text_eq`].
1865    pub fn filter_json_fused_text_in(
1866        mut self,
1867        path: impl Into<String>,
1868        values: Vec<String>,
1869    ) -> Result<Self, BuilderValidationError> {
1870        let path = path.into();
1871        validate_fusable_property_path(
1872            self.engine,
1873            &self.root_kind,
1874            &path,
1875            "filter_json_fused_text_in",
1876        )?;
1877        self.filter_builder = self
1878            .filter_builder
1879            .filter_json_fused_text_in_unchecked(path, values);
1880        Ok(self)
1881    }
1882
1883    /// Filter results where a JSON text property at `path` is one of
1884    /// `values`. Non-fused; no FTS schema required.
1885    pub fn filter_json_text_in(mut self, path: impl Into<String>, values: Vec<String>) -> Self {
1886        self.filter_builder = self.filter_builder.filter_json_text_in(path, values);
1887        self
1888    }
1889
1890    /// Filter results where a JSON timestamp at `path` is strictly
1891    /// greater than `value`, with fusion semantics.
1892    ///
1893    /// # Errors
1894    /// See [`Self::filter_json_fused_text_eq`].
1895    pub fn filter_json_fused_timestamp_gt(
1896        mut self,
1897        path: impl Into<String>,
1898        value: i64,
1899    ) -> Result<Self, BuilderValidationError> {
1900        let path = path.into();
1901        validate_fusable_property_path(
1902            self.engine,
1903            &self.root_kind,
1904            &path,
1905            "filter_json_fused_timestamp_gt",
1906        )?;
1907        self.filter_builder = self
1908            .filter_builder
1909            .filter_json_fused_timestamp_gt_unchecked(path, value);
1910        Ok(self)
1911    }
1912
1913    /// Filter results where a JSON timestamp at `path` is at or after
1914    /// `value`, with fusion semantics.
1915    ///
1916    /// # Errors
1917    /// See [`Self::filter_json_fused_text_eq`].
1918    pub fn filter_json_fused_timestamp_gte(
1919        mut self,
1920        path: impl Into<String>,
1921        value: i64,
1922    ) -> Result<Self, BuilderValidationError> {
1923        let path = path.into();
1924        validate_fusable_property_path(
1925            self.engine,
1926            &self.root_kind,
1927            &path,
1928            "filter_json_fused_timestamp_gte",
1929        )?;
1930        self.filter_builder = self
1931            .filter_builder
1932            .filter_json_fused_timestamp_gte_unchecked(path, value);
1933        Ok(self)
1934    }
1935
1936    /// Filter results where a JSON timestamp at `path` is strictly
1937    /// before `value`, with fusion semantics.
1938    ///
1939    /// # Errors
1940    /// See [`Self::filter_json_fused_text_eq`].
1941    pub fn filter_json_fused_timestamp_lt(
1942        mut self,
1943        path: impl Into<String>,
1944        value: i64,
1945    ) -> Result<Self, BuilderValidationError> {
1946        let path = path.into();
1947        validate_fusable_property_path(
1948            self.engine,
1949            &self.root_kind,
1950            &path,
1951            "filter_json_fused_timestamp_lt",
1952        )?;
1953        self.filter_builder = self
1954            .filter_builder
1955            .filter_json_fused_timestamp_lt_unchecked(path, value);
1956        Ok(self)
1957    }
1958
1959    /// Filter results where a JSON timestamp at `path` is at or before
1960    /// `value`, with fusion semantics.
1961    ///
1962    /// # Errors
1963    /// See [`Self::filter_json_fused_text_eq`].
1964    pub fn filter_json_fused_timestamp_lte(
1965        mut self,
1966        path: impl Into<String>,
1967        value: i64,
1968    ) -> Result<Self, BuilderValidationError> {
1969        let path = path.into();
1970        validate_fusable_property_path(
1971            self.engine,
1972            &self.root_kind,
1973            &path,
1974            "filter_json_fused_timestamp_lte",
1975        )?;
1976        self.filter_builder = self
1977            .filter_builder
1978            .filter_json_fused_timestamp_lte_unchecked(path, value);
1979        Ok(self)
1980    }
1981
1982    /// Filter results where a JSON boolean property at `path` equals
1983    /// `value`, with fusion semantics. See
1984    /// [`Self::filter_json_fused_text_eq`] for the contract.
1985    ///
1986    /// # Errors
1987    /// See [`Self::filter_json_fused_text_eq`].
1988    pub fn filter_json_fused_bool_eq(
1989        mut self,
1990        path: impl Into<String>,
1991        value: bool,
1992    ) -> Result<Self, BuilderValidationError> {
1993        let path = path.into();
1994        validate_fusable_property_path(
1995            self.engine,
1996            &self.root_kind,
1997            &path,
1998            "filter_json_fused_bool_eq",
1999        )?;
2000        self.filter_builder = self
2001            .filter_builder
2002            .filter_json_fused_bool_eq_unchecked(path, value);
2003        Ok(self)
2004    }
2005
2006    /// Compile the builder into a [`CompiledRetrievalPlan`] without executing
2007    /// it. Useful for tests and introspection.
2008    ///
2009    /// # Errors
2010    /// Returns [`CompileError`] if filter partitioning or text-query parsing
2011    /// fails.
2012    pub fn compile_plan(&self) -> Result<CompiledRetrievalPlan, CompileError> {
2013        // Take the filter accumulator AST and rewrite the seed
2014        // `text_search("", 0)` step into the real `Search { query, limit }`
2015        // step. Rewriting in place (rather than appending) preserves the
2016        // post-search position so that the filter partitioner classifies
2017        // every chained `.filter_*` predicate the same way the text/vector
2018        // builders do.
2019        let mut ast: QueryAst = self.filter_builder.clone().into_ast();
2020        ast.root_kind.clone_from(&self.root_kind);
2021        let mut replaced = false;
2022        for step in &mut ast.steps {
2023            if let QueryStep::TextSearch {
2024                query: TextQuery::Empty,
2025                limit: 0,
2026            } = step
2027            {
2028                *step = QueryStep::Search {
2029                    query: self.query.clone(),
2030                    limit: self.limit,
2031                };
2032                replaced = true;
2033                break;
2034            }
2035        }
2036        debug_assert!(
2037            replaced,
2038            "SearchBuilder filter accumulator must contain the seed TextSearch step"
2039        );
2040        let mut plan = fathomdb_query::compile_retrieval_plan(&ast)?;
2041        plan.text.strict.attribution_requested = self.attribution_requested;
2042        if let Some(relaxed) = plan.text.relaxed.as_mut() {
2043            relaxed.attribution_requested = self.attribution_requested;
2044        }
2045        Ok(plan)
2046    }
2047
2048    /// Execute the unified retrieval plan and return matching hits.
2049    ///
2050    /// # Errors
2051    /// Returns [`EngineError`] if compilation or execution fails.
2052    pub fn execute(&self) -> Result<SearchRows, EngineError> {
2053        let plan = self
2054            .compile_plan()
2055            .map_err(|e| EngineError::InvalidConfig(format!("search compilation failed: {e}")))?;
2056        self.engine
2057            .coordinator()
2058            .execute_retrieval_plan(&plan, &self.query)
2059    }
2060
2061    /// Add an expansion slot that traverses edges per root result.
2062    /// Pass `edge_filter: None` to preserve pre-Pack-D behavior.
2063    pub fn expand(
2064        mut self,
2065        slot: impl Into<String>,
2066        direction: fathomdb_query::TraverseDirection,
2067        label: impl Into<String>,
2068        max_depth: usize,
2069        filter: Option<fathomdb_query::Predicate>,
2070        edge_filter: Option<fathomdb_query::Predicate>,
2071    ) -> Self {
2072        self.filter_builder =
2073            self.filter_builder
2074                .expand(slot, direction, label, max_depth, filter, edge_filter);
2075        self
2076    }
2077
2078    /// Compile this builder's AST into an executable grouped query.
2079    ///
2080    /// Rewrites the seed `TextSearch` step into a real text-search step before
2081    /// delegating to [`compile_grouped_query`].
2082    ///
2083    /// # Errors
2084    /// Returns [`CompileError`] if grouped compilation fails.
2085    pub fn compile_grouped(&self) -> Result<CompiledGroupedQuery, CompileError> {
2086        let mut ast: QueryAst = self.filter_builder.clone().into_ast();
2087        ast.root_kind.clone_from(&self.root_kind);
2088        let mut replaced = false;
2089        for step in &mut ast.steps {
2090            if let QueryStep::TextSearch {
2091                query: TextQuery::Empty,
2092                limit: 0,
2093            } = step
2094            {
2095                *step = QueryStep::TextSearch {
2096                    query: TextQuery::parse(&self.query),
2097                    limit: self.limit,
2098                };
2099                replaced = true;
2100                break;
2101            }
2102        }
2103        debug_assert!(
2104            replaced,
2105            "SearchBuilder filter accumulator must contain the seed TextSearch step"
2106        );
2107        compile_grouped_query(&ast)
2108    }
2109
2110    /// Execute the grouped query and return root rows plus named expansion slots.
2111    ///
2112    /// # Errors
2113    /// Returns [`EngineError`] if compilation or execution fails.
2114    pub fn execute_grouped(self) -> Result<GroupedQueryRows, EngineError> {
2115        let compiled = self.compile_grouped().map_err(|e| {
2116            EngineError::InvalidConfig(format!("grouped query compilation failed: {e}"))
2117        })?;
2118        self.engine
2119            .coordinator()
2120            .execute_compiled_grouped_read(&compiled)
2121    }
2122}
2123
2124/// Pack F1 tethered semantic-search builder.
2125///
2126/// Returned by [`NodeQueryBuilder::semantic_search`]. Compiles a
2127/// [`CompiledSemanticSearch`] and dispatches via
2128/// [`fathomdb_engine::ExecutionCoordinator::execute_compiled_semantic_search`].
2129/// Unlike [`VectorSearchBuilder`], the caller supplies a natural-language
2130/// string — the engine embeds it at query time using the db-wide active
2131/// profile embedder.
2132#[must_use]
2133pub struct SemanticSearchBuilder<'e> {
2134    engine: &'e Engine,
2135    root_kind: String,
2136    text: String,
2137    limit: usize,
2138}
2139
2140impl SemanticSearchBuilder<'_> {
2141    /// Compile the builder into a [`CompiledSemanticSearch`] without
2142    /// executing it. Useful for tests and introspection.
2143    ///
2144    /// # Errors
2145    /// Returns [`CompileError`] on future compile-time validation (none in v1).
2146    pub fn compile_plan(&self) -> Result<CompiledSemanticSearch, CompileError> {
2147        Ok(CompiledSemanticSearch {
2148            root_kind: self.root_kind.clone(),
2149            text: self.text.clone(),
2150            limit: self.limit,
2151        })
2152    }
2153
2154    /// Execute the semantic search and return matching hits.
2155    ///
2156    /// # Errors
2157    /// Returns [`EngineError::EmbedderNotConfigured`] if no active
2158    /// embedding profile exists, [`EngineError::KindNotVectorIndexed`] if
2159    /// the kind has no enabled vector-index schema, or
2160    /// [`EngineError::DimensionMismatch`] if the embedder's output length
2161    /// disagrees with the profile's declared dimension. Stale schemas and
2162    /// embedder-unavailable degrade gracefully to an empty result with
2163    /// `was_degraded = true`.
2164    pub fn execute(&self) -> Result<SearchRows, EngineError> {
2165        let plan = self
2166            .compile_plan()
2167            .map_err(|e| EngineError::InvalidConfig(format!("semantic_search compile: {e}")))?;
2168        self.engine
2169            .coordinator()
2170            .execute_compiled_semantic_search(&plan)
2171    }
2172}
2173
2174/// Pack F1 tethered raw-vector-search builder.
2175///
2176/// Returned by [`NodeQueryBuilder::raw_vector_search`]. The caller supplies
2177/// a dense vector; the engine skips the read-time embedder and binds the
2178/// vector directly to the per-kind `vec_<kind>` KNN scan.
2179#[must_use]
2180pub struct RawVectorSearchBuilder<'e> {
2181    engine: &'e Engine,
2182    root_kind: String,
2183    vec: Vec<f32>,
2184    limit: usize,
2185}
2186
2187impl RawVectorSearchBuilder<'_> {
2188    /// Compile the builder into a [`CompiledRawVectorSearch`] without
2189    /// executing it.
2190    ///
2191    /// # Errors
2192    /// Returns [`CompileError`] on future compile-time validation (none in v1).
2193    pub fn compile_plan(&self) -> Result<CompiledRawVectorSearch, CompileError> {
2194        Ok(CompiledRawVectorSearch {
2195            root_kind: self.root_kind.clone(),
2196            vec: self.vec.clone(),
2197            limit: self.limit,
2198        })
2199    }
2200
2201    /// Execute the raw-vector search and return matching hits.
2202    ///
2203    /// # Errors
2204    /// Returns [`EngineError::EmbedderNotConfigured`] if no active
2205    /// embedding profile exists, [`EngineError::KindNotVectorIndexed`] if
2206    /// the kind has no enabled vector-index schema, or
2207    /// [`EngineError::DimensionMismatch`] if `vec.len()` ≠ profile dim.
2208    /// Stale schemas degrade gracefully to an empty result with
2209    /// `was_degraded = true`.
2210    pub fn execute(&self) -> Result<SearchRows, EngineError> {
2211        let plan = self
2212            .compile_plan()
2213            .map_err(|e| EngineError::InvalidConfig(format!("raw_vector_search compile: {e}")))?;
2214        self.engine
2215            .coordinator()
2216            .execute_compiled_raw_vector_search(&plan)
2217    }
2218}
2219
2220#[cfg(test)]
2221#[allow(clippy::expect_used, clippy::panic)]
2222mod tests {
2223    use super::{FallbackSearchBuilder, VectorSearchBuilder};
2224    use crate::{BuilderValidationError, Engine, EngineOptions};
2225    use fathomdb_query::Predicate;
2226    use tempfile::NamedTempFile;
2227
2228    fn open_engine_with_schema(register: bool) -> (NamedTempFile, Engine) {
2229        let db = NamedTempFile::new().expect("temporary db");
2230        let engine = Engine::open(EngineOptions::new(db.path())).expect("engine opens");
2231        if register {
2232            engine
2233                .register_fts_property_schema(
2234                    "Note",
2235                    &["$.title".to_owned(), "$.body".to_owned()],
2236                    None,
2237                )
2238                .expect("register fts property schema");
2239        }
2240        (db, engine)
2241    }
2242
2243    #[test]
2244    fn node_query_fused_text_eq_requires_registered_schema() {
2245        let (_db, engine) = open_engine_with_schema(false);
2246        let result = engine
2247            .query("Note")
2248            .filter_json_fused_text_eq("$.title", "hello");
2249        let Err(err) = result else {
2250            panic!("must reject fused filter without schema");
2251        };
2252        assert!(
2253            matches!(err, BuilderValidationError::MissingPropertyFtsSchema { ref kind } if kind == "Note"),
2254            "expected MissingPropertyFtsSchema, got {err:?}"
2255        );
2256    }
2257
2258    #[test]
2259    fn node_query_fused_text_eq_rejects_path_not_in_schema() {
2260        let (_db, engine) = open_engine_with_schema(true);
2261        let result = engine
2262            .query("Note")
2263            .filter_json_fused_text_eq("$.not_covered", "hello");
2264        let Err(err) = result else {
2265            panic!("path not in schema must be rejected");
2266        };
2267        assert!(
2268            matches!(err, BuilderValidationError::PathNotIndexed { ref kind, ref path } if kind == "Note" && path == "$.not_covered"),
2269            "expected PathNotIndexed, got {err:?}"
2270        );
2271    }
2272
2273    #[test]
2274    fn node_query_fused_text_eq_succeeds_with_registered_schema() {
2275        let (_db, engine) = open_engine_with_schema(true);
2276        let builder = engine
2277            .query("Note")
2278            .filter_json_fused_text_eq("$.title", "hello")
2279            .expect("fused filter with registered schema must succeed");
2280        let compiled = builder.compile().expect("compile");
2281        // The fused predicate must land inside base_candidates, with a
2282        // src.properties json_extract clause.
2283        assert!(
2284            compiled.sql.contains("json_extract(src.properties, ?"),
2285            "fused filter must emit against src.properties, got {}",
2286            compiled.sql
2287        );
2288    }
2289
2290    #[test]
2291    fn text_search_fused_timestamp_gt_validates_and_compiles() {
2292        let (_db, engine) = open_engine_with_schema(true);
2293        // Path must be present in schema.
2294        engine
2295            .register_fts_property_schema("Note2", &["$.written_at".to_owned()], None)
2296            .expect("register Note2 schema");
2297        let builder = engine
2298            .query("Note2")
2299            .text_search("budget", 5)
2300            .filter_json_fused_timestamp_gt("$.written_at", 1_700_000_000)
2301            .expect("fused timestamp gt must succeed with schema");
2302        let _ = builder.compile().expect("compile succeeds");
2303    }
2304
2305    #[test]
2306    fn vector_search_fused_text_eq_validates() {
2307        let (_db, engine) = open_engine_with_schema(true);
2308        let result = VectorSearchBuilder::new(&engine, "NoSchema", "q", 5)
2309            .filter_json_fused_text_eq("$.title", "hello");
2310        let Err(err) = result else {
2311            panic!("missing schema must error");
2312        };
2313        assert!(
2314            matches!(err, BuilderValidationError::MissingPropertyFtsSchema { .. }),
2315            "expected MissingPropertyFtsSchema, got {err:?}"
2316        );
2317        let ok = VectorSearchBuilder::new(&engine, "Note", "q", 5)
2318            .filter_json_fused_text_eq("$.title", "hello");
2319        assert!(ok.is_ok(), "registered kind must succeed");
2320    }
2321
2322    #[test]
2323    fn fallback_search_fused_text_eq_requires_kind_binding() {
2324        let (_db, engine) = open_engine_with_schema(true);
2325        let result = FallbackSearchBuilder::new(&engine, "budget", None, 10)
2326            .filter_json_fused_text_eq("$.title", "hello");
2327        let Err(err) = result else {
2328            panic!("no kind binding must error");
2329        };
2330        assert!(
2331            matches!(err, BuilderValidationError::KindRequiredForFusion { .. }),
2332            "expected KindRequiredForFusion, got {err:?}"
2333        );
2334        let ok = FallbackSearchBuilder::new(&engine, "budget", None, 10)
2335            .filter_kind_eq("Note")
2336            .filter_json_fused_text_eq("$.title", "hello");
2337        assert!(ok.is_ok(), "kind-bound fallback fused filter must succeed");
2338    }
2339
2340    #[test]
2341    fn unified_search_fused_text_eq_validates() {
2342        let (_db, engine) = open_engine_with_schema(true);
2343        let ok = engine
2344            .query("Note")
2345            .search("hello", 5)
2346            .filter_json_fused_text_eq("$.title", "hello");
2347        assert!(
2348            ok.is_ok(),
2349            "unified search builder must accept fused filter"
2350        );
2351        let result = engine
2352            .query("Unknown")
2353            .search("hello", 5)
2354            .filter_json_fused_text_eq("$.title", "hello");
2355        let Err(err) = result else {
2356            panic!("missing schema must error");
2357        };
2358        assert!(matches!(
2359            err,
2360            BuilderValidationError::MissingPropertyFtsSchema { .. }
2361        ));
2362    }
2363
2364    #[test]
2365    fn existing_filter_json_text_eq_still_compiles_unchanged_regression() {
2366        // Regression guard for D3: the post-filter filter_json_* family
2367        // must retain its original non-fused semantics. On the text
2368        // search path this means the predicate lands in the outer WHERE
2369        // against `n.properties`, not inside the CTE.
2370        let (_db, engine) = open_engine_with_schema(false);
2371        let compiled = engine
2372            .query("Note")
2373            .text_search("budget", 5)
2374            .filter_json_text_eq("$.status", "active")
2375            .compile()
2376            .expect("compile");
2377        assert!(
2378            compiled
2379                .sql
2380                .contains("\n  AND json_extract(n.properties, ?"),
2381            "filter_json_text_eq must emit into outer WHERE, got {}",
2382            compiled.sql
2383        );
2384    }
2385
2386    /// P7.5-N1: pin the dummy-step workaround invariant.
2387    ///
2388    /// `FallbackSearchBuilder` seeds an inert `text_search("", 0)` step
2389    /// into its internal filter accumulator so that
2390    /// `partition_search_filters` treats subsequent `.filter_*` calls as
2391    /// post-search predicates (the partitioner only classifies filters
2392    /// that appear AFTER a `TextSearch` or `VectorSearch` step in source
2393    /// order). Without that seed, `.filter_kind_eq("Goal")` would land in
2394    /// neither the fusable nor the residual bucket and would be silently
2395    /// dropped. This test compiles a plan via the public builder API and
2396    /// verifies the kind predicate ends up in `fusable_filters`.
2397    #[test]
2398    fn fallback_builder_filter_kind_eq_fuses_without_explicit_text_search_step() {
2399        let db = NamedTempFile::new().expect("temporary db");
2400        let engine =
2401            Engine::open(EngineOptions::new(db.path())).expect("engine opens for unit test");
2402
2403        let builder = FallbackSearchBuilder::new(&engine, "budget", Some("budget OR nothing"), 10)
2404            .filter_kind_eq("Goal");
2405        let plan = builder.compile_plan().expect("compile plan");
2406
2407        assert!(
2408            plan.strict
2409                .fusable_filters
2410                .iter()
2411                .any(|p| matches!(p, Predicate::KindEq(k) if k == "Goal")),
2412            "KindEq(\"Goal\") must land in strict.fusable_filters (got {:?})",
2413            plan.strict.fusable_filters
2414        );
2415        assert!(
2416            plan.strict.residual_filters.is_empty(),
2417            "strict.residual_filters should be empty for a single kind filter (got {:?})",
2418            plan.strict.residual_filters
2419        );
2420
2421        let relaxed = plan
2422            .relaxed
2423            .as_ref()
2424            .expect("relaxed branch present when caller supplied a relaxed query");
2425        assert!(
2426            relaxed
2427                .fusable_filters
2428                .iter()
2429                .any(|p| matches!(p, Predicate::KindEq(k) if k == "Goal")),
2430            "KindEq(\"Goal\") must also land in relaxed.fusable_filters (got {:?})",
2431            relaxed.fusable_filters
2432        );
2433    }
2434}