1use serde::{Deserialize, Serialize};
25
26use super::{CompactionSourceMeasurement, RecapMetrics};
27
28pub const COMPACTION_RECEIPT_SCHEMA_VERSION: u32 = 1;
32
33fn default_schema_version() -> u32 {
34 COMPACTION_RECEIPT_SCHEMA_VERSION
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(default)]
41pub struct CompactionReceipt {
42 #[serde(default = "default_schema_version")]
47 pub schema_version: u32,
48 pub receipt_id: String,
51 pub session_id: Option<String>,
53 pub transcript_id: Option<String>,
56 pub mode: String,
58 pub reason: String,
61 pub strategy: String,
63 pub engine_strategy: String,
65 pub requested_strategy: Option<String>,
69 pub resolved_threshold_tokens: Option<usize>,
73 pub threshold_source: Option<String>,
76 pub hard_limit_tokens: Option<usize>,
78 pub archived_messages: usize,
79 pub estimated_tokens_before: usize,
80 pub estimated_tokens_after: usize,
81 pub snapshot_asset_id: Option<String>,
83 pub instruction_mode: Option<String>,
84 pub instruction_source: Option<String>,
85 pub compaction_policy: Option<serde_json::Value>,
86 pub recap: Option<RecapMetrics>,
89 pub source_measurement: Option<CompactionSourceMeasurement>,
95}
96
97pub fn new_compaction_receipt_id() -> String {
99 format!("compaction-{}", uuid::Uuid::now_v7())
100}
101
102impl CompactionReceipt {
103 pub fn to_json(&self) -> serde_json::Value {
106 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
107 }
108
109 pub fn from_event_metadata(metadata: Option<&serde_json::Value>) -> Option<Self> {
114 let receipt = metadata?.get("receipt")?;
115 serde_json::from_value(receipt.clone()).ok()
116 }
117
118 pub fn from_host_payload(session_id: &str, payload: &serde_json::Value) -> Self {
124 let str_field = |key: &str| {
125 payload
126 .get(key)
127 .and_then(serde_json::Value::as_str)
128 .map(str::to_string)
129 };
130 let usize_field = |key: &str| {
131 payload
132 .get(key)
133 .and_then(serde_json::Value::as_u64)
134 .unwrap_or(0) as usize
135 };
136 if let Some(mut receipt) = Self::from_event_metadata(Some(payload))
137 .filter(|receipt| !receipt.receipt_id.is_empty())
138 {
139 receipt.session_id = Some(session_id.to_string());
140 if let Some(mode) = str_field("mode") {
141 receipt.mode = mode;
142 }
143 if let Some(reason) = str_field("reason") {
144 receipt.reason = reason;
145 }
146 if let Some(strategy) = str_field("strategy") {
147 receipt.strategy = strategy;
148 }
149 if let Some(requested_strategy) = str_field("requested_strategy") {
150 receipt.requested_strategy = Some(requested_strategy);
151 }
152 if let Some(threshold_source) = str_field("threshold_source") {
153 receipt.threshold_source = Some(threshold_source);
154 }
155 return receipt;
156 }
157 let strategy = str_field("strategy")
158 .or_else(|| str_field("engine_strategy"))
159 .unwrap_or_default();
160 let engine_strategy = str_field("engine_strategy").unwrap_or_else(|| strategy.clone());
161 Self {
162 schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
163 receipt_id: new_compaction_receipt_id(),
164 session_id: Some(session_id.to_string()),
165 transcript_id: None,
166 mode: str_field("mode").unwrap_or_else(|| "auto".to_string()),
167 reason: str_field("reason").unwrap_or_else(|| "threshold".to_string()),
168 strategy,
169 engine_strategy,
170 requested_strategy: str_field("requested_strategy"),
171 resolved_threshold_tokens: payload
172 .get("resolved_threshold_tokens")
173 .and_then(serde_json::Value::as_u64)
174 .map(|value| value as usize),
175 threshold_source: str_field("threshold_source"),
176 hard_limit_tokens: payload
177 .get("hard_limit_tokens")
178 .and_then(serde_json::Value::as_u64)
179 .map(|value| value as usize),
180 archived_messages: usize_field("archived_messages"),
181 estimated_tokens_before: usize_field("estimated_tokens_before"),
182 estimated_tokens_after: usize_field("estimated_tokens_after"),
183 snapshot_asset_id: str_field("snapshot_asset_id"),
184 instruction_mode: str_field("instruction_mode"),
185 instruction_source: str_field("instruction_source"),
186 compaction_policy: payload.get("compaction_policy").cloned(),
187 recap: payload
188 .get("recap")
189 .and_then(|value| serde_json::from_value::<RecapMetrics>(value.clone()).ok()),
190 source_measurement: payload.get("source_measurement").and_then(|value| {
193 serde_json::from_value::<CompactionSourceMeasurement>(value.clone()).ok()
194 }),
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn receipt_round_trips_through_json_with_recap_and_policy() {
205 let receipt = CompactionReceipt {
206 schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
207 receipt_id: "compaction-abc".to_string(),
208 session_id: Some("session-1".to_string()),
209 transcript_id: Some("session-1".to_string()),
210 mode: "auto".to_string(),
211 reason: "threshold".to_string(),
212 strategy: "hybrid".to_string(),
213 engine_strategy: "observation_mask".to_string(),
214 requested_strategy: Some("hybrid".to_string()),
215 resolved_threshold_tokens: Some(3_000),
216 threshold_source: Some("token_threshold".to_string()),
217 hard_limit_tokens: Some(8_000),
218 archived_messages: 7,
219 estimated_tokens_before: 4000,
220 estimated_tokens_after: 1200,
221 snapshot_asset_id: Some("snapshot-9".to_string()),
222 instruction_mode: Some("extend".to_string()),
223 instruction_source: Some("author".to_string()),
224 compaction_policy: Some(serde_json::json!({"scope": "summary"})),
225 recap: Some(RecapMetrics {
226 recap_bytes: 512,
227 budget_bytes: 16_000,
228 kept_results_count: 3,
229 dropped_count: 1,
230 carried_prior_recap: true,
231 }),
232 source_measurement: Some(CompactionSourceMeasurement {
235 source_message_count: Some(7),
236 source_bytes: Some(4_096),
237 summary_bytes: Some(512),
238 carried_source_bytes: Some(0),
239 }),
240 };
241 let json = receipt.to_json();
242 let decoded = CompactionReceipt::from_event_metadata(Some(&serde_json::json!({
243 "receipt": json,
244 })))
245 .expect("embedded receipt decodes");
246 assert_eq!(decoded, receipt);
247 }
248
249 #[test]
250 fn absent_receipt_key_yields_none_for_legacy_migration() {
251 let legacy = serde_json::json!({
252 "mode": "manual",
253 "strategy": "truncate",
254 "archived_messages": 3,
255 });
256 assert!(CompactionReceipt::from_event_metadata(Some(&legacy)).is_none());
257 assert!(CompactionReceipt::from_event_metadata(None).is_none());
258 }
259
260 #[test]
261 fn missing_schema_version_defaults_to_current() {
262 let receipt: CompactionReceipt = serde_json::from_value(serde_json::json!({
263 "receipt_id": "compaction-xyz",
264 "mode": "manual",
265 }))
266 .expect("partial receipt loads");
267 assert_eq!(receipt.schema_version, COMPACTION_RECEIPT_SCHEMA_VERSION);
268 assert_eq!(receipt.receipt_id, "compaction-xyz");
269 assert!(receipt.recap.is_none());
270 }
271
272 #[test]
273 fn host_payload_preserves_engine_truth_and_measured_zeroes() {
274 let receipt = CompactionReceipt::from_host_payload(
275 "session-1",
276 &serde_json::json!({
277 "mode": "auto",
278 "reason": "threshold",
279 "strategy": "hybrid",
280 "requested_strategy": "llm",
281 "engine_strategy": "llm",
282 "resolved_threshold_tokens": 0,
283 "threshold_source": "token_threshold",
284 "hard_limit_tokens": 8_000,
285 "source_measurement": {
286 "source_message_count": 4,
287 "source_bytes": 2_048,
288 "summary_bytes": 512,
289 "carried_source_bytes": 0
290 }
291 }),
292 );
293
294 assert_eq!(receipt.requested_strategy.as_deref(), Some("llm"));
295 assert_eq!(receipt.engine_strategy, "llm");
296 assert_eq!(receipt.resolved_threshold_tokens, Some(0));
297 assert_eq!(receipt.threshold_source.as_deref(), Some("token_threshold"));
298 assert_eq!(receipt.hard_limit_tokens, Some(8_000));
299 assert_eq!(
300 receipt
301 .source_measurement
302 .expect("source measurement is retained")
303 .carried_source_bytes,
304 Some(0),
305 );
306 }
307
308 #[test]
309 fn host_payload_preserves_forwarded_engine_receipt_identity_and_outcome() {
310 let engine_receipt = CompactionReceipt {
311 receipt_id: "compaction-engine-owned".to_string(),
312 mode: "manual".to_string(),
313 reason: "manual".to_string(),
314 strategy: "llm".to_string(),
315 engine_strategy: "llm".to_string(),
316 requested_strategy: Some("llm".to_string()),
317 resolved_threshold_tokens: Some(7),
318 threshold_source: Some("token_threshold".to_string()),
319 hard_limit_tokens: Some(99),
320 source_measurement: Some(CompactionSourceMeasurement {
321 summary_bytes: Some(123),
322 ..CompactionSourceMeasurement::default()
323 }),
324 ..CompactionReceipt::default()
325 };
326 let receipt = CompactionReceipt::from_host_payload(
327 "session-live",
328 &serde_json::json!({
329 "receipt": engine_receipt.to_json(),
330 "mode": "auto",
331 "reason": "threshold",
332 "strategy": "policy-label",
333 "requested_strategy": "custom",
334 "threshold_source": "pre_compact_modify",
335 "engine_strategy": "stale-flat-value",
336 "resolved_threshold_tokens": 999,
337 "hard_limit_tokens": 1000,
338 "source_measurement": {"summary_bytes": 1}
339 }),
340 );
341
342 assert_eq!(receipt.receipt_id, "compaction-engine-owned");
343 assert_eq!(receipt.session_id.as_deref(), Some("session-live"));
344 assert_eq!(receipt.mode, "auto");
345 assert_eq!(receipt.reason, "threshold");
346 assert_eq!(receipt.strategy, "policy-label");
347 assert_eq!(receipt.requested_strategy.as_deref(), Some("custom"));
348 assert_eq!(receipt.engine_strategy, "llm");
349 assert_eq!(receipt.resolved_threshold_tokens, Some(7));
350 assert_eq!(
351 receipt.threshold_source.as_deref(),
352 Some("pre_compact_modify")
353 );
354 assert_eq!(receipt.hard_limit_tokens, Some(99));
355 assert_eq!(
356 receipt
357 .source_measurement
358 .and_then(|value| value.summary_bytes),
359 Some(123),
360 );
361 }
362}