Skip to main content

rustbrain_core/
context.rs

1//! Graph-aware AI context assembly with token budgeting.
2//!
3//! Pipeline used by [`crate::Brain::context_for_prompt`]:
4//!
5//! 1. Ranked FTS seeds ([`crate::query`]) with stopword-aware query rewrite
6//! 2. Optional CSR k-hop expansion from `.brain/graph.mmap` (doc seeds preferred)
7//! 3. Score fusion (seed score × edge weight × hop decay) + symbol quality filters
8//! 4. Pack nodes with body excerpts until the approximate character/token budget is exhausted
9//!
10//! Token accounting is intentionally simple (`chars / 4`). It is good enough for
11//! agent prompt packing, not for billing-grade tokenizer parity.
12
13use crate::error::Result;
14use crate::fts::{prepare_search_query, tokenize_query};
15use crate::query::{QueryOptions, RankedHit};
16use crate::storage::Database;
17use crate::types::{ContextBundle, ContextNode, ContextRole, Node, NodeType};
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20use std::path::Path;
21use std::time::Instant;
22
23/// Max characters of body excerpt packed per note/seed.
24const MAX_EXCERPT_CHARS: usize = 900;
25/// Max characters for symbol neighbor excerpts (usually doc comments).
26const MAX_SYMBOL_EXCERPT_CHARS: usize = 280;
27/// Soft cap on packed symbol neighbors even when hop_to_symbols is on.
28const MAX_PACKED_SYMBOL_NEIGHBORS: usize = 8;
29
30/// Options controlling [`assemble_context`] and [`crate::Brain::context_for_prompt_with`].
31#[derive(Debug, Clone)]
32pub struct ContextOptions {
33    /// Approximate max tokens for the packed bundle (`chars ≈ tokens × 4`).
34    pub max_tokens: usize,
35    /// Graph expansion depth (`0` = seeds only; `1` is the usual default).
36    pub hop_depth: usize,
37    /// Max seed nodes taken from the ranked query before expansion.
38    pub max_seeds: usize,
39    /// Max total nodes packed into the bundle (seeds + neighbors).
40    pub max_nodes: usize,
41    /// Multiplicative decay applied per hop for neighbor scores.
42    pub hop_decay: f32,
43    /// Exclude pure code symbols from seeds (and from neighbors unless [`Self::hop_to_symbols`]).
44    pub no_symbols: bool,
45    /// Only include these node types when non-empty.
46    pub include_types: Vec<crate::types::NodeType>,
47    /// Always exclude these node types.
48    pub exclude_types: Vec<crate::types::NodeType>,
49    /// When true, allow graph hops *to* symbols even if `no_symbols` filters seeds.
50    /// Default true so ADR → `symbol:foo` remains useful for agents.
51    pub hop_to_symbols: bool,
52    /// Prefer expanding the graph from non-symbol seeds (docs/README). Default true.
53    pub hop_from_docs_only: bool,
54    /// Include body excerpts from FTS content (or summary fallback). Default true.
55    pub include_excerpts: bool,
56}
57
58impl Default for ContextOptions {
59    fn default() -> Self {
60        Self {
61            max_tokens: 2048,
62            hop_depth: 1,
63            max_seeds: 8,
64            max_nodes: 24,
65            hop_decay: 0.65,
66            // Agent-friendly: notes first; symbols arrive via hops when useful.
67            no_symbols: true,
68            include_types: Vec::new(),
69            exclude_types: Vec::new(),
70            hop_to_symbols: true,
71            hop_from_docs_only: true,
72            include_excerpts: true,
73        }
74    }
75}
76
77impl ContextOptions {
78    /// Agent-oriented defaults (same as [`Default`] as of 0.3.0).
79    pub fn agent() -> Self {
80        Self::default()
81    }
82
83    /// Include symbols as seeds and neighbors (power-user / debugging).
84    pub fn with_symbols(mut self) -> Self {
85        self.no_symbols = false;
86        self
87    }
88
89    fn seed_query_opts(&self) -> QueryOptions {
90        QueryOptions {
91            limit: self.max_seeds.saturating_mul(2).max(8),
92            no_symbols: self.no_symbols,
93            include_types: self.include_types.clone(),
94            exclude_types: self.exclude_types.clone(),
95            ..QueryOptions::default()
96        }
97    }
98
99    fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
100        use crate::types::NodeType;
101        if !self.include_types.is_empty() && !self.include_types.contains(ty) {
102            // Allow symbol neighbors when hop_to_symbols and role is Neighbor
103            if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
104                return false;
105            }
106        }
107        if self.exclude_types.contains(ty) {
108            return false;
109        }
110        if *ty == NodeType::Symbol {
111            if role == ContextRole::Seed && self.no_symbols {
112                return false;
113            }
114            if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
115                return false;
116            }
117        }
118        true
119    }
120}
121
122/// Intermediate scored candidate before token packing.
123#[derive(Debug, Clone)]
124struct Candidate {
125    node: Node,
126    score: f32,
127    role: ContextRole,
128    hop: u8,
129    excerpt: Option<String>,
130}
131
132/// Assemble a graph-aware context bundle for an agent prompt.
133///
134/// `brain_dir` is the `.brain` directory (for loading `graph.mmap`). When the
135/// mmap file is missing or the `mmap` feature is disabled, expansion is skipped
136/// and only ranked seeds are packed.
137///
138/// # Errors
139///
140/// Propagates FTS / database errors from the seed query.
141pub fn assemble_context(
142    db: &Database,
143    brain_dir: &Path,
144    prompt: &str,
145    opts: &ContextOptions,
146) -> Result<ContextBundle> {
147    let start = Instant::now();
148    let char_budget = opts.max_tokens.saturating_mul(4).max(256);
149    let query_tokens = prepare_search_query(prompt)
150        .map(|p| p.tokens)
151        .unwrap_or_else(|_| tokenize_query(prompt));
152
153    let qopts = opts.seed_query_opts();
154    let seeds = db.search_ranked(prompt, &qopts)?;
155
156    let mut candidates: Vec<Candidate> = Vec::new();
157    let mut seed_ids: HashSet<String> = HashSet::new();
158
159    for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
160        if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
161            continue;
162        }
163        seed_ids.insert(hit.node.id.clone());
164        // Slight rank-position prior
165        let score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
166        let excerpt = if opts.include_excerpts {
167            load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
168        } else {
169            None
170        };
171        candidates.push(Candidate {
172            node: hit.node,
173            score,
174            role: ContextRole::Seed,
175            hop: 0,
176            excerpt,
177        });
178    }
179
180    let mut graph_nodes = 0usize;
181    let mut graph_edges = 0usize;
182    let mut neighbor_ids: Vec<String> = Vec::new();
183
184    #[cfg(feature = "mmap")]
185    if opts.hop_depth > 0 {
186        let mmap_path = brain_dir.join("graph.mmap");
187        if mmap_path.exists() {
188            if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
189                graph_nodes = graph.node_count;
190                graph_edges = graph.edge_count;
191
192                let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
193
194                for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
195                    if opts.hop_from_docs_only && cand.node.node_type == NodeType::Symbol {
196                        continue;
197                    }
198                    if let Some(idx) = graph.index_of(&cand.node.id) {
199                        for (nidx, edge_w) in
200                            graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
201                        {
202                            if let Some(id) = graph.node_id(nidx as usize) {
203                                if seed_ids.contains(id) {
204                                    continue;
205                                }
206                                let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
207                                    1
208                                } else {
209                                    2
210                                };
211                                let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
212                                best_neighbor
213                                    .entry(id.to_string())
214                                    .and_modify(|(s, h)| {
215                                        if nscore > *s {
216                                            *s = nscore;
217                                            *h = hop;
218                                        }
219                                    })
220                                    .or_insert((nscore, hop));
221                            }
222                        }
223                    }
224                }
225
226                let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
227                neigh_sorted.sort_by(|a, b| {
228                    b.1 .0
229                        .partial_cmp(&a.1 .0)
230                        .unwrap_or(std::cmp::Ordering::Equal)
231                });
232
233                for (id, (score, hop)) in neigh_sorted {
234                    neighbor_ids.push(id.clone());
235                    if let Some(node) = db.get_node(&id)? {
236                        if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
237                            continue;
238                        }
239                        let mut nscore = score;
240                        if node.node_type == NodeType::Symbol {
241                            match symbol_neighbor_quality(&node, &query_tokens) {
242                                SymbolQuality::Drop => continue,
243                                SymbolQuality::Keep { boost } => nscore *= boost,
244                            }
245                        }
246                        let max_ex = if node.node_type == NodeType::Symbol {
247                            MAX_SYMBOL_EXCERPT_CHARS
248                        } else {
249                            MAX_EXCERPT_CHARS
250                        };
251                        let excerpt = if opts.include_excerpts {
252                            load_excerpt(db, &node, max_ex)
253                        } else {
254                            None
255                        };
256                        candidates.push(Candidate {
257                            node,
258                            score: nscore,
259                            role: ContextRole::Neighbor,
260                            hop,
261                            excerpt,
262                        });
263                    }
264                }
265            }
266        }
267    }
268
269    // Sort all candidates by score and pack into token budget.
270    candidates.sort_by(|a, b| {
271        // Prefer non-symbols slightly when scores are close (stable agent packs).
272        let a_bonus = if a.node.node_type == NodeType::Symbol {
273            0.0
274        } else {
275            0.05
276        };
277        let b_bonus = if b.node.node_type == NodeType::Symbol {
278            0.0
279        } else {
280            0.05
281        };
282        (b.score + b_bonus)
283            .partial_cmp(&(a.score + a_bonus))
284            .unwrap_or(std::cmp::Ordering::Equal)
285    });
286
287    let mut packed: Vec<ContextNode> = Vec::new();
288    let mut used_chars = estimate_overhead(prompt);
289    let mut seen = HashSet::new();
290    let mut symbol_neighbors = 0usize;
291
292    for cand in candidates {
293        if packed.len() >= opts.max_nodes {
294            break;
295        }
296        if !seen.insert(cand.node.id.clone()) {
297            continue;
298        }
299        if cand.role == ContextRole::Neighbor
300            && cand.node.node_type == NodeType::Symbol
301            && symbol_neighbors >= MAX_PACKED_SYMBOL_NEIGHBORS
302        {
303            continue;
304        }
305        let ctx_node = to_context_node(&cand);
306        let cost = estimate_node_chars(&ctx_node);
307        if used_chars + cost > char_budget && !packed.is_empty() {
308            // Always try to keep at least one seed
309            if cand.role == ContextRole::Neighbor {
310                continue;
311            }
312            if packed.iter().any(|n| n.role == ContextRole::Seed) {
313                continue;
314            }
315        }
316        if used_chars + cost > char_budget && !packed.is_empty() {
317            break;
318        }
319        used_chars += cost;
320        if cand.role == ContextRole::Neighbor && cand.node.node_type == NodeType::Symbol {
321            symbol_neighbors += 1;
322        }
323        packed.push(ctx_node);
324    }
325
326    neighbor_ids.retain(|id| !seed_ids.contains(id));
327    neighbor_ids.truncate(48);
328
329    let latency_us = start.elapsed().as_micros() as u64;
330    let tokens_used = used_chars.div_ceil(4);
331
332    Ok(ContextBundle {
333        prompt: prompt.to_string(),
334        max_tokens: opts.max_tokens,
335        tokens_used,
336        nodes: packed,
337        neighbor_ids,
338        latency_us,
339        graph_nodes,
340        graph_edges,
341    })
342}
343
344enum SymbolQuality {
345    Drop,
346    Keep { boost: f32 },
347}
348
349/// Drop theme consts / short noise; boost query-matching or method-like symbols.
350fn symbol_neighbor_quality(node: &Node, query_tokens: &[String]) -> SymbolQuality {
351    let title = node.title.to_lowercase();
352    let id = node.id.to_lowercase();
353    let leaf = id.rsplit('/').next().unwrap_or(&id);
354
355    // Query term appears in symbol name → keep and boost.
356    for t in query_tokens {
357        if title.contains(t.as_str()) || id.contains(t.as_str()) {
358            return SymbolQuality::Keep { boost: 1.8 };
359        }
360    }
361
362    // Method / path-like symbols (Type::method or multi-segment).
363    if title.contains("::") || leaf.contains("::") {
364        return SymbolQuality::Keep { boost: 1.15 };
365    }
366
367    // Very short or SCREAMING_SNAKE theme constants without query match.
368    let name = title.split("::").last().unwrap_or(&title);
369    if name.len() <= 2 {
370        return SymbolQuality::Drop;
371    }
372    if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() <= 12 {
373        // OK, ERR, BG_*, ACCENT_* style noise
374        return SymbolQuality::Drop;
375    }
376    // Generic single-token modules without query match (derive, tree, …)
377    if !name.contains('_') && !title.contains("::") && name.len() <= 8 {
378        // Keep types that look like CamelCase API entry points.
379        let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
380        let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
381        if has_upper && has_lower {
382            return SymbolQuality::Keep { boost: 1.0 };
383        }
384        return SymbolQuality::Drop;
385    }
386
387    SymbolQuality::Keep { boost: 1.0 }
388}
389
390fn load_excerpt(db: &Database, node: &Node, max_chars: usize) -> Option<String> {
391    let raw = db
392        .get_fts_content(&node.id)
393        .ok()
394        .flatten()
395        .filter(|s| !s.trim().is_empty())
396        .or_else(|| node.summary.clone().filter(|s| !s.trim().is_empty()))?;
397    Some(truncate_excerpt(&raw, max_chars))
398}
399
400fn truncate_excerpt(s: &str, max_chars: usize) -> String {
401    let s = s.trim();
402    if s.chars().count() <= max_chars {
403        return s.to_string();
404    }
405    let mut out = String::new();
406    for (i, ch) in s.chars().enumerate() {
407        if i >= max_chars.saturating_sub(1) {
408            break;
409        }
410        out.push(ch);
411    }
412    out.push('…');
413    out
414}
415
416fn to_context_node(c: &Candidate) -> ContextNode {
417    ContextNode {
418        id: c.node.id.clone(),
419        node_type: c.node.node_type.clone(),
420        title: c.node.title.clone(),
421        summary: c.node.summary.clone(),
422        excerpt: c.excerpt.clone(),
423        file_path: c.node.file_path.clone(),
424        score_hint: c.score,
425        role: c.role,
426        hop: c.hop,
427    }
428}
429
430fn estimate_overhead(prompt: &str) -> usize {
431    120 + prompt.len()
432}
433
434fn estimate_node_chars(n: &ContextNode) -> usize {
435    let mut c = n.id.len() + n.title.len() + 48;
436    if let Some(s) = &n.summary {
437        c += s.len();
438    }
439    if let Some(ex) = &n.excerpt {
440        c += ex.len();
441    }
442    if let Some(p) = &n.file_path {
443        c += p.len();
444    }
445    c
446}
447
448/// Convert ranked hits to nodes (helper for CLI / adapters).
449pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
450    hits.into_iter().map(|h| h.node).collect()
451}
452
453/// Multi-workspace ranked hit (workspace path + hit).
454///
455/// Prefer [`crate::GlobalRankedHit`] for the registry API; this type remains for
456/// ad-hoc tooling that reuses the same shape.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct WorkspaceHit {
459    /// Absolute workspace path that produced the hit.
460    pub workspace: String,
461    /// Ranked hit within that workspace.
462    pub hit: RankedHit,
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::brain::Brain;
469    use tempfile::tempdir;
470
471    #[test]
472    fn context_includes_graph_neighbors_within_budget() {
473        let dir = tempdir().unwrap();
474        let docs = dir.path().join("docs");
475        std::fs::create_dir_all(&docs).unwrap();
476        // Only seed A is FTS-matchable for "alphaunique"; B is only reachable via graph.
477        std::fs::write(
478            docs.join("alpha.md"),
479            "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
480        )
481        .unwrap();
482        std::fs::write(
483            docs.join("beta.md"),
484            "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
485        )
486        .unwrap();
487
488        let mut brain = Brain::create(dir.path()).unwrap();
489        brain.sync().unwrap();
490
491        let opts = ContextOptions {
492            max_tokens: 2048,
493            hop_depth: 1,
494            max_seeds: 4,
495            max_nodes: 12,
496            hop_decay: 0.7,
497            ..ContextOptions::default()
498        };
499        let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
500            .unwrap();
501        assert!(!ctx.nodes.is_empty());
502        assert!(ctx.tokens_used > 0);
503        assert!(ctx.tokens_used <= opts.max_tokens + 64); // soft bound with estimator slack
504
505        // Neighbor should be discovered via graph even if not an FTS seed.
506        let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
507            || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
508        assert!(
509            has_beta,
510            "expected beta neighbor via graph; nodes={:?} neigh={:?}",
511            ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
512            ctx.neighbor_ids
513        );
514    }
515
516    #[test]
517    fn tight_budget_limits_nodes() {
518        let dir = tempdir().unwrap();
519        let docs = dir.path().join("docs");
520        std::fs::create_dir_all(&docs).unwrap();
521        for i in 0..8 {
522            std::fs::write(
523                docs.join(format!("n{i}.md")),
524                format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
525            )
526            .unwrap();
527        }
528        let mut brain = Brain::create(dir.path()).unwrap();
529        brain.sync().unwrap();
530        let opts = ContextOptions {
531            max_tokens: 40, // very tight
532            hop_depth: 0,
533            max_seeds: 8,
534            max_nodes: 20,
535            hop_decay: 0.5,
536            ..ContextOptions::default()
537        };
538        let ctx =
539            assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
540        assert!(!ctx.nodes.is_empty());
541        assert!(ctx.nodes.len() < 8, "budget should clip nodes");
542    }
543
544    #[test]
545    fn natural_language_prompt_seeds_and_packs_excerpt() {
546        let dir = tempdir().unwrap();
547        std::fs::write(
548            dir.path().join("README.md"),
549            "# demotool\n\nLightweight **egui** explorer powered by DuckDB.\n\
550             Inspired by Duckling but avoids Tauri/WebView.\n",
551        )
552        .unwrap();
553        let mut brain = Brain::create(dir.path()).unwrap();
554        brain.sync().unwrap();
555
556        let ctx = assemble_context(
557            brain.database(),
558            brain.brain_dir(),
559            "why egui not tauri",
560            &ContextOptions::default(),
561        )
562        .unwrap();
563        assert!(
564            !ctx.nodes.is_empty(),
565            "expected seeds for NL prompt; packed=0"
566        );
567        let has_excerpt = ctx.nodes.iter().any(|n| {
568            n.excerpt
569                .as_ref()
570                .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("tauri"))
571                .unwrap_or(false)
572        });
573        assert!(
574            has_excerpt,
575            "expected body excerpt mentioning egui/tauri; nodes={:?}",
576            ctx.nodes
577                .iter()
578                .map(|n| (&n.id, &n.excerpt))
579                .collect::<Vec<_>>()
580        );
581    }
582}