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 preset: String,
16 pub format: String,
17 pub output_encoding: String,
18 pub task_scope: String,
19 pub request_id: Option<String>,
20 #[serde(default)]
23 pub pipeline: Option<PipelineReport>,
24 pub quality: Option<QualityReport>,
25 pub budget: Option<BudgetReport>,
26 pub encoding: Option<EncodingReport>,
27 pub pruning: Option<PruningReport>,
28 pub cache: Option<CacheReport>,
29 pub retrieval: Option<RetrievalReport>,
30 pub output_savings: Option<OutputSavingsReport>,
31 pub bypass: Option<BypassReport>,
32 pub command: Option<CommandReport>,
33 pub ledger: Option<LedgerReport>,
34 pub transforms: Vec<TransformReport>,
35 pub warnings: Vec<Warning>,
36}
37
38impl CompressionReport {
39 #[allow(clippy::too_many_arguments)]
40 pub fn new(
41 original_tokens: usize,
42 compressed_tokens: usize,
43 estimator: EstimatorInfo,
44 status: Status,
45 preset: String,
46 format: String,
47 task_scope: String,
48 transforms: Vec<TransformReport>,
49 warnings: Vec<Warning>,
50 ) -> Self {
51 let saved_tokens = original_tokens.saturating_sub(compressed_tokens);
52 let savings_ratio = if original_tokens == 0 {
53 0.0
54 } else {
55 saved_tokens as f64 / original_tokens as f64
56 };
57 let savings_pct = savings_ratio * 100.0;
58 Self {
59 schema_version: "2.0".to_string(),
60 original_tokens,
61 compressed_tokens,
62 saved_tokens,
63 savings_ratio,
64 savings_pct,
65 estimator,
66 status,
67 preset,
68 format,
69 output_encoding: "native".to_string(),
70 task_scope,
71 request_id: None,
72 pipeline: None,
73 quality: None,
74 budget: None,
75 encoding: None,
76 pruning: None,
77 cache: None,
78 retrieval: None,
79 output_savings: None,
80 bypass: None,
81 command: None,
82 ledger: None,
83 transforms,
84 warnings,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct EstimatorInfo {
91 pub backend: String,
92 pub model: Option<String>,
93 pub is_exact: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct PipelineReport {
102 pub raw_input_bytes: Option<usize>,
104 pub raw_input_tokens: Option<usize>,
106 pub final_output_bytes: usize,
107 pub final_output_tokens: usize,
109 pub total_saved_tokens: Option<usize>,
111 pub raw_capture: String,
113 pub upstream_recoverability: String,
115 pub stages: Vec<PipelineStageReport>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct PipelineStageReport {
122 pub id: String,
124 pub version: Option<String>,
125 pub input_bytes: Option<usize>,
126 pub output_bytes: Option<usize>,
127 pub saved_bytes: Option<usize>,
128 pub input_tokens: Option<usize>,
129 pub output_tokens: Option<usize>,
130 pub saved_tokens: Option<usize>,
131 pub estimator: Option<EstimatorInfo>,
132 pub status: String,
134 pub duration_ms: Option<f64>,
135 pub bypass_reason: Option<String>,
136 pub provenance: String,
138 pub recoverability: String,
140 pub evidence_ref: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144pub struct BudgetReport {
145 pub status: BudgetStatus,
146 pub target_tokens: Option<usize>,
147 pub protected_floor: usize,
148 pub achieved_tokens: usize,
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum BudgetStatus {
154 NotRequested,
155 Met,
156 BestEffort,
157 Unreachable,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct EncodingReport {
162 pub codec: String,
163 pub version: String,
164 pub roundtrip_verified: bool,
165 pub tokens_before: usize,
166 pub tokens_after: usize,
167 pub token_delta: i64,
168 pub warnings: Vec<Warning>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172pub struct PruningReport {
173 pub requested: bool,
174 pub applied: bool,
175 pub preview: bool,
176 pub candidate_items: usize,
177 pub retained_items: usize,
178 pub pruned_items: usize,
179 pub evidence_refs: usize,
180 pub preserve_paths: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184pub struct QualityReport {
185 pub eval_profile_id: String,
186 pub task_scope: String,
187 pub validated_ratio_band: Option<String>,
188 #[serde(default)]
194 pub quality_retention: Option<f64>,
195 #[serde(default)]
196 pub contrastive_failure_rate: Option<f64>,
197 pub gate_passed: bool,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct TransformReport {
202 pub id: String,
203 pub version: String,
204 pub tokens_before: usize,
205 pub tokens_after: usize,
206 pub saved_tokens: usize,
207 pub savings_ratio: f64,
208 pub elapsed_micros: Option<u64>,
209 pub status: TransformStatus,
210 pub skipped_reason: Option<SkippedReason>,
211 pub warnings: Vec<Warning>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub enum TransformStatus {
217 Applied,
218 NoOp,
219 Skipped,
220 RolledBack,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(rename_all = "snake_case")]
225pub enum SkippedReason {
226 TargetAlreadyMet,
227 NotApplicableToFormat,
228 NotEnabledInMode,
229 IncompatibleWithLossy,
234 ExperimentalFlagRequired,
235 DisabledByUser,
236 WouldIncreaseTokens,
237 FilterUntrusted,
238 FilterFailedVerify,
239 BypassEnvSet,
240 UnsupportedCommandShape,
241 PipeOrHeredocNotRewritten,
242 BinaryOutputDetected,
243 UnsafeCommandPassthrough,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
247pub struct Warning {
248 pub code: WarningCode,
249 pub severity: Severity,
250 pub transform: Option<String>,
251 pub message: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "snake_case")]
256pub enum Severity {
257 Info,
258 Warn,
259 Critical,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "snake_case")]
264pub enum WarningCode {
265 UnreachableTarget,
266 UnredactedContentPossible,
267 SafetyDowngrade,
268 SecurityFieldAltered,
269 HeuristicBudgetUsed,
270 PrefixModified,
271 OutputEncodingIncreased,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
275pub struct CacheReport {
276 pub boundary_kind: Option<String>,
277 pub protected_bytes: usize,
278 pub prefix_byte_identical: bool,
279 pub warnings: Vec<Warning>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283pub struct RetrievalReport {
284 pub store_namespace: String,
285 pub hash_algorithm: String,
286 pub marker_count: usize,
287 pub ttl_seconds: Option<u64>,
288 pub persisted_original_bytes: usize,
289 pub skipped_original_bytes: usize,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
293pub struct OutputSavingsReport {
294 pub profile: String,
295 pub estimated_output_tokens_saved: Option<usize>,
296 pub measured_output_tokens_saved: Option<usize>,
297 pub provenance: String,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301pub struct BypassReport {
302 pub reason: String,
303 pub source: String,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
307pub struct CommandReport {
308 pub command_family: Option<String>,
309 pub child_exit_code: Option<i32>,
310 pub duration_ms: u64,
311 pub raw_output_bytes: usize,
312 pub stdout_bytes: usize,
313 pub stderr_bytes: usize,
314 pub stderr_mode: String,
315 pub stderr_truncated: bool,
316 pub compressed_output_bytes: usize,
317 pub filter_pack_id: Option<String>,
318 pub filter_version: Option<String>,
319 pub never_worse_applied: bool,
320 pub bypass_reason: Option<String>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
324pub struct LedgerReport {
325 pub recorded: bool,
326 pub scope: Option<String>,
327 pub project_hash: Option<String>,
328 pub record_id: Option<String>,
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn heuristic_estimator() -> EstimatorInfo {
336 EstimatorInfo {
337 backend: "heuristic".to_string(),
338 model: None,
339 is_exact: false,
340 }
341 }
342
343 #[test]
344 fn saved_tokens_and_ratio_are_derived_correctly() {
345 let report = CompressionReport::new(
346 18_400,
347 11_900,
348 heuristic_estimator(),
349 Status::Compressed,
350 "balanced".to_string(),
351 "plain_text".to_string(),
352 "general".to_string(),
353 vec![],
354 vec![],
355 );
356 assert_eq!(report.saved_tokens, 6_500);
357 assert!((report.savings_ratio - 0.353_260_869_565_217_4).abs() < f64::EPSILON * 10.0);
358 assert!((report.savings_pct - 35.326_086_956_521_74).abs() < 1e-9);
359 assert_eq!(report.schema_version, "2.0");
360 }
361
362 #[test]
363 fn zero_original_tokens_never_divides_by_zero() {
364 let report = CompressionReport::new(
365 0,
366 0,
367 heuristic_estimator(),
368 Status::Passthrough,
369 "balanced".to_string(),
370 "plain_text".to_string(),
371 "general".to_string(),
372 vec![],
373 vec![],
374 );
375 assert_eq!(report.saved_tokens, 0);
376 assert_eq!(report.savings_ratio, 0.0);
377 assert_eq!(report.savings_pct, 0.0);
378 }
379
380 #[test]
381 fn compressed_never_exceeding_original_keeps_saved_tokens_nonnegative() {
382 let report = CompressionReport::new(
385 10,
386 15,
387 heuristic_estimator(),
388 Status::Compressed,
389 "balanced".to_string(),
390 "plain_text".to_string(),
391 "general".to_string(),
392 vec![],
393 vec![],
394 );
395 assert_eq!(report.saved_tokens, 0);
396 }
397
398 #[test]
399 fn status_serializes_inside_report_as_snake_case() {
400 let report = CompressionReport::new(
401 100,
402 80,
403 heuristic_estimator(),
404 Status::Compressed,
405 "balanced".to_string(),
406 "plain_text".to_string(),
407 "general".to_string(),
408 vec![],
409 vec![],
410 );
411 let json = serde_json::to_value(&report).unwrap();
412 assert_eq!(json["status"], "compressed");
413 assert_eq!(json["estimator"]["backend"], "heuristic");
414 assert_eq!(json["estimator"]["is_exact"], false);
415 }
416
417 #[test]
418 fn quality_report_round_trips() {
419 let quality = QualityReport {
420 eval_profile_id: "smoke-first-consumer".to_string(),
421 task_scope: "code_review".to_string(),
422 validated_ratio_band: Some("0.6-0.8".to_string()),
423 quality_retention: Some(0.975),
424 contrastive_failure_rate: Some(0.0),
425 gate_passed: true,
426 };
427 let json = serde_json::to_string(&quality).unwrap();
428 let back: QualityReport = serde_json::from_str(&json).unwrap();
429 assert_eq!(quality, back);
430 }
431
432 #[test]
433 fn quality_report_without_baked_in_gate_data_round_trips_as_absent_not_zero() {
434 let quality = QualityReport {
435 eval_profile_id: "unvalidated".to_string(),
436 task_scope: "all".to_string(),
437 validated_ratio_band: None,
438 quality_retention: None,
439 contrastive_failure_rate: None,
440 gate_passed: false,
441 };
442 let json = serde_json::to_value(&quality).unwrap();
443 assert!(
444 json["quality_retention"].is_null(),
445 "absent must not serialize as 0.0"
446 );
447 let back: QualityReport = serde_json::from_value(json).unwrap();
448 assert_eq!(quality, back);
449 }
450
451 #[test]
452 fn canonical_v2_report_fixture_round_trips_without_schema_drift() {
453 let expected: serde_json::Value = serde_json::from_str(include_str!(
454 "../../../tests/fixtures/compression_report_v2.json"
455 ))
456 .unwrap();
457 let report: CompressionReport = serde_json::from_value(expected.clone()).unwrap();
458 assert_eq!(serde_json::to_value(report).unwrap(), expected);
459
460 let schema: serde_json::Value = serde_json::from_str(include_str!(
461 "../../../tests/fixtures/compression_report_v2.schema.json"
462 ))
463 .unwrap();
464 let expected_keys = expected.as_object().unwrap().keys().collect::<Vec<_>>();
465 let schema_keys = schema["properties"]
466 .as_object()
467 .unwrap()
468 .keys()
469 .collect::<Vec<_>>();
470 assert_eq!(schema_keys, expected_keys);
471 assert_eq!(
472 schema["required"].as_array().unwrap().len(),
473 expected_keys.len()
474 );
475 }
476}