core/tests/test_refusal_taxonomy_lane.py
Shay 47c0a03d3b
feat(ADR-0163.B.2): four new exemplar corpora — discrete_count_statement, multiplicative_aggregation, currency_amount, plus temporal_aggregation v2 widening (#306)
Phase B round 2.  Categorizing the post-#304 GSM8K train_sample's
still-refused 47 set surfaced three coherent sub-shapes in the previously
UNCATEGORIZED tail plus five ratified-but-narrowness-blocked temporal
cases; this PR ships the operator-authored exemplar seeds + Phase A
categorizer extension that prove the corridor scales beyond round 1.

Exemplar corpora (70 new exemplars across 4 files):
- discrete_count_statement_v1.jsonl (20)
- multiplicative_aggregation_v1.jsonl (20)
- currency_amount_v1.jsonl (20)
- temporal_aggregation_v2.jsonl (10, widening)

Each corpus carries ≥3 verbatim train-sample citations, ≥12 (≥5 for v2)
novel operator-authored statements, and ≥1–3 edge cases.  Statements are
disjoint across all 7 round-1 + round-2 corpora; tests enforce.

Phase A categorizer (evals/refusal_taxonomy/shape_categories.py)
extends ShapeCategory with three new members and inserts their rule
predicates AFTER the existing more-specific categories:
- rate_with_currency before currency_amount
- multiplicative_aggregation before discrete_count_statement
Each new rule predicate cites ≥3 train_sample case_ids in its docstring
(ADR-0163 §Risks).  No LLM, no embedding, no learned classifier.

Refusal-taxonomy histogram empirical signal (public 50 sample):
- pre-round-2: 14 UNCATEGORIZED (categorized_rate 0.72)
- post-round-2: 1 UNCATEGORIZED (categorized_rate 0.98)

The single residual is case 0044 ("10% simple interest" — percentage
without change verb), an honest tail outside the three round-2 shapes.

wrong=0 holds on capability axes G1..G5 + S1; no runtime code shipped.
Smoke suite green (67/67).

Cross-refs: ADR-0163, #297 (Phase A), #298 (Phase B round 1),
#301 (Phase C), #302 (Phase D), #304 (round-1 ratify), #305 (session
recap).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:36:59 -07:00

337 lines
12 KiB
Python

