1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15pub struct VerdictOutput {
16 pub spec_version: String,
18
19 pub runner_version: String,
21
22 pub run_timestamp: String,
24
25 pub model: ModelMetadata,
27
28 pub conjuncts: ConjunctsOutput,
30
31 pub consistency_check: ConsistencyCheckOutput,
33
34 pub verdict: String,
36
37 #[serde(skip_serializing_if = "Vec::is_empty", default)]
39 pub verdict_reasons: Vec<String>,
40
41 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub known_gaps_acknowledged: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct ModelMetadata {
49 pub id: String,
51
52 pub provider: Option<String>,
54
55 pub version_or_date: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct ConjunctsOutput {
62 pub generality: ConjunctReport,
64
65 pub economic_substitutability: ConjunctReport,
67
68 pub environmental_transfer: ConjunctReport,
70
71 pub autonomous_agency: ConjunctReport,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct ConjunctReport {
80 pub status: String,
82
83 #[serde(skip_serializing_if = "Vec::is_empty", default)]
85 pub evidence: Vec<EvidenceReport>,
86
87 pub margins: Option<MarginReport>,
89}
90
91pub type ConjunctOutput = ConjunctReport;
93
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98pub struct EvidenceReport {
99 pub source: String,
101
102 pub measurement: String,
104
105 pub value: serde_json::Value,
107
108 pub threshold: Option<f64>,
110
111 pub floor: Option<f64>,
113
114 pub passes_threshold: Option<bool>,
116
117 pub below_floor: Option<bool>,
119
120 pub reliability_percentile: u8,
122
123 pub provenance: ProvenanceReport,
125}
126
127pub type EvidenceOutput = EvidenceReport;
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct ProvenanceReport {
133 pub source_url: String,
135
136 pub fetch_timestamp: String,
138
139 pub source_version: Option<String>,
141
142 pub raw_value: String,
144}
145
146pub type ProvenanceOutput = ProvenanceReport;
148
149#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
153pub struct MarginReport {
154 pub min: f64,
156
157 pub max: f64,
159}
160
161pub type MarginOutput = MarginReport;
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ConsistencyCheckOutput {
167 pub status: String,
169
170 #[serde(skip_serializing_if = "Vec::is_empty", default)]
172 pub failed_rules: Vec<String>,
173
174 pub detail: Option<String>,
176}
177
178pub fn generate_schema() -> schemars::schema::RootSchema {
183 schemars::schema_for!(VerdictOutput)
184}
185
186pub fn schema_json_string() -> Result<String, serde_json::Error> {
190 let schema = generate_schema();
191 serde_json::to_string_pretty(&schema)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn verdict_output_serialize_deserialize() {
200 let output = VerdictOutput {
201 spec_version: "0.1.0".to_string(),
202 runner_version: "0.1.0".to_string(),
203 run_timestamp: "2026-05-26T00:00:00Z".to_string(),
204 model: ModelMetadata {
205 id: "test-model".to_string(),
206 provider: Some("test-lab".to_string()),
207 version_or_date: Some("2026-05-26".to_string()),
208 },
209 conjuncts: ConjunctsOutput {
210 generality: ConjunctReport {
211 status: "pass".to_string(),
212 evidence: vec![],
213 margins: None,
214 },
215 economic_substitutability: ConjunctReport {
216 status: "pass".to_string(),
217 evidence: vec![],
218 margins: None,
219 },
220 environmental_transfer: ConjunctReport {
221 status: "partial".to_string(),
222 evidence: vec![],
223 margins: None,
224 },
225 autonomous_agency: ConjunctReport {
226 status: "pass".to_string(),
227 evidence: vec![],
228 margins: None,
229 },
230 },
231 consistency_check: ConsistencyCheckOutput {
232 status: "pass".to_string(),
233 failed_rules: vec![],
234 detail: None,
235 },
236 verdict: "not_attested".to_string(),
237 verdict_reasons: vec!["environmental_transfer".to_string()],
238 known_gaps_acknowledged: vec!["nes_underspecified".to_string()],
239 };
240
241 let json = serde_json::to_string(&output).expect("should serialize");
243 assert!(!json.is_empty());
244
245 let deserialized: VerdictOutput = serde_json::from_str(&json).expect("should deserialize");
247
248 assert_eq!(deserialized.spec_version, output.spec_version);
250 assert_eq!(deserialized.model.id, output.model.id);
251 assert_eq!(deserialized.verdict, output.verdict);
252 assert_eq!(deserialized.verdict_reasons.len(), 1);
253 }
254
255 #[test]
256 fn conjunct_report_serialize() {
257 let report = ConjunctReport {
258 status: "pass".to_string(),
259 evidence: vec![],
260 margins: Some(MarginReport {
261 min: 0.85,
262 max: 0.95,
263 }),
264 };
265
266 let json = serde_json::to_string(&report).expect("should serialize");
267 assert!(json.contains("\"status\":\"pass\""));
268 assert!(json.contains("\"min\":0.85"));
269 }
270
271 #[test]
272 fn evidence_report_with_provenance() {
273 let evidence = EvidenceReport {
274 source: "arc-agi-3".to_string(),
275 measurement: "interactive-private-pass".to_string(),
276 value: serde_json::json!(0.75),
277 threshold: Some(0.50),
278 floor: Some(0.05),
279 passes_threshold: Some(true),
280 below_floor: Some(false),
281 reliability_percentile: 80,
282 provenance: ProvenanceReport {
283 source_url: "https://arcprize.org".to_string(),
284 fetch_timestamp: "2026-05-26T00:00:00Z".to_string(),
285 source_version: Some("v1.0".to_string()),
286 raw_value: "0.75".to_string(),
287 },
288 };
289
290 let json = serde_json::to_string(&evidence).expect("should serialize");
291 let deserialized: EvidenceReport = serde_json::from_str(&json).expect("should deserialize");
292
293 assert_eq!(deserialized.source, "arc-agi-3");
294 assert_eq!(deserialized.passes_threshold, Some(true));
295 assert_eq!(deserialized.provenance.source_url, "https://arcprize.org");
296 }
297
298 #[test]
299 fn model_metadata_with_optional_fields() {
300 let model = ModelMetadata {
301 id: "model-v1".to_string(),
302 provider: None,
303 version_or_date: None,
304 };
305
306 let json = serde_json::to_string(&model).expect("should serialize");
307 assert!(json.contains("\"id\":\"model-v1\""));
308
309 let deserialized: ModelMetadata = serde_json::from_str(&json).expect("should deserialize");
310 assert_eq!(deserialized.id, "model-v1");
311 assert!(deserialized.provider.is_none());
312 }
313
314 #[test]
315 fn margin_report_serialize() {
316 let margin = MarginReport {
317 min: 0.12,
318 max: 2.98,
319 };
320
321 let json = serde_json::to_string(&margin).expect("should serialize");
322 let deserialized: MarginReport = serde_json::from_str(&json).expect("should deserialize");
323
324 assert_eq!(deserialized.min, 0.12);
325 assert_eq!(deserialized.max, 2.98);
326 }
327
328 #[test]
329 fn consistency_check_output_serialize() {
330 let check = ConsistencyCheckOutput {
331 status: "fail".to_string(),
332 failed_rules: vec!["margin_variance_ratio".to_string()],
333 detail: Some("min/max ratio = 0.12, below required 0.5".to_string()),
334 };
335
336 let json = serde_json::to_string(&check).expect("should serialize");
337 assert!(json.contains("margin_variance_ratio"));
338 }
339
340 #[test]
341 fn conjuncts_output_all_variants() {
342 let conjuncts = ConjunctsOutput {
343 generality: ConjunctReport {
344 status: "pass".to_string(),
345 evidence: vec![],
346 margins: None,
347 },
348 economic_substitutability: ConjunctReport {
349 status: "fail".to_string(),
350 evidence: vec![],
351 margins: None,
352 },
353 environmental_transfer: ConjunctReport {
354 status: "partial".to_string(),
355 evidence: vec![],
356 margins: None,
357 },
358 autonomous_agency: ConjunctReport {
359 status: "insufficient_data".to_string(),
360 evidence: vec![],
361 margins: None,
362 },
363 };
364
365 let json = serde_json::to_string(&conjuncts).expect("should serialize");
366 let deserialized: ConjunctsOutput =
367 serde_json::from_str(&json).expect("should deserialize");
368
369 assert_eq!(deserialized.generality.status, "pass");
370 assert_eq!(deserialized.economic_substitutability.status, "fail");
371 assert_eq!(deserialized.environmental_transfer.status, "partial");
372 assert_eq!(deserialized.autonomous_agency.status, "insufficient_data");
373 }
374
375 #[test]
376 fn json_schema_generation() {
377 let schema = schemars::schema_for!(VerdictOutput);
378 assert!(schema.schema.metadata.is_some());
379
380 let schema_json = serde_json::to_string(&schema).expect("should serialize schema");
382 assert!(!schema_json.is_empty());
383 }
384
385 #[test]
386 fn json_schema_for_conjunct_report() {
387 let schema = schemars::schema_for!(ConjunctReport);
388 let schema_json = serde_json::to_string(&schema).expect("should serialize schema");
389 assert!(schema_json.contains("status"));
390 assert!(schema_json.contains("evidence"));
391 }
392
393 #[test]
394 fn skip_serializing_if_empty() {
395 let output = VerdictOutput {
396 spec_version: "0.1.0".to_string(),
397 runner_version: "0.1.0".to_string(),
398 run_timestamp: "2026-05-26T00:00:00Z".to_string(),
399 model: ModelMetadata {
400 id: "test".to_string(),
401 provider: None,
402 version_or_date: None,
403 },
404 conjuncts: ConjunctsOutput {
405 generality: ConjunctReport {
406 status: "pass".to_string(),
407 evidence: vec![],
408 margins: None,
409 },
410 economic_substitutability: ConjunctReport {
411 status: "pass".to_string(),
412 evidence: vec![],
413 margins: None,
414 },
415 environmental_transfer: ConjunctReport {
416 status: "pass".to_string(),
417 evidence: vec![],
418 margins: None,
419 },
420 autonomous_agency: ConjunctReport {
421 status: "pass".to_string(),
422 evidence: vec![],
423 margins: None,
424 },
425 },
426 consistency_check: ConsistencyCheckOutput {
427 status: "pass".to_string(),
428 failed_rules: vec![],
429 detail: None,
430 },
431 verdict: "attested".to_string(),
432 verdict_reasons: vec![],
433 known_gaps_acknowledged: vec![],
434 };
435
436 let json = serde_json::to_string(&output).expect("should serialize");
437 assert!(!json.contains("\"verdict_reasons\":[]"));
439 assert!(!json.contains("\"known_gaps_acknowledged\":[]"));
440 }
441
442 #[test]
443 fn schema_drift_check() {
444 let committed_schema_str = include_str!("../../../../schema/agi4-output-v0.1.0.json");
446 let committed_schema: serde_json::Value = serde_json::from_str(committed_schema_str)
447 .expect("committed schema should be valid JSON");
448
449 let generated_schema = schemars::schema_for!(VerdictOutput);
451 let generated_json = serde_json::to_value(&generated_schema)
452 .expect("generated schema should serialize to JSON");
453
454 if committed_schema != generated_json {
456 let committed_pretty =
458 serde_json::to_string_pretty(&committed_schema).unwrap_or_default();
459 let generated_pretty =
460 serde_json::to_string_pretty(&generated_json).unwrap_or_default();
461
462 panic!(
463 "Schema drift detected!\n\nCommitted schema:\n{}\n\nGenerated schema:\n{}\n\n\
464 To fix, run: `cargo run -p agi4 -- schema > schema/agi4-output-v0.1.0.json`",
465 committed_pretty, generated_pretty
466 );
467 }
468 }
469}