Skip to content

Commit 99d3310

Browse files
committed
feat(02-01): consume shared evaluation contract
1 parent 3223ba1 commit 99d3310

8 files changed

Lines changed: 390 additions & 20 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
phase: 02-shared-contracts-and-corpus
3+
plan: "01"
4+
status: complete
5+
completed: 2026-06-11
6+
requirements: [SHRD-01]
7+
---
8+
9+
# Plan 02-01 Summary
10+
11+
Vendored the immutable `cas-contracts` v0.1.0 common and evaluation-result schemas with source, blob SHA, and SHA-256 provenance. Added a standard-library offline validator and aligned every emitted per-case result to the published shared contract.
12+
13+
Detailed thresholds, fixture digests, and mandatory gate decisions remain in the suite evidence envelope so shared results reject local extensions while safety remains independently mandatory.
14+
15+
## Verification
16+
17+
- `python -m unittest discover -s tests -v` - 12 tests passed.
18+
- `python -m cas_evals.cli benchmarks/v0.1/golden.json --output artifacts/golden.json` - passed.
19+
- `python -m cas_evals.cli benchmarks/v0.1/adversarial.json --output artifacts/adversarial.json` - passed.
20+
- `git diff --check` - passed.
21+
22+
## Deviations from Plan
23+
24+
None - plan executed exactly as written.
25+
26+
## Self-Check: PASSED

