Skip to main content

rustbrain_core/
query.rs

1//! Ranked hybrid query: FTS5 BM25 + tag / alias / title boosts.
2//!
3//! This is not neural retrieval. Scores are useful for ordering within a single
4//! brain (and for cross-workspace merge) but are not calibrated probabilities.
5
6use crate::error::Result;
7use crate::fts::prepare_search_query;
8use crate::storage::Database;
9use crate::types::{Node, NodeType};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13/// A single ranked search hit.
14///
15/// `reasons` is a debug-oriented list of score components (e.g. `fts:…`, `tag:raft`)
16/// intended for CLI `--scores` and UI tooltips — not a stable public schema.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RankedHit {
19    /// Matching node.
20    pub node: Node,
21    /// Higher is better. Combines inverted BM25 with additive boosts.
22    pub score: f32,
23    /// Why this hit was selected (for debugging / UI).
24    pub reasons: Vec<String>,
25}
26
27/// How much of MainBrain to include when [`QueryOptions::scope`] is set.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ScopeMainInclude {
30    /// Only the selected SubBrain (no MainBrain rows).
31    Strict,
32    /// Selected SubBrain + stable hub node ids (`readme`, `changelog`, `roadmap`, `backlog`).
33    ///
34    /// **Default** — avoids flooding scoped search with every root doc while still
35    /// orienting agents to project hubs.
36    #[default]
37    HubsOnly,
38    /// Selected SubBrain + every node owned by MainBrain (`main_scope`).
39    AllMain,
40}
41
42/// Options for [`Database::search_ranked`] / [`crate::Brain::query_ranked`].
43#[derive(Debug, Clone)]
44pub struct QueryOptions {
45    /// Maximum hits to return after ranking and deduplication.
46    pub limit: usize,
47    /// Multiplicative boosts applied when `node.node_type` matches.
48    ///
49    /// Defaults slightly prefer goals/ADRs/edge cases over raw symbols.
50    pub type_boosts: Vec<(NodeType, f32)>,
51    /// If non-empty, only include these node types.
52    pub include_types: Vec<NodeType>,
53    /// Exclude these node types after ranking (applied after include filter).
54    pub exclude_types: Vec<NodeType>,
55    /// Convenience: exclude [`NodeType::Symbol`] (typical for human CLI search).
56    pub no_symbols: bool,
57    /// When set (multi-brain), filter by owner scope (see [`Self::scope_main`]).
58    pub scope: Option<String>,
59    /// MainBrain id used when `scope` is set (default `main`).
60    pub main_scope: String,
61    /// How much MainBrain content to mix into a scoped query (default hubs-only).
62    pub scope_main: ScopeMainInclude,
63}
64
65impl Default for QueryOptions {
66    fn default() -> Self {
67        Self {
68            limit: 50,
69            type_boosts: vec![
70                (NodeType::Goal, 1.15),
71                (NodeType::Changelog, 1.12),
72                (NodeType::Plan, 1.12),
73                (NodeType::Adr, 1.1),
74                (NodeType::EdgeCase, 1.1),
75                (NodeType::Analysis, 1.08),
76                (NodeType::Concept, 1.05),
77                (NodeType::Symbol, 0.95),
78            ],
79            include_types: Vec::new(),
80            exclude_types: Vec::new(),
81            no_symbols: false,
82            scope: None,
83            main_scope: crate::scopes::MAIN_SCOPE.to_string(),
84            scope_main: ScopeMainInclude::HubsOnly,
85        }
86    }
87}
88
89impl QueryOptions {
90    /// Human-oriented defaults: exclude pure code symbols unless asked otherwise.
91    pub fn human() -> Self {
92        Self {
93            no_symbols: true,
94            ..Self::default()
95        }
96    }
97
98    /// Restrict to a SubBrain (default Main mix: hubs only).
99    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
100        self.scope = Some(scope.into());
101        self
102    }
103
104    fn allows(&self, ty: &NodeType) -> bool {
105        if self.no_symbols && *ty == NodeType::Symbol {
106            return false;
107        }
108        if !self.include_types.is_empty() && !self.include_types.contains(ty) {
109            return false;
110        }
111        if self.exclude_types.contains(ty) {
112            return false;
113        }
114        true
115    }
116
117    /// Whether a node passes the multi-brain scope filter.
118    pub fn allows_scope(&self, node_scope: &str, node_id: &str) -> bool {
119        let Some(want) = self.scope.as_deref() else {
120            return true;
121        };
122        if node_scope == want {
123            return true;
124        }
125        match self.scope_main {
126            ScopeMainInclude::Strict => false,
127            ScopeMainInclude::HubsOnly => crate::hubs::is_hub_node_id(node_id),
128            ScopeMainInclude::AllMain => node_scope == self.main_scope,
129        }
130    }
131}
132
133fn map_ranked_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Node, f32)> {
134    let type_str: String = row.get(1)?;
135    let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
136    let symbol_hash_raw: Option<i64> = row.get(4)?;
137    let scope: String = row
138        .get::<_, Option<String>>(7)?
139        .filter(|s| !s.is_empty())
140        .unwrap_or_else(|| crate::scopes::MAIN_SCOPE.to_string());
141    let bm25: f64 = row.get(10)?;
142    Ok((
143        Node {
144            id: row.get(0)?,
145            node_type,
146            title: row.get(2)?,
147            file_path: row.get(3)?,
148            symbol_hash: symbol_hash_raw.map(|h| h as u64),
149            summary: row.get(5)?,
150            content_hash: row.get(6)?,
151            scope,
152            created_at: row.get(8)?,
153            updated_at: row.get(9)?,
154        },
155        bm25 as f32,
156    ))
157}
158
159impl Database {
160    /// Ranked search combining FTS5 BM25 with tag, alias, title, and id boosts.
161    ///
162    /// Steps:
163    /// 1. Prepare the query (tokenize, drop stopwords, OR multi-token MATCH)
164    /// 2. Collect BM25-ordered candidates (oversample by `2 × limit`)
165    /// 3. Apply additive boosts for title/id/tag/alias token hits + multi-token coverage
166    /// 4. Apply multiplicative type priors from [`QueryOptions::type_boosts`]
167    /// 5. Augment with pure tag/alias exact matches FTS may have missed
168    /// 6. Sort by score, dedupe by id, truncate to `limit`
169    ///
170    /// # Errors
171    ///
172    /// Returns [`crate::BrainError::FtsQuery`] for empty input after tokenization.
173    pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
174        let prepared = prepare_search_query(query)?;
175        let tokens = &prepared.tokens;
176
177        // Oversample less when SQL already scope-filters; still 2× for type filters.
178        let sql_limit = (opts.limit as i64).saturating_mul(2).max(50);
179
180        let (sql, bind_scope): (String, Option<(String, ScopeMainInclude, String)>) =
181            if let Some(ref want) = opts.scope {
182                let clause = match opts.scope_main {
183                    ScopeMainInclude::Strict => {
184                        " AND n.scope = ?2 ".to_string()
185                    }
186                    ScopeMainInclude::HubsOnly => {
187                        " AND (n.scope = ?2 OR n.id IN ('readme','changelog','roadmap','backlog')) "
188                            .to_string()
189                    }
190                    ScopeMainInclude::AllMain => {
191                        " AND (n.scope = ?2 OR n.scope = ?3) ".to_string()
192                    }
193                };
194                (
195                    format!(
196                        "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
197                    n.content_hash, n.scope, n.created_at, n.updated_at,
198                    bm25(node_fts) AS bm25_score
199             FROM node_fts f
200             JOIN nodes n ON n.id = f.node_id
201             WHERE node_fts MATCH ?1
202             {clause}
203             ORDER BY bm25_score
204             LIMIT ?{lim}",
205                        lim = if matches!(opts.scope_main, ScopeMainInclude::AllMain) {
206                            4
207                        } else {
208                            3
209                        }
210                    ),
211                    Some((want.clone(), opts.scope_main, opts.main_scope.clone())),
212                )
213            } else {
214                (
215                    "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
216                    n.content_hash, n.scope, n.created_at, n.updated_at,
217                    bm25(node_fts) AS bm25_score
218             FROM node_fts f
219             JOIN nodes n ON n.id = f.node_id
220             WHERE node_fts MATCH ?1
221             ORDER BY bm25_score
222             LIMIT ?2"
223                        .to_string(),
224                    None,
225                )
226            };
227
228        let mut stmt = self.conn.prepare(&sql)?;
229        let rows = match &bind_scope {
230            None => stmt
231                .query_map(params![prepared.fts_match, sql_limit], map_ranked_row)?
232                .collect::<std::result::Result<Vec<_>, _>>()?,
233            Some((want, ScopeMainInclude::AllMain, main)) => stmt
234                .query_map(
235                    params![prepared.fts_match, want.as_str(), main.as_str(), sql_limit],
236                    map_ranked_row,
237                )?
238                .collect::<std::result::Result<Vec<_>, _>>()?,
239            Some((want, _, _)) => stmt
240                .query_map(params![prepared.fts_match, want.as_str(), sql_limit], map_ranked_row)?
241                .collect::<std::result::Result<Vec<_>, _>>()?,
242        };
243
244        let mut hits: Vec<RankedHit> = Vec::new();
245        for (node, bm25) in rows {
246            if !opts.allows(&node.node_type) {
247                continue;
248            }
249            if !opts.allows_scope(&node.scope, &node.id) {
250                continue;
251            }
252            // bm25() in SQLite FTS5: more relevant → more negative. Invert.
253            let fts_score = (-bm25).max(0.01);
254            let mut score = fts_score;
255            let mut reasons = vec![format!("fts:{fts_score:.3}")];
256            if prepared.used_or {
257                reasons.push("fts:or".into());
258            }
259            // Hub boost for root README / CHANGELOG / planning files.
260            if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
261                score *= 1.2;
262                reasons.push("hub:readme".into());
263            }
264            if node.id == crate::hubs::HUB_CHANGELOG
265                || node
266                    .file_path
267                    .as_deref()
268                    .is_some_and(|p| p.eq_ignore_ascii_case("CHANGELOG.md"))
269            {
270                score *= 1.15;
271                reasons.push("hub:changelog".into());
272            }
273            if node.id == crate::hubs::HUB_ROADMAP || node.id == crate::hubs::HUB_BACKLOG {
274                score *= 1.1;
275                reasons.push(format!("hub:{}", node.id));
276            }
277
278            // Title / id token hits + multi-token coverage bonus
279            let title_l = node.title.to_lowercase();
280            let id_l = node.id.to_lowercase();
281            let fts_body = self
282                .get_fts_content(&node.id)
283                .ok()
284                .flatten()
285                .unwrap_or_default()
286                .to_lowercase();
287            let mut coverage = 0usize;
288            for t in tokens {
289                let mut hit_tok = false;
290                if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
291                    score += 2.0;
292                    reasons.push(format!("title:{t}"));
293                    hit_tok = true;
294                }
295                if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
296                    score += 1.5;
297                    reasons.push(format!("id:{t}"));
298                    hit_tok = true;
299                }
300                if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
301                    score += 0.75;
302                    hit_tok = true;
303                }
304                if hit_tok {
305                    coverage += 1;
306                }
307            }
308            if tokens.len() > 1 && coverage > 1 {
309                let bonus = 1.5 * (coverage as f32);
310                score += bonus;
311                reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
312            }
313
314            // Tag boosts
315            let tags = self.get_tags_for(&node.id)?;
316            for tag in &tags {
317                let tag_l = tag.to_lowercase();
318                for t in tokens {
319                    if tag_l == *t || tag_l.contains(t.as_str()) {
320                        score += 3.0;
321                        reasons.push(format!("tag:{tag}"));
322                    }
323                }
324            }
325
326            // Alias boosts
327            let aliases = self.get_aliases_for(&node.id)?;
328            for alias in &aliases {
329                let a = alias.to_lowercase();
330                for t in tokens {
331                    if a == *t || a.contains(t.as_str()) {
332                        score += 2.5;
333                        reasons.push(format!("alias:{alias}"));
334                    }
335                }
336            }
337
338            // Type boost
339            for (ty, mult) in &opts.type_boosts {
340                if node.node_type == *ty {
341                    score *= mult;
342                    reasons.push(format!("type:{}×{mult}", ty.as_str()));
343                }
344            }
345
346            hits.push(RankedHit {
347                node,
348                score,
349                reasons,
350            });
351        }
352
353        // Also pull pure tag/alias matches that FTS may have missed (short queries).
354        self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
355
356        hits.retain(|h| {
357            opts.allows(&h.node.node_type) && opts.allows_scope(&h.node.scope, &h.node.id)
358        });
359
360        hits.sort_by(|a, b| {
361            b.score
362                .partial_cmp(&a.score)
363                .unwrap_or(std::cmp::Ordering::Equal)
364        });
365        // Dedup by id keeping best score
366        let mut seen = std::collections::HashSet::new();
367        hits.retain(|h| seen.insert(h.node.id.clone()));
368        hits.truncate(opts.limit);
369        Ok(hits)
370    }
371
372    /// Tags attached to a node (empty if none).
373    pub fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
374        let mut stmt = self
375            .conn
376            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
377        let rows = stmt.query_map([node_id], |row| row.get(0))?;
378        let mut v = Vec::new();
379        for r in rows {
380            v.push(r?);
381        }
382        Ok(v)
383    }
384
385    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
386        let mut stmt = self
387            .conn
388            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
389        let rows = stmt.query_map([node_id], |row| row.get(0))?;
390        let mut v = Vec::new();
391        for r in rows {
392            v.push(r?);
393        }
394        Ok(v)
395    }
396
397    fn augment_with_tag_alias_hits(
398        &self,
399        tokens: &[String],
400        hits: &mut Vec<RankedHit>,
401        opts: &QueryOptions,
402    ) -> Result<()> {
403        let existing: std::collections::HashSet<String> =
404            hits.iter().map(|h| h.node.id.clone()).collect();
405
406        for t in tokens {
407            // Tag exact
408            let mut stmt = self.conn.prepare(
409                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
410                        n.content_hash, n.scope, n.created_at, n.updated_at
411                 FROM node_tags t
412                 JOIN nodes n ON n.id = t.node_id
413                 WHERE lower(t.tag) = ?1
414                 LIMIT 20",
415            )?;
416            let rows = stmt.query_map([t.as_str()], Database::map_node_row)?;
417            for r in rows {
418                let node = r?;
419                if existing.contains(&node.id)
420                    || !opts.allows(&node.node_type)
421                    || !opts.allows_scope(&node.scope, &node.id)
422                {
423                    continue;
424                }
425                hits.push(RankedHit {
426                    node,
427                    score: 3.5,
428                    reasons: vec![format!("tag-exact:{t}")],
429                });
430            }
431
432            // Alias exact
433            let mut stmt = self.conn.prepare(
434                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
435                        n.content_hash, n.scope, n.created_at, n.updated_at
436                 FROM node_aliases a
437                 JOIN nodes n ON n.id = a.node_id
438                 WHERE a.alias = ?1
439                 LIMIT 20",
440            )?;
441            let rows = stmt.query_map([t.as_str()], Database::map_node_row)?;
442            for r in rows {
443                let node = r?;
444                if hits.iter().any(|h| h.node.id == node.id)
445                    || !opts.allows(&node.node_type)
446                    || !opts.allows_scope(&node.scope, &node.id)
447                {
448                    continue;
449                }
450                hits.push(RankedHit {
451                    node,
452                    score: 3.0,
453                    reasons: vec![format!("alias-exact:{t}")],
454                });
455            }
456        }
457        Ok(())
458    }
459
460    /// List unresolved WikiLink / symbol targets.
461    pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
462        let mut stmt = self.conn.prepare(
463            "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
464        )?;
465        let rows = stmt.query_map([], |row| {
466            Ok(PendingLink {
467                source_id: row.get(0)?,
468                raw_target: row.get(1)?,
469                relation_type: row.get(2)?,
470                created_at: row.get(3)?,
471            })
472        })?;
473        let mut out = Vec::new();
474        for r in rows {
475            out.push(r?);
476        }
477        Ok(out)
478    }
479
480    /// Count nodes grouped by `node_type`.
481    pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
482        let mut stmt = self
483            .conn
484            .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
485        let rows = stmt.query_map([], |row| {
486            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
487        })?;
488        let mut out = Vec::new();
489        for r in rows {
490            out.push(r?);
491        }
492        Ok(out)
493    }
494}
495
496/// Unresolved link kept for later resolution.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct PendingLink {
499    /// Source node id that referenced the target.
500    pub source_id: String,
501    /// Raw WikiLink / `symbol:…` target text.
502    pub raw_target: String,
503    /// Intended relation type (`relates_to`, `anchors`, …).
504    pub relation_type: String,
505    /// When the pending link was recorded (unix seconds).
506    pub created_at: i64,
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::types::NodeType;
513
514    #[test]
515    fn tag_boost_ranks_higher() {
516        let db = Database::open_in_memory().unwrap();
517        let n1 = Node {
518            id: "docs/a".into(),
519            node_type: NodeType::Concept,
520            title: "Something Else".into(),
521            file_path: None,
522            symbol_hash: None,
523            summary: Some("mentions raft in body".into()),
524            content_hash: None,
525            scope: crate::scopes::MAIN_SCOPE.to_string(),
526            created_at: 1,
527            updated_at: 1,
528        };
529        let n2 = Node {
530            id: "docs/b".into(),
531            node_type: NodeType::Concept,
532            title: "Protocol".into(),
533            file_path: None,
534            symbol_hash: None,
535            summary: Some("tagged note".into()),
536            content_hash: None,
537            scope: crate::scopes::MAIN_SCOPE.to_string(),
538            created_at: 1,
539            updated_at: 1,
540        };
541        db.insert_node(&n1).unwrap();
542        db.insert_node(&n2).unwrap();
543        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
544        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
545        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
546
547        let hits = db
548            .search_ranked("raft", &QueryOptions::default())
549            .unwrap();
550        assert!(hits.len() >= 2);
551        // Tag match should beat pure body mention when scores are close, or at least appear
552        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
553        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
554        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
555    }
556}