Skip to main content

lean_ctx/core/
context_field.rs

1//! Context Field Theory (CFT) -- unified potential function for context items.
2//!
3//! Combines information-theoretic, graph-based, and history signals into a
4//! single scalar potential Phi(i,t) per context item, enabling principled
5//! budget allocation and view selection.
6//!
7//! Scientific basis:
8//!   Phi(i,t) = w_R*R + w_S*S + w_G*G + w_H*H - w_C*C - w_D*D
9//! where R = task relevance (heat diffusion + PageRank),
10//!       S = surprise (cross-entropy with Zipfian prior),
11//!       G = graph proximity (weighted BFS distance),
12//!       H = history signal (access frequency),
13//!       C = token cost for the active view,
14//!       D = redundancy with already-selected items (Jaccard).
15
16use std::collections::HashMap;
17use std::fmt;
18
19use serde::{Deserialize, Serialize};
20
21// ---------------------------------------------------------------------------
22// Shared types used across CFT modules (Ledger, Overlay, Handles, Compiler)
23// ---------------------------------------------------------------------------
24
25/// Stable, content-addressed identifier for a context item.
26/// Derived from `kind + source_path` so the same file always maps to the
27/// same ID within a session, regardless of content changes.
28#[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    /// Phase-transition ordering: lower index = denser (more tokens).
126    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/// Token-cost estimates for each available view of a context item.
142#[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    /// Cheapest view that still provides content (excludes Handle).
161    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// ---------------------------------------------------------------------------
189// Context Potential Function
190// ---------------------------------------------------------------------------
191
192/// Weights for the potential function components. Fixed at
193/// [`FieldWeights::default`] on every production path, so Phi is a pure
194/// function of the signals (#498).
195#[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/// Raw signal components for a single context item before combination.
219#[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/// Combined potential for a context item.
230#[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/// Token budget parameters for compilation.
239#[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    /// Temperature derived from budget pressure: high pressure = high T.
256    /// T in [0.1, 2.0]. At T=0.1 (low pressure), prefer dense views.
257    /// At T=2.0 (high pressure), prefer sparse views.
258    pub fn temperature(&self) -> f64 {
259        let u = self.utilization();
260        (0.1 + u * 1.9).clamp(0.1, 2.0)
261    }
262}
263
264/// The Context Field: computes Phi for a set of items given a task context.
265pub 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    /// Compute the unified potential Phi(i,t) for a context item.
287    ///
288    /// All input signals should be normalized to [0, 1] before calling.
289    /// The cost and redundancy terms are subtracted (penalty).
290    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    /// Select the best view for an item given the temperature (budget pressure).
302    ///
303    /// Uses Boltzmann-weighted view selection:
304    ///   P(view_v | item_i, T) = exp(-C(v) / T) / Z(i, T)
305    ///
306    /// At low temperature (relaxed budget), denser views are preferred.
307    /// At high temperature (tight budget), sparser views are preferred.
308    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            // At low T, density_bonus dominates (prefer dense/full views).
323            // At high T, the cost penalty dominates (prefer cheap/sparse views).
324            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    /// Compute potentials for a batch of items.
335    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
361// ---------------------------------------------------------------------------
362// Signal extraction helpers (bridge to existing modules)
363// ---------------------------------------------------------------------------
364
365/// Normalize a relevance score from task_relevance.rs to [0, 1].
366pub 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
373/// Normalize a surprise score from surprise.rs to [0, 1].
374/// Surprise range is typically 5.0 (common) to 17.0+ (rare).
375pub fn normalize_surprise(surprise: f64) -> f64 {
376    ((surprise - 5.0) / 12.0).clamp(0.0, 1.0)
377}
378
379/// Normalize graph proximity (inverse of distance) to [0, 1].
380/// Distance 0 = same file = 1.0, distance N = 1/(1+N).
381pub fn normalize_graph_proximity(distance: usize) -> f64 {
382    1.0 / (1.0 + distance as f64)
383}
384
385/// Normalize token cost relative to budget.
386pub 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
393/// Compute efficiency ratio: Phi per token.
394/// Used by the greedy knapsack in the compiler.
395pub fn efficiency(phi: f64, tokens: usize) -> f64 {
396    if tokens == 0 {
397        return phi;
398    }
399    phi / tokens as f64
400}
401
402/// Default MMR trade-off: how much relevance (Phi) is weighted against
403/// non-redundancy during integration-aware selection (#5). 0.7 keeps relevance
404/// dominant while still penalizing near-duplicates.
405pub const MMR_LAMBDA: f64 = 0.7;
406
407/// Maximal Marginal Relevance score (#5): reward relevance (`phi`) but penalize
408/// redundancy with the already-selected set (`max_similarity`). This makes
409/// selection *integration-aware* in the IIT sense — a context package gains more
410/// from a complementary item than from a near-duplicate of one it already holds.
411/// Deterministic: a pure function of its inputs, no sampling.
412pub 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
417/// Compute real signals for a file path using existing scoring modules.
418/// Bridges CFT with the information-theoretic, graph-based, and history
419/// subsystems already in lean-ctx.
420pub 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    // R(i,t): Task relevance via keyword overlap + heatmap frequency
433    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    // S(i): Surprise from cross-entropy with Zipfian prior
449    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    // G(i,t): Graph proximity heuristic from path depth
455    // (property graph queries require a Connection not available here)
456    let depth = path.matches('/').count();
457    signals.graph_proximity = normalize_graph_proximity(depth);
458
459    // H(i): History signal from heatmap access count
460    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    // C(i,v): Normalized token cost relative to budget
464    signals.token_cost_norm = normalize_token_cost(full_tokens, budget_total);
465
466    // D(i): Redundancy — initialized at 0, refined during compilation pass
467    signals.redundancy = 0.0;
468
469    let view_costs = ViewCosts::from_full_tokens(full_tokens);
470    (signals, view_costs)
471}
472
473// ---------------------------------------------------------------------------
474// Tests
475// ---------------------------------------------------------------------------
476
477#[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        // #5: a redundant high-Phi item scores lower than a unique one.
605        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        // λ=1 ignores redundancy entirely → equals Phi.
616        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}