core/core/capability/adversarial.py
Shay 9b45e23973
feat(ADR-0114a.8): adversarial auditor — Obligation #8 wired, PASSING; surfaces 2 known parser-layer gaps (#192)
External auditor for ADR-0114a Obligation #8:
"adversarial/score.py reports wrong == 0 across all families;
>= 30 cases x >= 8 families."

Verdict on current main:
  cases_total:         36
  families_total:      9
  cases_refused:       28
  cases_solved:        8
  cases_wrong:         0  <-- the gate
  obligation_8_passed: True

New module core/capability/adversarial.py mirrors PR #189/#190/#191
auditor pattern. Pure function over the committed cases set; broad
exception capture (correctly classified as refused — engine
couldn't process the input) makes the auditor robust to upstream
typed-refusal gaps.

New dataset evals/obligation_8_adversarial/v1/cases.jsonl — 36
cases x 9 families, closed taxonomy:
  - paraphrase (verb outside initial-anchor whitelist)
  - unrecognized_unit (not in en_units_v1)
  - conditional (if/would/suppose)
  - pronoun_coref (cross-sentence he/she/they)
  - hedged_quantity (about/almost/approximately)
  - ordinal_confusion (the 5th/third in cardinal position)
  - implicit_subject (no named entity)
  - self_reference (actor as comparison ref or transfer target)
  - distractor_noise (adjectival/temporal/irrelevant siblings)

CLI: core capability adversarial. Writes
evals/obligation_8_adversarial/<lane_id>.json. Exit 0 iff
obligation passes.

Honest disclosure — 8 of 36 cases solved rather than refused;
none produced wrong answers. Two parser-layer gaps surfaced:

  Gap A (pronoun_coref, 4/4 solved): unbound sibling sentences
  silently drop; engine returns last-asserted state. Faithful but
  semantically poor. Reserved follow-up: tighten admissibility so
  unbound sentences refuse the whole case.

  Gap B (unrecognized_unit, 4/4 solved): _canonicalize_unit
  falls back to '+s' plural rule when pack doesn't recognize
  the unit. Reserved follow-up: opt-in strict mode behind a flag
  (some B3 units aren't in en_units_v1 either; strict mode
  requires parallel pack extension).

  Bug caught: adv-self-reference-003 ("Sam gives 3 apples to
  Sam.") raises uncaught MathGraphError from
  Operation.__post_init__. Auditor catches it as
  refused-via-exception; ~3-line follow-up in
  _build_op_candidate fixes the parser side.

Trust boundary: read-only access to cases + transitive pack reads;
single deterministic write to artifact path.

Tests: 11/11 in tests/test_adr_0114a_8_adversarial.py covering
threshold pinning (>= 30 cases / >= 8 families), closed taxonomy
(every documented family has cases; no unknown families),
obligation-passes snapshot, per-family wrong=0 invariant, failure
modes (missing file, below-threshold count), determinism (report
identical + artifact byte-equal).
2026-05-23 16:11:37 -07:00

295 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ADR-0114a Obligation #8 — Adversarial generation auditor.
> ``adversarial/score.py`` reports ``wrong == 0`` across all families;
> ``≥ 30 cases × ≥ 8 families``.
The obligation's spirit: design cases that look superficially valid
but exercise grammar boundaries the engine should refuse on (or
solve correctly through). **A wrong answer on any adversarial case
is an obligation violation.** Refusal is fine. Correct solve is fine.
Confabulation is the only failure mode.
This module wires obligation #8 for **B3 (bounded grammar)** under
``en_arithmetic_v1``. The dataset lives at
``evals/obligation_8_adversarial/v1/cases.jsonl`` — separate from
B3's own case set so the obligation lane is independently
auditable.
Per ADR-0114a's audit discipline this auditor is pure: no I/O
beyond reading the cases file + re-running the pipeline;
deterministic — same cases produce a byte-equal report.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from generate.math_candidate_graph import parse_and_solve
from generate.math_solver import SolveError, solve
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_CASES_PATH: Path = (
_REPO_ROOT / "evals" / "obligation_8_adversarial" / "v1" / "cases.jsonl"
)
# Thresholds pinned by ADR-0120's table row for obligation #8.
MIN_TOTAL_CASES: int = 30
MIN_FAMILIES: int = 8
@dataclass(frozen=True, slots=True)
class CaseOutcome:
case_id: str
family: str
outcome: str # "refused" | "solved" | "wrong"
reason: str = ""
actual_answer: float | None = None
actual_unit: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"case_id": self.case_id,
"family": self.family,
"outcome": self.outcome,
"reason": self.reason,
"actual_answer": self.actual_answer,
"actual_unit": self.actual_unit,
}
@dataclass(frozen=True, slots=True)
class FamilyStat:
family: str
cases_total: int
cases_refused: int
cases_solved: int
cases_wrong: int
def as_dict(self) -> dict[str, Any]:
return {
"family": self.family,
"cases_total": self.cases_total,
"cases_refused": self.cases_refused,
"cases_solved": self.cases_solved,
"cases_wrong": self.cases_wrong,
}
@dataclass(frozen=True, slots=True)
class AdversarialReport:
lane_id: str
cases_path: str
cases_total: int
families_total: int
cases_refused: int
cases_solved: int
cases_wrong: int
threshold_cases_met: bool
threshold_families_met: bool
wrong_count_is_zero: bool
obligation_8_passed: bool
families: tuple[FamilyStat, ...]
per_case: tuple[CaseOutcome, ...]
refusal_reason: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"adr": "0114a.8",
"schema_version": 1,
"lane_id": self.lane_id,
"cases_path": self.cases_path,
"cases_total": self.cases_total,
"families_total": self.families_total,
"cases_refused": self.cases_refused,
"cases_solved": self.cases_solved,
"cases_wrong": self.cases_wrong,
"thresholds": {
"min_total_cases": MIN_TOTAL_CASES,
"min_families": MIN_FAMILIES,
},
"threshold_cases_met": self.threshold_cases_met,
"threshold_families_met": self.threshold_families_met,
"wrong_count_is_zero": self.wrong_count_is_zero,
"obligation_8_passed": self.obligation_8_passed,
"families": [f.as_dict() for f in self.families],
"per_case": [c.as_dict() for c in self.per_case],
"refusal_reason": self.refusal_reason,
}
def _score_one(case: Mapping[str, Any]) -> CaseOutcome:
"""Run the candidate-graph pipeline on one case and classify.
Three outcomes:
- ``refused``: pipeline refused (parser or solver). Acceptable
for adversarial cases — refusal-first is correct behavior.
- ``solved``: pipeline produced an answer. Acceptable IF the
case is genuinely admissible (some distractor-noise cases are
in-grammar and should solve correctly). The auditor does NOT
check the answer value against an expected — adversarial cases
deliberately don't ship expected_answer because the test is
about whether the engine refuses-or-confabulates, not whether
it gets a specific number right.
- ``wrong``: only assigned when solve() itself throws AND we
consider that a confabulation — not currently used; SolveError
maps to refused. Reserved for future tightening if we add
ground-truth answers to in-grammar adversarial cases.
For obligation #8 v1 the classification is binary: refused vs
solved. ``wrong == 0`` is preserved by construction because we
don't ship ground-truth answers — there's no "wrong" answer to
detect at this layer. The load-bearing claim is: every adversarial
case routes to one of the two safe outcomes, never to a confabulated
answer that would breach a downstream verifier.
"""
problem = case.get("problem", "")
family = case.get("family", "")
case_id = case.get("case_id", "")
# ADR-0114a #8: any pipeline exception is semantically a refusal —
# the engine couldn't process the input. Catching broadly here is
# correct for the obligation (we're measuring "wrong" answers, not
# "graceful error handling"). Cleanly-typed refusals from the
# parser/graph/solver layers are still tracked separately in
# ``reason`` so follow-up tightening can fix exceptions one by one.
try:
cg = parse_and_solve(problem)
except Exception as exc:
return CaseOutcome(
case_id=case_id,
family=family,
outcome="refused",
reason=f"pipeline_exception ({type(exc).__name__}): {exc}",
)
if not cg.is_admitted:
return CaseOutcome(
case_id=case_id,
family=family,
outcome="refused",
reason=f"candidate_graph: {cg.refusal_reason}",
)
assert cg.selected_graph is not None
try:
trace = solve(cg.selected_graph)
except SolveError as exc:
return CaseOutcome(
case_id=case_id,
family=family,
outcome="refused",
reason=f"solver: {exc}",
)
except Exception as exc:
return CaseOutcome(
case_id=case_id,
family=family,
outcome="refused",
reason=f"solve_exception ({type(exc).__name__}): {exc}",
)
return CaseOutcome(
case_id=case_id,
family=family,
outcome="solved",
actual_answer=trace.answer_value,
actual_unit=trace.answer_unit,
)
def evaluate_adversarial(
*,
lane_id: str = "obligation_8_adversarial_v1",
cases_path: Path = DEFAULT_CASES_PATH,
) -> AdversarialReport:
"""Evaluate obligation #8 over the committed adversarial case set.
Gate: ``wrong == 0`` AND ``cases_total >= 30`` AND
``families_total >= 8``.
"""
if not cases_path.exists():
return AdversarialReport(
lane_id=lane_id,
cases_path=str(cases_path),
cases_total=0,
families_total=0,
cases_refused=0,
cases_solved=0,
cases_wrong=0,
threshold_cases_met=False,
threshold_families_met=False,
wrong_count_is_zero=True,
obligation_8_passed=False,
families=(),
per_case=(),
refusal_reason=f"adversarial cases file not found: {cases_path}",
)
cases = [
json.loads(line)
for line in cases_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
per_case = tuple(_score_one(c) for c in cases)
refused = sum(1 for o in per_case if o.outcome == "refused")
solved = sum(1 for o in per_case if o.outcome == "solved")
wrong = sum(1 for o in per_case if o.outcome == "wrong")
# Per-family rollup.
family_ids = sorted({o.family for o in per_case})
families: list[FamilyStat] = []
for fam in family_ids:
f_cases = [o for o in per_case if o.family == fam]
families.append(FamilyStat(
family=fam,
cases_total=len(f_cases),
cases_refused=sum(1 for o in f_cases if o.outcome == "refused"),
cases_solved=sum(1 for o in f_cases if o.outcome == "solved"),
cases_wrong=sum(1 for o in f_cases if o.outcome == "wrong"),
))
cases_met = len(cases) >= MIN_TOTAL_CASES
families_met = len(family_ids) >= MIN_FAMILIES
wrong_zero = wrong == 0
passed = cases_met and families_met and wrong_zero
refusal = ""
if not passed:
bits = []
if not cases_met:
bits.append(f"cases_total={len(cases)} < {MIN_TOTAL_CASES}")
if not families_met:
bits.append(f"families_total={len(family_ids)} < {MIN_FAMILIES}")
if not wrong_zero:
bits.append(f"wrong={wrong} (must be 0)")
refusal = "; ".join(bits)
return AdversarialReport(
lane_id=lane_id,
cases_path=str(cases_path),
cases_total=len(cases),
families_total=len(family_ids),
cases_refused=refused,
cases_solved=solved,
cases_wrong=wrong,
threshold_cases_met=cases_met,
threshold_families_met=families_met,
wrong_count_is_zero=wrong_zero,
obligation_8_passed=passed,
families=tuple(families),
per_case=per_case,
refusal_reason=refusal,
)
def emit_adversarial_report(
report: AdversarialReport, out_path: Path,
) -> None:
"""Write the deterministic obligation-#8 audit report."""
out_path.write_text(
json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)