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).
295 lines
9.9 KiB
Python
295 lines
9.9 KiB
Python
"""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",
|
||
)
|