Skip to main content

adk_computer_use/
eval.rs

1//! Deterministic release evaluation for computer-use sessions.
2//!
3//! [`ComputerUseEvaluator`] scores an observed [`SessionEvent`] trajectory
4//! against an expected tool sequence and flags safety violations (unleased
5//! mutations, commits without verification, duplicate mutations). The
6//! [`AdkEvaluationReceipt`] is a tamper-evident, canonically-hashed evidence
7//! artifact published for release review — CI produces it, but only an external
8//! release authority signs the matching statement, so CI output cannot
9//! self-promote a release.
10
11use 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/// Outcome of scoring one session's event trajectory.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ComputerUseEvaluation {
21    /// Whether the trajectory scored perfectly and had no violations.
22    pub passed: bool,
23    /// Trajectory similarity score in `0.0..=1.0` against the expected tools.
24    pub trajectory_score: f64,
25    /// Number of `action.started` events observed.
26    pub mutations: usize,
27    /// Number of `action.committed` events observed.
28    pub committed: usize,
29    /// Human-readable safety violations detected during scoring.
30    pub violations: Vec<String>,
31}
32
33/// A source file digest included in an [`AdkEvaluationReceipt`].
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AdkEvaluationSource {
37    /// Path of the source file.
38    pub path: String,
39    /// `sha256:`-prefixed digest of the file contents.
40    pub digest: String,
41}
42
43/// Claims asserted by an [`AdkEvaluationReceipt`] and re-checked on verify.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct AdkEvaluationClaims {
47    /// Whether the full test suite passed.
48    pub tests_passed: bool,
49    /// Whether auth principal/tenant binding was verified.
50    pub auth_bound: bool,
51    /// Whether multimodal (image) evidence delivery was verified.
52    pub multimodal_evidence: bool,
53    /// Number of duplicate mutations observed (must be zero to verify).
54    pub duplicate_mutations: u64,
55    /// Number of crash points covered (must be at least two to verify).
56    pub crash_points_covered: u64,
57    /// Total number of tests executed (must be non-zero to verify).
58    pub test_count: u64,
59}
60
61/// Tamper-evident release-evaluation receipt for a computer-use subject.
62///
63/// Seal a populated receipt with [`AdkEvaluationReceipt::seal`] to compute its
64/// canonical digest, and validate one with [`AdkEvaluationReceipt::verify`].
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct AdkEvaluationReceipt {
68    /// Receipt schema version (must be `1`).
69    pub schema_version: u32,
70    /// Protocol identifier (`adk-rust-computer-use-v8-evaluation`).
71    pub protocol: String,
72    /// Version of the evaluated subject.
73    pub subject_version: String,
74    /// RFC 3339 timestamp the receipt was generated.
75    pub generated_at: String,
76    /// Commands executed to produce the evidence.
77    pub commands: Vec<String>,
78    /// Distinct assertions the evidence proves.
79    pub assertions: Vec<String>,
80    /// Claims re-checked on verification.
81    pub claims: AdkEvaluationClaims,
82    /// Source file digests backing the evidence.
83    pub sources: Vec<AdkEvaluationSource>,
84    /// `sha256:`-prefixed digest over the sources.
85    pub source_digest: String,
86    /// `sha256:`-prefixed digest over the captured output.
87    pub output_digest: String,
88    /// Canonical digest over the whole receipt; empty until sealed.
89    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    /// Compute and set the canonical `receipt_digest` over the whole receipt.
123    ///
124    /// # Errors
125    ///
126    /// Returns a [`serde_json::Error`] if the receipt cannot be serialized to
127    /// JSON for canonicalization.
128    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    /// Validate schema, protocol, claims, and the canonical digest.
135    ///
136    /// Returns `true` only when every claim holds (tests passed, auth bound,
137    /// multimodal evidence present, no duplicate mutations, at least two crash
138    /// points, non-empty sources) and the receipt digest matches a fresh seal.
139    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
162/// Deterministic release evaluator layered beside ADK's task-quality evaluators.
163pub 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    /// Score an observed event trajectory against an expected tool sequence.
175    ///
176    /// Flags unleased mutations, verification/commit ordering violations,
177    /// duplicate receipts, and duplicate mutations. The result
178    /// [`ComputerUseEvaluation::passed`] is `true` only when there are no
179    /// violations and the trajectory score is perfect.
180    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    /// Extract the tool-use trajectory from `action.started` events.
247    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}