Skip to main content

provable_contracts/schema/
parser.rs

1use std::path::Path;
2
3use crate::error::ContractError;
4use crate::schema::types::{Contract, ContractKind, KaizenRecord, CONTRACT_TOP_LEVEL_FIELDS};
5
6/// Parse a YAML contract file into a [`Contract`] struct.
7///
8/// This is the entry point for Phase 2 validation. The parser
9/// deserializes the YAML and performs structural checks.
10///
11/// # Errors
12///
13/// Returns [`ContractError::Io`] if the file cannot be read,
14/// or [`ContractError::Yaml`] if the YAML is malformed.
15pub fn parse_contract(path: &Path) -> Result<Contract, ContractError> {
16    let content = std::fs::read_to_string(path)?;
17    parse_contract_str(&content)
18}
19
20/// Files under `contracts/` that are NOT `Contract` documents.
21///
22/// `contracts/binding.yaml` is a `BindingRegistry` (equation → implementing
23/// function), not a contract. It has no `metadata:` block, so parsing it as a
24/// `Contract` fails with ``missing field `metadata` `` — which is exactly how
25/// `cargo test -p aprender-contracts --test validate_contracts` failed 3 of its
26/// 10 tests on `main` while `pv lint contracts/` reported zero errors: the two
27/// walkers disagreed about what a contract file is.
28/// Files under `contracts/` that are NOT contracts, and would fail to parse as
29/// one: the binding registry, and ONT-001 ONT-1's declaration of corpora this
30/// tree does NOT hold (`contracts/external-corpora.yaml`). Adding a file here is
31/// how the corpus keeps ONE definition of "a contract file" — the census, the
32/// linter and the validator all read this list.
33const NON_CONTRACT_FILENAMES: [&str; 3] = ["binding.yaml", "binding.yml", "external-corpora.yaml"];
34
35/// Is `path` a `.yaml` file the contract schema owns?
36///
37/// The single source of truth for "which files under `contracts/` are
38/// contracts". `pv lint`'s directory walker and the `validate_contracts`
39/// integration test both call it, so neither can drift into walking a file the
40/// other skips. Directory-level exclusions (`kaizen/`, `legacy/`,
41/// `pipelines/`, `publish-manifests/`) are a separate concern and stay with the
42/// recursive walker in `lint::gates`.
43#[must_use]
44pub fn is_contract_yaml(path: &Path) -> bool {
45    if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
46        return false;
47    }
48    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
49        return false;
50    };
51    !name.starts_with('.') && !NON_CONTRACT_FILENAMES.contains(&name)
52}
53
54/// Parse a YAML contract from a string.
55///
56/// Two passes on purpose. `Contract` is intentionally NOT
57/// `#[serde(deny_unknown_fields)]` — 1224 of the 1726 contracts `pv lint`
58/// walks carry a downstream-owned top-level block (`family:`, `sections:`,
59/// `surface:`, …) and denying them would stop ~71% of the corpus from parsing
60/// in one commit. But serde's tolerance is also what let a top-level `kind:`
61/// (119 contracts) and a misspelled `falsification:` block vanish without a
62/// sound. So the second pass reads the raw mapping and records which top-level
63/// keys serde did not consume, in [`Contract::unknown_top_level_keys`]; the
64/// validator turns the never-legitimate ones into errors (SCHEMA-018 /
65/// SCHEMA-019) and leaves the rest alone.
66///
67/// # Errors
68///
69/// Returns [`ContractError::Yaml`] if the YAML is malformed or does not match
70/// the contract schema.
71pub fn parse_contract_str(yaml: &str) -> Result<Contract, ContractError> {
72    let mut contract: Contract = serde_yaml::from_str(yaml)?;
73    contract.unknown_top_level_keys = unknown_top_level_keys(yaml);
74    contract.strict_yaml_error = strict_yaml_error(yaml);
75    contract.kaizen_record = kaizen_record(yaml, contract.kind())?;
76    Ok(contract)
77}
78
79/// Third pass, run ONLY for `metadata.kind: kaizen`: read the top-level blocks
80/// a kaizen improvement record carries (`contract:`, `kaizen:`, `status:`,
81/// `baseline:`, `target:`, …) that `Contract` deliberately does not model.
82///
83/// Gated on the kind rather than run unconditionally because those key names
84/// are not owned by the kaizen schema: `status:`, `version:` and `invariants:`
85/// appear at top level across the corpus with shapes that would make this
86/// deserialize fail. On a non-kaizen contract that failure would turn a
87/// perfectly good contract into a parse error; scoping the pass means it can
88/// only ever be reached by a document that has declared itself a kaizen record.
89///
90/// When it IS reached, a type error is returned rather than swallowed — a
91/// kaizen record whose `baseline:` is unreadable must be REPORTED, not
92/// silently validated as if it had none.
93fn kaizen_record(yaml: &str, kind: ContractKind) -> Result<Option<KaizenRecord>, ContractError> {
94    if kind != ContractKind::Kaizen {
95        return Ok(None);
96    }
97    Ok(Some(serde_yaml::from_str::<KaizenRecord>(yaml)?))
98}
99
100/// Top-level mapping keys of `yaml` that are not fields of [`Contract`].
101///
102/// Deliberately deserializes into `BTreeMap<String, IgnoredAny>` rather than
103/// `serde_yaml::Value`: `IgnoredAny` drains each value without building it, so
104/// this pass reads ONLY the top-level key names and cannot be derailed by
105/// anything nested. That matters — `contracts/apr-cli-commands-v1.yaml` defines
106/// `subcommands:` twice inside `commands:`, which makes a strict
107/// `serde_yaml::Value` parse fail; capturing keys through `Value` silently
108/// returned "no unknown keys" for that file and its top-level
109/// `kind: CLICommandContract` went unreported. A `BTreeMap` also just
110/// overwrites a duplicate top-level key instead of erroring.
111///
112/// Returns an empty list when the document is not a mapping at all — that case
113/// is already a hard parse error above, so this never masks one.
114fn unknown_top_level_keys(yaml: &str) -> Vec<String> {
115    use serde::de::IgnoredAny;
116    use std::collections::BTreeMap;
117
118    let Ok(map) = serde_yaml::from_str::<BTreeMap<String, IgnoredAny>>(yaml) else {
119        return Vec::new();
120    };
121    map.into_keys()
122        .filter(|k| !CONTRACT_TOP_LEVEL_FIELDS.contains(&k.as_str()))
123        .collect()
124}
125
126/// The error a STRICT reader gets on YAML the contract schema accepted.
127///
128/// `Contract`'s derived deserializer walks only the fields it knows and skips
129/// the rest, so a document can be well-formed to it and malformed to anyone
130/// else. The one shape this catches today is a duplicate mapping key: YAML
131/// requires keys to be unique, and every consumer that builds a real map (a
132/// `serde_yaml::Value`, `yq`, a Python `dict`) keeps exactly one of them and
133/// throws the other away without a word.
134///
135/// `None` means the document round-trips through a strict reader.
136fn strict_yaml_error(yaml: &str) -> Option<String> {
137    serde_yaml::from_str::<serde_yaml::Value>(yaml)
138        .err()
139        .map(|e| e.to_string())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    const MINIMAL_CONTRACT: &str = r#"
147metadata:
148  version: "1.0.0"
149  description: "Test contract"
150  references:
151    - "Test paper (2024)"
152equations:
153  test_eq:
154    formula: "f(x) = x + 1"
155proof_obligations: []
156falsification_tests: []
157"#;
158
159    #[test]
160    fn parse_minimal_contract() {
161        let contract = parse_contract_str(MINIMAL_CONTRACT).unwrap();
162        assert_eq!(contract.metadata.version, "1.0.0");
163        assert_eq!(contract.metadata.description, "Test contract");
164        assert_eq!(contract.equations.len(), 1);
165        assert!(contract.equations.contains_key("test_eq"));
166    }
167
168    #[test]
169    fn parse_contract_with_all_fields() {
170        let yaml = r#"
171metadata:
172  version: "1.0.0"
173  created: "2026-02-18"
174  author: "Test Author"
175  description: "Full contract"
176  references:
177    - "Paper A (2024)"
178    - "Paper B (2025)"
179equations:
180  softmax:
181    formula: "σ(x)_i = exp(x_i - max(x)) / Σ exp(x_j - max(x))"
182    domain: "x ∈ ℝ^n, n ≥ 1"
183    codomain: "σ(x) ∈ (0,1)^n"
184    invariants:
185      - "sum(output) = 1.0"
186      - "output_i > 0"
187proof_obligations:
188  - type: invariant
189    property: "Output sums to 1"
190    formal: "|sum(softmax(x)) - 1.0| < ε"
191    tolerance: 1.0e-6
192    applies_to: all
193  - type: equivalence
194    property: "SIMD matches scalar"
195    tolerance: 8.0
196    applies_to: simd
197kernel_structure:
198  phases:
199    - name: find_max
200      description: "Find max element"
201      invariant: "max >= all elements"
202    - name: exp_subtract
203      description: "Compute exp(x_i - max)"
204      invariant: "all values in (0, 1]"
205simd_dispatch:
206  softmax:
207    scalar: "softmax_scalar"
208    avx2: "softmax_avx2"
209enforcement:
210  normalization:
211    description: "Output sums to 1.0"
212    check: "contract_tests::FALSIFY-SM-001"
213    severity: "ERROR"
214falsification_tests:
215  - id: FALSIFY-SM-001
216    rule: "Normalization"
217    prediction: "sum(output) ≈ 1.0"
218    test: "proptest with random vectors"
219    if_fails: "Missing max-subtraction trick"
220kani_harnesses:
221  - id: KANI-SM-001
222    obligation: SM-INV-001
223    property: "Softmax sums to 1.0"
224    bound: 16
225    strategy: stub_float
226    solver: cadical
227    harness: verify_softmax_normalization
228qa_gate:
229  id: F-SM-001
230  name: "Softmax Contract"
231  checks:
232    - "normalization"
233  pass_criteria: "All falsification tests pass"
234  falsification: "Introduce off-by-one in max reduction"
235"#;
236
237        let contract = parse_contract_str(yaml).unwrap();
238        assert_eq!(contract.metadata.version, "1.0.0");
239        assert_eq!(contract.metadata.references.len(), 2);
240        assert_eq!(contract.equations.len(), 1);
241        assert_eq!(contract.proof_obligations.len(), 2);
242        assert!(contract.kernel_structure.is_some());
243        let ks = contract.kernel_structure.unwrap();
244        assert_eq!(ks.phases.len(), 2);
245        assert_eq!(contract.simd_dispatch.len(), 1);
246        assert_eq!(contract.enforcement.len(), 1);
247        assert_eq!(contract.falsification_tests.len(), 1);
248        assert_eq!(contract.falsification_tests[0].id, "FALSIFY-SM-001");
249        assert_eq!(contract.kani_harnesses.len(), 1);
250        assert_eq!(contract.kani_harnesses[0].bound, Some(16));
251        assert!(contract.qa_gate.is_some());
252    }
253
254    #[test]
255    fn parse_invalid_yaml_returns_error() {
256        let result = parse_contract_str("not: [valid: yaml: {{");
257        assert!(result.is_err());
258    }
259
260    #[test]
261    fn parse_missing_metadata_returns_error() {
262        let yaml = r#"
263equations:
264  test:
265    formula: "f(x) = x"
266"#;
267        let result = parse_contract_str(yaml);
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn parse_obligation_types() {
273        let yaml = r#"
274metadata:
275  version: "1.0.0"
276  description: "type test"
277equations:
278  f:
279    formula: "f(x) = x"
280proof_obligations:
281  - type: invariant
282    property: "test"
283    if_fails: ""
284  - type: equivalence
285    property: "test"
286  - type: bound
287    property: "test"
288  - type: monotonicity
289    property: "test"
290  - type: idempotency
291    property: "test"
292  - type: linearity
293    property: "test"
294  - type: symmetry
295    property: "test"
296  - type: associativity
297    property: "test"
298  - type: conservation
299    property: "test"
300falsification_tests: []
301"#;
302        let contract = parse_contract_str(yaml).unwrap();
303        assert_eq!(contract.proof_obligations.len(), 9);
304    }
305
306    #[test]
307    fn parse_dbc_obligation_types() {
308        use crate::schema::types::ObligationType;
309
310        let yaml = r#"
311metadata:
312  version: "1.0.0"
313  description: "DbC type test"
314  depends_on: ["parent-v1"]
315equations:
316  f:
317    formula: "f(x) = x"
318proof_obligations:
319  - type: precondition
320    property: "input finite"
321    formal: "isFinite(x)"
322  - type: postcondition
323    property: "output bounded"
324    requires: "PRE-001"
325  - type: frame
326    property: "input unchanged"
327  - type: loop_invariant
328    property: "max tracks true max"
329    applies_to_phase: "find_max"
330  - type: loop_variant
331    property: "remaining decreasing"
332    applies_to_phase: "accumulate"
333  - type: old_state
334    property: "cache grows"
335  - type: subcontract
336    property: "refines parent"
337    parent_contract: "parent-v1"
338falsification_tests: []
339"#;
340        let contract = parse_contract_str(yaml).unwrap();
341        assert_eq!(contract.proof_obligations.len(), 7);
342        assert_eq!(
343            contract.proof_obligations[0].obligation_type,
344            ObligationType::Precondition
345        );
346        assert_eq!(
347            contract.proof_obligations[1].obligation_type,
348            ObligationType::Postcondition
349        );
350        assert_eq!(
351            contract.proof_obligations[1].requires.as_deref(),
352            Some("PRE-001")
353        );
354        assert_eq!(
355            contract.proof_obligations[2].obligation_type,
356            ObligationType::Frame
357        );
358        assert_eq!(
359            contract.proof_obligations[3].obligation_type,
360            ObligationType::LoopInvariant
361        );
362        assert_eq!(
363            contract.proof_obligations[3].applies_to_phase.as_deref(),
364            Some("find_max")
365        );
366        assert_eq!(
367            contract.proof_obligations[4].obligation_type,
368            ObligationType::LoopVariant
369        );
370        assert_eq!(
371            contract.proof_obligations[5].obligation_type,
372            ObligationType::OldState
373        );
374        assert_eq!(
375            contract.proof_obligations[6].obligation_type,
376            ObligationType::Subcontract
377        );
378        assert_eq!(
379            contract.proof_obligations[6].parent_contract.as_deref(),
380            Some("parent-v1")
381        );
382    }
383
384    #[test]
385    fn parse_contract_with_kind_model_family() {
386        use crate::schema::types::ContractKind;
387
388        // A realistic aprender model-family YAML: metadata + custom
389        // top-level fields. No equations, no proof obligations — should
390        // parse and validate cleanly as kind: model-family.
391        let yaml = r#"
392metadata:
393  version: "1.0.0"
394  description: "Google BERT architecture family metadata"
395  kind: model-family
396  references:
397    - "https://arxiv.org/abs/1810.04805"
398    - "https://huggingface.co/google-bert"
399# Custom top-level fields ignored by the kernel schema,
400# consumed by the downstream crate that owns the file.
401family: bert
402display_name: "Google BERT"
403vendor: Google
404architectures:
405  - BertModel
406  - BertForMaskedLM
407size_variants:
408  base:
409    parameters: "110M"
410    hidden_dim: 768
411"#;
412        let contract = parse_contract_str(yaml).unwrap();
413        assert_eq!(contract.kind(), ContractKind::ModelFamily);
414        assert!(!contract.requires_proofs());
415        assert!(!contract.is_registry());
416        // Validates cleanly — no kernel-specific checks fire.
417        let violations = crate::schema::validate_contract(&contract);
418        let errors: Vec<_> = violations
419            .iter()
420            .filter(|v| v.severity == crate::error::Severity::Error)
421            .collect();
422        assert!(
423            errors.is_empty(),
424            "model-family YAML should validate with no errors, got: {errors:?}",
425        );
426    }
427
428    #[test]
429    fn parse_contract_defaults_to_kernel_kind() {
430        use crate::schema::types::ContractKind;
431
432        let contract = parse_contract_str(MINIMAL_CONTRACT).unwrap();
433        assert_eq!(contract.kind(), ContractKind::Kernel);
434        assert!(contract.requires_proofs());
435    }
436
437    #[test]
438    fn parse_kani_strategies() {
439        use crate::schema::types::KaniStrategy;
440
441        let yaml = r#"
442metadata:
443  version: "1.0.0"
444  description: "kani test"
445equations:
446  f:
447    formula: "f(x) = x"
448kani_harnesses:
449  - id: K1
450    obligation: OBL-1
451    strategy: exhaustive
452  - id: K2
453    obligation: OBL-2
454    strategy: stub_float
455  - id: K3
456    obligation: OBL-3
457    strategy: compositional
458falsification_tests: []
459"#;
460        let contract = parse_contract_str(yaml).unwrap();
461        assert_eq!(contract.kani_harnesses.len(), 3);
462        assert_eq!(
463            contract.kani_harnesses[0].strategy,
464            Some(KaniStrategy::Exhaustive)
465        );
466        assert_eq!(
467            contract.kani_harnesses[1].strategy,
468            Some(KaniStrategy::StubFloat)
469        );
470        assert_eq!(
471            contract.kani_harnesses[2].strategy,
472            Some(KaniStrategy::Compositional)
473        );
474    }
475}