Skip to main content

chio_conformance/
econsim.rs

1use std::collections::BTreeSet;
2
3use chio_core::receipt::lineage::SignedExportEnvelope;
4use chio_core::Keypair;
5use serde::{Deserialize, Serialize};
6
7pub const ECONSIM_SCENARIO_RESULT_SCHEMA: &str = "chio.econsim.scenario-result.v1";
8pub const ECONSIM_QUALIFICATION_MATRIX_SCHEMA: &str = "chio.econsim.qualification-matrix.v1";
9
10pub const ECONSIM_SCENARIO_CLASSES: [&str; 6] = [
11    "collusion-bid-ring",
12    "credit-exhaustion",
13    "fee-structuring",
14    "oracle-divergence",
15    "settlement-dos",
16    "sybil-pricing-ring",
17];
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum EconsimDisposition {
22    FailClosed,
23    Breach,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum EconsimFindingSeverity {
29    Low,
30    Medium,
31    High,
32    Critical,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum EconsimTargetStatus {
38    Bound,
39    Missing,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum EconsimOutcome {
45    Held,
46    Finding,
47    TargetMissing,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct EconsimScenarioResult {
53    pub schema: String,
54    pub scenario_id: String,
55    pub scenario_class: String,
56    pub seed: u64,
57    pub corpus_manifest_digest: String,
58    pub requirement_ids: Vec<String>,
59    pub expected_disposition: EconsimDisposition,
60    pub observed_disposition: Option<EconsimDisposition>,
61    pub target_status: EconsimTargetStatus,
62    pub outcome: EconsimOutcome,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub finding_severity: Option<EconsimFindingSeverity>,
65    pub assertion_scope: String,
66    pub explicit_limits: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct EconsimHarnessProvenance {
72    pub git_commit: String,
73    pub source_tree_state: String,
74    pub source_tree_digest: String,
75    pub executable_digest: String,
76    pub cargo_lock_digest: String,
77    pub enabled_features: Vec<String>,
78    pub target_triple: String,
79    pub rustc_version: String,
80    pub cargo_version: String,
81    pub command: Vec<String>,
82    pub scenario_manifest_digest: String,
83    pub corpus_manifest_digest: String,
84    pub runner_key_id: String,
85    pub qualification_scope: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct EconsimQualificationMatrix {
91    pub schema: String,
92    pub profile_id: String,
93    pub harness_provenance: EconsimHarnessProvenance,
94    pub cases: Vec<EconsimScenarioResult>,
95}
96
97pub type SignedEconsimQualificationMatrix = SignedExportEnvelope<EconsimQualificationMatrix>;
98
99#[derive(Debug, thiserror::Error, PartialEq, Eq)]
100pub enum EconsimValidationError {
101    #[error("unsupported econsim schema: {0}")]
102    UnsupportedSchema(String),
103    #[error("econsim field must not be empty: {0}")]
104    EmptyField(&'static str),
105    #[error("econsim scenario seed must be non-zero")]
106    ZeroSeed,
107    #[error("unknown econsim scenario class: {0}")]
108    UnknownClass(String),
109    #[error("duplicate econsim scenario id: {0}")]
110    DuplicateScenario(String),
111    #[error("invalid econsim result: {0}")]
112    InvalidResult(&'static str),
113    #[error("econsim matrix is missing scenario class: {0}")]
114    MissingClass(String),
115    #[error("econsim matrix cannot be signed: {0}")]
116    SigningBoundary(&'static str),
117    #[error("econsim matrix signing failed: {0}")]
118    Signing(String),
119}
120
121pub fn validate_econsim_scenario_result(
122    result: &EconsimScenarioResult,
123) -> Result<(), EconsimValidationError> {
124    if result.schema != ECONSIM_SCENARIO_RESULT_SCHEMA {
125        return Err(EconsimValidationError::UnsupportedSchema(
126            result.schema.clone(),
127        ));
128    }
129    for (value, field) in [
130        (&result.scenario_id, "scenarioId"),
131        (&result.scenario_class, "scenarioClass"),
132        (&result.corpus_manifest_digest, "corpusManifestDigest"),
133        (&result.assertion_scope, "assertionScope"),
134    ] {
135        if value.trim().is_empty() {
136            return Err(EconsimValidationError::EmptyField(field));
137        }
138    }
139    if !ECONSIM_SCENARIO_CLASSES.contains(&result.scenario_class.as_str()) {
140        return Err(EconsimValidationError::UnknownClass(
141            result.scenario_class.clone(),
142        ));
143    }
144    if result.seed == 0 {
145        return Err(EconsimValidationError::ZeroSeed);
146    }
147    if result.requirement_ids.is_empty() {
148        return Err(EconsimValidationError::EmptyField("requirementIds"));
149    }
150    if result.explicit_limits.is_empty() {
151        return Err(EconsimValidationError::EmptyField("explicitLimits"));
152    }
153    if result.expected_disposition != EconsimDisposition::FailClosed {
154        return Err(EconsimValidationError::InvalidResult(
155            "expected disposition must be fail-closed",
156        ));
157    }
158    match result.outcome {
159        EconsimOutcome::Held
160            if result.target_status == EconsimTargetStatus::Bound
161                && result.observed_disposition == Some(EconsimDisposition::FailClosed)
162                && result.finding_severity.is_none() => {}
163        EconsimOutcome::Finding
164            if result.target_status == EconsimTargetStatus::Bound
165                && result.observed_disposition == Some(EconsimDisposition::Breach)
166                && result.finding_severity.is_some() => {}
167        EconsimOutcome::TargetMissing
168            if result.target_status == EconsimTargetStatus::Missing
169                && result.observed_disposition.is_none()
170                && result.finding_severity == Some(EconsimFindingSeverity::High) => {}
171        _ => {
172            return Err(EconsimValidationError::InvalidResult(
173                "outcome, target, disposition, and severity disagree",
174            ));
175        }
176    }
177    Ok(())
178}
179
180pub fn validate_econsim_qualification_matrix(
181    matrix: &EconsimQualificationMatrix,
182) -> Result<(), EconsimValidationError> {
183    if matrix.schema != ECONSIM_QUALIFICATION_MATRIX_SCHEMA {
184        return Err(EconsimValidationError::UnsupportedSchema(
185            matrix.schema.clone(),
186        ));
187    }
188    if matrix.profile_id.trim().is_empty() {
189        return Err(EconsimValidationError::EmptyField("profileId"));
190    }
191    if matrix.cases.is_empty() {
192        return Err(EconsimValidationError::EmptyField("cases"));
193    }
194    validate_provenance(&matrix.harness_provenance)?;
195    let mut ids = BTreeSet::new();
196    let mut classes = BTreeSet::new();
197    for case in &matrix.cases {
198        validate_econsim_scenario_result(case)?;
199        if !ids.insert(case.scenario_id.as_str()) {
200            return Err(EconsimValidationError::DuplicateScenario(
201                case.scenario_id.clone(),
202            ));
203        }
204        classes.insert(case.scenario_class.as_str());
205    }
206    for class in ECONSIM_SCENARIO_CLASSES {
207        if !classes.contains(class) {
208            return Err(EconsimValidationError::MissingClass(class.to_owned()));
209        }
210    }
211    Ok(())
212}
213
214pub fn sign_econsim_qualification_matrix(
215    matrix: EconsimQualificationMatrix,
216    signer: &Keypair,
217) -> Result<SignedEconsimQualificationMatrix, EconsimValidationError> {
218    validate_econsim_qualification_matrix(&matrix)?;
219    if matrix.cases.iter().any(|case| {
220        case.outcome == EconsimOutcome::TargetMissing
221            || case.finding_severity >= Some(EconsimFindingSeverity::High)
222    }) {
223        return Err(EconsimValidationError::SigningBoundary(
224            "missing targets and unresolved high or critical findings are not signable",
225        ));
226    }
227    SignedExportEnvelope::sign(matrix, signer)
228        .map_err(|error| EconsimValidationError::Signing(error.to_string()))
229}
230
231fn validate_provenance(
232    provenance: &EconsimHarnessProvenance,
233) -> Result<(), EconsimValidationError> {
234    for (value, field) in [
235        (&provenance.git_commit, "harnessProvenance.gitCommit"),
236        (
237            &provenance.source_tree_state,
238            "harnessProvenance.sourceTreeState",
239        ),
240        (
241            &provenance.source_tree_digest,
242            "harnessProvenance.sourceTreeDigest",
243        ),
244        (
245            &provenance.executable_digest,
246            "harnessProvenance.executableDigest",
247        ),
248        (
249            &provenance.cargo_lock_digest,
250            "harnessProvenance.cargoLockDigest",
251        ),
252        (&provenance.target_triple, "harnessProvenance.targetTriple"),
253        (&provenance.rustc_version, "harnessProvenance.rustcVersion"),
254        (&provenance.cargo_version, "harnessProvenance.cargoVersion"),
255        (
256            &provenance.scenario_manifest_digest,
257            "harnessProvenance.scenarioManifestDigest",
258        ),
259        (
260            &provenance.corpus_manifest_digest,
261            "harnessProvenance.corpusManifestDigest",
262        ),
263        (&provenance.runner_key_id, "harnessProvenance.runnerKeyId"),
264        (
265            &provenance.qualification_scope,
266            "harnessProvenance.qualificationScope",
267        ),
268    ] {
269        if value.trim().is_empty() {
270            return Err(EconsimValidationError::EmptyField(field));
271        }
272    }
273    if provenance.command.is_empty() {
274        return Err(EconsimValidationError::EmptyField(
275            "harnessProvenance.command",
276        ));
277    }
278    Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn held(class: &str) -> EconsimScenarioResult {
286        EconsimScenarioResult {
287            schema: ECONSIM_SCENARIO_RESULT_SCHEMA.to_owned(),
288            scenario_id: format!("{class}-seed-7"),
289            scenario_class: class.to_owned(),
290            seed: 7,
291            corpus_manifest_digest: "a".repeat(64),
292            requirement_ids: vec!["AE-TEST".to_owned()],
293            expected_disposition: EconsimDisposition::FailClosed,
294            observed_disposition: Some(EconsimDisposition::FailClosed),
295            target_status: EconsimTargetStatus::Bound,
296            outcome: EconsimOutcome::Held,
297            finding_severity: None,
298            assertion_scope: "the named production validator rejected the corpus".to_owned(),
299            explicit_limits: vec!["internal qualification only".to_owned()],
300        }
301    }
302
303    fn provenance() -> EconsimHarnessProvenance {
304        EconsimHarnessProvenance {
305            git_commit: "abc".to_owned(),
306            source_tree_state: "clean".to_owned(),
307            source_tree_digest: "b".repeat(64),
308            executable_digest: "c".repeat(64),
309            cargo_lock_digest: "d".repeat(64),
310            enabled_features: Vec::new(),
311            target_triple: "test-target".to_owned(),
312            rustc_version: "rustc test".to_owned(),
313            cargo_version: "cargo test".to_owned(),
314            command: vec!["chio-econsim-runner".to_owned()],
315            scenario_manifest_digest: "e".repeat(64),
316            corpus_manifest_digest: "a".repeat(64),
317            runner_key_id: "runner-test".to_owned(),
318            qualification_scope: "internal qualification only".to_owned(),
319        }
320    }
321
322    fn matrix() -> EconsimQualificationMatrix {
323        EconsimQualificationMatrix {
324            schema: ECONSIM_QUALIFICATION_MATRIX_SCHEMA.to_owned(),
325            profile_id: "econsim-v1".to_owned(),
326            harness_provenance: provenance(),
327            cases: ECONSIM_SCENARIO_CLASSES.into_iter().map(held).collect(),
328        }
329    }
330
331    #[test]
332    fn complete_held_matrix_signs_and_verifies() {
333        let signed = sign_econsim_qualification_matrix(matrix(), &Keypair::from_seed(&[7; 32]))
334            .expect("complete econsim matrix signs");
335        assert!(signed.verify_signature().expect("signature verifies"));
336    }
337
338    #[test]
339    fn high_finding_and_missing_target_do_not_cross_signing_boundary() {
340        for (outcome, target, observed) in [
341            (
342                EconsimOutcome::Finding,
343                EconsimTargetStatus::Bound,
344                Some(EconsimDisposition::Breach),
345            ),
346            (
347                EconsimOutcome::TargetMissing,
348                EconsimTargetStatus::Missing,
349                None,
350            ),
351        ] {
352            let mut candidate = matrix();
353            candidate.cases[0].outcome = outcome;
354            candidate.cases[0].target_status = target;
355            candidate.cases[0].observed_disposition = observed;
356            candidate.cases[0].finding_severity = Some(EconsimFindingSeverity::High);
357            assert!(matches!(
358                sign_econsim_qualification_matrix(candidate, &Keypair::from_seed(&[7; 32])),
359                Err(EconsimValidationError::SigningBoundary(_))
360            ));
361        }
362    }
363
364    #[test]
365    fn short_unknown_and_cross_field_invalid_matrices_reject() {
366        let mut short = matrix();
367        short.cases.pop();
368        assert!(matches!(
369            validate_econsim_qualification_matrix(&short),
370            Err(EconsimValidationError::MissingClass(_))
371        ));
372
373        let mut unknown = held("sybil-pricing-ring");
374        unknown.scenario_class = "unknown".to_owned();
375        assert!(matches!(
376            validate_econsim_scenario_result(&unknown),
377            Err(EconsimValidationError::UnknownClass(_))
378        ));
379
380        let mut invalid = held("sybil-pricing-ring");
381        invalid.finding_severity = Some(EconsimFindingSeverity::Low);
382        assert_eq!(
383            validate_econsim_scenario_result(&invalid),
384            Err(EconsimValidationError::InvalidResult(
385                "outcome, target, disposition, and severity disagree"
386            ))
387        );
388    }
389}