1use std::collections::HashMap;
17use std::fmt;
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct ContextItemId(pub String);
30
31impl ContextItemId {
32 pub fn from_file(path: &str) -> Self {
33 Self(format!("file:{path}"))
34 }
35 pub fn from_shell(command: &str) -> Self {
36 let hash = crate::core::project_hash::hash_project_root(command);
37 Self(format!("shell:{hash}"))
38 }
39 pub fn from_knowledge(category: &str, key: &str) -> Self {
40 Self(format!("knowledge:{category}:{key}"))
41 }
42 pub fn from_memory(key: &str) -> Self {
43 Self(format!("memory:{key}"))
44 }
45 pub fn from_provider(provider: &str, key: &str) -> Self {
46 Self(format!("provider:{provider}:{key}"))
47 }
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl fmt::Display for ContextItemId {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ContextKind {
62 File,
63 Shell,
64 Knowledge,
65 Memory,
66 Provider,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[derive(Default)]
72pub enum ContextState {
73 #[default]
74 Candidate,
75 Included,
76 Excluded,
77 Pinned,
78 Stale,
79 Shadowed,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ViewKind {
85 Full,
86 Signatures,
87 Map,
88 Diff,
89 Aggressive,
90 Entropy,
91 Lines,
92 Reference,
93 Handle,
94}
95
96impl ViewKind {
97 pub fn as_str(&self) -> &'static str {
98 match self {
99 Self::Full => "full",
100 Self::Signatures => "signatures",
101 Self::Map => "map",
102 Self::Diff => "diff",
103 Self::Aggressive => "aggressive",
104 Self::Entropy => "entropy",
105 Self::Lines => "lines",
106 Self::Reference => "reference",
107 Self::Handle => "handle",
108 }
109 }
110
111 pub fn parse(s: &str) -> Self {
112 match s.trim().to_lowercase().as_str() {
113 "signatures" => Self::Signatures,
114 "map" => Self::Map,
115 "diff" => Self::Diff,
116 "aggressive" => Self::Aggressive,
117 "entropy" => Self::Entropy,
118 "lines" => Self::Lines,
119 "reference" => Self::Reference,
120 "handle" => Self::Handle,
121 _ => Self::Full,
122 }
123 }
124
125 pub fn density_rank(&self) -> u8 {
127 match self {
128 Self::Full => 0,
129 Self::Aggressive => 1,
130 Self::Diff => 2,
131 Self::Lines => 3,
132 Self::Entropy => 4,
133 Self::Signatures => 5,
134 Self::Map => 6,
135 Self::Reference => 7,
136 Self::Handle => 8,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct ViewCosts {
144 pub estimates: HashMap<ViewKind, usize>,
145}
146
147impl ViewCosts {
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 pub fn set(&mut self, view: ViewKind, tokens: usize) {
153 self.estimates.insert(view, tokens);
154 }
155
156 pub fn get(&self, view: &ViewKind) -> usize {
157 self.estimates.get(view).copied().unwrap_or(0)
158 }
159
160 pub fn cheapest_content_view(&self) -> Option<(ViewKind, usize)> {
162 self.estimates
163 .iter()
164 .filter(|(v, _)| **v != ViewKind::Handle)
165 .min_by_key(|&(_, &tokens)| tokens)
166 .map(|(&v, &t)| (v, t))
167 }
168
169 pub fn from_full_tokens(full_tokens: usize) -> Self {
170 let mut vc = Self::new();
171 vc.set(ViewKind::Full, full_tokens);
172 vc.set(ViewKind::Signatures, full_tokens / 5);
173 vc.set(ViewKind::Map, full_tokens / 8);
174 vc.set(ViewKind::Reference, full_tokens / 20);
175 vc.set(ViewKind::Handle, 25);
176 vc
177 }
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct Provenance {
182 pub tool: Option<String>,
183 pub agent_id: Option<String>,
184 pub client_name: Option<String>,
185 pub timestamp: Option<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct FieldWeights {
197 pub w_relevance: f64,
198 pub w_surprise: f64,
199 pub w_graph: f64,
200 pub w_history: f64,
201 pub w_cost: f64,
202 pub w_redundancy: f64,
203}
204
205impl Default for FieldWeights {
206 fn default() -> Self {
207 Self {
208 w_relevance: 0.35,
209 w_surprise: 0.15,
210 w_graph: 0.20,
211 w_history: 0.10,
212 w_cost: 0.10,
213 w_redundancy: 0.10,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct FieldSignals {
221 pub relevance: f64,
222 pub surprise: f64,
223 pub graph_proximity: f64,
224 pub history_signal: f64,
225 pub token_cost_norm: f64,
226 pub redundancy: f64,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct FieldPotential {
232 pub signals: FieldSignals,
233 pub phi: f64,
234 pub view_costs: ViewCosts,
235 pub best_view: ViewKind,
236}
237
238#[derive(Debug, Clone, Copy)]
240pub struct TokenBudget {
241 pub total: usize,
242 pub used: usize,
243}
244
245impl TokenBudget {
246 pub fn remaining(&self) -> usize {
247 self.total.saturating_sub(self.used)
248 }
249 pub fn utilization(&self) -> f64 {
250 if self.total == 0 {
251 return 1.0;
252 }
253 self.used as f64 / self.total as f64
254 }
255 pub fn temperature(&self) -> f64 {
259 let u = self.utilization();
260 (0.1 + u * 1.9).clamp(0.1, 2.0)
261 }
262}
263
264pub struct ContextField {
266 weights: FieldWeights,
267}
268
269impl Default for ContextField {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275impl ContextField {
276 pub fn new() -> Self {
277 Self {
278 weights: FieldWeights::default(),
279 }
280 }
281
282 pub fn with_weights(weights: FieldWeights) -> Self {
283 Self { weights }
284 }
285
286 pub fn compute_phi(&self, signals: &FieldSignals) -> f64 {
291 let w = &self.weights;
292 let phi = w.w_relevance * signals.relevance
293 + w.w_surprise * signals.surprise
294 + w.w_graph * signals.graph_proximity
295 + w.w_history * signals.history_signal
296 - w.w_cost * signals.token_cost_norm
297 - w.w_redundancy * signals.redundancy;
298 phi.clamp(0.0, 1.0)
299 }
300
301 pub fn select_view(&self, costs: &ViewCosts, temperature: f64) -> ViewKind {
309 if costs.estimates.is_empty() {
310 return ViewKind::Full;
311 }
312
313 let t = temperature.max(0.01);
314 let max_cost = costs.estimates.values().copied().max().unwrap_or(1).max(1) as f64;
315
316 let mut best_view = ViewKind::Full;
317 let mut best_score = f64::NEG_INFINITY;
318
319 for (&view, &tokens) in &costs.estimates {
320 let normalized_cost = tokens as f64 / max_cost;
321 let density_bonus = 1.0 - (view.density_rank() as f64 / 8.0);
322 let score = density_bonus * (2.0 - t) - normalized_cost * t;
325 if score > best_score {
326 best_score = score;
327 best_view = view;
328 }
329 }
330
331 best_view
332 }
333
334 pub fn compute_batch(
336 &self,
337 items: &[(ContextItemId, FieldSignals, ViewCosts)],
338 budget: TokenBudget,
339 ) -> HashMap<ContextItemId, FieldPotential> {
340 let temperature = budget.temperature();
341 let mut result = HashMap::new();
342
343 for (id, signals, costs) in items {
344 let phi = self.compute_phi(signals);
345 let best_view = self.select_view(costs, temperature);
346 result.insert(
347 id.clone(),
348 FieldPotential {
349 signals: signals.clone(),
350 phi,
351 view_costs: costs.clone(),
352 best_view,
353 },
354 );
355 }
356
357 result
358 }
359}
360
361pub fn normalize_relevance(score: f64, max_score: f64) -> f64 {
367 if max_score <= 0.0 {
368 return 0.0;
369 }
370 (score / max_score).clamp(0.0, 1.0)
371}
372
373pub fn normalize_surprise(surprise: f64) -> f64 {
376 ((surprise - 5.0) / 12.0).clamp(0.0, 1.0)
377}
378
379pub fn normalize_graph_proximity(distance: usize) -> f64 {
382 1.0 / (1.0 + distance as f64)
383}
384
385pub fn normalize_token_cost(tokens: usize, budget_total: usize) -> f64 {
387 if budget_total == 0 {
388 return 1.0;
389 }
390 (tokens as f64 / budget_total as f64).clamp(0.0, 1.0)
391}
392
393pub fn efficiency(phi: f64, tokens: usize) -> f64 {
396 if tokens == 0 {
397 return phi;
398 }
399 phi / tokens as f64
400}
401
402pub const MMR_LAMBDA: f64 = 0.7;
406
407pub fn mmr_score(phi: f64, max_similarity: f64, lambda: f64) -> f64 {
413 let l = lambda.clamp(0.0, 1.0);
414 l * phi - (1.0 - l) * max_similarity.clamp(0.0, 1.0)
415}
416
417pub fn compute_signals_for_path(
421 path: &str,
422 task: Option<&str>,
423 file_content: Option<&str>,
424 budget_total: usize,
425 full_tokens: usize,
426) -> (FieldSignals, ViewCosts) {
427 let mut signals = FieldSignals::default();
428
429 let heatmap = super::heatmap::HeatMap::load();
430 let heat_entry = heatmap.entries.get(path);
431
432 if let Some(task_desc) = task {
434 let (_, keywords) = super::task_relevance::parse_task_hints(task_desc);
435 let path_lower = path.to_lowercase();
436 let keyword_hits = keywords
437 .iter()
438 .filter(|kw| path_lower.contains(&kw.to_lowercase()))
439 .count();
440 let keyword_score = (keyword_hits as f64 * 0.3).min(1.0);
441 let freq_score = heat_entry.map_or(0.0, |e| (e.access_count as f64 / 10.0).min(1.0));
442 signals.relevance = normalize_relevance(keyword_score + freq_score, 2.0);
443 } else {
444 let freq = heat_entry.map_or(0.0, |e| e.access_count as f64);
445 signals.relevance = normalize_relevance(freq, 10.0);
446 }
447
448 if let Some(content) = file_content {
450 let surprise_val = super::surprise::line_surprise(content);
451 signals.surprise = normalize_surprise(surprise_val);
452 }
453
454 let depth = path.matches('/').count();
457 signals.graph_proximity = normalize_graph_proximity(depth);
458
459 let access_count = heat_entry.map_or(0, |e| e.access_count);
461 signals.history_signal = (access_count as f64 / 20.0).min(1.0);
462
463 signals.token_cost_norm = normalize_token_cost(full_tokens, budget_total);
465
466 signals.redundancy = 0.0;
468
469 let view_costs = ViewCosts::from_full_tokens(full_tokens);
470 (signals, view_costs)
471}
472
473#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn phi_increases_with_relevance() {
483 let field = ContextField::new();
484 let low = field.compute_phi(&FieldSignals {
485 relevance: 0.2,
486 ..Default::default()
487 });
488 let high = field.compute_phi(&FieldSignals {
489 relevance: 0.9,
490 ..Default::default()
491 });
492 assert!(high > low, "higher relevance should yield higher phi");
493 }
494
495 #[test]
496 fn phi_decreases_with_cost() {
497 let field = ContextField::new();
498 let cheap = field.compute_phi(&FieldSignals {
499 relevance: 0.5,
500 token_cost_norm: 0.1,
501 ..Default::default()
502 });
503 let expensive = field.compute_phi(&FieldSignals {
504 relevance: 0.5,
505 token_cost_norm: 0.9,
506 ..Default::default()
507 });
508 assert!(cheap > expensive, "higher cost should reduce phi");
509 }
510
511 #[test]
512 fn phi_decreases_with_redundancy() {
513 let field = ContextField::new();
514 let unique = field.compute_phi(&FieldSignals {
515 relevance: 0.5,
516 redundancy: 0.0,
517 ..Default::default()
518 });
519 let redundant = field.compute_phi(&FieldSignals {
520 relevance: 0.5,
521 redundancy: 0.9,
522 ..Default::default()
523 });
524 assert!(unique > redundant, "redundancy should reduce phi");
525 }
526
527 #[test]
528 fn phi_is_clamped_to_unit_interval() {
529 let field = ContextField::new();
530 let phi = field.compute_phi(&FieldSignals {
531 relevance: 1.0,
532 surprise: 1.0,
533 graph_proximity: 1.0,
534 history_signal: 1.0,
535 token_cost_norm: 0.0,
536 redundancy: 0.0,
537 });
538 assert!(phi <= 1.0);
539 assert!(phi >= 0.0);
540 }
541
542 #[test]
543 fn view_selection_prefers_dense_at_low_temperature() {
544 let field = ContextField::new();
545 let costs = ViewCosts::from_full_tokens(5000);
546 let view = field.select_view(&costs, 0.1);
547 assert_eq!(
548 view,
549 ViewKind::Full,
550 "low temperature (relaxed budget) should prefer full view"
551 );
552 }
553
554 #[test]
555 fn view_selection_prefers_sparse_at_high_temperature() {
556 let field = ContextField::new();
557 let costs = ViewCosts::from_full_tokens(5000);
558 let view = field.select_view(&costs, 2.0);
559 assert_ne!(
560 view,
561 ViewKind::Full,
562 "high temperature (tight budget) should prefer sparser view"
563 );
564 }
565
566 #[test]
567 fn budget_temperature_scales_with_utilization() {
568 let low = TokenBudget {
569 total: 10000,
570 used: 1000,
571 };
572 let high = TokenBudget {
573 total: 10000,
574 used: 9000,
575 };
576 assert!(
577 high.temperature() > low.temperature(),
578 "higher utilization should increase temperature"
579 );
580 }
581
582 #[test]
583 fn normalize_surprise_maps_range() {
584 assert!((normalize_surprise(5.0) - 0.0).abs() < 0.01);
585 assert!((normalize_surprise(17.0) - 1.0).abs() < 0.01);
586 assert!((normalize_surprise(11.0) - 0.5).abs() < 0.01);
587 }
588
589 #[test]
590 fn normalize_graph_proximity_inverse_distance() {
591 assert!((normalize_graph_proximity(0) - 1.0).abs() < f64::EPSILON);
592 assert!((normalize_graph_proximity(1) - 0.5).abs() < f64::EPSILON);
593 assert!(normalize_graph_proximity(10) < 0.15);
594 }
595
596 #[test]
597 fn efficiency_ratio_is_phi_per_token() {
598 let e = efficiency(0.8, 400);
599 assert!((e - 0.002).abs() < 0.0001);
600 }
601
602 #[test]
603 fn mmr_penalizes_redundancy() {
604 let unique = mmr_score(0.8, 0.0, MMR_LAMBDA);
606 let redundant = mmr_score(0.8, 0.9, MMR_LAMBDA);
607 assert!(
608 unique > redundant,
609 "redundant item ({redundant}) must score below unique ({unique})"
610 );
611 }
612
613 #[test]
614 fn mmr_lambda_one_is_pure_relevance() {
615 assert!((mmr_score(0.6, 0.9, 1.0) - 0.6).abs() < f64::EPSILON);
617 }
618
619 #[test]
620 fn context_item_id_stable() {
621 let a = ContextItemId::from_file("src/main.rs");
622 let b = ContextItemId::from_file("src/main.rs");
623 assert_eq!(a, b);
624 }
625
626 #[test]
627 fn view_costs_from_full() {
628 let vc = ViewCosts::from_full_tokens(5000);
629 assert_eq!(vc.get(&ViewKind::Full), 5000);
630 assert_eq!(vc.get(&ViewKind::Signatures), 1000);
631 assert_eq!(vc.get(&ViewKind::Map), 625);
632 assert_eq!(vc.get(&ViewKind::Handle), 25);
633 }
634
635 #[test]
636 fn batch_compute_produces_results_for_all_items() {
637 let field = ContextField::new();
638 let items = vec![
639 (
640 ContextItemId::from_file("a.rs"),
641 FieldSignals {
642 relevance: 0.8,
643 ..Default::default()
644 },
645 ViewCosts::from_full_tokens(2000),
646 ),
647 (
648 ContextItemId::from_file("b.rs"),
649 FieldSignals {
650 relevance: 0.3,
651 ..Default::default()
652 },
653 ViewCosts::from_full_tokens(500),
654 ),
655 ];
656 let budget = TokenBudget {
657 total: 10000,
658 used: 2000,
659 };
660 let results = field.compute_batch(&items, budget);
661 assert_eq!(results.len(), 2);
662 assert!(results.contains_key(&ContextItemId::from_file("a.rs")));
663 assert!(results.contains_key(&ContextItemId::from_file("b.rs")));
664 }
665}