1use crate::common::{
4 CapabilityId, PlanId, ReceiptId, TaskId, ValidationError, deserialize_milliunit,
5 deserialize_schema_version, validate_milliunit, validate_schema_version,
6};
7use crate::evidence::EvidenceRefV1;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ContextStrategy {
14 Minimal,
15 Balanced,
16 Comprehensive,
17 CachedFirst,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum StopCondition {
24 OnCompletion,
25 OnAcceptance,
26 OnBudgetExhaustion,
27 OnError,
28 Manual,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ExecutionPlanV1 {
35 #[serde(deserialize_with = "deserialize_schema_version")]
36 pub schema_version: u32,
37 pub plan_id: PlanId,
38 pub task_id: TaskId,
39 pub context_budget_tokens: u64,
40 pub context_strategy: ContextStrategy,
41 pub knowledge_refs: Vec<String>,
42 pub capability_ids: Vec<CapabilityId>,
43 pub model: String,
44 pub provider: String,
45 #[serde(deserialize_with = "deserialize_milliunit")]
46 pub reasoning_allocation_milli: u16,
47 pub max_retries: u32,
48 pub fallback_refs: Vec<String>,
49 pub stop_condition: StopCondition,
50 pub expected_cost_micros: u64,
51 #[serde(deserialize_with = "deserialize_milliunit")]
52 pub expected_quality_milli: u16,
53 pub expected_latency_ms: u64,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub policy_decision_ref: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub scheduler_decision_ref: Option<String>,
58}
59
60impl ExecutionPlanV1 {
61 pub fn validate(&self) -> Result<(), ValidationError> {
63 validate_schema_version(self.schema_version)?;
64 validate_milliunit(
65 self.reasoning_allocation_milli,
66 "reasoning_allocation_milli",
67 )?;
68 validate_milliunit(self.expected_quality_milli, "expected_quality_milli")?;
69 Ok(())
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct ContextBalanceV1 {
77 pub original_tokens: u64,
78 pub materialized_tokens: u64,
79 pub delivered_tokens: u64,
80 pub provider_billed_tokens: u64,
81}
82
83impl ContextBalanceV1 {
84 pub fn validate(&self) -> Result<(), ValidationError> {
86 if self.materialized_tokens > self.original_tokens {
87 return Err(ValidationError::new(
88 "materialized_tokens exceeds original_tokens",
89 ));
90 }
91 if self.delivered_tokens > self.materialized_tokens {
92 return Err(ValidationError::new(
93 "delivered_tokens exceeds materialized_tokens",
94 ));
95 }
96 Ok(())
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct ExecutionReceiptV1 {
104 #[serde(deserialize_with = "deserialize_schema_version")]
105 pub schema_version: u32,
106 pub receipt_id: ReceiptId,
107 pub task_id: TaskId,
108 pub plan_id: PlanId,
109 pub context_balance: ContextBalanceV1,
110 pub fresh_input_tokens: u64,
111 pub cached_input_tokens: u64,
112 pub output_tokens: u64,
113 pub reasoning_tokens: u64,
114 pub requested_model: String,
115 pub selected_model: String,
116 pub provider: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub capability_id: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub capability_version: Option<String>,
123 pub model_calls: u32,
124 pub retries: u32,
125 pub latency_ms: u64,
126 pub actual_cost_micros: u64,
127 pub baseline_cost_micros: u64,
128 pub avoided_cost_micros: u64,
129 pub etpao_milli: u64,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub outcome_ref: Option<String>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub knowledge_refs: Vec<String>,
134 pub decision_refs: Vec<String>,
135 pub evidence_refs: Vec<EvidenceRefV1>,
136 pub signature: String,
137}
138
139impl ExecutionReceiptV1 {
140 pub fn validate(&self) -> Result<(), ValidationError> {
142 validate_schema_version(self.schema_version)?;
143 self.context_balance.validate()?;
144 if self.avoided_cost_micros > self.baseline_cost_micros {
145 return Err(ValidationError::new(
146 "avoided_cost_micros exceeds baseline_cost_micros",
147 ));
148 }
149 Ok(())
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn id<T>(value: &str) -> T
158 where
159 T: TryFrom<String>,
160 <T as TryFrom<String>>::Error: std::fmt::Debug,
161 {
162 T::try_from(value.to_owned()).expect("identifier should be valid")
163 }
164
165 fn balance() -> ContextBalanceV1 {
166 ContextBalanceV1 {
167 original_tokens: 1_000,
168 materialized_tokens: 800,
169 delivered_tokens: 700,
170 provider_billed_tokens: 650,
171 }
172 }
173
174 #[test]
175 fn plan_serialization_round_trip() {
176 let plan = ExecutionPlanV1 {
177 schema_version: 1,
178 plan_id: id("plan-1"),
179 task_id: id("task-1"),
180 context_budget_tokens: 10_000,
181 context_strategy: ContextStrategy::Balanced,
182 knowledge_refs: vec!["knowledge:1".to_owned()],
183 capability_ids: vec![id("capability:search")],
184 model: "model-1".to_owned(),
185 provider: "provider-1".to_owned(),
186 reasoning_allocation_milli: 500,
187 max_retries: 2,
188 fallback_refs: vec!["model:fallback".to_owned()],
189 stop_condition: StopCondition::OnAcceptance,
190 expected_cost_micros: 5_000,
191 expected_quality_milli: 850,
192 expected_latency_ms: 1_500,
193 policy_decision_ref: Some("decision:policy".to_owned()),
194 scheduler_decision_ref: Some("decision:scheduler".to_owned()),
195 };
196 let json = serde_json::to_string(&plan).expect("plan should serialize");
197 let decoded: ExecutionPlanV1 =
198 serde_json::from_str(&json).expect("plan should deserialize");
199 assert_eq!(plan, decoded);
200 plan.validate().expect("plan should satisfy invariants");
201 }
202
203 #[test]
204 fn receipt_serialization_round_trip() {
205 let receipt = ExecutionReceiptV1 {
206 schema_version: 1,
207 receipt_id: id("receipt-1"),
208 task_id: id("task-1"),
209 plan_id: id("plan-1"),
210 context_balance: balance(),
211 fresh_input_tokens: 600,
212 cached_input_tokens: 100,
213 output_tokens: 200,
214 reasoning_tokens: 50,
215 requested_model: "model-requested".to_owned(),
216 selected_model: "model-selected".to_owned(),
217 provider: "provider-1".to_owned(),
218 capability_id: Some("capability://leanctx/context".to_owned()),
219 capability_version: Some("1.0.0".to_owned()),
220 model_calls: 2,
221 retries: 1,
222 latency_ms: 900,
223 actual_cost_micros: 2_000,
224 baseline_cost_micros: 3_000,
225 avoided_cost_micros: 1_000,
226 etpao_milli: 1_250,
227 outcome_ref: Some("outcome:1".to_owned()),
228 knowledge_refs: vec!["knowledge:1".to_owned()],
229 decision_refs: vec!["decision:1".to_owned()],
230 evidence_refs: vec![],
231 signature: "signature".to_owned(),
232 };
233 let json = serde_json::to_string(&receipt).expect("receipt should serialize");
234 let decoded: ExecutionReceiptV1 =
235 serde_json::from_str(&json).expect("receipt should deserialize");
236 assert_eq!(receipt, decoded);
237 receipt
238 .validate()
239 .expect("receipt should satisfy invariants");
240 }
241
242 #[test]
243 fn receipt_capability_metadata_is_optional_and_backward_compatible() {
244 let receipt = ExecutionReceiptV1 {
245 schema_version: 1,
246 receipt_id: id("receipt-1"),
247 task_id: id("task-1"),
248 plan_id: id("plan-1"),
249 context_balance: balance(),
250 fresh_input_tokens: 600,
251 cached_input_tokens: 100,
252 output_tokens: 200,
253 reasoning_tokens: 50,
254 requested_model: "model-requested".to_owned(),
255 selected_model: "model-selected".to_owned(),
256 provider: "provider-1".to_owned(),
257 capability_id: Some("capability://leanctx/context".to_owned()),
258 capability_version: Some("1.0.0".to_owned()),
259 model_calls: 2,
260 retries: 1,
261 latency_ms: 900,
262 actual_cost_micros: 2_000,
263 baseline_cost_micros: 3_000,
264 avoided_cost_micros: 1_000,
265 etpao_milli: 1_250,
266 outcome_ref: Some("outcome:1".to_owned()),
267 knowledge_refs: vec!["knowledge:1".to_owned()],
268 decision_refs: vec!["decision:1".to_owned()],
269 evidence_refs: vec![],
270 signature: "signature".to_owned(),
271 };
272 let json = serde_json::to_value(&receipt).expect("receipt should serialize");
273 assert_eq!(json["capability_id"], "capability://leanctx/context");
274 assert_eq!(
275 serde_json::from_value::<ExecutionReceiptV1>(json.clone())
276 .expect("receipt with capability metadata should deserialize"),
277 receipt
278 );
279
280 let mut legacy = json;
281 let object = legacy
282 .as_object_mut()
283 .expect("serialized receipt should be an object");
284 object.remove("capability_id");
285 object.remove("capability_version");
286
287 let decoded: ExecutionReceiptV1 =
288 serde_json::from_value(legacy).expect("legacy receipt should deserialize");
289 assert_eq!(decoded.capability_id, None);
290 assert_eq!(decoded.capability_version, None);
291
292 let without_capability = serde_json::to_value(decoded)
293 .expect("receipt without capability metadata should serialize");
294 assert!(without_capability.get("capability_id").is_none());
295 assert!(without_capability.get("capability_version").is_none());
296 }
297}