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