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}
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#[derive(Debug, Clone)]
51struct Candidate {
52 node: Node,
53 score: f32,
54 role: ContextRole,
55 hop: u8,
56}
57
58pub 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 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 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 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 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 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
248pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
250 hits.into_iter().map(|h| h.node).collect()
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct WorkspaceHit {
259 pub workspace: String,
261 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 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); 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, 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}