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