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.
141 lines
4.2 KiB
Python
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",
|
|
]
|