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`])
6//! 2. Optional CSR k-hop expansion from `.brain/graph.mmap`
7//! 3. Score fusion (seed score × edge weight × hop decay)
8//! 4. Pack nodes 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::query::{QueryOptions, RankedHit};
15use crate::storage::Database;
16use crate::types::{ContextBundle, ContextNode, ContextRole, Node};
17use serde::{Deserialize, Serialize};
18use std::collections::{HashMap, HashSet};
19use std::path::Path;
20use std::time::Instant;
21
22/// Options controlling [`assemble_context`] and [`crate::Brain::context_for_prompt_with`].
23#[derive(Debug, Clone)]
24pub struct ContextOptions {
25    /// Approximate max tokens for the packed bundle (`chars ≈ tokens × 4`).
26    pub max_tokens: usize,
27    /// Graph expansion depth (`0` = seeds only; `1` is the usual default).
28    pub hop_depth: usize,
29    /// Max seed nodes taken from the ranked query before expansion.
30    pub max_seeds: usize,
31    /// Max total nodes packed into the bundle (seeds + neighbors).
32    pub max_nodes: usize,
33    /// Multiplicative decay applied per hop for neighbor scores.
34    pub hop_decay: f32,
35    /// Exclude pure code symbols from seeds and packed neighbors.
36    pub no_symbols: bool,
37    /// Only include these node types when non-empty.
38    pub include_types: Vec<crate::types::NodeType>,
39    /// Always exclude these node types.
40    pub exclude_types: Vec<crate::types::NodeType>,
41    /// When true, allow graph hops *to* symbols even if `no_symbols` filters seeds.
42    /// Default true so ADR → `symbol:foo` remains useful for agents.
43    pub hop_to_symbols: bool,
44}
45
46impl Default for ContextOptions {
47    fn default() -> Self {
48        Self {
49            max_tokens: 2048,
50            hop_depth: 1,
51            max_seeds: 8,
52            max_nodes: 24,
53            hop_decay: 0.65,
54            no_symbols: false,
55            include_types: Vec::new(),
56            exclude_types: Vec::new(),
57            hop_to_symbols: true,
58        }
59    }
60}
61
62impl ContextOptions {
63    fn seed_query_opts(&self) -> QueryOptions {
64        QueryOptions {
65            limit: self.max_seeds.saturating_mul(2).max(8),
66            no_symbols: self.no_symbols,
67            include_types: self.include_types.clone(),
68            exclude_types: self.exclude_types.clone(),
69            ..QueryOptions::default()
70        }
71    }
72
73    fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
74        use crate::types::NodeType;
75        if !self.include_types.is_empty() && !self.include_types.contains(ty) {
76            // Allow symbol neighbors when hop_to_symbols and role is Neighbor
77            if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
78                return false;
79            }
80        }
81        if self.exclude_types.contains(ty) {
82            return false;
83        }
84        if *ty == NodeType::Symbol {
85            if role == ContextRole::Seed && self.no_symbols {
86                return false;
87            }
88            if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
89                return false;
90            }
91        }
92        true
93    }
94}
95
96/// Intermediate scored candidate before token packing.
97#[derive(Debug, Clone)]
98struct Candidate {
99    node: Node,
100    score: f32,
101    role: ContextRole,
102    hop: u8,
103}
104
105/// Assemble a graph-aware context bundle for an agent prompt.
106///
107/// `brain_dir` is the `.brain` directory (for loading `graph.mmap`). When the
108/// mmap file is missing or the `mmap` feature is disabled, expansion is skipped
109/// and only ranked seeds are packed.
110///
111/// # Errors
112///
113/// Propagates FTS / database errors from the seed query.
114pub fn assemble_context(
115    db: &Database,
116    brain_dir: &Path,
117    prompt: &str,
118    opts: &ContextOptions,
119) -> Result<ContextBundle> {
120    let start = Instant::now();
121    let char_budget = opts.max_tokens.saturating_mul(4).max(256);
122
123    let qopts = opts.seed_query_opts();
124    let seeds = db.search_ranked(prompt, &qopts)?;
125
126    let mut candidates: Vec<Candidate> = Vec::new();
127    let mut seed_ids: HashSet<String> = HashSet::new();
128
129    for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
130        if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
131            continue;
132        }
133        seed_ids.insert(hit.node.id.clone());
134        // Slight rank-position prior
135        let score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
136        candidates.push(Candidate {
137            node: hit.node,
138            score,
139            role: ContextRole::Seed,
140            hop: 0,
141        });
142    }
143
144    let mut graph_nodes = 0usize;
145    let mut graph_edges = 0usize;
146    let mut neighbor_ids: Vec<String> = Vec::new();
147
148    #[cfg(feature = "mmap")]
149    if opts.hop_depth > 0 {
150        let mmap_path = brain_dir.join("graph.mmap");
151        if mmap_path.exists() {
152            if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
153                graph_nodes = graph.node_count;
154                graph_edges = graph.edge_count;
155
156                let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
157
158                for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
159                    if let Some(idx) = graph.index_of(&cand.node.id) {
160                        for (nidx, edge_w) in
161                            graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
162                        {
163                            if let Some(id) = graph.node_id(nidx as usize) {
164                                if seed_ids.contains(id) {
165                                    continue;
166                                }
167                                // k_hop returns first-visit neighbors; for depth>1 we
168                                // approximate hop from cumulative path weight.
169                                let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
170                                    1
171                                } else {
172                                    2
173                                };
174                                let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
175                                best_neighbor
176                                    .entry(id.to_string())
177                                    .and_modify(|(s, h)| {
178                                        if nscore > *s {
179                                            *s = nscore;
180                                            *h = hop;
181                                        }
182                                    })
183                                    .or_insert((nscore, hop));
184                            }
185                        }
186                    }
187                }
188
189                let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
190                neigh_sorted.sort_by(|a, b| {
191                    b.1 .0
192                        .partial_cmp(&a.1 .0)
193                        .unwrap_or(std::cmp::Ordering::Equal)
194                });
195
196                for (id, (score, hop)) in neigh_sorted {
197                    neighbor_ids.push(id.clone());
198                    if let Some(node) = db.get_node(&id)? {
199                        if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
200                            continue;
201                        }
202                        candidates.push(Candidate {
203                            node,
204                            score,
205                            role: ContextRole::Neighbor,
206                            hop,
207                        });
208                    }
209                }
210            }
211        }
212    }
213
214    // Sort all candidates by score and pack into token budget.
215    candidates.sort_by(|a, b| {
216        b.score
217            .partial_cmp(&a.score)
218            .unwrap_or(std::cmp::Ordering::Equal)
219    });
220
221    let mut packed: Vec<ContextNode> = Vec::new();
222    let mut used_chars = estimate_overhead(prompt);
223    let mut seen = HashSet::new();
224
225    for cand in candidates {
226        if packed.len() >= opts.max_nodes {
227            break;
228        }
229        if !seen.insert(cand.node.id.clone()) {
230            continue;
231        }
232        let ctx_node = to_context_node(&cand);
233        let cost = estimate_node_chars(&ctx_node);
234        if used_chars + cost > char_budget && !packed.is_empty() {
235            // Always try to keep at least one seed
236            if cand.role == ContextRole::Neighbor {
237                continue;
238            }
239            if packed.iter().any(|n| n.role == ContextRole::Seed) {
240                continue;
241            }
242        }
243        if used_chars + cost > char_budget && !packed.is_empty() {
244            break;
245        }
246        used_chars += cost;
247        packed.push(ctx_node);
248    }
249
250    // Ensure neighbor_ids only lists those not fully expanded into packed as seeds
251    // (keep as list of graph-discovered ids for transparency).
252    neighbor_ids.retain(|id| !seed_ids.contains(id));
253    neighbor_ids.truncate(48);
254
255    let latency_us = start.elapsed().as_micros() as u64;
256    let tokens_used = used_chars.div_ceil(4);
257
258    Ok(ContextBundle {
259        prompt: prompt.to_string(),
260        max_tokens: opts.max_tokens,
261        tokens_used,
262        nodes: packed,
263        neighbor_ids,
264        latency_us,
265        graph_nodes,
266        graph_edges,
267    })
268}
269
270fn to_context_node(c: &Candidate) -> ContextNode {
271    ContextNode {
272        id: c.node.id.clone(),
273        node_type: c.node.node_type.clone(),
274        title: c.node.title.clone(),
275        summary: c.node.summary.clone(),
276        file_path: c.node.file_path.clone(),
277        score_hint: c.score,
278        role: c.role,
279        hop: c.hop,
280    }
281}
282
283fn estimate_overhead(prompt: &str) -> usize {
284    120 + prompt.len()
285}
286
287fn estimate_node_chars(n: &ContextNode) -> usize {
288    let mut c = n.id.len() + n.title.len() + 48;
289    if let Some(s) = &n.summary {
290        c += s.len();
291    }
292    if let Some(p) = &n.file_path {
293        c += p.len();
294    }
295    c
296}
297
298/// Convert ranked hits to nodes (helper for CLI / adapters).
299pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
300    hits.into_iter().map(|h| h.node).collect()
301}
302
303/// Multi-workspace ranked hit (workspace path + hit).
304///
305/// Prefer [`crate::GlobalRankedHit`] for the registry API; this type remains for
306/// ad-hoc tooling that reuses the same shape.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct WorkspaceHit {
309    /// Absolute workspace path that produced the hit.
310    pub workspace: String,
311    /// Ranked hit within that workspace.
312    pub hit: RankedHit,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::brain::Brain;
319    use tempfile::tempdir;
320
321    #[test]
322    fn context_includes_graph_neighbors_within_budget() {
323        let dir = tempdir().unwrap();
324        let docs = dir.path().join("docs");
325        std::fs::create_dir_all(&docs).unwrap();
326        // Only seed A is FTS-matchable for "alphaunique"; B is only reachable via graph.
327        std::fs::write(
328            docs.join("alpha.md"),
329            "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
330        )
331        .unwrap();
332        std::fs::write(
333            docs.join("beta.md"),
334            "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
335        )
336        .unwrap();
337
338        let mut brain = Brain::create(dir.path()).unwrap();
339        brain.sync().unwrap();
340
341        let opts = ContextOptions {
342            max_tokens: 2048,
343            hop_depth: 1,
344            max_seeds: 4,
345            max_nodes: 12,
346            hop_decay: 0.7,
347            ..ContextOptions::default()
348        };
349        let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
350            .unwrap();
351        assert!(!ctx.nodes.is_empty());
352        assert!(ctx.tokens_used > 0);
353        assert!(ctx.tokens_used <= opts.max_tokens + 64); // soft bound with estimator slack
354
355        // Neighbor should be discovered via graph even if not an FTS seed.
356        let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
357            || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
358        assert!(
359            has_beta,
360            "expected beta neighbor via graph; nodes={:?} neigh={:?}",
361            ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
362            ctx.neighbor_ids
363        );
364    }
365
366    #[test]
367    fn tight_budget_limits_nodes() {
368        let dir = tempdir().unwrap();
369        let docs = dir.path().join("docs");
370        std::fs::create_dir_all(&docs).unwrap();
371        for i in 0..8 {
372            std::fs::write(
373                docs.join(format!("n{i}.md")),
374                format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
375            )
376            .unwrap();
377        }
378        let mut brain = Brain::create(dir.path()).unwrap();
379        brain.sync().unwrap();
380        let opts = ContextOptions {
381            max_tokens: 40, // very tight
382            hop_depth: 0,
383            max_seeds: 8,
384            max_nodes: 20,
385            hop_decay: 0.5,
386            ..ContextOptions::default()
387        };
388        let ctx =
389            assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
390        assert!(!ctx.nodes.is_empty());
391        assert!(ctx.nodes.len() < 8, "budget should clip nodes");
392    }
393}