Skip to main content

git_internal/internal/object/
provenance.rs

1//! AI Provenance Definition
2//!
3//! A `Provenance` records **how** a [`Run`](super::run::Run) was executed:
4//! which LLM provider, model, and parameters were used, and how many
5//! tokens were consumed. It is the "lab notebook" for AI execution —
6//! capturing the exact configuration so results can be reproduced,
7//! compared, and accounted for.
8//!
9//! # Position in Lifecycle
10//!
11//! ```text
12//! Run ──(1:1)──▶ Provenance
13//!  │
14//!  ├── patchsets ──▶ [PatchSet₀, ...]
15//!  ├── evidence  ──▶ [Evidence₀, ...]
16//!  └── decision  ──▶ Decision
17//! ```
18//!
19//! A Provenance is created **once per Run**, typically at run start
20//! when the orchestrator selects the model and provider. Token usage
21//! (`token_usage`) is populated after the Run completes. The
22//! Provenance is a sibling of PatchSet, Evidence, and Decision —
23//! all attached to the same Run but serving different purposes.
24//!
25//! # Purpose
26//!
27//! - **Reproducibility**: Given the same model, parameters, and
28//!   [`ContextSnapshot`](super::context::ContextSnapshot), the agent
29//!   should produce equivalent results.
30//! - **Cost Accounting**: `token_usage.cost_usd` enables per-Run and
31//!   per-Task cost tracking and budgeting.
32//! - **Optimization**: Comparing Provenance across Runs of the same
33//!   Task reveals which model/parameter combinations yield better
34//!   results or lower cost.
35
36use std::fmt;
37
38use serde::{Deserialize, Serialize};
39use uuid::Uuid;
40
41use crate::{
42    errors::GitError,
43    hash::ObjectHash,
44    internal::object::{
45        ObjectTrait,
46        types::{ActorRef, Header, ObjectType},
47    },
48};
49
50/// Normalized token usage across providers.
51///
52/// All fields use a provider-neutral representation so that usage
53/// from different LLM providers (OpenAI, Anthropic, etc.) can be
54/// compared directly.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct TokenUsage {
57    /// Number of tokens in the prompt / input.
58    pub input_tokens: u64,
59    /// Number of tokens in the completion / output.
60    pub output_tokens: u64,
61    /// `input_tokens + output_tokens`. Stored explicitly for quick
62    /// aggregation; [`is_consistent`](TokenUsage::is_consistent)
63    /// verifies the invariant.
64    pub total_tokens: u64,
65    /// Estimated cost in USD for this usage, if the provider reports
66    /// pricing. `None` when pricing data is unavailable.
67    pub cost_usd: Option<f64>,
68}
69
70impl TokenUsage {
71    pub fn is_consistent(&self) -> bool {
72        self.total_tokens == self.input_tokens + self.output_tokens
73    }
74
75    pub fn cost_per_token(&self) -> Option<f64> {
76        if self.total_tokens == 0 {
77            return None;
78        }
79        self.cost_usd.map(|cost| cost / self.total_tokens as f64)
80    }
81}
82
83/// LLM provider/model configuration and usage for a single Run.
84///
85/// Created once per Run. See module documentation for lifecycle
86/// position and purpose.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Provenance {
89    /// Common header (object ID, type, timestamps, creator, etc.).
90    #[serde(flatten)]
91    header: Header,
92    /// The [`Run`](super::run::Run) this Provenance describes.
93    ///
94    /// Every Provenance belongs to exactly one Run. The Run does not
95    /// store a back-reference; lookup is done by scanning or indexing.
96    run_id: Uuid,
97    /// LLM provider identifier (e.g. "openai", "anthropic", "local").
98    ///
99    /// Used together with `model` to fully identify the AI backend.
100    /// The value is a free-form string; no enum is imposed because
101    /// new providers appear frequently.
102    provider: String,
103    /// Model identifier as returned by the provider (e.g.
104    /// "gpt-4", "claude-opus-4-20250514", "llama-3-70b").
105    ///
106    /// Should match the provider's official model ID so that results
107    /// can be correlated with the provider's documentation and pricing.
108    model: String,
109    /// Provider-specific raw parameters payload.
110    ///
111    /// A catch-all JSON object for parameters that don't have
112    /// dedicated fields (e.g. `top_p`, `frequency_penalty`, custom
113    /// system prompts). `None` when no extra parameters were set.
114    /// `temperature` and `max_tokens` are extracted into dedicated
115    /// fields for convenience but may also appear here.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    parameters: Option<serde_json::Value>,
118    /// Sampling temperature used for generation.
119    ///
120    /// `0.0` = deterministic, higher = more creative. `None` if the
121    /// provider default was used. The getter falls back to
122    /// `parameters.temperature` when this field is not set.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    temperature: Option<f64>,
125    /// Maximum number of tokens the model was allowed to generate.
126    ///
127    /// `None` if the provider default was used. The getter falls back
128    /// to `parameters.max_tokens` when this field is not set.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    max_tokens: Option<u64>,
131    /// Token consumption and cost for this Run.
132    ///
133    /// Populated after the Run completes. `None` while the Run is
134    /// still in progress or if the provider does not report usage.
135    /// See [`TokenUsage`] for field details.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    token_usage: Option<TokenUsage>,
138}
139
140impl Provenance {
141    pub fn new(
142        created_by: ActorRef,
143        run_id: Uuid,
144        provider: impl Into<String>,
145        model: impl Into<String>,
146    ) -> Result<Self, String> {
147        Ok(Self {
148            header: Header::new(ObjectType::Provenance, created_by)?,
149            run_id,
150            provider: provider.into(),
151            model: model.into(),
152            parameters: None,
153            temperature: None,
154            max_tokens: None,
155            token_usage: None,
156        })
157    }
158
159    pub fn header(&self) -> &Header {
160        &self.header
161    }
162
163    pub fn run_id(&self) -> Uuid {
164        self.run_id
165    }
166
167    pub fn provider(&self) -> &str {
168        &self.provider
169    }
170
171    pub fn model(&self) -> &str {
172        &self.model
173    }
174
175    /// Provider-specific raw parameters payload.
176    pub fn parameters(&self) -> Option<&serde_json::Value> {
177        self.parameters.as_ref()
178    }
179
180    /// Normalized temperature if available.
181    pub fn temperature(&self) -> Option<f64> {
182        self.temperature.or_else(|| {
183            self.parameters
184                .as_ref()
185                .and_then(|p| p.get("temperature"))
186                .and_then(|v| v.as_f64())
187        })
188    }
189
190    /// Normalized max_tokens if available.
191    pub fn max_tokens(&self) -> Option<u64> {
192        self.max_tokens.or_else(|| {
193            self.parameters
194                .as_ref()
195                .and_then(|p| p.get("max_tokens"))
196                .and_then(|v| v.as_u64())
197        })
198    }
199
200    pub fn token_usage(&self) -> Option<&TokenUsage> {
201        self.token_usage.as_ref()
202    }
203
204    pub fn set_parameters(&mut self, parameters: Option<serde_json::Value>) {
205        self.parameters = parameters;
206    }
207
208    pub fn set_temperature(&mut self, temperature: Option<f64>) {
209        self.temperature = temperature;
210    }
211
212    pub fn set_max_tokens(&mut self, max_tokens: Option<u64>) {
213        self.max_tokens = max_tokens;
214    }
215
216    pub fn set_token_usage(&mut self, token_usage: Option<TokenUsage>) {
217        self.token_usage = token_usage;
218    }
219}
220
221impl fmt::Display for Provenance {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(f, "Provenance: {}", self.header.object_id())
224    }
225}
226
227impl ObjectTrait for Provenance {
228    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
229    where
230        Self: Sized,
231    {
232        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
233    }
234
235    fn get_type(&self) -> ObjectType {
236        ObjectType::Provenance
237    }
238
239    fn get_size(&self) -> usize {
240        match serde_json::to_vec(self) {
241            Ok(v) => v.len(),
242            Err(e) => {
243                tracing::warn!("failed to compute Provenance size: {}", e);
244                0
245            }
246        }
247    }
248
249    fn to_data(&self) -> Result<Vec<u8>, GitError> {
250        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_provenance_fields() {
260        let actor = ActorRef::agent("test-agent").expect("actor");
261        let run_id = Uuid::from_u128(0x1);
262
263        let mut provenance = Provenance::new(actor, run_id, "openai", "gpt-4").expect("provenance");
264        provenance.set_parameters(Some(
265            serde_json::json!({"temperature": 0.2, "max_tokens": 128}),
266        ));
267        provenance.set_temperature(Some(0.2));
268        provenance.set_max_tokens(Some(128));
269        provenance.set_token_usage(Some(TokenUsage {
270            input_tokens: 10,
271            output_tokens: 5,
272            total_tokens: 15,
273            cost_usd: Some(0.001),
274        }));
275
276        assert!(provenance.parameters().is_some());
277        assert_eq!(provenance.temperature(), Some(0.2));
278        assert_eq!(provenance.max_tokens(), Some(128));
279        let usage = provenance.token_usage().expect("token usage");
280        assert_eq!(usage.input_tokens, 10);
281        assert_eq!(usage.output_tokens, 5);
282        assert_eq!(usage.total_tokens, 15);
283        assert_eq!(usage.cost_usd, Some(0.001));
284    }
285}