1use crate::SessionEvent;
12use adk_eval::{ToolTrajectoryScorer, ToolUse};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use sha2::{Digest, Sha256};
16use std::collections::{HashMap, HashSet};
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct ComputerUseEvaluation {
21 pub passed: bool,
23 pub trajectory_score: f64,
25 pub mutations: usize,
27 pub committed: usize,
29 pub violations: Vec<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AdkEvaluationSource {
37 pub path: String,
39 pub digest: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct AdkEvaluationClaims {
47 pub tests_passed: bool,
49 pub auth_bound: bool,
51 pub multimodal_evidence: bool,
53 pub duplicate_mutations: u64,
55 pub crash_points_covered: u64,
57 pub test_count: u64,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct AdkEvaluationReceipt {
68 pub schema_version: u32,
70 pub protocol: String,
72 pub subject_version: String,
74 pub generated_at: String,
76 pub commands: Vec<String>,
78 pub assertions: Vec<String>,
80 pub claims: AdkEvaluationClaims,
82 pub sources: Vec<AdkEvaluationSource>,
84 pub source_digest: String,
86 pub output_digest: String,
88 pub receipt_digest: String,
90}
91
92fn canonical_json(value: &serde_json::Value) -> String {
93 match value {
94 serde_json::Value::Array(values) => {
95 format!("[{}]", values.iter().map(canonical_json).collect::<Vec<_>>().join(","))
96 }
97 serde_json::Value::Object(values) => {
98 let mut entries = values.iter().collect::<Vec<_>>();
99 entries.sort_by_key(|(left, _)| *left);
100 format!(
101 "{{{}}}",
102 entries
103 .into_iter()
104 .map(|(key, value)| format!(
105 "{}:{}",
106 serde_json::to_string(key).unwrap(),
107 canonical_json(value)
108 ))
109 .collect::<Vec<_>>()
110 .join(",")
111 )
112 }
113 _ => serde_json::to_string(value).unwrap(),
114 }
115}
116
117fn sha256(value: &str) -> String {
118 format!("sha256:{:x}", Sha256::digest(value.as_bytes()))
119}
120
121impl AdkEvaluationReceipt {
122 pub fn seal(mut self) -> Result<Self, serde_json::Error> {
129 self.receipt_digest.clear();
130 self.receipt_digest = sha256(&canonical_json(&serde_json::to_value(&self)?));
131 Ok(self)
132 }
133
134 pub fn verify(&self) -> bool {
140 if self.schema_version != 1
141 || self.protocol != "adk-rust-computer-use-v8-evaluation"
142 || self.subject_version.is_empty()
143 || self.commands.len() < 2
144 || self.assertions.is_empty()
145 || self.assertions.iter().collect::<HashSet<_>>().len() != self.assertions.len()
146 || !self.claims.tests_passed
147 || !self.claims.auth_bound
148 || !self.claims.multimodal_evidence
149 || self.claims.duplicate_mutations != 0
150 || self.claims.crash_points_covered < 2
151 || self.claims.test_count == 0
152 || self.sources.is_empty()
153 || !self.source_digest.starts_with("sha256:")
154 || !self.output_digest.starts_with("sha256:")
155 {
156 return false;
157 }
158 self.clone().seal().is_ok_and(|sealed| sealed.receipt_digest == self.receipt_digest)
159 }
160}
161
162pub struct ComputerUseEvaluator {
164 trajectory: ToolTrajectoryScorer,
165}
166
167impl Default for ComputerUseEvaluator {
168 fn default() -> Self {
169 Self { trajectory: ToolTrajectoryScorer::new() }
170 }
171}
172
173impl ComputerUseEvaluator {
174 pub fn evaluate(
181 &self,
182 expected_trajectory: &[ToolUse],
183 events: &[SessionEvent],
184 ) -> ComputerUseEvaluation {
185 let actual = Self::trajectory(events);
186 let trajectory_score = self.trajectory.score(expected_trajectory, &actual);
187 let mut violations = Vec::new();
188 let mut started = HashSet::new();
189 let mut verified = HashSet::new();
190 let mut receipts = HashSet::new();
191 let mut per_action_starts = HashMap::<String, usize>::new();
192 let mut committed = 0;
193
194 for event in events {
195 let action_id = event.action_id.clone().unwrap_or_default();
196 match event.event_type.as_str() {
197 "action.started" => {
198 *per_action_starts.entry(action_id.clone()).or_default() += 1;
199 started.insert(action_id.clone());
200 if event.payload.get("leaseId").is_none_or(serde_json::Value::is_null) {
201 violations.push(format!("mutation_without_lease:{action_id}"));
202 }
203 }
204 "action.verified" => {
205 if !started.contains(&action_id) {
206 violations.push(format!("verification_without_start:{action_id}"));
207 }
208 if event.payload.get("verified").and_then(serde_json::Value::as_bool)
209 == Some(true)
210 {
211 verified.insert(action_id);
212 }
213 }
214 "action.committed" => {
215 committed += 1;
216 if !started.contains(&action_id) {
217 violations.push(format!("commit_without_start:{action_id}"));
218 }
219 if !verified.contains(&action_id) {
220 violations.push(format!("commit_without_verification:{action_id}"));
221 }
222 if let Some(receipt) =
223 event.payload.get("receiptId").and_then(|value| value.as_str())
224 && !receipts.insert(receipt.to_string())
225 {
226 violations.push(format!("duplicate_receipt:{receipt}"));
227 }
228 }
229 _ => {}
230 }
231 }
232 for (action, count) in &per_action_starts {
233 if *count > 1 {
234 violations.push(format!("duplicate_mutation:{action}:{count}"));
235 }
236 }
237 ComputerUseEvaluation {
238 passed: violations.is_empty() && trajectory_score >= 1.0,
239 trajectory_score,
240 mutations: per_action_starts.values().sum(),
241 committed,
242 violations,
243 }
244 }
245
246 pub fn trajectory(events: &[SessionEvent]) -> Vec<ToolUse> {
248 events
249 .iter()
250 .filter(|event| event.event_type == "action.started")
251 .map(|event| {
252 ToolUse::new(
253 event.payload.get("tool").and_then(|value| value.as_str()).unwrap_or("unknown"),
254 )
255 .with_args(json!({
256 "actionId": event.action_id,
257 "mode": event.payload.get("mode"),
258 }))
259 })
260 .collect()
261 }
262}
263
264#[cfg(test)]
265mod receipt_tests {
266 use super::*;
267
268 fn receipt() -> AdkEvaluationReceipt {
269 AdkEvaluationReceipt {
270 schema_version: 1,
271 protocol: "adk-rust-computer-use-v8-evaluation".into(),
272 subject_version: "8.0.0".into(),
273 generated_at: "2026-07-13T12:00:00Z".into(),
274 commands: vec!["cargo test graph".into(), "cargo test multimodal".into()],
275 assertions: vec!["graph.pre_effect_crash".into(), "graph.post_commit_crash".into()],
276 claims: AdkEvaluationClaims {
277 tests_passed: true,
278 auth_bound: true,
279 multimodal_evidence: true,
280 duplicate_mutations: 0,
281 crash_points_covered: 2,
282 test_count: 2,
283 },
284 sources: vec![AdkEvaluationSource {
285 path: "test.rs".into(),
286 digest: format!("sha256:{}", "a".repeat(64)),
287 }],
288 source_digest: format!("sha256:{}", "b".repeat(64)),
289 output_digest: format!("sha256:{}", "c".repeat(64)),
290 receipt_digest: String::new(),
291 }
292 .seal()
293 .unwrap()
294 }
295
296 #[test]
297 fn evaluation_receipt_is_canonical_and_tamper_evident() {
298 let value = receipt();
299 assert!(value.verify());
300 let round_trip: AdkEvaluationReceipt =
301 serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap();
302 assert!(round_trip.verify());
303 let mut tampered = value;
304 tampered.claims.duplicate_mutations = 1;
305 assert!(!tampered.verify());
306 }
307}