blob: f0218a8d9b98419915c7944d26fb94c314ff59a0 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
"""
Skill eval runner.
Two modes:
1. **Print mode (default)** — emit the system prompt, user prompt, and
expected JSON for each case. The operator (or the agent making the
change, in self-eval mode) pastes the prompts into the model under
test and diffs the response against expected.json manually.
2. **`--cli` mode** — pipe ``<system_prompt>\\n\\n<user_prompt>`` on stdin
to the configured shell command, capture stdout, extract the JSON
the model produced, and compare against expected.json automatically.
Reports PASS / FAIL / MANUAL per case and exits non-zero on any FAIL.
"Structural" expected.json files (top-level ``has_*`` flags or
``mention_*`` lists) assert properties of the model's prose rather than
exact field values. When the fixtures dir provides an ``assertions.json``
mapping each such key to a predicate (``regex`` / ``contains`` /
``contains_all`` / ``empty`` / ``non_empty`` / ``field_true`` /
``max_length`` run locally; ``judge`` piped to the grader CLI), those
cases are graded automatically.
Any predicate may carry ``"negate": true`` to assert the absence of a
match. A structural case with no ``assertions.json`` falls back to MANUAL
and prints prompts for manual review.
By default, free-text fields (rationale, reason, drop_reason,
blockers, etc.) are graded by piping a short rubric prompt to a
cheap judge model (``claude -p --model haiku`` by default) and
parsing ``{"match": bool, "reason": str}``. Decision fields
(booleans, enums, counts, ordering, IDs) are still compared
exactly. Override the grader command with ``--grader-cli``, or pass
``--exact`` to disable grading and require verbatim equality on
every field. The set of prose fields defaults to a built-in list
and can be overridden per fixtures dir via ``grading-schema.json``.
No caching: every prose field is sent to the grader on every run.
Usage:
# Print prompts for all cases under a fixtures directory
uv run --project tools/skill-evals skill-eval \\
evals/security-issue-import/step-2a-semantic-sweep/fixtures/
# Print prompt for a single case
uv run --project tools/skill-evals skill-eval \\
evals/security-issue-import/step-2a-semantic-sweep/fixtures/case-1-clear-duplicate
# Automated comparison against a CLI. Decision fields are graded
# exact; prose fields go to the default grader (claude -p --model haiku).
uv run --project tools/skill-evals skill-eval --cli "claude -p" \\
evals/security-issue-import/step-2a-semantic-sweep/fixtures/
# Override the grader, e.g. to use a different cheap model.
uv run --project tools/skill-evals skill-eval \\
--cli "claude -p" \\
--grader-cli "llm -m gpt-4o-mini" \\
evals/security-issue-import/step-2a-semantic-sweep/fixtures/
# Disable the grader and require verbatim JSON equality on every field.
uv run --project tools/skill-evals skill-eval --cli "claude -p" --exact \\
evals/security-issue-import/step-2a-semantic-sweep/fixtures/
"""
from __future__ import annotations
import argparse
import json
import re
import shlex
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Prompt construction
# ---------------------------------------------------------------------------
# Available slots: {corpus}, {roster}, {report}.
# Literal braces in a custom user-prompt-template.md that are NOT slots
# must be doubled ({{ and }}) so Python's str.format() leaves them intact.
USER_PROMPT_TEMPLATE = """\
## Existing open trackers (corpus)
{corpus}
## Reporter roster (existing trackers mapped to reporter email)
{roster}
## Incoming report
{report}
Apply the semantic sweep and reporter-identity check. Return JSON only.
"""
def build_corpus_text(corpus: list[dict]) -> str:
lines = []
for item in corpus:
lines.append(f"#{item['number']} | {item['title']!r}")
lines.append(f"Body: {item['body']}")
lines.append("")
return "\n".join(lines)
def build_roster_text(roster: dict[str, str]) -> str:
if not roster:
return "(none)"
return "\n".join(f"#{num}: {email}" for num, email in roster.items())
def find_repo_root(start: Path) -> Path:
"""Walk up the directory tree until a .git directory is found."""
p = start.resolve()
while p != p.parent:
if (p / ".git").exists():
return p
p = p.parent
raise RuntimeError(f"Could not find repo root (.git) from {start}")
def extract_skill_section(skill_md_path: Path, heading: str) -> str:
"""Return the section of a SKILL.md that begins with *heading*.
Extraction ends at the next heading of the same or higher level, or at
the end of the file. Raises ValueError if the heading is not found.
"""
text = skill_md_path.read_text()
lines = text.split("\n")
heading_stripped = heading.rstrip()
m = re.match(r"^(#{1,6}) ", heading_stripped)
if not m:
raise ValueError(f"Heading {heading!r} does not look like a Markdown heading")
heading_level = len(m.group(1))
start = next(
(i for i, line in enumerate(lines) if line.rstrip() == heading_stripped),
None,
)
if start is None:
raise ValueError(f"Heading {heading!r} not found in {skill_md_path}")
end = len(lines)
in_fence = False
for i in range(start + 1, len(lines)):
stripped = lines[i].lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
if in_fence:
continue
hm = re.match(r"^(#{1,6}) ", lines[i])
if hm and len(hm.group(1)) <= heading_level:
end = i
break
return "\n".join(lines[start:end]).rstrip()
def load_step_config(fixtures_dir: Path) -> tuple[str, str]:
"""Return (system_prompt, user_prompt_template) for the given fixtures dir.
Resolution order:
1. ``step-config.json`` — extracts the step section live from the skill's
SKILL.md, then appends ``output-spec.md`` if present. This is the
preferred path: tests automatically exercise the current skill text.
2. ``system-prompt.md`` — a manually maintained prompt used by triage steps.
Raises FileNotFoundError if neither file is present.
"""
user_tmpl_path = fixtures_dir / "user-prompt-template.md"
user_prompt_template = user_tmpl_path.read_text() if user_tmpl_path.exists() else USER_PROMPT_TEMPLATE
# 1. step-config.json → live extraction from SKILL.md
config_path = fixtures_dir / "step-config.json"
if config_path.exists():
config = json.loads(config_path.read_text())
repo_root = find_repo_root(fixtures_dir)
skill_md_path = repo_root / config["skill_md"]
section = extract_skill_section(skill_md_path, config["step_heading"])
output_spec_path = fixtures_dir / "output-spec.md"
if output_spec_path.exists():
section += "\n\n" + output_spec_path.read_text()
return section, user_prompt_template
# 2. system-prompt.md → manually maintained (triage steps)
sys_prompt_path = fixtures_dir / "system-prompt.md"
if sys_prompt_path.exists():
return sys_prompt_path.read_text(), user_prompt_template
raise FileNotFoundError(
f"{fixtures_dir} has neither step-config.json nor system-prompt.md. "
"Add a step-config.json pointing at the relevant SKILL.md section."
)
# ---------------------------------------------------------------------------
# Case loading
# ---------------------------------------------------------------------------
def load_case(case_dir: Path) -> tuple[list[dict], dict, str, dict]:
"""Return (corpus, roster, report_text, expected).
``corpus.json`` and ``reporter-roster.json`` are optional — steps that
do not need them simply omit them and get an empty list / dict. Each may
live either in the case directory (per-case fixtures) or in the shared
fixtures directory; the case-level file takes precedence when both exist.
"""
fixtures_dir = case_dir.parent
def _resolve(name: str) -> Path:
case_level = case_dir / name
return case_level if case_level.exists() else fixtures_dir / name
corpus_path = _resolve("corpus.json")
roster_path = _resolve("reporter-roster.json")
corpus = json.loads(corpus_path.read_text()) if corpus_path.exists() else []
roster = json.loads(roster_path.read_text()) if roster_path.exists() else {}
report = (case_dir / "report.md").read_text()
expected = json.loads((case_dir / "expected.json").read_text())
return corpus, roster, report, expected
def load_case_tags(case_dir: Path) -> set[str]:
"""Return optional runner-selection tags for a case.
Tags live in ``case-meta.json`` so expected.json stays focused on the
behavioral assertion. Unknown metadata keys are ignored.
"""
meta_path = case_dir / "case-meta.json"
if not meta_path.exists():
return set()
meta = json.loads(meta_path.read_text())
tags = meta.get("tags", [])
if not isinstance(tags, list) or not all(isinstance(tag, str) for tag in tags):
raise ValueError(f"{meta_path} must contain a string-list 'tags' field")
return set(tags)
# ---------------------------------------------------------------------------
# Automated comparison (--cli mode)
# ---------------------------------------------------------------------------
def is_structural_expected(expected: dict) -> bool:
"""Return True if expected.json describes prose properties, not exact output.
Composition steps (e.g. compose-comment, compose-verdict) assert structural
properties of the model's prose via boolean ``has_*`` flags and membership
lists like ``mention_handles``. Those cannot be JSON-equality-compared
against a model's actual prose output, so --cli mode falls back to manual
review for them.
"""
if not isinstance(expected, dict):
return False
return any(key.startswith(("has_", "mention_")) for key in expected)
def extract_json_from_output(text: str) -> tuple[object | None, str | None]:
"""Return (parsed_json, error) extracted from a model's stdout.
Tries three strategies in order:
1. The whole output is valid JSON.
2. The output contains a ```json ... ``` fenced block.
3. The output contains a balanced ``{...}`` (or ``[...]``) block — the
longest such block is tried.
Returns ``(value, None)`` on success or ``(None, error_message)`` if no
JSON could be extracted.
"""
stripped = text.strip()
if stripped:
try:
return json.loads(stripped), None
except json.JSONDecodeError:
pass
fence = re.search(r"```(?:json)?\s*\n(.*?)\n```", text, re.DOTALL)
if fence:
try:
return json.loads(fence.group(1).strip()), None
except json.JSONDecodeError:
pass
candidate = _find_largest_brace_block(text)
if candidate is not None:
try:
return json.loads(candidate), None
except json.JSONDecodeError:
pass
return None, "no JSON object or array found in model output"
def _find_largest_brace_block(text: str) -> str | None:
"""Return the largest balanced ``{...}`` or ``[...]`` substring, or None."""
best: str | None = None
for opener, closer in (("{", "}"), ("[", "]")):
depth = 0
start = -1
for i, ch in enumerate(text):
if ch == opener:
if depth == 0:
start = i
depth += 1
elif ch == closer and depth > 0:
depth -= 1
if depth == 0 and start >= 0:
candidate = text[start : i + 1]
if best is None or len(candidate) > len(best):
best = candidate
start = -1
return best
def compare_outputs(actual: object, expected: object) -> tuple[bool, str]:
"""Return (passed, diff_text). Diff is empty when passed=True."""
if actual == expected:
return True, ""
return False, _format_diff(actual, expected)
# ---------------------------------------------------------------------------
# Field-aware grading (--grader-cli mode)
# ---------------------------------------------------------------------------
# Default grader shell command. Used when --cli is set and --exact is not.
# Haiku is the cheapest Claude model; the rubric is small so cost is minimal.
DEFAULT_GRADER_CLI: str = "claude -p --model haiku"
# Keys whose values are treated as prose by default. The runner sends these
# to the grader CLI for a soft "does the candidate support the same
# conclusion?" judgement instead of requiring verbatim string equality.
# A per-fixtures-dir ``grading-schema.json`` can replace this list.
DEFAULT_PROSE_FIELDS: frozenset[str] = frozenset(
{
"rationale",
"reason",
"reasons",
"drop_reason",
"blockers",
"notes",
"summary",
"explanation",
"details",
"description",
}
)
GRADER_RUBRIC = """\
You are grading one field of a model's structured answer against a reference answer.
Field path: {field_path}
Expected value:
{expected_value}
Candidate value:
{candidate_value}
Does the candidate value support the same conclusion as the expected value? Ignore wording differences and reorderings. Reply with one line of JSON only, no prose: {{"match": true, "reason": "<one-line explanation>"}} or {{"match": false, "reason": "<one-line explanation>"}}.
"""
BATCH_GRADER_RUBRIC = """\
You are grading a model's structured answer against a reference answer, field by field.
For each (Field, Expected, Candidate) triple below, decide whether the candidate value supports the same conclusion as the expected value. Ignore wording differences and reorderings.
{fields_block}
Reply with one line of JSON only, no prose. The JSON is an object mapping each field path string to {{"match": true|false, "reason": "<one-line explanation>"}}. Include every field listed above. Example:
{{"$.foo": {{"match": true, "reason": "same conclusion"}}, "$.bar": {{"match": false, "reason": "different verdict"}}}}
"""
def load_grading_schema(fixtures_dir: Path) -> set[str]:
"""Return the set of prose field names for cases in this fixtures dir.
Reads ``fixtures_dir/grading-schema.json`` when present. The file may
set ``prose_fields`` to a string list that *replaces* the default set
(use ``["rationale", "reason", ...]`` to be explicit, or ``[]`` to
grade everything by exact match).
Falls back to :data:`DEFAULT_PROSE_FIELDS` when no schema file exists.
"""
path = fixtures_dir / "grading-schema.json"
if not path.exists():
return set(DEFAULT_PROSE_FIELDS)
data = json.loads(path.read_text())
fields = data.get("prose_fields")
if fields is None:
return set(DEFAULT_PROSE_FIELDS)
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
raise ValueError(f"{path} must contain a string-list 'prose_fields' field")
return set(fields)
def _render_field_value(value: object) -> str:
"""Render an expected/candidate field value for the grader prompt."""
if isinstance(value, str):
return value
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
def grade_prose_field(
field_path: str,
expected_value: object,
actual_value: object,
grader_cli: str,
timeout: int,
) -> tuple[bool, str]:
"""Ask the grader CLI whether the candidate value supports the same conclusion.
Returns ``(match, note)``. ``note`` is empty on match and a one-line
summary on mismatch (or grader failure).
"""
if expected_value == actual_value:
return True, ""
prompt = GRADER_RUBRIC.format(
field_path=field_path,
expected_value=_render_field_value(expected_value),
candidate_value=_render_field_value(actual_value),
)
try:
stdout, stderr, rc = run_cli(grader_cli, prompt, timeout=timeout)
except subprocess.TimeoutExpired:
return False, f"{field_path}: grader CLI timed out after {timeout}s"
except OSError as exc:
return False, f"{field_path}: grader CLI invocation failed ({exc})"
if rc != 0:
return False, f"{field_path}: grader CLI exited {rc} ({stderr.strip()[:200]})"
verdict, err = extract_json_from_output(stdout)
if err is not None or not isinstance(verdict, dict) or "match" not in verdict:
return False, f"{field_path}: grader returned unusable output ({err or 'missing match key'})"
match = bool(verdict.get("match"))
reason = str(verdict.get("reason", "")).strip()
if match:
return True, ""
return False, f"{field_path}: grader says NO ({reason or 'no reason given'})"
def collect_diffs(
actual: object,
expected: object,
*,
prose_fields: set[str],
path: str = "$",
) -> tuple[list[str], list[tuple[str, object, object]]]:
"""Walk both trees in parallel; return (decision_msgs, prose_pairs).
``decision_msgs`` lists structural/decision-field mismatches (type, key
set, list length, scalar inequality on non-prose keys). These cannot
be resolved by the grader. ``prose_pairs`` lists
``(field_path, expected_value, actual_value)`` for prose-keyed
mismatches that the grader should judge. Equal values are omitted from
both lists.
"""
if type(actual) is not type(expected):
return [
f"{path}: type mismatch (actual={type(actual).__name__}, expected={type(expected).__name__})"
], []
if isinstance(expected, dict):
actual_dict = actual # type: ignore[assignment]
# Only assert on the intersection of keys. Keys in expected that the
# model didn't emit are skipped (not failed), and keys in actual that
# expected doesn't declare are ignored. expected.json describes what
# the model's answer SHOULD say where it speaks, not what it must
# include.
decision_msgs: list[str] = []
prose_pairs: list[tuple[str, object, object]] = []
for key in expected:
if key not in actual_dict:
continue
child_path = f"{path}.{key}" if path else key
if key in prose_fields:
if expected[key] != actual_dict[key]:
prose_pairs.append((child_path, expected[key], actual_dict[key]))
else:
sub_d, sub_p = collect_diffs(
actual_dict[key],
expected[key],
prose_fields=prose_fields,
path=child_path,
)
decision_msgs.extend(sub_d)
prose_pairs.extend(sub_p)
return decision_msgs, prose_pairs
if isinstance(expected, list):
actual_list = actual # type: ignore[assignment]
if len(actual_list) != len(expected):
return [f"{path}: length mismatch (actual={len(actual_list)}, expected={len(expected)})"], []
decision_msgs = []
prose_pairs = []
for i, (a_item, e_item) in enumerate(zip(actual_list, expected, strict=False)):
sub_d, sub_p = collect_diffs(
a_item,
e_item,
prose_fields=prose_fields,
path=f"{path}[{i}]",
)
decision_msgs.extend(sub_d)
prose_pairs.extend(sub_p)
return decision_msgs, prose_pairs
if actual == expected:
return [], []
# Non-prose scalar strings: treat case and surrounding/collapsed whitespace
# as insignificant. A weaker model that reaches the right verdict but writes
# it as "invalid" / "request_changes" should not fail on casing alone. A
# genuinely different value still differs after normalisation, so this never
# masks a wrong verdict.
if isinstance(actual, str) and isinstance(expected, str):
norm_actual = " ".join(actual.split()).casefold()
norm_expected = " ".join(expected.split()).casefold()
if norm_actual == norm_expected:
return [], []
return [f"{path}: expected={expected!r}, actual={actual!r}"], []
def _format_batch_fields_block(pairs: list[tuple[str, object, object]]) -> str:
chunks = []
for path, expected, actual in pairs:
chunks.append(
f"Field: {path}\nExpected:\n{_render_field_value(expected)}\nCandidate:\n{_render_field_value(actual)}"
)
return "\n\n".join(chunks)
def batch_grade_prose_fields(
pairs: list[tuple[str, object, object]],
grader_cli: str,
timeout: int,
) -> dict[str, tuple[bool, str]]:
"""Send one rubric prompt covering every pair; return path -> (match, note).
Returns an empty dict when ``pairs`` is empty (no grader call). On grader
failure (timeout, OSError, non-zero exit, unparsable output, missing
path in the verdict), every pair without a clean verdict is returned as
``(False, <one-line explanation>)``.
"""
if not pairs:
return {}
prompt = BATCH_GRADER_RUBRIC.format(fields_block=_format_batch_fields_block(pairs))
try:
stdout, stderr, rc = run_cli(grader_cli, prompt, timeout=timeout)
except subprocess.TimeoutExpired:
return {p: (False, f"grader CLI timed out after {timeout}s") for p, _, _ in pairs}
except OSError as exc:
return {p: (False, f"grader CLI invocation failed ({exc})") for p, _, _ in pairs}
if rc != 0:
return {p: (False, f"grader CLI exited {rc} ({stderr.strip()[:200]})") for p, _, _ in pairs}
verdict, err = extract_json_from_output(stdout)
if err is not None or not isinstance(verdict, dict):
return {p: (False, f"grader returned unusable output ({err or 'not a dict'})") for p, _, _ in pairs}
result: dict[str, tuple[bool, str]] = {}
for path, _, _ in pairs:
entry = verdict.get(path)
if not isinstance(entry, dict) or "match" not in entry:
result[path] = (False, f"grader did not return a verdict for {path}")
continue
match = bool(entry.get("match"))
reason = str(entry.get("reason", "")).strip()
if match:
result[path] = (True, "")
else:
result[path] = (False, f"grader says NO ({reason or 'no reason given'})")
return result
def compare_with_grader(
actual: object,
expected: object,
*,
prose_fields: set[str],
grader_cli: str,
timeout: int,
) -> tuple[bool, list[str]]:
"""Field-aware comparison: decision keys exact, prose keys judged by grader.
Walks both trees once to separate decision-field diffs from prose-field
diffs, then makes a single batched grader call covering every prose
mismatch. If decision fields already fail the comparison, the grader
is skipped entirely (one fewer CLI call per failing case).
Returns ``(ok, messages)``; ``messages`` is empty when ok and otherwise
lists one note per failing field.
"""
decision_msgs, prose_pairs = collect_diffs(actual, expected, prose_fields=prose_fields)
if decision_msgs:
# Case already fails on a decision field; no need to call the grader.
return False, decision_msgs
if not prose_pairs:
return True, []
grades = batch_grade_prose_fields(prose_pairs, grader_cli, timeout)
ok = True
msgs: list[str] = []
for path, _, _ in prose_pairs:
match, note = grades.get(path, (False, f"{path}: no verdict returned by grader"))
if not match:
ok = False
# `note` from batch_grade_prose_fields is already field-attributed
# for missing entries; for grader verdicts it isn't, so prepend the
# path for clarity in the output.
if note.startswith(path):
msgs.append(note)
else:
msgs.append(f"{path}: {note}")
return ok, msgs
# ---------------------------------------------------------------------------
# Structural assertions (has_* / mention_* keys)
# ---------------------------------------------------------------------------
# Structural expected.json files assert *properties of the model's prose*
# (does the announce body contain the Download Page link? did the model flag
# the injection?) rather than exact field values. Each such property is named
# by a has_* / mention_* key and is evaluated by a predicate declared in the
# fixtures dir's assertions.json. Deterministic predicate types run locally —
# fast, free, and flake-free, which is exactly what you want for links,
# headers, and security properties. The judge type pipes a yes/no rubric to
# the grader CLI for the genuinely semantic properties that regex can't pin
# down.
_DETERMINISTIC_ASSERTION_TYPES: frozenset[str] = frozenset(
{"regex", "contains", "contains_all", "empty", "non_empty", "field_true", "max_length"}
)
_VALID_ASSERTION_TYPES: frozenset[str] = _DETERMINISTIC_ASSERTION_TYPES | {"judge"}
def load_assertions(fixtures_dir: Path) -> dict[str, dict]:
"""Return the structural-assertion specs for cases in this fixtures dir.
Reads ``fixtures_dir/assertions.json`` when present: an object mapping
each ``has_*`` / ``mention_*`` key to a predicate spec. Returns an empty
dict when the file is absent — the runner then falls back to MANUAL for
structural cases, preserving the prior behaviour.
Raises ValueError if the file is malformed or names an unknown predicate
type, so a typo fails loudly rather than silently skipping a check.
"""
path = fixtures_dir / "assertions.json"
if not path.exists():
return {}
data = json.loads(path.read_text())
if not isinstance(data, dict):
raise ValueError(f"{path} must be a JSON object mapping assertion keys to specs")
for key, spec in data.items():
if not isinstance(spec, dict):
raise ValueError(f"{path}: assertion {key!r} must be an object")
atype = spec.get("type")
if atype not in _VALID_ASSERTION_TYPES:
raise ValueError(
f"{path}: assertion {key!r} has invalid type {atype!r}; "
f"valid types: {sorted(_VALID_ASSERTION_TYPES)}"
)
return data
def _resolve_field(actual: object, field: str) -> tuple[object, bool]:
"""Return ``(value, present)`` for a dotted ``field`` path into ``actual``."""
cur = actual
for part in field.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None, False
return cur, True
def _compile_flags(spec: dict) -> int:
flags = 0
mapping = {"i": re.IGNORECASE, "s": re.DOTALL, "m": re.MULTILINE}
for ch in str(spec.get("flags", "")):
flags |= mapping.get(ch, 0)
return flags
def evaluate_deterministic_assertion(spec: dict, actual: object) -> tuple[bool | None, str]:
"""Evaluate a non-judge assertion. Return ``(holds, note)``.
``holds`` is True/False for whether the asserted property is present in
the model output, or None on a spec/usage error (which the caller reports
as a failure). ``note`` is a short explanation, empty on a clean result.
Missing-field semantics: ``empty`` treats an absent field as empty (True);
``non_empty`` / ``field_true`` / the text predicates treat an absent field
as not satisfied (False).
A ``"negate": true`` key inverts the result, so a predicate can assert the
*absence* of a pattern (e.g. ``has_no_passphrase_arg`` checks that the
``--passphrase`` token does not appear). Negation is applied only to a
concrete True/False result; a spec/usage error (None) is passed through
unchanged so the typo still fails loudly.
"""
holds, note = _evaluate_deterministic_assertion_raw(spec, actual)
if spec.get("negate") and holds is not None:
holds = not holds
return holds, note
def _evaluate_deterministic_assertion_raw(spec: dict, actual: object) -> tuple[bool | None, str]:
"""Evaluate the predicate before any ``negate`` inversion is applied."""
atype = spec["type"]
field = spec.get("field")
if field is None:
return None, f"type {atype!r} requires a 'field'"
value, present = _resolve_field(actual, field)
if atype == "empty":
return (not present or value in ([], "", None, {})), ""
if atype == "non_empty":
return (present and value not in ([], "", None, {})), ""
if atype == "field_true":
return (present and value is True), ""
if atype == "max_length":
limit = spec.get("max")
if not isinstance(limit, int) or isinstance(limit, bool):
return None, "type 'max_length' requires an integer 'max'"
if not present:
return False, f"field {field!r} not present in output"
try:
length = len(value) # type: ignore[arg-type]
except TypeError:
return None, f"field {field!r} value has no length"
return (length <= limit), ("" if length <= limit else f"len={length} > max={limit}")
# Text predicates need a string. Non-string values are JSON-serialised so
# a list/number field can still be substring/regex-matched if a spec asks.
if not present:
return False, f"field {field!r} not present in output"
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
ci = "i" in str(spec.get("flags", ""))
if atype == "regex":
pattern = spec.get("pattern")
if pattern is None:
return None, "type 'regex' requires a 'pattern'"
return (re.search(pattern, text, _compile_flags(spec)) is not None), ""
if atype == "contains":
sub = spec.get("substring")
if sub is None:
return None, "type 'contains' requires a 'substring'"
hay = text.lower() if ci else text
needle = sub.lower() if ci else sub
return (needle in hay), ""
if atype == "contains_all":
subs = spec.get("substrings")
if not isinstance(subs, list) or not subs:
return None, "type 'contains_all' requires a non-empty 'substrings' list"
hay = text.lower() if ci else text
missing = [s for s in subs if (s.lower() if ci else s) not in hay]
return (not missing), (f"missing: {missing}" if missing else "")
return None, f"unhandled assertion type {atype!r}"
JUDGE_ASSERTION_RUBRIC = """\
You are checking whether a model's output satisfies specific named properties.
Model output (JSON):
{output}
For each property below, decide strictly from the output whether the property holds.
{props_block}
Reply with one line of JSON only, no prose: an object mapping each property key to {{"holds": true|false, "reason": "<one-line explanation>"}}. Include every property key listed above. Example:
{{"has_foo": {{"holds": true, "reason": "output states X"}}, "mention_bar": {{"holds": false, "reason": "not mentioned"}}}}
"""
def _format_judge_props_block(specs: dict[str, dict]) -> str:
chunks = []
for key, spec in specs.items():
rubric = spec.get("rubric", "")
field = spec.get("field")
scope = f" (focus on the {field!r} field)" if field else ""
chunks.append(f"Property: {key}{scope}\nHolds when: {rubric}")
return "\n\n".join(chunks)
def batch_judge_assertions(
specs: dict[str, dict],
actual: object,
grader_cli: str,
timeout: int,
) -> dict[str, tuple[bool | None, str]]:
"""Send one rubric covering every judge assertion; return key -> (holds, note).
Empty ``specs`` makes no grader call. On any grader failure (timeout,
OSError, non-zero exit, unparsable output, missing key in the verdict),
the affected keys are returned with ``holds=None`` so the caller fails the
assertion rather than silently passing it — important for the security
cases this is used on.
"""
if not specs:
return {}
prompt = JUDGE_ASSERTION_RUBRIC.format(
output=json.dumps(actual, indent=2, ensure_ascii=False, sort_keys=True),
props_block=_format_judge_props_block(specs),
)
try:
stdout, stderr, rc = run_cli(grader_cli, prompt, timeout=timeout)
except subprocess.TimeoutExpired:
return dict.fromkeys(specs, (None, f"grader CLI timed out after {timeout}s"))
except OSError as exc:
return dict.fromkeys(specs, (None, f"grader CLI invocation failed ({exc})"))
if rc != 0:
return dict.fromkeys(specs, (None, f"grader CLI exited {rc} ({stderr.strip()[:200]})"))
verdict, err = extract_json_from_output(stdout)
if err is not None or not isinstance(verdict, dict):
return dict.fromkeys(specs, (None, f"grader returned unusable output ({err or 'not a dict'})"))
result: dict[str, tuple[bool | None, str]] = {}
for key in specs:
entry = verdict.get(key)
if not isinstance(entry, dict) or "holds" not in entry:
result[key] = (None, f"grader did not return a verdict for {key}")
continue
result[key] = (bool(entry.get("holds")), str(entry.get("reason", "")).strip())
return result
def compare_structural(
actual: object,
expected: dict,
assertions: dict[str, dict],
*,
prose_fields: set[str],
grader_cli: str,
exact: bool,
grader_timeout: int,
) -> tuple[bool, list[str]]:
"""Grade a structural expected.json (``has_*`` / ``mention_*`` keys).
Structural keys are evaluated by their ``assertions.json`` predicates;
deterministic ones run locally and judge ones go to the grader in a single
batched call. Any remaining (non-structural) keys are compared with the
standard field-aware comparator — exact for decision fields, grader for
prose, or pure exact when ``exact`` is set. Returns ``(ok, notes)`` with
one note per failing field.
"""
structural = {k: v for k, v in expected.items() if k.startswith(("has_", "mention_"))}
remainder = {k: v for k, v in expected.items() if k not in structural}
ok = True
notes: list[str] = []
if remainder:
sub_ok, sub_notes = compare_with_grader(
actual,
remainder,
prose_fields=set() if exact else prose_fields,
grader_cli=grader_cli,
timeout=grader_timeout,
)
if not sub_ok:
ok = False
notes.extend(sub_notes)
judge_specs: dict[str, dict] = {}
judge_expected: dict[str, bool] = {}
for key, exp_val in structural.items():
spec = assertions.get(key)
if spec is None:
ok = False
notes.append(f"{key}: no assertion defined in assertions.json")
continue
if spec["type"] == "judge":
judge_specs[key] = spec
judge_expected[key] = bool(exp_val)
continue
holds, note = evaluate_deterministic_assertion(spec, actual)
if holds is None:
ok = False
notes.append(f"{key}: {note}")
elif holds != bool(exp_val):
detail = f" ({note})" if note else ""
notes.append(f"{key}: property={holds}, expected {bool(exp_val)}{detail}")
ok = False
if judge_specs:
grades = batch_judge_assertions(judge_specs, actual, grader_cli, grader_timeout)
for key in judge_specs:
holds, note = grades.get(key, (None, "no verdict returned by grader"))
if holds is None:
ok = False
notes.append(f"{key}: {note}")
elif holds != judge_expected[key]:
detail = f" ({note})" if note else ""
notes.append(f"{key}: judge says property={holds}, expected {judge_expected[key]}{detail}")
ok = False
return ok, notes
def _format_diff(actual: object, expected: object) -> str:
actual_text = json.dumps(actual, indent=2, sort_keys=True)
expected_text = json.dumps(expected, indent=2, sort_keys=True)
a_lines = actual_text.splitlines()
e_lines = expected_text.splitlines()
lines = ["--- expected", "+++ actual"]
for line in e_lines:
if line not in a_lines:
lines.append(f"- {line}")
for line in a_lines:
if line not in e_lines:
lines.append(f"+ {line}")
return "\n".join(lines)
def run_cli(cli: str, prompt: str, timeout: int = 120) -> tuple[str, str, int]:
"""Run ``cli`` with ``prompt`` on stdin. Return (stdout, stderr, rc).
The command string is tokenised with ``shlex.split`` and executed with
``shell=False``. The operator supplies the command, so trust is not the
issue — using an argv list rather than a shell string keeps prompt content
(which can be attacker-controlled when an eval exercises an injection
case) firmly on stdin and well away from any shell interpretation, and
removes a class of accidental-metacharacter footgun in the operator's
command. Operators who need shell features (pipes, redirections, env-var
prefixes) should wrap their command in ``bash -c '<pipeline>'``.
"""
proc = subprocess.run(
shlex.split(cli),
input=prompt,
capture_output=True,
text=True,
shell=False,
timeout=timeout,
check=False,
)
return proc.stdout, proc.stderr, proc.returncode
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def find_cases(path: Path) -> list[tuple[Path, Path]]:
"""Return (case_dir, fixtures_dir) pairs under path.
Handles three levels of granularity:
- single case dir (contains report.md)
- fixtures dir (contains case-* subdirs)
- skill/step dir (contains fixtures/ subdirs recursively)
"""
if (path / "report.md").exists():
return [(path, path.parent)]
# Direct fixtures dir — all cases share the same fixtures dir.
direct = sorted(p for p in path.iterdir() if p.is_dir() and (p / "report.md").exists())
if direct:
return [(p, path) for p in direct]
# Recursive search — e.g. skill dir spanning multiple steps.
# De-duplicate: skip any fixtures/ that is itself nested under another
# fixtures/ already in the set (guards against accidental double-counting
# if someone copies a case sub-tree that contains its own fixtures/).
results = []
seen_fixtures: set[Path] = set()
for fixtures_dir in sorted(path.rglob("fixtures")):
if not fixtures_dir.is_dir():
continue
if any(fixtures_dir.is_relative_to(f) for f in seen_fixtures):
continue
seen_fixtures.add(fixtures_dir)
for case_dir in sorted(fixtures_dir.iterdir()):
if case_dir.is_dir() and (case_dir / "report.md").exists():
results.append((case_dir, fixtures_dir))
return results
def collect_tag_counts(cases: list[tuple[Path, Path]]) -> dict[str, int]:
"""Return how many discovered cases carry each tag."""
counts: dict[str, int] = {}
for case_dir, _fixtures_dir in cases:
for tag in load_case_tags(case_dir):
counts[tag] = counts.get(tag, 0) + 1
return counts
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Run skill eval cases. Default mode prints prompts for manual review. "
"--cli mode pipes prompts through a shell command and auto-compares "
"against expected.json."
)
)
parser.add_argument(
"path",
type=Path,
help="Path to a single case directory or a fixtures directory containing multiple cases.",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress prompt content; print only case names and expected JSON.",
)
parser.add_argument(
"--cli",
type=str,
default=None,
help=(
"Shell command that reads a prompt on stdin and writes the model "
"response to stdout (e.g. 'claude -p'). When set, the runner sends "
"<system_prompt>\\n\\n<user_prompt> to the command for each case, "
"extracts JSON from stdout, and compares against expected.json. "
"Exits non-zero on any FAIL."
),
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Timeout in seconds for each --cli invocation (default: 120).",
)
parser.add_argument(
"--grader-cli",
type=str,
default=DEFAULT_GRADER_CLI,
help=(
"Shell command for a cheap judge model that grades free-text "
"fields (rationale, reason, drop_reason, blockers, etc.). "
"Prose fields are compared via a rubric prompt instead of "
"exact equality; decision fields stay on exact compare. The set "
"of prose fields is the runner's default plus any per-fixtures "
"grading-schema.json overrides. Requires --cli. Default: "
f"'{DEFAULT_GRADER_CLI}'. Pass --exact to disable grading."
),
)
parser.add_argument(
"--exact",
action="store_true",
help=(
"Disable the field-aware grader and require verbatim JSON "
"equality on every field (the runner's pre-grader behaviour)."
),
)
parser.add_argument(
"--grader-timeout",
type=int,
default=60,
help="Timeout in seconds for each --grader-cli invocation (default: 60).",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="In --cli mode, also print the prompts and the model's raw stdout per case.",
)
parser.add_argument(
"--fail-fast",
action="store_true",
help=("Stops on the first failure instead of running all cases. Only applies in --cli mode; "),
)
parser.add_argument(
"--tag",
action="append",
default=[],
help=(
"Run only cases tagged in case-meta.json. May be passed multiple "
"times; a case is included if it has all requested tags."
),
)
parser.add_argument(
"--list-tags",
action="store_true",
help=(
"Print every distinct tag declared in case-meta.json under path, "
"with the number of cases carrying each tag, and exit without "
"running prompts."
),
)
args = parser.parse_args(argv)
grader_explicit = args.grader_cli != DEFAULT_GRADER_CLI
if args.cli is None and (grader_explicit or args.exact):
parser.error("--grader-cli and --exact require --cli")
cases = find_cases(args.path)
if args.list_tags:
tag_counts = collect_tag_counts(cases)
if not tag_counts:
print("no tags found")
return 0
for tag in sorted(tag_counts):
print(f"{tag} {tag_counts[tag]}")
return 0
if args.tag:
requested_tags = set(args.tag)
cases = [
(case_dir, fixtures_dir)
for case_dir, fixtures_dir in cases
if requested_tags.issubset(load_case_tags(case_dir))
]
if not cases:
tag_suffix = f" matching tag(s): {', '.join(args.tag)}" if args.tag else ""
print(f"No eval cases found under {args.path}{tag_suffix}", file=sys.stderr)
return 1
# Cache loaded step configs so we don't re-read prompts for every case in
# the same fixtures dir (common when running a whole skill at once).
_step_config_cache: dict[Path, tuple[str, str]] = {}
# Cache the prose-field schema per fixtures dir (config only, not grader results).
_grading_schema_cache: dict[Path, set[str]] = {}
# Cache the structural-assertion specs per fixtures dir.
_assertions_cache: dict[Path, dict[str, dict]] = {}
passed = failed = manual = errored = 0
for case_dir, fixtures_dir in cases:
if (args.cli is not None) and args.fail_fast and (failed or errored):
print("Fail-fast enabled; stopping on first failure or error.")
break
if fixtures_dir not in _step_config_cache:
_step_config_cache[fixtures_dir] = load_step_config(fixtures_dir)
system_prompt, user_prompt_template = _step_config_cache[fixtures_dir]
corpus, roster, report, expected = load_case(case_dir)
try:
user_prompt = user_prompt_template.format(
corpus=build_corpus_text(corpus),
roster=build_roster_text(roster),
report=report,
)
except (KeyError, ValueError) as exc:
raise type(exc)(
f"user-prompt-template.md in {fixtures_dir} has a format error: {exc}. "
"Available slots: {{corpus}}, {{roster}}, {{report}}. "
"Literal braces that are not slots must be doubled ({{ and }})."
) from exc
step_label = fixtures_dir.parent.name
case_label = f"{step_label}/{case_dir.name}"
if args.cli is None:
# Print mode (existing behaviour)
print(f"{'=' * 60}")
print(f"CASE: {case_label}")
print(f"{'=' * 60}")
if not args.quiet:
print("--- SYSTEM PROMPT ---")
print(system_prompt)
print("--- USER PROMPT ---")
print(user_prompt)
print("--- EXPECTED ---")
print(json.dumps(expected, indent=2))
print()
continue
# --cli mode: run the configured command and auto-compare.
structural = isinstance(expected, dict) and is_structural_expected(expected)
assertions: dict[str, dict] = {}
if structural:
if fixtures_dir not in _assertions_cache:
_assertions_cache[fixtures_dir] = load_assertions(fixtures_dir)
assertions = _assertions_cache[fixtures_dir]
if not assertions:
# No assertions.json: preserve the manual-review fallback.
print(f"MANUAL {case_label} (structural expected.json — review actual output by hand)")
if args.verbose:
_print_prompts_and_run(args, system_prompt, user_prompt)
manual += 1
continue
full_prompt = f"{system_prompt}\n\n{user_prompt}"
try:
stdout, stderr, rc = run_cli(args.cli, full_prompt, timeout=args.timeout)
except subprocess.TimeoutExpired:
print(f"ERROR {case_label} (CLI timed out after {args.timeout}s)")
errored += 1
continue
except OSError as exc:
print(f"ERROR {case_label} (CLI invocation failed: {exc})")
errored += 1
continue
if rc != 0:
if args.exact:
print(f"ERROR {case_label} (CLI exited {rc}; stderr: {stderr.strip()[:200]})")
errored += 1
if args.verbose:
print("--- STDOUT ---")
print(stdout)
continue
# Field-aware mode: a non-zero exit (often a refusal or a CLI
# safety filter) is wrapped just like a no-JSON case. The
# intersection-only comparator decides whether this case still
# passes based on the keys expected.json declares. Wrap is a
# silent implementation detail — the case still reports as
# PASS or FAIL like any other.
actual = {"raw_output": stdout, "stderr": stderr, "exit_code": rc}
else:
actual, parse_err = extract_json_from_output(stdout)
if parse_err is not None:
if args.exact:
# Exact mode requires literal JSON; non-JSON is an error.
print(f"ERROR {case_label} ({parse_err})")
errored += 1
if args.verbose:
print("--- STDOUT ---")
print(stdout)
continue
# Field-aware mode: wrap the prose as a synthetic object so
# the intersection-only comparator can proceed. A model that
# produced prose-only output will PASS unless expected.json
# asserts on `raw_output`.
actual = {"raw_output": stdout}
if structural:
if fixtures_dir not in _grading_schema_cache:
_grading_schema_cache[fixtures_dir] = load_grading_schema(fixtures_dir)
ok, notes = compare_structural(
actual,
expected,
assertions,
prose_fields=_grading_schema_cache[fixtures_dir],
grader_cli=args.grader_cli,
exact=args.exact,
grader_timeout=args.grader_timeout,
)
if ok:
print(f"PASS {case_label}")
passed += 1
else:
print(f"FAIL {case_label}")
for note in notes:
print(f" {note}")
failed += 1
elif not args.exact:
if fixtures_dir not in _grading_schema_cache:
_grading_schema_cache[fixtures_dir] = load_grading_schema(fixtures_dir)
prose_fields = _grading_schema_cache[fixtures_dir]
ok, notes = compare_with_grader(
actual,
expected,
prose_fields=prose_fields,
grader_cli=args.grader_cli,
timeout=args.grader_timeout,
)
if ok:
print(f"PASS {case_label}")
passed += 1
else:
print(f"FAIL {case_label}")
for note in notes:
print(f" {note}")
failed += 1
else:
ok, diff = compare_outputs(actual, expected)
if ok:
print(f"PASS {case_label}")
passed += 1
else:
print(f"FAIL {case_label}")
print(diff)
failed += 1
if args.verbose:
print("--- SYSTEM PROMPT ---")
print(system_prompt)
print("--- USER PROMPT ---")
print(user_prompt)
print("--- STDOUT ---")
print(stdout)
print()
if args.cli is not None:
total = passed + failed + manual + errored
print()
print(f"{'=' * 60}")
print(f"Ran {total} cases: {passed} passed, {failed} failed, {manual} manual, {errored} errored")
print(f"{'=' * 60}")
if failed or errored:
return 1
return 0
def _print_prompts_and_run(args: argparse.Namespace, system_prompt: str, user_prompt: str) -> None:
"""Verbose helper for MANUAL-mode cases: show what would have been sent."""
print("--- SYSTEM PROMPT ---")
print(system_prompt)
print("--- USER PROMPT ---")
print(user_prompt)
if __name__ == "__main__":
sys.exit(main())