Skip to main content

lean_ctx/core/context_quality/
receipt.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ContextQualityReceiptV1` — the quality evidence of one context transformation.
3//!
4//! Quality is never collapsed into one score. The receipt keeps independent dimensions
5//! (transformation, retention, recovery, security, task quality) and marks every
6//! dimension that was not measured as such instead of reporting a zero. It carries no
7//! content and no timestamp, so the same inputs always render byte-identically (#498).
8//!
9//! The receipt is the quality section of the per-round decision receipt planned in the
10//! Context Gateway (G4); `lean-ctx quality-lab`, its former producer, was removed.
11
12use serde::{Deserialize, Serialize};
13
14use super::EvidenceTier;
15use super::retention::{RecoveryPath, RetentionReport, assess};
16
17pub const RECEIPT_KIND: &str = "lean-ctx.context-quality-receipt";
18pub const RECEIPT_SCHEMA_VERSION: u32 = 1;
19
20/// Size and identity of the transformation itself. Token reduction is recorded, never
21/// treated as evidence of quality.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct TransformationEvidence {
24    pub source_type: String,
25    pub transformation_mode: String,
26    pub input_tokens: usize,
27    pub delivered_tokens: usize,
28    pub saved_tokens: usize,
29    /// `delivered / input`; 1.0 means nothing was removed.
30    pub compression_ratio: f64,
31    pub engine_version: String,
32}
33
34/// Recovery-handle verification counts (filled by the recovery verifier).
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RecoveryEvidence {
37    pub handles_emitted: usize,
38    pub handles_verified: usize,
39    pub failures: usize,
40    pub critical_failures: usize,
41}
42
43/// Overall state of one quality dimension.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum DimensionState {
47    Pass,
48    Fail,
49    Unmeasured,
50}
51
52impl DimensionState {
53    pub fn label(self) -> &'static str {
54        match self {
55            Self::Pass => "PASS",
56            Self::Fail => "FAIL",
57            Self::Unmeasured => "UNMEASURED",
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ContextQualityReceiptV1 {
64    pub kind: String,
65    pub schema_version: u32,
66    pub evidence_tier: EvidenceTier,
67    pub transformation: TransformationEvidence,
68    pub retention: RetentionReport,
69    /// `None` when no recovery handle was emitted or verified for this delivery.
70    pub recovery: Option<RecoveryEvidence>,
71    /// Task quality is a model evaluation (`lean-ctx eval ab`); a single transformation
72    /// never measures it.
73    pub task_quality: DimensionState,
74}
75
76impl ContextQualityReceiptV1 {
77    /// Builds the receipt for `original` → `delivered`. `recovery` states whether the
78    /// original stays reachable through a verified path.
79    pub fn assess(
80        source_type: &str,
81        transformation_mode: &str,
82        original: &str,
83        delivered: &str,
84        recovery: RecoveryPath,
85        recovery_evidence: Option<RecoveryEvidence>,
86    ) -> Self {
87        let input_tokens = crate::core::tokens::count_tokens(original);
88        let delivered_tokens = crate::core::tokens::count_tokens(delivered);
89        let compression_ratio = if input_tokens == 0 {
90            1.0
91        } else {
92            delivered_tokens as f64 / input_tokens as f64
93        };
94        Self {
95            kind: RECEIPT_KIND.to_string(),
96            schema_version: RECEIPT_SCHEMA_VERSION,
97            evidence_tier: EvidenceTier::DeterministicQuality,
98            transformation: TransformationEvidence {
99                source_type: source_type.to_string(),
100                transformation_mode: transformation_mode.to_string(),
101                input_tokens,
102                delivered_tokens,
103                saved_tokens: input_tokens.saturating_sub(delivered_tokens),
104                compression_ratio,
105                engine_version: env!("CARGO_PKG_VERSION").to_string(),
106            },
107            retention: assess(original, delivered, recovery),
108            recovery: recovery_evidence,
109            task_quality: DimensionState::Unmeasured,
110        }
111    }
112
113    pub fn retention_state(&self) -> DimensionState {
114        if !self.retention.passes() {
115            DimensionState::Fail
116        } else if self.retention.critical.total() + self.retention.important.total() == 0 {
117            DimensionState::Unmeasured
118        } else {
119            DimensionState::Pass
120        }
121    }
122
123    pub fn recovery_state(&self) -> DimensionState {
124        match self.recovery {
125            None => DimensionState::Unmeasured,
126            // A handle that does not recover is broken even when the facts behind it
127            // were not critical.
128            Some(r) if r.failures > 0 => DimensionState::Fail,
129            Some(_) => DimensionState::Pass,
130        }
131    }
132
133    /// No measured dimension failed. This is what a gate checks; it is *not* a claim of
134    /// measured success — see [`Self::overall_state`].
135    pub fn passes(&self) -> bool {
136        self.retention_state() != DimensionState::Fail
137            && self.recovery_state() != DimensionState::Fail
138    }
139
140    /// `Fail` if any measured dimension failed, `Unmeasured` if neither retention nor
141    /// recovery measured anything, `Pass` only when something was measured and held.
142    pub fn overall_state(&self) -> DimensionState {
143        let states = [self.retention_state(), self.recovery_state()];
144        if states.contains(&DimensionState::Fail) {
145            DimensionState::Fail
146        } else if states.iter().all(|s| *s == DimensionState::Unmeasured) {
147            DimensionState::Unmeasured
148        } else {
149            DimensionState::Pass
150        }
151    }
152
153    /// Human-readable report: one block per dimension, `UNMEASURED` instead of zeros.
154    pub fn render(&self) -> String {
155        let t = &self.transformation;
156        let r = &self.retention;
157        let mut out = String::new();
158        out.push_str("Context Quality\n");
159        out.push_str(&format!(
160            "Representation   {} via {}\n  input {} tok → delivered {} tok ({:.1}% reduction)\n",
161            t.source_type,
162            t.transformation_mode,
163            t.input_tokens,
164            t.delivered_tokens,
165            (1.0 - t.compression_ratio) * 100.0
166        ));
167        out.push_str(&format!(
168            "Retention        {}\n  critical  retained {} · recoverable {} · lost {}\n  important retained {} · recoverable {} · lost {}\n",
169            self.retention_state().label(),
170            r.critical.retained,
171            r.critical.recoverable,
172            r.critical.lost,
173            r.important.retained,
174            r.important.recoverable,
175            r.important.lost,
176        ));
177        if !r.lost_critical_kinds.is_empty() {
178            let kinds: Vec<String> = r
179                .lost_critical_kinds
180                .iter()
181                .map(|k| format!("{k:?}"))
182                .collect();
183            out.push_str(&format!("  lost critical kinds: {}\n", kinds.join(", ")));
184        }
185        if r.critical_unchecked {
186            out.push_str("  too many critical facts to check — failing closed\n");
187        }
188        if r.truncated {
189            out.push_str("  (non-critical probe limit reached — remainder unchecked)\n");
190        }
191        match self.recovery {
192            Some(rec) => out.push_str(&format!(
193                "Recovery         {}\n  handles {} · verified {} · failures {}\n",
194                self.recovery_state().label(),
195                rec.handles_emitted,
196                rec.handles_verified,
197                rec.failures
198            )),
199            None => out.push_str("Recovery         UNMEASURED\n"),
200        }
201        out.push_str(&format!(
202            "Security         secret lines withheld from probing: {}\n",
203            r.secret_lines_withheld
204        ));
205        out.push_str(&format!(
206            "Task quality     {} (evidence tier {}; run `lean-ctx eval ab`)\n",
207            self.task_quality.label(),
208            self.evidence_tier.code()
209        ));
210        out.push_str(&format!(
211            "Verdict          Context quality {}\n",
212            self.overall_state().label()
213        ));
214        out
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn receipt_reports_unmeasured_dimensions_instead_of_zero() {
224        let r = ContextQualityReceiptV1::assess(
225            "shell",
226            "terse",
227            "plain prose without facts\n",
228            "plain prose\n",
229            RecoveryPath::None,
230            None,
231        );
232        assert_eq!(r.retention_state(), DimensionState::Unmeasured);
233        assert_eq!(r.recovery_state(), DimensionState::Unmeasured);
234        let text = r.render();
235        assert!(text.contains("Retention        UNMEASURED"));
236        assert!(text.contains("Recovery         UNMEASURED"));
237        assert!(text.contains("Task quality     UNMEASURED"));
238        // Nothing measured is not a pass, even though nothing failed.
239        assert_eq!(r.overall_state(), DimensionState::Unmeasured);
240        assert!(text.contains("Context quality UNMEASURED"));
241    }
242
243    #[test]
244    fn critical_loss_fails_the_receipt_and_critical_recovery_failure_too() {
245        let original = "error[E0599]: no method named `total`\n";
246        let lost = ContextQualityReceiptV1::assess(
247            "shell",
248            "aggressive",
249            original,
250            "compile issue\n",
251            RecoveryPath::None,
252            None,
253        );
254        assert!(!lost.passes());
255        assert!(lost.render().contains("Context quality FAIL"));
256
257        let broken_recovery = ContextQualityReceiptV1::assess(
258            "shell",
259            "aggressive",
260            original,
261            original,
262            RecoveryPath::None,
263            Some(RecoveryEvidence {
264                handles_emitted: 1,
265                handles_verified: 0,
266                failures: 1,
267                critical_failures: 0,
268            }),
269        );
270        assert!(!broken_recovery.passes());
271    }
272
273    #[test]
274    fn receipt_is_deterministic_and_carries_no_content() {
275        let original = "test result: FAILED. 3 passed; 2 failed\nsee src/lib.rs:10\n";
276        let a = ContextQualityReceiptV1::assess(
277            "shell",
278            "lite",
279            original,
280            original,
281            RecoveryPath::None,
282            None,
283        );
284        let b = ContextQualityReceiptV1::assess(
285            "shell",
286            "lite",
287            original,
288            original,
289            RecoveryPath::None,
290            None,
291        );
292        let ja = serde_json::to_string(&a).unwrap();
293        assert_eq!(ja, serde_json::to_string(&b).unwrap());
294        assert!(
295            !ja.contains("src/lib.rs"),
296            "receipt must not copy content: {ja}"
297        );
298    }
299}