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};
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
20    // Kernel-only checks: these enforce the provability invariant and
21    // require equations + proof obligations + tests + Kani harnesses.
22    if contract.kind() == ContractKind::Kernel && !contract.is_registry() {
23        validate_equations(contract, &mut violations);
24        validate_provability_invariant(contract, &mut violations);
25        validate_proof_obligations(contract, &mut violations);
26        validate_falsification_tests(contract, &mut violations);
27        validate_kani_harnesses(contract, &mut violations);
28        validate_qa_gate(contract, &mut violations);
29    } else {
30        // Non-kernel kinds (registry, model-family, schema): still validate
31        // any proof obligations/falsification/kani data that IS present, so
32        // mistakes are caught even on exempt contracts.
33        validate_proof_obligations(contract, &mut violations);
34        validate_falsification_tests(contract, &mut violations);
35        validate_kani_harnesses(contract, &mut violations);
36    }
37
38    // BeatBenchmark-only checks (PMAT-741): the `beat:` block must pin a
39    // falsifiable, four-pillar incumbent baseline. Independent of the
40    // kernel/non-kernel split above.
41    if contract.kind() == ContractKind::BeatBenchmark {
42        validate_beat_benchmark(contract, &mut violations);
43    }
44
45    violations
46}
47
48/// The four incumbents a BEAT may target (case-insensitive substring match, so
49/// `ollama` and `llama.cpp` both satisfy Pillar 4).
50const BEAT_INCUMBENTS: [&str; 5] = ["scikit-learn", "pytorch", "unsloth", "ollama", "llama.cpp"];
51
52/// Enforce the BeatBenchmark shape (PMAT-741): a `beat-benchmark` contract MUST
53/// carry a well-formed `beat:` block so the claim is a falsifiable CI gate, not
54/// prose. Rules BEAT-001..007.
55fn validate_beat_benchmark(contract: &Contract, violations: &mut Vec<Violation>) {
56    let push = |violations: &mut Vec<Violation>, rule: &str, message: String, field: &str| {
57        violations.push(Violation {
58            severity: Severity::Error,
59            rule: rule.to_string(),
60            message,
61            location: Some(format!("beat.{field}")),
62        });
63    };
64
65    let Some(beat) = contract.beat.as_ref() else {
66        violations.push(Violation {
67            severity: Severity::Error,
68            rule: "BEAT-001".to_string(),
69            message: "beat-benchmark contract must define a `beat:` block \
70                      (incumbent, metric, direction, beat_threshold, ci_gate_name)"
71                .to_string(),
72            location: Some("beat".to_string()),
73        });
74        return;
75    };
76
77    // BEAT-002: incumbent must name one of the four pillars.
78    let incumbent = beat.incumbent.trim().to_lowercase();
79    if incumbent.is_empty() {
80        push(
81            violations,
82            "BEAT-002",
83            "beat.incumbent must not be empty".to_string(),
84            "incumbent",
85        );
86    } else if !BEAT_INCUMBENTS.iter().any(|p| incumbent.contains(p)) {
87        push(
88            violations,
89            "BEAT-002",
90            format!(
91                "beat.incumbent {:?} must name one of the four pillars ({})",
92                beat.incumbent,
93                BEAT_INCUMBENTS.join(", ")
94            ),
95            "incumbent",
96        );
97    }
98
99    // BEAT-003: a measured metric is required.
100    if beat.metric.trim().is_empty() {
101        push(
102            violations,
103            "BEAT-003",
104            "beat.metric must name the measured quantity (e.g. accuracy, wall_clock_ms, \
105             tokens_per_sec)"
106                .to_string(),
107            "metric",
108        );
109    }
110
111    // BEAT-004: direction fixes which way is a regression.
112    match beat.direction.trim() {
113        "higher_is_better" | "lower_is_better" => {}
114        other => push(
115            violations,
116            "BEAT-004",
117            format!(
118                "beat.direction must be `higher_is_better` or `lower_is_better`, got {other:?}"
119            ),
120            "direction",
121        ),
122    }
123
124    // BEAT-005: a finite, machine-pinned threshold is required (the gate value).
125    match beat.beat_threshold {
126        None => push(
127            violations,
128            "BEAT-005",
129            "beat.beat_threshold is required — the pinned value CI fails below".to_string(),
130            "beat_threshold",
131        ),
132        Some(t) if !t.is_finite() => push(
133            violations,
134            "BEAT-005",
135            format!("beat.beat_threshold must be finite, got {t}"),
136            "beat_threshold",
137        ),
138        Some(_) => {}
139    }
140
141    // BEAT-006: the enforcing CI gate must be named.
142    if beat.ci_gate_name.trim().is_empty() {
143        push(
144            violations,
145            "BEAT-006",
146            "beat.ci_gate_name must name the CI test that enforces this gate".to_string(),
147            "ci_gate_name",
148        );
149    }
150
151    // BEAT-007: approved_compute is required and must be CPU or GPU (the
152    // autonomous-vs-operator track distinction depends on it).
153    match beat
154        .approved_compute
155        .as_deref()
156        .map(|c| c.trim().to_uppercase())
157    {
158        None => push(
159            violations,
160            "BEAT-007",
161            "beat.approved_compute is required — must be `CPU` or `GPU`".to_string(),
162            "approved_compute",
163        ),
164        Some(ref c) if c != "CPU" && c != "GPU" => push(
165            violations,
166            "BEAT-007",
167            format!(
168                "beat.approved_compute must be `CPU` or `GPU`, got {:?}",
169                beat.approved_compute
170            ),
171            "approved_compute",
172        ),
173        Some(_) => {}
174    }
175}
176
177/// Enforce the provability invariant: kernel contracts (non-registry) MUST have
178/// `proof_obligations`, `falsification_tests`, and `kani_harnesses`.
179fn validate_provability_invariant(contract: &Contract, violations: &mut Vec<Violation>) {
180    for v in contract.provability_violations() {
181        violations.push(Violation {
182            severity: Severity::Error,
183            rule: "PROVABILITY-001".to_string(),
184            message: v,
185            location: None,
186        });
187    }
188}
189
190fn validate_metadata(contract: &Contract, violations: &mut Vec<Violation>) {
191    if contract.metadata.references.is_empty() {
192        violations.push(Violation {
193            severity: Severity::Error,
194            rule: "SCHEMA-001".to_string(),
195            message: "metadata.references must not be empty — \
196                      every contract must cite its source paper(s)"
197                .to_string(),
198            location: Some("metadata.references".to_string()),
199        });
200    }
201
202    if contract.metadata.version.is_empty() {
203        violations.push(Violation {
204            severity: Severity::Error,
205            rule: "SCHEMA-002".to_string(),
206            message: "metadata.version must not be empty".to_string(),
207            location: Some("metadata.version".to_string()),
208        });
209    }
210}
211
212fn validate_equations(contract: &Contract, violations: &mut Vec<Violation>) {
213    if contract.equations.is_empty() {
214        violations.push(Violation {
215            severity: Severity::Error,
216            rule: "SCHEMA-003".to_string(),
217            message: "equations must contain at least one equation".to_string(),
218            location: Some("equations".to_string()),
219        });
220    }
221
222    for (name, eq) in &contract.equations {
223        if eq.formula.is_empty() {
224            violations.push(Violation {
225                severity: Severity::Error,
226                rule: "SCHEMA-004".to_string(),
227                message: format!("equations.{name}.formula must not be empty"),
228                location: Some(format!("equations.{name}.formula")),
229            });
230        }
231    }
232}
233
234fn validate_proof_obligations(contract: &Contract, violations: &mut Vec<Violation>) {
235    use crate::schema::types::ObligationType;
236
237    let mut seen_ids = HashSet::new();
238    for (i, ob) in contract.proof_obligations.iter().enumerate() {
239        if ob.property.is_empty() {
240            violations.push(Violation {
241                severity: Severity::Error,
242                rule: "SCHEMA-005".to_string(),
243                message: format!("proof_obligations[{i}].property must not be empty"),
244                location: Some(format!("proof_obligations[{i}].property")),
245            });
246        }
247        if let Some(ref formal) = ob.formal {
248            if !seen_ids.insert(formal.clone()) {
249                violations.push(Violation {
250                    severity: Severity::Warning,
251                    rule: "SCHEMA-006".to_string(),
252                    message: format!("Duplicate formal predicate: {formal}"),
253                    location: Some(format!("proof_obligations[{i}].formal")),
254                });
255            }
256        }
257
258        // DbC field/type constraints
259        if ob.requires.is_some() && ob.obligation_type != ObligationType::Postcondition {
260            violations.push(Violation {
261                severity: Severity::Error,
262                rule: "SCHEMA-014".to_string(),
263                message: format!(
264                    "proof_obligations[{i}].requires is only valid on \
265                     postcondition obligations (found on {})",
266                    ob.obligation_type
267                ),
268                location: Some(format!("proof_obligations[{i}].requires")),
269            });
270        }
271
272        if ob.applies_to_phase.is_some()
273            && ob.obligation_type != ObligationType::LoopInvariant
274            && ob.obligation_type != ObligationType::LoopVariant
275        {
276            violations.push(Violation {
277                severity: Severity::Error,
278                rule: "SCHEMA-015".to_string(),
279                message: format!(
280                    "proof_obligations[{i}].applies_to_phase is only valid on \
281                     loop_invariant or loop_variant obligations (found on {})",
282                    ob.obligation_type
283                ),
284                location: Some(format!("proof_obligations[{i}].applies_to_phase")),
285            });
286        }
287
288        if ob.parent_contract.is_some() && ob.obligation_type != ObligationType::Subcontract {
289            violations.push(Violation {
290                severity: Severity::Error,
291                rule: "SCHEMA-016".to_string(),
292                message: format!(
293                    "proof_obligations[{i}].parent_contract is only valid on \
294                     subcontract obligations (found on {})",
295                    ob.obligation_type
296                ),
297                location: Some(format!("proof_obligations[{i}].parent_contract")),
298            });
299        }
300
301        // Subcontract parent_contract must be in depends_on
302        if let Some(ref parent) = ob.parent_contract {
303            if ob.obligation_type == ObligationType::Subcontract
304                && !contract.metadata.depends_on.contains(parent)
305            {
306                violations.push(Violation {
307                    severity: Severity::Error,
308                    rule: "SCHEMA-017".to_string(),
309                    message: format!(
310                        "proof_obligations[{i}].parent_contract \"{parent}\" \
311                         must be listed in metadata.depends_on"
312                    ),
313                    location: Some(format!("proof_obligations[{i}].parent_contract")),
314                });
315            }
316        }
317    }
318}
319
320fn validate_falsification_tests(contract: &Contract, violations: &mut Vec<Violation>) {
321    let mut ids = HashSet::new();
322    for test in &contract.falsification_tests {
323        if !ids.insert(&test.id) {
324            violations.push(Violation {
325                severity: Severity::Error,
326                rule: "SCHEMA-007".to_string(),
327                message: format!("Duplicate falsification test ID: {}", test.id),
328                location: Some(format!("falsification_tests.{}", test.id)),
329            });
330        }
331        if test.prediction.is_empty() {
332            violations.push(Violation {
333                severity: Severity::Error,
334                rule: "SCHEMA-008".to_string(),
335                message: format!(
336                    "falsification_tests.{}.prediction must not be empty — \
337                     every test must make a falsifiable prediction",
338                    test.id
339                ),
340                location: Some(format!("falsification_tests.{}.prediction", test.id)),
341            });
342        }
343        if test.if_fails.is_empty() {
344            violations.push(Violation {
345                severity: Severity::Warning,
346                rule: "SCHEMA-009".to_string(),
347                message: format!(
348                    "falsification_tests.{}.if_fails is empty — \
349                     should describe root cause diagnosis",
350                    test.id
351                ),
352                location: Some(format!("falsification_tests.{}.if_fails", test.id)),
353            });
354        }
355    }
356}
357
358fn validate_kani_harnesses(contract: &Contract, violations: &mut Vec<Violation>) {
359    let mut ids = HashSet::new();
360    for harness in &contract.kani_harnesses {
361        if !ids.insert(&harness.id) {
362            violations.push(Violation {
363                severity: Severity::Error,
364                rule: "SCHEMA-010".to_string(),
365                message: format!("Duplicate Kani harness ID: {}", harness.id),
366                location: Some(format!("kani_harnesses.{}", harness.id)),
367            });
368        }
369        if harness.obligation.is_empty() {
370            violations.push(Violation {
371                severity: Severity::Error,
372                rule: "SCHEMA-011".to_string(),
373                message: format!(
374                    "kani_harnesses.{}.obligation must not be empty — \
375                     every harness must reference a proof obligation",
376                    harness.id
377                ),
378                location: Some(format!("kani_harnesses.{}.obligation", harness.id)),
379            });
380        }
381        if harness.bound.is_none() {
382            violations.push(Violation {
383                severity: Severity::Warning,
384                rule: "SCHEMA-012".to_string(),
385                message: format!(
386                    "kani_harnesses.{}.bound not specified — \
387                     Kani requires an unwind bound",
388                    harness.id
389                ),
390                location: Some(format!("kani_harnesses.{}.bound", harness.id)),
391            });
392        }
393    }
394}
395
396fn validate_qa_gate(contract: &Contract, violations: &mut Vec<Violation>) {
397    if contract.qa_gate.is_none() {
398        violations.push(Violation {
399            severity: Severity::Warning,
400            rule: "SCHEMA-013".to_string(),
401            message: "No qa_gate defined — contract should define a \
402                      certeza quality gate"
403                .to_string(),
404            location: Some("qa_gate".to_string()),
405        });
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    include!("validator_tests.rs");
412}