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;