1use lean_ctx_protocol::{
5 EvidenceKind, EvidenceRefV1, ExecutionReceiptV1, ReceiptId, ReceiptValueClassificationV1,
6 SignatureStatus, TaskId,
7};
8use serde::{Deserialize, Deserializer, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::{Result, event::ExecutionEvent, store::ExecutionLedgerStore};
12
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct TaskCostSummary {
16 pub task_id: String,
17 pub total_tokens: u64,
18 pub total_tokens_in: u64,
19 pub total_tokens_out: u64,
20 pub cost_micros: u64,
22 pub total_cost_micros: u64,
23 pub model_calls: u32,
24 pub total_latency_ms: u64,
25}
26
27impl TaskCostSummary {
28 #[must_use]
30 pub const fn cost_classification(&self) -> ReceiptValueClassificationV1 {
31 if self.model_calls == 0 {
32 ReceiptValueClassificationV1::Unavailable
33 } else {
34 ReceiptValueClassificationV1::Estimated
35 }
36 }
37
38 #[must_use]
40 pub const fn baseline_cost_classification(&self) -> ReceiptValueClassificationV1 {
41 ReceiptValueClassificationV1::Unavailable
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct CanonicalReceiptProjectionV1 {
51 pub task_id: String,
52 pub invocation_id: String,
53 pub receipt_id: String,
54 pub receipt_ref: String,
55 pub receipt_digest: String,
56 pub chain_id: String,
57 pub sequence_number: u64,
58 pub previous_receipt_id: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize)]
67#[serde(transparent)]
68pub struct LegacyReceiptProjectionV1 {
69 receipt: ExecutionReceiptV1,
70}
71
72impl<'de> Deserialize<'de> for LegacyReceiptProjectionV1 {
73 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
74 where
75 D: Deserializer<'de>,
76 {
77 let receipt = ExecutionReceiptV1::deserialize(deserializer)?;
78 receipt.validate().map_err(serde::de::Error::custom)?;
79 if receipt.outcome_ref.is_some()
80 || receipt
81 .evidence_refs
82 .iter()
83 .any(|evidence| evidence.signature_status == SignatureStatus::Verified)
84 {
85 return Err(serde::de::Error::custom(
86 "legacy receipt cannot carry outcome or verified-signature authority",
87 ));
88 }
89 Ok(Self { receipt })
90 }
91}
92
93impl LegacyReceiptProjectionV1 {
94 #[must_use]
96 pub const fn legacy_receipt(&self) -> &ExecutionReceiptV1 {
97 &self.receipt
98 }
99
100 #[must_use]
102 pub const fn cost_classification(&self) -> ReceiptValueClassificationV1 {
103 if self.receipt.model_calls == 0 {
104 ReceiptValueClassificationV1::Unavailable
105 } else {
106 ReceiptValueClassificationV1::Estimated
107 }
108 }
109
110 #[must_use]
112 pub const fn baseline_cost_classification(&self) -> ReceiptValueClassificationV1 {
113 ReceiptValueClassificationV1::Unavailable
114 }
115
116 #[must_use]
118 pub fn into_legacy_receipt(self) -> ExecutionReceiptV1 {
119 self.receipt
120 }
121}
122
123pub struct ExecutionProjection<'a> {
125 store: &'a ExecutionLedgerStore,
126}
127
128impl<'a> ExecutionProjection<'a> {
129 #[must_use]
130 pub const fn new(store: &'a ExecutionLedgerStore) -> Self {
131 Self { store }
132 }
133
134 #[must_use]
135 pub fn task_cost_summary(&self, task_id: &str) -> TaskCostSummary {
136 task_cost_summary_from_store(self.store, task_id)
137 }
138
139 #[must_use]
140 pub fn legacy_receipt_for_task(&self, task_id: &str) -> Option<LegacyReceiptProjectionV1> {
141 legacy_receipt_for_task_from_store(self.store, task_id)
142 }
143
144 #[must_use]
146 pub fn canonical_receipt_for_task(
147 &self,
148 task_id: &str,
149 ) -> Option<CanonicalReceiptProjectionV1> {
150 canonical_receipt_for_task_from_store(self.store, task_id)
151 }
152
153 pub fn canonical_receipt_for_task_verified(
155 &self,
156 task_id: &str,
157 ) -> Result<Option<CanonicalReceiptProjectionV1>> {
158 canonical_receipt_for_task_verified_from_store(self.store, task_id)
159 }
160}
161
162impl ExecutionLedgerStore {
163 #[must_use]
165 pub fn task_cost_summary(&self, task_id: &str) -> TaskCostSummary {
166 task_cost_summary_from_store(self, task_id)
167 }
168
169 #[must_use]
171 pub fn legacy_receipt_for_task(&self, task_id: &str) -> Option<LegacyReceiptProjectionV1> {
172 legacy_receipt_for_task_from_store(self, task_id)
173 }
174
175 #[must_use]
177 pub fn canonical_receipt_for_task(
178 &self,
179 task_id: &str,
180 ) -> Option<CanonicalReceiptProjectionV1> {
181 canonical_receipt_for_task_from_store(self, task_id)
182 }
183
184 pub fn canonical_receipt_for_task_verified(
186 &self,
187 task_id: &str,
188 ) -> Result<Option<CanonicalReceiptProjectionV1>> {
189 canonical_receipt_for_task_verified_from_store(self, task_id)
190 }
191}
192
193#[must_use]
197pub fn canonical_receipt_for_task_from_store(
198 store: &ExecutionLedgerStore,
199 task_id: &str,
200) -> Option<CanonicalReceiptProjectionV1> {
201 canonical_receipt_for_events(store.by_task(task_id))
202}
203
204pub fn canonical_receipt_for_task_verified_from_store(
206 store: &ExecutionLedgerStore,
207 task_id: &str,
208) -> Result<Option<CanonicalReceiptProjectionV1>> {
209 Ok(canonical_receipt_for_events(
210 store.by_task_verified(task_id)?,
211 ))
212}
213
214fn canonical_receipt_for_events(
215 events: Vec<ExecutionEvent>,
216) -> Option<CanonicalReceiptProjectionV1> {
217 events.into_iter().rev().find_map(|event| {
218 if let ExecutionEvent::CanonicalReceiptRecorded {
219 task_id,
220 invocation_id,
221 receipt_id,
222 receipt_ref,
223 receipt_digest,
224 receipt_chain_id,
225 receipt_sequence_number,
226 previous_receipt_id,
227 ..
228 } = event
229 {
230 Some(CanonicalReceiptProjectionV1 {
231 task_id,
232 invocation_id,
233 receipt_id,
234 receipt_ref,
235 receipt_digest,
236 chain_id: receipt_chain_id,
237 sequence_number: receipt_sequence_number,
238 previous_receipt_id,
239 })
240 } else {
241 None
242 }
243 })
244}
245
246#[must_use]
248pub fn task_cost_summary(task_id: &str) -> TaskCostSummary {
249 ExecutionLedgerStore::from_default().map_or_else(
250 |_| TaskCostSummary::default(),
251 |store| task_cost_summary_from_store(&store, task_id),
252 )
253}
254
255#[must_use]
257pub fn task_cost_summary_from_store(
258 store: &ExecutionLedgerStore,
259 task_id: &str,
260) -> TaskCostSummary {
261 let mut summary = TaskCostSummary {
262 task_id: task_id.to_owned(),
263 ..TaskCostSummary::default()
264 };
265
266 for event in store.by_task(task_id) {
267 let ExecutionEvent::ModelInvoked {
268 model,
269 tokens_in,
270 tokens_out,
271 latency_ms,
272 ..
273 } = event
274 else {
275 continue;
276 };
277 summary.total_tokens_in = summary.total_tokens_in.saturating_add(tokens_in);
278 summary.total_tokens_out = summary.total_tokens_out.saturating_add(tokens_out);
279 summary.total_tokens = summary
280 .total_tokens_in
281 .saturating_add(summary.total_tokens_out);
282 summary.model_calls = summary.model_calls.saturating_add(1);
283 summary.total_latency_ms = summary.total_latency_ms.saturating_add(latency_ms);
284 let cost = estimate_cost_micros(&model, tokens_in, tokens_out);
285 summary.cost_micros = summary.cost_micros.saturating_add(cost);
286 summary.total_cost_micros = summary.cost_micros;
287 }
288
289 summary
290}
291
292#[must_use]
298pub fn legacy_receipt_for_task(task_id: &str) -> Option<LegacyReceiptProjectionV1> {
299 ExecutionLedgerStore::from_default()
300 .ok()
301 .and_then(|store| legacy_receipt_for_task_from_store(&store, task_id))
302}
303
304#[must_use]
306pub fn legacy_receipt_for_task_from_store(
307 store: &ExecutionLedgerStore,
308 task_id: &str,
309) -> Option<LegacyReceiptProjectionV1> {
310 let events = store.by_task(task_id);
311 let task_id = TaskId::try_from(task_id.to_owned()).ok()?;
312
313 let mut plan_id = None;
314 let mut context_balance = None;
315 let mut receipt_identity: Option<(String, String, String)> = None;
316 let mut decision_refs = Vec::new();
317 let mut model_calls = 0_u32;
318 let mut total_input = 0_u64;
319 let mut total_output = 0_u64;
320 let mut total_latency = 0_u64;
321 let mut estimated_cost_micros = 0_u64;
322 let mut first_model = None;
323 let mut last_model = None;
324 let mut provider = None;
325 let mut has_canonical_receipt = false;
326
327 for event in events {
328 match event {
329 ExecutionEvent::PlanCreated { plan_id: value, .. } => plan_id = Some(value),
330 ExecutionEvent::ContextDelivered {
331 context_balance: value,
332 ..
333 } => context_balance = Some(value),
334 ExecutionEvent::ModelInvoked {
335 plan_id: invoked_plan,
336 model,
337 provider: invoked_provider,
338 tokens_in,
339 tokens_out,
340 latency_ms,
341 ..
342 } => {
343 if plan_id.is_none() {
344 plan_id = Some(invoked_plan);
345 }
346 model_calls = model_calls.saturating_add(1);
347 total_input = total_input.saturating_add(tokens_in);
348 total_output = total_output.saturating_add(tokens_out);
349 total_latency = total_latency.saturating_add(latency_ms);
350 estimated_cost_micros = estimated_cost_micros
351 .saturating_add(estimate_cost_micros(&model, tokens_in, tokens_out));
352 if first_model.is_none() {
353 first_model = Some(model.clone());
354 }
355 last_model = Some(model);
356 provider = Some(invoked_provider);
357 }
358 ExecutionEvent::ReceiptSigned {
359 receipt_id,
360 receipt_hash,
361 signature,
362 ..
363 } => receipt_identity = Some((receipt_id, receipt_hash, signature)),
364 ExecutionEvent::DecisionRecorded { decision_id, .. } => decision_refs.push(decision_id),
365 ExecutionEvent::CanonicalReceiptRecorded { .. } => has_canonical_receipt = true,
366 ExecutionEvent::OutcomeRecorded { .. }
367 | ExecutionEvent::TaskStarted { .. }
368 | ExecutionEvent::EngineInvoked { .. } => {}
369 }
370 }
371
372 if has_canonical_receipt {
373 return None;
374 }
375
376 let plan_id = plan_id?;
377 let plan_id = lean_ctx_protocol::PlanId::try_from(plan_id).ok()?;
378 let context_balance = context_balance?;
379 let (receipt_id, receipt_hash, signature) = receipt_identity?;
380 let receipt_id = ReceiptId::try_from(receipt_id).ok()?;
381 let requested_model = first_model?;
382 let selected_model = last_model?;
383 let provider = provider?;
384 if model_calls == 0 {
385 return None;
386 }
387
388 let actual_cost_micros = estimated_cost_micros;
389 let evidence_refs = vec![EvidenceRefV1 {
390 schema_version: Some(1),
391 kind: EvidenceKind::ProviderReceipt,
392 uri: format!("receipt:{receipt_hash}"),
393 digest: evidence_digest(&receipt_hash),
394 signature_status: if signature.is_empty() {
395 SignatureStatus::NotSigned
396 } else {
397 SignatureStatus::Unverified
398 },
399 media_type: Some("application/json".to_owned()),
400 extensions: Default::default(),
401 }];
402
403 let receipt = ExecutionReceiptV1 {
404 schema_version: 1,
405 receipt_id,
406 task_id,
407 plan_id,
408 context_balance,
409 fresh_input_tokens: total_input,
410 cached_input_tokens: 0,
411 output_tokens: total_output,
412 reasoning_tokens: 0,
413 requested_model,
414 selected_model,
415 provider,
416 capability_id: None,
417 capability_version: None,
418 model_calls,
419 retries: 0,
420 latency_ms: total_latency,
421 actual_cost_micros,
422 baseline_cost_micros: actual_cost_micros,
423 avoided_cost_micros: 0,
424 etpao_milli: 0,
425 outcome_ref: None,
426 knowledge_refs: Vec::new(),
427 decision_refs,
428 evidence_refs,
429 signature,
430 executor_agent_id: None,
431 attempt_id: None,
432 context_plan_id: None,
433 context_receipt_ref: None,
434 capability_bindings: Vec::new(),
435 observations: lean_ctx_protocol::ExecutionObservationsV1 {
436 model_calls: Some(model_calls),
437 retries: Some(0),
438 latency_ms: Some(total_latency),
439 actual_cost_micros: Some(actual_cost_micros),
440 },
441 extensions: Default::default(),
442 };
443 receipt.validate().ok()?;
444 Some(LegacyReceiptProjectionV1 { receipt })
445}
446
447fn evidence_digest(value: &str) -> String {
448 let digest = value
449 .strip_prefix("sha256:")
450 .or_else(|| value.strip_prefix("blake3:"))
451 .unwrap_or(value);
452 if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
453 return value.to_owned();
454 }
455 let digest = Sha256::digest(value.as_bytes()).iter().fold(
456 String::with_capacity(64),
457 |mut output, byte| {
458 use std::fmt::Write;
459 write!(&mut output, "{byte:02x}").expect("writing to a string cannot fail");
460 output
461 },
462 );
463 format!("sha256:{digest}")
464}
465
466fn estimate_cost_micros(model: &str, tokens_in: u64, tokens_out: u64) -> u64 {
467 let quote = crate::core::gain::model_pricing::ModelPricing::load().quote(Some(model));
468 let usd = quote.cost.estimate_usd(tokens_in, tokens_out, 0, 0);
469 if !usd.is_finite() || usd <= 0.0 {
470 return 0;
471 }
472 (usd * 1_000_000.0).round() as u64
473}