Skip to main content

scc_context/
budget.rs

1//! Token-budget allocators for context packs.
2//!
3//! Production task packs still drop lowest-priority sections
4//! ([`crate::packs`] adaptive path). This module is the inspectable
5//! fixed-percent + rollover alternative learned from Ripwire's packer:
6//! unused quota moves forward; over-quota buckets are truncated and
7//! disclosed. Do not switch the production default without ablation.
8
9/// Atlas / Surface / Structural-exact / Call-flow / Verify / Uncertainty.
10pub const TASK_CONTEXT_QUOTA_PERCENTS: [u8; 6] = [20, 25, 30, 10, 10, 5];
11
12/// Bucket names aligned with SCC's four context levels plus verify/uncertainty.
13pub const TASK_CONTEXT_QUOTA_NAMES: [&str; 6] = [
14    "atlas",
15    "surface",
16    "source",
17    "flow",
18    "verify",
19    "uncertainty",
20];
21
22/// One filled bucket after rollover.
23#[derive(Debug, Clone, PartialEq, Eq)]
24// trace:exempt reason=internal-detail
25pub struct FilledBucket {
26    pub name: &'static str,
27    pub percent: u8,
28    /// Quota after receiving unused tokens from previous buckets.
29    pub quota: usize,
30    /// Tokens actually granted (min(used, quota)).
31    pub granted: usize,
32    /// How many requested tokens did not fit.
33    pub truncated: usize,
34    /// Unused quota rolled to the next bucket (0 on the last).
35    pub rolled_forward: usize,
36}
37
38/// Split `total` into percents, then fill each bucket from `used`, rolling
39/// unused quota forward. `used.len()` must equal the percent table.
40/// Last bucket absorbs leftover from integer division of percents.
41// trace:v1 id=impl.scc.context.budget-rollover work=WORK-ripwire-lessons-phase3 satisfies=REQ-budget-rollover
42pub fn fill_with_rollover(total: usize, percents: &[u8], names: &[&'static str], used: &[usize]) -> Vec<FilledBucket> {
43    assert_eq!(percents.len(), used.len());
44    assert_eq!(percents.len(), names.len());
45    let sum: u16 = percents.iter().map(|p| *p as u16).sum();
46    assert_eq!(sum, 100, "quota percents must sum to 100");
47
48    let mut leftover = 0usize;
49    let mut granted_so_far = 0usize;
50    let mut out = Vec::with_capacity(percents.len());
51    for (i, (p, u)) in percents.iter().zip(used.iter()).enumerate() {
52        let base = if i + 1 == percents.len() {
53            total.saturating_sub(granted_so_far + leftover)
54                .saturating_add(leftover)
55        } else {
56            total * (*p as usize) / 100 + leftover
57        };
58        let take = (*u).min(base);
59        let truncated = u.saturating_sub(base);
60        let rolled = base.saturating_sub(*u);
61        granted_so_far += take;
62        leftover = rolled;
63        out.push(FilledBucket {
64            name: names[i],
65            percent: *p,
66            quota: base,
67            granted: take,
68            truncated,
69            rolled_forward: if i + 1 == percents.len() { 0 } else { rolled },
70        });
71    }
72    out
73}
74
75/// Convenience: SCC task-context split.
76// trace:exempt reason=internal-detail
77pub fn fill_task_context_quotas(total: usize, used: &[usize; 6]) -> Vec<FilledBucket> {
78    fill_with_rollover(
79        total,
80        &TASK_CONTEXT_QUOTA_PERCENTS,
81        &TASK_CONTEXT_QUOTA_NAMES,
82        used,
83    )
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    // trace:v1 id=test.scc.context.budget-percents verifies=REQ-budget-rollover exercises=impl.scc.context.budget-rollover
92    fn percents_sum_to_one_hundred() {
93        let sum: u16 = TASK_CONTEXT_QUOTA_PERCENTS.iter().map(|p| *p as u16).sum();
94        assert_eq!(sum, 100);
95        assert_eq!(TASK_CONTEXT_QUOTA_NAMES.len(), TASK_CONTEXT_QUOTA_PERCENTS.len());
96    }
97
98    #[test]
99    // trace:v1 id=test.scc.context.budget-rollover verifies=REQ-budget-rollover exercises=impl.scc.context.budget-rollover
100    fn unused_quota_rolls_forward_and_truncation_is_disclosed() {
101        let filled = fill_with_rollover(
102            100,
103            &[40, 30, 30],
104            &["a", "b", "c"],
105            &[10, 50, 10],
106        );
107        assert_eq!(filled[0].granted, 10);
108        assert_eq!(filled[0].truncated, 0);
109        assert_eq!(filled[0].rolled_forward, 30);
110        assert_eq!(filled[1].quota, 60, "30% + 30 rolled");
111        assert_eq!(filled[1].granted, 50);
112        assert_eq!(filled[1].truncated, 0);
113        assert_eq!(filled[1].rolled_forward, 10);
114        assert_eq!(filled[2].granted, 10);
115        assert_eq!(filled[2].truncated, 0);
116        let over = fill_with_rollover(100, &[50, 50], &["x", "y"], &[80, 80]);
117        assert_eq!(over[0].granted, 50);
118        assert_eq!(over[0].truncated, 30);
119        assert_eq!(over[1].granted, 50);
120        assert_eq!(over[1].truncated, 30);
121        let task = fill_task_context_quotas(1000, &[0, 0, 0, 0, 0, 0]);
122        assert_eq!(task.iter().map(|b| b.granted).sum::<usize>(), 0);
123        assert!(task.iter().any(|b| b.name == "verify"));
124    }
125}