Skip to main content

provable_contracts/schema/
validator.rs

1use std::collections::HashSet;
2
3use crate::error::{Severity, Violation};
4use crate::schema::types::{Contract, ContractKind, CONTRACT_TOP_LEVEL_FIELDS};
5
6/// Validate a parsed contract for completeness and consistency.
7///
8/// Returns a list of violations. If any violation has
9/// [`Severity::Error`], the contract is considered invalid.
10///
11/// Validation is kind-aware: non-kernel contracts (registries, model-family
12/// schemas, reference documents) are validated only for metadata consistency;
13/// the provability invariant, equations, and proof/kani/falsification checks
14/// only apply to `ContractKind::Kernel`.
15pub fn validate_contract(contract: &Contract) -> Vec<Violation> {
16    let mut violations = Vec::new();
17
18    validate_metadata(contract, &mut violations);
19    // Runs BEFORE the kind split below on purpose: a top-level `kind:` is
20    // exactly the key that would otherwise decide which branch runs, and the
21    // whole point of SCHEMA-018 is that it silently decides nothing.
22    validate_top_level_keys(contract, &mut violations);
23
24    // Kernel-only checks: these enforce the provability invariant and
25    // require equations + proof obligations + tests + Kani harnesses.
26    if contract.kind() == ContractKind::Kernel && !contract.is_registry() {
27        validate_equations(contract, &mut violations);
28        validate_provability_invariant(contract, &mut violations);
29        validate_proof_obligations(contract, &mut violations);
30        validate_falsification_tests(contract, &mut violations);
31        validate_kani_harnesses(contract, &mut violations);
32        validate_qa_gate(contract, &mut violations);
33    } else {
34        // Non-kernel kinds (registry, model-family, schema): still validate
35        // any proof obligations/falsification/kani data that IS present, so
36        // mistakes are caught even on exempt contracts.
37        validate_proof_obligations(contract, &mut violations);
38        validate_falsification_tests(contract, &mut violations);
39        validate_kani_harnesses(contract, &mut violations);
40    }
41
42    // BeatBenchmark-only checks (PMAT-741): the `beat:` block must pin a
43    // falsifiable, four-pillar incumbent baseline. Independent of the
44    // kernel/non-kernel split above.
45    if contract.kind() == ContractKind::BeatBenchmark {
46        validate_beat_benchmark(contract, &mut violations);
47    }
48
49    // Kaizen-only checks: an improvement record is exempt from
50    // PROVABILITY-001 (it is a measurement, not a theorem) but is held to its
51    // own falsifiability rules — see `schema::kaizen`, KAIZEN-001..006.
52    if contract.kind() == ContractKind::Kaizen {
53        crate::schema::kaizen::validate_kaizen(contract, &mut violations);
54    }
55
56    // CRUX competitive-research metadata (aprender#2555): kind-independent —
57    // the three fields are carried by 275 `crux-*` contracts of several kinds
58    // and by non-crux contracts that reuse the vocabulary.
59    validate_crux_intake(contract, &mut violations);
60
61    violations
62}
63
64/// The closed set of competitors a CRUX story may be extracted from
65/// (`metadata.competitor`, rule CRUX-002).
66///
67/// # Why this is NOT `BEAT_INCUMBENTS`
68///
69/// Reusing [`BEAT_INCUMBENTS`] was considered and REJECTED — it names a
70/// different domain and would import a defect. `BEAT_INCUMBENTS` answers "whom
71/// does aprender claim to *beat* on a pinned benchmark" (the four-pillar
72/// mission); `metadata.competitor` answers "whose UX was this story *extracted
73/// from*". MEASURED on this branch: 275 contract FILES carry the field, in 292
74/// declarations (17 crux contracts carry a second `competitor` inside an
75/// equivalence obligation). `BEAT_INCUMBENTS.iter().any(|p| c.contains(p))`
76/// accepts only `pytorch` (37) and `ollama` (21) — 58 of 292. It cannot name:
77///
78/// - `huggingface` (88 contracts — the single largest source), nor `vllm` (32),
79/// - `llama_cpp` (37): the BEAT list spells it `llama.cpp`, and `"llama.cpp"`
80///   is not a substring of `"llama_cpp"`, so even the pillar it does name is
81///   missed under the underscore spelling the crux corpus uses,
82/// - `ecosystem` (30), `openclaw` (20), `hf-kernels-community` (15),
83///   `apr-qa-playbook` (9), `openclip` (2), `none` (1).
84///
85/// Extended 2026-09-12 (aprender#3146): category N adds `burn` (7 stories) and
86/// `linfa` (10), the two Rust-native ML frameworks. Neither is substring-matched
87/// by `BEAT_INCUMBENTS`, so neither could be named by reusing that list.
88///
89/// So this registry is the corpus vocabulary, exactly. Every member is
90/// exercised by at least one contract in `contracts/`; adding a competitor is a
91/// deliberate one-line edit here plus a test, which is the point — an open
92/// domain is what let `THIS-COMPETITOR-DOES-NOT-EXIST` validate.
93pub(crate) const CRUX_COMPETITORS: [&str; 14] = [
94    "apr-qa-playbook",
95    // Burn (tracel-ai/burn) — the Rust deep-learning framework, 0.21.0 / 15.9k
96    // stars / 312 reverse-dependencies at admission. Added 2026-09-12 with 7
97    // category-N stories extracted from its crate surface: burn-linalg (SVD),
98    // burn-tensor (const-generic rank), burn-autodiff (op coverage), ONNX
99    // import, burn-ir, burn-rl, burn-vision. NOT a BEAT pillar — aprender makes
100    // no claim to beat Burn on a pinned benchmark; this is a capability/UX
101    // source, which is exactly the distinction this registry exists to keep.
102    "burn",
103    "ecosystem",
104    "hf-kernels-community",
105    "huggingface",
106    // linfa (rust-ml/linfa) — the Rust classical-ML toolkit, 0.8.1 / 18
107    // algorithm sub-crates at admission. Added 2026-09-12 with 10 category-N
108    // stories: linfa-nn (spatial index), linfa-pls, linfa-lars, linfa-kernel,
109    // linfa-ftrl, linfa-ensemble (AdaBoost/bagging), OPTICS, Barnes-Hut t-SNE,
110    // random projection, PCA-on-SVD. `scikit-learn` is the BEAT pillar on this
111    // axis and is deliberately NOT here; linfa is the Rust-native UX source.
112    "linfa",
113    "llama_cpp",
114    "none",
115    "ollama",
116    "openclaw",
117    "openclip",
118    // Orange Sun Pulp Free Chat — a local-first desktop chat app (CRUX-C-37).
119    // Admitted because it competes on the SAME axis this project sells on
120    // (private, on-device, no subscription) while publishing no throughput
121    // number at all, which is itself a competitive datapoint.
122    "pulp-free-chat",
123    "pytorch",
124    "vllm",
125];
126
127/// Documented inclusive bounds of `metadata.demand_score`, from
128/// `contracts/crux-competitive-research-ux-v1.yaml`: "a demand_score (1..5) …
129/// demand_score maps directly to pmat priority".
130const DEMAND_SCORE_RANGE: std::ops::RangeInclusive<i64> = 1..=5;
131
132/// Validate the CRUX competitive-research domains (aprender#2555).
133///
134/// Two SURFACES carry these fields, and both are checked here:
135///
136/// 1. `metadata.{competitor,demand_score,intake_status}` on an individual
137///    `crux-*` contract.
138/// 2. The `stories:` rows of the MASTER REGISTRY,
139///    `contracts/crux-competitive-research-ux-v1.yaml`.
140///
141/// Surface 2 was added because the original rationale for this rule did not
142/// survive measurement. #2555 justified CRUX-001 as guarding "the ranking
143/// signal the whole competitive-research programme sorts by" — but MEASURED,
144/// nothing in the repo reads `metadata.demand_score`. The signal §12.1 of
145/// `docs/specifications/crux-competitive-research-ux-workflows.md` maps to
146/// `pmat work` priority is `stories[].demand_score` in the registry: 250 rows,
147/// entirely ungated. Checking only surface 1 left the stated justification
148/// unsupported by the code.
149///
150/// On a registry row the three fields are also REQUIRED, not optional. On
151/// surface 1 they cannot be: `Option` is right there, because 1500-odd non-crux
152/// contracts carry none of them (see the presence obligation in
153/// `contracts/crux-intake-metadata-domains-v1.yaml`). A registry row has no
154/// such excuse — it exists to be ranked.
155///
156/// `intake_status` / `status` values are absent from the checks below ON
157/// PURPOSE: both are the closed enum `IntakeStatus`, so an invented value is
158/// rejected during deserialization and never reaches a validator. That is the
159/// stronger guarantee — a lint can be read and ignored, a parse failure cannot.
160fn validate_crux_intake(contract: &Contract, violations: &mut Vec<Violation>) {
161    // CRUX-001: demand_score is the ranking signal the whole competitive-research
162    // programme sorts by. An unvalidated out-of-range value silently dominates
163    // every ranking it appears in.
164    if let Some(score) = contract.metadata.demand_score {
165        if !DEMAND_SCORE_RANGE.contains(&score) {
166            violations.push(Violation {
167                severity: Severity::Error,
168                rule: "CRUX-001".to_string(),
169                message: format!(
170                    "metadata.demand_score {score} is outside the documented range {}..={} \
171                     — it is the priority signal pmat work sorts by, so an out-of-range \
172                     value silently outranks every real story",
173                    DEMAND_SCORE_RANGE.start(),
174                    DEMAND_SCORE_RANGE.end(),
175                ),
176                location: Some("metadata.demand_score".to_string()),
177            });
178        }
179    }
180
181    // CRUX-002: competitor must name a source in the registry above.
182    //
183    // No `.trim()` here, deliberately. It used to trim before comparing, which
184    // made `competitor: "  ecosystem  "` validate clean while the STORED value
185    // kept its padding — the check laundered a value it did not fix, so every
186    // consumer reading `metadata.competitor` still saw the untrimmed string.
187    // Normalisation now happens once, at parse time
188    // (`deserialize_trimmed_opt_string` in `schema/types.rs`), so what is
189    // compared is exactly what is stored.
190    if let Some(competitor) = contract.metadata.competitor.as_deref() {
191        if !CRUX_COMPETITORS.contains(&competitor) {
192            violations.push(Violation {
193                severity: Severity::Error,
194                rule: "CRUX-002".to_string(),
195                message: format!(
196                    "metadata.competitor {competitor:?} is not a known competitive-research \
197                     source — must be one of: {}",
198                    CRUX_COMPETITORS.join(", ")
199                ),
200                location: Some("metadata.competitor".to_string()),
201            });
202        }
203    }
204
205    validate_crux_registry_stories(contract, violations);
206}
207
208/// Hold every MASTER-REGISTRY story row to the same two domains.
209///
210/// These are the rows that carry the ranking signal, so here the fields are
211/// required as well as bounded: a row with no `demand_score` cannot be sorted,
212/// and a row with no `competitor` cannot be attributed.
213fn validate_crux_registry_stories(contract: &Contract, violations: &mut Vec<Violation>) {
214    for story in &contract.stories {
215        let at = |field: &str| Some(format!("stories[{}].{field}", story.id));
216
217        match story.demand_score {
218            None => violations.push(Violation {
219                severity: Severity::Error,
220                rule: "CRUX-001".to_string(),
221                message: format!(
222                    "registry story {} has no demand_score — it is the priority signal \
223                     pmat work sorts by, and an absent one sorts arbitrarily",
224                    story.id
225                ),
226                location: at("demand_score"),
227            }),
228            Some(score) if !DEMAND_SCORE_RANGE.contains(&score) => violations.push(Violation {
229                severity: Severity::Error,
230                rule: "CRUX-001".to_string(),
231                message: format!(
232                    "registry story {} has demand_score {score}, outside the documented \
233                     range {}..={} — a single fabricated score reorders the whole queue",
234                    story.id,
235                    DEMAND_SCORE_RANGE.start(),
236                    DEMAND_SCORE_RANGE.end(),
237                ),
238                location: at("demand_score"),
239            }),
240            Some(_) => {}
241        }
242
243        match story.competitor.as_deref() {
244            None => violations.push(Violation {
245                severity: Severity::Error,
246                rule: "CRUX-002".to_string(),
247                message: format!(
248                    "registry story {} has no competitor — the row cannot be attributed \
249                     to the UX it was extracted from",
250                    story.id
251                ),
252                location: at("competitor"),
253            }),
254            Some(c) if !CRUX_COMPETITORS.contains(&c) => violations.push(Violation {
255                severity: Severity::Error,
256                rule: "CRUX-002".to_string(),
257                message: format!(
258                    "registry story {} names competitor {c:?}, which is not a known \
259                     competitive-research source — must be one of: {}",
260                    story.id,
261                    CRUX_COMPETITORS.join(", ")
262                ),
263                location: at("competitor"),
264            }),
265            Some(_) => {}
266        }
267    }
268}
269
270/// The four incumbents a BEAT may target (case-insensitive substring match, so
271/// `ollama` and `llama.cpp` both satisfy Pillar 4).
272const BEAT_INCUMBENTS: [&str; 5] = ["scikit-learn", "pytorch", "unsloth", "ollama", "llama.cpp"];
273
274/// Enforce the BeatBenchmark shape (PMAT-741): a `beat-benchmark` contract MUST
275/// carry a well-formed `beat:` block so the claim is a falsifiable CI gate, not
276/// prose. Rules BEAT-001..007.
277fn validate_beat_benchmark(contract: &Contract, violations: &mut Vec<Violation>) {
278    let push = |violations: &mut Vec<Violation>, rule: &str, message: String, field: &str| {
279        violations.push(Violation {
280            severity: Severity::Error,
281            rule: rule.to_string(),
282            message,
283            location: Some(format!("beat.{field}")),
284        });
285    };
286
287    let Some(beat) = contract.beat.as_ref() else {
288        violations.push(Violation {
289            severity: Severity::Error,
290            rule: "BEAT-001".to_string(),
291            message: "beat-benchmark contract must define a `beat:` block \
292                      (incumbent, metric, direction, beat_threshold, ci_gate_name)"
293                .to_string(),
294            location: Some("beat".to_string()),
295        });
296        return;
297    };
298
299    // BEAT-002: incumbent must name one of the four pillars.
300    let incumbent = beat.incumbent.trim().to_lowercase();
301    if incumbent.is_empty() {
302        push(
303            violations,
304            "BEAT-002",
305            "beat.incumbent must not be empty".to_string(),
306            "incumbent",
307        );
308    } else if !BEAT_INCUMBENTS.iter().any(|p| incumbent.contains(p)) {
309        push(
310            violations,
311            "BEAT-002",
312            format!(
313                "beat.incumbent {:?} must name one of the four pillars ({})",
314                beat.incumbent,
315                BEAT_INCUMBENTS.join(", ")
316            ),
317            "incumbent",
318        );
319    }
320
321    // BEAT-003: a measured metric is required.
322    if beat.metric.trim().is_empty() {
323        push(
324            violations,
325            "BEAT-003",
326            "beat.metric must name the measured quantity (e.g. accuracy, wall_clock_ms, \
327             tokens_per_sec)"
328                .to_string(),
329            "metric",
330        );
331    }
332
333    // BEAT-004: direction fixes which way is a regression.
334    match beat.direction.trim() {
335        "higher_is_better" | "lower_is_better" => {}
336        other => push(
337            violations,
338            "BEAT-004",
339            format!(
340                "beat.direction must be `higher_is_better` or `lower_is_better`, got {other:?}"
341            ),
342            "direction",
343        ),
344    }
345
346    // BEAT-005: a finite, machine-pinned threshold is required (the gate value).
347    match beat.beat_threshold {
348        None => push(
349            violations,
350            "BEAT-005",
351            "beat.beat_threshold is required — the pinned value CI fails below".to_string(),
352            "beat_threshold",
353        ),
354        Some(t) if !t.is_finite() => push(
355            violations,
356            "BEAT-005",
357            format!("beat.beat_threshold must be finite, got {t}"),
358            "beat_threshold",
359        ),
360        Some(_) => {}
361    }
362
363    // BEAT-006: the enforcing CI gate must be named.
364    if beat.ci_gate_name.trim().is_empty() {
365        push(
366            violations,
367            "BEAT-006",
368            "beat.ci_gate_name must name the CI test that enforces this gate".to_string(),
369            "ci_gate_name",
370        );
371    }
372
373    // BEAT-007: approved_compute is required and must be CPU or GPU (the
374    // autonomous-vs-operator track distinction depends on it).
375    match beat
376        .approved_compute
377        .as_deref()
378        .map(|c| c.trim().to_uppercase())
379    {
380        None => push(
381            violations,
382            "BEAT-007",
383            "beat.approved_compute is required — must be `CPU` or `GPU`".to_string(),
384            "approved_compute",
385        ),
386        Some(ref c) if c != "CPU" && c != "GPU" => push(
387            violations,
388            "BEAT-007",
389            format!(
390                "beat.approved_compute must be `CPU` or `GPU`, got {:?}",
391                beat.approved_compute
392            ),
393            "approved_compute",
394        ),
395        Some(_) => {}
396    }
397}
398
399/// Enforce the provability invariant: kernel contracts (non-registry) MUST have
400/// `proof_obligations`, `falsification_tests`, and `kani_harnesses`.
401fn validate_provability_invariant(contract: &Contract, violations: &mut Vec<Violation>) {
402    for v in contract.provability_violations() {
403        violations.push(Violation {
404            severity: Severity::Error,
405            rule: "PROVABILITY-001".to_string(),
406            message: v,
407            location: None,
408        });
409    }
410}
411
412/// The forms a YAML key could be a plural/case/separator variant of.
413///
414/// Case-folded with separators dropped, then the key itself plus its `-s` and
415/// `-es` singularizations. Comparing SETS rather than normalizing to one
416/// canonical string is what makes `qa_gates` ~ `qa_gate` and
417/// `kani_harness` ~ `kani_harnesses` both work: a single-pass normalizer has to
418/// choose between stripping `es` (right for `harnesses`, wrong for `gates`) and
419/// stripping `s` (vice versa), and gets one of the two wrong whichever it picks.
420fn key_forms(key: &str) -> Vec<String> {
421    let squashed: String = key
422        .chars()
423        .filter(char::is_ascii_alphanumeric)
424        .map(|c| c.to_ascii_lowercase())
425        .collect();
426    let mut forms = vec![squashed.clone()];
427    for suffix in ["es", "s"] {
428        if let Some(stem) = squashed.strip_suffix(suffix) {
429            if !stem.is_empty() {
430                forms.push(stem.to_string());
431            }
432        }
433    }
434    forms
435}
436
437/// The real block name an unknown top-level key is a near-miss of, if any.
438///
439/// Exact field names never reach here (the parser filters them out), so a hit
440/// is always a misspelling, a case/separator variant, or a singular/plural slip
441/// — never a legitimate downstream-owned block. The near-collisions this must
442/// NOT fire on are pinned by `legitimate_downstream_keys_are_not_flagged`:
443/// `invariants` is not `type_invariants`, `gates` is not `qa_gate`, `spec` is
444/// not `coq_spec`.
445fn near_miss_of(key: &str) -> Option<&'static str> {
446    let forms = key_forms(key);
447    CONTRACT_TOP_LEVEL_FIELDS
448        .iter()
449        .copied()
450        .find(|field| key_forms(field).iter().any(|f| forms.contains(f)))
451}
452
453/// SCHEMA-018 / SCHEMA-019: reject the two top-level shapes that are never
454/// legitimate.
455///
456/// `Contract` tolerates unknown top-level keys by design — see
457/// [`crate::schema::parse_contract_str`]. This check does not change that; it
458/// carves out the two cases where serde's silence is a defect:
459///
460/// * **SCHEMA-018** — a top-level `kind:`. 119 contracts carried one. It is
461///   dropped, so the contract silently falls back to `metadata.kind` (or to the
462///   `kernel` default), and in 72 of those files the top-level value said
463///   `KernelContract` while `metadata.registry: true` made the contract an
464///   exempt registry. The key does not just fail to help, it lies.
465/// * **SCHEMA-019** — a near-miss of a real block name. This is how
466///   `contracts/publish-workspace-v1.yaml` lost four FALSIFY-PUB-* entries:
467///   they sat under a key serde did not recognise, `pv status` printed
468///   "Falsification tests: 0", and nothing anywhere said why.
469fn validate_top_level_keys(contract: &Contract, violations: &mut Vec<Violation>) {
470    // SCHEMA-020: the document is not valid YAML to a strict reader even though
471    // the derived deserializer accepted it — today that means a duplicate
472    // mapping key, one of whose values is being thrown away silently.
473    if let Some(err) = contract.strict_yaml_error.as_ref() {
474        violations.push(Violation {
475            severity: Severity::Error,
476            rule: "SCHEMA-020".to_string(),
477            message: format!(
478                "the contract schema accepted this document but a strict YAML reader \
479                 rejects it ({err}) — `yq`, PyYAML and any `serde_yaml::Value` consumer \
480                 will drop content here. A duplicate mapping key is the usual cause: \
481                 merge the two blocks into one"
482            ),
483            location: None,
484        });
485    }
486
487    for key in &contract.unknown_top_level_keys {
488        if key == "kind" {
489            violations.push(Violation {
490                severity: Severity::Error,
491                rule: "SCHEMA-018".to_string(),
492                message: "top-level `kind:` is not part of the contract schema and is \
493                          silently dropped — the contract's kind comes from \
494                          `metadata.kind:` (or defaults to `kernel`). Move it under \
495                          `metadata:` if it names a real kind, or delete it"
496                    .to_string(),
497                location: Some("kind".to_string()),
498            });
499        } else if let Some(field) = near_miss_of(key) {
500            violations.push(Violation {
501                severity: Severity::Error,
502                rule: "SCHEMA-019".to_string(),
503                message: format!(
504                    "top-level `{key}:` is not a contract field and is silently dropped \
505                     — did you mean `{field}:`? Everything under `{key}:` is invisible \
506                     to every pv gate"
507                ),
508                location: Some(key.clone()),
509            });
510        }
511    }
512}
513
514fn validate_metadata(contract: &Contract, violations: &mut Vec<Violation>) {
515    if contract.metadata.references.is_empty() {
516        violations.push(Violation {
517            severity: Severity::Error,
518            rule: "SCHEMA-001".to_string(),
519            message: "metadata.references must not be empty — \
520                      every contract must cite its source paper(s)"
521                .to_string(),
522            location: Some("metadata.references".to_string()),
523        });
524    }
525
526    if contract.metadata.version.is_empty() {
527        violations.push(Violation {
528            severity: Severity::Error,
529            rule: "SCHEMA-002".to_string(),
530            message: "metadata.version must not be empty".to_string(),
531            location: Some("metadata.version".to_string()),
532        });
533    }
534}
535
536fn validate_equations(contract: &Contract, violations: &mut Vec<Violation>) {
537    if contract.equations.is_empty() {
538        violations.push(Violation {
539            severity: Severity::Error,
540            rule: "SCHEMA-003".to_string(),
541            message: "equations must contain at least one equation".to_string(),
542            location: Some("equations".to_string()),
543        });
544    }
545
546    for (name, eq) in &contract.equations {
547        if eq.formula.is_empty() {
548            violations.push(Violation {
549                severity: Severity::Error,
550                rule: "SCHEMA-004".to_string(),
551                message: format!("equations.{name}.formula must not be empty"),
552                location: Some(format!("equations.{name}.formula")),
553            });
554        }
555    }
556}
557
558/// SCHEMA-005/006/014/015/016/017 over every proof obligation.
559///
560/// Split into three helpers rather than one loop body. As a single function it
561/// measured cognitive 30 against the repo's per-function ceiling of 25 (pmat
562/// analyze complexity), which blocked any commit that touched this file —
563/// including one that only added three lines elsewhere in it. The three
564/// helpers are the three things the loop actually checks: the obligation's own
565/// identity, whether its DbC fields belong on its type, and whether a
566/// subcontract's parent is declared. Order of pushed violations is unchanged.
567fn validate_proof_obligations(contract: &Contract, violations: &mut Vec<Violation>) {
568    let mut seen_formal = HashSet::new();
569    for (i, ob) in contract.proof_obligations.iter().enumerate() {
570        validate_obligation_identity(i, ob, &mut seen_formal, violations);
571        validate_obligation_dbc_fields(i, ob, violations);
572        validate_obligation_parent_link(i, ob, contract, violations);
573        validate_obligation_not_applicable(i, ob, violations);
574    }
575}
576
577/// SCHEMA-021/022/023 (PMAT-3091): an obligation declared
578/// `applies_to: not_applicable` must say why it is not a code property
579/// (`na_reason`, SCHEMA-021) and where the claim IS verified (`na_owner`,
580/// SCHEMA-022). A `na_reason`/`na_owner` on an obligation that does NOT declare
581/// `not_applicable` justifies nothing and is decoration (SCHEMA-023).
582fn validate_obligation_not_applicable(
583    index: usize,
584    ob: &crate::schema::types::ProofObligation,
585    violations: &mut Vec<Violation>,
586) {
587    let blank = |v: &Option<String>| v.as_deref().is_none_or(|s| s.trim().is_empty());
588    let mut push = |rule: &str, field: &str, message: String| {
589        violations.push(Violation {
590            severity: Severity::Error,
591            rule: rule.to_string(),
592            message,
593            location: Some(format!("proof_obligations[{index}].{field}")),
594        });
595    };
596    if ob.is_not_applicable() {
597        if blank(&ob.na_reason) {
598            push(
599                "SCHEMA-021",
600                "na_reason",
601                format!(
602                    "proof_obligations[{index}] is applies_to: not_applicable \
603                     but na_reason is missing or empty — say why it is not a code property"
604                ),
605            );
606        }
607        if blank(&ob.na_owner) {
608            push(
609                "SCHEMA-022",
610                "na_owner",
611                format!(
612                    "proof_obligations[{index}] is applies_to: not_applicable \
613                     but na_owner is missing or empty — name the bench, check or \
614                     evidence command that verifies it"
615                ),
616            );
617        }
618        return;
619    }
620    for (field, value) in [("na_reason", &ob.na_reason), ("na_owner", &ob.na_owner)] {
621        if value.is_some() {
622            push(
623                "SCHEMA-023",
624                field,
625                format!(
626                    "proof_obligations[{index}].{field} is only valid with \
627                     applies_to: not_applicable — a dangling justification is decoration"
628                ),
629            );
630        }
631    }
632}
633
634/// SCHEMA-005/006: an obligation states a property, and no two obligations
635/// share a formal predicate.
636fn validate_obligation_identity(
637    index: usize,
638    ob: &crate::schema::types::ProofObligation,
639    seen_formal: &mut HashSet<String>,
640    violations: &mut Vec<Violation>,
641) {
642    if ob.property.is_empty() {
643        violations.push(Violation {
644            severity: Severity::Error,
645            rule: "SCHEMA-005".to_string(),
646            message: format!("proof_obligations[{index}].property must not be empty"),
647            location: Some(format!("proof_obligations[{index}].property")),
648        });
649    }
650    if let Some(ref formal) = ob.formal {
651        if !seen_formal.insert(formal.clone()) {
652            violations.push(Violation {
653                severity: Severity::Warning,
654                rule: "SCHEMA-006".to_string(),
655                message: format!("Duplicate formal predicate: {formal}"),
656                location: Some(format!("proof_obligations[{index}].formal")),
657            });
658        }
659    }
660}
661
662/// SCHEMA-014/015/016: a DbC field only belongs on the obligation types that
663/// give it meaning. A `requires:` on an invariant, or an `applies_to_phase:`
664/// on a postcondition, is read by nothing.
665fn validate_obligation_dbc_fields(
666    index: usize,
667    ob: &crate::schema::types::ProofObligation,
668    violations: &mut Vec<Violation>,
669) {
670    use crate::schema::types::ObligationType;
671
672    let misplaced: [(bool, &str, &str, &str); 3] = [
673        (
674            ob.requires.is_some() && ob.obligation_type != ObligationType::Postcondition,
675            "SCHEMA-014",
676            "requires",
677            "postcondition",
678        ),
679        (
680            ob.applies_to_phase.is_some()
681                && ob.obligation_type != ObligationType::LoopInvariant
682                && ob.obligation_type != ObligationType::LoopVariant,
683            "SCHEMA-015",
684            "applies_to_phase",
685            "loop_invariant or loop_variant",
686        ),
687        (
688            ob.parent_contract.is_some() && ob.obligation_type != ObligationType::Subcontract,
689            "SCHEMA-016",
690            "parent_contract",
691            "subcontract",
692        ),
693    ];
694
695    for (is_misplaced, rule, field, valid_on) in misplaced {
696        if is_misplaced {
697            violations.push(Violation {
698                severity: Severity::Error,
699                rule: rule.to_string(),
700                message: format!(
701                    "proof_obligations[{index}].{field} is only valid on \
702                     {valid_on} obligations (found on {})",
703                    ob.obligation_type
704                ),
705                location: Some(format!("proof_obligations[{index}].{field}")),
706            });
707        }
708    }
709}
710
711/// SCHEMA-017: a subcontract's `parent_contract` must be declared in
712/// `metadata.depends_on`, so the composition graph can see the edge.
713fn validate_obligation_parent_link(
714    index: usize,
715    ob: &crate::schema::types::ProofObligation,
716    contract: &Contract,
717    violations: &mut Vec<Violation>,
718) {
719    use crate::schema::types::ObligationType;
720
721    let Some(parent) = ob.parent_contract.as_ref() else {
722        return;
723    };
724    if ob.obligation_type != ObligationType::Subcontract
725        || contract.metadata.depends_on.contains(parent)
726    {
727        return;
728    }
729    violations.push(Violation {
730        severity: Severity::Error,
731        rule: "SCHEMA-017".to_string(),
732        message: format!(
733            "proof_obligations[{index}].parent_contract \"{parent}\" \
734             must be listed in metadata.depends_on"
735        ),
736        location: Some(format!("proof_obligations[{index}].parent_contract")),
737    });
738}
739
740fn validate_falsification_tests(contract: &Contract, violations: &mut Vec<Violation>) {
741    let mut ids = HashSet::new();
742    for test in &contract.falsification_tests {
743        if !ids.insert(&test.id) {
744            violations.push(Violation {
745                severity: Severity::Error,
746                rule: "SCHEMA-007".to_string(),
747                message: format!("Duplicate falsification test ID: {}", test.id),
748                location: Some(format!("falsification_tests.{}", test.id)),
749            });
750        }
751        if test.prediction.is_empty() {
752            violations.push(Violation {
753                severity: Severity::Error,
754                rule: "SCHEMA-008".to_string(),
755                message: format!(
756                    "falsification_tests.{}.prediction must not be empty — \
757                     every test must make a falsifiable prediction",
758                    test.id
759                ),
760                location: Some(format!("falsification_tests.{}.prediction", test.id)),
761            });
762        }
763        if test.if_fails.is_empty() {
764            violations.push(Violation {
765                severity: Severity::Warning,
766                rule: "SCHEMA-009".to_string(),
767                message: format!(
768                    "falsification_tests.{}.if_fails is empty — \
769                     should describe root cause diagnosis",
770                    test.id
771                ),
772                location: Some(format!("falsification_tests.{}.if_fails", test.id)),
773            });
774        }
775    }
776}
777
778fn validate_kani_harnesses(contract: &Contract, violations: &mut Vec<Violation>) {
779    let mut ids = HashSet::new();
780    for harness in &contract.kani_harnesses {
781        if !ids.insert(&harness.id) {
782            violations.push(Violation {
783                severity: Severity::Error,
784                rule: "SCHEMA-010".to_string(),
785                message: format!("Duplicate Kani harness ID: {}", harness.id),
786                location: Some(format!("kani_harnesses.{}", harness.id)),
787            });
788        }
789        if harness.obligation.is_empty() {
790            violations.push(Violation {
791                severity: Severity::Error,
792                rule: "SCHEMA-011".to_string(),
793                message: format!(
794                    "kani_harnesses.{}.obligation must not be empty — \
795                     every harness must reference a proof obligation",
796                    harness.id
797                ),
798                location: Some(format!("kani_harnesses.{}.obligation", harness.id)),
799            });
800        }
801        if harness.bound.is_none() {
802            violations.push(Violation {
803                severity: Severity::Warning,
804                rule: "SCHEMA-012".to_string(),
805                message: format!(
806                    "kani_harnesses.{}.bound not specified — \
807                     Kani requires an unwind bound",
808                    harness.id
809                ),
810                location: Some(format!("kani_harnesses.{}.bound", harness.id)),
811            });
812        }
813    }
814}
815
816fn validate_qa_gate(contract: &Contract, violations: &mut Vec<Violation>) {
817    if contract.qa_gate.is_none() {
818        violations.push(Violation {
819            severity: Severity::Warning,
820            rule: "SCHEMA-013".to_string(),
821            message: "No qa_gate defined — contract should define a \
822                      certeza quality gate"
823                .to_string(),
824            location: Some("qa_gate".to_string()),
825        });
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    include!("validator_tests.rs");
832}