quality.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  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. materials: tuple[ValidationMaterial, ...] = Field(min_length=1)
  21. @model_validator(mode="after")
  22. def validate_material(self) -> "QualityCheckInput":
  23. if any(self.subject.subject_type not in rule.subject_types for rule in self.rules):
  24. raise ValueError("quality rule does not support this subject type")
  25. if self.subject.subject_type == "candidate":
  26. assert self.subject.candidate_ref is not None
  27. ref = self.subject.candidate_ref.artifact_ref
  28. if len(self.materials) != 1:
  29. raise ValueError("candidate quality checks require one exact material")
  30. material = self.materials[0]
  31. if (
  32. material.artifact_id != ref.artifact_id
  33. or material.version != ref.version
  34. or material.content_hash != ref.content_hash
  35. ):
  36. raise ValueError("quality material does not match the CandidateRef")
  37. return self
  38. @property
  39. def material(self) -> ValidationMaterial:
  40. """Compatibility accessor for providers that consume one primary material."""
  41. return self.materials[0]
  42. class QualityCheckOutcome(ApplicationModel):
  43. rule_id: str = Field(min_length=1, max_length=100)
  44. rule_version: str = Field(min_length=1, max_length=100)
  45. status: Literal["passed", "failed", "unknown", "error"]
  46. evidence_refs: tuple[str, ...] = ()
  47. issue: str | None = Field(default=None, max_length=2_000)
  48. @model_validator(mode="after")
  49. def validate_status(self) -> "QualityCheckOutcome":
  50. if self.status == "passed" and self.issue is not None:
  51. raise ValueError("passed quality outcome requires issue=null")
  52. if self.status != "passed" and not self.issue:
  53. raise ValueError(f"{self.status} quality outcome requires issue")
  54. return self
  55. class QualityCheckProvider(Protocol):
  56. async def check(
  57. self,
  58. request: QualityCheckInput,
  59. ) -> Sequence[QualityCheckOutcome]: ...
  60. class CandidateValidationRecord(ApplicationModel):
  61. schema_version: Literal[1] = 1
  62. candidate_ref: CandidateRef
  63. plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  64. validation_result: dict[str, Any]
  65. validated_at_sequence: int = Field(ge=0)
  66. @model_validator(mode="after")
  67. def validate_result(self) -> "CandidateValidationRecord":
  68. # Lazy import keeps application declarations independent from the
  69. # Validator implementation while still rejecting malformed ledgers.
  70. from cyber_agent.core.validation import ValidationResult
  71. result = ValidationResult.model_validate(self.validation_result)
  72. if result.plan_hash != self.plan_hash:
  73. raise ValueError("candidate validation result plan hash mismatch")
  74. return self
  75. class CandidateValidationCheckpoint(ApplicationModel):
  76. """Durable per-scope candidate validation state before final aggregation."""
  77. schema_version: Literal[1] = 1
  78. candidate_ref: CandidateRef
  79. plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  80. validation_plan: dict[str, Any]
  81. scope_results: tuple[dict[str, Any], ...] = ()
  82. fixed_checks: tuple[dict[str, Any], ...] = ()
  83. fixed_scope_errors: dict[str, str] = Field(default_factory=dict)
  84. quality_completed: bool = False
  85. material_usage_recorded: bool = False
  86. aggregate_result: dict[str, Any] | None = None
  87. validated_at_sequence: int = Field(ge=0)