1use std::path::{Path, PathBuf};
13
14use ed25519_dalek::{Signer, SigningKey};
15use serde::{Deserialize, Serialize};
16
17use super::report::{AbReport, Verdict};
18use super::sha256_hex;
19
20const SCHEMA_VERSION: u32 = 1;
21const KIND: &str = "lean-ctx.eval-ab-artifact";
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct SignedAbReportV1 {
26 pub schema_version: u32,
27 pub kind: String,
28 pub created_at: String,
29 pub lean_ctx_version: String,
30 pub agent_id: String,
31 pub determinism_digest: String,
33 pub verdict: Verdict,
35 pub report: AbReport,
37 pub signer_public_key: Option<String>,
39 pub signature: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct AbVerifyResult {
46 pub signature_valid: bool,
48 pub digest_matches: bool,
50 pub signer_public_key: Option<String>,
51 pub error: Option<String>,
52}
53
54impl AbVerifyResult {
55 pub fn ok(&self) -> bool {
57 self.signature_valid && self.digest_matches
58 }
59}
60
61impl SignedAbReportV1 {
62 pub fn from_report(report: AbReport, agent_id: &str) -> Self {
64 let determinism_digest = determinism_digest(&report);
65 Self {
66 schema_version: SCHEMA_VERSION,
67 kind: KIND.to_string(),
68 created_at: chrono::Utc::now().to_rfc3339(),
69 lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
70 agent_id: agent_id.to_string(),
71 determinism_digest,
72 verdict: report.verdict,
73 report,
74 signer_public_key: None,
75 signature: None,
76 }
77 }
78
79 pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
82 let mut clone = self.clone();
83 clone.signature = None;
84 clone.signer_public_key = None;
85 serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
86 }
87
88 pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
90 let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
91 self.sign_with_key(&key)
92 }
93
94 pub fn sign_with_key(&mut self, key: &SigningKey) -> Result<(), String> {
96 self.signature = None;
97 self.signer_public_key = None;
98 let canonical = self.canonical_bytes()?;
99 let sig = key.sign(&canonical);
100 self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
101 &key.verifying_key().to_bytes(),
102 ));
103 self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
104 Ok(())
105 }
106
107 pub fn verify(&self) -> AbVerifyResult {
109 let digest_matches = determinism_digest(&self.report) == self.determinism_digest;
110 let fail = |msg: &str| AbVerifyResult {
111 signature_valid: false,
112 digest_matches,
113 signer_public_key: self.signer_public_key.clone(),
114 error: Some(msg.to_string()),
115 };
116
117 let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
118 return fail("artifact is not signed");
119 };
120 let (Ok(sig_bytes), Ok(pk_bytes)) = (
121 crate::core::agent_identity::hex_decode(sig_hex),
122 crate::core::agent_identity::hex_decode(pk_hex),
123 ) else {
124 return fail("malformed signature or public key hex");
125 };
126 let canonical = match self.canonical_bytes() {
127 Ok(c) => c,
128 Err(e) => return fail(&e),
129 };
130 if crate::core::agent_identity::verify_signature(&pk_bytes, &canonical, &sig_bytes) {
131 AbVerifyResult {
132 signature_valid: true,
133 digest_matches,
134 signer_public_key: Some(pk_hex.clone()),
135 error: None,
136 }
137 } else {
138 fail("signature does not match payload (tampered or wrong key)")
139 }
140 }
141}
142
143#[derive(Serialize)]
145struct Evidence {
146 suite: String,
147 budget_tokens: usize,
148 model_fingerprint: String,
149 bootstrap_iters: usize,
150 bootstrap_seed: u64,
151 noninferiority_margin: f64,
152 verdict: Verdict,
153 tasks: Vec<EvidenceRow>,
154}
155
156#[derive(Serialize)]
157struct EvidenceRow {
158 task_id: String,
159 domain: String,
160 baseline_value: f64,
161 lean_ctx_value: f64,
162 baseline_passed: bool,
163 lean_ctx_passed: bool,
164 baseline_context_digest: String,
165 lean_ctx_context_digest: String,
166 baseline_answer_digest: String,
167 lean_ctx_answer_digest: String,
168}
169
170pub fn determinism_digest(report: &AbReport) -> String {
172 let mut rows: Vec<EvidenceRow> = report
173 .records
174 .iter()
175 .map(|r| EvidenceRow {
176 task_id: r.task_id.clone(),
177 domain: r.domain.clone(),
178 baseline_value: r.baseline_value,
179 lean_ctx_value: r.lean_ctx_value,
180 baseline_passed: r.baseline_passed,
181 lean_ctx_passed: r.lean_ctx_passed,
182 baseline_context_digest: r.baseline_context_digest.clone(),
183 lean_ctx_context_digest: r.lean_ctx_context_digest.clone(),
184 baseline_answer_digest: r.baseline_answer_digest.clone(),
185 lean_ctx_answer_digest: r.lean_ctx_answer_digest.clone(),
186 })
187 .collect();
188 rows.sort_by(|a, b| a.task_id.cmp(&b.task_id));
189
190 let evidence = Evidence {
191 suite: report.suite.clone(),
192 budget_tokens: report.budget_tokens,
193 model_fingerprint: report.model.digest(),
194 bootstrap_iters: report.stats.bootstrap_iters,
195 bootstrap_seed: report.stats.bootstrap_seed,
196 noninferiority_margin: report.stats.noninferiority_margin,
197 verdict: report.verdict,
198 tasks: rows,
199 };
200 let bytes = serde_json::to_vec(&evidence).unwrap_or_default();
201 sha256_hex(&bytes)
202}
203
204pub fn default_artifact_path() -> Result<PathBuf, String> {
206 let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("eval");
207 std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir eval: {e}"))?;
208 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
209 Ok(dir.join(format!("ab-report-v1_{stamp}.json")))
210}
211
212pub fn write_artifact(artifact: &SignedAbReportV1, out: &Path) -> Result<PathBuf, String> {
214 if let Some(parent) = out.parent() {
215 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
216 }
217 let json = serde_json::to_string_pretty(artifact).map_err(|e| format!("serialize: {e}"))?;
218 std::fs::write(out, json).map_err(|e| format!("write {}: {e}", out.display()))?;
219 Ok(out.to_path_buf())
220}
221
222pub fn load_artifact(path: &Path) -> Result<SignedAbReportV1, String> {
224 let raw = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
225 let artifact: SignedAbReportV1 =
226 serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
227 if artifact.kind != KIND {
228 return Err(format!(
229 "not a {KIND} artifact (kind = {:?})",
230 artifact.kind
231 ));
232 }
233 Ok(artifact)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::core::eval_ab::model::{ModelFingerprint, ModelParams};
240 use crate::core::eval_ab::report::{AbReport, PairRecord, ReportConfig};
241
242 fn report() -> AbReport {
243 let records = vec![PairRecord {
244 task_id: "t1".into(),
245 domain: "qa".into(),
246 baseline_value: 0.2,
247 lean_ctx_value: 0.9,
248 baseline_passed: false,
249 lean_ctx_passed: true,
250 baseline_tokens: 100,
251 lean_ctx_tokens: 90,
252 baseline_context_digest: "ca".into(),
253 lean_ctx_context_digest: "cb".into(),
254 baseline_answer_digest: "aa".into(),
255 lean_ctx_answer_digest: "ab".into(),
256 }];
257 let fp = ModelFingerprint {
258 provider: "recorded".into(),
259 endpoint: "rec".into(),
260 params: ModelParams::default(),
261 };
262 AbReport::build("suite", 4000, fp, records, ReportConfig::default())
263 }
264
265 fn key() -> SigningKey {
266 let mut seed = [0u8; 32];
267 getrandom::fill(&mut seed).unwrap();
268 SigningKey::from_bytes(&seed)
269 }
270
271 #[test]
272 fn digest_ignores_timestamp_and_version() {
273 let a = SignedAbReportV1::from_report(report(), "local");
274 let mut r2 = report();
275 r2.created_at = "2000-01-01T00:00:00Z".into();
276 r2.lean_ctx_version = "0.0.0".into();
277 let b = SignedAbReportV1::from_report(r2, "local");
278 assert_eq!(a.determinism_digest, b.determinism_digest);
279 }
280
281 #[test]
282 fn sign_then_verify_roundtrips() {
283 let mut a = SignedAbReportV1::from_report(report(), "local");
284 a.sign_with_key(&key()).unwrap();
285 let res = a.verify();
286 assert!(res.ok(), "{res:?}");
287 }
288
289 #[test]
290 fn tampering_with_scores_breaks_digest_and_signature() {
291 let mut a = SignedAbReportV1::from_report(report(), "local");
292 a.sign_with_key(&key()).unwrap();
293 a.report.records[0].lean_ctx_value = 0.0;
294 let res = a.verify();
295 assert!(!res.digest_matches, "edited score must break the digest");
296 assert!(
297 !res.signature_valid,
298 "edited payload must break the signature"
299 );
300 }
301
302 #[test]
303 fn write_load_roundtrip_preserves_signature() {
304 let mut a = SignedAbReportV1::from_report(report(), "local");
305 a.sign_with_key(&key()).unwrap();
306 let nanos = std::time::SystemTime::now()
307 .duration_since(std::time::UNIX_EPOCH)
308 .map_or(0, |d| d.as_nanos());
309 let path = std::env::temp_dir().join(format!("lc-ab-{}-{nanos}.json", std::process::id()));
310 write_artifact(&a, &path).unwrap();
311 let loaded = load_artifact(&path).unwrap();
312 assert_eq!(loaded, a);
313 assert!(loaded.verify().ok());
314 let _ = std::fs::remove_file(&path);
315 }
316
317 #[test]
318 fn load_rejects_foreign_json() {
319 let nanos = std::time::SystemTime::now()
320 .duration_since(std::time::UNIX_EPOCH)
321 .map_or(0, |d| d.as_nanos());
322 let path =
323 std::env::temp_dir().join(format!("lc-foreign-ab-{}-{nanos}.json", std::process::id()));
324 std::fs::write(&path, r#"{"kind":"nope"}"#).unwrap();
325 assert!(load_artifact(&path).is_err());
326 let _ = std::fs::remove_file(&path);
327 }
328}