Skip to main content

kmp_domain/value_objects/
resolution_tier.rs

1use crate::DomainError;
2use crate::value_objects::KmpMode;
3
4/// Resolution tier for multi-resolution bundle rendering.
5///
6/// Bundles are assembled in three tiers of decreasing criticality:
7/// - L0: compact summary (~100 tokens) — always fits
8/// - L1: causal spine (~500 tokens) — root + focus + causal chain
9/// - L2: evidence pack — remaining budget fills with details and structural data
10#[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    /// All tiers in rendering order.
38    pub fn all() -> &'static [Self] {
39        &[Self::L0Summary, Self::L1CausalSpine, Self::L2EvidencePack]
40    }
41}
42
43/// Per-tier token budget allocation.
44///
45/// L0 and L1 get fixed ceilings; L2 gets the remainder.
46/// If the total budget is smaller than L0+L1 ceilings, tiers
47/// are filled in order until the budget is exhausted.
48#[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    /// Distribute a total token budget across tiers.
60    ///
61    /// L0 gets up to 100 tokens, L1 up to 500, L2 gets the rest.
62    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    /// Unlimited budget — no tier is constrained.
71    pub fn unlimited() -> Self {
72        Self {
73            l0: u32::MAX,
74            l1: u32::MAX,
75            l2: u32::MAX,
76        }
77    }
78
79    /// Resume-focused budget: L0 gets ceiling, L1 gets ALL remaining, L2 gets 0.
80    ///
81    /// This lets the causal spine use the full token budget minus the compact summary,
82    /// dropping all evidence/structural content to maximize causal chain preservation.
83    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    /// Distribute budget using mode-specific strategy.
90    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}