1use std::collections::HashSet;
20
21#[derive(Debug, Clone)]
24pub struct CoverageItem {
25 pub terms: HashSet<String>,
27 pub cost: usize,
29}
30
31pub 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
47pub(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; 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 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 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 let items = vec![
129 item(&["x", "y"], 1), item(&["z", "w"], 1), item(&["x", "y"], 1), ];
133 let sel = greedy_max_coverage(&items, 2, |_| 1.0);
134 assert_eq!(sel.len(), 2);
135 assert!(sel.contains(&0) || sel.contains(&2)); assert!(sel.contains(&1)); 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 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}