-
Notifications
You must be signed in to change notification settings - Fork 0
feat(verification): type verification_spec with roles (schema foundation) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4d0443a
64ea9f1
53d3a2f
aa1371a
ea81ad3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -54,6 +54,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from devops_bench.tasks import Task | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from devops_bench.verification import VerificationSpec | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from devops_bench.verification.entry import VerificationEntry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| __all__ = ["DefaultEvalHarness"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -470,6 +471,32 @@ def _build_verification_mapping( | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {}, [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors: list[dict[str, str]] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping: dict[str, Any] = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Typed path: task.verification_spec has been parsed into VerificationEntry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # objects at load time. Placeholders live inside entry.spec (still raw dicts). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(raw, list) and raw and isinstance(raw[0], VerificationEntry): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for entry in raw: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec_resolved = self._resolve_spec_placeholders( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entry.spec, cluster_name, target_deployment, namespace | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if entry.name in mapping: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _log.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "duplicate verification entry name %r; overwriting", entry.name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping[entry.name] = VerificationSpec(spec_resolved) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as exc: # noqa: BLE001 - surface every failure | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _log.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "verification entry %r failed to validate; skipping: %s", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entry.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exc, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors.append({"name": entry.name, "reason": str(exc)}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+479
to
+495
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap the placeholder resolution (
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return mapping, errors | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Legacy path: raw dict / list / JSON-string (pre-migration or direct calls | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # that bypass the typed schema). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolved = self._resolve_spec_placeholders(raw, cluster_name, target_deployment, namespace) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(resolved, str): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -480,7 +507,6 @@ def _build_verification_mapping( | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {}, errors | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entries = resolved if isinstance(resolved, list) else [resolved] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping: dict[str, Any] = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for index, entry in enumerate(entries): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not isinstance(entry, dict): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -500,6 +526,8 @@ def _build_verification_mapping( | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| # a ``spec`` key is itself treated as the typed node. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| node = entry.get("spec") if "spec" in entry else entry | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if name in mapping: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _log.warning("duplicate verification entry name %r; overwriting", name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping[name] = VerificationSpec(node) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as exc: # noqa: BLE001 - surface every failure | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _log.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -944,7 +972,11 @@ def _empty_record(self, task: Task) -> dict[str, Any]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| "expected_output_raw": task.expected_output, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "retrieval_context": list(task.retrieval_context), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "chaos_spec": task.chaos_spec, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "verification_spec": task.verification_spec, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "verification_spec": ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [e.model_dump() if hasattr(e, "model_dump") else e for e in task.verification_spec] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if task.verification_spec is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+975
to
+979
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "chaos_report": {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "perf_report": {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "documentation": [doc.model_dump() for doc in task.documentation], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,14 +23,23 @@ | |
| import time | ||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Callable | ||
| from typing import Any | ||
| from typing import Any, Literal | ||
|
|
||
| from pydantic import BaseModel, Field | ||
|
|
||
| from devops_bench.core import Registry | ||
| from devops_bench.k8s import poll_until | ||
|
|
||
| __all__ = ["VERIFIERS", "VerificationResult", "BaseVerifier"] | ||
| __all__ = ["Mode", "VERIFIERS", "VerificationResult", "BaseVerifier"] | ||
|
|
||
| # Execution mode for a leaf verifier. | ||
| # | ||
| # converge -- verify(timeout_sec=N): keep polling until success or deadline. | ||
| # assert -- verify(timeout_sec=0): evaluate exactly once, never sleep. | ||
| # hold -- sample verify(0) repeatedly; every sample must pass. | ||
| # unchanged -- NOT IMPLEMENTED: requires a pre-agent snapshot protocol. | ||
| # The first consumer (unchanged_outside) is not in this PR. | ||
| Mode = Literal["converge", "assert", "unchanged", "hold"] | ||
|
|
||
| # Registry keyed by the ``type`` discriminator literal. Entry-point discovery | ||
| # lets external packages register a verifier without touching this tree. | ||
|
|
@@ -53,6 +62,14 @@ class VerificationResult(BaseModel): | |
| name: Optional label echoed from the spec node, for result rendering. | ||
| children: Per-member results from compound (sequence/parallel) nodes. | ||
| raw: Leaf-only kubectl diagnostics or supporting data. | ||
| weight: Contribution weight used by the rollup; default 1.0. Echoed | ||
| from the originating leaf's :attr:`BaseVerifier.weight` so the | ||
| result tree is self-describing for downstream scoring. The runner | ||
| (:meth:`~devops_bench.verification.runner.VerifierAgent._run_leaf`) | ||
| stamps the leaf's configured weight onto the result after | ||
| evaluation, so a custom verifier that builds this result directly | ||
| (bypassing :meth:`BaseVerifier._poll_to_result`) does not need to | ||
| forward ``weight`` itself — the runner is authoritative. | ||
| """ | ||
|
|
||
| success: bool | ||
|
|
@@ -61,6 +78,7 @@ class VerificationResult(BaseModel): | |
| name: str | None = None | ||
| children: list[VerificationResult] = Field(default_factory=list) | ||
| raw: dict | None = None | ||
| weight: float = 1.0 | ||
|
|
||
|
|
||
| VerificationResult.model_rebuild() | ||
|
|
@@ -77,10 +95,26 @@ class BaseVerifier(BaseModel, ABC): | |
| kubeconfig: Optional path to a kubeconfig file, forwarded to the | ||
| ``devops_bench.k8s`` wrappers so a check can target a specific | ||
| cluster. When ``None`` the wrappers use the ambient kubeconfig. | ||
| weight: Contribution weight used by the rollup when summing pass/fail | ||
| fractions across leaves. Default 1.0 (all leaves equal). Echoed | ||
| onto the :class:`VerificationResult` so the tree is self-describing. | ||
| mode: Explicit execution mode. When ``None`` the runner derives the | ||
| mode from the parent :class:`~devops_bench.verification.entry.Role`: | ||
| ``"objective"`` -> ``"converge"``; | ||
| ``"safeguard"`` -> ``"assert"``. An explicit ``mode`` always wins | ||
| over the role-derived default. | ||
| hold_window_sec: Duration of the hold-mode sampling window; meaningful | ||
| only when ``mode="hold"``. Defaults to 30 s. | ||
| hold_interval_sec: Pause between hold-mode samples; meaningful only | ||
| when ``mode="hold"``. Defaults to 5 s. | ||
| """ | ||
|
|
||
| name: str | None = None | ||
| kubeconfig: str | None = None | ||
| weight: float = 1.0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
| mode: Mode | None = None | ||
| hold_window_sec: float = 30.0 | ||
| hold_interval_sec: float = 5.0 | ||
|
|
||
| @abstractmethod | ||
| def verify(self, timeout_sec: float) -> VerificationResult: | ||
|
|
@@ -133,4 +167,5 @@ def predicate() -> bool: | |
| reason=last["reason"], | ||
| name=self.name, | ||
| raw=last["raw"], | ||
| weight=self.weight, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| # Copyright 2026 The Kubernetes Authors. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Typed outer wrapper for a verification spec entry. | ||
|
|
||
| This module is intentionally kept dependency-light: it must not import from | ||
| ``devops_bench.tasks`` (that direction would create a cycle) and it must not | ||
| import from ``devops_bench.verification.spec`` (that triggers registry | ||
| population and verifier imports at task-load time, before placeholder | ||
| substitution has run). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Literal | ||
|
|
||
| from pydantic import BaseModel, model_validator | ||
|
|
||
| __all__ = ["Role", "Severity", "VerificationEntry"] | ||
|
|
||
| # Scoring role of a verification entry. | ||
| # | ||
| # objective -- something the agent must achieve; feeds the correctness score c. | ||
| # safeguard -- a protective invariant that must hold; a violation penalizes | ||
| # (recoverable) or hard-gates (catastrophic) the score. | ||
| Role = Literal["objective", "safeguard"] | ||
|
|
||
| # Severity of a safeguard violation. Required when role='safeguard', forbidden | ||
| # when role='objective'. | ||
| # | ||
| # recoverable -- a violation penalizes the score (feeds rec_v). | ||
| # catastrophic -- a violation hard-gates the score to zero (feeds cat_v). | ||
| Severity = Literal["recoverable", "catastrophic"] | ||
|
|
||
|
|
||
| def _contains_unchanged_mode(value: Any) -> bool: | ||
| """Recursively walk a raw spec value looking for ``mode: unchanged``.""" | ||
| if isinstance(value, dict): | ||
| if value.get("mode") == "unchanged": | ||
| return True | ||
| return any(_contains_unchanged_mode(v) for v in value.values()) | ||
| if isinstance(value, list): | ||
| return any(_contains_unchanged_mode(item) for item in value) | ||
| return False | ||
|
|
||
|
|
||
| class VerificationEntry(BaseModel): | ||
| """One named verification suite with an associated scoring role. | ||
|
|
||
| Attributes: | ||
| name: Cross-reference key resolved by the chaos ``verify:`` field and | ||
| used as the key in the name-keyed verification mapping. | ||
| role: Scoring category. Defaults to ``"objective"`` so every | ||
| existing spec authored without a ``role`` key is backward-compatible | ||
| without editing. | ||
| severity: Required when ``role == "safeguard"``; must be ``None`` when | ||
| ``role == "objective"``. ``"recoverable"`` violations penalize the | ||
| score (feed ``rec_v``); ``"catastrophic"`` violations hard-gate the | ||
| run (feed ``cat_v``). | ||
| spec: Raw (unparsed) verification node dict. Placeholder substitution | ||
| (``{{NAMESPACE}}``, etc.) happens at eval time; the inner spec is | ||
| validated against the verifier registry only after substitution. | ||
| """ | ||
|
|
||
| name: str | ||
| role: Role = "objective" | ||
| severity: Severity | None = None | ||
| spec: Any | ||
|
|
||
| @model_validator(mode="after") | ||
| def _validate_severity(self) -> VerificationEntry: | ||
| if self.role == "safeguard" and self.severity is None: | ||
| raise ValueError( | ||
| "severity is required when role='safeguard'; " | ||
| "set severity='recoverable' or severity='catastrophic'" | ||
| ) | ||
| if self.role == "objective" and self.severity is not None: | ||
| raise ValueError( | ||
| "severity must be None when role='objective'; " | ||
| "severity is only meaningful on safeguard entries" | ||
| ) | ||
| return self | ||
|
|
||
| @model_validator(mode="after") | ||
| def _reject_unchanged_mode(self) -> VerificationEntry: | ||
| if _contains_unchanged_mode(self.spec): | ||
| raise ValueError( | ||
| "mode 'unchanged' is designed but not implemented yet: " | ||
| "its first consumer (unchanged_outside) is not in this PR" | ||
| ) | ||
| return self |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If multiple verification entries share the same
name, the subsequent entries will silently overwrite the previous ones inmapping. This is likely an authoring error (e.g., copy-paste mistake) and can lead to verification checks being silently skipped. Consider logging a warning when a duplicate name is detected.