Skip to main content

innate_core/storage/
chunks.rs

1use super::*;
2
3impl Storage {
4    pub fn insert_chunk(&self, c: &ChunkRow) -> Result<()> {
5        self.conn.execute(
6            "INSERT INTO chunks (
7                id, skill_name, seq, content, trigger_desc, anti_trigger_desc,
8                content_hash, token_count, origin, source, maturity, related_ids,
9                protected, state, state_reason, state_updated_at,
10                confidence, confidence_base, confidence_reason, version, distilled_from,
11                distill_provider, distill_model, distill_prompt_version, parent_id,
12                selected_count, used_count, used_success_count,
13                success_trace_ids_count, last_success_at, last_agg_ts,
14                embed_version, created_at, updated_at, last_used_at, agent
15            ) VALUES (
16                ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,
17                ?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,
18                ?26,?27,?28,?29,?30,?31,?32,?33,?34,?35,?36
19            )",
20            params![
21                c.id,
22                c.skill_name,
23                c.seq,
24                c.content,
25                c.trigger_desc,
26                c.anti_trigger_desc,
27                c.content_hash,
28                c.token_count,
29                c.origin,
30                c.source,
31                c.maturity,
32                c.related_ids,
33                c.protected,
34                c.state,
35                c.state_reason,
36                c.state_updated_at,
37                c.confidence,
38                c.confidence,
39                c.confidence_reason,
40                c.version,
41                c.distilled_from,
42                c.distill_provider,
43                c.distill_model,
44                c.distill_prompt_version,
45                c.parent_id,
46                c.selected_count,
47                c.used_count,
48                c.used_success_count,
49                c.success_trace_ids_count,
50                c.last_success_at,
51                c.last_agg_ts,
52                c.embed_version,
53                c.created_at,
54                c.updated_at,
55                c.last_used_at,
56                c.agent
57            ],
58        )?;
59        // Associative entity index (SAG-inspired, ACT-R spreading activation).
60        // Deterministic extraction on the same write path that feeds chunks_fts,
61        // so every chunk writer (lifecycle/distill/curate) indexes entities with
62        // no extra wiring. Sparks are excluded from recall, so skip indexing them.
63        if c.origin != "spark" {
64            self.replace_chunk_entities(
65                &c.id,
66                &crate::entities::extract_entities(&c.content, c.trigger_desc.as_deref()),
67            )?;
68        }
69        Ok(())
70    }
71
72    /// Replace a chunk's associative entities (idempotent: clear then insert).
73    pub fn replace_chunk_entities(
74        &self,
75        chunk_id: &str,
76        entities: &[crate::entities::ExtractedEntity],
77    ) -> Result<()> {
78        self.conn
79            .execute("DELETE FROM chunk_entities WHERE chunk_id=?1", params![chunk_id])?;
80        if entities.is_empty() {
81            return Ok(());
82        }
83        let mut stmt = self.conn.prepare_cached(
84            "INSERT OR IGNORE INTO chunk_entities (chunk_id, entity, etype, weight)
85             VALUES (?1, ?2, ?3, 1.0)",
86        )?;
87        for e in entities {
88            stmt.execute(params![chunk_id, e.entity, e.etype])?;
89        }
90        Ok(())
91    }
92
93    /// Entities of the given chunks (for seeding 2-hop spreading from the highest
94    /// base-relevance candidates). Returns id → its entity strings.
95    pub fn entities_for_chunks(&self, ids: &[&str]) -> Result<HashMap<String, Vec<String>>> {
96        let mut out: HashMap<String, Vec<String>> = HashMap::new();
97        if ids.is_empty() {
98            return Ok(out);
99        }
100        let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
101        let sql = format!(
102            "SELECT chunk_id, entity FROM chunk_entities WHERE chunk_id IN ({placeholders})"
103        );
104        let mut stmt = self.conn.prepare(&sql)?;
105        let params = rusqlite::params_from_iter(ids.iter());
106        let rows = stmt.query_map(params, |r| {
107            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
108        })?;
109        for row in rows {
110            let (cid, ent) = row?;
111            out.entry(cid).or_default().push(ent);
112        }
113        Ok(out)
114    }
115
116    /// Reverse index: chunks linked to any of the given entities, restricted to
117    /// recall-valid chunks (mirrors `search_lexical`: not archived, not spark).
118    /// Returns (entity, chunk_id) pairs; the caller computes fan = group size and
119    /// the ACT-R `1/fan` normalization in pure logic (IO/logic separation).
120    pub fn entity_links(&self, entities: &[&str]) -> Result<Vec<(String, String)>> {
121        if entities.is_empty() {
122            return Ok(Vec::new());
123        }
124        let placeholders = entities.iter().map(|_| "?").collect::<Vec<_>>().join(",");
125        let sql = format!(
126            "SELECT e.entity, e.chunk_id
127             FROM chunk_entities e
128             JOIN chunks c ON c.id = e.chunk_id
129             WHERE e.entity IN ({placeholders})
130               AND c.state != 'archived' AND c.origin != 'spark'"
131        );
132        let mut stmt = self.conn.prepare(&sql)?;
133        let params = rusqlite::params_from_iter(entities.iter());
134        let rows = stmt.query_map(params, |r| {
135            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
136        })?;
137        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
138    }
139
140    pub fn insert_vec_content(&self, chunk_id: &str, emb: &[u8]) -> Result<()> {
141        self.conn.execute(
142            "INSERT OR REPLACE INTO vec_content(chunk_id, embedding) VALUES (?,?)",
143            params![chunk_id, emb],
144        )?;
145        self.note_vector_write(&self.vec_content_cache, chunk_id, emb)
146    }
147
148    pub fn insert_vec_trigger(&self, chunk_id: &str, emb: &[u8]) -> Result<()> {
149        self.conn.execute(
150            "INSERT OR REPLACE INTO vec_trigger(chunk_id, embedding) VALUES (?,?)",
151            params![chunk_id, emb],
152        )?;
153        self.note_vector_write(&self.vec_trigger_cache, chunk_id, emb)
154    }
155
156    /// Record a single vector write: bump the shared revision (so *other*
157    /// processes drop their caches), upsert the one entry into the warm cache
158    /// in place (so *this* long-lived process keeps its cache instead of
159    /// reloading the whole corpus), then re-sync the local revision tracker so
160    /// our own bump does not trip `refresh_vector_caches_if_changed`.
161    ///
162    /// A cold cache (None) is left cold — the next search loads everything,
163    /// including this row. This keeps bulk paths (e.g. full re-embed) O(N) by
164    /// invalidating once up front rather than upserting per write.
165    fn note_vector_write(&self, cache: &VectorCache, chunk_id: &str, emb: &[u8]) -> Result<()> {
166        self.bump_vector_revision()?;
167        if let Some(entries) = cache.borrow_mut().as_mut() {
168            let mut v = unpack_embedding(emb);
169            l2_normalize(&mut v);
170            match entries.iter_mut().find(|(id, _)| id == chunk_id) {
171                Some(slot) => slot.1 = v,
172                None => entries.push((chunk_id.to_string(), v)),
173            }
174        }
175        self.sync_vector_revision()
176    }
177
178    /// Monotonically advance `meta.vector_revision`. Any vector write must call
179    /// this so that other processes detect the change and drop their in-memory
180    /// caches on the next search (see `refresh_vector_caches_if_changed`).
181    fn bump_vector_revision(&self) -> Result<()> {
182        self.conn.execute(
183            "INSERT INTO meta(key, value) VALUES ('vector_revision', '1')
184             ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1",
185            [],
186        )?;
187        Ok(())
188    }
189
190    /// Align the local revision tracker with the persisted value so an in-place
191    /// cache update performed by this process is not discarded on the next search.
192    fn sync_vector_revision(&self) -> Result<()> {
193        let current = self
194            .get_meta("vector_revision")?
195            .and_then(|v| v.parse::<i64>().ok())
196            .unwrap_or(0);
197        self.vector_cache_revision.set(Some(current));
198        Ok(())
199    }
200
201    /// Drop both in-memory vector caches and reset the revision tracker. Used on
202    /// transaction rollback (in-place upserts may not have persisted) and before
203    /// bulk re-embed loops (to avoid O(N²) in-place upserts on a warm cache).
204    pub(crate) fn invalidate_vector_caches(&self) {
205        *self.vec_content_cache.borrow_mut() = None;
206        *self.vec_trigger_cache.borrow_mut() = None;
207        self.vector_cache_revision.set(None);
208    }
209
210    /// Paginated chunk listing for the web viewer. Filters by exact `state` and
211    /// `origin` when provided; returns a compact projection (content truncated to
212    /// a preview) ordered newest-first. Read-only — never mutates.
213    pub fn list_chunks(
214        &self,
215        state: Option<&str>,
216        origin: Option<&str>,
217        limit: usize,
218        offset: usize,
219    ) -> Result<Vec<Value>> {
220        let mut sql = String::from(
221            "SELECT id, skill_name, seq, origin, state, state_reason, maturity, \
222             confidence, token_count, protected, selected_count, used_count, \
223             used_success_count, substr(content, 1, 280) AS content_preview, \
224             created_at, updated_at, last_used_at \
225             FROM chunks",
226        );
227        let mut clauses: Vec<&str> = Vec::new();
228        if state.is_some() {
229            clauses.push("state = :state");
230        }
231        if origin.is_some() {
232            clauses.push("origin = :origin");
233        }
234        if !clauses.is_empty() {
235            sql.push_str(" WHERE ");
236            sql.push_str(&clauses.join(" AND "));
237        }
238        sql.push_str(" ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
239
240        let mut stmt = self.conn.prepare(&sql)?;
241        let names: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
242        let mut params: Vec<(&str, &dyn rusqlite::ToSql)> = Vec::new();
243        if let Some(s) = state.as_ref() {
244            params.push((":state", s));
245        }
246        if let Some(o) = origin.as_ref() {
247            params.push((":origin", o));
248        }
249        let limit_i = limit as i64;
250        let offset_i = offset as i64;
251        params.push((":limit", &limit_i));
252        params.push((":offset", &offset_i));
253
254        let rows = stmt.query_map(params.as_slice(), |r| row_to_json_with_names(r, &names))?;
255        let mut out = Vec::new();
256        for row in rows {
257            out.push(row?);
258        }
259        Ok(out)
260    }
261
262    /// R9 — export full chunk records (not previews) as portable rows for
263    /// `innate export`. Sparks are excluded (recall-exempt). Archived chunks are
264    /// skipped unless `include_archived`. Returns the fields needed to re-`add`
265    /// the chunk elsewhere plus provenance for auditing.
266    pub fn export_chunks(&self, include_archived: bool) -> Result<Vec<Value>> {
267        let sql = if include_archived {
268            "SELECT id, content, trigger_desc, anti_trigger_desc, skill_name,
269                    origin, source, state, confidence, created_at
270             FROM chunks WHERE origin != 'spark' ORDER BY created_at ASC"
271        } else {
272            "SELECT id, content, trigger_desc, anti_trigger_desc, skill_name,
273                    origin, source, state, confidence, created_at
274             FROM chunks WHERE origin != 'spark' AND state != 'archived'
275             ORDER BY created_at ASC"
276        };
277        let mut stmt = self.conn.prepare(sql)?;
278        let rows = stmt.query_map([], |r| {
279            Ok(serde_json::json!({
280                "id": r.get::<_, String>(0)?,
281                "content": r.get::<_, String>(1)?,
282                "trigger_desc": r.get::<_, Option<String>>(2)?,
283                "anti_trigger_desc": r.get::<_, Option<String>>(3)?,
284                "skill_name": r.get::<_, Option<String>>(4)?,
285                "origin": r.get::<_, String>(5)?,
286                "source": r.get::<_, Option<String>>(6)?,
287                "state": r.get::<_, String>(7)?,
288                "confidence": r.get::<_, Option<f64>>(8)?,
289                "created_at": r.get::<_, String>(9)?,
290            }))
291        })?;
292        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
293    }
294
295    pub fn get_chunk(&self, id: &str) -> Result<Option<Value>> {
296        let mut stmt = self
297            .conn
298            .prepare_cached("SELECT * FROM chunks WHERE id=?")?;
299        let row = stmt.query_row([id], row_to_json);
300        match row {
301            Ok(v) => Ok(Some(v)),
302            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
303            Err(e) => Err(e.into()),
304        }
305    }
306
307    pub fn update_chunk_state(
308        &self,
309        id: &str,
310        state: &str,
311        reason: Option<&str>,
312        now: &str,
313    ) -> Result<()> {
314        self.conn.execute(
315            "UPDATE chunks SET state=?, state_reason=?, state_updated_at=?, updated_at=? WHERE id=?",
316            params![state, reason, now, now, id],
317        )?;
318        Ok(())
319    }
320
321    pub fn update_chunk_confidence(
322        &self,
323        id: &str,
324        conf: f64,
325        reason: Option<&str>,
326        now: &str,
327    ) -> Result<()> {
328        self.conn.execute(
329            "UPDATE chunks
330             SET confidence=?, confidence_base=?, confidence_reason=?, updated_at=?
331             WHERE id=?",
332            params![conf, conf, reason, now, id],
333        )?;
334        Ok(())
335    }
336
337    pub fn update_chunk_last_used(&self, id: &str, now: &str) -> Result<()> {
338        self.conn.execute(
339            "UPDATE chunks SET last_used_at=?, updated_at=? WHERE id=?",
340            params![now, now, id],
341        )?;
342        Ok(())
343    }
344
345    pub fn get_chunk_by_hash(&self, hash: &str) -> Result<Option<Value>> {
346        let row = self.conn.query_row(
347            "SELECT * FROM chunks WHERE content_hash=? LIMIT 1",
348            [hash],
349            row_to_json,
350        );
351        match row {
352            Ok(v) => Ok(Some(v)),
353            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
354            Err(e) => Err(e.into()),
355        }
356    }
357
358    // ------------------------------------------------------------------
359    // Vector search (pure-Rust cosine similarity, replaces sqlite-vec)
360    // ------------------------------------------------------------------
361
362    pub fn search_vec_content(&self, query: &[f32], limit: usize) -> Result<Vec<(String, f32)>> {
363        self.search_vec(&self.vec_content_cache, "vec_content", query, limit)
364    }
365
366    pub fn search_vec_trigger(&self, query: &[f32], limit: usize) -> Result<Vec<(String, f32)>> {
367        self.search_vec(&self.vec_trigger_cache, "vec_trigger", query, limit)
368    }
369
370    fn search_vec(
371        &self,
372        cache_cell: &VectorCache,
373        table: &str,
374        query: &[f32],
375        limit: usize,
376    ) -> Result<Vec<(String, f32)>> {
377        if limit == 0 {
378            return Ok(Vec::new());
379        }
380        self.refresh_vector_caches_if_changed()?;
381
382        // Populate cache on first access after open or invalidation.
383        // Stored vectors are L2-normalised here so the search inner loop can use
384        // a plain dot product instead of recomputing norms on every comparison.
385        if cache_cell.borrow().is_none() {
386            let sql = format!("SELECT chunk_id, embedding FROM {table}");
387            let mut stmt = self.conn.prepare(&sql)?;
388            let raw: Vec<(String, Vec<u8>)> = stmt
389                .query_map([], |r| {
390                    Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?))
391                })?
392                .collect::<rusqlite::Result<Vec<_>>>()?;
393            let mut entries: Vec<(String, Vec<f32>)> = Vec::with_capacity(raw.len());
394            for (id, blob) in raw {
395                // Fail-closed: a persisted embedding must be a whole number of
396                // f32 values (4 bytes each). A structurally corrupt blob aborts
397                // the load rather than silently yielding a truncated vector.
398                if blob.is_empty() || blob.len() % 4 != 0 {
399                    return Err(crate::errors::InnateError::Other(format!(
400                        "corrupt embedding for chunk {id} in {table}: {} bytes (not a non-zero multiple of 4)",
401                        blob.len()
402                    )));
403                }
404                let mut v = unpack_embedding(&blob);
405                l2_normalize(&mut v);
406                entries.push((id, v));
407            }
408            *cache_cell.borrow_mut() = Some(entries);
409        }
410
411        let cache = cache_cell.borrow();
412        let entries = cache.as_ref().unwrap();
413
414        // Normalise the query once; cached vectors are already unit-length, so
415        // cosine similarity reduces to a dot product over each entry.
416        let mut q = query.to_vec();
417        l2_normalize(&mut q);
418
419        // Score by (index, similarity) without cloning ids; partial-sort the top
420        // `limit` to the front (O(N) select), then clone ids for the winners only.
421        // Only score vectors whose dimension matches the query. A mismatch means
422        // a stale embed_version vector or a (4-byte-aligned) corruption — either
423        // way it must not contribute a truncated/garbage dot product.
424        let mut scored: Vec<(usize, f32)> = entries
425            .iter()
426            .enumerate()
427            .filter(|(_, (_, v))| v.len() == q.len())
428            .map(|(i, (_, v))| (i, dot_product(&q, v)))
429            .collect();
430        if scored.len() > limit {
431            scored.select_nth_unstable_by(limit - 1, |a, b| {
432                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
433            });
434            scored.truncate(limit);
435        }
436        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
437        Ok(scored
438            .into_iter()
439            .map(|(i, sim)| (entries[i].0.clone(), sim))
440            .collect())
441    }
442
443    /// Lexical/BM25 retrieval channel (hybrid 检索的词法一路). Builds a safe FTS5
444    /// MATCH from the query's alphanumeric tokens and returns up to `limit`
445    /// non-archived, non-spark chunks ranked by BM25, with each score normalised
446    /// to `(0,1]` (best match → 1.0) so it can fuse alongside cosine sims.
447    /// Returns empty when the query has no usable tokens (degrades to vector-only).
448    pub fn search_lexical(&self, query: &str, limit: usize) -> Result<Vec<(String, f32)>> {
449        if limit == 0 {
450            return Ok(Vec::new());
451        }
452        let Some(match_expr) = fts5_match_query(query) else {
453            return Ok(Vec::new());
454        };
455        let mut stmt = self.conn.prepare_cached(
456            "SELECT chunks_fts.id, bm25(chunks_fts) AS score
457             FROM chunks_fts
458             JOIN chunks c ON c.id = chunks_fts.id
459             WHERE chunks_fts MATCH ?1
460               AND c.state != 'archived' AND c.origin != 'spark'
461             ORDER BY score ASC
462             LIMIT ?2",
463        )?;
464        let rows = stmt.query_map(params![match_expr, limit as i64], |r| {
465            Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?))
466        })?;
467        // FTS5 bm25(): more negative = more relevant. Convert to positive
468        // relevance and normalise by the best in this result set (scale-stable
469        // across queries; the top lexical hit always maps to 1.0).
470        let raw: Vec<(String, f64)> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
471        let best_rel = raw.iter().map(|(_, s)| -s).fold(f64::MIN, f64::max);
472        let out = raw
473            .into_iter()
474            .enumerate()
475            .map(|(i, (id, score))| {
476                let sim = if best_rel > 0.0 {
477                    (-score / best_rel).clamp(0.0, 1.0) as f32
478                } else {
479                    // Degenerate (non-positive relevances): fall back to rank decay.
480                    ((limit - i) as f32) / (limit as f32)
481                };
482                (id, sim)
483            })
484            .collect();
485        Ok(out)
486    }
487
488    fn refresh_vector_caches_if_changed(&self) -> Result<()> {
489        let current = self
490            .get_meta("vector_revision")?
491            .and_then(|value| value.parse::<i64>().ok())
492            .unwrap_or(0);
493        let previous = self.vector_cache_revision.replace(Some(current));
494        if previous.is_some_and(|revision| revision != current) {
495            *self.vec_content_cache.borrow_mut() = None;
496            *self.vec_trigger_cache.borrow_mut() = None;
497        }
498        Ok(())
499    }
500
501    /// Fetch multiple chunks by id in one query; returns a map of id → chunk JSON.
502    pub fn get_chunks_by_ids(&self, ids: &[&str]) -> Result<HashMap<String, Value>> {
503        if ids.is_empty() {
504            return Ok(HashMap::new());
505        }
506        let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
507        let sql = format!("SELECT * FROM chunks WHERE id IN ({placeholders})");
508        let mut stmt = self.conn.prepare(&sql)?;
509        let names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
510        let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |r| {
511            row_to_json_with_names(r, &names)
512        })?;
513        let mut map = HashMap::with_capacity(ids.len());
514        for row in rows {
515            let row = row?;
516            if let Some(id) = row.get("id").and_then(Value::as_str) {
517                map.insert(id.to_string(), row);
518            }
519        }
520        Ok(map)
521    }
522
523    // ------------------------------------------------------------------
524    // Invalidated hashes
525    // ------------------------------------------------------------------
526
527    pub fn is_hash_invalidated(&self, hash: &str) -> Result<bool> {
528        let count: i64 = self.conn.query_row(
529            "SELECT count(*) FROM invalidated_hashes WHERE content_hash=?",
530            [hash],
531            |r| r.get(0),
532        )?;
533        Ok(count > 0)
534    }
535
536    pub fn insert_invalidated_hash(
537        &self,
538        hash: &str,
539        reason: Option<&str>,
540        ts: &str,
541    ) -> Result<()> {
542        self.conn.execute(
543            "INSERT OR IGNORE INTO invalidated_hashes(content_hash, reason, ts) VALUES (?,?,?)",
544            params![hash, reason, ts],
545        )?;
546        Ok(())
547    }
548
549    // ------------------------------------------------------------------
550    // Usage trace
551    // ------------------------------------------------------------------
552
553    // Chunk queries (aggregate / curate helpers)
554    // ------------------------------------------------------------------
555
556    pub(crate) fn query_chunks(&self, sql: &str) -> Result<Vec<Value>> {
557        self.query_json(sql, params![])
558    }
559
560    pub(crate) fn query_chunks_params<P: rusqlite::Params>(
561        &self,
562        sql: &str,
563        p: P,
564    ) -> Result<Vec<Value>> {
565        self.query_json(sql, p)
566    }
567
568    // ------------------------------------------------------------------
569    // Deps
570    // ------------------------------------------------------------------
571
572    pub fn get_deps(&self, chunk_id: &str) -> Result<Vec<DepEdge>> {
573        let mut stmt = self
574            .conn
575            .prepare_cached("SELECT dst, kind, dst_lib FROM deps WHERE src=?")?;
576        let rows = stmt.query_map([chunk_id], |r| {
577            Ok((
578                r.get::<_, String>(0)?,
579                r.get::<_, String>(1)?,
580                r.get::<_, Option<String>>(2)?,
581            ))
582        })?;
583        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
584    }
585
586    /// Batch variant of `get_deps`: fetch outgoing edges for many sources in one
587    /// query. Returns `src` → `[(dst, kind, dst_lib)]`. Sources with no edges are
588    /// simply absent from the map.
589    pub fn get_deps_batch(&self, srcs: &[&str]) -> Result<HashMap<String, Vec<DepEdge>>> {
590        if srcs.is_empty() {
591            return Ok(HashMap::new());
592        }
593        let placeholders = srcs.iter().map(|_| "?").collect::<Vec<_>>().join(",");
594        let sql = format!("SELECT src, dst, kind, dst_lib FROM deps WHERE src IN ({placeholders})");
595        let mut stmt = self.conn.prepare(&sql)?;
596        let rows = stmt.query_map(rusqlite::params_from_iter(srcs.iter()), |r| {
597            Ok((
598                r.get::<_, String>(0)?,
599                r.get::<_, String>(1)?,
600                r.get::<_, String>(2)?,
601                r.get::<_, Option<String>>(3)?,
602            ))
603        })?;
604        let mut map: HashMap<String, Vec<(String, String, Option<String>)>> = HashMap::new();
605        for row in rows {
606            let (src, dst, kind, lib) = row?;
607            map.entry(src).or_default().push((dst, kind, lib));
608        }
609        Ok(map)
610    }
611
612    pub fn get_reverse_deps(&self, chunk_id: &str) -> Result<Vec<String>> {
613        let mut stmt = self
614            .conn
615            .prepare_cached("SELECT src FROM deps WHERE dst=?")?;
616        let rows = stmt.query_map([chunk_id], |r| r.get::<_, String>(0))?;
617        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
618    }
619
620    pub fn insert_dep(
621        &self,
622        src: &str,
623        dst: &str,
624        kind: &str,
625        dst_lib: Option<&str>,
626    ) -> Result<()> {
627        self.conn.execute(
628            "INSERT OR IGNORE INTO deps(src,dst,kind,dst_lib) VALUES (?,?,?,?)",
629            params![src, dst, kind, dst_lib],
630        )?;
631        Ok(())
632    }
633
634    // ------------------------------------------------------------------
635    // Chunk success traces (aggregate fact table)
636    // ------------------------------------------------------------------
637
638    pub fn upsert_chunk_success_trace(
639        &self,
640        chunk_id: &str,
641        trace_id: &str,
642        ts: &str,
643    ) -> Result<()> {
644        self.conn.execute(
645            "INSERT OR IGNORE INTO chunk_success_traces(chunk_id, trace_id, ts) VALUES (?,?,?)",
646            params![chunk_id, trace_id, ts],
647        )?;
648        Ok(())
649    }
650
651    // ------------------------------------------------------------------
652}
653
654/// Build a safe FTS5 MATCH expression from free text. Splits on non-alphanumeric
655/// boundaries, lowercases, drops 1-char and a few high-frequency tokens, quotes
656/// each remaining token as a phrase (so no FTS5 operator can be injected), and
657/// OR-joins them. BM25's IDF naturally downweights common terms, so aggressive
658/// stop-word pruning is unnecessary. Returns `None` when nothing usable remains.
659pub(crate) fn fts5_match_query(query: &str) -> Option<String> {
660    // Minimal stop set — only the highest-frequency function words. Kept small on
661    // purpose: BM25 idf handles the rest, and over-pruning loses recall.
662    const STOP: &[&str] = &[
663        "the", "a", "an", "to", "of", "in", "on", "for", "and", "or", "is", "do", "i",
664    ];
665    let mut seen = std::collections::HashSet::new();
666    let tokens: Vec<String> = query
667        .split(|c: char| !c.is_alphanumeric())
668        .filter(|t| t.len() >= 2)
669        .map(|t| t.to_lowercase())
670        .filter(|t| !STOP.contains(&t.as_str()))
671        .filter(|t| seen.insert(t.clone()))
672        .take(32)
673        .map(|t| format!("\"{t}\""))
674        .collect();
675    if tokens.is_empty() {
676        None
677    } else {
678        Some(tokens.join(" OR "))
679    }
680}