Skip to main content

tokenfold_core/
report.rs

1use serde::{Deserialize, Serialize};
2
3use crate::status::Status;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub struct CompressionReport {
7    pub schema_version: String,
8    pub original_tokens: usize,
9    pub compressed_tokens: usize,
10    pub saved_tokens: usize,
11    pub savings_ratio: f64, // fraction: 0.353
12    pub savings_pct: f64,   // positive percent: 35.3
13    pub estimator: EstimatorInfo,
14    pub status: Status,
15    pub mode: String,
16    pub format: String,
17    pub task_scope: String,
18    pub request_id: Option<String>,
19    pub quality: Option<QualityReport>,
20    pub budget: Option<BudgetReport>,
21    pub cache: Option<CacheReport>,
22    pub retrieval: Option<RetrievalReport>,
23    pub output_savings: Option<OutputSavingsReport>,
24    pub bypass: Option<BypassReport>,
25    pub command: Option<CommandReport>,
26    pub ledger: Option<LedgerReport>,
27    pub transforms: Vec<TransformReport>,
28    pub warnings: Vec<Warning>,
29}
30
31impl CompressionReport {
32    #[allow(clippy::too_many_arguments)]
33    pub fn new(
34        original_tokens: usize,
35        compressed_tokens: usize,
36        estimator: EstimatorInfo,
37        status: Status,
38        mode: String,
39        format: String,
40        task_scope: String,
41        transforms: Vec<TransformReport>,
42        warnings: Vec<Warning>,
43    ) -> Self {
44        let saved_tokens = original_tokens.saturating_sub(compressed_tokens);
45        let savings_ratio = if original_tokens == 0 {
46            0.0
47        } else {
48            saved_tokens as f64 / original_tokens as f64
49        };
50        let savings_pct = savings_ratio * 100.0;
51        Self {
52            schema_version: "1.0".to_string(),
53            original_tokens,
54            compressed_tokens,
55            saved_tokens,
56            savings_ratio,
57            savings_pct,
58            estimator,
59            status,
60            mode,
61            format,
62            task_scope,
63            request_id: None,
64            quality: None,
65            budget: None,
66            cache: None,
67            retrieval: None,
68            output_savings: None,
69            bypass: None,
70            command: None,
71            ledger: None,
72            transforms,
73            warnings,
74        }
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct EstimatorInfo {
80    pub backend: String,
81    pub model: Option<String>,
82    pub is_exact: bool,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct BudgetReport {
87    pub target_tokens: Option<usize>,
88    pub protected_floor: usize,
89    pub achieved_tokens: usize,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct QualityReport {
94    pub eval_profile_id: String,
95    pub task_scope: String,
96    pub validated_ratio_band: Option<String>,
97    pub quality_retention: f64,
98    pub contrastive_failure_rate: f64,
99    pub gate_passed: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct TransformReport {
104    pub id: String,
105    pub version: String,
106    pub tokens_before: usize,
107    pub tokens_after: usize,
108    pub saved_tokens: usize,
109    pub savings_ratio: f64,
110    pub elapsed_micros: Option<u64>,
111    pub status: TransformStatus,
112    pub skipped_reason: Option<SkippedReason>,
113    pub warnings: Vec<Warning>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum TransformStatus {
119    Applied,
120    NoOp,
121    Skipped,
122    RolledBack,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "snake_case")]
127pub enum SkippedReason {
128    TargetAlreadyMet,
129    NotApplicableToFormat,
130    NotEnabledInMode,
131    ExperimentalFlagRequired,
132    DisabledByUser,
133    WouldIncreaseTokens,
134    FilterUntrusted,
135    FilterFailedVerify,
136    BypassEnvSet,
137    UnsupportedCommandShape,
138    PipeOrHeredocNotRewritten,
139    BinaryOutputDetected,
140    UnsafeCommandPassthrough,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct Warning {
145    pub code: WarningCode,
146    pub severity: Severity,
147    pub transform: Option<String>,
148    pub message: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum Severity {
154    Info,
155    Warn,
156    Critical,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[serde(rename_all = "snake_case")]
161pub enum WarningCode {
162    UnreachableTarget,
163    UnredactedContentPossible,
164    SafetyDowngrade,
165    SecurityFieldAltered,
166    HeuristicBudgetUsed,
167    PrefixModified,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct CacheReport {
172    pub boundary_kind: Option<String>,
173    pub protected_bytes: usize,
174    pub prefix_byte_identical: bool,
175    pub warnings: Vec<Warning>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179pub struct RetrievalReport {
180    pub store_namespace: String,
181    pub hash_algorithm: String,
182    pub marker_count: usize,
183    pub ttl_seconds: Option<u64>,
184    pub persisted_original_bytes: usize,
185    pub skipped_original_bytes: usize,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189pub struct OutputSavingsReport {
190    pub profile: String,
191    pub estimated_output_tokens_saved: Option<usize>,
192    pub measured_output_tokens_saved: Option<usize>,
193    pub provenance: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
197pub struct BypassReport {
198    pub reason: String,
199    pub source: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct CommandReport {
204    pub command_family: Option<String>,
205    pub child_exit_code: Option<i32>,
206    pub duration_ms: u64,
207    pub raw_output_bytes: usize,
208    pub stdout_bytes: usize,
209    pub stderr_bytes: usize,
210    pub stderr_mode: String,
211    pub stderr_truncated: bool,
212    pub compressed_output_bytes: usize,
213    pub filter_pack_id: Option<String>,
214    pub filter_version: Option<String>,
215    pub never_worse_applied: bool,
216    pub bypass_reason: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
220pub struct LedgerReport {
221    pub recorded: bool,
222    pub scope: Option<String>,
223    pub project_hash: Option<String>,
224    pub record_id: Option<String>,
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn heuristic_estimator() -> EstimatorInfo {
232        EstimatorInfo {
233            backend: "heuristic".to_string(),
234            model: None,
235            is_exact: false,
236        }
237    }
238
239    #[test]
240    fn saved_tokens_and_ratio_are_derived_correctly() {
241        let report = CompressionReport::new(
242            18_400,
243            11_900,
244            heuristic_estimator(),
245            Status::Compressed,
246            "balanced".to_string(),
247            "plain_text".to_string(),
248            "general".to_string(),
249            vec![],
250            vec![],
251        );
252        assert_eq!(report.saved_tokens, 6_500);
253        assert!((report.savings_ratio - 0.353_260_869_565_217_4).abs() < f64::EPSILON * 10.0);
254        assert!((report.savings_pct - 35.326_086_956_521_74).abs() < 1e-9);
255        assert_eq!(report.schema_version, "1.0");
256    }
257
258    #[test]
259    fn zero_original_tokens_never_divides_by_zero() {
260        let report = CompressionReport::new(
261            0,
262            0,
263            heuristic_estimator(),
264            Status::Passthrough,
265            "balanced".to_string(),
266            "plain_text".to_string(),
267            "general".to_string(),
268            vec![],
269            vec![],
270        );
271        assert_eq!(report.saved_tokens, 0);
272        assert_eq!(report.savings_ratio, 0.0);
273        assert_eq!(report.savings_pct, 0.0);
274    }
275
276    #[test]
277    fn compressed_never_exceeding_original_keeps_saved_tokens_nonnegative() {
278        // saturating_sub guards against compressed_tokens > original_tokens (should never
279        // happen, but the report must never panic or underflow if it does).
280        let report = CompressionReport::new(
281            10,
282            15,
283            heuristic_estimator(),
284            Status::BestEffort,
285            "balanced".to_string(),
286            "plain_text".to_string(),
287            "general".to_string(),
288            vec![],
289            vec![],
290        );
291        assert_eq!(report.saved_tokens, 0);
292    }
293
294    #[test]
295    fn status_serializes_inside_report_as_snake_case() {
296        let report = CompressionReport::new(
297            100,
298            80,
299            heuristic_estimator(),
300            Status::UnreachableTarget,
301            "balanced".to_string(),
302            "plain_text".to_string(),
303            "general".to_string(),
304            vec![],
305            vec![],
306        );
307        let json = serde_json::to_value(&report).unwrap();
308        assert_eq!(json["status"], "unreachable_target");
309        assert_eq!(json["estimator"]["backend"], "heuristic");
310        assert_eq!(json["estimator"]["is_exact"], false);
311    }
312
313    #[test]
314    fn quality_report_round_trips() {
315        let quality = QualityReport {
316            eval_profile_id: "smoke-first-consumer".to_string(),
317            task_scope: "code_review".to_string(),
318            validated_ratio_band: Some("0.6-0.8".to_string()),
319            quality_retention: 0.975,
320            contrastive_failure_rate: 0.0,
321            gate_passed: true,
322        };
323        let json = serde_json::to_string(&quality).unwrap();
324        let back: QualityReport = serde_json::from_str(&json).unwrap();
325        assert_eq!(quality, back);
326    }
327}