core/core/capability/perturbation_b3.py
Shay 29111b7762
feat(ADR-0114a.5): reasoning-isolation perturbation suite — Obligation #5 wired for B3, PASSING 130/130 preserving, 68/68 breaking (#191)
Discharges ADR-0114a Obligation #5 for the B3 bounded-grammar lane.

Closed perturbation taxonomy (5 invariance-preserving, 3 invariance-breaking
transforms) operates on problem text only; parser, solver, and cases.jsonl
are untouched. Both rates are ε=0 per ADR-0120 §"Threshold rationale".

Results on main B3 (35 solved_correct cases):
  invariance_preserving: 130/130 = 1.0000
  invariance_breaking:    68/68  = 1.0000
  obligation_5_passed: True

Skipped transforms documented explicitly (not silently absent):
  commutative_reorder: all 35 — no single-entity multi-unit init state
  op_verb_flip:        15 — multiply/divide/compare/transfer cases
  value_replacement_op: 15 — no distinct numeric operand
  unit_synonym:         7 — rate-declaration $ syntax cases
  value_replacement_init: 7 — value cancels or not found
  entity_rename_v{1,2,3}: 1 each — b3-013 "Birds" collective is self-mapping

Ships:
  core/capability/perturbation_b3.py — generator + scorer + validate_perturbation_suite()
  tests/test_adr_0114a_5_perturbation.py — 15 tests (purity, preserving, breaking, determinism, snapshot, refusal, skip coverage)
  core/cli.py — core capability perturbation [--lane-id] [--json]
  evals/obligation_5_perturbation/B3_bounded_grammar.json — written by CLI
  docs/decisions/ADR-0114a.5-perturbation-suite.md — ADR with taxonomy tables
2026-05-23 16:07:59 -07:00

893 lines
30 KiB
Python

