Skip to main content

lean_ctx/core/
context_compiler.rs

1//! Context Compiler -- builds minimal context packages under budget constraints.
2//!
3//! Physical metaphor: Free Energy minimization.
4//! F = E - TS, where E = token cost, T = budget pressure, S = information (Phi).
5//!
6//! Algorithm:
7//!   1. LOAD    ledger items + active overlays -> candidates
8//!   2. SCORE   Phi(i,t) for each candidate (Context Field)
9//!   3. SELECT  greedy knapsack with view selection
10//!   4. DEDUP   redundancy removal via Jaccard
11//!   5. ORDER   Lost-in-the-Middle reorder (LiTM profile)
12//!   6. RENDER  output in the requested mode
13//!   7. PROVE   record provenance in evidence ledger
14
15use serde::Serialize;
16
17use super::context_field::{
18    ContextItemId, ContextKind, ContextState, TokenBudget, ViewCosts, ViewKind, efficiency,
19};
20use super::entropy::jaccard_similarity;
21
22/// Compilation output mode.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CompileMode {
25    HandleManifest,
26    Compressed,
27    FullPrompt,
28}
29
30impl CompileMode {
31    pub fn parse(s: &str) -> Self {
32        match s.trim().to_lowercase().as_str() {
33            "compressed" => Self::Compressed,
34            "full" | "full_prompt" => Self::FullPrompt,
35            _ => Self::HandleManifest,
36        }
37    }
38
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Self::HandleManifest => "handle_manifest",
42            Self::Compressed => "compressed",
43            Self::FullPrompt => "full_prompt",
44        }
45    }
46}
47
48/// A candidate item ready for selection.
49#[derive(Debug, Clone)]
50pub struct CompileCandidate {
51    pub id: ContextItemId,
52    pub kind: ContextKind,
53    pub path: String,
54    pub state: ContextState,
55    pub phi: f64,
56    pub view_costs: ViewCosts,
57    pub selected_view: ViewKind,
58    pub selected_tokens: usize,
59    pub pinned: bool,
60    /// Content fingerprint used for redundancy/MMR comparison (#5). A short
61    /// signature or token sketch of the item's content; when `None`, selection
62    /// falls back to the path (so callers that don't provide content behave as
63    /// before). Comparing content — not paths — is what makes dedup correct.
64    pub content_sketch: Option<String>,
65}
66
67/// Result of a compilation run.
68#[derive(Debug, Clone, Serialize)]
69pub struct CompileResult {
70    pub run_id: String,
71    pub mode: String,
72    pub budget_total: usize,
73    pub budget_used: usize,
74    pub items_considered: usize,
75    pub items_selected: usize,
76    pub items_excluded: usize,
77    pub items_pinned: usize,
78    pub selected: Vec<SelectedItem>,
79    pub excluded_reasons: Vec<ExcludedItem>,
80    pub warnings: Vec<String>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct SelectedItem {
85    pub id: String,
86    pub path: String,
87    pub view: String,
88    pub tokens: usize,
89    pub phi: f64,
90    pub pinned: bool,
91}
92
93#[derive(Debug, Clone, Serialize)]
94pub struct ExcludedItem {
95    pub id: String,
96    pub path: String,
97    pub reason: String,
98}
99
100/// Compile a minimal context package from candidates under budget constraints.
101///
102/// This implements a greedy knapsack: pinned items first, then by efficiency
103/// (Phi/token), with automatic view downgrade under budget pressure.
104pub fn compile(
105    candidates: &[CompileCandidate],
106    budget: TokenBudget,
107    mode: CompileMode,
108) -> CompileResult {
109    let run_id = format!(
110        "run_{}_{}",
111        chrono::Utc::now().format("%Y%m%d_%H%M%S"),
112        std::process::id() % 1000
113    );
114
115    let mut selected: Vec<SelectedItem> = Vec::new();
116    let mut excluded: Vec<ExcludedItem> = Vec::new();
117    let mut warnings: Vec<String> = Vec::new();
118    let mut tokens_used: usize = 0;
119    let remaining = budget.remaining();
120
121    let (pinned, unpinned): (Vec<_>, Vec<_>) = candidates
122        .iter()
123        .partition(|c| c.pinned || c.state == ContextState::Pinned);
124
125    for c in &pinned {
126        if c.state == ContextState::Excluded {
127            excluded.push(ExcludedItem {
128                id: c.id.to_string(),
129                path: c.path.clone(),
130                reason: "excluded by overlay".to_string(),
131            });
132            continue;
133        }
134        let (view, tokens) =
135            best_affordable_view(&c.view_costs, remaining.saturating_sub(tokens_used));
136        tokens_used = tokens_used.saturating_add(tokens);
137        selected.push(SelectedItem {
138            id: c.id.to_string(),
139            path: c.path.clone(),
140            view: view.as_str().to_string(),
141            tokens,
142            phi: c.phi,
143            pinned: true,
144        });
145    }
146
147    // Steps 2+3: SCORE + SELECT via greedy Maximal Marginal Relevance (#5).
148    // Relevance = normalized efficiency (Phi/token, keeps the knapsack budget-
149    // aware); penalty = max content similarity to the already-selected set, so a
150    // near-duplicate of something already chosen loses to a complementary item.
151    // Deterministic: fixed λ, fixed tie-break (efficiency, then id).
152    let pickable: Vec<usize> = unpinned
153        .iter()
154        .enumerate()
155        .filter(|(_, c)| c.state != ContextState::Excluded)
156        .map(|(i, _)| i)
157        .collect();
158
159    let effs: Vec<f64> = unpinned
160        .iter()
161        .map(|c| {
162            let best_tokens = c
163                .view_costs
164                .cheapest_content_view()
165                .map_or(c.selected_tokens, |(_, t)| t);
166            efficiency(c.phi, best_tokens.max(1))
167        })
168        .collect();
169    let max_eff = effs
170        .iter()
171        .copied()
172        .fold(0.0_f64, f64::max)
173        .max(f64::MIN_POSITIVE);
174
175    // Sketches of already-selected items (pinned first) drive the redundancy term.
176    let mut selected_sketches: Vec<String> = selected
177        .iter()
178        .map(|s| sketch_of(candidates, &s.id, &s.path))
179        .collect();
180    let mut redundancy_applied = false;
181    let mut remaining_idx: Vec<usize> = pickable;
182
183    while !remaining_idx.is_empty() {
184        let budget_left = remaining.saturating_sub(tokens_used);
185        if budget_left == 0 {
186            break;
187        }
188        // Pick the highest-MMR candidate that still fits the budget.
189        let mut best: Option<(usize, usize, f64, usize)> = None; // (pos_in_vec, cand_idx, mmr, tokens)
190        for (pos, &idx) in remaining_idx.iter().enumerate() {
191            let c = &unpinned[idx];
192            let (_, tokens) = best_affordable_view(&c.view_costs, budget_left);
193            if tokens == 0 || tokens > budget_left {
194                continue;
195            }
196            let sketch = candidate_sketch(c);
197            let max_sim = selected_sketches
198                .iter()
199                .map(|s| jaccard_similarity(s, &sketch))
200                .fold(0.0_f64, f64::max);
201            if max_sim > 0.0 {
202                redundancy_applied = true;
203            }
204            let norm_eff = effs[idx] / max_eff;
205            let mmr = crate::core::context_field::mmr_score(
206                norm_eff,
207                max_sim,
208                crate::core::context_field::MMR_LAMBDA,
209            );
210            let better = match best {
211                None => true,
212                Some((_, best_idx, best_mmr, _)) => {
213                    mmr > best_mmr
214                        || (mmr == best_mmr && effs[idx] > effs[best_idx])
215                        || (mmr == best_mmr
216                            && (effs[idx] - effs[best_idx]).abs() < f64::EPSILON
217                            && c.id.to_string() < unpinned[best_idx].id.to_string())
218                }
219            };
220            if better {
221                best = Some((pos, idx, mmr, tokens));
222            }
223        }
224
225        let Some((pos, idx, _mmr, _)) = best else {
226            // Nothing else fits the remaining budget.
227            break;
228        };
229        remaining_idx.remove(pos);
230        let c = &unpinned[idx];
231        let (view, tokens) = best_affordable_view(&c.view_costs, budget_left);
232        tokens_used = tokens_used.saturating_add(tokens);
233        selected_sketches.push(candidate_sketch(c));
234        selected.push(SelectedItem {
235            id: c.id.to_string(),
236            path: c.path.clone(),
237            view: view.as_str().to_string(),
238            tokens,
239            phi: c.phi,
240            pinned: false,
241        });
242    }
243
244    // Anything still unpicked didn't fit the budget.
245    for idx in remaining_idx {
246        let c = &unpinned[idx];
247        excluded.push(ExcludedItem {
248            id: c.id.to_string(),
249            path: c.path.clone(),
250            reason: "budget exhausted".to_string(),
251        });
252    }
253    if redundancy_applied {
254        crate::core::introspect::tick("integration_phi");
255    }
256
257    for c in candidates
258        .iter()
259        .filter(|c| c.state == ContextState::Excluded)
260    {
261        if !excluded.iter().any(|e| e.id == c.id.to_string()) {
262            excluded.push(ExcludedItem {
263                id: c.id.to_string(),
264                path: c.path.clone(),
265                reason: "excluded by overlay/policy".to_string(),
266            });
267        }
268    }
269
270    // Step 4: DEDUP — drop items whose CONTENT is >70% redundant with an
271    // already-kept item of equal-or-higher Phi (IIT non-redundancy). The old
272    // code compared file *paths* and mis-indexed the kept list; we now compare
273    // each item's content sketch against the sketches we actually kept.
274    let mut deduped: Vec<SelectedItem> = Vec::with_capacity(selected.len());
275    let mut kept_sketches: Vec<String> = Vec::with_capacity(selected.len());
276    let mut dedup_tokens = 0usize;
277    for item in &selected {
278        let sketch_i = sketch_of(candidates, &item.id, &item.path);
279        let dominated = deduped
280            .iter()
281            .zip(&kept_sketches)
282            .any(|(existing, sketch_j)| {
283                if sketch_i.is_empty() || sketch_j.is_empty() {
284                    return false;
285                }
286                jaccard_similarity(sketch_j, &sketch_i) > DEDUP_JACCARD_THRESHOLD
287                    && existing.phi >= item.phi
288            });
289        if dominated {
290            excluded.push(ExcludedItem {
291                id: item.id.clone(),
292                path: item.path.clone(),
293                reason: "dedup: >70% content overlap with higher-Phi item".to_string(),
294            });
295        } else {
296            dedup_tokens += item.tokens;
297            kept_sketches.push(sketch_i);
298            deduped.push(item.clone());
299        }
300    }
301    selected = deduped;
302    tokens_used = dedup_tokens;
303
304    // Step 5: ORDER — Lost-in-the-Middle (LiTM) reorder.
305    // High-Phi items at the beginning and end; medium-Phi in the middle.
306    if selected.len() >= 3 {
307        selected.sort_by(|a, b| {
308            b.phi
309                .partial_cmp(&a.phi)
310                .unwrap_or(std::cmp::Ordering::Equal)
311        });
312        let n = selected.len();
313        let mut reordered = Vec::with_capacity(n);
314        let mut left = Vec::new();
315        let mut right = Vec::new();
316        for (i, item) in selected.into_iter().enumerate() {
317            if i % 2 == 0 {
318                left.push(item);
319            } else {
320                right.push(item);
321            }
322        }
323        right.reverse();
324        reordered.extend(left);
325        reordered.extend(right);
326        selected = reordered;
327    }
328
329    if tokens_used as f64 / budget.total.max(1) as f64 > 0.9 {
330        warnings.push(format!(
331            "Context budget >90% utilized ({tokens_used}/{} tokens)",
332            budget.total
333        ));
334    }
335
336    CompileResult {
337        run_id,
338        mode: mode.as_str().to_string(),
339        budget_total: budget.total,
340        budget_used: tokens_used,
341        items_considered: candidates.len(),
342        items_selected: selected.len(),
343        items_excluded: excluded.len(),
344        items_pinned: pinned.len(),
345        selected,
346        excluded_reasons: excluded,
347        warnings,
348    }
349}
350
351/// Above this content-Jaccard, a lower-or-equal-Phi item is treated as a
352/// redundant duplicate and dropped during DEDUP (#5).
353const DEDUP_JACCARD_THRESHOLD: f64 = 0.7;
354
355/// Content fingerprint of a candidate for redundancy comparison (#5): its
356/// explicit `content_sketch` when provided, else the path as a degraded fallback
357/// (so callers that supply no content behave exactly as before).
358fn candidate_sketch(c: &CompileCandidate) -> String {
359    c.content_sketch.clone().unwrap_or_else(|| c.path.clone())
360}
361
362/// Look up a candidate by its id string and return its content sketch, falling
363/// back to `fallback_path` when the candidate or its sketch is missing.
364fn sketch_of(candidates: &[CompileCandidate], id: &str, fallback_path: &str) -> String {
365    candidates
366        .iter()
367        .find(|c| c.id.to_string() == id)
368        .and_then(|c| c.content_sketch.clone())
369        .unwrap_or_else(|| fallback_path.to_string())
370}
371
372/// Select the best view that fits within the budget, preferring denser views.
373fn best_affordable_view(costs: &ViewCosts, budget_left: usize) -> (ViewKind, usize) {
374    let mut options: Vec<(ViewKind, usize)> = costs
375        .estimates
376        .iter()
377        .map(|(&v, &t)| (v, t))
378        .filter(|(_, t)| *t <= budget_left && *t > 0)
379        .collect();
380
381    options.sort_by_key(|(v, _)| v.density_rank());
382
383    options
384        .first()
385        .copied()
386        .unwrap_or((ViewKind::Handle, 25.min(budget_left)))
387}
388
389/// Format the compilation result for display.
390pub fn format_compile_result(result: &CompileResult) -> String {
391    let mut out = String::new();
392    out.push_str(&format!(
393        "[compiled] {} mode, {}/{} tokens\n",
394        result.mode, result.budget_used, result.budget_total
395    ));
396    out.push_str(&format!(
397        "Selected: {} items, Excluded: {}, Pinned: {}\n\n",
398        result.items_selected, result.items_excluded, result.items_pinned
399    ));
400
401    if !result.selected.is_empty() {
402        out.push_str("Included:\n");
403        for item in &result.selected {
404            let pin_tag = if item.pinned { " [pinned]" } else { "" };
405            out.push_str(&format!(
406                "  {} {} {}t phi={:.2}{}\n",
407                item.path, item.view, item.tokens, item.phi, pin_tag
408            ));
409        }
410    }
411
412    if !result.excluded_reasons.is_empty() {
413        out.push('\n');
414        out.push_str("Excluded:\n");
415        for item in &result.excluded_reasons {
416            out.push_str(&format!("  {} — {}\n", item.path, item.reason));
417        }
418    }
419
420    if !result.warnings.is_empty() {
421        out.push('\n');
422        for w in &result.warnings {
423            out.push_str(&format!("WARNING: {w}\n"));
424        }
425    }
426
427    out
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn make_candidate(path: &str, phi: f64, full_tokens: usize, pinned: bool) -> CompileCandidate {
435        CompileCandidate {
436            id: ContextItemId::from_file(path),
437            kind: ContextKind::File,
438            path: path.to_string(),
439            state: if pinned {
440                ContextState::Pinned
441            } else {
442                ContextState::Included
443            },
444            phi,
445            view_costs: ViewCosts::from_full_tokens(full_tokens),
446            selected_view: ViewKind::Full,
447            selected_tokens: full_tokens,
448            pinned,
449            content_sketch: None,
450        }
451    }
452
453    fn make_candidate_with_sketch(
454        path: &str,
455        phi: f64,
456        full_tokens: usize,
457        sketch: &str,
458    ) -> CompileCandidate {
459        CompileCandidate {
460            content_sketch: Some(sketch.to_string()),
461            ..make_candidate(path, phi, full_tokens, false)
462        }
463    }
464
465    #[test]
466    fn compile_selects_highest_efficiency_first() {
467        let candidates = vec![
468            make_candidate("low_eff.rs", 0.1, 5000, false),
469            make_candidate("high_eff.rs", 0.9, 200, false),
470        ];
471        let budget = TokenBudget {
472            total: 10000,
473            used: 0,
474        };
475        let result = compile(&candidates, budget, CompileMode::HandleManifest);
476        assert_eq!(result.items_selected, 2);
477        assert_eq!(result.selected[0].path, "high_eff.rs");
478    }
479
480    #[test]
481    fn compile_respects_budget() {
482        let candidates = vec![
483            make_candidate("big.rs", 0.5, 8000, false),
484            make_candidate("small.rs", 0.5, 200, false),
485        ];
486        let budget = TokenBudget {
487            total: 2000,
488            used: 0,
489        };
490        let result = compile(&candidates, budget, CompileMode::Compressed);
491        let total_tokens: usize = result.selected.iter().map(|s| s.tokens).sum();
492        assert!(
493            total_tokens <= 2000,
494            "selected tokens {total_tokens} should fit in budget 2000"
495        );
496    }
497
498    #[test]
499    fn compile_includes_pinned_first() {
500        let candidates = vec![
501            make_candidate("normal.rs", 0.9, 200, false),
502            make_candidate("pinned.rs", 0.1, 300, true),
503        ];
504        let budget = TokenBudget {
505            total: 10000,
506            used: 0,
507        };
508        let result = compile(&candidates, budget, CompileMode::HandleManifest);
509        assert!(result.selected[0].pinned, "pinned item should come first");
510    }
511
512    #[test]
513    fn compile_excludes_excluded_state() {
514        let candidates = vec![CompileCandidate {
515            state: ContextState::Excluded,
516            ..make_candidate("excluded.rs", 0.9, 100, false)
517        }];
518        let budget = TokenBudget {
519            total: 10000,
520            used: 0,
521        };
522        let result = compile(&candidates, budget, CompileMode::HandleManifest);
523        assert_eq!(result.items_selected, 0);
524        assert_eq!(result.items_excluded, 1);
525    }
526
527    #[test]
528    fn compile_downgrades_view_when_budget_tight() {
529        let candidates = vec![make_candidate("big.rs", 0.9, 5000, false)];
530        let budget = TokenBudget {
531            total: 800,
532            used: 0,
533        };
534        let result = compile(&candidates, budget, CompileMode::Compressed);
535        if let Some(item) = result.selected.first() {
536            assert_ne!(item.view, "full", "should downgrade from full under budget");
537            assert!(item.tokens <= 800);
538        }
539    }
540
541    #[test]
542    fn compile_warns_at_high_utilization() {
543        let candidates = vec![make_candidate("a.rs", 0.9, 950, false)];
544        let budget = TokenBudget {
545            total: 1000,
546            used: 0,
547        };
548        let result = compile(&candidates, budget, CompileMode::HandleManifest);
549        assert!(
550            !result.warnings.is_empty(),
551            "should warn when >90% utilized"
552        );
553    }
554
555    #[test]
556    fn dedup_drops_content_duplicate_keeps_higher_phi() {
557        // #5: two items with identical content sketches but different paths —
558        // the lower-Phi one must be dropped (content-based, not path-based).
559        let candidates = vec![
560            make_candidate_with_sketch("a.rs", 0.9, 300, "same content fingerprint here"),
561            make_candidate_with_sketch("b.rs", 0.4, 300, "same content fingerprint here"),
562        ];
563        let budget = TokenBudget {
564            total: 10000,
565            used: 0,
566        };
567        let result = compile(&candidates, budget, CompileMode::HandleManifest);
568        assert_eq!(
569            result.items_selected, 1,
570            "content duplicate should be deduped to one item"
571        );
572        assert_eq!(
573            result.selected[0].path, "a.rs",
574            "the higher-Phi duplicate must survive"
575        );
576    }
577
578    #[test]
579    fn distinct_content_is_not_deduped() {
580        // #5 regression: different content must NOT be treated as duplicate.
581        let candidates = vec![
582            make_candidate_with_sketch("a.rs", 0.9, 300, "alpha beta gamma"),
583            make_candidate_with_sketch("b.rs", 0.8, 300, "delta epsilon zeta"),
584        ];
585        let budget = TokenBudget {
586            total: 10000,
587            used: 0,
588        };
589        let result = compile(&candidates, budget, CompileMode::HandleManifest);
590        assert_eq!(
591            result.items_selected, 2,
592            "distinct content must both survive"
593        );
594    }
595
596    #[test]
597    fn compile_is_deterministic() {
598        // Determinism contract (#498): identical input → identical selection.
599        let candidates = vec![
600            make_candidate("a.rs", 0.7, 400, false),
601            make_candidate("b.rs", 0.6, 300, false),
602            make_candidate("c.rs", 0.8, 500, false),
603        ];
604        let budget = TokenBudget {
605            total: 5000,
606            used: 0,
607        };
608        let r1 = compile(&candidates, budget, CompileMode::Compressed);
609        let r2 = compile(&candidates, budget, CompileMode::Compressed);
610        let paths1: Vec<&str> = r1.selected.iter().map(|s| s.path.as_str()).collect();
611        let paths2: Vec<&str> = r2.selected.iter().map(|s| s.path.as_str()).collect();
612        assert_eq!(paths1, paths2, "selection order must be deterministic");
613    }
614
615    #[test]
616    fn format_compile_result_includes_key_info() {
617        let candidates = vec![
618            make_candidate("a.rs", 0.8, 500, false),
619            make_candidate("b.rs", 0.3, 200, true),
620        ];
621        let budget = TokenBudget {
622            total: 10000,
623            used: 0,
624        };
625        let result = compile(&candidates, budget, CompileMode::HandleManifest);
626        let text = format_compile_result(&result);
627        assert!(text.contains("a.rs"));
628        assert!(text.contains("b.rs"));
629        assert!(text.contains("[pinned]"));
630    }
631}