Skip to main content

lean_ctx/core/context_kernel/
etpao_live.rs

1//! Live Effective Tokens Per Accepted Outcome (ETPAO) measurement.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::coverage_class::CoverageClass;
8
9/// Token usage and client metadata for one request.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RequestMetrics {
12    /// Tokens supplied as model input.
13    pub input_tokens: usize,
14    /// Tokens produced by the model.
15    pub output_tokens: usize,
16    /// Tokens consumed by model reasoning.
17    pub reasoning_tokens: usize,
18    /// Tokens consumed by tool or response schemas.
19    pub schema_tokens: usize,
20    /// Tokens written to the prompt cache.
21    pub cache_write_tokens: usize,
22    /// Number of retries associated with the request.
23    pub retry_count: usize,
24    /// Stable identifier for the originating client.
25    pub client_id: String,
26    /// Context-control coverage available for the request.
27    pub coverage_class: CoverageClass,
28}
29
30/// Observed result of one client request.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct OutcomeMetrics {
33    /// Whether the client accepted the result.
34    pub accepted: bool,
35    /// Normalized result quality reported by the evaluator.
36    pub quality_score: f64,
37    /// Whether the result succeeded without a retry.
38    pub first_pass: bool,
39    /// Stable identifier for the originating client.
40    pub client_id: String,
41}
42
43/// Aggregate snapshot of live ETPAO measurements.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct EtpaoSummary {
46    /// Tokens consumed per accepted outcome across all clients.
47    pub etpao: f64,
48    /// Total tokens consumed by all recorded requests.
49    pub total_tokens: usize,
50    /// Number of recorded accepted outcomes.
51    pub accepted_outcomes: usize,
52    /// Fraction of outcomes that succeeded on the first pass.
53    pub first_pass_rate: f64,
54    /// Percentage of request tokens attributable to retried requests.
55    pub retry_tax_pct: f64,
56    /// ETPAO grouped by the debug name of each coverage class.
57    pub by_coverage_class: HashMap<String, f64>,
58}
59
60/// In-memory collector for live request and outcome measurements.
61#[derive(Debug, Clone, Default)]
62pub struct EtpaoLive {
63    requests: Vec<RequestMetrics>,
64    outcomes: Vec<OutcomeMetrics>,
65}
66
67impl EtpaoLive {
68    /// Creates an empty live measurement collector.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Records token usage for one request.
74    pub fn record_request(&mut self, req: RequestMetrics) {
75        self.requests.push(req);
76    }
77
78    /// Records the evaluated outcome of one request.
79    pub fn record_outcome(&mut self, outcome: OutcomeMetrics) {
80        self.outcomes.push(outcome);
81    }
82
83    /// Returns aggregate tokens per accepted outcome.
84    pub fn current_etpao(&self) -> f64 {
85        let tokens = self
86            .requests
87            .iter()
88            .map(total_request_tokens)
89            .sum::<usize>();
90        tokens as f64 / self.accepted_outcomes().max(1) as f64
91    }
92
93    /// Returns tokens per accepted outcome for a client with recorded requests.
94    pub fn etpao_for_client(&self, client_id: &str) -> Option<f64> {
95        let mut requests = self
96            .requests
97            .iter()
98            .filter(|req| req.client_id == client_id);
99        let first = requests.next()?;
100        let tokens = requests.fold(total_request_tokens(first), |total, req| {
101            total.saturating_add(total_request_tokens(req))
102        });
103        let accepted = self
104            .outcomes
105            .iter()
106            .filter(|outcome| outcome.client_id == client_id && outcome.accepted)
107            .count();
108        Some(tokens as f64 / accepted.max(1) as f64)
109    }
110
111    /// Returns the fraction of tokens consumed by requests with retries.
112    pub fn retry_tax(&self) -> f64 {
113        let total = self
114            .requests
115            .iter()
116            .map(total_request_tokens)
117            .sum::<usize>();
118        if total == 0 {
119            return 0.0;
120        }
121        let retried = self
122            .requests
123            .iter()
124            .filter(|req| req.retry_count > 0)
125            .map(total_request_tokens)
126            .sum::<usize>();
127        retried as f64 / total as f64
128    }
129
130    /// Builds an aggregate serializable measurement snapshot.
131    pub fn summary(&self) -> EtpaoSummary {
132        let total_tokens = self.requests.iter().map(total_request_tokens).sum();
133        let accepted_outcomes = self.accepted_outcomes();
134        let first_pass_rate = if self.outcomes.is_empty() {
135            0.0
136        } else {
137            self.outcomes
138                .iter()
139                .filter(|outcome| outcome.first_pass)
140                .count() as f64
141                / self.outcomes.len() as f64
142        };
143        EtpaoSummary {
144            etpao: self.current_etpao(),
145            total_tokens,
146            accepted_outcomes,
147            first_pass_rate,
148            retry_tax_pct: self.retry_tax() * 100.0,
149            by_coverage_class: self.etpao_by_coverage_class(),
150        }
151    }
152
153    /// Returns the number of recorded requests.
154    pub fn request_count(&self) -> usize {
155        self.requests.len()
156    }
157
158    /// Returns the number of recorded outcomes.
159    pub fn outcome_count(&self) -> usize {
160        self.outcomes.len()
161    }
162
163    fn accepted_outcomes(&self) -> usize {
164        self.outcomes
165            .iter()
166            .filter(|outcome| outcome.accepted)
167            .count()
168    }
169
170    fn etpao_by_coverage_class(&self) -> HashMap<String, f64> {
171        let mut totals = HashMap::<CoverageClass, usize>::new();
172        for request in &self.requests {
173            let total = totals.entry(request.coverage_class).or_default();
174            *total = total.saturating_add(total_request_tokens(request));
175        }
176        totals
177            .into_iter()
178            .map(|(class, tokens)| {
179                let accepted = self
180                    .outcomes
181                    .iter()
182                    .filter(|outcome| {
183                        outcome.accepted
184                            && self.requests.iter().any(|request| {
185                                request.coverage_class == class
186                                    && request.client_id == outcome.client_id
187                            })
188                    })
189                    .count();
190                (format!("{class:?}"), tokens as f64 / accepted.max(1) as f64)
191            })
192            .collect()
193    }
194}
195
196/// Returns all token categories accounted for by one request.
197pub fn total_request_tokens(req: &RequestMetrics) -> usize {
198    req.input_tokens
199        .saturating_add(req.output_tokens)
200        .saturating_add(req.reasoning_tokens)
201        .saturating_add(req.schema_tokens)
202        .saturating_add(req.cache_write_tokens)
203}
204
205#[cfg(test)]
206#[rustfmt::skip]
207mod tests {
208    use super::{EtpaoLive, OutcomeMetrics, RequestMetrics};
209    use crate::core::context_kernel::coverage_class::CoverageClass;
210
211    fn request(client: &str, tokens: usize, class: CoverageClass) -> RequestMetrics {
212        RequestMetrics { input_tokens: tokens, output_tokens: 0, reasoning_tokens: 0,
213            schema_tokens: 0, cache_write_tokens: 0, retry_count: 0,
214            client_id: client.to_owned(), coverage_class: class }
215    }
216
217    fn outcome(client: &str, accepted: bool, first_pass: bool) -> OutcomeMetrics {
218        OutcomeMetrics { accepted, quality_score: 1.0, first_pass,
219            client_id: client.to_owned() }
220    }
221
222    #[test]
223    fn empty_etpao_is_zero() { assert_eq!(EtpaoLive::new().current_etpao(), 0.0); }
224
225    #[test]
226    fn single_request_single_outcome() { let mut live = EtpaoLive::new(); live.record_request(request("a", 1_000, CoverageClass::FullInline)); live.record_outcome(outcome("a", true, true)); assert_eq!(live.current_etpao(), 1_000.0); }
227
228    #[test]
229    fn retry_tax_computed() { let mut live = EtpaoLive::new(); for client in ["a", "b", "c"] { live.record_request(request(client, 100, CoverageClass::ContextControlled)); } live.requests[1].retry_count = 1; assert!((live.retry_tax() - 1.0 / 3.0).abs() < f64::EPSILON); }
230
231    #[test]
232    fn etpao_for_client_filters() { let mut live = EtpaoLive::new(); live.record_request(request("a", 100, CoverageClass::FullInline)); live.record_request(request("b", 250, CoverageClass::FullInline)); live.record_outcome(outcome("a", true, true)); live.record_outcome(outcome("b", true, true)); assert_eq!(live.etpao_for_client("a"), Some(100.0)); assert_eq!(live.etpao_for_client("b"), Some(250.0)); assert_eq!(live.etpao_for_client("missing"), None); }
233
234    #[test]
235    fn summary_has_all_fields() { let mut live = EtpaoLive::new(); live.record_request(request("a", 100, CoverageClass::FullInline)); live.record_outcome(outcome("a", true, true)); let summary = live.summary(); assert_eq!((summary.etpao, summary.total_tokens), (100.0, 100)); assert_eq!((summary.accepted_outcomes, summary.first_pass_rate), (1, 1.0)); assert_eq!(summary.retry_tax_pct, 0.0); assert_eq!((live.request_count(), live.outcome_count()), (1, 1)); }
236
237    #[test]
238    fn by_coverage_class_separation() { let mut live = EtpaoLive::new(); live.record_request(request("inline", 100, CoverageClass::FullInline)); live.record_request(request("controlled", 300, CoverageClass::ContextControlled)); live.record_outcome(outcome("inline", true, true)); live.record_outcome(outcome("controlled", true, true)); let by_class = live.summary().by_coverage_class; assert_eq!(by_class.get("FullInline"), Some(&100.0)); assert_eq!(by_class.get("ContextControlled"), Some(&300.0)); }
239
240    #[test]
241    fn first_pass_rate_correct() { let mut live = EtpaoLive::new(); for index in 0..10 { live.record_outcome(outcome("a", true, index < 7)); } assert_eq!(live.summary().first_pass_rate, 0.7); }
242}