Skip to main content

lean_ctx/core/
context_packing.rs

1//! Submodular context packing — choosing *which* items to include under a
2//! budget so the selected set maximises coverage of what the task cares about.
3//!
4//! ## Why submodular?
5//!
6//! Picking a set of context items to maximise term coverage is the classic
7//! **maximum coverage** problem. Coverage is *monotone submodular*: each added
8//! item helps, but with diminishing returns (a second file covering the same
9//! terms adds little). For such objectives the simple **greedy** algorithm —
10//! repeatedly take the item with the largest marginal gain per unit cost — is
11//! provably within a `1 − 1/e ≈ 0.63` factor of the optimum (Nemhauser,
12//! Wolsey & Fisher 1978). That guarantee, plus `O(n·k)` cost, is exactly what
13//! we want for online context assembly.
14//!
15//! This module is deliberately generic and side-effect free: callers map their
16//! domain objects (symbol bodies, ranked files, chunks) onto [`CoverageItem`]s
17//! and get back the indices to keep, in selection order.
18
19use std::collections::HashSet;
20
21/// A candidate for inclusion: the set of weighted terms it covers and the
22/// budget cost of including it (e.g. its token count).
23#[derive(Debug, Clone)]
24pub struct CoverageItem {
25    /// Distinct terms this item covers. Weights come from `term_weight`.
26    pub terms: HashSet<String>,
27    /// Budget consumed if this item is selected (must be ≥ 1).
28    pub cost: usize,
29}
30
31/// Greedy submodular maximisation of weighted term coverage under `budget`.
32///
33/// Returns the indices of selected items in selection order. Items whose cost
34/// alone exceeds the remaining budget are skipped. `term_weight` lets callers
35/// prioritise rarer / more salient terms (uniform weight ⇒ plain coverage).
36///
37/// Guarantees: with unit costs this is the standard greedy max-coverage with a
38/// `1 − 1/e` approximation ratio. With costs it uses marginal-gain-per-cost,
39/// the cost-effective greedy heuristic.
40pub fn greedy_max_coverage<F>(items: &[CoverageItem], budget: usize, term_weight: F) -> Vec<usize>
41where
42    F: Fn(&str) -> f64,
43{
44    greedy_scored_coverage(items, budget, term_weight, |_| 1.0)
45}
46
47/// The same host-owned budget/coverage selection with optional bounded item
48/// priorities. Scores never supply content, identities or additional authority.
49pub(crate) fn greedy_scored_coverage<F, S>(
50    items: &[CoverageItem],
51    budget: usize,
52    term_weight: F,
53    item_score: S,
54) -> Vec<usize>
55where
56    F: Fn(&str) -> f64,
57    S: Fn(usize) -> f64,
58{
59    let mut covered: HashSet<&str> = HashSet::new();
60    let mut selected: Vec<usize> = Vec::new();
61    let mut remaining: Vec<usize> = (0..items.len()).collect();
62    let mut spent = 0usize;
63
64    while !remaining.is_empty() && spent < budget {
65        let mut best: Option<(usize, usize, f64)> = None; // (pos_in_remaining, item_idx, gain_per_cost)
66
67        for (pos, &idx) in remaining.iter().enumerate() {
68            let item = &items[idx];
69            let priority = item_score(idx);
70            if item.cost == 0
71                || item.cost > budget - spent
72                || !priority.is_finite()
73                || priority <= 0.0
74            {
75                continue;
76            }
77            // Marginal gain = weight of newly covered terms.
78            let gain: f64 = item
79                .terms
80                .iter()
81                .filter(|t| !covered.contains(t.as_str()))
82                .map(|t| term_weight(t))
83                .sum();
84            if gain <= 0.0 {
85                continue;
86            }
87            let gain_per_cost = gain * priority / item.cost as f64;
88            let take = match best {
89                Some((_, _, best_gpc)) => gain_per_cost > best_gpc,
90                None => true,
91            };
92            if take {
93                best = Some((pos, idx, gain_per_cost));
94            }
95        }
96
97        match best {
98            Some((pos, idx, _)) => {
99                for t in &items[idx].terms {
100                    covered.insert(t.as_str());
101                }
102                spent += items[idx].cost;
103                selected.push(idx);
104                remaining.swap_remove(pos);
105            }
106            // No remaining item adds positive marginal coverage within budget.
107            None => break,
108        }
109    }
110
111    selected
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn item(terms: &[&str], cost: usize) -> CoverageItem {
119        CoverageItem {
120            terms: terms.iter().map(|s| (*s).to_string()).collect(),
121            cost,
122        }
123    }
124
125    #[test]
126    fn picks_complementary_items_over_redundant_ones() {
127        // A and B together cover {x,y,z,w}; C is fully redundant with A.
128        let items = vec![
129            item(&["x", "y"], 1), // A
130            item(&["z", "w"], 1), // B
131            item(&["x", "y"], 1), // C (redundant with A)
132        ];
133        let sel = greedy_max_coverage(&items, 2, |_| 1.0);
134        assert_eq!(sel.len(), 2);
135        assert!(sel.contains(&0) || sel.contains(&2)); // one of the {x,y} items
136        assert!(sel.contains(&1)); // the complementary {z,w} item is always taken
137        assert!(
138            !(sel.contains(&0) && sel.contains(&2)),
139            "must not pick both redundant items"
140        );
141    }
142
143    #[test]
144    fn respects_budget() {
145        let items = vec![item(&["a"], 1), item(&["b"], 1), item(&["c"], 1)];
146        let sel = greedy_max_coverage(&items, 2, |_| 1.0);
147        assert_eq!(sel.len(), 2);
148    }
149
150    #[test]
151    fn skips_items_that_add_no_new_coverage() {
152        let items = vec![item(&["a", "b"], 1), item(&["a"], 1)];
153        let sel = greedy_max_coverage(&items, 5, |_| 1.0);
154        assert_eq!(sel, vec![0], "second item is fully subsumed → skipped");
155    }
156
157    #[test]
158    fn term_weight_prioritises_rare_terms() {
159        // Item 0 covers a common term; item 1 covers a rare (high-weight) one.
160        let items = vec![item(&["common"], 1), item(&["rare"], 1)];
161        let weight = |t: &str| if t == "rare" { 10.0 } else { 1.0 };
162        let sel = greedy_max_coverage(&items, 1, weight);
163        assert_eq!(sel, vec![1], "rare/high-weight term wins the single slot");
164    }
165
166    #[test]
167    fn empty_input_yields_empty_selection() {
168        let sel = greedy_max_coverage(&[], 5, |_| 1.0);
169        assert!(sel.is_empty(), "{sel:?}");
170    }
171
172    #[test]
173    fn zero_cost_items_are_skipped() {
174        let items = vec![item(&["a"], 0), item(&["b"], 1)];
175        let sel = greedy_max_coverage(&items, 5, |_| 1.0);
176        assert_eq!(sel, vec![1]);
177    }
178
179    #[test]
180    fn scores_change_priority_without_overriding_budget_or_finiteness() {
181        let items = vec![item(&["a"], 1), item(&["b"], 1), item(&["c"], usize::MAX)];
182        let selected =
183            greedy_scored_coverage(&items, 2, |_| 1.0, |index| [0.1, 0.9, f64::INFINITY][index]);
184        assert_eq!(selected, vec![1, 0]);
185        let selected =
186            greedy_scored_coverage(&items, 1, |_| 1.0, |index| [f64::NAN, 0.9, 1.0][index]);
187        assert_eq!(selected, vec![1]);
188    }
189}