Skip to main content

kindling_provider/
provider.rs

1//! Local FTS-based retrieval provider.
2//!
3//! Mirrors `LocalFtsProvider` in
4//! `packages/kindling-provider-local/src/provider/local-fts.ts`: FTS
5//! matching, scope filtering, and recency are computed in SQL; BM25
6//! normalization is done in Rust across both entity types so scores are
7//! comparable between observations and summaries.
8//!
9//! Scoring formula:
10//!
11//! ```text
12//! score = (fts_relevance * 0.7) + (recency_score * 0.3)
13//! ```
14//!
15//! where `fts_relevance` is the BM25 rank normalized to `[0, 1]` across all
16//! results and `recency_score = MAX(0, 1.0 - age_ms / max_age_ms)`.
17//!
18//! The one deliberate API deviation from TypeScript: the TS provider reads
19//! `Date.now()` internally, while [`RetrievalProvider::search`] takes `now`
20//! explicitly so retrieval is deterministic and testable.
21
22use rusqlite::types::Value as SqlValue;
23use rusqlite::{params_from_iter, Connection};
24
25use kindling_store::SqliteKindlingStore;
26use kindling_types::{
27    Id, Observation, ObservationKind, ProviderSearchOptions, ProviderSearchResult, RetrievedEntity,
28    ScopeIds, Summary, Timestamp,
29};
30
31use crate::error::{ProviderError, ProviderResult};
32
33/// Max age in ms for recency scoring (30 days).
34const MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000;
35
36/// Default result cap when `max_results` is not given.
37const DEFAULT_MAX_RESULTS: u32 = 50;
38
39/// Match-context preview length in UTF-16 code units (JS `string.length`).
40const MATCH_CONTEXT_UNITS: usize = 100;
41
42/// A retrieval provider: named source of ranked search results.
43///
44/// Mirrors the `RetrievalProvider` interface in
45/// `packages/kindling-core/src/types/retrieval.ts`, with `now` made explicit
46/// (epoch ms) instead of read from the system clock inside the provider.
47pub trait RetrievalProvider {
48    /// Provider name, recorded in retrieval provenance.
49    fn name(&self) -> &str;
50
51    /// Ranked search results for `options`, scored as of `now` (epoch ms).
52    fn search(
53        &self,
54        options: &ProviderSearchOptions,
55        now: Timestamp,
56    ) -> ProviderResult<Vec<ProviderSearchResult>>;
57}
58
59/// Row returned by the raw observations query (pre-normalization).
60struct RawObsRow {
61    id: String,
62    kind: String,
63    content: String,
64    provenance: String,
65    ts: Timestamp,
66    scope_ids: String,
67    redacted: i64,
68    fts_rank: f64,
69    recency: f64,
70}
71
72/// Row returned by the raw summaries query (pre-normalization).
73struct RawSumRow {
74    id: String,
75    capsule_id: String,
76    content: String,
77    confidence: f64,
78    evidence_refs: String,
79    created_at: Timestamp,
80    fts_rank: f64,
81    recency: f64,
82}
83
84/// FTS5 + recency retrieval over a kindling SQLite database.
85pub struct LocalFtsProvider<'conn> {
86    conn: &'conn Connection,
87}
88
89impl<'conn> LocalFtsProvider<'conn> {
90    /// Provider name, as recorded in retrieval provenance.
91    pub const NAME: &'static str = "local-fts";
92
93    pub fn new(conn: &'conn Connection) -> Self {
94        Self { conn }
95    }
96
97    /// Borrow the connection of an open store.
98    pub fn from_store(store: &'conn SqliteKindlingStore) -> Self {
99        Self::new(store.connection())
100    }
101
102    /// Search observations: raw rows with `fts_rank` and `recency`. BM25
103    /// normalization happens in the caller across all entity types.
104    fn search_observations_raw(
105        &self,
106        query: &str,
107        scope: &ScopeIds,
108        exclude_ids: &[Id],
109        include_redacted: bool,
110        now: Timestamp,
111        limit: u32,
112    ) -> ProviderResult<Vec<RawObsRow>> {
113        let (scope_clauses, scope_params) = build_scope_filters(scope, "o");
114        let exclude_filter = exclude_id_filter("o", exclude_ids);
115        let redacted_filter = if include_redacted {
116            ""
117        } else {
118            "AND o.redacted = 0"
119        };
120        let scope_filter = if scope_clauses.is_empty() {
121            String::new()
122        } else {
123            format!("AND {}", scope_clauses.join(" AND "))
124        };
125
126        let sql = format!(
127            "WITH fts_hits AS (
128               SELECT rowid, rank FROM observations_fts WHERE content MATCH ?
129             )
130             SELECT
131               o.id, o.kind, o.content, o.provenance, o.ts, o.scope_ids, o.redacted,
132               f.rank AS fts_rank,
133               MAX(0.0, 1.0 - CAST(? - o.ts AS REAL) / ?) AS recency
134             FROM fts_hits f
135             JOIN observations o ON f.rowid = o.rowid
136             WHERE 1=1
137               {redacted_filter}
138               {scope_filter}
139               {exclude_filter}
140             ORDER BY f.rank ASC
141             LIMIT ?"
142        );
143
144        let mut params: Vec<SqlValue> = vec![
145            SqlValue::Text(query.to_string()),
146            SqlValue::Integer(now),
147            SqlValue::Integer(MAX_AGE_MS),
148        ];
149        params.extend(scope_params);
150        params.extend(exclude_ids.iter().map(|id| SqlValue::Text(id.clone())));
151        params.push(SqlValue::Integer(i64::from(limit)));
152
153        let result = (|| -> Result<Vec<RawObsRow>, rusqlite::Error> {
154            let mut stmt = self.conn.prepare(&sql)?;
155            let rows = stmt.query_map(params_from_iter(params), |row| {
156                Ok(RawObsRow {
157                    id: row.get(0)?,
158                    kind: row.get(1)?,
159                    content: row.get(2)?,
160                    provenance: row.get(3)?,
161                    ts: row.get(4)?,
162                    scope_ids: row.get(5)?,
163                    redacted: row.get(6)?,
164                    fts_rank: row.get(7)?,
165                    recency: row.get(8)?,
166                })
167            })?;
168            rows.collect()
169        })();
170
171        match result {
172            Ok(rows) => Ok(rows),
173            Err(err) if is_fts_syntax_error(&err) => Ok(Vec::new()),
174            Err(err) => Err(err.into()),
175        }
176    }
177
178    /// Search summaries: raw rows with `fts_rank` and `recency`. BM25
179    /// normalization happens in the caller across all entity types.
180    fn search_summaries_raw(
181        &self,
182        query: &str,
183        scope: &ScopeIds,
184        exclude_ids: &[Id],
185        now: Timestamp,
186        limit: u32,
187    ) -> ProviderResult<Vec<RawSumRow>> {
188        let (scope_clauses, scope_params) = build_scope_filters(scope, "c");
189        let exclude_filter = exclude_id_filter("s", exclude_ids);
190        let scope_filter = if scope_clauses.is_empty() {
191            String::new()
192        } else {
193            format!("AND {}", scope_clauses.join(" AND "))
194        };
195
196        let sql = format!(
197            "WITH fts_hits AS (
198               SELECT rowid, rank FROM summaries_fts WHERE content MATCH ?
199             )
200             SELECT
201               s.id, s.capsule_id, s.content, s.confidence, s.evidence_refs, s.created_at,
202               f.rank AS fts_rank,
203               MAX(0.0, 1.0 - CAST(? - s.created_at AS REAL) / ?) AS recency
204             FROM fts_hits f
205             JOIN summaries s ON f.rowid = s.rowid
206             JOIN capsules c ON s.capsule_id = c.id
207             WHERE 1=1
208               {scope_filter}
209               {exclude_filter}
210             ORDER BY f.rank ASC
211             LIMIT ?"
212        );
213
214        let mut params: Vec<SqlValue> = vec![
215            SqlValue::Text(query.to_string()),
216            SqlValue::Integer(now),
217            SqlValue::Integer(MAX_AGE_MS),
218        ];
219        params.extend(scope_params);
220        params.extend(exclude_ids.iter().map(|id| SqlValue::Text(id.clone())));
221        params.push(SqlValue::Integer(i64::from(limit)));
222
223        let result = (|| -> Result<Vec<RawSumRow>, rusqlite::Error> {
224            let mut stmt = self.conn.prepare(&sql)?;
225            let rows = stmt.query_map(params_from_iter(params), |row| {
226                Ok(RawSumRow {
227                    id: row.get(0)?,
228                    capsule_id: row.get(1)?,
229                    content: row.get(2)?,
230                    confidence: row.get(3)?,
231                    evidence_refs: row.get(4)?,
232                    created_at: row.get(5)?,
233                    fts_rank: row.get(6)?,
234                    recency: row.get(7)?,
235                })
236            })?;
237            rows.collect()
238        })();
239
240        match result {
241            Ok(rows) => Ok(rows),
242            Err(err) if is_fts_syntax_error(&err) => Ok(Vec::new()),
243            Err(err) => Err(err.into()),
244        }
245    }
246}
247
248impl RetrievalProvider for LocalFtsProvider<'_> {
249    fn name(&self) -> &str {
250        Self::NAME
251    }
252
253    fn search(
254        &self,
255        options: &ProviderSearchOptions,
256        now: Timestamp,
257    ) -> ProviderResult<Vec<ProviderSearchResult>> {
258        let max_results = options.max_results.unwrap_or(DEFAULT_MAX_RESULTS);
259        let exclude_ids = options.exclude_ids.as_deref().unwrap_or(&[]);
260        let include_redacted = options.include_redacted.unwrap_or(false);
261
262        let obs_raw = self.search_observations_raw(
263            &options.query,
264            &options.scope_ids,
265            exclude_ids,
266            include_redacted,
267            now,
268            max_results,
269        )?;
270        let sum_raw = self.search_summaries_raw(
271            &options.query,
272            &options.scope_ids,
273            exclude_ids,
274            now,
275            max_results,
276        )?;
277
278        // Normalize BM25 ranks across BOTH result sets so scores are
279        // comparable. FTS5 rank is negative; more negative = more relevant.
280        let mut min_rank = f64::INFINITY;
281        let mut max_rank = f64::NEG_INFINITY;
282        for rank in obs_raw
283            .iter()
284            .map(|r| r.fts_rank)
285            .chain(sum_raw.iter().map(|r| r.fts_rank))
286        {
287            min_rank = min_rank.min(rank);
288            max_rank = max_rank.max(rank);
289        }
290        let rank_range = if min_rank <= max_rank {
291            max_rank - min_rank
292        } else {
293            0.0 // no rows at all; loop below never runs
294        };
295        let normalize_fts = |rank: f64| -> f64 {
296            if rank_range == 0.0 {
297                0.5 // Unknown relative relevance
298            } else {
299                (max_rank - rank) / rank_range
300            }
301        };
302        let score_of = |fts_rank: f64, recency: f64| -> f64 {
303            let score = (normalize_fts(fts_rank) * 0.7 + recency * 0.3).clamp(0.0, 1.0);
304            (score * 1e10).round() / 1e10
305        };
306
307        let mut results: Vec<ProviderSearchResult> =
308            Vec::with_capacity(obs_raw.len() + sum_raw.len());
309
310        for row in obs_raw {
311            results.push(ProviderSearchResult {
312                score: score_of(row.fts_rank, row.recency),
313                match_context: Some(match_context(&row.content)),
314                entity: RetrievedEntity::Observation(Observation {
315                    id: row.id,
316                    kind: parse_observation_kind(&row.kind)?,
317                    provenance: serde_json::from_str(&row.provenance)?,
318                    ts: row.ts,
319                    scope_ids: serde_json::from_str(&row.scope_ids)?,
320                    redacted: row.redacted == 1,
321                    content: row.content,
322                }),
323            });
324        }
325
326        for row in sum_raw {
327            results.push(ProviderSearchResult {
328                score: score_of(row.fts_rank, row.recency),
329                match_context: Some(match_context(&row.content)),
330                entity: RetrievedEntity::Summary(Summary {
331                    id: row.id,
332                    capsule_id: row.capsule_id,
333                    confidence: row.confidence,
334                    created_at: row.created_at,
335                    evidence_refs: serde_json::from_str(&row.evidence_refs)?,
336                    content: row.content,
337                }),
338            });
339        }
340
341        // Stable sort, descending score: ties keep observations-then-summaries
342        // insertion order, matching the TS provider's stable Array.sort.
343        results.sort_by(|a, b| b.score.total_cmp(&a.score));
344        results.truncate(max_results as usize);
345        Ok(results)
346    }
347}
348
349/// First 100 UTF-16 code units of `content`, with `...` appended when
350/// truncated. Counts code units to match JS `string.length` / `substring`;
351/// rounds down at a surrogate-pair boundary (same policy as kindling-filter).
352fn match_context(content: &str) -> String {
353    let mut units = 0usize;
354    for (byte_idx, ch) in content.char_indices() {
355        let width = ch.len_utf16();
356        if units + width > MATCH_CONTEXT_UNITS {
357            return format!("{}...", &content[..byte_idx]);
358        }
359        units += width;
360    }
361    content.to_string()
362}
363
364/// `AND <prefix>.<column> = ?` clauses for each scope dimension that is set.
365/// `task_id` has no denormalized column and is intentionally not filterable,
366/// matching the TS provider.
367fn build_scope_filters(scope: &ScopeIds, prefix: &str) -> (Vec<String>, Vec<SqlValue>) {
368    let mut clauses = Vec::new();
369    let mut params = Vec::new();
370    let filters = [
371        ("session_id", &scope.session_id),
372        ("repo_id", &scope.repo_id),
373        ("agent_id", &scope.agent_id),
374        ("user_id", &scope.user_id),
375    ];
376    for (column, value) in filters {
377        if let Some(value) = value {
378            clauses.push(format!("{prefix}.{column} = ?"));
379            params.push(SqlValue::Text(value.clone()));
380        }
381    }
382    (clauses, params)
383}
384
385/// `AND <prefix>.id NOT IN (?, …)` for excluded IDs, or empty.
386fn exclude_id_filter(prefix: &str, exclude_ids: &[Id]) -> String {
387    if exclude_ids.is_empty() {
388        return String::new();
389    }
390    let placeholders = vec!["?"; exclude_ids.len()].join(",");
391    format!("AND {prefix}.id NOT IN ({placeholders})")
392}
393
394/// True for FTS5 query-syntax errors, which are swallowed into empty results
395/// (malformed user queries are not provider failures). Mirrors
396/// `isFtsSyntaxError` in the TS provider.
397fn is_fts_syntax_error(err: &rusqlite::Error) -> bool {
398    let msg = err.to_string().to_lowercase();
399    msg.contains("fts5")
400        || msg.contains("fts syntax")
401        || msg.contains("unterminated string")
402        || msg.contains("unknown special query")
403}
404
405fn parse_observation_kind(value: &str) -> ProviderResult<ObservationKind> {
406    serde_json::from_value(serde_json::Value::String(value.to_string())).map_err(|_| {
407        ProviderError::UnexpectedRowValue {
408            column: "kind",
409            value: value.to_string(),
410        }
411    })
412}