lean_ctx/core/context_quality/
receipt.rs1use 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#[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 pub compression_ratio: f64,
31 pub engine_version: String,
32}
33
34#[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#[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 pub recovery: Option<RecoveryEvidence>,
71 pub task_quality: DimensionState,
74}
75
76impl ContextQualityReceiptV1 {
77 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 Some(r) if r.failures > 0 => DimensionState::Fail,
129 Some(_) => DimensionState::Pass,
130 }
131 }
132
133 pub fn passes(&self) -> bool {
136 self.retention_state() != DimensionState::Fail
137 && self.recovery_state() != DimensionState::Fail
138 }
139
140 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 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 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}