Skip to main content

provable_contracts/schema/
types.rs

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