scc_context/selector.rs
1//! Token-budget selection over ranked context candidates (Wave 14B/15.1).
2//!
3//! Four deterministic, no-panic selectors:
4//! - [`select_with_budget`]: greedy value/token knapsack over
5//! [`ContextItem`]s with a soft budget + hard maximum; required items
6//! are always kept (the caller compresses them when they alone exceed
7//! the hard max).
8//! - [`select_in_order`]: the `optimizer`-off variant — rank-order cut,
9//! no value/token reordering.
10//! - [`mmr_diversify`]: Maximal Marginal Relevance — penalizes candidates
11//! similar to already-selected ones so one component/owner group cannot
12//! crowd out the rest.
13//! - [`enforce_quotas`]: TOKEN-aware per-kind caps (fraction × available
14//! tokens, adapted to the pool's running average token cost) over a
15//! ranked list, preserving rank order.
16
17use scc_core::ContextItem;
18use std::collections::HashMap;
19
20/// Select indices into `items` within a token `budget`. Required items come
21/// first and are never dropped (even when they alone exceed the budget —
22/// required context is never silently cut); the remaining budget is filled
23/// greedily by value/token, highest first. Deterministic: ties break on
24/// index order.
25///
26/// `hard_max` is the absolute token ceiling (soft target + 20% by default):
27/// required items may push the total over `budget` but the caller
28/// structurally compresses them when they alone exceed `hard_max` (the
29/// item costs then already reflect the compressed render); pool items are
30/// only added while the total stays within both the soft budget and the
31/// hard maximum.
32// trace:v1 id=impl.scc.selector work=WORK-SCC-015 satisfies=REQ-SCC-IR
33pub fn select_with_budget(items: &[ContextItem], budget: usize, hard_max: usize) -> Vec<usize> {
34 let mut selected: Vec<usize> = Vec::new();
35 let mut spent: usize = 0;
36 for (i, item) in items.iter().enumerate() {
37 if item.required {
38 selected.push(i);
39 spent = spent.saturating_add(item.token_cost);
40 }
41 }
42 let mut rest: Vec<usize> = (0..items.len()).filter(|i| !items[*i].required).collect();
43 rest.sort_by(|a, b| {
44 let va = items[*a].value / items[*a].token_cost.max(1) as f64;
45 let vb = items[*b].value / items[*b].token_cost.max(1) as f64;
46 vb.partial_cmp(&va)
47 .unwrap_or(std::cmp::Ordering::Equal)
48 .then_with(|| a.cmp(b))
49 });
50 for i in rest {
51 let cost = items[i].token_cost;
52 let next = spent.saturating_add(cost);
53 if next <= budget && next <= hard_max {
54 selected.push(i);
55 spent = next;
56 }
57 }
58 selected
59}
60
61/// Budget selection in list order (the `optimizer`-off ablation of
62/// [`select_with_budget`]): walk items in importance order, keep required
63/// items unconditionally, and accept pool items while the total token
64/// spend stays within both the soft `budget` and the `hard_max` ceiling.
65/// No value/token reordering — a candidate's rank order decides.
66// trace:v1 id=impl.scc.selector.select-in-order work=WORK-SCC-015 satisfies=REQ-SCC-IR
67pub fn select_in_order(items: &[ContextItem], budget: usize, hard_max: usize) -> Vec<usize> {
68 let mut selected: Vec<usize> = Vec::new();
69 let mut spent: usize = 0;
70 for (i, item) in items.iter().enumerate() {
71 if item.required {
72 selected.push(i);
73 spent = spent.saturating_add(item.token_cost);
74 continue;
75 }
76 let next = spent.saturating_add(item.token_cost);
77 if next <= budget && next <= hard_max {
78 selected.push(i);
79 spent = next;
80 }
81 }
82 selected
83}
84
85/// Maximal Marginal Relevance selection: repeatedly pick the candidate
86/// maximizing `lambda * score - (1 - lambda) * max_similarity(selected)`,
87/// up to `budget` items. `lambda` is clamped to [0, 1]; 0.5 is the default
88/// (see [`mmr_diversify_default`]). Deterministic: ties keep the earlier
89/// candidate in `ranked` order.
90// trace:exempt reason=internal-detail
91pub fn mmr_diversify(
92 ranked: &[(String, f64)],
93 similarity: impl Fn(&str, &str) -> f64,
94 lambda: f64,
95 budget: usize,
96) -> Vec<String> {
97 let n = ranked.len();
98 let budget = budget.min(n);
99 let lambda = lambda.clamp(0.0, 1.0);
100 let mut selected: Vec<usize> = Vec::with_capacity(budget);
101 let mut picked = vec![false; n];
102 // Incremental max-similarity (profiler receipt 2026-09-11: the naive
103 // re-scan over all selected per candidate was ~98% of the surface
104 // render). max() is commutative, so carrying each candidate's running
105 // max forward and comparing only against the newly selected item
106 // yields bit-identical values — and therefore identical picks — in
107 // O(budget*n) similarity calls instead of O(budget*n*selected).
108 let mut max_sim = vec![0.0_f64; n];
109 while selected.len() < budget {
110 if let Some(&last) = selected.last() {
111 for i in 0..n {
112 if picked[i] {
113 continue;
114 }
115 let s = similarity(&ranked[i].0, &ranked[last].0);
116 if s > max_sim[i] {
117 max_sim[i] = s;
118 }
119 }
120 }
121 let mut best: Option<(usize, f64)> = None;
122 for i in 0..n {
123 if picked[i] {
124 continue;
125 }
126 let v = lambda * ranked[i].1 - (1.0 - lambda) * max_sim[i];
127 if best.is_none_or(|(_, bv)| v > bv) {
128 best = Some((i, v));
129 }
130 }
131 match best {
132 Some((i, _)) => {
133 selected.push(i);
134 picked[i] = true;
135 }
136 None => break,
137 }
138 }
139 selected.into_iter().map(|i| ranked[i].0.clone()).collect()
140}
141
142/// [`mmr_diversify`] with the spec's default `lambda = 0.5`.
143// trace:exempt reason=internal-detail
144pub fn mmr_diversify_default(
145 ranked: &[(String, f64)],
146 similarity: impl Fn(&str, &str) -> f64,
147 budget: usize,
148) -> Vec<String> {
149 mmr_diversify(ranked, similarity, 0.5, budget)
150}
151
152/// Enforce per-kind quotas over a ranked list, preserving rank order —
153/// TOKEN-aware (reviewer item 6): each kind's cap is a token budget
154/// `fraction * available_tokens`; a candidate is accepted only while
155/// accepting it would not push its kind's accumulated spend over that
156/// cap. Because the cap is a token budget applied over actual per-
157/// candidate costs, the effective entry allowance adapts to the selected
158/// pool's running average token cost (expensive candidates exhaust a
159/// kind's allocation faster, cheap ones admit more entries). A candidate
160/// that would exceed its group cap is skipped — the next candidate of
161/// another group takes its place, so the remaining groups rebalance.
162///
163/// This implements the spec's global surface budget — 30% public/
164/// entrypoint, 25% core impl, 15% types/interfaces, 10% state owners,
165/// 10% contract APIs, 10% flow-critical (quota keys `public`, `core`,
166/// `types`, `state`, `contract`, `flow`) — scaled to the tokens actually
167/// available to the pool (`available_tokens` = budget minus required
168/// spend; required entries are partitioned out before this stage and may
169/// exceed their group allocation).
170// trace:exempt reason=internal-detail
171pub fn enforce_quotas(
172 ranked: &[(String, f64)],
173 kind_of: impl Fn(&str) -> &str,
174 quotas: &[(String, f64)],
175 available_tokens: usize,
176 token_cost: impl Fn(&str) -> usize,
177) -> Vec<String> {
178 let mut caps: HashMap<&str, usize> = HashMap::new();
179 for (kind, frac) in quotas {
180 let cap = (frac.clamp(0.0, 1.0) * available_tokens as f64).round() as usize;
181 caps.insert(kind.as_str(), cap);
182 }
183 let mut spent: HashMap<&str, usize> = HashMap::new();
184 let mut out: Vec<String> = Vec::new();
185 for (id, _) in ranked {
186 let k = kind_of(id);
187 let take = match caps.get(k) {
188 None => true,
189 Some(&cap) => {
190 let s = spent.entry(k).or_insert(0);
191 let next = s.saturating_add(token_cost(id));
192 if next <= cap {
193 *s = next;
194 true
195 } else {
196 false
197 }
198 }
199 };
200 if take {
201 out.push(id.clone());
202 }
203 }
204 out
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211// trace:exempt reason=internal-detail
212 fn item(id: &str, value: f64, token_cost: usize, required: bool) -> ContextItem {
213 ContextItem {
214 id: id.to_string(),
215 value,
216 token_cost,
217 required,
218 group: None,
219 }
220 }
221
222 // ---- (g) budget selection: drops low-value, never drops required ----
223
224 #[test]
225// trace:exempt reason=internal-detail
226 fn budget_selection_drops_low_value_and_keeps_required() {
227 let items = vec![
228 item("required", 0.1, 120, true),
229 item("high", 0.9, 50, false),
230 item("mid", 0.5, 50, false),
231 item("low", 0.1, 50, false),
232 ];
233 // Budget 170: required (120) + highest value/token (high, 50).
234 let sel = select_with_budget(&items, 170, 204);
235 assert_eq!(sel, vec![0, 1]);
236 // Mid/low are dropped; required is present.
237 assert!(sel.contains(&0));
238 assert!(!sel.contains(&2) && !sel.contains(&3));
239
240 // Budget exhausted by required alone: still never drops required.
241 let sel = select_with_budget(&items, 50, 204);
242 assert_eq!(sel, vec![0]);
243
244 // Budget 0: only required.
245 let sel = select_with_budget(&items, 0, 204);
246 assert_eq!(sel, vec![0]);
247
248 // Empty input.
249 assert!(select_with_budget(&[], 100, 100).is_empty());
250
251 // No required: pure value/token greedy.
252 let no_req = vec![item("a", 0.1, 100, false), item("b", 0.9, 10, false)];
253 let sel = select_with_budget(&no_req, 100, 100);
254 assert_eq!(sel, vec![1]);
255
256 // Hard max never drops required — a ceiling below the required
257 // spend still selects it (the caller compresses the render
258 // instead); pool items must fit the soft budget AND the hard max.
259 let sel = select_with_budget(&items, 50, 60);
260 assert_eq!(sel, vec![0]);
261 let sel = select_with_budget(&items, 10, 5);
262 assert_eq!(sel, vec![0]);
263 }
264
265 // ---- optimizer-off: rank-order selection ----
266
267 #[test]
268// trace:exempt reason=internal-detail
269 fn select_in_order_keeps_rank_order() {
270 let items = vec![
271 item("required", 0.1, 120, true),
272 item("high", 0.9, 50, false),
273 item("mid", 0.5, 50, false),
274 item("low", 0.1, 50, false),
275 ];
276 // Rank-order cut: required first, then items in list order while
277 // the total fits — no value/token reordering.
278 let sel = select_in_order(&items, 170, 204);
279 assert_eq!(sel, vec![0, 1]);
280 // One token less: required alone; mid/low are cut by budget.
281 let sel = select_in_order(&items, 169, 204);
282 assert_eq!(sel, vec![0]);
283 // Required is never dropped, even at a zero budget.
284 let sel = select_in_order(&items, 0, 204);
285 assert_eq!(sel, vec![0]);
286 }
287
288 // ---- (e) MMR diversity: same-owner DTOs are capped ----
289
290 #[test]
291// trace:exempt reason=internal-detail
292 fn mmr_caps_same_owner_dtos() {
293 let ranked: Vec<(String, f64)> = (0..5)
294 .map(|i| (format!("Order.dto{i}"), 0.9 - 0.1 * i as f64))
295 .collect();
296 // All five share the same owner group → similarity 1.0 between any
297 // pair. With a small budget, at most 2 are selected.
298 let same_owner = |a: &str, b: &str| -> f64 {
299 if a.starts_with("Order.") && b.starts_with("Order.") {
300 1.0
301 } else {
302 0.0
303 }
304 };
305 let sel = mmr_diversify_default(&ranked, same_owner, 2);
306 assert_eq!(sel.len(), 2);
307 // The two highest-scoring survive (identical penalty).
308 assert_eq!(sel[0], "Order.dto0");
309 assert_eq!(sel[1], "Order.dto1");
310
311 // Budget larger than the list returns everything.
312 let all = mmr_diversify_default(&ranked, same_owner, 10);
313 assert_eq!(all.len(), 5);
314 }
315
316 #[test]
317// trace:exempt reason=internal-detail
318 fn mmr_keeps_diverse_items() {
319 let ranked = vec![
320 ("a".to_string(), 0.9),
321 ("b".to_string(), 0.8),
322 ("c".to_string(), 0.7),
323 ];
324 // Distinct groups → similarity 0 → pure score order.
325 let distinct = |_: &str, _: &str| 0.0;
326 let sel = mmr_diversify_default(&ranked, distinct, 3);
327 assert_eq!(sel, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
328
329 // Lambda 0 = pure diversity: second pick is the least similar to
330 // the first (score order with all-zero similarity ties breaks to
331 // the earliest).
332 let sel = mmr_diversify(&ranked, distinct, 0.0, 3);
333 assert_eq!(sel.len(), 3);
334
335 // Empty ranked / zero budget.
336 assert!(mmr_diversify_default(&[], distinct, 5).is_empty());
337 assert!(mmr_diversify_default(&ranked, distinct, 0).is_empty());
338 }
339
340 // ---- (f) token-aware quota enforcement ----
341
342 #[test]
343// trace:exempt reason=internal-detail
344 fn quotas_are_token_aware() {
345 // One kind dominates the candidate pool (100 of 140 candidates);
346 // caps derive from available TOKENS, not candidate counts.
347 let mut ranked: Vec<(String, f64)> = Vec::new();
348 for i in 0..100 {
349 ranked.push((format!("pub:{i}"), 1.0 - i as f64 / 200.0));
350 }
351 for i in 0..20 {
352 ranked.push((format!("core:{i}"), 1.0 - i as f64 / 200.0));
353 }
354 for i in 0..20 {
355 ranked.push((format!("types:{i}"), 1.0 - i as f64 / 200.0));
356 }
357
358 fn kind_of(id: &str) -> &str {
359 if id.starts_with("pub:") {
360 "public"
361 } else if id.starts_with("core:") {
362 "core"
363 } else {
364 "types"
365 }
366 }
367 fn other_kind(_: &str) -> &str {
368 "other"
369 }
370 let quotas = vec![
371 ("public".to_string(), 0.50),
372 ("core".to_string(), 0.25),
373 ("types".to_string(), 0.25),
374 ];
375 // 1400 tokens available: caps 700/350/350. At 10 tokens each the
376 // dominant kind is capped at 70 entries (not its 100-candidate
377 // count share); the under-represented kinds keep all 20 entries.
378 let cost10 = |_: &str| 10usize;
379 let sel = enforce_quotas(&ranked, kind_of, "as, 1400, cost10);
380 let count = |k: &str| sel.iter().filter(|id| kind_of(id.as_str()) == k).count();
381 assert_eq!(count("public"), 70);
382 assert_eq!(count("core"), 20);
383 assert_eq!(count("types"), 20);
384 // Rank order is preserved: the first accepted per kind is the
385 // first candidate of that kind in the ranked list.
386 assert_eq!(sel[0], "pub:0");
387 assert_eq!(sel[70], "core:0");
388
389 // Expensive candidates exhaust a kind's allocation faster: at 50
390 // tokens each the public cap (700) admits 14 entries, not 70.
391 let cost50 = |_: &str| 50usize;
392 let sel50 = enforce_quotas(&ranked, kind_of, "as, 1400, cost50);
393 assert_eq!(
394 sel50.iter().filter(|id| kind_of(id.as_str()) == "public").count(),
395 14
396 );
397
398 // A capped-out dominant kind rebalances to the next group: the
399 // first core candidate follows the last accepted public one.
400 let sel = enforce_quotas(&ranked, kind_of, "as, 1400, cost10);
401 let core_first = sel.iter().position(|id| kind_of(id.as_str()) == "core").unwrap();
402 assert_eq!(sel[core_first], "core:0");
403
404 // Kinds without a quota entry are uncapped.
405 let sel_other = enforce_quotas(
406 &[("x".to_string(), 0.5), ("y".to_string(), 0.5)],
407 other_kind,
408 &[],
409 100,
410 |_: &str| 10,
411 );
412 assert_eq!(sel_other.len(), 2);
413 }
414}