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 validate_crux_intake(contract, &mut violations);
53
54 violations
55}
56
57pub(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
96const DEMAND_SCORE_RANGE: std::ops::RangeInclusive<i64> = 1..=5;
100
101fn validate_crux_intake(contract: &Contract, violations: &mut Vec<Violation>) {
130 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 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
177fn 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
239const BEAT_INCUMBENTS: [&str; 5] = ["scikit-learn", "pytorch", "unsloth", "ollama", "llama.cpp"];
242
243fn 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 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 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 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 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 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 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
368fn 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
381fn 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
406fn 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
422fn validate_top_level_keys(contract: &Contract, violations: &mut Vec<Violation>) {
439 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 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 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}