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 preset: String,
16    pub format: String,
17    pub output_encoding: String,
18    pub task_scope: String,
19    pub request_id: Option<String>,
20    /// Staged `raw -> RTK -> tokenfold` accounting. `None` for the common
21    /// single-stage path; populated only by RTK-composed `wrap --rtk` runs.
22    #[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/// Separates savings and recoverability across composed stages (RTK then
97/// tokenfold) so RTK's savings are never credited to tokenfold. The top-level
98/// `original_tokens` keeps its v1 meaning — tokens *entering* `tokenfold_core`,
99/// which is the post-RTK count when composed.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct PipelineReport {
102    /// Pre-RTK byte count. `Some` only when a complete raw capture was observed.
103    pub raw_input_bytes: Option<usize>,
104    /// Pre-RTK token count. `Some` only when raw capture is complete.
105    pub raw_input_tokens: Option<usize>,
106    pub final_output_bytes: usize,
107    /// Equals top-level `compressed_tokens`.
108    pub final_output_tokens: usize,
109    /// Populated only when raw and final counts use the same estimator.
110    pub total_saved_tokens: Option<usize>,
111    /// `"complete"`, `"partial"`, `"unavailable"`, or `"not_applicable"`.
112    pub raw_capture: String,
113    /// `"full"`, `"tokenfold_only"`, `"none"`, or `"not_applicable"`.
114    pub upstream_recoverability: String,
115    pub stages: Vec<PipelineStageReport>,
116}
117
118/// One composed stage. Count fields are nullable because an unavailable
119/// stage or missing pre-stage capture cannot be measured honestly.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct PipelineStageReport {
122    /// `"rtk"` or `"tokenfold"`.
123    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    /// `"applied"`, `"passthrough"`, `"unavailable"`, `"incompatible"`, or `"failed"`.
133    pub status: String,
134    pub duration_ms: Option<f64>,
135    pub bypass_reason: Option<String>,
136    /// e.g. `"external:rtk@0.4.1"` or `"tokenfold_core"`.
137    pub provenance: String,
138    /// `"full"`, `"partial"`, `"none"`, or `"not_applicable"`.
139    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    /// `None` when a lossy transform ran but no fidelity-gate data was baked in at build time —
189    /// the documented "early dev build, before any gate data exists" state. These were plain
190    /// `f64` before, which forced that state to be reported as a fabricated `0.0` ("nothing was
191    /// retained") — indistinguishable from a real, measured total-loss result. Absent data must
192    /// read as absent, not as a measurement.
193    #[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    /// The transform is enabled for this preset/format, but a lossy run (`policy.lossy`) actually
230    /// pruned the payload, and this transform restructures arrays in a way that would move a
231    /// `lossy_preserve` path off the array it names. See `pipeline::apply_transforms`, which
232    /// defers these until after the lossy stage and only skips them when pruning really applied.
233    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        // saturating_sub guards against compressed_tokens > original_tokens (should never
383        // happen, but the report must never panic or underflow if it does).
384        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}