1use std::collections::HashSet;
2
3use crate::error::{Severity, Violation};
4use crate::schema::types::{Contract, ContractKind, CONTRACT_TOP_LEVEL_FIELDS};
5
6pub fn validate_contract(contract: &Contract) -> Vec<Violation> {
16 let mut violations = Vec::new();
17
18 validate_metadata(contract, &mut violations);
19 validate_top_level_keys(contract, &mut violations);
23
24 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 validate_proof_obligations(contract, &mut violations);
38 validate_falsification_tests(contract, &mut violations);
39 validate_kani_harnesses(contract, &mut violations);
40 }
41
42 if contract.kind() == ContractKind::BeatBenchmark {
46 validate_beat_benchmark(contract, &mut violations);
47 }
48
49 if contract.kind() == ContractKind::Kaizen {
53 crate::schema::kaizen::validate_kaizen(contract, &mut violations);
54 }
55
56 validate_crux_intake(contract, &mut violations);
60
61 violations
62}
63
64pub(crate) const CRUX_COMPETITORS: [&str; 14] = [
94 "apr-qa-playbook",
95 "burn",
103 "ecosystem",
104 "hf-kernels-community",
105 "huggingface",
106 "linfa",
113 "llama_cpp",
114 "none",
115 "ollama",
116 "openclaw",
117 "openclip",
118 "pulp-free-chat",
123 "pytorch",
124 "vllm",
125];
126
127const DEMAND_SCORE_RANGE: std::ops::RangeInclusive<i64> = 1..=5;
131
132fn validate_crux_intake(contract: &Contract, violations: &mut Vec<Violation>) {
161 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 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
208fn 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
270const BEAT_INCUMBENTS: [&str; 5] = ["scikit-learn", "pytorch", "unsloth", "ollama", "llama.cpp"];
273
274fn 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 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 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 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 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 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 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
399fn 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
412fn 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
437fn 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
453fn validate_top_level_keys(contract: &Contract, violations: &mut Vec<Violation>) {
470 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
558fn 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
577fn 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
634fn 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
662fn 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
711fn 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}