"""ADR-0114a Obligation #5 — reasoning-isolation perturbation suite for B3.
Generates and scores two classes of perturbation over B3 (bounded grammar)
expected-correct cases:
Invariance-preserving — answer MUST NOT change:
entity_rename_v{1,2,3}: Rename entities from a closed substitution pool.
unit_synonym: Replace count unit consistently (apples↔oranges,
dollars↔cents). Skip if unit has no synonym.
commutative_reorder: Swap initial-possession sentences for a single
entity holding two distinct units. Skipped for all
current B3 cases (no single-entity multi-unit init).
Invariance-breaking — answer MUST change by a predictable delta:
value_replacement_init: Replace first initial-possession value with
value + 2; predicted delta = +2.
value_replacement_op: Replace first operation numeric value with
value + 2; predicted delta = +2. Skip if
operation has no extractable numeric value.
op_verb_flip: Swap first add/subtract verb to its conjugate
(buys ↔ loses family). Skip for multiply/divide/
compare/transfer/rate operations.
Public API:
generate_b3_perturbations(case_id, problem, expected_answer, expected_unit)
-> list[B3Perturbation]
skip_reasons_b3(case_id, problem, expected_answer, expected_unit)
-> dict[str, str]
validate_perturbation_suite(lane_id, cases_path) -> PerturbationReport
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from generate.math_parser import ParseError, parse_problem
from generate.math_solver import SolveError, solve
INVARIANCE_PRESERVING = "invariance_preserving"
INVARIANCE_BREAKING = "invariance_breaking"
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_B3_CASES: Path = (
_REPO_ROOT / "evals" / "math_bounded_grammar" / "v1" / "cases.jsonl"
)
# ---------------------------------------------------------------------------
# Closed substitution pools — documented in ADR-0114a.5
# ---------------------------------------------------------------------------
# Three entity-rename variants; each maps every B3 entity to an alternative.
# Capitalized proper nouns so the parser's entity regex keeps accepting them.
_ENTITY_POOLS: list[dict[str, str]] = [
{"Sam": "Alex", "Tom": "Carol", "Bob": "David", "Birds": "Birds"},
{"Sam": "Pat", "Tom": "Robin", "Bob": "Jordan", "Birds": "Birds"},
{"Sam": "Quinn", "Tom": "Morgan", "Bob": "Blake", "Birds": "Birds"},
]
# Unit-noun synonym map (pack-aligned against en_units_v1 / parser allowed_nouns).
# Both directions are listed so the map is symmetric.
_UNIT_SYNONYMS: dict[str, str] = {
"apples": "oranges",
"oranges": "apples",
"dollars": "cents",
"cents": "dollars",
}
# Verb flip: add-verb family ↔ subtract-verb family.
# "buys" ↔ "loses" is the canonical pair; the extended table covers other
# add/subtract verbs that appear in B3 cases so all applicable verbs flip.
_VERB_FLIP: dict[str, str] = {
"buys": "loses",
"loses": "buys",
"gets": "eats",
"eats": "gets",
"receives": "loses",
"earns": "spends",
"spends": "earns",
"finds": "loses",
"adds": "loses",
"sells": "gets",
"donates": "gets",
"uses": "gets",
"drops": "gets",
"removes": "gets",
"sends": "buys", # transfer → add substitute in non-transfer context
}
# Verbs that cannot be flipped by simple substitution (keep separate to
# document them as explicit skip targets in generated skip_reasons).
_SKIP_VERBS: frozenset[str] = frozenset(
{"doubles", "triples", "splits", "gives", "hands", "passes", "mails"}
)
# All add + subtract verbs from the parser's tables.
_ADD_VERBS: frozenset[str] = frozenset(
{"buys", "gets", "finds", "receives", "earns", "adds"}
)
_SUBTRACT_VERBS: frozenset[str] = frozenset(
{"eats", "loses", "sells", "donates", "uses", "spends", "drops", "removes"}
)
_ALL_OP_VERBS: frozenset[str] = _ADD_VERBS | _SUBTRACT_VERBS
# ---------------------------------------------------------------------------
# Core data types
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class B3Perturbation:
"""One generated perturbation for a B3 case."""
perturbation_id: str
case_id: str
kind: str # INVARIANCE_PRESERVING | INVARIANCE_BREAKING
transform: str # e.g. "entity_rename_v1"
problem_text: str # perturbed problem string
expected_answer: float
expected_unit: str
predicted_delta: float | None # None for preserving; signed for breaking
transform_params: dict[str, Any]
def as_dict(self) -> dict[str, Any]:
return {
"perturbation_id": self.perturbation_id,
"case_id": self.case_id,
"kind": self.kind,
"transform": self.transform,
"problem_text": self.problem_text,
"expected_answer": self.expected_answer,
"expected_unit": self.expected_unit,
"predicted_delta": self.predicted_delta,
"transform_params": self.transform_params,
}
@dataclass(frozen=True, slots=True)
class PerturbationCaseResult:
"""Scored result for one perturbation variant."""
perturbation_id: str
kind: str
transform: str
ok: bool
detail: str
@dataclass(frozen=True, slots=True)
class PerturbationReport:
"""Aggregate B3 perturbation obligation #5 report."""
adr: str
schema_version: int
lane_id: str
cases_total: int
cases_expected_correct: int
preserving_attempted: int
preserving_correct: int
preserving_rate: float
breaking_attempted: int
breaking_correct: int
breaking_rate: float
obligation_5_passed: bool
skip_counts: dict[str, int]
per_perturbation: tuple[PerturbationCaseResult, ...]
refusal_reason: str
report_digest: str
def as_dict(self) -> dict[str, Any]:
return {
"adr": self.adr,
"schema_version": self.schema_version,
"lane_id": self.lane_id,
"cases_total": self.cases_total,
"cases_expected_correct": self.cases_expected_correct,
"preserving_attempted": self.preserving_attempted,
"preserving_correct": self.preserving_correct,
"preserving_rate": self.preserving_rate,
"breaking_attempted": self.breaking_attempted,
"breaking_correct": self.breaking_correct,
"breaking_rate": self.breaking_rate,
"obligation_5_passed": self.obligation_5_passed,
"skip_counts": self.skip_counts,
"per_perturbation": [
{
"perturbation_id": r.perturbation_id,
"kind": r.kind,
"transform": r.transform,
"ok": r.ok,
"detail": r.detail,
}
for r in self.per_perturbation
],
"refusal_reason": self.refusal_reason,
"report_digest": self.report_digest,
}
# ---------------------------------------------------------------------------
# String-level perturbation helpers (operate on problem text only)
# ---------------------------------------------------------------------------
def _rename_entities(problem: str, entity_map: dict[str, str]) -> str:
"""Replace every entity name in problem text using entity_map.
Uses word-boundary regex so 'Sam' inside 'Samuel' is not replaced.
Substitutions are applied in longest-first order to avoid partial
matches when one entity name is a prefix of another.
"""
result = problem
for src in sorted(entity_map, key=len, reverse=True):
dst = entity_map[src]
result = re.sub(rf"\b{re.escape(src)}\b", dst, result)
return result
def _substitute_unit(problem: str, src_unit: str, dst_unit: str) -> str:
"""Replace all occurrences of src_unit (singular or plural) with dst_unit.
Handles the canonical plural forms used by the parser.
Keeps surrounding whitespace intact.
"""
# Build singular form by stripping trailing 's' if present.
src_singular = src_unit.rstrip("s") if src_unit.endswith("s") else src_unit
dst_singular = dst_unit.rstrip("s") if dst_unit.endswith("s") else dst_unit
result = problem
# Replace plural first (longer match), then singular.
for src, dst in [(src_unit, dst_unit), (src_singular, dst_singular)]:
if src != dst:
result = re.sub(rf"\b{re.escape(src)}\b", dst, result)
return result
def _first_initial_possession_value(problem: str) -> int | None:
"""Return the integer value of the first '<Entity> has N <unit>' match.
Returns None when not found.
"""
m = re.search(
r"([A-Z]\w+|[Tt]he\s+\w+)\s+(?:has|have)\s+"
r"(\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)"
r"\s+\w+",
problem,
)
if not m:
return None
word_numbers = {
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12,
}
raw = m.group(2)
return int(raw) if raw.isdigit() else word_numbers.get(raw.lower())
def _replace_value_in_text(problem: str, old_value: int, delta: int) -> str:
"""Replace the first occurrence of str(old_value) (as a whole word, not after $)."""
new_value = old_value + delta
return re.sub(
rf"(?<!\$)(?<!\d)\b{re.escape(str(old_value))}\b",
str(new_value),
problem,
count=1,
)
def _first_operation_verb_and_value(problem: str) -> tuple[str | None, int | None]:
"""Find the first add/subtract verb in problem text, and its accompanying integer.
Returns (verb, value_or_None). 'value' is extracted from the same
sentence. Returns (None, None) if no add/subtract verb found.
"""
sentences = re.split(r"(?<=[.?!])\s+", problem.strip())
for sent in sentences:
# Skip question sentences.
if sent.rstrip().endswith("?"):
continue
for verb in _ALL_OP_VERBS:
if re.search(rf"\b{re.escape(verb)}\b", sent):
# Extract numeric operand from same sentence (not rate $N).
m = re.search(r"(?<!\$)\b(\d+)\b", sent)
val = int(m.group(1)) if m else None
return verb, val
return None, None
def _flip_verb(problem: str, src_verb: str, dst_verb: str) -> str:
"""Replace first occurrence of src_verb with dst_verb (whole word)."""
return re.sub(rf"\b{re.escape(src_verb)}\b", dst_verb, problem, count=1)
# ---------------------------------------------------------------------------
# Pipeline runner
# ---------------------------------------------------------------------------
def _run(problem: str) -> tuple[float | None, str | None, str]:
"""Run parse + solve on problem text.
Returns (answer_value, answer_unit, error_detail).
error_detail is empty string on success.
"""
try:
graph = parse_problem(problem)
trace = solve(graph)
return trace.answer_value, trace.answer_unit, ""
except (ParseError, SolveError) as exc:
return None, None, f"{type(exc).__name__}: {exc}"
def _make_id(case_id: str, transform: str) -> str:
return f"{case_id}:{transform}"
# ---------------------------------------------------------------------------
# Perturbation generators
# ---------------------------------------------------------------------------
def _gen_entity_renames(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> list[B3Perturbation]:
"""Generate up to 3 entity-rename invariance-preserving variants."""
results: list[B3Perturbation] = []
for version, pool in enumerate(_ENTITY_POOLS, start=1):
perturbed = _rename_entities(problem, pool)
if perturbed == problem:
continue # no entity in this case matched the pool — skip variant
transform = f"entity_rename_v{version}"
results.append(
B3Perturbation(
perturbation_id=_make_id(case_id, transform),
case_id=case_id,
kind=INVARIANCE_PRESERVING,
transform=transform,
problem_text=perturbed,
expected_answer=expected_answer,
expected_unit=expected_unit,
predicted_delta=None,
transform_params={"entity_map": {k: v for k, v in pool.items()
if k in problem}},
)
)
return results
def _gen_unit_synonym(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> B3Perturbation | None:
"""Generate a unit-synonym invariance-preserving variant.
Returns None if expected_unit has no synonym or if unit appears inside
a rate declaration (which hardcodes $N syntax — not substitutable).
"""
synonym = _UNIT_SYNONYMS.get(expected_unit)
if synonym is None:
return None
# Rate declarations use $N syntax; substituting the unit alone breaks them.
# Detect rate cases by the presence of a "$" literal in the problem.
if "$" in problem:
return None
perturbed = _substitute_unit(problem, expected_unit, synonym)
if perturbed == problem:
return None
return B3Perturbation(
perturbation_id=_make_id(case_id, "unit_synonym"),
case_id=case_id,
kind=INVARIANCE_PRESERVING,
transform="unit_synonym",
problem_text=perturbed,
expected_answer=expected_answer,
expected_unit=synonym,
predicted_delta=None,
transform_params={"src_unit": expected_unit, "dst_unit": synonym},
)
def _gen_commutative_reorder(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> B3Perturbation | None:
"""Generate a commutative-reorder variant (single-entity multi-unit only).
Current B3 cases do not have single-entity multi-unit initial states;
this will always return None and document the skip in skip_reasons_b3.
Implemented for future-proofing when such cases are added.
"""
# Pattern: two consecutive initial-possession sentences for the SAME entity
# with DIFFERENT units.
pat = re.compile(
r"(([A-Z]\w+)\s+has\s+\d+\s+(\w+)\.)\s+"
r"(\2\s+has\s+\d+\s+(?!\3\b)(\w+)\.)"
)
m = pat.search(problem)
if not m:
return None
# Swap the two matched sentences.
s1 = m.group(1)
s2 = m.group(4)
perturbed = problem.replace(f"{s1} {s2}", f"{s2} {s1}", 1)
if perturbed == problem:
return None
return B3Perturbation(
perturbation_id=_make_id(case_id, "commutative_reorder"),
case_id=case_id,
kind=INVARIANCE_PRESERVING,
transform="commutative_reorder",
problem_text=perturbed,
expected_answer=expected_answer,
expected_unit=expected_unit,
predicted_delta=None,
transform_params={"swapped": [s1, s2]},
)
def _gen_value_replacement_init(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> B3Perturbation | None:
"""Replace first initial-possession value by value + 2 (invariance-breaking)."""
old_value = _first_initial_possession_value(problem)
if old_value is None:
return None
if not str(old_value) in problem:
return None
delta = 2
perturbed = _replace_value_in_text(problem, old_value, delta)
if perturbed == problem:
return None
new_answer, new_unit, err = _run(perturbed)
if err or new_answer is None:
return None
if new_answer == expected_answer:
# Replacement did not change answer (e.g. value cancels); skip.
return None
return B3Perturbation(
perturbation_id=_make_id(case_id, "value_replacement_init"),
case_id=case_id,
kind=INVARIANCE_BREAKING,
transform="value_replacement_init",
problem_text=perturbed,
expected_answer=new_answer,
expected_unit=new_unit or expected_unit,
predicted_delta=new_answer - expected_answer,
transform_params={
"replaced_value": old_value,
"replacement": old_value + delta,
"delta": delta,
},
)
def _gen_value_replacement_op(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> B3Perturbation | None:
"""Replace first operation numeric value by value + 2 (invariance-breaking).
Skip if no add/subtract operation with a numeric operand is found, or if
the operation value is the same as the initial-possession value (would
be a duplicate replacement target).
"""
verb, op_value = _first_operation_verb_and_value(problem)
if verb is None or op_value is None:
return None
init_value = _first_initial_possession_value(problem)
# Build a problem with the initial-possession value temporarily masked so
# that _replace_value_in_text targets the operation value, not the init.
# Strategy: replace init value with a sentinel, replace op value, restore.
# Only do this when init_value == op_value (ambiguous target).
if init_value is not None and init_value == op_value:
# Can't distinguish the two occurrences with simple substitution; skip.
return None
delta = 2
perturbed = _replace_value_in_text(problem, op_value, delta)
if perturbed == problem:
return None
new_answer, new_unit, err = _run(perturbed)
if err or new_answer is None:
return None
if new_answer == expected_answer:
return None
return B3Perturbation(
perturbation_id=_make_id(case_id, "value_replacement_op"),
case_id=case_id,
kind=INVARIANCE_BREAKING,
transform="value_replacement_op",
problem_text=perturbed,
expected_answer=new_answer,
expected_unit=new_unit or expected_unit,
predicted_delta=new_answer - expected_answer,
transform_params={
"replaced_value": op_value,
"replacement": op_value + delta,
"verb_context": verb,
"delta": delta,
},
)
def _gen_op_verb_flip(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> B3Perturbation | None:
"""Flip the first add/subtract verb to its conjugate (invariance-breaking).
Skip if the first operation verb is outside the flip table (multiply,
divide, transfer, compare, rate).
"""
verb, _ = _first_operation_verb_and_value(problem)
if verb is None or verb not in _VERB_FLIP:
return None
dst_verb = _VERB_FLIP[verb]
perturbed = _flip_verb(problem, verb, dst_verb)
if perturbed == problem:
return None
new_answer, new_unit, err = _run(perturbed)
if err or new_answer is None:
return None
if new_answer == expected_answer:
return None
return B3Perturbation(
perturbation_id=_make_id(case_id, "op_verb_flip"),
case_id=case_id,
kind=INVARIANCE_BREAKING,
transform="op_verb_flip",
problem_text=perturbed,
expected_answer=new_answer,
expected_unit=new_unit or expected_unit,
predicted_delta=new_answer - expected_answer,
transform_params={"src_verb": verb, "dst_verb": dst_verb},
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def generate_b3_perturbations(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> list[B3Perturbation]:
"""Generate all applicable perturbations for one B3 solved_correct case.
Invariance-preserving (up to 5 per case):
entity_rename_v1/v2/v3, unit_synonym, commutative_reorder
Invariance-breaking (up to 3 per case):
value_replacement_init, value_replacement_op, op_verb_flip
Inapplicable transforms are omitted; see skip_reasons_b3 for why.
"""
results: list[B3Perturbation] = []
# Invariance-preserving
results.extend(_gen_entity_renames(case_id, problem, expected_answer, expected_unit))
u = _gen_unit_synonym(case_id, problem, expected_answer, expected_unit)
if u:
results.append(u)
cr = _gen_commutative_reorder(case_id, problem, expected_answer, expected_unit)
if cr:
results.append(cr)
# Invariance-breaking
for gen in (
_gen_value_replacement_init,
_gen_value_replacement_op,
_gen_op_verb_flip,
):
p = gen(case_id, problem, expected_answer, expected_unit)
if p:
results.append(p)
return results
def skip_reasons_b3(
case_id: str,
problem: str,
expected_answer: float,
expected_unit: str,
) -> dict[str, str]:
"""Return a dict of transform → reason for every skipped transform.
Complements generate_b3_perturbations: each skipped transform is
explicit rather than silently absent.
"""
reasons: dict[str, str] = {}
# entity_rename: check each variant
for version, pool in enumerate(_ENTITY_POOLS, start=1):
perturbed = _rename_entities(problem, pool)
if perturbed == problem:
reasons[f"entity_rename_v{version}"] = (
"no entity in problem text matched the substitution pool"
)
# unit_synonym
synonym = _UNIT_SYNONYMS.get(expected_unit)
if synonym is None:
reasons["unit_synonym"] = (
f"unit {expected_unit!r} has no synonym in the closed substitution pool"
)
elif "$" in problem:
reasons["unit_synonym"] = (
"rate declaration ($ syntax) in problem makes unit substitution unsafe"
)
# commutative_reorder
if _gen_commutative_reorder(case_id, problem, expected_answer, expected_unit) is None:
reasons["commutative_reorder"] = (
"no single-entity multi-unit consecutive initial-possession pair found"
)
# value_replacement_init
init_value = _first_initial_possession_value(problem)
if init_value is None:
reasons["value_replacement_init"] = "no initial-possession value found"
elif _gen_value_replacement_init(case_id, problem, expected_answer, expected_unit) is None:
reasons["value_replacement_init"] = (
"replacement produced same answer (value cancels out)"
)
# value_replacement_op
verb, op_value = _first_operation_verb_and_value(problem)
if verb is None or op_value is None:
reasons["value_replacement_op"] = (
"no add/subtract operation with a numeric operand found"
)
else:
init_v = _first_initial_possession_value(problem)
if init_v is not None and init_v == op_value:
reasons["value_replacement_op"] = (
"initial-possession value and operation value are equal — "
"cannot unambiguously target the operation position"
)
elif _gen_value_replacement_op(
case_id, problem, expected_answer, expected_unit
) is None:
reasons["value_replacement_op"] = (
"replacement produced same answer or parse failure"
)
# op_verb_flip
if verb is None:
reasons["op_verb_flip"] = "no operation verb found in problem"
elif verb in _SKIP_VERBS:
reasons["op_verb_flip"] = (
f"verb {verb!r} is a multiply/divide/transfer verb — "
"not in the closed flip table (would change operation semantics "
"beyond sign-flip; deferred)"
)
elif verb not in _VERB_FLIP:
reasons["op_verb_flip"] = (
f"verb {verb!r} is not in the closed flip table"
)
elif _gen_op_verb_flip(case_id, problem, expected_answer, expected_unit) is None:
reasons["op_verb_flip"] = (
"flip produced same answer or parse failure"
)
return reasons
# ---------------------------------------------------------------------------
# Scorer
# ---------------------------------------------------------------------------
def score_b3_perturbation(p: B3Perturbation) -> tuple[bool, str]:
"""Run the B3 pipeline on p.problem_text and compare against expected."""
answer, unit, err = _run(p.problem_text)
if err:
return False, err
if unit != p.expected_unit:
return False, f"unit {unit!r} != expected {p.expected_unit!r}"
if answer != p.expected_answer:
return False, f"answer {answer!r} != expected {p.expected_answer!r}"
return True, "ok"
# ---------------------------------------------------------------------------
# Lane validator
# ---------------------------------------------------------------------------
def validate_perturbation_suite(
lane_id: str = "B3_bounded_grammar",
cases_path: Path = DEFAULT_B3_CASES,
) -> PerturbationReport:
"""Validate ADR-0114a Obligation #5 for the B3 bounded-grammar lane.
Reads cases_path; generates perturbations for every solved_correct case;
scores each; returns PerturbationReport with both aggregate rates and
per-perturbation detail.
Exit criterion: obligation_5_passed iff
preserving_rate == 1.0 AND breaking_rate == 1.0.
"""
if not cases_path.exists():
return _refusal_report(lane_id, f"cases file not found: {cases_path}")
raw_cases: list[dict] = [
json.loads(line)
for line in cases_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if not raw_cases:
return _refusal_report(lane_id, "cases file is empty")
expected_correct = [c for c in raw_cases if c.get("expected") == "solved_correct"]
if not expected_correct:
return _refusal_report(lane_id, "no solved_correct cases found in cases file")
per_perturbation: list[PerturbationCaseResult] = []
preserving_attempted = preserving_correct = 0
breaking_attempted = breaking_correct = 0
skip_counts: dict[str, int] = {}
for case in expected_correct:
case_id = case["case_id"]
problem = case["problem"]
exp_ans = case["expected_answer"]
exp_unit = case["expected_unit"]
skips = skip_reasons_b3(case_id, problem, exp_ans, exp_unit)
for transform in skips:
skip_counts[transform] = skip_counts.get(transform, 0) + 1
perturbations = generate_b3_perturbations(case_id, problem, exp_ans, exp_unit)
for pert in perturbations:
ok, detail = score_b3_perturbation(pert)
per_perturbation.append(
PerturbationCaseResult(
perturbation_id=pert.perturbation_id,
kind=pert.kind,
transform=pert.transform,
ok=ok,
detail=detail,
)
)
if pert.kind == INVARIANCE_PRESERVING:
preserving_attempted += 1
if ok:
preserving_correct += 1
else:
breaking_attempted += 1
if ok:
breaking_correct += 1
preserving_rate = (
preserving_correct / preserving_attempted if preserving_attempted else 0.0
)
breaking_rate = (
breaking_correct / breaking_attempted if breaking_attempted else 0.0
)
passed = (
preserving_attempted > 0
and breaking_attempted > 0
and preserving_rate == 1.0
and breaking_rate == 1.0
)
refusal_reason = ""
if not passed:
parts = []
if preserving_rate < 1.0:
parts.append(
f"preserving_rate={preserving_rate:.4f} "
f"({preserving_correct}/{preserving_attempted})"
)
if breaking_rate < 1.0:
parts.append(
f"breaking_rate={breaking_rate:.4f} "
f"({breaking_correct}/{breaking_attempted})"
)
if not preserving_attempted:
parts.append("no invariance-preserving perturbations generated")
if not breaking_attempted:
parts.append("no invariance-breaking perturbations generated")
refusal_reason = "; ".join(parts)
report_dict = {
"adr": "0114a.5",
"schema_version": 1,
"lane_id": lane_id,
"cases_total": len(raw_cases),
"cases_expected_correct": len(expected_correct),
"preserving_attempted": preserving_attempted,
"preserving_correct": preserving_correct,
"preserving_rate": preserving_rate,
"breaking_attempted": breaking_attempted,
"breaking_correct": breaking_correct,
"breaking_rate": breaking_rate,
"obligation_5_passed": passed,
"skip_counts": dict(sorted(skip_counts.items())),
"refusal_reason": refusal_reason,
}
digest = hashlib.sha256(
json.dumps(report_dict, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return PerturbationReport(
adr="0114a.5",
schema_version=1,
lane_id=lane_id,
cases_total=len(raw_cases),
cases_expected_correct=len(expected_correct),
preserving_attempted=preserving_attempted,
preserving_correct=preserving_correct,
preserving_rate=preserving_rate,
breaking_attempted=breaking_attempted,
breaking_correct=breaking_correct,
breaking_rate=breaking_rate,
obligation_5_passed=passed,
skip_counts=dict(sorted(skip_counts.items())),
per_perturbation=tuple(per_perturbation),
refusal_reason=refusal_reason,
report_digest=digest,
)
def emit_perturbation_report(report: PerturbationReport, out_path: Path) -> None:
"""Write the deterministic obligation-#5 perturbation report."""
out_path.write_text(
json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _refusal_report(lane_id: str, reason: str) -> PerturbationReport:
digest = hashlib.sha256(reason.encode("utf-8")).hexdigest()
return PerturbationReport(
adr="0114a.5",
schema_version=1,
lane_id=lane_id,
cases_total=0,
cases_expected_correct=0,
preserving_attempted=0,
preserving_correct=0,
preserving_rate=0.0,
breaking_attempted=0,
breaking_correct=0,
breaking_rate=0.0,
obligation_5_passed=False,
skip_counts={},
per_perturbation=(),
refusal_reason=reason,
report_digest=digest,
)