quality.py 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. """Deterministic candidate-quality contracts and strict provider boundary."""
  2. from __future__ import annotations
  3. from typing import Any, Literal, Protocol, Sequence
  4. from pydantic import Field, model_validator
  5. from cyber_agent.application.candidate import CandidateRef
  6. from cyber_agent.application.models import ApplicationModel, QualityRuleSpec
  7. from cyber_agent.core.artifacts import ValidationMaterial
  8. class ValidationSubject(ApplicationModel):
  9. subject_type: Literal["trace", "candidate"]
  10. trace_id: str = Field(min_length=1)
  11. candidate_ref: CandidateRef | None = None
  12. @model_validator(mode="after")
  13. def validate_reference(self) -> "ValidationSubject":
  14. if (self.subject_type == "candidate") != (self.candidate_ref is not None):
  15. raise ValueError("candidate subjects require exactly one CandidateRef")
  16. return self
  17. class QualityCheckInput(ApplicationModel):
  18. subject: ValidationSubject
  19. rules: tuple[QualityRuleSpec, ...] = Field(min_length=1)
  20. material: ValidationMaterial
  21. @model_validator(mode="after")
  22. def validate_material(self) -> "QualityCheckInput":
  23. if self.subject.subject_type != "candidate" or self.subject.candidate_ref is None:
  24. raise ValueError("quality checks currently require a candidate subject")
  25. ref = self.subject.candidate_ref.artifact_ref
  26. if (
  27. self.material.artifact_id != ref.artifact_id
  28. or self.material.version != ref.version
  29. or self.material.content_hash != ref.content_hash
  30. ):
  31. raise ValueError("quality material does not match the CandidateRef")
  32. return self
  33. class QualityCheckOutcome(ApplicationModel):
  34. rule_id: str = Field(min_length=1, max_length=100)
  35. rule_version: str = Field(min_length=1, max_length=100)
  36. status: Literal["passed", "failed", "unknown", "error"]
  37. evidence_refs: tuple[str, ...] = ()
  38. issue: str | None = Field(default=None, max_length=2_000)
  39. @model_validator(mode="after")
  40. def validate_status(self) -> "QualityCheckOutcome":
  41. if self.status == "passed" and self.issue is not None:
  42. raise ValueError("passed quality outcome requires issue=null")
  43. if self.status != "passed" and not self.issue:
  44. raise ValueError(f"{self.status} quality outcome requires issue")
  45. return self
  46. class QualityCheckProvider(Protocol):
  47. async def check(
  48. self,
  49. request: QualityCheckInput,
  50. ) -> Sequence[QualityCheckOutcome]: ...
  51. class CandidateValidationRecord(ApplicationModel):
  52. schema_version: Literal[1] = 1
  53. candidate_ref: CandidateRef
  54. plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  55. validation_result: dict[str, Any]
  56. validated_at_sequence: int = Field(ge=0)
  57. @model_validator(mode="after")
  58. def validate_result(self) -> "CandidateValidationRecord":
  59. # Lazy import keeps application declarations independent from the
  60. # Validator implementation while still rejecting malformed ledgers.
  61. from cyber_agent.core.validation import ValidationResult
  62. result = ValidationResult.model_validate(self.validation_result)
  63. if result.plan_hash != self.plan_hash:
  64. raise ValueError("candidate validation result plan hash mismatch")
  65. return self