Skip to main content

arkhe_kernel/state/
quota.rs

1//! `QuotaReductionPolicy` — what to do when a parent's quota
2//! reduction would drop below current child aggregate usage.
3//!
4//! Default `Reject` — explicit opt-in is required for destructive policies.
5//! `apply_quota_reduction` is the standalone deterministic algorithm;
6//! parent-child wiring at `Kernel::create_instance` time composes around it.
7
8use serde::{Deserialize, Serialize};
9
10use crate::abi::InstanceId;
11
12/// What to do when a parent's quota reduction would drop below the
13/// aggregate usage of its children. Default: [`Reject`](Self::Reject).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[non_exhaustive]
16pub enum QuotaReductionPolicy {
17    /// Reject the reduction request if `new_quota < sum(child_usage)`.
18    #[default]
19    Reject,
20    /// Existing usage retained as-is; the new quota applies only to
21    /// future allocations.
22    GrandfatherExisting,
23    /// Existing usage proportionally scaled down; deterministic
24    /// round-robin over `BTreeMap<InstanceId, _>` ascending order
25    /// distributes any remainder bytes.
26    ThrottleProportional,
27}
28
29/// Failure mode for [`apply_quota_reduction`] under
30/// [`QuotaReductionPolicy::Reject`].
31#[non_exhaustive]
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub enum QuotaReductionError {
34    /// `Reject` policy: the requested `new_quota` is strictly less than
35    /// the aggregate `current_usage` already attributed to children.
36    WouldViolateChildren {
37        /// Sum of `current_usage` across the children at the time of
38        /// the call.
39        current_usage: u64,
40        /// Requested quota that triggered the rejection.
41        new_quota: u64,
42    },
43}
44
45impl core::fmt::Display for QuotaReductionError {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        match self {
48            Self::WouldViolateChildren {
49                current_usage,
50                new_quota,
51            } => write!(
52                f,
53                "quota reduction would violate children: current_usage={}, new_quota={}",
54                current_usage, new_quota
55            ),
56        }
57    }
58}
59
60impl std::error::Error for QuotaReductionError {}
61
62/// Compute each child's new quota under `policy` against `new_quota`.
63///
64/// `children` must be sorted by `InstanceId` ascending (A23 canonical
65/// order); a `debug_assert!` checks this in debug builds. The returned
66/// vector preserves input order.
67///
68/// Algorithm by variant:
69/// - `Reject`: `Err(WouldViolateChildren)` when `sum(current) > new_quota`,
70///   otherwise return children unchanged.
71/// - `GrandfatherExisting`: return children unchanged unconditionally —
72///   the caller treats `new_quota - sum(current)` as the headroom for
73///   future allocations.
74/// - `ThrottleProportional`: each child gets
75///   `floor(current * new_quota / total)` (u128-promoted to avoid
76///   overflow); the floor remainder `new_quota - sum(scaled)` is
77///   distributed as +1 per child in ascending `InstanceId` order until
78///   exhausted (deterministic — A23). `total == 0` short-circuits to
79///   identity. This policy only ever scales *down*: when `new_quota >=
80///   current_total` the request is not a reduction, so children pass
81///   through unchanged (it never inflates a child above its current
82///   usage — mirroring the no-op posture of `Reject`/`GrandfatherExisting`
83///   on a non-reducing request).
84///
85/// Panic-free (A12): saturating arithmetic throughout.
86#[must_use = "policy result determines whether the reduction can proceed"]
87pub fn apply_quota_reduction(
88    policy: QuotaReductionPolicy,
89    new_quota: u64,
90    children: &[(InstanceId, u64)],
91) -> Result<Vec<(InstanceId, u64)>, QuotaReductionError> {
92    debug_assert!(
93        children.windows(2).all(|w| w[0].0 <= w[1].0),
94        "apply_quota_reduction: children must be sorted by InstanceId ascending"
95    );
96
97    let current_total: u64 = children
98        .iter()
99        .map(|(_, u)| *u)
100        .fold(0u64, u64::saturating_add);
101
102    match policy {
103        QuotaReductionPolicy::Reject => {
104            if current_total > new_quota {
105                Err(QuotaReductionError::WouldViolateChildren {
106                    current_usage: current_total,
107                    new_quota,
108                })
109            } else {
110                Ok(children.to_vec())
111            }
112        }
113        QuotaReductionPolicy::GrandfatherExisting => Ok(children.to_vec()),
114        QuotaReductionPolicy::ThrottleProportional => {
115            // Only scale down. A non-reducing request (`new_quota >=
116            // current_total`) passes children through unchanged rather than
117            // inflating each child's quota above its current usage.
118            if children.is_empty() || current_total == 0 || new_quota >= current_total {
119                return Ok(children.to_vec());
120            }
121            let total = current_total as u128;
122            let target = new_quota as u128;
123            let mut out: Vec<(InstanceId, u64)> = Vec::with_capacity(children.len());
124            let mut allocated: u128 = 0;
125            for (id, current) in children {
126                let scaled = (*current as u128).saturating_mul(target) / total;
127                let scaled_u64 = scaled.min(u64::MAX as u128) as u64;
128                allocated = allocated.saturating_add(scaled);
129                out.push((*id, scaled_u64));
130            }
131            let mut remainder = target.saturating_sub(allocated);
132            for entry in out.iter_mut() {
133                if remainder == 0 {
134                    break;
135                }
136                entry.1 = entry.1.saturating_add(1);
137                remainder -= 1;
138            }
139            Ok(out)
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn policy_default_is_reject() {
150        assert_eq!(
151            QuotaReductionPolicy::default(),
152            QuotaReductionPolicy::Reject
153        );
154    }
155
156    #[test]
157    fn policy_three_distinct_variants() {
158        let a = QuotaReductionPolicy::Reject;
159        let b = QuotaReductionPolicy::GrandfatherExisting;
160        let c = QuotaReductionPolicy::ThrottleProportional;
161        assert_ne!(a, b);
162        assert_ne!(b, c);
163        assert_ne!(a, c);
164    }
165
166    #[test]
167    fn error_display_includes_numbers() {
168        let e = QuotaReductionError::WouldViolateChildren {
169            current_usage: 100,
170            new_quota: 50,
171        };
172        let s = format!("{}", e);
173        assert!(s.contains("current_usage=100"));
174        assert!(s.contains("new_quota=50"));
175    }
176
177    #[test]
178    fn error_implements_std_error() {
179        fn assert_err<E: std::error::Error>() {}
180        assert_err::<QuotaReductionError>();
181    }
182
183    // ---- apply_quota_reduction ----
184
185    fn id(n: u64) -> InstanceId {
186        InstanceId::new(n).unwrap()
187    }
188
189    #[test]
190    fn reject_allows_under_quota() {
191        let cs = vec![(id(1), 25), (id(2), 25)];
192        let result = apply_quota_reduction(QuotaReductionPolicy::Reject, 100, &cs).unwrap();
193        assert_eq!(result, cs);
194    }
195
196    #[test]
197    fn reject_denies_over_quota() {
198        let cs = vec![(id(1), 100), (id(2), 100)];
199        let err = apply_quota_reduction(QuotaReductionPolicy::Reject, 100, &cs).unwrap_err();
200        assert_eq!(
201            err,
202            QuotaReductionError::WouldViolateChildren {
203                current_usage: 200,
204                new_quota: 100,
205            }
206        );
207    }
208
209    #[test]
210    fn reject_at_exact_limit_allows() {
211        let cs = vec![(id(1), 50), (id(2), 50)];
212        let result = apply_quota_reduction(QuotaReductionPolicy::Reject, 100, &cs).unwrap();
213        assert_eq!(result, cs);
214    }
215
216    #[test]
217    fn grandfather_returns_children_unchanged() {
218        let cs = vec![(id(1), 100), (id(2), 100)];
219        let result =
220            apply_quota_reduction(QuotaReductionPolicy::GrandfatherExisting, 100, &cs).unwrap();
221        assert_eq!(result, cs);
222    }
223
224    #[test]
225    fn throttle_basic() {
226        // 100/200/300 (total 600) scaled to new_quota 300 → exact halving.
227        let cs = vec![(id(1), 100), (id(2), 200), (id(3), 300)];
228        let result =
229            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 300, &cs).unwrap();
230        assert_eq!(result, vec![(id(1), 50), (id(2), 100), (id(3), 150)]);
231    }
232
233    #[test]
234    fn throttle_remainder_distributes_ascending() {
235        // 100/100/100 (total 300) scaled to 100: each floor = 33; sum = 99;
236        // remainder 1 lands on id=1 (lowest). Final: 34/33/33, sum = 100.
237        let cs = vec![(id(1), 100), (id(2), 100), (id(3), 100)];
238        let result =
239            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 100, &cs).unwrap();
240        assert_eq!(result, vec![(id(1), 34), (id(2), 33), (id(3), 33)]);
241        let sum: u64 = result.iter().map(|(_, q)| *q).sum();
242        assert_eq!(sum, 100);
243    }
244
245    #[test]
246    fn throttle_does_not_scale_up_when_quota_exceeds_total() {
247        // `new_quota > current_total` is not a reduction: children must pass
248        // through unchanged, never inflated above current usage. (Regression
249        // for the "scaled down" contract — previously this scaled UP.)
250        let cs = vec![(id(1), 100), (id(2), 200)];
251        let result =
252            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 600, &cs).unwrap();
253        assert_eq!(result, cs);
254    }
255
256    #[test]
257    fn throttle_at_exact_total_is_identity() {
258        // `new_quota == current_total`: floor(current * total / total) ==
259        // current already, and the guard short-circuits to identity.
260        let cs = vec![(id(1), 100), (id(2), 200)];
261        let result =
262            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 300, &cs).unwrap();
263        assert_eq!(result, cs);
264    }
265
266    #[test]
267    fn throttle_zero_total_idempotent() {
268        let cs = vec![(id(1), 0), (id(2), 0)];
269        let result =
270            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 100, &cs).unwrap();
271        assert_eq!(result, cs);
272    }
273
274    #[test]
275    fn throttle_deterministic() {
276        let cs = vec![(id(1), 100), (id(2), 100), (id(3), 100)];
277        let r1 =
278            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 100, &cs).unwrap();
279        let r2 =
280            apply_quota_reduction(QuotaReductionPolicy::ThrottleProportional, 100, &cs).unwrap();
281        assert_eq!(r1, r2);
282    }
283
284    #[test]
285    fn apply_to_empty_children_returns_empty() {
286        let cs: Vec<(InstanceId, u64)> = vec![];
287        for policy in [
288            QuotaReductionPolicy::Reject,
289            QuotaReductionPolicy::GrandfatherExisting,
290            QuotaReductionPolicy::ThrottleProportional,
291        ] {
292            let result = apply_quota_reduction(policy, 100, &cs).unwrap();
293            assert!(
294                result.is_empty(),
295                "policy {:?} should pass empty through",
296                policy
297            );
298        }
299    }
300}