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    // CRUX competitive-research metadata (aprender#2555): kind-independent —
50    // the three fields are carried by 275 `crux-*` contracts of several kinds
51    // and by non-crux contracts that reuse the vocabulary.
52    validate_crux_intake(contract, &mut violations);
53
54    violations
55}
56
57/// The closed set of competitors a CRUX story may be extracted from
58/// (`metadata.competitor`, rule CRUX-002).
59///
60/// # Why this is NOT `BEAT_INCUMBENTS`
61///
62/// Reusing [`BEAT_INCUMBENTS`] was considered and REJECTED — it names a
63/// different domain and would import a defect. `BEAT_INCUMBENTS` answers "whom
64/// does aprender claim to *beat* on a pinned benchmark" (the four-pillar
65/// mission); `metadata.competitor` answers "whose UX was this story *extracted
66/// from*". MEASURED on this branch: 275 contract FILES carry the field, in 292
67/// declarations (17 crux contracts carry a second `competitor` inside an
68/// equivalence obligation). `BEAT_INCUMBENTS.iter().any(|p| c.contains(p))`
69/// accepts only `pytorch` (37) and `ollama` (21) — 58 of 292. It cannot name:
70///
71/// - `huggingface` (88 contracts — the single largest source), nor `vllm` (32),
72/// - `llama_cpp` (37): the BEAT list spells it `llama.cpp`, and `"llama.cpp"`
73///   is not a substring of `"llama_cpp"`, so even the pillar it does name is
74///   missed under the underscore spelling the crux corpus uses,
75/// - `ecosystem` (30), `openclaw` (20), `hf-kernels-community` (15),
76///   `apr-qa-playbook` (9), `openclip` (2), `none` (1).
77///
78/// So this registry is the corpus vocabulary, exactly. Every member is
79/// exercised by at least one contract in `contracts/`; adding a competitor is a
80/// deliberate one-line edit here plus a test, which is the point — an open
81/// domain is what let `THIS-COMPETITOR-DOES-NOT-EXIST` validate.
82pub(crate) const CRUX_COMPETITORS: [&str; 11] = [
83    "apr-qa-playbook",
84    "ecosystem",
85    "hf-kernels-community",
86    "huggingface",
87    "llama_cpp",
88    "none",
89    "ollama",
90    "openclaw",
91    "openclip",
92    "pytorch",
93    "vllm",
94];
95
96/// Documented inclusive bounds of `metadata.demand_score`, from
97/// `contracts/crux-competitive-research-ux-v1.yaml`: "a demand_score (1..5) …
98/// demand_score maps directly to pmat priority".
99const DEMAND_SCORE_RANGE: std::ops::RangeInclusive<i64> = 1..=5;
100
101/// Validate the CRUX competitive-research domains (aprender#2555).
102///
103/// Two SURFACES carry these fields, and both are checked here:
104///
105/// 1. `metadata.{competitor,demand_score,intake_status}` on an individual
106///    `crux-*` contract.
107/// 2. The `stories:` rows of the MASTER REGISTRY,
108///    `contracts/crux-competitive-research-ux-v1.yaml`.
109///
110/// Surface 2 was added because the original rationale for this rule did not
111/// survive measurement. #2555 justified CRUX-001 as guarding "the ranking
112/// signal the whole competitive-research programme sorts by" — but MEASURED,
113/// nothing in the repo reads `metadata.demand_score`. The signal §12.1 of
114/// `docs/specifications/crux-competitive-research-ux-workflows.md` maps to
115/// `pmat work` priority is `stories[].demand_score` in the registry: 250 rows,
116/// entirely ungated. Checking only surface 1 left the stated justification
117/// unsupported by the code.
118///
119/// On a registry row the three fields are also REQUIRED, not optional. On
120/// surface 1 they cannot be: `Option` is right there, because 1500-odd non-crux
121/// contracts carry none of them (see the presence obligation in
122/// `contracts/crux-intake-metadata-domains-v1.yaml`). A registry row has no
123/// such excuse — it exists to be ranked.
124///
125/// `intake_status` / `status` values are absent from the checks below ON
126/// PURPOSE: both are the closed enum `IntakeStatus`, so an invented value is
127/// rejected during deserialization and never reaches a validator. That is the
128/// stronger guarantee — a lint can be read and ignored, a parse failure cannot.
129fn validate_crux_intake(contract: &Contract, violations: &mut Vec<Violation>) {
130    // CRUX-001: demand_score is the ranking signal the whole competitive-research
131    // programme sorts by. An unvalidated out-of-range value silently dominates
132    // every ranking it appears in.
133    if let Some(score) = contract.metadata.demand_score {
134        if !DEMAND_SCORE_RANGE.contains(&score) {
135            violations.push(Violation {
136                severity: Severity::Error,
137                rule: "CRUX-001".to_string(),
138                message: format!(
139                    "metadata.demand_score {score} is outside the documented range {}..={} \
140                     — it is the priority signal pmat work sorts by, so an out-of-range \
141                     value silently outranks every real story",
142                    DEMAND_SCORE_RANGE.start(),
143                    DEMAND_SCORE_RANGE.end(),
144                ),
145                location: Some("metadata.demand_score".to_string()),
146            });
147        }
148    }
149
150    // CRUX-002: competitor must name a source in the registry above.
151    //
152    // No `.trim()` here, deliberately. It used to trim before comparing, which
153    // made `competitor: "  ecosystem  "` validate clean while the STORED value
154    // kept its padding — the check laundered a value it did not fix, so every
155    // consumer reading `metadata.competitor` still saw the untrimmed string.
156    // Normalisation now happens once, at parse time
157    // (`deserialize_trimmed_opt_string` in `schema/types.rs`), so what is
158    // compared is exactly what is stored.
159    if let Some(competitor) = contract.metadata.competitor.as_deref() {
160        if !CRUX_COMPETITORS.contains(&competitor) {
161            violations.push(Violation {
162                severity: Severity::Error,
163                rule: "CRUX-002".to_string(),
164                message: format!(
165                    "metadata.competitor {competitor:?} is not a known competitive-research \
166                     source — must be one of: {}",
167                    CRUX_COMPETITORS.join(", ")
168                ),
169                location: Some("metadata.competitor".to_string()),
170            });
171        }
172    }
173
174    validate_crux_registry_stories(contract, violations);
175}
176
177/// Hold every MASTER-REGISTRY story row to the same two domains.
178///
179/// These are the rows that carry the ranking signal, so here the fields are
180/// required as well as bounded: a row with no `demand_score` cannot be sorted,
181/// and a row with no `competitor` cannot be attributed.
182fn validate_crux_registry_stories(contract: &Contract, violations: &mut Vec<Violation>) {
183    for story in &contract.stories {
184        let at = |field: &str| Some(format!("stories[{}].{field}", story.id));
185
186        match story.demand_score {
187            None => violations.push(Violation {
188                severity: Severity::Error,
189                rule: "CRUX-001".to_string(),
190                message: format!(
191                    "registry story {} has no demand_score — it is the priority signal \
192                     pmat work sorts by, and an absent one sorts arbitrarily",
193                    story.id
194                ),
195                location: at("demand_score"),
196            }),
197            Some(score) if !DEMAND_SCORE_RANGE.contains(&score) => violations.push(Violation {
198                severity: Severity::Error,
199                rule: "CRUX-001".to_string(),
200                message: format!(
201                    "registry story {} has demand_score {score}, outside the documented \
202                     range {}..={} — a single fabricated score reorders the whole queue",
203                    story.id,
204                    DEMAND_SCORE_RANGE.start(),
205                    DEMAND_SCORE_RANGE.end(),
206                ),
207                location: at("demand_score"),
208            }),
209            Some(_) => {}
210        }
211
212        match story.competitor.as_deref() {
213            None => violations.push(Violation {
214                severity: Severity::Error,
215                rule: "CRUX-002".to_string(),
216                message: format!(
217                    "registry story {} has no competitor — the row cannot be attributed \
218                     to the UX it was extracted from",
219                    story.id
220                ),
221                location: at("competitor"),
222            }),
223            Some(c) if !CRUX_COMPETITORS.contains(&c) => violations.push(Violation {
224                severity: Severity::Error,
225                rule: "CRUX-002".to_string(),
226                message: format!(
227                    "registry story {} names competitor {c:?}, which is not a known \
228                     competitive-research source — must be one of: {}",
229                    story.id,
230                    CRUX_COMPETITORS.join(", ")
231                ),
232                location: at("competitor"),
233            }),
234            Some(_) => {}
235        }
236    }
237}
238
239/// The four incumbents a BEAT may target (case-insensitive substring match, so
240/// `ollama` and `llama.cpp` both satisfy Pillar 4).
241const BEAT_INCUMBENTS: [&str; 5] = ["scikit-learn", "pytorch", "unsloth", "ollama", "llama.cpp"];
242
243/// Enforce the BeatBenchmark shape (PMAT-741): a `beat-benchmark` contract MUST
244/// carry a well-formed `beat:` block so the claim is a falsifiable CI gate, not
245/// prose. Rules BEAT-001..007.
246fn validate_beat_benchmark(contract: &Contract, violations: &mut Vec<Violation>) {
247    let push = |violations: &mut Vec<Violation>, rule: &str, message: String, field: &str| {
248        violations.push(Violation {
249            severity: Severity::Error,
250            rule: rule.to_string(),
251            message,
252            location: Some(format!("beat.{field}")),
253        });
254    };
255
256    let Some(beat) = contract.beat.as_ref() else {
257        violations.push(Violation {
258            severity: Severity::Error,
259            rule: "BEAT-001".to_string(),
260            message: "beat-benchmark contract must define a `beat:` block \
261                      (incumbent, metric, direction, beat_threshold, ci_gate_name)"
262                .to_string(),
263            location: Some("beat".to_string()),
264        });
265        return;
266    };
267
268    // BEAT-002: incumbent must name one of the four pillars.
269    let incumbent = beat.incumbent.trim().to_lowercase();
270    if incumbent.is_empty() {
271        push(
272            violations,
273            "BEAT-002",
274            "beat.incumbent must not be empty".to_string(),
275            "incumbent",
276        );
277    } else if !BEAT_INCUMBENTS.iter().any(|p| incumbent.contains(p)) {
278        push(
279            violations,
280            "BEAT-002",
281            format!(
282                "beat.incumbent {:?} must name one of the four pillars ({})",
283                beat.incumbent,
284                BEAT_INCUMBENTS.join(", ")
285            ),
286            "incumbent",
287        );
288    }
289
290    // BEAT-003: a measured metric is required.
291    if beat.metric.trim().is_empty() {
292        push(
293            violations,
294            "BEAT-003",
295            "beat.metric must name the measured quantity (e.g. accuracy, wall_clock_ms, \
296             tokens_per_sec)"
297                .to_string(),
298            "metric",
299        );
300    }
301
302    // BEAT-004: direction fixes which way is a regression.
303    match beat.direction.trim() {
304        "higher_is_better" | "lower_is_better" => {}
305        other => push(
306            violations,
307            "BEAT-004",
308            format!(
309                "beat.direction must be `higher_is_better` or `lower_is_better`, got {other:?}"
310            ),
311            "direction",
312        ),
313    }
314
315    // BEAT-005: a finite, machine-pinned threshold is required (the gate value).
316    match beat.beat_threshold {
317        None => push(
318            violations,
319            "BEAT-005",
320            "beat.beat_threshold is required — the pinned value CI fails below".to_string(),
321            "beat_threshold",
322        ),
323        Some(t) if !t.is_finite() => push(
324            violations,
325            "BEAT-005",
326            format!("beat.beat_threshold must be finite, got {t}"),
327            "beat_threshold",
328        ),
329        Some(_) => {}
330    }
331
332    // BEAT-006: the enforcing CI gate must be named.
333    if beat.ci_gate_name.trim().is_empty() {
334        push(
335            violations,
336            "BEAT-006",
337            "beat.ci_gate_name must name the CI test that enforces this gate".to_string(),
338            "ci_gate_name",
339        );
340    }
341
342    // BEAT-007: approved_compute is required and must be CPU or GPU (the
343    // autonomous-vs-operator track distinction depends on it).
344    match beat
345        .approved_compute
346        .as_deref()
347        .map(|c| c.trim().to_uppercase())
348    {
349        None => push(
350            violations,
351            "BEAT-007",
352            "beat.approved_compute is required — must be `CPU` or `GPU`".to_string(),
353            "approved_compute",
354        ),
355        Some(ref c) if c != "CPU" && c != "GPU" => push(
356            violations,
357            "BEAT-007",
358            format!(
359                "beat.approved_compute must be `CPU` or `GPU`, got {:?}",
360                beat.approved_compute
361            ),
362            "approved_compute",
363        ),
364        Some(_) => {}
365    }
366}
367
368/// Enforce the provability invariant: kernel contracts (non-registry) MUST have
369/// `proof_obligations`, `falsification_tests`, and `kani_harnesses`.
370fn validate_provability_invariant(contract: &Contract, violations: &mut Vec<Violation>) {
371    for v in contract.provability_violations() {
372        violations.push(Violation {
373            severity: Severity::Error,
374            rule: "PROVABILITY-001".to_string(),
375            message: v,
376            location: None,
377        });
378    }
379}
380
381/// The forms a YAML key could be a plural/case/separator variant of.
382///
383/// Case-folded with separators dropped, then the key itself plus its `-s` and
384/// `-es` singularizations. Comparing SETS rather than normalizing to one
385/// canonical string is what makes `qa_gates` ~ `qa_gate` and
386/// `kani_harness` ~ `kani_harnesses` both work: a single-pass normalizer has to
387/// choose between stripping `es` (right for `harnesses`, wrong for `gates`) and
388/// stripping `s` (vice versa), and gets one of the two wrong whichever it picks.
389fn key_forms(key: &str) -> Vec<String> {
390    let squashed: String = key
391        .chars()
392        .filter(char::is_ascii_alphanumeric)
393        .map(|c| c.to_ascii_lowercase())
394        .collect();
395    let mut forms = vec![squashed.clone()];
396    for suffix in ["es", "s"] {
397        if let Some(stem) = squashed.strip_suffix(suffix) {
398            if !stem.is_empty() {
399                forms.push(stem.to_string());
400            }
401        }
402    }
403    forms
404}
405
406/// The real block name an unknown top-level key is a near-miss of, if any.
407///
408/// Exact field names never reach here (the parser filters them out), so a hit
409/// is always a misspelling, a case/separator variant, or a singular/plural slip
410/// — never a legitimate downstream-owned block. The near-collisions this must
411/// NOT fire on are pinned by `legitimate_downstream_keys_are_not_flagged`:
412/// `invariants` is not `type_invariants`, `gates` is not `qa_gate`, `spec` is
413/// not `coq_spec`.
414fn near_miss_of(key: &str) -> Option<&'static str> {
415    let forms = key_forms(key);
416    CONTRACT_TOP_LEVEL_FIELDS
417        .iter()
418        .copied()
419        .find(|field| key_forms(field).iter().any(|f| forms.contains(f)))
420}
421
422/// SCHEMA-018 / SCHEMA-019: reject the two top-level shapes that are never
423/// legitimate.
424///
425/// `Contract` tolerates unknown top-level keys by design — see
426/// [`crate::schema::parse_contract_str`]. This check does not change that; it
427/// carves out the two cases where serde's silence is a defect:
428///
429/// * **SCHEMA-018** — a top-level `kind:`. 119 contracts carried one. It is
430///   dropped, so the contract silently falls back to `metadata.kind` (or to the
431///   `kernel` default), and in 72 of those files the top-level value said
432///   `KernelContract` while `metadata.registry: true` made the contract an
433///   exempt registry. The key does not just fail to help, it lies.
434/// * **SCHEMA-019** — a near-miss of a real block name. This is how
435///   `contracts/publish-workspace-v1.yaml` lost four FALSIFY-PUB-* entries:
436///   they sat under a key serde did not recognise, `pv status` printed
437///   "Falsification tests: 0", and nothing anywhere said why.
438fn validate_top_level_keys(contract: &Contract, violations: &mut Vec<Violation>) {
439    // SCHEMA-020: the document is not valid YAML to a strict reader even though
440    // the derived deserializer accepted it — today that means a duplicate
441    // mapping key, one of whose values is being thrown away silently.
442    if let Some(err) = contract.strict_yaml_error.as_ref() {
443        violations.push(Violation {
444            severity: Severity::Error,
445            rule: "SCHEMA-020".to_string(),
446            message: format!(
447                "the contract schema accepted this document but a strict YAML reader \
448                 rejects it ({err}) — `yq`, PyYAML and any `serde_yaml::Value` consumer \
449                 will drop content here. A duplicate mapping key is the usual cause: \
450                 merge the two blocks into one"
451            ),
452            location: None,
453        });
454    }
455
456    for key in &contract.unknown_top_level_keys {
457        if key == "kind" {
458            violations.push(Violation {
459                severity: Severity::Error,
460                rule: "SCHEMA-018".to_string(),
461                message: "top-level `kind:` is not part of the contract schema and is \
462                          silently dropped — the contract's kind comes from \
463                          `metadata.kind:` (or defaults to `kernel`). Move it under \
464                          `metadata:` if it names a real kind, or delete it"
465                    .to_string(),
466                location: Some("kind".to_string()),
467            });
468        } else if let Some(field) = near_miss_of(key) {
469            violations.push(Violation {
470                severity: Severity::Error,
471                rule: "SCHEMA-019".to_string(),
472                message: format!(
473                    "top-level `{key}:` is not a contract field and is silently dropped \
474                     — did you mean `{field}:`? Everything under `{key}:` is invisible \
475                     to every pv gate"
476                ),
477                location: Some(key.clone()),
478            });
479        }
480    }
481}
482
483fn validate_metadata(contract: &Contract, violations: &mut Vec<Violation>) {
484    if contract.metadata.references.is_empty() {
485        violations.push(Violation {
486            severity: Severity::Error,
487            rule: "SCHEMA-001".to_string(),
488            message: "metadata.references must not be empty — \
489                      every contract must cite its source paper(s)"
490                .to_string(),
491            location: Some("metadata.references".to_string()),
492        });
493    }
494
495    if contract.metadata.version.is_empty() {
496        violations.push(Violation {
497            severity: Severity::Error,
498            rule: "SCHEMA-002".to_string(),
499            message: "metadata.version must not be empty".to_string(),
500            location: Some("metadata.version".to_string()),
501        });
502    }
503}
504
505fn validate_equations(contract: &Contract, violations: &mut Vec<Violation>) {
506    if contract.equations.is_empty() {
507        violations.push(Violation {
508            severity: Severity::Error,
509            rule: "SCHEMA-003".to_string(),
510            message: "equations must contain at least one equation".to_string(),
511            location: Some("equations".to_string()),
512        });
513    }
514
515    for (name, eq) in &contract.equations {
516        if eq.formula.is_empty() {
517            violations.push(Violation {
518                severity: Severity::Error,
519                rule: "SCHEMA-004".to_string(),
520                message: format!("equations.{name}.formula must not be empty"),
521                location: Some(format!("equations.{name}.formula")),
522            });
523        }
524    }
525}
526
527fn validate_proof_obligations(contract: &Contract, violations: &mut Vec<Violation>) {
528    use crate::schema::types::ObligationType;
529
530    let mut seen_ids = HashSet::new();
531    for (i, ob) in contract.proof_obligations.iter().enumerate() {
532        if ob.property.is_empty() {
533            violations.push(Violation {
534                severity: Severity::Error,
535                rule: "SCHEMA-005".to_string(),
536                message: format!("proof_obligations[{i}].property must not be empty"),
537                location: Some(format!("proof_obligations[{i}].property")),
538            });
539        }
540        if let Some(ref formal) = ob.formal {
541            if !seen_ids.insert(formal.clone()) {
542                violations.push(Violation {
543                    severity: Severity::Warning,
544                    rule: "SCHEMA-006".to_string(),
545                    message: format!("Duplicate formal predicate: {formal}"),
546                    location: Some(format!("proof_obligations[{i}].formal")),
547                });
548            }
549        }
550
551        // DbC field/type constraints
552        if ob.requires.is_some() && ob.obligation_type != ObligationType::Postcondition {
553            violations.push(Violation {
554                severity: Severity::Error,
555                rule: "SCHEMA-014".to_string(),
556                message: format!(
557                    "proof_obligations[{i}].requires is only valid on \
558                     postcondition obligations (found on {})",
559                    ob.obligation_type
560                ),
561                location: Some(format!("proof_obligations[{i}].requires")),
562            });
563        }
564
565        if ob.applies_to_phase.is_some()
566            && ob.obligation_type != ObligationType::LoopInvariant
567            && ob.obligation_type != ObligationType::LoopVariant
568        {
569            violations.push(Violation {
570                severity: Severity::Error,
571                rule: "SCHEMA-015".to_string(),
572                message: format!(
573                    "proof_obligations[{i}].applies_to_phase is only valid on \
574                     loop_invariant or loop_variant obligations (found on {})",
575                    ob.obligation_type
576                ),
577                location: Some(format!("proof_obligations[{i}].applies_to_phase")),
578            });
579        }
580
581        if ob.parent_contract.is_some() && ob.obligation_type != ObligationType::Subcontract {
582            violations.push(Violation {
583                severity: Severity::Error,
584                rule: "SCHEMA-016".to_string(),
585                message: format!(
586                    "proof_obligations[{i}].parent_contract is only valid on \
587                     subcontract obligations (found on {})",
588                    ob.obligation_type
589                ),
590                location: Some(format!("proof_obligations[{i}].parent_contract")),
591            });
592        }
593
594        // Subcontract parent_contract must be in depends_on
595        if let Some(ref parent) = ob.parent_contract {
596            if ob.obligation_type == ObligationType::Subcontract
597                && !contract.metadata.depends_on.contains(parent)
598            {
599                violations.push(Violation {
600                    severity: Severity::Error,
601                    rule: "SCHEMA-017".to_string(),
602                    message: format!(
603                        "proof_obligations[{i}].parent_contract \"{parent}\" \
604                         must be listed in metadata.depends_on"
605                    ),
606                    location: Some(format!("proof_obligations[{i}].parent_contract")),
607                });
608            }
609        }
610    }
611}
612
613fn validate_falsification_tests(contract: &Contract, violations: &mut Vec<Violation>) {
614    let mut ids = HashSet::new();
615    for test in &contract.falsification_tests {
616        if !ids.insert(&test.id) {
617            violations.push(Violation {
618                severity: Severity::Error,
619                rule: "SCHEMA-007".to_string(),
620                message: format!("Duplicate falsification test ID: {}", test.id),
621                location: Some(format!("falsification_tests.{}", test.id)),
622            });
623        }
624        if test.prediction.is_empty() {
625            violations.push(Violation {
626                severity: Severity::Error,
627                rule: "SCHEMA-008".to_string(),
628                message: format!(
629                    "falsification_tests.{}.prediction must not be empty — \
630                     every test must make a falsifiable prediction",
631                    test.id
632                ),
633                location: Some(format!("falsification_tests.{}.prediction", test.id)),
634            });
635        }
636        if test.if_fails.is_empty() {
637            violations.push(Violation {
638                severity: Severity::Warning,
639                rule: "SCHEMA-009".to_string(),
640                message: format!(
641                    "falsification_tests.{}.if_fails is empty — \
642                     should describe root cause diagnosis",
643                    test.id
644                ),
645                location: Some(format!("falsification_tests.{}.if_fails", test.id)),
646            });
647        }
648    }
649}
650
651fn validate_kani_harnesses(contract: &Contract, violations: &mut Vec<Violation>) {
652    let mut ids = HashSet::new();
653    for harness in &contract.kani_harnesses {
654        if !ids.insert(&harness.id) {
655            violations.push(Violation {
656                severity: Severity::Error,
657                rule: "SCHEMA-010".to_string(),
658                message: format!("Duplicate Kani harness ID: {}", harness.id),
659                location: Some(format!("kani_harnesses.{}", harness.id)),
660            });
661        }
662        if harness.obligation.is_empty() {
663            violations.push(Violation {
664                severity: Severity::Error,
665                rule: "SCHEMA-011".to_string(),
666                message: format!(
667                    "kani_harnesses.{}.obligation must not be empty — \
668                     every harness must reference a proof obligation",
669                    harness.id
670                ),
671                location: Some(format!("kani_harnesses.{}.obligation", harness.id)),
672            });
673        }
674        if harness.bound.is_none() {
675            violations.push(Violation {
676                severity: Severity::Warning,
677                rule: "SCHEMA-012".to_string(),
678                message: format!(
679                    "kani_harnesses.{}.bound not specified — \
680                     Kani requires an unwind bound",
681                    harness.id
682                ),
683                location: Some(format!("kani_harnesses.{}.bound", harness.id)),
684            });
685        }
686    }
687}
688
689fn validate_qa_gate(contract: &Contract, violations: &mut Vec<Violation>) {
690    if contract.qa_gate.is_none() {
691        violations.push(Violation {
692            severity: Severity::Warning,
693            rule: "SCHEMA-013".to_string(),
694            message: "No qa_gate defined — contract should define a \
695                      certeza quality gate"
696                .to_string(),
697            location: Some("qa_gate".to_string()),
698        });
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    include!("validator_tests.rs");
705}