Skip to main content

lean_ctx_protocol/
execution.rs

1//! Planning and execution receipt contracts.
2
3use 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/// Strategy used to assemble context for a plan.
11#[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/// Terminal condition selected by an execution plan.
21#[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/// V1 execution plan produced from a task envelope.
32#[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    /// Validate invariants that also apply to values constructed in Rust.
62    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/// Four-stage token balance carried by an execution receipt.
74#[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    /// Validate monotonic context accounting across the four stages.
85    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/// Auditable result of executing an execution plan.
101#[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    /// Capability that produced this receipt, when the producer is known.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub capability_id: Option<String>,
120    /// Version of the capability that produced this receipt.
121    #[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    /// Validate receipt accounting and schema invariants.
141    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}