Skip to main content

agentic_forge_core/metrics/
conservation.rs

1//! Conservation score computation and reporting.
2
3use super::audit::AuditLog;
4use super::tokens::TokenMetrics;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ConservationReport {
9    pub score: f64,
10    pub total_tokens: u64,
11    pub total_savings: u64,
12    pub cache_hit_rate: f64,
13    pub layer_breakdown: LayerBreakdown,
14    pub verdict: ConservationVerdict,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct LayerBreakdown {
19    pub cache_tokens: u64,
20    pub index_tokens: u64,
21    pub scoped_tokens: u64,
22    pub delta_tokens: u64,
23    pub full_tokens: u64,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum ConservationVerdict {
28    Excellent, // >= 0.8
29    Good,      // >= 0.6
30    Fair,      // >= 0.4
31    Poor,      // >= 0.2
32    Wasteful,  // < 0.2
33}
34
35impl ConservationVerdict {
36    pub fn from_score(score: f64) -> Self {
37        if score >= 0.8 {
38            Self::Excellent
39        } else if score >= 0.6 {
40            Self::Good
41        } else if score >= 0.4 {
42            Self::Fair
43        } else if score >= 0.2 {
44            Self::Poor
45        } else {
46            Self::Wasteful
47        }
48    }
49
50    pub fn name(&self) -> &'static str {
51        match self {
52            Self::Excellent => "excellent",
53            Self::Good => "good",
54            Self::Fair => "fair",
55            Self::Poor => "poor",
56            Self::Wasteful => "wasteful",
57        }
58    }
59}
60
61pub fn generate_report(metrics: &TokenMetrics, audit: &AuditLog) -> ConservationReport {
62    let score = metrics.conservation_score();
63    let total_tokens = metrics.total_tokens();
64    let total_savings = metrics.total_savings();
65    let cache_hit_rate = audit.cache_hit_rate();
66
67    use std::sync::atomic::Ordering;
68    let layer_breakdown = LayerBreakdown {
69        cache_tokens: metrics.layer0_cache.load(Ordering::Relaxed),
70        index_tokens: metrics.layer1_index.load(Ordering::Relaxed),
71        scoped_tokens: metrics.layer2_scoped.load(Ordering::Relaxed),
72        delta_tokens: metrics.layer3_delta.load(Ordering::Relaxed),
73        full_tokens: metrics.layer4_full.load(Ordering::Relaxed),
74    };
75
76    ConservationReport {
77        score,
78        total_tokens,
79        total_savings,
80        cache_hit_rate,
81        layer_breakdown,
82        verdict: ConservationVerdict::from_score(score),
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::super::tokens::Layer;
89    use super::*;
90
91    #[test]
92    fn test_verdict_from_score() {
93        assert_eq!(
94            ConservationVerdict::from_score(0.9),
95            ConservationVerdict::Excellent
96        );
97        assert_eq!(
98            ConservationVerdict::from_score(0.7),
99            ConservationVerdict::Good
100        );
101        assert_eq!(
102            ConservationVerdict::from_score(0.5),
103            ConservationVerdict::Fair
104        );
105        assert_eq!(
106            ConservationVerdict::from_score(0.3),
107            ConservationVerdict::Poor
108        );
109        assert_eq!(
110            ConservationVerdict::from_score(0.1),
111            ConservationVerdict::Wasteful
112        );
113    }
114
115    #[test]
116    fn test_generate_report() {
117        let metrics = TokenMetrics::new();
118        let audit = AuditLog::new(100);
119
120        // Simulate usage
121        metrics.record(Layer::Full, 500, 500);
122        metrics.record(Layer::Cache, 0, 500);
123        metrics.record(Layer::Scoped, 50, 500);
124
125        let report = generate_report(&metrics, &audit);
126        assert!(report.score > 0.0);
127        assert_eq!(report.total_tokens, 550);
128        assert!(report.total_savings > 0);
129    }
130
131    #[test]
132    fn test_report_with_warm_cache() {
133        let metrics = TokenMetrics::new();
134        let audit = AuditLog::new(100);
135
136        // Cold: 5 full queries
137        for _ in 0..5 {
138            metrics.record(Layer::Full, 500, 500);
139        }
140
141        // Warm: 15 cache hits
142        for _ in 0..15 {
143            metrics.record(Layer::Cache, 0, 500);
144        }
145
146        let report = generate_report(&metrics, &audit);
147        assert!(
148            report.score >= 0.7,
149            "Score should be >=0.7 with 75% cache hits: {}",
150            report.score
151        );
152        assert!(
153            report.verdict == ConservationVerdict::Good
154                || report.verdict == ConservationVerdict::Excellent
155        );
156    }
157
158    #[test]
159    fn test_conservation_after_warmup() {
160        let metrics = TokenMetrics::new();
161        let audit = AuditLog::new(100);
162
163        // Phase 1: All cold
164        for _ in 0..10 {
165            metrics.record(Layer::Full, 500, 500);
166        }
167        let cold_report = generate_report(&metrics, &audit);
168
169        // Phase 2: All cached
170        for _ in 0..10 {
171            metrics.record(Layer::Cache, 0, 500);
172        }
173        let warm_report = generate_report(&metrics, &audit);
174
175        assert!(
176            warm_report.score > cold_report.score,
177            "Warm score ({}) should exceed cold score ({})",
178            warm_report.score,
179            cold_report.score
180        );
181    }
182
183    #[test]
184    fn test_verdict_name() {
185        assert_eq!(ConservationVerdict::Excellent.name(), "excellent");
186        assert_eq!(ConservationVerdict::Wasteful.name(), "wasteful");
187    }
188}