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    /// F-055: staged `raw -> RTK -> tokenfold` accounting. `None` for the common
20    /// single-stage path; populated only by RTK-composed `wrap --rtk` runs.
21    #[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/// F-055: separates savings and recoverability across composed stages (RTK then
91/// tokenfold) so RTK's savings are never credited to tokenfold. The top-level
92/// `original_tokens` keeps its v1 meaning — tokens *entering* `tokenfold_core`,
93/// which is the post-RTK count when composed. See INTERFACES.md §1.9 / §2.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct PipelineReport {
96    /// Pre-RTK byte count. `Some` only when a complete raw capture was observed.
97    pub raw_input_bytes: Option<usize>,
98    /// Pre-RTK token count. `Some` only when raw capture is complete.
99    pub raw_input_tokens: Option<usize>,
100    pub final_output_bytes: usize,
101    /// Equals top-level `compressed_tokens`.
102    pub final_output_tokens: usize,
103    /// Populated only when raw and final counts use the same estimator.
104    pub total_saved_tokens: Option<usize>,
105    /// `"complete"`, `"partial"`, `"unavailable"`, or `"not_applicable"`.
106    pub raw_capture: String,
107    /// `"full"`, `"tokenfold_only"`, `"none"`, or `"not_applicable"`.
108    pub upstream_recoverability: String,
109    pub stages: Vec<PipelineStageReport>,
110}
111
112/// F-055: one composed stage. Count fields are nullable because an unavailable
113/// stage or missing pre-stage capture cannot be measured honestly.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct PipelineStageReport {
116    /// `"rtk"` or `"tokenfold"`.
117    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    /// `"applied"`, `"passthrough"`, `"unavailable"`, `"incompatible"`, or `"failed"`.
127    pub status: String,
128    pub duration_ms: Option<f64>,
129    pub bypass_reason: Option<String>,
130    /// e.g. `"external:rtk@0.4.1"` or `"tokenfold_core"`.
131    pub provenance: String,
132    /// `"full"`, `"partial"`, `"none"`, or `"not_applicable"`.
133    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        // saturating_sub guards against compressed_tokens > original_tokens (should never
331        // happen, but the report must never panic or underflow if it does).
332        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}