Skip to main content

lean_ctx/core/execution_ledger/
projection.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Read-only projections over execution-ledger events.
3
4use 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/// Token and model-call totals for one task.
14#[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    /// Sum of per-invocation model price-table estimates, never a measured invoice.
21    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    /// Classifies the cost as an estimate whenever model usage was observed.
29    #[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    /// No independently modeled or observed legacy baseline exists.
39    #[must_use]
40    pub const fn baseline_cost_classification(&self) -> ReceiptValueClassificationV1 {
41        ReceiptValueClassificationV1::Unavailable
42    }
43}
44
45/// Verified ledger link to a canonical receipt artifact.
46///
47/// Exact proof still requires resolving the linked `ReceiptDocumentV1` bytes and
48/// cryptographically verifying them; this projection never substitutes for that.
49#[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/// Non-authoritative compatibility projection derived from legacy `ReceiptSigned`.
62///
63/// Its numeric cost fields are model price-table estimates. The legacy baseline
64/// repeats that estimate for wire compatibility and is not an independently
65/// observed or modeled baseline.
66#[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    /// Borrows the non-authoritative legacy receipt payload.
95    #[must_use]
96    pub const fn legacy_receipt(&self) -> &ExecutionReceiptV1 {
97        &self.receipt
98    }
99
100    /// Classifies the legacy cost from whether model usage was observed.
101    #[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    /// The compatibility baseline is not independently modeled or observed.
111    #[must_use]
112    pub const fn baseline_cost_classification(&self) -> ReceiptValueClassificationV1 {
113        ReceiptValueClassificationV1::Unavailable
114    }
115
116    /// Consumes this wrapper for explicitly legacy interoperability.
117    #[must_use]
118    pub fn into_legacy_receipt(self) -> ExecutionReceiptV1 {
119        self.receipt
120    }
121}
122
123/// Read-only projection facade for callers that want to keep the store explicit.
124pub 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    /// Compatibility projection; exact consumers must use the verified variant.
145    #[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    /// Returns authoritative receipt metadata or a verified unavailable error.
154    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    /// Projects token/cost/model-call totals for one task.
164    #[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    /// Builds the explicitly non-authoritative legacy receipt projection.
170    #[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    /// Compatibility projection; exact consumers must use the verified variant.
176    #[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    /// Returns authoritative receipt metadata or a verified unavailable error.
185    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/// Compatibility projection that maps unavailable ledger state to no receipt.
194///
195/// Exact consumers must use [`canonical_receipt_for_task_verified_from_store`].
196#[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
204/// Projects authoritative receipt metadata from a verified, available snapshot.
205pub 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/// Projects the default execution ledger for `task_id`.
247#[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/// Projects an explicitly selected execution ledger for `task_id`.
256#[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/// Builds a non-authoritative compatibility receipt from legacy observed events.
293///
294/// This view exists only for the legacy CLI. It never conveys accepted outcome,
295/// billing, signature, or exact-proof authority; those require a verified
296/// `CanonicalReceiptRecorded` artifact path.
297#[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/// Builds a non-authoritative compatibility receipt from an explicit store.
305#[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}