Skip to content

Commit ceba95c

Browse files
committed
feat: add eval-schemas, eval-fixtures, eval-notebook checks
1 parent 1b2378a commit ceba95c

6 files changed

Lines changed: 260 additions & 10 deletions

File tree

scripts/README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,16 @@ fails the PR (it never auto-fixes) — so fixing locally saves a round-trip.
3737
|-------|--------------|
3838
| `strip-notebooks` | Clears outputs, execution counts, and cell metadata from `.ipynb` files |
3939
| `eval-config` | Validates each evaluator `config.json` against the shared schema (`evals/_schemas/config.schema.json`, referenced by the config's own `$schema`), plus cross-file rules: referenced files exist, prompt `sha256` matches, placeholders ⇄ template `{vars}` are in sync, system prompts carry no placeholders, and no obsolete `format_instructions` survive |
40+
| `eval-schemas` | Meta-validates each `input_schema.json` / `output_schema.json` is a well-formed JSON Schema document |
41+
| `eval-fixtures` | Validates `fixtures.json` against the shared `evals/_schemas/fixtures.schema.json`, and binds each case's `input`/`expected` to that evaluator's own input/output schema |
42+
| `eval-notebook` | Where an evaluator ships an example notebook, confirms it loads the config + prompt files from disk rather than hardcoding them |
4043

4144
## Coming next
4245

4346
This is the seed of a broader self-service harness. Planned additions:
4447

45-
- **`eval-schemas`** — meta-validate each `input_schema.json` / `output_schema.json`
46-
is a well-formed JSON Schema document.
47-
- **`eval-fixtures`** — validate `fixtures.json` against the shared
48-
`evals/_schemas/fixtures.schema.json`, and bind each case's `input`/`expected`
49-
to that evaluator's own input/output schema.
50-
- **`eval-notebook`** — where an evaluator ships an example notebook, confirm it
51-
loads the config + prompt files from disk rather than hardcoding them.
48+
- **model-snapshot pinning** — require `model.name` to reference a pinned
49+
snapshot (provider-specific), so evaluators don't float across model updates.
5250
- **delegation to the SDK suites** (`sdks/typescript` lint/type/test,
5351
`sdks/python` Makefile) so one local command covers everything. CI keeps the
5452
per-SDK workflows separate for parallelism and path-filtered signal.

scripts/checks/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""Check registry. Add new checks here to wire them into the harness + CI."""
22

33
from .eval_config import EvalConfig
4+
from .eval_fixtures import EvalFixtures
5+
from .eval_notebook import EvalNotebook
6+
from .eval_schemas import EvalSchemas
47
from .strip_notebooks import StripNotebooks
58

69
ALL_CHECKS = [
710
StripNotebooks(),
811
EvalConfig(),
12+
EvalSchemas(),
13+
EvalFixtures(),
14+
EvalNotebook(),
915
]

scripts/checks/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ def evaluator_configs() -> list[str]:
6060
return tracked_files("evals/**/config.json")
6161

6262

63-
def shared_schema_path(spec_version: str, name: str) -> str:
64-
"""Path to a version-pinned shared schema, e.g. evals/_schemas/1.0/<name>."""
65-
return os.path.join(SCHEMA_ROOT, spec_version, name)
63+
def shared_schema_path(name: str) -> str:
64+
"""Path to a shared schema, e.g. evals/_schemas/fixtures.schema.json."""
65+
return os.path.join(SCHEMA_ROOT, name)
6666

6767

6868
def load_json(path: str):

scripts/checks/eval_fixtures.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Validate each evaluator's fixtures.json.
2+
3+
Two layers, mirroring eval-config:
4+
- shared structure: every case is {id, input, expected} per the shared
5+
evals/_schemas/fixtures.schema.json.
6+
- per-evaluator binding: each case's `input` must satisfy that evaluator's own
7+
input_schema.json, and each field in `expected` must satisfy the matching
8+
property in its output_schema.json. (`expected` is a subset of the full
9+
output — just the label(s) — so we validate present fields, not requireds.)
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import os
16+
17+
from jsonschema import Draft202012Validator
18+
from jsonschema.exceptions import SchemaError
19+
20+
from .base import Check, Result, Violation, evaluator_configs, load_json, shared_schema_path
21+
22+
_FIXTURES_SCHEMA = "fixtures.schema.json"
23+
24+
25+
class EvalFixtures(Check):
26+
name = "eval-fixtures"
27+
description = "Validate fixtures.json structure + bind each case's input/expected to the evaluator's schemas"
28+
29+
def run(self, fix: bool) -> Result:
30+
result = Result(self.name)
31+
shared = Draft202012Validator(load_json(shared_schema_path(_FIXTURES_SCHEMA)))
32+
for cfg_path in evaluator_configs():
33+
self._check_fixtures(cfg_path, shared, result)
34+
return result
35+
36+
def _check_fixtures(self, cfg_path: str, shared: Draft202012Validator, result: Result) -> None:
37+
base = os.path.dirname(cfg_path)
38+
config = load_json(cfg_path)
39+
40+
def fail(msg: str) -> None:
41+
result.violations.append(Violation(cfg_path, msg))
42+
43+
rel = config.get("fixtures", {}).get("path")
44+
if not rel:
45+
return
46+
path = os.path.join(base, rel)
47+
if not os.path.exists(path):
48+
return # eval-config reports the missing file
49+
try:
50+
fixtures = load_json(path)
51+
except json.JSONDecodeError as e:
52+
fail(f"{rel}: invalid JSON: {e}")
53+
return
54+
55+
# Layer 1: shared structure.
56+
structurally_ok = True
57+
for err in sorted(shared.iter_errors(fixtures), key=lambda e: list(e.path)):
58+
structurally_ok = False
59+
fail(f"{rel}: {self._loc(err)}: {err.message}")
60+
if not structurally_ok:
61+
return # per-case binding below assumes well-formed cases
62+
63+
# Layer 2: bind input/expected to this evaluator's own schemas.
64+
input_validator = self._validator_for(config, base, "input_schema")
65+
expected_validator = self._expected_validator(config, base)
66+
for case in fixtures:
67+
cid = case.get("id", "?")
68+
if input_validator:
69+
for err in input_validator.iter_errors(case.get("input", {})):
70+
fail(f"{rel}[{cid}].input: {self._loc(err)}: {err.message}")
71+
if expected_validator:
72+
for err in expected_validator.iter_errors(case.get("expected", {})):
73+
fail(f"{rel}[{cid}].expected: {self._loc(err)}: {err.message}")
74+
75+
def _validator_for(self, config: dict, base: str, key: str):
76+
ref = config.get(key, {})
77+
if not (isinstance(ref, dict) and "$ref" in ref):
78+
return None
79+
path = os.path.join(base, ref["$ref"])
80+
if not os.path.exists(path):
81+
return None
82+
return self._safe_validator(load_json(path))
83+
84+
def _expected_validator(self, config: dict, base: str):
85+
"""A partial validator: each expected field must match its output property.
86+
87+
Built from output_schema.properties (+ $defs for $ref resolution) with no
88+
`required`, so a fixture carrying only the label validates, and an unknown
89+
expected field is rejected.
90+
"""
91+
ref = config.get("output_schema", {})
92+
if not (isinstance(ref, dict) and "$ref" in ref):
93+
return None
94+
path = os.path.join(base, ref["$ref"])
95+
if not os.path.exists(path):
96+
return None
97+
out = load_json(path)
98+
schema = {
99+
"type": "object",
100+
"additionalProperties": False,
101+
"properties": out.get("properties", {}),
102+
}
103+
if "$defs" in out:
104+
schema["$defs"] = out["$defs"]
105+
return self._safe_validator(schema)
106+
107+
@staticmethod
108+
def _safe_validator(schema: dict):
109+
"""Build a validator only if `schema` is itself a valid JSON Schema.
110+
111+
A malformed input/output schema is reported by eval-schemas; here we just
112+
skip binding rather than crash on it.
113+
"""
114+
try:
115+
Draft202012Validator.check_schema(schema)
116+
except SchemaError:
117+
return None
118+
return Draft202012Validator(schema)
119+
120+
@staticmethod
121+
def _loc(err) -> str:
122+
return "/".join(str(p) for p in err.path) or "(root)"

scripts/checks/eval_notebook.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Check that an evaluator's example notebook reads from its config, not copies.
2+
3+
Where an evaluator ships a notebook alongside its config, the notebook should
4+
load the canonical assets from disk (config.json + the prompt files declared in
5+
it) rather than hardcoding prompts/schema inline — so the notebook can't drift
6+
from the source of truth. Evaluators without a notebook (e.g. config-only ones)
7+
are skipped.
8+
9+
This is a lightweight heuristic: it confirms the notebook's code references
10+
config.json and loads the prompt files (by the `source_path` loader pattern, or
11+
by naming each prompt file). It does not execute the notebook.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import glob
17+
import json
18+
import os
19+
20+
from .base import Check, Result, Violation, evaluator_configs, load_json
21+
22+
23+
class EvalNotebook(Check):
24+
name = "eval-notebook"
25+
description = "Where an evaluator ships a notebook, confirm it loads config + prompts from disk"
26+
27+
def run(self, fix: bool) -> Result:
28+
result = Result(self.name)
29+
for cfg_path in evaluator_configs():
30+
self._check_notebook(cfg_path, result)
31+
return result
32+
33+
def _check_notebook(self, cfg_path: str, result: Result) -> None:
34+
base = os.path.dirname(cfg_path)
35+
notebooks = glob.glob(os.path.join(base, "*.ipynb"))
36+
if not notebooks:
37+
return # config-only evaluator — nothing to check
38+
39+
config = load_json(cfg_path)
40+
prompt_files = [
41+
m["source_path"]
42+
for m in config.get("steps", [{}])[0].get("prompt", {}).get("messages", [])
43+
if m.get("source_path")
44+
]
45+
46+
for nb_path in notebooks:
47+
src = self._code_source(nb_path)
48+
49+
if "config.json" not in src:
50+
result.violations.append(
51+
Violation(nb_path, "does not load config.json — should read the config, not hardcode it")
52+
)
53+
# Either the dynamic loader pattern, or every prompt file named explicitly.
54+
loads_prompts = "source_path" in src or (
55+
prompt_files and all(pf in src for pf in prompt_files)
56+
)
57+
if not loads_prompts:
58+
result.violations.append(
59+
Violation(nb_path, "does not load the prompt files from disk (no source_path loader nor prompt filenames)")
60+
)
61+
62+
@staticmethod
63+
def _code_source(nb_path: str) -> str:
64+
"""Concatenated source of a notebook's code cells."""
65+
try:
66+
nb = load_json(nb_path)
67+
except (OSError, json.JSONDecodeError):
68+
return ""
69+
return "\n".join(
70+
"".join(c.get("source", []))
71+
for c in nb.get("cells", [])
72+
if c.get("cell_type") == "code"
73+
)

scripts/checks/eval_schemas.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Meta-validate each evaluator's input_schema.json / output_schema.json.
2+
3+
These files *are* JSON Schemas (they describe an evaluator's input and output),
4+
so the check is: is each one a well-formed JSON Schema document? We run the
5+
Draft 2020-12 meta-schema against them. Existence of the $ref is already covered
6+
by eval-config; here we only judge validity of the schema documents themselves.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
14+
from jsonschema import Draft202012Validator
15+
from jsonschema.exceptions import SchemaError
16+
17+
from .base import Check, Result, Violation, evaluator_configs, load_json
18+
19+
20+
class EvalSchemas(Check):
21+
name = "eval-schemas"
22+
description = "Meta-validate each evaluator's input_schema.json / output_schema.json"
23+
24+
def run(self, fix: bool) -> Result:
25+
result = Result(self.name)
26+
for cfg_path in evaluator_configs():
27+
base = os.path.dirname(cfg_path)
28+
try:
29+
config = load_json(cfg_path)
30+
except (OSError, json.JSONDecodeError):
31+
continue # eval-config reports unreadable configs
32+
for key in ("input_schema", "output_schema"):
33+
self._check_schema_file(config.get(key, {}), base, result)
34+
return result
35+
36+
def _check_schema_file(self, ref: dict, base: str, result: Result) -> None:
37+
if not (isinstance(ref, dict) and "$ref" in ref):
38+
return
39+
rel = ref["$ref"]
40+
path = os.path.join(base, rel)
41+
if not os.path.exists(path):
42+
return # eval-config already flags missing $ref targets
43+
try:
44+
doc = load_json(path)
45+
except json.JSONDecodeError as e:
46+
result.violations.append(Violation(path, f"invalid JSON: {e}"))
47+
return
48+
try:
49+
Draft202012Validator.check_schema(doc)
50+
except SchemaError as e:
51+
result.violations.append(Violation(path, f"not a valid JSON Schema: {e.message}"))

0 commit comments

Comments
 (0)