Skip to main content

agi4_schema/
lib.rs

1//! JSON schema and serialization types for AGI/4 verdicts.
2//!
3//! This crate defines the output types that serialize to JSON conforming to
4//! SPEC.md §7 provenance requirements and ARCHITECTURE.md §7 schema.
5//! JSON schema is exported and validated against committed schema files in CI.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// The top-level verdict output JSON.
11///
12/// Contains the complete verdict result with evidence, consistency check results,
13/// and verdict reasons. Serializes to JSON matching ARCHITECTURE.md §7 schema.
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15pub struct VerdictOutput {
16    /// AGI/4 specification version (SemVer).
17    pub spec_version: String,
18
19    /// Runner version (SemVer).
20    pub runner_version: String,
21
22    /// ISO 8601 timestamp of verdict computation.
23    pub run_timestamp: String,
24
25    /// Model being evaluated.
26    pub model: ModelMetadata,
27
28    /// Per-conjunct evaluation results.
29    pub conjuncts: ConjunctsOutput,
30
31    /// Cross-conjunct consistency check result.
32    pub consistency_check: ConsistencyCheckOutput,
33
34    /// The final verdict: "attested", "not_attested", or "insufficient_data".
35    pub verdict: String,
36
37    /// Reasons why verdict is not attested (if applicable).
38    #[serde(skip_serializing_if = "Vec::is_empty", default)]
39    pub verdict_reasons: Vec<String>,
40
41    /// Known gaps in the specification and measurements.
42    #[serde(skip_serializing_if = "Vec::is_empty", default)]
43    pub known_gaps_acknowledged: Vec<String>,
44}
45
46/// Model identification metadata.
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct ModelMetadata {
49    /// Model identifier.
50    pub id: String,
51
52    /// Organization/lab that created the model.
53    pub provider: Option<String>,
54
55    /// Model version or release date.
56    pub version_or_date: Option<String>,
57}
58
59/// Output for all four conjuncts.
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct ConjunctsOutput {
62    /// Generality conjunct evaluation.
63    pub generality: ConjunctReport,
64
65    /// Economic substitutability conjunct evaluation.
66    pub economic_substitutability: ConjunctReport,
67
68    /// Environmental transfer conjunct evaluation.
69    pub environmental_transfer: ConjunctReport,
70
71    /// Autonomous agency conjunct evaluation.
72    pub autonomous_agency: ConjunctReport,
73}
74
75/// Output for a single conjunct (aliased as ConjunctReport per DoD).
76///
77/// Reports the evaluation status, evidence, and margin information for a conjunct.
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct ConjunctReport {
80    /// Conjunct status: "pass", "partial", "fail", or "insufficient_data".
81    pub status: String,
82
83    /// Evidence from upstream sources contributing to this conjunct.
84    #[serde(skip_serializing_if = "Vec::is_empty", default)]
85    pub evidence: Vec<EvidenceReport>,
86
87    /// Min/max margin information (used by consistency check).
88    pub margins: Option<MarginReport>,
89}
90
91/// Alias for backward compatibility.
92pub type ConjunctOutput = ConjunctReport;
93
94/// Evidence report with threshold comparison information.
95///
96/// Wraps Evidence with computed threshold and floor comparisons.
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98pub struct EvidenceReport {
99    /// Source identifier (e.g., "arc-agi-3", "metr-80pct-time-horizon").
100    pub source: String,
101
102    /// Measurement identifier within the source.
103    pub measurement: String,
104
105    /// Measurement value (fraction, hours, or other).
106    pub value: serde_json::Value,
107
108    /// Threshold value for this source (if known).
109    pub threshold: Option<f64>,
110
111    /// Floor value for this source (if known).
112    pub floor: Option<f64>,
113
114    /// Whether value passes threshold.
115    pub passes_threshold: Option<bool>,
116
117    /// Whether value is below floor.
118    pub below_floor: Option<bool>,
119
120    /// Reliability percentile of the measurement.
121    pub reliability_percentile: u8,
122
123    /// Provenance metadata for the measurement.
124    pub provenance: ProvenanceReport,
125}
126
127/// Alias for backward compatibility.
128pub type EvidenceOutput = EvidenceReport;
129
130/// Provenance metadata for an evidence measurement.
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct ProvenanceReport {
133    /// Source URL or API endpoint.
134    pub source_url: String,
135
136    /// ISO 8601 timestamp when data was fetched.
137    pub fetch_timestamp: String,
138
139    /// Source version or dataset version (if applicable).
140    pub source_version: Option<String>,
141
142    /// The raw value as ingested (before parsing/validation).
143    pub raw_value: String,
144}
145
146/// Alias for backward compatibility.
147pub type ProvenanceOutput = ProvenanceReport;
148
149/// Margin information for a conjunct's evidence.
150///
151/// Used by consistency check to validate margin variance.
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
153pub struct MarginReport {
154    /// Minimum margin (value / threshold) across sources.
155    pub min: f64,
156
157    /// Maximum margin (value / threshold) across sources.
158    pub max: f64,
159}
160
161/// Alias for backward compatibility.
162pub type MarginOutput = MarginReport;
163
164/// Consistency check result.
165#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ConsistencyCheckOutput {
167    /// Check status: "pass" or "fail".
168    pub status: String,
169
170    /// Which sub-rules failed (e.g., "margin_variance_ratio").
171    #[serde(skip_serializing_if = "Vec::is_empty", default)]
172    pub failed_rules: Vec<String>,
173
174    /// Human-readable detail on why check failed.
175    pub detail: Option<String>,
176}
177
178/// Generate the JSON schema for the AGI/4 verdict output.
179///
180/// Returns the complete JSON schema that describes the structure of VerdictOutput.
181/// This schema is committed to the repository and validated in CI to detect drift.
182pub fn generate_schema() -> schemars::schema::RootSchema {
183    schemars::schema_for!(VerdictOutput)
184}
185
186/// Serialize the JSON schema to a pretty-printed JSON string.
187///
188/// Used for both CLI output and schema validation in CI.
189pub 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        // Serialize to JSON
242        let json = serde_json::to_string(&output).expect("should serialize");
243        assert!(!json.is_empty());
244
245        // Deserialize back
246        let deserialized: VerdictOutput = serde_json::from_str(&json).expect("should deserialize");
247
248        // Verify round-trip
249        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        // Verify it can be serialized to JSON schema
381        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        // Empty vecs should not be serialized
438        assert!(!json.contains("\"verdict_reasons\":[]"));
439        assert!(!json.contains("\"known_gaps_acknowledged\":[]"));
440    }
441
442    #[test]
443    fn schema_drift_check() {
444        // Load the committed schema file
445        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        // Generate schema from current code
450        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        // Compare: if they don't match, schema has drifted
455        if committed_schema != generated_json {
456            // For debugging, show the diff
457            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}