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}