"""ADR-0163 Phase A — refusal-taxonomy lane tests."""
from __future__ import annotations
import ast
import hashlib
import json
import re
from pathlib import Path
import pytest
from evals.framework import discover_lanes, get_lane, load_cases, run_lane
from evals.refusal_taxonomy.runner import build_report, categorize_cases
from evals.refusal_taxonomy.shape_categories import (
SHAPE_CATEGORY_ORDER,
ShapeCategory,
categorize,
)
from scripts.build_refusal_taxonomy_cases import (
build_cases,
extract_statement,
)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_LANE_ROOT = _REPO_ROOT / "evals" / "refusal_taxonomy"
_CASES_PATH = _LANE_ROOT / "public" / "v1" / "cases.jsonl"
_REPORT_PATH = _LANE_ROOT / "v1" / "report.json"
_GSM8K_REPORT = (
_REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json"
)
_SHAPE_CATEGORIES_PATH = _LANE_ROOT / "shape_categories.py"
# ---------------------------------------------------------------------------
# Case-set integrity
# ---------------------------------------------------------------------------
def test_cases_file_exists_and_nonempty():
assert _CASES_PATH.exists(), f"expected case set at {_CASES_PATH}"
cases = load_cases(_CASES_PATH)
assert len(cases) == 50, "v1 sample should mirror the 50-case GSM8K train sample"
def test_case_schema_valid():
cases = load_cases(_CASES_PATH)
for case in cases:
assert set(case.keys()) >= {"case_id", "statement", "refusal_reason"}
assert isinstance(case["case_id"], str) and case["case_id"].strip()
assert isinstance(case["statement"], str) and case["statement"].strip()
assert isinstance(case["refusal_reason"], str) and case["refusal_reason"].strip()
def test_cases_sorted_by_id():
cases = load_cases(_CASES_PATH)
ids = [c["case_id"] for c in cases]
assert ids == sorted(ids), "cases.jsonl must be deterministically sorted"
# ---------------------------------------------------------------------------
# Lane discovery
# ---------------------------------------------------------------------------
def test_lane_auto_discoverable():
names = [lane.name for lane in discover_lanes()]
assert "refusal_taxonomy" in names
def test_lane_run_via_framework():
lane = get_lane("refusal_taxonomy")
result = run_lane(lane, version="v1", split="public")
assert result.metrics["total"] == 50
# Phase B round 2 extended the categorizer with three new shape
# categories; the post-extension histogram leaves only the residual
# uncategorized tail. The exact rate is asserted against the
# committed report by ``test_committed_report_matches_categorizer``.
assert result.metrics["categorized_rate"] >= 0.95
# ---------------------------------------------------------------------------
# Enum coverage + exhaustiveness
# ---------------------------------------------------------------------------
def test_shape_category_order_covers_enum():
assert set(SHAPE_CATEGORY_ORDER) == set(ShapeCategory)
assert len(SHAPE_CATEGORY_ORDER) == len(ShapeCategory)
def test_every_category_value_reachable_by_a_rule():
# For each non-UNCATEGORIZED category, provide a tiny synthetic
# statement that the categorizer routes to it. If a future change
# eliminates a rule, this test fails loudly.
probes: dict[ShapeCategory, str] = {
ShapeCategory.NESTED_QUESTION_TARGET:
"If Jen has 150 ducks, how many total birds does she have?",
ShapeCategory.UNIT_PARTITION:
"She splits it up into 25-foot sections.",
ShapeCategory.RATE_WITH_CURRENCY: "Tina makes $18.00 an hour.",
ShapeCategory.COMPARATIVE_WITH_UNIT:
"Her grandfather is 7 times her age.",
ShapeCategory.FRACTIONAL_RATE_OF_CHANGE:
"His fish ate half of them.",
ShapeCategory.INDEFINITE_QUANTITY: "There are some kids in camp.",
ShapeCategory.TEMPORAL_AGGREGATION:
"Mark does a gig every other day for 2 weeks.",
ShapeCategory.CONDITIONAL_QUANTITY:
"If she had two more, she would have plenty.",
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY:
"Marnie makes bead bracelets.",
ShapeCategory.CURRENCY_AMOUNT:
"It cost $100,000 to open initially.",
ShapeCategory.MULTIPLICATIVE_AGGREGATION:
"Each survey has 10 questions.",
ShapeCategory.DISCRETE_COUNT_STATEMENT:
"Nicole collected 400 Pokemon cards.",
ShapeCategory.UNCATEGORIZED:
"John invests in a bank and gets 10% simple interest.",
}
for category, probe in probes.items():
assert categorize(probe) is category, (
f"probe for {category.value!r} routed to {categorize(probe).value!r}"
)
def test_added_category_cites_three_examples():
"""Enforce ADR-0163 §Risks: every category cites ≥ 3 refused statements
OR is documented in its docstring as a reserve slot with rationale.
This test parses the shape_categories.py source and verifies that each
rule predicate docstring contains either three "case " citations or the
word "reserve".
"""
source = _SHAPE_CATEGORIES_PATH.read_text()
tree = ast.parse(source)
rule_funcs = [
node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef)
and node.name.startswith("_is_")
]
assert rule_funcs, "expected rule predicates named _is_*"
case_cite_re = re.compile(r"case\s+\d{4}", re.IGNORECASE)
for func in rule_funcs:
doc = ast.get_docstring(func) or ""
cites = len(case_cite_re.findall(doc))
if cites < 3 and "reserve" not in doc.lower():
pytest.fail(
f"{func.name}: needs ≥ 3 case citations or a 'reserve' note "
f"(found {cites} citations)"
)
# ---------------------------------------------------------------------------
# Categorizer determinism + purity
# ---------------------------------------------------------------------------
def test_categorizer_is_deterministic():
cases = load_cases(_CASES_PATH)
runs = [
[categorize(c["statement"]).value for c in cases]
for _ in range(5)
]
assert all(run == runs[0] for run in runs)
def test_categorizer_is_pure_no_io(tmp_path, monkeypatch):
# Sentinel — fail if the categorizer touches the filesystem or os.environ.
def fail_open(*_a, **_kw):
raise AssertionError("categorize() must not perform I/O")
monkeypatch.setattr("builtins.open", fail_open)
monkeypatch.setattr("os.environ", {})
# Drive a handful of probes; any I/O attempt explodes the test.
for statement in (
"Tina makes $18.00 an hour.",
"There are some kids in camp.",
"She splits it up into 25-foot sections.",
"If Jen has 150 ducks, how many total birds does she have?",
):
categorize(statement)
def test_categorizer_rejects_non_string():
with pytest.raises(TypeError):
categorize(None) # type: ignore[arg-type]
def test_categorizer_no_llm_or_ml_imports():
"""Per ADR-0163 §Constraint #4: no LLM call, no embedding, no learned model."""
source = _SHAPE_CATEGORIES_PATH.read_text()
banned = (
"openai", "anthropic", "huggingface", "transformers", "torch",
"tensorflow", "sklearn", "numpy", "sentence_transformers",
"requests", "httpx", "urllib",
)
for token in banned:
assert token not in source.lower(), (
f"shape_categories.py must not reference {token!r}"
"rules-only doctrine"
)
# ---------------------------------------------------------------------------
# Histogram correctness
# ---------------------------------------------------------------------------
def test_histogram_synthetic_fixture():
cases = [
{"case_id": "x1", "statement": "There are some kids in camp.",
"refusal_reason": "r"},
{"case_id": "x2", "statement": "Marnie makes bead bracelets.",
"refusal_reason": "r"},
{"case_id": "x3", "statement": "Tina makes $18.00 an hour.",
"refusal_reason": "r"},
]
report = build_report(cases)
assert report.metrics["total"] == 3
assert report.metrics["by_category"]["indefinite_quantity"] == 1
assert report.metrics["by_category"]["descriptive_setup_no_quantity"] == 1
assert report.metrics["by_category"]["rate_with_currency"] == 1
assert report.metrics["uncategorized"] == 0
assert report.metrics["categorized_rate"] == pytest.approx(1.0)
def test_histogram_includes_all_categories_even_when_zero():
cases = [
{"case_id": "x1", "statement": "Marnie makes bead bracelets.",
"refusal_reason": "r"},
]
report = build_report(cases)
keys = set(report.metrics["by_category"].keys())
assert keys == {c.value for c in SHAPE_CATEGORY_ORDER}
def test_v1_report_uncategorized_under_fifty_percent():
payload = json.loads(_REPORT_PATH.read_text())
rate = payload["metrics"]["categorized_rate"]
assert rate >= 0.5, (
f"<50% categorized signals taxonomy/data mismatch; got {rate:.2%}"
)
# ---------------------------------------------------------------------------
# Replay determinism
# ---------------------------------------------------------------------------
def test_report_replays_byte_identical():
cases = load_cases(_CASES_PATH)
r1 = build_report(cases)
r2 = build_report(cases)
assert r1.metrics == r2.metrics
assert r1.case_details == r2.case_details
def test_committed_report_matches_categorizer():
cases = load_cases(_CASES_PATH)
fresh = build_report(cases)
committed = json.loads(_REPORT_PATH.read_text())
assert fresh.metrics["case_digest"] == committed["metrics"]["case_digest"]
assert fresh.metrics["by_category"] == committed["metrics"]["by_category"]
# ---------------------------------------------------------------------------
# Helper script
# ---------------------------------------------------------------------------
def test_helper_extracts_statement_from_reason():
reason = (
"candidate_graph: no admissible candidate for statement: "
"'Tina makes $18.00 an hour.'"
)
assert extract_statement(reason) == "Tina makes $18.00 an hour."
def test_helper_handles_question_envelope():
reason = (
"candidate_graph: no admissible candidate for question: "
"'If Jen has 150 ducks, how many total birds does she have?'"
)
assert extract_statement(reason) == (
"If Jen has 150 ducks, how many total birds does she have?"
)
def test_helper_rebuilds_cases_matching_committed():
rebuilt = build_cases(_GSM8K_REPORT)
committed = load_cases(_CASES_PATH)
assert rebuilt == committed
# ---------------------------------------------------------------------------
# Read-only invariant
# ---------------------------------------------------------------------------
def _tree_digest(root: Path) -> str:
h = hashlib.sha256()
if not root.exists():
return "absent"
for path in sorted(p for p in root.rglob("*") if p.is_file()):
h.update(str(path.relative_to(root)).encode("utf-8"))
h.update(b"\0")
h.update(path.read_bytes())
h.update(b"\0")
return h.hexdigest()
def test_lane_run_does_not_mutate_protected_trees():
teaching = _REPO_ROOT / "teaching"
packs = _REPO_ROOT / "packs"
lp_data = _REPO_ROOT / "language_packs" / "data"
before = (
_tree_digest(teaching),
_tree_digest(packs),
_tree_digest(lp_data),
)
cases = load_cases(_CASES_PATH)
build_report(cases)
after = (
_tree_digest(teaching),
_tree_digest(packs),
_tree_digest(lp_data),
)
assert before == after, (
"refusal-taxonomy lane must be read-only over teaching/, packs/, "
"and language_packs/data/"
)