1use 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#[derive(Debug, Clone)]
24pub struct ContextOptions {
25 pub max_tokens: usize,
27 pub hop_depth: usize,
29 pub max_seeds: usize,
31 pub max_nodes: usize,
33 pub hop_decay: f32,
35 pub no_symbols: bool,
37 pub include_types: Vec<crate::types::NodeType>,
39 pub exclude_types: Vec<crate::types::NodeType>,
41 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 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#[derive(Debug, Clone)]
98struct Candidate {
99 node: Node,
100 score: f32,
101 role: ContextRole,
102 hop: u8,
103}
104
105pub 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 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 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 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 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 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
298pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
300 hits.into_iter().map(|h| h.node).collect()
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct WorkspaceHit {
309 pub workspace: String,
311 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 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); 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, 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}