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