validation_policy.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. """Minimal V2 validation planning and deterministic rule execution."""
  2. from __future__ import annotations
  3. import inspect
  4. from dataclasses import asdict, dataclass, field
  5. from typing import Any, Awaitable, Dict, List, Mapping, Optional, Protocol, Sequence, Union
  6. from .models import (
  7. ArtifactRef,
  8. ArtifactSnapshot,
  9. TaskAttempt,
  10. TaskRecord,
  11. ValidationMode,
  12. ValidationPlan,
  13. ValidationVerdict,
  14. json_values,
  15. )
  16. @dataclass(frozen=True)
  17. class ValidationContext:
  18. root_trace_id: str
  19. task: TaskRecord
  20. attempt: TaskAttempt
  21. snapshot: ArtifactSnapshot
  22. prior_validation_count: int = 0
  23. context_documents: Mapping[str, Any] = field(default_factory=dict)
  24. class ValidationPolicy(Protocol):
  25. def plan(self, context: ValidationContext) -> ValidationPlan: ...
  26. @dataclass(frozen=True)
  27. class DefaultValidationPolicy:
  28. """Preserves V1: every attempt receives a full independent Agent Validator."""
  29. validator_preset: str = "validator"
  30. def __post_init__(self) -> None:
  31. if not self.validator_preset.strip():
  32. raise ValueError("validator_preset cannot be empty")
  33. def plan(self, context: ValidationContext) -> ValidationPlan:
  34. del context
  35. return ValidationPlan(
  36. mode=ValidationMode.AGENT,
  37. validator_preset=self.validator_preset,
  38. )
  39. @dataclass(frozen=True)
  40. class DeterministicRuleResult:
  41. rule_id: str
  42. verdict: ValidationVerdict
  43. reason: str
  44. evidence_refs: List[ArtifactRef] = field(default_factory=list)
  45. error: Optional[str] = None
  46. def to_dict(self) -> Dict[str, Any]:
  47. return json_values(asdict(self))
  48. @dataclass(frozen=True)
  49. class DeterministicValidationResult:
  50. verdict: ValidationVerdict
  51. rule_results: List[DeterministicRuleResult] = field(default_factory=list)
  52. @property
  53. def errors(self) -> List[str]:
  54. return [item.error for item in self.rule_results if item.error]
  55. def to_dict(self) -> Dict[str, Any]:
  56. return json_values(asdict(self))
  57. RuleOutcome = Union[
  58. DeterministicRuleResult,
  59. Awaitable[DeterministicRuleResult],
  60. ]
  61. class DeterministicRule(Protocol):
  62. rule_id: str
  63. def evaluate(self, context: ValidationContext) -> RuleOutcome: ...
  64. class DeterministicValidator(Protocol):
  65. async def validate(
  66. self,
  67. context: ValidationContext,
  68. plan: ValidationPlan,
  69. ) -> DeterministicValidationResult: ...
  70. class RuleBasedDeterministicValidator:
  71. """Runs named rules in plan order and converts rule failures to inconclusive."""
  72. def __init__(self, rules: Sequence[DeterministicRule]) -> None:
  73. self._rules: Dict[str, DeterministicRule] = {}
  74. for rule in rules:
  75. rule_id = rule.rule_id.strip()
  76. if not rule_id:
  77. raise ValueError("Deterministic rule_id cannot be empty")
  78. if rule_id in self._rules:
  79. raise ValueError(f"Duplicate deterministic rule_id: {rule_id}")
  80. self._rules[rule_id] = rule
  81. async def validate(
  82. self,
  83. context: ValidationContext,
  84. plan: ValidationPlan,
  85. ) -> DeterministicValidationResult:
  86. if plan.mode != ValidationMode.DETERMINISTIC:
  87. raise ValueError("RuleBasedDeterministicValidator requires a deterministic plan")
  88. if not plan.rule_ids:
  89. return DeterministicValidationResult(ValidationVerdict.INCONCLUSIVE)
  90. results: List[DeterministicRuleResult] = []
  91. for rule_id in plan.rule_ids:
  92. rule = self._rules.get(rule_id)
  93. if rule is None:
  94. results.append(_rule_error(rule_id, "Deterministic rule is not registered"))
  95. continue
  96. try:
  97. outcome = rule.evaluate(context)
  98. result = await outcome if inspect.isawaitable(outcome) else outcome
  99. if result.rule_id != rule_id:
  100. raise ValueError(
  101. f"Rule returned id {result.rule_id!r}, expected {rule_id!r}"
  102. )
  103. results.append(result)
  104. except Exception as exc:
  105. results.append(_rule_error(rule_id, f"{type(exc).__name__}: {exc}"))
  106. verdicts = {item.verdict for item in results}
  107. if ValidationVerdict.FAILED in verdicts:
  108. verdict = ValidationVerdict.FAILED
  109. elif verdicts == {ValidationVerdict.PASSED}:
  110. verdict = ValidationVerdict.PASSED
  111. else:
  112. verdict = ValidationVerdict.INCONCLUSIVE
  113. return DeterministicValidationResult(verdict, results)
  114. def _rule_error(rule_id: str, error: str) -> DeterministicRuleResult:
  115. return DeterministicRuleResult(
  116. rule_id=rule_id,
  117. verdict=ValidationVerdict.INCONCLUSIVE,
  118. reason="Rule could not produce a verdict",
  119. error=error,
  120. )
  121. __all__ = [
  122. "DefaultValidationPolicy",
  123. "DeterministicRule",
  124. "DeterministicRuleResult",
  125. "DeterministicValidationResult",
  126. "DeterministicValidator",
  127. "RuleBasedDeterministicValidator",
  128. "ValidationContext",
  129. "ValidationMode",
  130. "ValidationPlan",
  131. "ValidationPolicy",
  132. ]