core/evals/refusal_taxonomy/contract.md
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

4.4 KiB

Refusal Taxonomy Eval Contract (ADR-0163 Phase A)

Purpose

refusal-taxonomy is a read-only evaluation lane that categorises refused GSM8K statements by statement shape, not by content. The histogram it emits is the load-bearing measurement that gates Phase B of ADR-0163: the top categories by count become the input list for hand-authored exemplar corpora.

Phase A produces no recognizers and no corpus changes. Its sole job is to turn a flat refusal list into a measured distribution of shapes so the operator can choose what to teach next.

Source of truth

The v1 case set is derived from:

evals/gsm8k_math/train_sample/v1/report.json

— every record whose verdict == "refused", with the embedded statement extracted out of the reason envelope. The case set is rebuilt deterministically by scripts/build_refusal_taxonomy_cases.py; the script's output is sorted by case_id and committed to evals/refusal_taxonomy/public/v1/cases.jsonl (the framework-standard public-split location). The Phase A v1 report artifact lives at evals/refusal_taxonomy/v1/report.json.

Non-goals

This lane MUST NOT:

  • mutate any corpus, pack, or language pack
  • write to engine_state
  • create, accept, or reject proposals
  • call an LLM, embedding model, or any learned classifier
  • alter the GSM8K refusal counts elsewhere in the repo

The lane only reads its own cases.jsonl (or an operator-supplied refused-case set) and emits a histogram.

Categorizer doctrine

Per ADR-0163 §Constraint #4 and CLAUDE.md, the categorizer is rules-only:

  • No LLM call, no embedding, no learned model.
  • Deterministic — the output is a pure function of the input string.
  • No hidden normalization — lowercasing/padding is only for substring word-boundary safety; the original statement is never mutated for downstream consumers.
  • First-match-wins. Priority order is fixed in shape_categories.py.
  • UNCATEGORIZED is a first-class outcome. It is honest measurement, not a failure.

Adding a new category requires citing ≥ 3 refused statements as evidence in the category's docstring. This is enforced by tests/test_refusal_taxonomy_lane.py.

Shape categories (v1)

The nine baseline categories named in ADR-0163 §Phase A, plus uncategorized:

Category Definition
nested_question_target "If X, how many/much Y …?"
unit_partition hyphenated unit, e.g. "25-foot sections"
rate_with_currency $N + per-unit framing (per hour, /kg, for one cup)
comparative_with_unit "more than", "twice as", "N times", "as many as"
fractional_rate_of_change fraction or % paired with a change-of-state verb
indefinite_quantity "some", "several", "a few", "many", "any"
temporal_aggregation "each day", "every X", "in N minutes", day-of-week enumeration
conditional_quantity bare "If X, would/will Y" without a how-target
descriptive_setup_no_quantity no digit, no number-word, no quantifier
uncategorized none of the above

Case set schema

Each line in cases.jsonl is a JSON object:

{"case_id": "gsm8k-train-sample-v1-NNNN",
 "statement": "<the refused statement>",
 "refusal_reason": "candidate_graph: no admissible candidate for ..."}

Lane output

run_lane(cases) returns a LaneReport with:

metrics = {
    "total": <int>,
    "by_category": {category_value: count, ...},     # every category present
    "uncategorized": <int>,
    "categorized_rate": <float in [0, 1]>,
    "case_digest": <sha256 of canonical case_details>,
}
case_details = [
    {"case_id", "statement", "shape_category", "refusal_reason"},
    ...
]

The histogram includes every category from SHAPE_CATEGORY_ORDER — counts of zero are reported explicitly, not omitted.

Replay & determinism

For a fixed cases.jsonl at a fixed commit SHA, run_lane returns the same metrics and case_details bit-for-bit. case_digest is a sha256 over the canonical-JSON serialization of case_details and acts as a single integrity hash for downstream tooling.

ADR compatibility

This lane preserves:

  • ADR-0163 Phase A boundary — measurement only, no corpus mutation
  • ADR-0114a wrong = 0 invariant — the lane scores shape, not correctness, so the GSM8K and capability-axis wrong counts elsewhere in the repo are unaffected
  • CLAUDE.md non-negotiables — no hidden normalization, no LLM fallback, deterministic replay