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, pub savings_pct: f64, 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 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}