kmp_domain/value_objects/
resolution_tier.rs1use crate::DomainError;
2use crate::value_objects::KmpMode;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
11pub enum ResolutionTier {
12 L0Summary,
13 L1CausalSpine,
14 L2EvidencePack,
15}
16
17impl ResolutionTier {
18 pub fn parse(value: &str) -> Result<Self, DomainError> {
19 match value.trim() {
20 "l0_summary" => Ok(Self::L0Summary),
21 "l1_causal_spine" => Ok(Self::L1CausalSpine),
22 "l2_evidence_pack" => Ok(Self::L2EvidencePack),
23 other => Err(DomainError::InvalidState(format!(
24 "invalid resolution tier `{other}`"
25 ))),
26 }
27 }
28
29 pub fn as_str(&self) -> &'static str {
30 match self {
31 Self::L0Summary => "l0_summary",
32 Self::L1CausalSpine => "l1_causal_spine",
33 Self::L2EvidencePack => "l2_evidence_pack",
34 }
35 }
36
37 pub fn all() -> &'static [Self] {
39 &[Self::L0Summary, Self::L1CausalSpine, Self::L2EvidencePack]
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct TierBudget {
50 pub l0: u32,
51 pub l1: u32,
52 pub l2: u32,
53}
54
55const L0_CEILING: u32 = 100;
56const L1_CEILING: u32 = 500;
57
58impl TierBudget {
59 pub fn from_total(total: u32) -> Self {
63 let l0 = total.min(L0_CEILING);
64 let remaining = total.saturating_sub(l0);
65 let l1 = remaining.min(L1_CEILING);
66 let l2 = remaining.saturating_sub(l1);
67 Self { l0, l1, l2 }
68 }
69
70 pub fn unlimited() -> Self {
72 Self {
73 l0: u32::MAX,
74 l1: u32::MAX,
75 l2: u32::MAX,
76 }
77 }
78
79 pub fn from_total_resume_focused(total: u32) -> Self {
84 let l0 = total.min(L0_CEILING);
85 let l1 = total.saturating_sub(l0);
86 Self { l0, l1, l2: 0 }
87 }
88
89 pub fn from_total_with_mode(total: u32, mode: KmpMode) -> Self {
91 match mode {
92 super::KmpMode::ResumeFocused => Self::from_total_resume_focused(total),
93 _ => Self::from_total(total),
94 }
95 }
96
97 pub fn total(&self) -> u32 {
98 self.l0.saturating_add(self.l1).saturating_add(self.l2)
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn parse_roundtrip() {
108 for tier in ResolutionTier::all() {
109 let parsed = ResolutionTier::parse(tier.as_str()).expect("valid tier");
110 assert_eq!(&parsed, tier);
111 }
112 }
113
114 #[test]
115 fn parse_invalid_returns_error() {
116 assert!(ResolutionTier::parse("invalid").is_err());
117 }
118
119 #[test]
120 fn ordering_matches_tier_priority() {
121 assert!(ResolutionTier::L0Summary < ResolutionTier::L1CausalSpine);
122 assert!(ResolutionTier::L1CausalSpine < ResolutionTier::L2EvidencePack);
123 }
124
125 #[test]
126 fn budget_from_total_distributes_correctly() {
127 let b = TierBudget::from_total(4096);
128 assert_eq!(b.l0, 100);
129 assert_eq!(b.l1, 500);
130 assert_eq!(b.l2, 3496);
131 assert_eq!(b.total(), 4096);
132 }
133
134 #[test]
135 fn budget_small_total_fills_l0_first() {
136 let b = TierBudget::from_total(50);
137 assert_eq!(b.l0, 50);
138 assert_eq!(b.l1, 0);
139 assert_eq!(b.l2, 0);
140 }
141
142 #[test]
143 fn budget_medium_total_fills_l0_and_partial_l1() {
144 let b = TierBudget::from_total(300);
145 assert_eq!(b.l0, 100);
146 assert_eq!(b.l1, 200);
147 assert_eq!(b.l2, 0);
148 }
149
150 #[test]
151 fn budget_zero() {
152 let b = TierBudget::from_total(0);
153 assert_eq!(b.l0, 0);
154 assert_eq!(b.l1, 0);
155 assert_eq!(b.l2, 0);
156 }
157
158 #[test]
159 fn unlimited_budget() {
160 let b = TierBudget::unlimited();
161 assert_eq!(b.l0, u32::MAX);
162 assert!(b.total() > 0);
163 }
164
165 #[test]
166 fn budget_resume_focused_gives_all_to_l1() {
167 let b = TierBudget::from_total_resume_focused(512);
168 assert_eq!(b.l0, 100);
169 assert_eq!(b.l1, 412);
170 assert_eq!(b.l2, 0);
171 assert_eq!(b.total(), 512);
172 }
173
174 #[test]
175 fn budget_with_mode_dispatches_correctly() {
176 let resume = TierBudget::from_total_with_mode(4096, KmpMode::ResumeFocused);
177 assert_eq!(resume.l2, 0, "resume_focused should give nothing to L2");
178
179 let default = TierBudget::from_total_with_mode(4096, KmpMode::ReasonPreserving);
180 assert!(default.l2 > 0, "reason_preserving should have L2 budget");
181 }
182}