Skip to main content

macrame/graph/
vector_filter.rs

1//! Filtered vector search: strategies, the byte-budget cost model, and the
2//! planner that chooses between them (§5.3, D-007).
3//!
4//! A vector query rarely arrives naked. The caller wants the ten nearest
5//! neighbours of an embedding *among concepts reachable in two hops*, and the
6//! two access paths cannot be nested: the DiskANN index is opaque to SQL
7//! predicates, and the relational filter is opaque to the index. Composing them
8//! is a cost decision, and this module makes it arithmetically rather than by
9//! rule of thumb.
10//!
11//! # What changed in this cycle, and why the third strategy is gone
12//!
13//! §5.3 specified three strategies. `TwoPhaseTempTable` was to push the
14//! candidate id set into the vector query as an allow-list, staging it through a
15//! TEMP table. **Both of its mechanisms are absent from libSQL 0.9.30**, and
16//! both were measured rather than reasoned about:
17//!
18//! * `CREATE TEMP TABLE` on the read connection fails with `SQLITE_READONLY
19//!   (8)`. `PRAGMA query_only = ON` (D-019) covers the TEMP database too, and
20//!   D-019 is the runtime half of the write-serialization guarantee, so it is
21//!   the strategy that gives way, not the pragma.
22//! * There is no allow-list to push into. `vector_top_k` refuses a fourth
23//!   argument at runtime — *"too many arguments on vector_top_k() - max 3"* —
24//!   and `vectorIndexSearch` in the bundled amalgamation rejects `argc != 3`
25//!   before it looks at anything else.
26//!
27//! So the variant named an access path this engine does not offer, and the cost
28//! table priced an operation that cannot be issued. It is removed rather than
29//! kept as decoration: that is the precedent D-039 set with `louvain_communities`
30//! returning one community per node. If a future libSQL gains a constrained
31//! index walk, the variant comes back with a body, which is a smaller change
32//! than the confusion of shipping a name with nothing behind it.
33//!
34//! # Why the strategy can never change the answer
35//!
36//! `PostFilter` retrieves a generous `k′` from the index and then discards
37//! whatever fails the predicate. When the filter is tight the answer set falls
38//! off the end of `k′`, and the classic implementation returns four rows for a
39//! top-ten query without saying so. That is a silent wrong answer, which
40//! Doctrine II exists to prevent, so it is not merely documented here — it is
41//! detected. When a post-filtered pass comes back short *and* the underlying
42//! index scan was saturated, the planner cannot conclude the matches do not
43//! exist, so it escalates to the exact strategy and says so at `debug`.
44//!
45//! That gives the module its acceptance gate: the two strategies must agree, on
46//! every query, for every graph. Strategy is then a performance decision and
47//! nothing else, which is the only form in which a planner is safe.
48
49use crate::error::{DbError, Result};
50use crate::graph::builder::TraversalBuilder;
51use crate::vector::{declared_dimension, ModelName, VectorSearchResult};
52
53/// Strategy for combining vector search and graph traversal filters (§5.3).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[non_exhaustive]
56pub enum VectorFilterStrategy {
57    /// Vector top-k′ from the index first, then discard what fails the filter.
58    ///
59    /// Cheap when the filter is loose, because most of the k′ survives. Degrades
60    /// when it is tight, and [`FilteredVectorSearch`] escalates rather than
61    /// under-returning when it detects that it has.
62    PostFilter,
63    /// Candidate ids from the traversal first, then exact distances over just
64    /// those rows.
65    ///
66    /// Exact by construction — every candidate is scored, so nothing can fall
67    /// off the end of a k′. A brute-force scan over `F32_BLOB` with no index,
68    /// so it is priced by the candidate count.
69    PreFilterCTE,
70}
71
72/// What the counting probe learned about the candidate set.
73///
74/// The distinction is the point: SQLite has no histograms and `sqlite_stat1`
75/// carries average rows-per-key, which estimates an equality predicate and not
76/// multi-hop reachability. So the count is *measured*, by running the traversal
77/// under a cap — and a probe that hits its cap has not measured anything except
78/// that the set is too big to care about the exact size.
79///
80/// **The cap became a real one at 0.15.10** ([D-252]). Until then the traversal
81/// ran to completion and the tail was dropped afterwards, so `AtLeast` recorded
82/// that the answer had been trimmed and never that any work had been saved —
83/// C-8's finding that `probe_cap` "bounds memory, not work". It is now
84/// [`TraversalBuilder::limit`](crate::graph::TraversalBuilder::limit), which
85/// stops the recursion, and the variant means what its name always claimed.
86///
87/// [D-252]: ../../docs/architecture/s13-decision-register.md#d-252
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum CandidateCount {
91    /// The traversal ran to the end of the graph and returned this many ids.
92    Exact(usize),
93    /// The walk stopped at the cap. The true count is at least this.
94    ///
95    /// **This is the id count, not the cap**, and the two can differ now that
96    /// the ceiling is on the walk rather than on the list it produced: the walk
97    /// dedupes on `(node, depth)` and the projection drops retired concepts, so
98    /// a walk cut at 10 rows can yield 8 ids. Reporting 8 is the true lower
99    /// bound; reporting 10 would be a number nothing counted.
100    AtLeast(usize),
101}
102
103impl CandidateCount {
104    /// The number to compute with. For a capped probe this understates the true
105    /// count, which is the safe direction: it makes `PreFilterCTE` look cheaper
106    /// than it is, and `PreFilterCTE` is the exact strategy.
107    ///
108    /// It also now reflects what the traversal was *paid for* rather than what
109    /// survived a truncation, which is the half of C-8 the cost model cared
110    /// about: the estimator was pricing strategies against a candidate count
111    /// the walk had already exceeded.
112    pub fn lower_bound(self) -> usize {
113        match self {
114            Self::Exact(n) | Self::AtLeast(n) => n,
115        }
116    }
117
118    pub fn is_capped(self) -> bool {
119        matches!(self, Self::AtLeast(_))
120    }
121}
122
123/// One planning decision, with the arithmetic that produced it (D-007).
124///
125/// Returned rather than only logged, so a caller — and more importantly a test —
126/// can assert on the choice instead of scraping `tracing` output.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct CostEstimate {
130    pub strategy: VectorFilterStrategy,
131    pub candidates: CandidateCount,
132    /// Bytes `PostFilter` is estimated to touch.
133    pub post_filter_bytes: usize,
134    /// Bytes `PreFilterCTE` is estimated to touch.
135    pub pre_filter_bytes: usize,
136    /// The inflated k′ `PostFilter` would request.
137    pub k_prime: usize,
138}
139
140/// Bytes of bookkeeping per candidate id carried through a filter pass.
141///
142/// A ULID is 26 characters and the `String` header is the rest. Deliberately an
143/// estimate of the payload, matching how `Subgraph::estimated_bytes` accounts
144/// for its own (D-047) — the two must use the same arithmetic or the budget
145/// means different things in different modules.
146const ID_BYTES: usize = 26 + std::mem::size_of::<String>();
147
148/// Bytes of a result row once materialized: the id plus the score.
149const ROW_BYTES: usize = ID_BYTES + std::mem::size_of::<f32>();
150
151/// Byte-budget cost model estimator for vector filter strategies (§5.3, D-007).
152///
153/// The 0.4.5–0.5.4 version of this type carried a `byte_budget` field it never
154/// read and branched on `candidate_count` against two hard-coded thresholds
155/// (500, 5000) — D-007's interface with none of D-007's mechanism. It now prices
156/// both strategies in bytes and takes the minimum, and `byte_budget` is a hard
157/// ceiling on the candidate set rather than an unused field.
158#[derive(Debug, Clone)]
159#[non_exhaustive]
160pub struct CostEstimator {
161    pub byte_budget: usize,
162    /// Corpus size: how many vectors the model holds. Sets selectivity, and so
163    /// the k′ inflation.
164    pub corpus: usize,
165    /// Bytes per stored vector, from the model's declared dimension (D-037).
166    pub vector_bytes: usize,
167}
168
169impl CostEstimator {
170    pub fn new(byte_budget: usize, corpus: usize, vector_bytes: usize) -> Self {
171        Self {
172            byte_budget,
173            corpus,
174            vector_bytes,
175        }
176    }
177
178    /// The k′ `PostFilter` must request to expect `k` survivors.
179    ///
180    /// Selectivity is `candidates / corpus`, so `k′ = k × corpus / candidates`.
181    /// Clamped to the corpus: asking the index for more rows than exist is not
182    /// an error but it is not an estimate either, and letting it run away makes
183    /// the cost comparison meaningless for tight filters — which is precisely
184    /// when the comparison matters.
185    pub fn k_prime(&self, k: usize, candidates: usize) -> usize {
186        if candidates == 0 || self.corpus == 0 {
187            return k;
188        }
189        let inflated = (k as u128 * self.corpus as u128) / candidates as u128;
190        (inflated.max(k as u128) as usize).min(self.corpus.max(k))
191    }
192
193    /// Price both strategies and take the minimum (§5.3, D-007).
194    ///
195    /// | Strategy | Estimated bytes |
196    /// |---|---|
197    /// | `PostFilter` | `k′ × (vector_bytes + row_bytes)` + the filter pass |
198    /// | `PreFilterCTE` | the filtered scan + `candidates × vector_bytes` |
199    ///
200    /// The filter pass is common to both — the traversal has to run either way —
201    /// so it appears in both rows and cancels out of the comparison. It is
202    /// included anyway, because the budget ceiling is checked against an absolute
203    /// figure and a cost model that omits a term it "knows" cancels is a cost
204    /// model that lies the moment someone adds a third strategy.
205    pub fn estimate(&self, k: usize, candidates: CandidateCount) -> Result<CostEstimate> {
206        let n = candidates.lower_bound();
207
208        // The hard ceiling of §5.4, applied to the candidate set regardless of
209        // strategy. A capped probe means the set is larger than this figure, so
210        // refusing on the lower bound is the conservative direction.
211        let candidate_bytes = n.saturating_mul(ID_BYTES);
212        if candidate_bytes > self.byte_budget {
213            return Err(DbError::SubgraphTooLarge {
214                n: candidate_bytes,
215                budget: self.byte_budget,
216            });
217        }
218
219        let filter_pass = candidate_bytes;
220        let k_prime = self.k_prime(k, n);
221
222        let post_filter_bytes = k_prime
223            .saturating_mul(self.vector_bytes.saturating_add(ROW_BYTES))
224            .saturating_add(filter_pass);
225        let pre_filter_bytes = n
226            .saturating_mul(self.vector_bytes.saturating_add(ROW_BYTES))
227            .saturating_add(filter_pass);
228
229        let strategy = if post_filter_bytes <= pre_filter_bytes {
230            VectorFilterStrategy::PostFilter
231        } else {
232            VectorFilterStrategy::PreFilterCTE
233        };
234
235        Ok(CostEstimate {
236            strategy,
237            candidates,
238            post_filter_bytes,
239            pre_filter_bytes,
240            k_prime,
241        })
242    }
243}
244
245/// Default ceiling on the candidate set, in bytes.
246pub const DEFAULT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
247
248/// Default cap on the counting probe.
249///
250/// The probe costs a fraction of what it prices, and the cap is what bounds
251/// that fraction. Above it the planner knows only "more than the cap", which is
252/// already enough to reject `PreFilterCTE`.
253///
254/// **It bounds the walk since 0.15.10** and bounded only the returned list
255/// before it, which is C-8. The number is unchanged: 10,000 candidate ids is
256/// still far past the point where `PreFilterCTE` can win, and the release
257/// changed what reaching it costs rather than where it sits.
258pub const DEFAULT_PROBE_CAP: usize = 10_000;
259
260/// A vector search restricted to the nodes a traversal reaches (§5.3).
261///
262/// Mirrors [`TraversalBuilder`], which is the crate's established shape for a
263/// read with options. The strategy is chosen by the planner and not by the
264/// caller: D-007's whole content is that the choice is arithmetic, and a
265/// parameter that lets a caller get it wrong would be a fidelity leak of the
266/// kind Doctrine VIII names. [`Self::strategy`] exists to force a strategy in
267/// tests — above all in the test that requires the two to agree.
268#[derive(Debug, Clone)]
269pub struct FilteredVectorSearch {
270    model: ModelName,
271    query: Vec<f32>,
272    top_k: usize,
273    traversal: TraversalBuilder,
274    forced: Option<VectorFilterStrategy>,
275    byte_budget: usize,
276    probe_cap: usize,
277}
278
279impl FilteredVectorSearch {
280    pub fn new(model: ModelName, query: Vec<f32>, traversal: TraversalBuilder) -> Self {
281        Self {
282            model,
283            query,
284            top_k: 10,
285            traversal,
286            forced: None,
287            byte_budget: DEFAULT_BYTE_BUDGET,
288            probe_cap: DEFAULT_PROBE_CAP,
289        }
290    }
291
292    pub fn top_k(mut self, k: usize) -> Self {
293        self.top_k = k;
294        self
295    }
296
297    pub fn byte_budget(mut self, budget: usize) -> Self {
298        self.byte_budget = budget;
299        self
300    }
301
302    /// How many walk rows the counting probe may pay for.
303    ///
304    /// Applied as [`TraversalBuilder::limit`], so it stops the traversal rather
305    /// than trimming its result, and the two are not the same number: the walk
306    /// dedupes on `(node, depth)` and its projection drops retired concepts, so
307    /// a cap of `n` yields at most `n` candidates. That is exactly what the
308    /// probe wants — it is measuring whether the set is too big to care about
309    /// the size of, and an undercount is the safe direction.
310    pub fn probe_cap(mut self, cap: usize) -> Self {
311        self.probe_cap = cap;
312        self
313    }
314
315    /// Force a strategy, bypassing the planner. For tests and diagnosis.
316    pub fn strategy(mut self, strategy: VectorFilterStrategy) -> Self {
317        self.forced = Some(strategy);
318        self
319    }
320
321    /// Run the search, returning results and the plan that produced them.
322    ///
323    /// The estimate comes back so a caller can log it against reality, which is
324    /// D-007's empirical-tuning requirement. `execute` is the same call with the
325    /// plan dropped.
326    pub async fn execute_explained(
327        &self,
328        conn: &libsql::Connection,
329        now_ts: &str,
330    ) -> Result<(Vec<VectorSearchResult>, CostEstimate)> {
331        if self.top_k == 0 {
332            let estimate = CostEstimate {
333                strategy: VectorFilterStrategy::PreFilterCTE,
334                candidates: CandidateCount::Exact(0),
335                post_filter_bytes: 0,
336                pre_filter_bytes: 0,
337                k_prime: 0,
338            };
339            return Ok((Vec::new(), estimate));
340        }
341
342        // The probe is the traversal, run under a cap. It doubles as the
343        // candidate set: having paid for the walk, throwing the ids away and
344        // walking again for `PreFilterCTE` would be the cost model charging
345        // twice for what it priced once.
346        let (candidates, count) = self.probe(conn, now_ts).await?;
347
348        let dim = declared_dimension(conn, &self.model).await?;
349        let corpus = self.corpus_size(conn).await?;
350        let estimator = CostEstimator::new(self.byte_budget, corpus, dim * 4);
351        let mut estimate = estimator.estimate(self.top_k, count)?;
352
353        if let Some(forced) = self.forced {
354            estimate.strategy = forced;
355        }
356
357        tracing::debug!(
358            strategy = ?estimate.strategy,
359            candidates = candidates.len(),
360            capped = count.is_capped(),
361            k_prime = estimate.k_prime,
362            post_filter_bytes = estimate.post_filter_bytes,
363            pre_filter_bytes = estimate.pre_filter_bytes,
364            "filtered vector search plan"
365        );
366
367        if candidates.is_empty() {
368            return Ok((Vec::new(), estimate));
369        }
370
371        let results = match estimate.strategy {
372            VectorFilterStrategy::PreFilterCTE => self.run_pre_filter(conn, &candidates).await?,
373            VectorFilterStrategy::PostFilter => {
374                let (rows, saturated) = self
375                    .run_post_filter(conn, &candidates, estimate.k_prime)
376                    .await?;
377                // Short *and* saturated means the index scan ran out before the
378                // filter did, so the missing rows may exist and may be nearer
379                // than what came back. Escalate rather than under-return.
380                if rows.len() < self.top_k && saturated {
381                    tracing::debug!(
382                        got = rows.len(),
383                        want = self.top_k,
384                        k_prime = estimate.k_prime,
385                        "post-filter saturated and short; escalating to PreFilterCTE"
386                    );
387                    self.run_pre_filter(conn, &candidates).await?
388                } else {
389                    rows
390                }
391            }
392        };
393
394        Ok((results, estimate))
395    }
396
397    /// Run the search (§5.3).
398    pub async fn execute(
399        &self,
400        conn: &libsql::Connection,
401        now_ts: &str,
402    ) -> Result<Vec<VectorSearchResult>> {
403        Ok(self.execute_explained(conn, now_ts).await?.0)
404    }
405
406    /// The counting probe: the traversal, capped where the cap costs something.
407    ///
408    /// **This is C-8** ([D-252]). The cap used to be `ids.truncate(probe_cap)`
409    /// after a traversal that had already run: `DEFAULT_PROBE_CAP = 10_000` read
410    /// as a bound on cost and bounded only how much of the result was kept, so
411    /// on a hub-heavy graph the expensive part was paid in full and then thrown
412    /// away. The ceiling is now the walk's own, and the walk says whether it
413    /// bit — which is what keeps `Exact` honest. Inferring it from `ids.len() <
414    /// probe_cap` would not: the walk's rows and the ids that survive its
415    /// projection are different counts, so a cut walk can return fewer ids than
416    /// the cap and would have been reported as a complete answer.
417    ///
418    /// The clone is one `TraversalBuilder` per search, against a walk this
419    /// method is about to run. The alternative is a `&mut self` here or a
420    /// `limit` parameter threaded through `execute_ids_explained`, and both put
421    /// the cap somewhere other than on the thing it bounds.
422    ///
423    /// [D-252]: ../../docs/architecture/s13-decision-register.md#d-252
424    async fn probe(
425        &self,
426        conn: &libsql::Connection,
427        now_ts: &str,
428    ) -> Result<(Vec<String>, CandidateCount)> {
429        let capped = self.traversal.clone().limit(self.probe_cap);
430        let (ids, outcome) = capped.execute_ids_explained(conn, now_ts).await?;
431        let count = if outcome.hit_limit() {
432            CandidateCount::AtLeast(ids.len())
433        } else {
434            CandidateCount::Exact(ids.len())
435        };
436        Ok((ids, count))
437    }
438
439    /// How many vectors the model holds.
440    ///
441    /// **`COUNT(*)` per query, deliberately, and measured before being left
442    /// alone (defect AF, Wave 3).** The objection was that D-007 argues strategy
443    /// choice should be arithmetic rather than a rule of thumb, and the
444    /// arithmetic's own input is O(corpus) while the thing it selects is not.
445    /// True in mechanism. Measured:
446    ///
447    /// ```text
448    /// corpus     2,000 vectors    5.2 µs
449    /// corpus    20,000 vectors    8.5 µs
450    /// whole filtered search       2.5 ms
451    /// ```
452    ///
453    /// Ten times the corpus costs 1.6 times the time, because ~4.9 µs of it is a
454    /// round trip and statement preparation — `declared_dimension`, which reads
455    /// one `PRAGMA`, costs 5.0 µs flat for the same reason. Extrapolated to §9's
456    /// stated 100K corpus that is ~22 µs against a 2.5 ms search: **under 1%.**
457    ///
458    /// So it is not cached, and the reason is worth stating because the
459    /// implementation plan proposed caching it on the grounds that "neither
460    /// `corpus_size` nor `declared_dimension` can change without DDL". That is
461    /// true of the dimension and **false of the count** — it changes on every
462    /// `upsert_embeddings`. Caching it would trade a real staleness bug for less
463    /// than one percent of a query. `declared_dimension` *is* DDL-fixed and
464    /// could be cached soundly; at 5 µs there is nothing to buy.
465    async fn corpus_size(&self, conn: &libsql::Connection) -> Result<usize> {
466        let sql = format!("SELECT COUNT(*) FROM {}", self.model.table());
467        let n: i64 = conn
468            .query(&sql, ())
469            .await?
470            .next()
471            .await?
472            .ok_or_else(|| DbError::ModelNotRegistered {
473                model: self.model.to_string(),
474                table: self.model.table(),
475            })?
476            .get(0)?;
477        Ok(n as usize)
478    }
479
480    /// Exact distances over the candidate rows, ordered, limited to `top_k`.
481    ///
482    /// **It joins `concepts` for the same reason `search_vector` does** (0.13.18,
483    /// W9.3, [D-191](../../docs/architecture/s13-decision-register.md#d-191)).
484    /// This is the *third* reader of an embedding table, and the plan that
485    /// closed F-31 named two — the argument for one predicate applied where the
486    /// join is holds here exactly, and without it the two strategies would
487    /// disagree about a retired concept, which is worse than both being wrong.
488    /// `the_strategy_never_changes_the_answer` is the gate that says so.
489    ///
490    /// No `k'` inflation is needed on this path: the filter and the `LIMIT` are
491    /// in one statement, so the limit already applies to survivors.
492    ///
493    /// **The instant comes from the traversal** (0.13.19, W9.4,
494    /// [D-192](../../docs/architecture/s13-decision-register.md#d-192)), and it
495    /// binds after the candidate chunk because the chunk is variadic and the
496    /// instant is not.
497    ///
498    /// Candidate ids are carried in the statement as bound parameters, never
499    /// spliced. A TEMP table would be the natural staging and is unavailable
500    /// under `PRAGMA query_only` (measured: `SQLITE_READONLY (8)`); a bound
501    /// placeholder list needs no write privilege at all, which makes the
502    /// reformulation strictly better than the mechanism it replaces rather than
503    /// a concession to it.
504    async fn run_pre_filter(
505        &self,
506        conn: &libsql::Connection,
507        candidates: &[String],
508    ) -> Result<Vec<VectorSearchResult>> {
509        let blob = self.encoded_query(conn).await?;
510        let at = self.traversal.as_of_valid.as_deref();
511        let mut out: Vec<VectorSearchResult> = Vec::new();
512
513        // SQLITE_MAX_VARIABLE_NUMBER bounds one statement's parameter count, so
514        // a candidate set larger than a chunk becomes several statements whose
515        // results are merged. The alternative — one statement with the ids
516        // interpolated — is the injection shape D-039 removed from the traversal
517        // CTE, and it is not reintroduced here for a read path either.
518        const IDS_PER_STATEMENT: usize = 500;
519        for chunk in candidates.chunks(IDS_PER_STATEMENT) {
520            let placeholders: Vec<String> =
521                (0..chunk.len()).map(|i| format!("?{}", i + 2)).collect();
522            let sql = format!(
523                "SELECT e.concept_id, vector_distance_cos(e.embedding, ?1)
524                   FROM {table} AS e
525                   JOIN concepts AS c ON c.id = e.concept_id
526                  WHERE e.concept_id IN ({ids})
527                    AND {visible}
528                  ORDER BY 2 ASC
529                  LIMIT {k}",
530                table = self.model.table(),
531                ids = placeholders.join(", "),
532                visible = crate::vector::search::visible_concept(at.map(|_| chunk.len() + 2)),
533                k = self.top_k,
534            );
535
536            let mut params: Vec<libsql::Value> = vec![blob.clone().into()];
537            params.extend(chunk.iter().map(|id| id.as_str().into()));
538            if let Some(t) = at {
539                params.push(t.into());
540            }
541
542            let mut rows = conn.query(&sql, params).await?;
543            while let Some(row) = rows.next().await? {
544                out.push(VectorSearchResult {
545                    concept_id: row.get(0)?,
546                    score: row.get::<f64>(1)? as f32,
547                });
548            }
549        }
550
551        // Each chunk was ordered and limited independently, so the merge has to
552        // reorder. Total ordering by score with the id as tie-break: two rows at
553        // an identical distance must not swap between runs, or the same query
554        // answers differently on two machines.
555        out.sort_by(|a, b| {
556            a.score
557                .partial_cmp(&b.score)
558                .unwrap_or(std::cmp::Ordering::Equal)
559                .then_with(|| a.concept_id.cmp(&b.concept_id))
560        });
561        out.truncate(self.top_k);
562        Ok(out)
563    }
564
565    /// Top-k′ from the index, then discard what the filter rejects.
566    ///
567    /// Returns the survivors and whether the index scan was *saturated* — it
568    /// returned every row it was asked for, so there were more it did not
569    /// return. Saturation is what makes a short result inconclusive.
570    ///
571    /// It passes the traversal's instant down for the same reason it passes the
572    /// model down: this arm's answer has to be the other arm's answer, and the
573    /// gate that says so is `a_validity_that_ended_is_invisible_to_the_
574    /// strategy_choice`.
575    ///
576    /// **It passes no half-life, and that is a decision** (0.13.20, W9.5,
577    /// [D-193](../../docs/architecture/s13-decision-register.md#d-193)). Decay
578    /// reorders whatever pool it is handed, and these two strategies do not
579    /// hold the same pool: this one gets the k' the cost model priced, while
580    /// `run_pre_filter` scores every candidate the traversal returned. Ranking
581    /// by age inside each would make the answer a function of the byte estimate
582    /// — which is the one thing [D-050](../../docs/architecture/s13-decision-register.md#d-050)
583    /// forbids, and the property that makes having a planner safe at all.
584    async fn run_post_filter(
585        &self,
586        conn: &libsql::Connection,
587        candidates: &[String],
588        k_prime: usize,
589    ) -> Result<(Vec<VectorSearchResult>, bool)> {
590        let hits = crate::vector::search_vector(
591            conn,
592            &self.query,
593            &self.model,
594            k_prime,
595            self.traversal.as_of_valid.as_deref(),
596            None,
597        )
598        .await?;
599        let saturated = hits.len() >= k_prime;
600
601        let allow: std::collections::HashSet<&str> =
602            candidates.iter().map(String::as_str).collect();
603        let mut out: Vec<VectorSearchResult> = hits
604            .into_iter()
605            .filter(|h| allow.contains(h.concept_id.as_str()))
606            .collect();
607        out.truncate(self.top_k);
608        Ok((out, saturated))
609    }
610
611    async fn encoded_query(&self, conn: &libsql::Connection) -> Result<Vec<u8>> {
612        let dim = declared_dimension(conn, &self.model).await?;
613        crate::vector::EmbeddingCodec::encode(&self.query, dim, self.model.as_str())
614    }
615}