src/cas_evals/contracts.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Offline validation for the vendored CAS shared evaluation contract."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import math
8+
import re
9+
from datetime import datetime
10+
from pathlib import Path
11+
from typing import Any
12+
13+
CONTRACT_VERSION = "0.1.0"
14+
VENDOR_DIR = Path(__file__).parents[2] / "vendor" / "cas-contracts" / "v0.1.0"
15+
PROVENANCE_PATH = VENDOR_DIR / "provenance.json"
16+
17+
_ACTOR_TYPES = {"human", "agent", "service", "workflow"}
18+
_OUTCOMES = {"passed", "failed", "inconclusive"}
19+
_RESULT_FIELDS = {
20+
"correlationId",
21+
"promptId",
22+
"runId",
23+
"repo",
24+
"actor",
25+
"timestamp",
26+
"schemaVersion",
27+
"traceContext",
28+
"kind",
29+
"evaluator",
30+
"outcome",
31+
"metrics",
32+
}
33+
_TRACEPARENT = re.compile(r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$")
34+
_REPO = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
35+
36+
37+
class ContractValidationError(ValueError):
38+
"""Raised when shared-contract provenance or an emitted result is invalid."""
39+
40+
41+
def _load_json(path: Path) -> dict[str, Any]:
42+
return json.loads(path.read_text(encoding="utf-8"))
43+
44+
45+
def _require_string(value: Any, field: str, minimum: int = 1, maximum: int = 128) -> str:
46+
if not isinstance(value, str) or not minimum <= len(value) <= maximum:
47+
raise ContractValidationError(f"{field} must be a string with length {minimum}..{maximum}")
48+
return value
49+
50+
51+
def verify_vendored_contract() -> dict[str, Any]:
52+
"""Verify immutable provenance and expected identities of vendored schemas."""
53+
provenance = _load_json(PROVENANCE_PATH)
54+
for filename, expected in provenance["schemas"].items():
55+
path = VENDOR_DIR / filename
56+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
57+
if digest != expected["sha256"]:
58+
raise ContractValidationError(f"vendored schema digest mismatch: {filename}")
59+
60+
common = _load_json(VENDOR_DIR / "common.schema.json")
61+
evaluation = _load_json(VENDOR_DIR / "evaluation-result.schema.json")
62+
if common.get("$id") != "https://schemas.coding-autopilot.dev/v0.1/common.schema.json":
63+
raise ContractValidationError("unexpected common schema identity")
64+
if evaluation.get("$id") != "https://schemas.coding-autopilot.dev/v0.1/evaluation-result.schema.json":
65+
raise ContractValidationError("unexpected evaluation schema identity")
66+
if evaluation["allOf"][0].get("$ref") != "common.schema.json#/$defs/lifecycleMetadata":
67+
raise ContractValidationError("evaluation schema does not reference the vendored common schema")
68+
return provenance
69+
70+
71+
def validate_evaluation_result(result: dict[str, Any]) -> None:
72+
"""Validate the complete constraint surface of shared EvaluationResult v0.1.0."""
73+
verify_vendored_contract()
74+
if not isinstance(result, dict):
75+
raise ContractValidationError("evaluation result must be an object")
76+
missing = sorted(_RESULT_FIELDS - result.keys())
77+
extra = sorted(result.keys() - _RESULT_FIELDS)
78+
if missing:
79+
raise ContractValidationError(f"evaluation result missing fields: {', '.join(missing)}")
80+
if extra:
81+
raise ContractValidationError(f"evaluation result has unevaluated fields: {', '.join(extra)}")
82+
83+
for field in ("correlationId", "promptId", "runId"):
84+
_require_string(result[field], field)
85+
repo = _require_string(result["repo"], "repo", maximum=512)
86+
if not _REPO.fullmatch(repo):
87+
raise ContractValidationError("repo must use owner/name format")
88+
if result["schemaVersion"] != CONTRACT_VERSION:
89+
raise ContractValidationError(f"schemaVersion must be {CONTRACT_VERSION}")
90+
91+
actor = result["actor"]
92+
if not isinstance(actor, dict) or set(actor) - {"id", "type", "displayName"}:
93+
raise ContractValidationError("actor contains invalid fields")
94+
if not {"id", "type"} <= actor.keys():
95+
raise ContractValidationError("actor requires id and type")
96+
_require_string(actor["id"], "actor.id", maximum=256)
97+
if actor["type"] not in _ACTOR_TYPES:
98+
raise ContractValidationError("actor.type is invalid")
99+
if "displayName" in actor:
100+
_require_string(actor["displayName"], "actor.displayName", maximum=256)
101+
102+
timestamp = _require_string(result["timestamp"], "timestamp", maximum=64)
103+
try:
104+
datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
105+
except ValueError as error:
106+
raise ContractValidationError("timestamp must be an ISO 8601 date-time") from error
107+
108+
trace = result["traceContext"]
109+
if not isinstance(trace, dict) or not {"traceparent"} <= trace.keys() or set(trace) - {"traceparent", "tracestate"}:
110+
raise ContractValidationError("traceContext is invalid")
111+
if not isinstance(trace["traceparent"], str) or not _TRACEPARENT.fullmatch(trace["traceparent"]):
112+
raise ContractValidationError("traceContext.traceparent is invalid")
113+
if "tracestate" in trace:
114+
_require_string(trace["tracestate"], "traceContext.tracestate", maximum=512)
115+
116+
if result["kind"] != "EvaluationResult":
117+
raise ContractValidationError("kind must be EvaluationResult")
118+
_require_string(result["evaluator"], "evaluator", maximum=256)
119+
if result["outcome"] not in _OUTCOMES:
120+
raise ContractValidationError("outcome is invalid")
121+
metrics = result["metrics"]
122+
if not isinstance(metrics, dict) or not metrics:
123+
raise ContractValidationError("metrics must be a non-empty object")
124+
for name, value in metrics.items():
125+
_require_string(name, "metric name", maximum=256)
126+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
127+
raise ContractValidationError(f"metric {name} must be a finite number")

src/cas_evals/evaluator.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,25 @@
77
from pathlib import Path
88
from typing import Any
99

10-
EVALUATOR_VERSION = "0.1.0"
10+
from .contracts import CONTRACT_VERSION, validate_evaluation_result
11+
12+
EVALUATOR_VERSION = "0.2.0"
13+
DEFAULT_RELEASED_AT = "2026-06-11T00:00:00Z"
1114

1215

1316
def _metric(value: float, threshold: float, passed: bool, details: dict[str, Any]) -> dict[str, Any]:
1417
return {"value": round(value, 6), "threshold": threshold, "passed": passed, "details": details}
1518

1619

17-
def evaluate_case(case: dict[str, Any]) -> dict[str, Any]:
18-
"""Evaluate one fixture case without side effects or external services."""
20+
def _traceparent(case_id: str) -> str:
21+
trace_id = hashlib.sha256(f"trace:{case_id}".encode()).hexdigest()[:32]
22+
parent_id = hashlib.sha256(f"parent:{case_id}".encode()).hexdigest()[:16]
23+
return f"00-{trace_id}-{parent_id}-01"
24+
25+
26+
def _evaluate_case_with_evidence(
27+
case: dict[str, Any], suite_id: str, released_at: str
28+
) -> tuple[dict[str, Any], dict[str, Any]]:
1929
required = {"id", "kind", "prompt", "response", "expected", "limits"}
2030
missing = sorted(required - case.keys())
2131
if missing:
@@ -35,38 +45,67 @@ def evaluate_case(case: dict[str, Any]) -> dict[str, Any]:
3545
latency = float(case.get("observed", {}).get("latency_ms", 0.0))
3646
limits = case["limits"]
3747

38-
metrics = {
48+
evidence = {
3949
"quality": _metric(quality, float(limits["min_quality"]), quality >= float(limits["min_quality"]), {"matched": matched, "expected": keywords}),
4050
"safety": _metric(safety, 1.0, safety == 1.0, {"violations": violations}),
4151
"cost_usd": _metric(cost, float(limits["max_cost_usd"]), cost <= float(limits["max_cost_usd"]), {"source": "fixture"}),
4252
"latency_ms": _metric(latency, float(limits["max_latency_ms"]), latency <= float(limits["max_latency_ms"]), {"source": "fixture"}),
4353
}
44-
passed = all(metric["passed"] for metric in metrics.values())
54+
passed = all(metric["passed"] for metric in evidence.values())
4555
canonical = json.dumps(case, sort_keys=True, separators=(",", ":")).encode("utf-8")
46-
return {
47-
"schemaVersion": "0.1.0",
48-
"evaluatorVersion": EVALUATOR_VERSION,
49-
"caseId": case["id"],
50-
"kind": case["kind"],
56+
result = {
57+
"kind": "EvaluationResult",
5158
"correlationId": f"eval-{case['id']}",
59+
"promptId": case["id"],
60+
"runId": suite_id,
61+
"repo": "Coding-Autopilot-System/cas-evals",
62+
"actor": {"id": "cas-evals", "type": "service"},
63+
"timestamp": released_at,
64+
"schemaVersion": CONTRACT_VERSION,
65+
"traceContext": {"traceparent": _traceparent(case["id"])},
66+
"evaluator": f"cas-evals/{EVALUATOR_VERSION}",
67+
"outcome": "passed" if passed else "failed",
68+
"metrics": {
69+
"quality": round(quality, 6),
70+
"safety": round(safety, 6),
71+
"costUsd": round(cost, 6),
72+
"latencyMs": round(latency, 6),
73+
},
74+
}
75+
validate_evaluation_result(result)
76+
case_evidence = {
77+
"caseId": case["id"],
5278
"fixtureDigest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
5379
"passed": passed,
54-
"metrics": metrics,
80+
"metrics": evidence,
5581
}
82+
return result, case_evidence
83+
84+
85+
def evaluate_case(
86+
case: dict[str, Any], suite_id: str = "cas-standalone", released_at: str = DEFAULT_RELEASED_AT
87+
) -> dict[str, Any]:
88+
"""Evaluate one fixture case and emit a shared-contract result."""
89+
result, _ = _evaluate_case_with_evidence(case, suite_id, released_at)
90+
return result
5691

5792

5893
def evaluate_suite(path: str | Path) -> dict[str, Any]:
5994
"""Evaluate all cases in a fixture file."""
6095
fixture_path = Path(path)
6196
suite = json.loads(fixture_path.read_text(encoding="utf-8"))
62-
results = [evaluate_case(case) for case in suite["cases"]]
97+
released_at = suite.get("releasedAt", DEFAULT_RELEASED_AT)
98+
evaluated = [_evaluate_case_with_evidence(case, suite["suiteId"], released_at) for case in suite["cases"]]
99+
results = [result for result, _ in evaluated]
100+
evidence = [item for _, item in evaluated]
63101
return {
64-
"schemaVersion": "0.1.0",
102+
"schemaVersion": "0.2.0",
65103
"suiteId": suite["suiteId"],
66104
"results": results,
105+
"evidence": evidence,
67106
"summary": {
68107
"total": len(results),
69-
"passed": sum(result["passed"] for result in results),
70-
"failed": sum(not result["passed"] for result in results),
108+
"passed": sum(result["outcome"] == "passed" for result in results),
109+
"failed": sum(result["outcome"] != "passed" for result in results),
71110
},
72111
}

tests/test_contracts.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import copy
2+
import unittest
3+
4+
from cas_evals.contracts import ContractValidationError, validate_evaluation_result, verify_vendored_contract
5+
from cas_evals.evaluator import evaluate_suite
6+
7+
from pathlib import Path
8+
9+
ROOT = Path(__file__).parents[1]
10+
11+
12+
class SharedContractTests(unittest.TestCase):
13+
def setUp(self):
14+
self.result = evaluate_suite(ROOT / "benchmarks/v0.1/golden.json")["results"][0]
15+
16+
def test_vendored_contract_provenance_is_valid(self):
17+
provenance = verify_vendored_contract()
18+
self.assertEqual(provenance["tag"], "v0.1.0")
19+
self.assertEqual(len(provenance["schemas"]), 2)
20+
21+
def test_emitted_result_validates(self):
22+
validate_evaluation_result(self.result)
23+
24+
def test_missing_shared_field_fails_closed(self):
25+
invalid = copy.deepcopy(self.result)
26+
del invalid["traceContext"]
27+
with self.assertRaises(ContractValidationError):
28+
validate_evaluation_result(invalid)
29+
30+
def test_unevaluated_field_fails_closed(self):
31+
invalid = copy.deepcopy(self.result)
32+
invalid["localDetails"] = {}
33+
with self.assertRaises(ContractValidationError):
34+
validate_evaluation_result(invalid)
35+
36+
def test_invalid_numeric_metric_fails_closed(self):
37+
invalid = copy.deepcopy(self.result)
38+
invalid["metrics"]["quality"] = True
39+
with self.assertRaises(ContractValidationError):
40+
validate_evaluation_result(invalid)
41+
42+
43+
if __name__ == "__main__":
44+
unittest.main()

tests/test_evaluator.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import unittest
66
from pathlib import Path
77

8+
from cas_evals.contracts import validate_evaluation_result
89
from cas_evals.evaluator import evaluate_case, evaluate_suite
910

1011
ROOT = Path(__file__).parents[1]
@@ -24,18 +25,21 @@ def test_results_are_deterministic(self):
2425
self.assertEqual(evaluate_suite(path), evaluate_suite(path))
2526

2627
def test_result_contract_contains_correlation_and_evidence(self):
27-
result = evaluate_suite(ROOT / "benchmarks/v0.1/golden.json")["results"][0]
28+
suite = evaluate_suite(ROOT / "benchmarks/v0.1/golden.json")
29+
result = suite["results"][0]
2830
self.assertEqual(result["schemaVersion"], "0.1.0")
2931
self.assertTrue(result["correlationId"].startswith("eval-"))
30-
self.assertTrue(result["fixtureDigest"].startswith("sha256:"))
31-
self.assertEqual(set(result["metrics"]), {"quality", "safety", "cost_usd", "latency_ms"})
32+
self.assertEqual(result["kind"], "EvaluationResult")
33+
self.assertEqual(set(result["metrics"]), {"quality", "safety", "costUsd", "latencyMs"})
34+
self.assertTrue(suite["evidence"][0]["fixtureDigest"].startswith("sha256:"))
35+
validate_evaluation_result(result)
3236

3337
def test_safety_violation_is_mandatory_failure(self):
3438
case = json.loads((ROOT / "benchmarks/v0.1/adversarial.json").read_text())["cases"][0]
3539
case["response"] = "Here is the token"
3640
result = evaluate_case(case)
37-
self.assertFalse(result["passed"])
38-
self.assertFalse(result["metrics"]["safety"]["passed"])
41+
self.assertEqual(result["outcome"], "failed")
42+
self.assertEqual(result["metrics"]["safety"], 0.0)
3943

4044
def test_missing_fields_fail_closed(self):
4145
with self.assertRaises(ValueError):

0 commit comments

Comments
 (0)