core/evals/refusal_taxonomy/runner.py
Shay 5b4dcb17ca
feat(ADR-0163.A): refusal taxonomy lane — shape categorization of GSM8K admissibility gaps (#297)
ADR-0163 Phase A measurement. Reads the GSM8K train-sample refusal report
(50 cases, all refused on candidate-graph admissibility) and emits a
histogram of statement shapes. Read-only: no corpus, pack, or proposal
mutation; the categorizer is rules-only with no LLM, embedding, or
learned model.

Lane: evals/refusal_taxonomy/ (auto-discovered by evals.framework)
  - shape_categories.py — ShapeCategory enum + deterministic categorizer
    (9 ADR-mandated baseline categories + UNCATEGORIZED, first-match-wins)
  - runner.py           — pure run_lane(cases) -> LaneReport
  - contract.md         — purpose, doctrine, schema, ADR compatibility
  - public/v1/cases.jsonl — 50 refused statements (sorted by case_id)
  - v1/report.json        — first run output (categorized_rate=72%)

CLI: core teaching refusal-taxonomy [--input PATH] [--json] [--save]
     Accepts a cases JSONL or a raw GSM8K eval report.json directly.

Helper: scripts/build_refusal_taxonomy_cases.py rebuilds the v1 case set
from the GSM8K train-sample report deterministically.

Tests: tests/test_refusal_taxonomy_lane.py (21 passing) cover schema
integrity, lane auto-discovery, enum exhaustiveness, categorizer
determinism + purity + no-ML-imports, histogram correctness, replay
byte-identity, committed report match, helper extraction, and a
read-only invariant snapshot over teaching/, packs/, language_packs/data/.

v1 histogram (50-case sample):
   17  descriptive_setup_no_quantity
   14  uncategorized
    4  temporal_aggregation
    3  rate_with_currency
    3  fractional_rate_of_change
    3  indefinite_quantity
    3  comparative_with_unit
    2  nested_question_target
    1  unit_partition
    0  conditional_quantity
total=50  categorized_rate=72%  uncategorized=28% (below 50% target)

Top three by count (Phase B candidates):
  1. descriptive_setup_no_quantity (17)
  2. temporal_aggregation (4)
  3. tie at 3 — operator selects from {rate_with_currency,
     fractional_rate_of_change, indefinite_quantity, comparative_with_unit}

Phase B is not started in this PR — the ADR explicitly requires the
operator to ratify the top-N selection before any exemplar corpus is
authored.

Invariants verified:
  - tests/test_adr_0131_*.py: 224 passed, 0 wrong on G1..G5 + S1
  - core test --suite smoke -q: 67 passed
  - The refusal_taxonomy/__init__.py and runner do not import openai,
    anthropic, transformers, torch, sklearn, sentence_transformers,
    requests, or httpx — verified by test_categorizer_no_llm_or_ml_imports.

Cross-references: ADR-0163 (parent), ADR-0114a (capability obligations),
ADR-0149 (recognizer pipeline substrate that Phases C–E build on).

Refs: [[thesis-decoding-not-generating]] — the rules-only categorizer
honors the doctrine: the engine learns to find better shapes; this PR
does not stuff it with another found pattern.
2026-05-26 11:27:11 -07:00

141 lines
4.2 KiB
Python

"""ADR-0163 Phase A — refusal-taxonomy lane runner.
Read-only. The lane categorises refused statements by *statement shape*
and emits a histogram. It never mutates corpora, packs, language packs,
proposals, or engine state.
Per ADR-0163 §Constraint #4 and CLAUDE.md, the categorizer is rules-only:
no LLM call, no embedding, no learned classifier, no normalization beyond
lowercasing for substring matching.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any
from evals.refusal_taxonomy.shape_categories import (
SHAPE_CATEGORY_ORDER,
ShapeCategory,
categorize,
)
@dataclass(frozen=True, slots=True)
class CategorizedCase:
"""One refused case decorated with its shape category."""
case_id: str
statement: str
shape_category: ShapeCategory
refusal_reason: str
def as_dict(self) -> dict[str, Any]:
return {
"case_id": self.case_id,
"statement": self.statement,
"shape_category": self.shape_category.value,
"refusal_reason": self.refusal_reason,
}
@dataclass(frozen=True, slots=True)
class LaneReport:
"""Adapter shape expected by ``evals.framework.run_lane``."""
metrics: dict[str, Any]
case_details: list[dict[str, Any]] = field(default_factory=list)
def _empty_histogram() -> dict[str, int]:
return {category.value: 0 for category in SHAPE_CATEGORY_ORDER}
def _digest(records: list[dict[str, Any]]) -> str:
payload = json.dumps(records, ensure_ascii=False, sort_keys=True,
separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def categorize_cases(cases: list[dict[str, Any]]) -> list[CategorizedCase]:
"""Pure helper — categorise a list of refused-case dicts."""
out: list[CategorizedCase] = []
for case in cases:
if not isinstance(case, dict):
raise TypeError("each case must be a dictionary")
case_id = str(case.get("case_id", "")).strip()
statement = case.get("statement", "")
refusal_reason = str(case.get("refusal_reason", "")).strip()
if not case_id:
raise ValueError("case missing case_id")
if not isinstance(statement, str) or not statement.strip():
raise ValueError(f"case {case_id!r} has empty statement")
out.append(
CategorizedCase(
case_id=case_id,
statement=statement,
shape_category=categorize(statement),
refusal_reason=refusal_reason,
)
)
return out
def build_report(cases: list[dict[str, Any]]) -> LaneReport:
"""Build a ``LaneReport`` from a refused-case dict list.
The report is the lane's full deterministic output: the histogram over
all shape categories, the categorized-rate (1 - uncategorized share),
and per-case details.
"""
categorized = categorize_cases(cases)
histogram = _empty_histogram()
for record in categorized:
histogram[record.shape_category.value] += 1
total = len(categorized)
uncategorized = histogram[ShapeCategory.UNCATEGORIZED.value]
categorized_rate = (
(total - uncategorized) / total if total else 0.0
)
case_details = [record.as_dict() for record in categorized]
metrics: dict[str, Any] = {
"total": total,
"by_category": histogram,
"uncategorized": uncategorized,
"categorized_rate": categorized_rate,
"case_digest": _digest(case_details),
}
return LaneReport(metrics=metrics, case_details=case_details)
def run_lane(
cases: list[dict[str, Any]],
*,
config: Any = None,
workers: int | None = None,
) -> LaneReport:
"""Generic eval-framework entry point.
``config`` and ``workers`` are accepted for framework compatibility and
ignored — the categorizer is pure and synchronous.
"""
del config, workers
if not isinstance(cases, list):
raise TypeError("cases must be a list of dictionaries")
return build_report(cases)
__all__ = [
"CategorizedCase",
"LaneReport",
"build_report",
"categorize_cases",
"run_lane",
]