1use cupel::SelectionReport;
2use cupel::analytics;
3use cupel::diagnostics::{ExcludedItem, ExclusionReason, IncludedItem};
4use cupel::model::{ContextBudget, ContextKind};
5
6pub struct SelectionReportAssertionChain<'a> {
12 pub(crate) report: &'a SelectionReport,
13}
14
15impl<'a> SelectionReportAssertionChain<'a> {
16 pub(crate) fn new(report: &'a SelectionReport) -> Self {
17 Self { report }
18 }
19
20 pub fn include_item_with_kind(&mut self, kind: ContextKind) -> &mut Self {
24 let included = &self.report.included;
25 if !included.iter().any(|i| i.item.kind() == &kind) {
26 let kinds: Vec<_> = included
27 .iter()
28 .map(|i| i.item.kind().as_str().to_string())
29 .collect::<std::collections::HashSet<_>>()
30 .into_iter()
31 .collect();
32 let kinds_str = kinds.join(", ");
33 panic!(
34 "include_item_with_kind({kind}) failed: Included contained 0 items with Kind={kind}. \
35 Included had {count} items with kinds: [{kinds_str}].",
36 kind = kind,
37 count = included.len(),
38 );
39 }
40 self
41 }
42
43 pub fn include_item_matching(
47 &mut self,
48 predicate: impl Fn(&IncludedItem) -> bool,
49 ) -> &mut Self {
50 let included = &self.report.included;
51 if !included.iter().any(predicate) {
52 panic!(
53 "include_item_matching failed: no item in Included matched the predicate. \
54 Included had {count} items.",
55 count = included.len(),
56 );
57 }
58 self
59 }
60
61 pub fn include_exact_n_items_with_kind(&mut self, kind: ContextKind, n: usize) -> &mut Self {
65 let included = &self.report.included;
66 let actual = included.iter().filter(|i| i.item.kind() == &kind).count();
67 if actual != n {
68 panic!(
69 "include_exact_n_items_with_kind({kind}, {n}) failed: expected {n} items with Kind={kind} in Included, \
70 but found {actual}. Included had {count} items total.",
71 kind = kind,
72 n = n,
73 actual = actual,
74 count = included.len(),
75 );
76 }
77 self
78 }
79
80 pub fn exclude_item_with_reason(&mut self, reason: ExclusionReason) -> &mut Self {
84 let excluded = &self.report.excluded;
85 let found = excluded
86 .iter()
87 .any(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(&reason));
88 if !found {
89 let reasons: Vec<_> = excluded
90 .iter()
91 .map(|e| format!("{:?}", e.reason))
92 .collect::<std::collections::HashSet<_>>()
93 .into_iter()
94 .collect();
95 let reasons_str = reasons.join(", ");
96 panic!(
97 "exclude_item_with_reason({reason:?}) failed: no excluded item had reason {reason:?}. \
98 Excluded had {count} items with reasons: [{reasons_str}].",
99 reason = reason,
100 count = excluded.len(),
101 );
102 }
103 self
104 }
105
106 pub fn exclude_item_matching_with_reason(
110 &mut self,
111 predicate: impl Fn(&ExcludedItem) -> bool,
112 reason: ExclusionReason,
113 ) -> &mut Self {
114 let excluded = &self.report.excluded;
115 let predicate_matches: Vec<_> = excluded.iter().filter(|e| predicate(e)).collect();
116 let found = predicate_matches
117 .iter()
118 .any(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(&reason));
119 if !found {
120 let actual_reasons: Vec<_> = predicate_matches
121 .iter()
122 .map(|e| format!("{:?}", e.reason))
123 .collect::<std::collections::HashSet<_>>()
124 .into_iter()
125 .collect();
126 let actual_reasons_str = actual_reasons.join(", ");
127 panic!(
128 "exclude_item_matching_with_reason(reason={reason:?}) failed: predicate matched {count} \
129 excluded item(s) but none had reason {reason:?}. Matched items had reasons: [{actual_reasons_str}].",
130 reason = reason,
131 count = predicate_matches.len(),
132 );
133 }
134 self
135 }
136
137 pub fn have_excluded_item_with_budget_details(
143 &mut self,
144 predicate: impl Fn(&ExcludedItem) -> bool,
145 expected_item_tokens: i64,
146 expected_available_tokens: i64,
147 ) -> &mut Self {
148 let excluded = &self.report.excluded;
149 let budget_match = excluded
151 .iter()
152 .find(|e| predicate(e) && matches!(e.reason, ExclusionReason::BudgetExceeded { .. }));
153 match budget_match {
154 Some(e) => {
155 if let ExclusionReason::BudgetExceeded {
156 item_tokens: ait,
157 available_tokens: aat,
158 } = e.reason
159 {
160 if ait != expected_item_tokens || aat != expected_available_tokens {
161 panic!(
162 "have_excluded_item_with_budget_details failed: expected BudgetExceeded \
163 with item_tokens={eIT}, available_tokens={eAT}, \
164 but found item_tokens={aIT}, available_tokens={aAT}.",
165 eIT = expected_item_tokens,
166 eAT = expected_available_tokens,
167 aIT = ait,
168 aAT = aat,
169 );
170 }
171 }
172 }
173 None => {
174 panic!(
175 "have_excluded_item_with_budget_details failed: expected BudgetExceeded \
176 with item_tokens={eIT}, available_tokens={eAT}, \
177 but no matching item had reason BudgetExceeded.",
178 eIT = expected_item_tokens,
179 eAT = expected_available_tokens,
180 );
181 }
182 }
183 self
184 }
185
186 pub fn have_no_exclusions_for_kind(&mut self, kind: ContextKind) -> &mut Self {
190 let excluded = &self.report.excluded;
191 let matching: Vec<_> = excluded.iter().filter(|e| e.item.kind() == &kind).collect();
192 if !matching.is_empty() {
193 let first = &matching[0];
194 panic!(
195 "have_no_exclusions_for_kind({kind}) failed: found {count} excluded item(s) with Kind={kind}. \
196 First: score={score:.4}, reason={reason:?}.",
197 kind = kind,
198 count = matching.len(),
199 score = first.score,
200 reason = first.reason,
201 );
202 }
203 self
204 }
205
206 pub fn have_at_least_n_exclusions(&mut self, n: usize) -> &mut Self {
210 let actual = self.report.excluded.len();
211 if actual < n {
212 panic!(
213 "have_at_least_n_exclusions({n}) failed: expected at least {n} excluded items, \
214 but Excluded had {actual}.",
215 );
216 }
217 self
218 }
219
220 pub fn excluded_items_are_sorted_by_score_descending(&mut self) -> &mut Self {
224 let excluded = &self.report.excluded;
225 for i in 0..excluded.len().saturating_sub(1) {
226 let si_prev = excluded[i].score;
227 let si = excluded[i + 1].score;
228 if si > si_prev {
229 panic!(
230 "excluded_items_are_sorted_by_score_descending failed: item at index {next} \
231 (score={si:.6}) is higher than item at index {i} (score={si_prev:.6}). \
232 Expected non-increasing scores.",
233 next = i + 1,
234 i = i,
235 si = si,
236 si_prev = si_prev,
237 );
238 }
239 }
240 self
241 }
242
243 pub fn have_budget_utilization_above(
247 &mut self,
248 threshold: f64,
249 budget: &ContextBudget,
250 ) -> &mut Self {
251 let actual = analytics::budget_utilization(self.report, budget);
252 if actual < threshold {
253 let included_tokens: i64 = self.report.included.iter().map(|i| i.item.tokens()).sum();
254 let max_tokens = budget.max_tokens();
255 panic!(
256 "have_budget_utilization_above({threshold}) failed: computed utilization was \
257 {actual:.6} (includedTokens={included_tokens}, budget.MaxTokens={max_tokens}).",
258 );
259 }
260 self
261 }
262
263 pub fn have_kind_coverage_count(&mut self, n: usize) -> &mut Self {
267 let actual = analytics::kind_diversity(self.report);
268 if actual < n {
269 let kinds: Vec<_> = self
270 .report
271 .included
272 .iter()
273 .map(|i| i.item.kind().as_str().to_string())
274 .collect::<std::collections::HashSet<_>>()
275 .into_iter()
276 .collect();
277 let actual_kinds = kinds.join(", ");
278 panic!(
279 "have_kind_coverage_count({n}) failed: expected at least {n} distinct ContextKind \
280 values in Included, but found {actual}: [{actual_kinds}].",
281 );
282 }
283 self
284 }
285
286 pub fn place_item_at_edge(&mut self, predicate: impl Fn(&IncludedItem) -> bool) -> &mut Self {
290 let included = &self.report.included;
291 let count = included.len();
292
293 let found = included
295 .iter()
296 .enumerate()
297 .find(|(_, item)| predicate(item));
298
299 match found {
300 None => {
301 panic!("place_item_at_edge failed: no item in Included matched the predicate.");
302 }
303 Some((idx, _)) => {
304 let last = count.saturating_sub(1);
305 if idx != 0 && idx != last {
306 panic!(
307 "place_item_at_edge failed: item matching predicate was at index {idx} \
308 (not at edge). Edge positions: 0 and {last}. Included had {count} items.",
309 );
310 }
311 }
312 }
313 self
314 }
315
316 pub fn place_top_n_scored_at_edges(&mut self, n: usize) -> &mut Self {
324 if n == 0 {
325 return self;
326 }
327 let count = self.report.included.len();
328 if n > count {
329 panic!(
330 "place_top_n_scored_at_edges({n}) failed: n={n} exceeds Included count={count}.",
331 );
332 }
333
334 let mut scored: Vec<(f64, usize)> = self
336 .report
337 .included
338 .iter()
339 .enumerate()
340 .map(|(i, item)| (item.score, i))
341 .collect();
342 scored.sort_by(|a, b| b.0.total_cmp(&a.0));
343
344 let top_n = &scored[..n];
346 let min_top_score = top_n.iter().map(|(s, _)| *s).fold(f64::INFINITY, f64::min);
347
348 let mut edge_positions: Vec<usize> = Vec::with_capacity(n);
350 let mut lo = 0usize;
351 let mut hi = count - 1;
352 while edge_positions.len() < n {
353 edge_positions.push(lo);
354 if lo != hi && edge_positions.len() < n {
355 edge_positions.push(hi);
356 }
357 lo += 1;
358 hi = hi.saturating_sub(1);
359 }
360
361 let edge_set: std::collections::HashSet<usize> = edge_positions.iter().copied().collect();
362
363 let mut fail_count = 0usize;
365 for &(score, idx) in top_n {
366 if score >= min_top_score && !edge_set.contains(&idx) {
368 fail_count += 1;
369 }
370 }
371
372 if fail_count > 0 {
373 let top_items: Vec<_> = top_n
374 .iter()
375 .map(|&(score, idx)| {
376 let kind = self.report.included[idx].item.kind().as_str().to_string();
377 format!("(kind={kind}, score={score:.6}, idx={idx})")
378 })
379 .collect();
380 let top_items_str = top_items.join(", ");
381 let edge_pos_str = edge_positions
382 .iter()
383 .map(|p| p.to_string())
384 .collect::<Vec<_>>()
385 .join(", ");
386 panic!(
387 "place_top_n_scored_at_edges({n}) failed: {fail_count} of the top-{n} scored items \
388 were not at expected edge positions. Top-{n} items (by score): [{top_items_str}]. \
389 Expected edge positions: [{edge_pos_str}].",
390 );
391 }
392 self
393 }
394}