harn_vm/orchestration/
compaction_receipt.rs1use serde::{Deserialize, Serialize};
25
26use super::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 archived_messages: usize,
66 pub estimated_tokens_before: usize,
67 pub estimated_tokens_after: usize,
68 pub snapshot_asset_id: Option<String>,
70 pub instruction_mode: Option<String>,
71 pub instruction_source: Option<String>,
72 pub compaction_policy: Option<serde_json::Value>,
73 pub recap: Option<RecapMetrics>,
76}
77
78pub fn new_compaction_receipt_id() -> String {
80 format!("compaction-{}", uuid::Uuid::now_v7())
81}
82
83impl CompactionReceipt {
84 pub fn to_json(&self) -> serde_json::Value {
87 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
88 }
89
90 pub fn from_event_metadata(metadata: Option<&serde_json::Value>) -> Option<Self> {
95 let receipt = metadata?.get("receipt")?;
96 serde_json::from_value(receipt.clone()).ok()
97 }
98
99 pub fn from_host_payload(session_id: &str, payload: &serde_json::Value) -> Self {
105 let str_field = |key: &str| {
106 payload
107 .get(key)
108 .and_then(serde_json::Value::as_str)
109 .map(str::to_string)
110 };
111 let usize_field = |key: &str| {
112 payload
113 .get(key)
114 .and_then(serde_json::Value::as_u64)
115 .unwrap_or(0) as usize
116 };
117 let strategy = str_field("strategy")
118 .or_else(|| str_field("engine_strategy"))
119 .unwrap_or_default();
120 let engine_strategy = str_field("engine_strategy").unwrap_or_else(|| strategy.clone());
121 Self {
122 schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
123 receipt_id: new_compaction_receipt_id(),
124 session_id: Some(session_id.to_string()),
125 transcript_id: None,
126 mode: str_field("mode").unwrap_or_else(|| "auto".to_string()),
127 reason: str_field("reason").unwrap_or_else(|| "threshold".to_string()),
128 strategy,
129 engine_strategy,
130 archived_messages: usize_field("archived_messages"),
131 estimated_tokens_before: usize_field("estimated_tokens_before"),
132 estimated_tokens_after: usize_field("estimated_tokens_after"),
133 snapshot_asset_id: str_field("snapshot_asset_id"),
134 instruction_mode: str_field("instruction_mode"),
135 instruction_source: str_field("instruction_source"),
136 compaction_policy: payload.get("compaction_policy").cloned(),
137 recap: payload
138 .get("recap")
139 .and_then(|value| serde_json::from_value::<RecapMetrics>(value.clone()).ok()),
140 }
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn receipt_round_trips_through_json_with_recap_and_policy() {
150 let receipt = CompactionReceipt {
151 schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
152 receipt_id: "compaction-abc".to_string(),
153 session_id: Some("session-1".to_string()),
154 transcript_id: Some("session-1".to_string()),
155 mode: "auto".to_string(),
156 reason: "threshold".to_string(),
157 strategy: "hybrid".to_string(),
158 engine_strategy: "observation_mask".to_string(),
159 archived_messages: 7,
160 estimated_tokens_before: 4000,
161 estimated_tokens_after: 1200,
162 snapshot_asset_id: Some("snapshot-9".to_string()),
163 instruction_mode: Some("extend".to_string()),
164 instruction_source: Some("author".to_string()),
165 compaction_policy: Some(serde_json::json!({"scope": "summary"})),
166 recap: Some(RecapMetrics {
167 recap_bytes: 512,
168 budget_bytes: 16_000,
169 kept_results_count: 3,
170 dropped_count: 1,
171 carried_prior_recap: true,
172 }),
173 };
174 let json = receipt.to_json();
175 let decoded = CompactionReceipt::from_event_metadata(Some(&serde_json::json!({
176 "receipt": json,
177 })))
178 .expect("embedded receipt decodes");
179 assert_eq!(decoded, receipt);
180 }
181
182 #[test]
183 fn absent_receipt_key_yields_none_for_legacy_migration() {
184 let legacy = serde_json::json!({
185 "mode": "manual",
186 "strategy": "truncate",
187 "archived_messages": 3,
188 });
189 assert!(CompactionReceipt::from_event_metadata(Some(&legacy)).is_none());
190 assert!(CompactionReceipt::from_event_metadata(None).is_none());
191 }
192
193 #[test]
194 fn missing_schema_version_defaults_to_current() {
195 let receipt: CompactionReceipt = serde_json::from_value(serde_json::json!({
196 "receipt_id": "compaction-xyz",
197 "mode": "manual",
198 }))
199 .expect("partial receipt loads");
200 assert_eq!(receipt.schema_version, COMPACTION_RECEIPT_SCHEMA_VERSION);
201 assert_eq!(receipt.receipt_id, "compaction-xyz");
202 assert!(receipt.recap.is_none());
203 }
204}