Skip to main content

provable_contracts/schema/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4pub use super::clause::Clause;
5pub use super::composition::{ShapeContract, ShapeExpr};
6pub use super::kaizen::{KaizenRecord, KAIZEN_STATUSES};
7pub use super::kind::ContractKind;
8
9/// A complete YAML kernel contract.
10///
11/// This is the root type for the contract schema defined in
12/// `docs/specifications/pv-spec.md` Section 3.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct Contract {
15    pub metadata: Metadata,
16    /// Equations are optional — kaizen, pipeline, and registry contracts
17    /// may define only `proof_obligations` without mathematical equations.
18    ///
19    /// Accepts both map form (`equations: { silu: { formula: ... } }`, the
20    /// canonical schema) and sequence form (`equations: [{ id: silu,
21    /// formula: ... }]`, used by several diagnostic/methodology contracts
22    /// predating APR-MONO). The sequence form promotes each item's `id`
23    /// field to the map key.
24    #[serde(default, deserialize_with = "deserialize_equations")]
25    pub equations: BTreeMap<String, Equation>,
26    #[serde(default)]
27    pub proof_obligations: Vec<ProofObligation>,
28    #[serde(default)]
29    pub kernel_structure: Option<KernelStructure>,
30    #[serde(default)]
31    pub simd_dispatch: BTreeMap<String, BTreeMap<String, String>>,
32    #[serde(default)]
33    pub enforcement: BTreeMap<String, EnforcementRule>,
34    #[serde(default)]
35    pub falsification_tests: Vec<FalsificationTest>,
36    #[serde(default)]
37    pub kani_harnesses: Vec<KaniHarness>,
38    #[serde(default)]
39    pub qa_gate: Option<QaGate>,
40    /// Phase 7: Lean 4 verification summary across all obligations.
41    #[serde(default)]
42    pub verification_summary: Option<VerificationSummary>,
43    /// Type-level invariants (Meyer's class invariants).
44    #[serde(default)]
45    pub type_invariants: Vec<TypeInvariant>,
46    /// ONT-4e: preconditions, `{id, statement, formal, formal_status}` (see [`Clause`]).
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub requires: Vec<Clause>,
49    /// ONT-4e: postconditions, the same shape as `requires`.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub ensures: Vec<Clause>,
52    /// Coq verification specification.
53    #[serde(default)]
54    pub coq_spec: Option<CoqSpec>,
55    /// BEAT-benchmark parameters (PMAT-741) — present on `metadata.kind:
56    /// beat-benchmark` contracts; pins a machine-measured incumbent baseline so
57    /// CI fails when aprender regresses below it on the incumbent's canonical task.
58    #[serde(default)]
59    pub beat: Option<Beat>,
60    /// CRUX master-registry story rows (`contracts/crux-competitive-research-ux-v1.yaml`).
61    ///
62    /// THIS is the list the competitive-research programme actually sorts by.
63    /// aprender#2555 originally range-checked only `metadata.demand_score` and
64    /// justified it as "the ranking signal the whole programme sorts by" — but
65    /// MEASURED, nothing in the repo reads `metadata.demand_score`; the 250
66    /// rows below are what §12.1 of
67    /// `docs/specifications/crux-competitive-research-ux-workflows.md` maps to
68    /// `pmat work` priority. They were entirely ungated. Validating them is
69    /// what makes that justification true.
70    #[serde(default)]
71    pub stories: Vec<CruxStory>,
72    /// Legacy free-form top-level `falsification:` block.
73    ///
74    /// 400 contracts in `contracts/` carry this key, every one of them holding
75    /// a structured list (shapes seen in the wild: `{condition, action,
76    /// severity}`, `{name, description, check}`, `{id, assertion,
77    /// test_harness}`). `Contract` is not `deny_unknown_fields`, so before this
78    /// field existed serde dropped all of it silently — the same mechanism as
79    /// #2465 (`test_harness`) and #2504. `contracts/publish-workspace-v1.yaml`
80    /// is the canonical victim: four FALSIFY-PUB-* entries live here and `pv
81    /// status` reported "Falsification tests: 0" while the file read as
82    /// governance.
83    ///
84    /// It is deliberately `serde_yaml::Value`: the block is NOT
85    /// `falsification_tests` and must never be counted as one — it is captured
86    /// so that tooling can SEE it and report the contract as inert. Migrating
87    /// these entries into real `falsification_tests` is contract-by-contract
88    /// work, not a schema change.
89    #[serde(default)]
90    pub falsification: Option<serde_yaml::Value>,
91    /// Legacy free-form top-level `falsification_conditions:` block — the same
92    /// silent-drop class as [`Contract::falsification`], used by 12 contracts.
93    /// Kept as a distinct field (not a serde `alias`) so a contract carrying
94    /// both keys still parses instead of failing on a duplicate field.
95    #[serde(default)]
96    pub falsification_conditions: Option<serde_yaml::Value>,
97    /// Top-level YAML keys that are not fields of `Contract`, captured verbatim
98    /// by [`crate::schema::parse_contract_str`].
99    ///
100    /// The schema deliberately tolerates unknown top-level keys — model-family,
101    /// spec and registry YAMLs carry downstream-owned blocks (see
102    /// `parse_contract_with_kind_model_family`), and 1224 of the 1726 contracts
103    /// `pv lint` walks have at least one. `deny_unknown_fields` is therefore not
104    /// an option. Instead the validator uses this list to reject the two shapes
105    /// that are never legitimate: a top-level `kind:` (SCHEMA-018) and a
106    /// near-miss misspelling of a real block name (SCHEMA-019).
107    ///
108    /// Not serialized: it is a parse artifact, not contract content.
109    #[serde(skip)]
110    pub unknown_top_level_keys: Vec<String>,
111    /// Was `metadata.kind:` WRITTEN in the source YAML? (ONT-6b, infra#751)
112    ///
113    /// [`ContractKind`] derives `Default = Kernel` and `metadata.kind` carries
114    /// `#[serde(default)]`, so by the time anything can read [`Contract::kind`]
115    /// the difference between *declared kernel* and *defaulted to kernel* is
116    /// gone. 666 of this corpus's contracts declare no kind at all and are
117    /// judged by the kernel rules because of that default; until ONT-6b nothing
118    /// in `pv validate`'s output said so, and a kind-less file and the same
119    /// bytes with `kind: kernel` printed identically (measured 2026-09-19,
120    /// pv 0.68.1). The validator uses this to decorate the FIRST kernel-only
121    /// error the default caused — see `schema::validator::validate_contract`.
122    ///
123    /// Not serialized: like `unknown_top_level_keys`, it is a parse artifact.
124    /// `false` is the safe default for a `Contract` built in code rather than
125    /// parsed: it only ever adds an explanation to an error that already fired.
126    #[serde(skip)]
127    pub kind_declared: bool,
128    /// The kaizen-record blocks (`contract:`, `kaizen:`, `baseline:`,
129    /// `target:`, …) captured by a second parse pass when — and only when —
130    /// `metadata.kind` is `kaizen`.
131    ///
132    /// Kept OUT of the serde surface of `Contract` on purpose. The corpus
133    /// carries `status:`, `version:`, `invariants:` and `files:` at top level
134    /// on documents of several kinds with incompatible shapes, so promoting
135    /// them to real `Contract` fields would change how all 1726 contracts
136    /// parse in order to validate 46 kaizen records. Scoping the second pass
137    /// to `kind: kaizen` means a type mismatch in some unrelated contract's
138    /// `status:` can never reach this struct.
139    ///
140    /// Not serialized: it is a parse artifact, not contract content.
141    #[serde(skip)]
142    pub kaizen_record: Option<KaizenRecord>,
143    /// The error a strict YAML reader produced on a document this schema
144    /// nonetheless accepted, captured by
145    /// [`crate::schema::parse_contract_str`]. `None` is the healthy case.
146    ///
147    /// The derived deserializer skips unknown subtrees without reading them, so
148    /// a contract can parse cleanly here and be rejected by `yq`, PyYAML, or a
149    /// `serde_yaml::Value` round-trip. SCHEMA-020 turns that divergence into an
150    /// error instead of leaving it to be discovered downstream.
151    ///
152    /// Not serialized: it is a parse artifact, not contract content.
153    #[serde(skip)]
154    pub strict_yaml_error: Option<String>,
155    /// Was a top-level `falsification_tests:` key WRITTEN in the source YAML?
156    /// (#5291)
157    ///
158    /// `falsification_tests` carries `#[serde(default)]`, so an absent key and
159    /// `falsification_tests: []` deserialize to the same empty `Vec`. They are
160    /// not the same claim. Absent is legitimate on a registry or schema
161    /// contract, and on a kernel contract PROVABILITY-001 already refuses it.
162    /// Written-and-empty declares a test block with nothing in it. On a
163    /// contract whose kind exempts it from PROVABILITY-001 (the #5290 shape:
164    /// `kind: kernel` plus the legacy `registry: true`), nothing refused it.
165    /// SCHEMA-024 reads this to tell the two apart.
166    ///
167    /// Not serialized: like `unknown_top_level_keys`, it is a parse artifact.
168    #[serde(skip)]
169    pub falsification_tests_declared: bool,
170}
171
172/// One row of the CRUX master registry's `stories:` list.
173///
174/// Fields beyond the three domain-checked ones are accepted and ignored — the
175/// registry carries `title`/`contract`/`category` that no rule constrains.
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct CruxStory {
178    /// Story id, e.g. `CRUX-A-01`. Used only to locate a violation.
179    #[serde(default)]
180    pub id: String,
181    /// Which competitor's UX the story was extracted from. Membership-checked
182    /// against `CRUX_COMPETITORS` (rule CRUX-002), the same registry that
183    /// governs `metadata.competitor`, and trimmed on parse for the same reason.
184    #[serde(default, deserialize_with = "deserialize_trimmed_opt_string")]
185    pub competitor: Option<String>,
186    /// Demand, documented `1..=5`. Range-checked by rule CRUX-001 — the same
187    /// `DEMAND_SCORE_RANGE` that governs `metadata.demand_score`.
188    ///
189    /// `i64` for the same reason as [`Metadata::demand_score`]: an out-of-range
190    /// value must REACH the validator and be named, not die in serde.
191    #[serde(default)]
192    pub demand_score: Option<i64>,
193    /// Story status. A closed enum, so an invented value FAILS TO PARSE — the
194    /// registry is held to exactly the vocabulary `IntakeStatus` defines.
195    #[serde(default)]
196    pub status: Option<IntakeStatus>,
197}
198
199/// Every top-level key `Contract` deserializes, in declaration order.
200///
201/// This list is the allow-list SCHEMA-019 checks near-misses against, and it is
202/// pinned to the struct by `contract_fields_match_struct` in `types_tests.rs`:
203/// adding a field to `Contract` without adding it here turns the new block into
204/// a "near-miss of itself" and fails that test.
205pub const CONTRACT_TOP_LEVEL_FIELDS: [&str; 18] = [
206    "metadata",
207    "equations",
208    "proof_obligations",
209    "kernel_structure",
210    "simd_dispatch",
211    "enforcement",
212    "falsification_tests",
213    "kani_harnesses",
214    "qa_gate",
215    "verification_summary",
216    "type_invariants",
217    "requires",
218    "ensures",
219    "coq_spec",
220    "beat",
221    "stories",
222    "falsification",
223    "falsification_conditions",
224];
225
226/// Parameters of a head-to-head BEAT benchmark (`metadata.kind: beat-benchmark`,
227/// PMAT-741): a falsifiable, CI-wired claim that aprender meets-or-beats an
228/// incumbent (scikit-learn / PyTorch / Unsloth / Ollama·llama.cpp) on the
229/// incumbent's own canonical task — the measurement backbone of the four-pillar
230/// "replace AND beat" mission. Required-shape is enforced by
231/// `validate_beat_benchmark` in the validator (BEAT-001..007).
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct Beat {
234    /// Which pillar (1=sklearn, 2=PyTorch, 3=Unsloth, 4=Ollama/llama.cpp).
235    #[serde(default)]
236    pub pillar: Option<u8>,
237    /// The incumbent being beaten — must name one of the four pillars.
238    #[serde(default)]
239    pub incumbent: String,
240    /// How/when the baseline was pinned (free-form provenance).
241    #[serde(default)]
242    pub incumbent_pinned: Option<String>,
243    /// The canonical task on which the beat is measured (apples-to-apples).
244    #[serde(default)]
245    pub canonical_task: Option<String>,
246    /// The measured metric (e.g. `accuracy`, `wall_clock_ms`, `tokens_per_sec`, `mse`).
247    #[serde(default)]
248    pub metric: String,
249    /// `higher_is_better` or `lower_is_better` — fixes the regression direction.
250    #[serde(default)]
251    pub direction: String,
252    /// The incumbent's pinned baseline value.
253    #[serde(default)]
254    pub baseline_value: Option<f64>,
255    /// Optional worst-case incumbent value (e.g. sklearn min over seeds).
256    #[serde(default)]
257    pub baseline_floor: Option<f64>,
258    /// The threshold aprender must meet/beat; CI fails on regression past it.
259    #[serde(default)]
260    pub beat_threshold: Option<f64>,
261    /// When the baseline was sourced (ISO date).
262    #[serde(default)]
263    pub baseline_sourced_date: Option<String>,
264    /// `CPU` or `GPU` — the compute approved for this gate.
265    #[serde(default)]
266    pub approved_compute: Option<String>,
267    /// The CI test/gate name that enforces this beat.
268    #[serde(default)]
269    pub ci_gate_name: String,
270}
271
272/// The outcome of evaluating a measured value against a [`Beat`]'s pinned
273/// threshold — the falsifiable verdict at the heart of `apr beat-run`.
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum BeatOutcome {
277    /// aprender meets-or-beats the incumbent: measured is on the winning side of
278    /// `beat_threshold` per `direction`.
279    Won,
280    /// aprender regressed below the pinned threshold — CI must fail.
281    Regressed,
282}
283
284impl Beat {
285    /// Evaluate a measured value against this beat's pinned `beat_threshold`,
286    /// honoring `direction`:
287    /// - `higher_is_better` ⇒ `Won` iff `measured >= beat_threshold`
288    /// - `lower_is_better`  ⇒ `Won` iff `measured <= beat_threshold`
289    ///
290    /// Returns `None` when the contract is too malformed to judge (no
291    /// `beat_threshold`, a non-finite threshold/measurement, or an unknown
292    /// `direction`) — the caller should treat that as a hard error, not a pass.
293    /// The validator's BEAT-004/BEAT-005 rules reject such contracts up front,
294    /// so a well-formed contract always yields `Some`.
295    #[must_use]
296    pub fn evaluate(&self, measured: f64) -> Option<BeatOutcome> {
297        let threshold = self.beat_threshold?;
298        if !threshold.is_finite() || !measured.is_finite() {
299            return None;
300        }
301        match self.direction.trim() {
302            "higher_is_better" => Some(if measured >= threshold {
303                BeatOutcome::Won
304            } else {
305                BeatOutcome::Regressed
306            }),
307            "lower_is_better" => Some(if measured <= threshold {
308                BeatOutcome::Won
309            } else {
310                BeatOutcome::Regressed
311            }),
312            _ => None,
313        }
314    }
315
316    /// Convenience: `true` iff [`evaluate`](Self::evaluate) returns
317    /// [`BeatOutcome::Won`]. A malformed contract (`None`) is **not** a win.
318    #[must_use]
319    pub fn is_won(&self, measured: f64) -> bool {
320        self.evaluate(measured) == Some(BeatOutcome::Won)
321    }
322}
323
324impl Contract {
325    /// Back-compat: `metadata.registry: true` OR `metadata.kind: registry`.
326    pub fn is_registry(&self) -> bool {
327        self.metadata.registry || self.metadata.kind == ContractKind::Registry
328    }
329
330    /// The effective kind, honoring the legacy `registry: true` flag.
331    pub fn kind(&self) -> ContractKind {
332        if self.metadata.registry && self.metadata.kind == ContractKind::Kernel {
333            ContractKind::Registry
334        } else {
335            self.metadata.kind
336        }
337    }
338
339    /// True iff this contract must satisfy PROVABILITY-001 (kernel only).
340    pub fn requires_proofs(&self) -> bool {
341        self.kind() == ContractKind::Kernel
342    }
343
344    /// How many entries sit in the legacy top-level `falsification:` /
345    /// `falsification_conditions:` blocks — content the schema captures but
346    /// does NOT count as `falsification_tests`.
347    ///
348    /// A non-zero result together with an empty `falsification_tests` is the
349    /// inert-contract signature (#2504): the file reads as enforced and
350    /// enforces nothing. `pv status` reports it so the reader is never told
351    /// "Falsification tests: 0" without being told where the entries went.
352    #[must_use]
353    pub fn legacy_falsification_entries(&self) -> usize {
354        fn count(v: Option<&serde_yaml::Value>) -> usize {
355            match v {
356                Some(serde_yaml::Value::Sequence(s)) => s.len(),
357                Some(serde_yaml::Value::Mapping(m)) => m.len(),
358                Some(serde_yaml::Value::Null) | None => 0,
359                Some(_) => 1,
360            }
361        }
362        count(self.falsification.as_ref()) + count(self.falsification_conditions.as_ref())
363    }
364
365    /// Enforce the provability invariant: kernel contracts MUST have
366    /// `proof_obligations`, `falsification_tests`, and `kani_harnesses`.
367    /// Returns a list of violations. Empty list = contract is valid.
368    pub fn provability_violations(&self) -> Vec<String> {
369        if !self.requires_proofs() {
370            return vec![];
371        }
372        let mut violations = Vec::new();
373        if self.proof_obligations.is_empty() {
374            violations.push("Kernel contract has no proof_obligations".into());
375        }
376        if self.falsification_tests.is_empty() {
377            violations.push("Kernel contract has no falsification_tests".into());
378        }
379        if self.kani_harnesses.is_empty() {
380            violations.push("Kernel contract has no kani_harnesses".into());
381        }
382        if self.falsification_tests.len() < self.proof_obligations.len() {
383            violations.push(format!(
384                "falsification_tests ({}) < proof_obligations ({})",
385                self.falsification_tests.len(),
386                self.proof_obligations.len(),
387            ));
388        }
389        violations
390    }
391}
392
393/// Contract metadata block.
394#[derive(Debug, Clone, Default, Serialize, Deserialize)]
395pub struct Metadata {
396    pub version: String,
397    #[serde(default)]
398    pub created: Option<String>,
399    #[serde(default)]
400    pub author: Option<String>,
401    pub description: String,
402    #[serde(default)]
403    pub references: Vec<String>,
404    /// Contract dependencies — other contracts this one composes.
405    /// Values are contract stems (e.g. "silu-kernel-v1").
406    #[serde(default)]
407    pub depends_on: Vec<String>,
408    /// Legacy registry flag — prefer `metadata.kind: registry` for new contracts.
409    #[serde(default)]
410    pub registry: bool,
411    /// Contract kind. Defaults to [`ContractKind::Kernel`].
412    #[serde(default)]
413    pub kind: ContractKind,
414    /// Per-contract enforcement level (Section 17, Gap 1).
415    /// `basic` → schema valid; `standard` → + falsification + kani;
416    /// `strict` → + all bindings implemented; `proven` → + Lean 4 proved.
417    #[serde(default)]
418    pub enforcement_level: Option<EnforcementLevel>,
419    /// Once set, the contract cannot drop below this verification level
420    /// without an explicit `pv unlock` (Section 17, Gap 5).
421    #[serde(default)]
422    pub locked_level: Option<String>,
423    /// CRUX competitive-research story: which competitor's UX the story was
424    /// extracted from. Membership-checked against the `CRUX_COMPETITORS`
425    /// registry in `schema/validator.rs` (rule CRUX-002).
426    ///
427    /// NORMALISED ON PARSE (trimmed). The validator used to `.trim()` before
428    /// comparing, so `competitor: "  ecosystem  "` passed CRUX-002 while the
429    /// stored value kept its padding: the gate laundered a value it never
430    /// fixed, and every consumer reading this field still saw the untrimmed
431    /// string. Trimming here means the checked value and the stored value are
432    /// the same value.
433    #[serde(default, deserialize_with = "deserialize_trimmed_opt_string")]
434    pub competitor: Option<String>,
435    /// CRUX competitive-research story: demand, documented `1..=5` by
436    /// `contracts/crux-competitive-research-ux-v1.yaml` §"demand_score (1..5)".
437    /// Range-checked by rule CRUX-001.
438    ///
439    /// Deliberately `i64`, not `u8`: an out-of-range value must reach the
440    /// validator and be reported as `demand_score 99999 is outside 1..=5`,
441    /// not die in serde as an opaque integer-overflow message.
442    #[serde(default)]
443    pub demand_score: Option<i64>,
444    /// CRUX competitive-research story: intake status. A closed enum, so an
445    /// invented value FAILS TO PARSE (see [`IntakeStatus`]).
446    #[serde(default)]
447    pub intake_status: Option<IntakeStatus>,
448}
449
450/// Deserialize an optional string, trimming surrounding whitespace.
451///
452/// aprender#2555 follow-up: a domain check that trims before comparing accepts
453/// `"  ecosystem  "` and then stores it verbatim. Normalising at the parse
454/// boundary is the fix — it is done once, before any rule runs, so no rule has
455/// to remember to trim and none can disagree about whether it did.
456///
457/// PRESENT-BUT-EMPTY IS NOT ABSENT. A trimmed-to-empty value stays
458/// `Some(String::new())` rather than collapsing to `None`, so `competitor: ''`
459/// and `competitor: '   '` are still REPORTED by CRUX-002 as unregistered.
460/// Collapsing them would have quietly widened the presence gap this field
461/// already has: omission is invisible to the gate, and turning a written-down
462/// blank into another invisible case makes that worse, not better.
463fn deserialize_trimmed_opt_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
464where
465    D: serde::Deserializer<'de>,
466{
467    let raw: Option<String> = Option::deserialize(deserializer)?;
468    Ok(raw.map(|v| v.trim().to_string()))
469}
470
471/// Intake status of a CRUX competitive-research story (`metadata.intake_status`).
472///
473/// The vocabulary is closed and is exactly `STATUS_BADGE` in
474/// `scripts/crux_scaffold_contracts.py`, the generator that emits all 275
475/// `crux-*-v1.yaml` files: `supported`, `partial`, `missing`, `unclear`.
476///
477/// This is an ENUM rather than a `String` on purpose (aprender#2555). A field
478/// serde never parsed cannot be checked by any validator, and a field parsed as
479/// `String` can only be *linted* — a lint is advisory and the caller may ignore
480/// it. Making the type closed pushes the check into deserialization, so an
481/// invented value is not a warning about a contract, it is not a contract.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "lowercase")]
484pub enum IntakeStatus {
485    /// apr has no surface for this story.
486    Missing,
487    /// apr has a partial surface; parity gaps remain.
488    Partial,
489    /// apr reaches parity with the competitor's canonical verb.
490    Supported,
491    /// The competitor's behaviour has not been pinned down yet.
492    Unclear,
493}
494
495impl std::fmt::Display for IntakeStatus {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        let s = match self {
498            Self::Missing => "missing",
499            Self::Partial => "partial",
500            Self::Supported => "supported",
501            Self::Unclear => "unclear",
502        };
503        write!(f, "{s}")
504    }
505}
506
507/// Per-contract enforcement level (gradual enforcement, Section 17).
508#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
509#[serde(rename_all = "lowercase")]
510pub enum EnforcementLevel {
511    /// Schema valid, has equations.
512    Basic,
513    /// + falsification tests + Kani harnesses.
514    Standard,
515    /// + all bindings implemented + `#[contract]` annotations.
516    Strict,
517    /// + Lean 4 proved (no sorry).
518    Proven,
519}
520
521/// A mathematical equation extracted from a paper (Phase 1 output).
522#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct Equation {
524    /// Default-empty so diagnostic/methodology contracts that use prose
525    /// requirements instead of a formula (e.g.
526    /// `decode-hot-path-prefix-cache-diagnostic-v1`) still parse.
527    #[serde(default)]
528    pub formula: String,
529    #[serde(default)]
530    pub domain: Option<String>,
531    #[serde(default)]
532    pub codomain: Option<String>,
533    #[serde(default)]
534    pub invariants: Vec<String>,
535    /// Rust preconditions — compiled to `debug_assert!()` by `build.rs`.
536    #[serde(default)]
537    pub preconditions: Vec<String>,
538    /// Rust postconditions — compiled to `debug_assert!()` by `build.rs`.
539    #[serde(default)]
540    pub postconditions: Vec<String>,
541    /// Lean 4 theorem name that proves this equation correct.
542    /// Example: "ProvableContracts.Theorems.Softmax.PartitionOfUnity"
543    #[serde(default)]
544    pub lean_theorem: Option<String>,
545    /// IEEE 754 tolerance: codegen emits `>=` instead of `>` for boundaries (GH-67).
546    #[serde(default)]
547    pub float_tolerance: Option<f64>,
548    /// Compositional verification: what this equation requires from upstream.
549    /// References a guarantees block from another contract/equation.
550    #[serde(default)]
551    pub assumes: Option<ShapeContract>,
552    /// Compositional verification: what this equation provides to downstream.
553    /// Must be satisfiable by any downstream equation that assumes it.
554    #[serde(default)]
555    pub guarantees: Option<ShapeContract>,
556}
557
558/// One or more cited targets (#3347).
559///
560/// The corpus writes an obligation-to-test citation three ways and all three
561/// are authored by hand, so the type accepts all three rather than making one
562/// of them a parse error: a scalar (`FALSIFY-PM-004`), a comma-separated
563/// scalar (`apr-serve-cancellation-v1` names four in one field), and a YAML
564/// sequence (`publish-manifest-v1`, the only one today -- and the one that
565/// proved `Option<String>` was the wrong type by failing the WHOLE corpus
566/// with `invalid type: sequence, expected a string`).
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(untagged)]
569pub enum Citation {
570    /// A single field, possibly holding a comma-separated list.
571    One(String),
572    /// A YAML sequence of targets.
573    Many(Vec<String>),
574}
575
576impl Citation {
577    /// The cited targets, trimmed, with empties dropped. A comma splits a
578    /// scalar because contracts write lists both ways.
579    pub fn targets(&self) -> impl Iterator<Item = &str> {
580        let slice: &[String] = match self {
581            Self::One(s) => std::slice::from_ref(s),
582            Self::Many(v) => v.as_slice(),
583        };
584        slice
585            .iter()
586            .flat_map(|s| s.split(','))
587            .map(str::trim)
588            .filter(|t| !t.is_empty())
589    }
590}
591
592/// A proof obligation derived from an equation.
593///
594/// 26 obligation types: 19 property types plus 7 Design by Contract
595/// types (`precondition`, `postcondition`, `frame`, `loop_invariant`,
596/// `loop_variant`, `old_state`, `subcontract`).
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct ProofObligation {
599    /// Stable identifier, e.g. `GDN-BND-001`. `Option` because 52 contracts
600    /// predate the convention and carry their own (`REG-OB-001`, `PO-HEH-001`,
601    /// `OBLIG-DATA-QUALITY-007-DEAD-OUTPUT-ROW`), and because requiring it is a
602    /// separate, enforcing change.
603    ///
604    /// Until this field existed, `id:` was written to disk and **silently
605    /// dropped on parse** -- the struct had no such field and there is no
606    /// `deny_unknown_fields`, so 3,612 ids generated by
607    /// `scripts/lib/obligation_ids.py` were decoration: no consumer could read
608    /// one. An obligation with no id cannot be cited by a kani harness, a test,
609    /// a receipt or a commit, which is the whole point of having one (#3314).
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub id: Option<String>,
612    /// Obligation category. Defaults to `Invariant` for legacy contracts
613    /// that predate the DbC split (e.g. `eval-harness-humaneval-v1`,
614    /// `publish-manifest-v1`) which ship with just `property:`/`formal:`.
615    #[serde(rename = "type", default)]
616    pub obligation_type: ObligationType,
617    /// Human-readable statement of what must hold. Alias `statement`
618    /// accepted for legacy diagnostic contracts (e.g.
619    /// `decode-hot-path-prefix-cache-diagnostic-v1`) whose POs predate
620    /// the canonical `property:` naming.
621    #[serde(default, alias = "statement")]
622    pub property: String,
623    /// Formal predicate (Rust/Lean syntax). Alias `verification` accepted
624    /// for legacy contracts that ship a shell/pmat-query check instead of
625    /// a formal predicate.
626    #[serde(default, alias = "verification")]
627    pub formal: Option<String>,
628    #[serde(default)]
629    pub tolerance: Option<f64>,
630    #[serde(default)]
631    pub applies_to: Option<AppliesTo>,
632    /// The falsification test(s) that discharge this obligation (#3347) --
633    /// the same link as `FalsificationTest::obligation`, written from the
634    /// obligation's side. 89 obligations in `contracts/` carry it and it is
635    /// the most-used spelling of the link; it too was dropped on parse.
636    ///
637    /// Two resolvable shapes, both measured: `falsification_tests[N]` (62,
638    /// resolves only when `N` is in range) and a test `id` (13). The
639    /// remaining 14 are comma-separated id lists, prose, or a KANI harness id
640    /// -- a kani id is NOT an L2 link and deliberately does not resolve.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub discharged_by: Option<Citation>,
643    /// Why this obligation is NOT a property of code (PMAT-3091) -- e.g. a
644    /// checkpoint fact, an `O()` with no constant, a throughput claim.
645    ///
646    /// Only meaningful with `applies_to: not_applicable`, where it is REQUIRED
647    /// (SCHEMA-021). Present on any other obligation it is decoration -- a
648    /// justification nothing declares -- and is an error (SCHEMA-023).
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub na_reason: Option<String>,
651    /// Where the claim IS verified, since a unit test cannot (PMAT-3091): a
652    /// bench, a `pv`/CI check, or an evidence command.
653    ///
654    /// Same decoration rule as `na_reason`: required with
655    /// `applies_to: not_applicable` (SCHEMA-022), an error without it
656    /// (SCHEMA-023).
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub na_owner: Option<String>,
659    /// Phase 7: Lean 4 theorem proving metadata.
660    #[serde(default)]
661    pub lean: Option<LeanProof>,
662    /// Postcondition only: links to a precondition obligation ID.
663    #[serde(default)]
664    pub requires: Option<String>,
665    /// Loop invariant/variant only: references a `kernel_structure.phases[]` name.
666    #[serde(default)]
667    pub applies_to_phase: Option<String>,
668    /// Subcontract only: contract stem being refined (must be in `metadata.depends_on`).
669    #[serde(default)]
670    pub parent_contract: Option<String>,
671}
672
673#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
674#[serde(rename_all = "lowercase")]
675pub enum ObligationType {
676    #[default]
677    Invariant,
678    Equivalence,
679    Bound,
680    Monotonicity,
681    Idempotency,
682    Linearity,
683    Symmetry,
684    Associativity,
685    Conservation,
686    Ordering,
687    Completeness,
688    Soundness,
689    Involution,
690    Determinism,
691    Roundtrip,
692    #[serde(rename = "state_machine")]
693    StateMachine,
694    Classification,
695    Independence,
696    Termination,
697    /// Memory/IO safety obligation (bounds checks, non-null, etc.). Legacy
698    /// pre-APR-MONO contracts (e.g. `apr-cli-publish-extra-v1`) used this
699    /// spelling; kept for back-compat alongside the 26 other types.
700    Safety,
701    /// Liveness property (eventually-happens). Same legacy contract
702    /// (`apr-cli-publish-extra-v1`) uses this for progress obligations;
703    /// kept for back-compat.
704    Liveness,
705    // Eiffel DbC types (Meyer 1997)
706    Precondition,
707    Postcondition,
708    Frame,
709    #[serde(rename = "loop_invariant")]
710    LoopInvariant,
711    #[serde(rename = "loop_variant")]
712    LoopVariant,
713    #[serde(rename = "old_state")]
714    OldState,
715    Subcontract,
716}
717
718impl std::fmt::Display for ObligationType {
719    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
720        let s = match self {
721            Self::Invariant => "invariant",
722            Self::Equivalence => "equivalence",
723            Self::Bound => "bound",
724            Self::Monotonicity => "monotonicity",
725            Self::Idempotency => "idempotency",
726            Self::Linearity => "linearity",
727            Self::Symmetry => "symmetry",
728            Self::Associativity => "associativity",
729            Self::Conservation => "conservation",
730            Self::Ordering => "ordering",
731            Self::Completeness => "completeness",
732            Self::Soundness => "soundness",
733            Self::Involution => "involution",
734            Self::Determinism => "determinism",
735            Self::Roundtrip => "roundtrip",
736            Self::StateMachine => "state_machine",
737            Self::Classification => "classification",
738            Self::Independence => "independence",
739            Self::Termination => "termination",
740            Self::Safety => "safety",
741            Self::Liveness => "liveness",
742            Self::Precondition => "precondition",
743            Self::Postcondition => "postcondition",
744            Self::Frame => "frame",
745            Self::LoopInvariant => "loop_invariant",
746            Self::LoopVariant => "loop_variant",
747            Self::OldState => "old_state",
748            Self::Subcontract => "subcontract",
749        };
750        write!(f, "{s}")
751    }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755#[serde(rename_all = "lowercase")]
756pub enum AppliesTo {
757    All,
758    Scalar,
759    Simd,
760    Converter,
761    /// Not a property of code, so not applicable to unit tests (PMAT-3091).
762    /// Requires `na_reason` and `na_owner` on the obligation. `N/A` is accepted
763    /// as an alias; it is matched as a named variant BEFORE the `#[serde(other)]`
764    /// catch-all, so it can never parse as an algorithm target named "N/A".
765    /// Always serialized as `not_applicable`.
766    #[serde(rename = "not_applicable", alias = "N/A")]
767    NotApplicable,
768    /// Algorithm-specific target (e.g., "degree", "bce", "huber").
769    #[serde(other)]
770    Other,
771}
772
773impl ProofObligation {
774    /// `true` when the obligation is declared `applies_to: not_applicable`.
775    #[must_use]
776    pub fn is_not_applicable(&self) -> bool {
777        self.applies_to == Some(AppliesTo::NotApplicable)
778    }
779}
780
781/// Kernel phase decomposition.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct KernelStructure {
784    pub phases: Vec<KernelPhase>,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct KernelPhase {
789    pub name: String,
790    pub description: String,
791    #[serde(default)]
792    pub invariant: Option<String>,
793}
794
795/// An enforcement rule from the contract.
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct EnforcementRule {
798    pub description: String,
799    #[serde(default)]
800    pub check: Option<String>,
801    #[serde(default)]
802    pub severity: Option<String>,
803    #[serde(default)]
804    pub reference: Option<String>,
805}
806
807/// A Popperian falsification test.
808///
809/// Each makes a falsifiable prediction about the implementation.
810/// If the prediction is wrong, the test identifies root cause.
811#[derive(Debug, Clone, Default, Serialize, Deserialize)]
812pub struct FalsificationTest {
813    pub id: String,
814    /// What the test asserts. Alias `description` accepted for legacy
815    /// pre-APR-MONO contracts that used the `description:` field name.
816    /// `name:` is NOT aliased because several legacy contracts (e.g.
817    /// `publish-manifest-v1`) ship both `name:` (a slug) and
818    /// `description:` (prose) side-by-side; aliasing both collapses to
819    /// a `duplicate field` error.
820    #[serde(default, alias = "description")]
821    pub rule: String,
822    /// The predicted outcome if the rule holds. Alias `expected` accepted
823    /// for legacy contracts (e.g. `expected: exit 0`, `expected: "PASS"`).
824    /// Defaulted because diagnostic contracts often encode prediction
825    /// inside the rule text alone.
826    #[serde(default, alias = "expected")]
827    pub prediction: String,
828    /// How to run the test. Alias `command` accepted for legacy contracts
829    /// (e.g. shell snippets under `command: |`).
830    #[serde(default, alias = "command")]
831    pub test: Option<String>,
832    /// How to run the test, in the `test_harness:` spelling. 619 entries in
833    /// `contracts/` use this field INSTEAD of `test:` — 94 of them holding a
834    /// real `cargo test` invocation, the rest a shell harness (`grep -q …`,
835    /// `test -f …`, `bash …`).
836    ///
837    /// #2465: this field did not exist on the struct, and `FalsificationTest`
838    /// is not `deny_unknown_fields`, so serde dropped it silently. Every one
839    /// of those 619 entries reached `strict_test_binding` with `test: None`
840    /// and was skipped — the gate reported them as neither bound nor broken.
841    #[serde(default)]
842    pub test_harness: Option<String>,
843    /// The bare test-fn name, when the contract names it here rather than in
844    /// an invocation. Deliberately NOT a serde `alias` of `rule`: several
845    /// legacy contracts (e.g. `publish-manifest-v1`) ship `name:` (a slug)
846    /// and `description:` (prose) side by side, and aliasing both onto one
847    /// field collapses to a `duplicate field` parse error. Consumed as a
848    /// binding source of last resort — see `strict_test_binding`.
849    #[serde(default)]
850    pub name: Option<String>,
851    /// What failure means. Alias `fails_if` accepted for legacy contracts.
852    /// Defaulted because several legacy diagnostic contracts omit it.
853    #[serde(default, alias = "fails_if")]
854    pub if_fails: String,
855    /// The obligation this test discharges — the only machine-readable claim
856    /// that THIS test proves THAT obligation (#3347).
857    ///
858    /// Until this field existed the key was written to disk and silently
859    /// dropped on parse (the same shape as `id` in #3314 and `test_harness`
860    /// in #2465: no `deny_unknown_fields`, so serde discarded it), which is
861    /// why `obligation_matrix` had nothing to read and fell back to comparing
862    /// an INDEX against `falsification_tests.len()`.
863    ///
864    /// Measured over `contracts/` (1,842 files, 4,691 falsification tests):
865    /// 26 entries spell it `obligation:` and 38 spell it `binds_to:`. No entry
866    /// carries BOTH — checked, and it matters, because serde collapses an
867    /// alias pair present on one mapping into a `duplicate field` parse error.
868    ///
869    /// Resolved against the obligation's `id`, then its exact `property` text.
870    /// A comma-separated list cites several obligations.
871    #[serde(default, alias = "binds_to", skip_serializing_if = "Option::is_none")]
872    pub obligation: Option<Citation>,
873}
874
875/// A Kani bounded model checking harness definition.
876///
877/// Corresponds to Phase 6 (Verify) of the pipeline.
878#[derive(Debug, Clone, Default, Serialize, Deserialize)]
879pub struct KaniHarness {
880    pub id: String,
881    pub obligation: String,
882    #[serde(default)]
883    pub property: Option<String>,
884    #[serde(default)]
885    pub bound: Option<u32>,
886    #[serde(default)]
887    pub strategy: Option<KaniStrategy>,
888    #[serde(default)]
889    pub solver: Option<String>,
890    #[serde(default)]
891    pub harness: Option<String>,
892    /// GH-1595: When `true`, the harness has been verified by a green
893    /// `cargo kani` run in CI (e.g. apr-cookbook `kani-gate`). Lifts the
894    /// D3 strategy weight to 1.0 for non-exhaustive strategies because
895    /// the runtime witness supplants the static-readiness signal.
896    #[serde(default)]
897    pub actually_verified: Option<bool>,
898}
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
901#[serde(rename_all = "snake_case")]
902pub enum KaniStrategy {
903    Exhaustive,
904    StubFloat,
905    Compositional,
906    BoundedInt,
907    /// Symbolic IEEE-754 `f32` over a bounded window, each element assumed
908    /// `is_finite()` and within a magnitude bound. Transcendentals are NOT
909    /// stubbed: the proof discharges the real float arithmetic. Contrast
910    /// `stub_float`, which axiomatizes exp/log/sin/cos (#2530).
911    BoundedFloat,
912}
913
914impl std::fmt::Display for KaniStrategy {
915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916        let s = match self {
917            Self::Exhaustive => "exhaustive",
918            Self::StubFloat => "stub_float",
919            Self::Compositional => "compositional",
920            Self::BoundedInt => "bounded_int",
921            Self::BoundedFloat => "bounded_float",
922        };
923        write!(f, "{s}")
924    }
925}
926
927/// Phase 7: Lean 4 theorem proving metadata for a proof obligation.
928#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct LeanProof {
930    /// Lean 4 theorem name (e.g., `Softmax.partition_of_unity`).
931    pub theorem: String,
932    /// Lean 4 module path (e.g., `ProvableContracts.Softmax`).
933    #[serde(default)]
934    pub module: Option<String>,
935    /// Current status of the Lean proof.
936    #[serde(default)]
937    pub status: LeanStatus,
938    /// Lean-level theorem dependencies.
939    #[serde(default)]
940    pub depends_on: Vec<String>,
941    /// Mathlib import paths required.
942    #[serde(default)]
943    pub mathlib_imports: Vec<String>,
944    /// Free-form notes (e.g., "Proof over reals; f32 gap addressed separately").
945    #[serde(default)]
946    pub notes: Option<String>,
947}
948
949/// Status of a Lean 4 proof.
950#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
951#[serde(rename_all = "kebab-case")]
952pub enum LeanStatus {
953    /// Proof is complete and type-checks.
954    Proved,
955    /// Proof uses `sorry` (axiomatized, not yet proved).
956    #[default]
957    Sorry,
958    /// Work in progress.
959    Wip,
960    /// Obligation is not amenable to Lean proof (e.g., performance).
961    NotApplicable,
962}
963
964impl std::fmt::Display for LeanStatus {
965    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966        let s = match self {
967            Self::Proved => "proved",
968            Self::Sorry => "sorry",
969            Self::Wip => "wip",
970            Self::NotApplicable => "not-applicable",
971        };
972        write!(f, "{s}")
973    }
974}
975
976/// Phase 7: Verification summary across all obligations in a contract.
977#[derive(Debug, Clone, Serialize, Deserialize)]
978pub struct VerificationSummary {
979    pub total_obligations: u32,
980    #[serde(default)]
981    pub l2_property_tested: u32,
982    #[serde(default)]
983    pub l3_kani_proved: u32,
984    #[serde(default)]
985    pub l4_lean_proved: u32,
986    #[serde(default)]
987    pub l4_sorry_count: u32,
988    #[serde(default)]
989    pub l4_not_applicable: u32,
990}
991
992/// QA gate definition for certeza integration.
993///
994/// Legacy diagnostic contracts (e.g.
995/// `decode-hot-path-prefix-cache-diagnostic-v1`) ship a `qa_gate:` block
996/// with only `must_pass:` / `integration:` / `regression_protection:` — no
997/// `id:` or `name:`. All schema fields default so those parse cleanly.
998#[derive(Debug, Clone, Default, Serialize, Deserialize)]
999pub struct QaGate {
1000    #[serde(default)]
1001    pub id: String,
1002    #[serde(default)]
1003    pub name: String,
1004    #[serde(default)]
1005    pub description: Option<String>,
1006    #[serde(default)]
1007    pub checks: Vec<String>,
1008    #[serde(default)]
1009    pub pass_criteria: Option<String>,
1010    #[serde(default)]
1011    pub falsification: Option<String>,
1012}
1013
1014/// A type-level invariant (Meyer's class invariant).
1015///
1016/// Asserts a predicate that must hold for every instance of `type_name`
1017/// at every stable state — after construction and after every public method.
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct TypeInvariant {
1020    pub name: String,
1021    /// Rust type name (e.g., `ValidatedTensor`).
1022    #[serde(rename = "type")]
1023    pub type_name: String,
1024    /// Rust boolean expression over `self` (e.g., `!self.dims.is_empty()`).
1025    pub predicate: String,
1026    #[serde(default)]
1027    pub description: Option<String>,
1028}
1029
1030/// Coq verification specification for a contract.
1031#[derive(Debug, Clone, Serialize, Deserialize)]
1032pub struct CoqSpec {
1033    /// Coq module name (e.g., `SoftmaxSpec`).
1034    pub module: String,
1035    /// Coq import statements.
1036    #[serde(default)]
1037    pub imports: Vec<String>,
1038    /// Coq definitions generated from equations.
1039    #[serde(default)]
1040    pub definitions: Vec<CoqDefinition>,
1041    /// Links from proof obligations to Coq lemmas.
1042    #[serde(default)]
1043    pub obligations: Vec<CoqObligation>,
1044}
1045
1046/// A Coq definition derived from a contract equation.
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1048pub struct CoqDefinition {
1049    pub name: String,
1050    pub statement: String,
1051}
1052
1053/// A link between a proof obligation and a Coq lemma.
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1055pub struct CoqObligation {
1056    /// References a proof obligation property or ID.
1057    pub links_to: String,
1058    /// Coq lemma name.
1059    pub coq_lemma: String,
1060    /// Current status of the Coq proof.
1061    #[serde(default = "coq_status_default")]
1062    pub status: String,
1063}
1064
1065fn coq_status_default() -> String {
1066    "stub".to_string()
1067}
1068
1069/// Accepts `equations:` as either a map (canonical) or a list-of-dicts
1070/// with an `id` field (legacy pre-APR-MONO diagnostic contracts like
1071/// `decode-hot-path-prefix-cache-diagnostic-v1`). The list form promotes
1072/// each entry's `id` to the map key; entries without `id` fall back to
1073/// `equation_{N}` so parsing never silently drops data.
1074fn deserialize_equations<'de, D>(d: D) -> Result<BTreeMap<String, Equation>, D::Error>
1075where
1076    D: serde::Deserializer<'de>,
1077{
1078    use serde::de::Error;
1079    use serde_yaml::Value;
1080
1081    let value = Value::deserialize(d)?;
1082    match value {
1083        Value::Null => Ok(BTreeMap::new()),
1084        Value::Mapping(_) => serde_yaml::from_value(value).map_err(D::Error::custom),
1085        Value::Sequence(items) => {
1086            let mut out = BTreeMap::new();
1087            for (i, mut item) in items.into_iter().enumerate() {
1088                let key = match &mut item {
1089                    Value::Mapping(m) => m
1090                        .remove(Value::String("id".into()))
1091                        .and_then(|v| v.as_str().map(ToString::to_string))
1092                        .unwrap_or_else(|| format!("equation_{i}")),
1093                    _ => format!("equation_{i}"),
1094                };
1095                let eq: Equation = serde_yaml::from_value(item).map_err(D::Error::custom)?;
1096                out.insert(key, eq);
1097            }
1098            Ok(out)
1099        }
1100        other => Err(D::Error::custom(format!(
1101            "`equations:` must be a map or a list; got {other:?}"
1102        ))),
1103    }
1104}
1105
1106#[cfg(test)]
1107#[path = "types_tests.rs"]
1108mod tests;