feat(ADR-0114a.2): OOD-ratio auditor — Obligation #2 wired for B3, ratio=1.00 (#193)

35-case OOD set (ood-001..ood-035): surface-varied siblings of B3's 35
solved_correct public cases.  Entity-name pool: Maya/Liam/Noah/Diana/Felix/
Priya/Omar/Rosa/Jun/Kai.  Unit-noun pool: oranges/marbles/pencils/books/
stamps/coins/balls (all parser-allowed count nouns).  Every case in-grammar
per ADR-0131.3 and parseable without error.

Auditor (core/capability/ood_ratio.py): reads B3 public report.json + OOD
report.json, computes ood_ratio = ood_accuracy / public_accuracy, enforces
two independent gates — ratio ≥ 0.95 and wrong == 0.

CLI: core capability ood-ratio (exit 0 iff both gates pass).

Measured: public 50/50=1.000, OOD 35/35=1.000, ratio=1.000. Obligation #10
and B3 public lane unchanged.
This commit is contained in:
Shay 2026-05-23 16:25:28 -07:00 committed by GitHub
parent 1f90cb6cf6
commit 1babef946e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1288 additions and 0 deletions

View file

@ -0,0 +1,206 @@
"""ADR-0114a Obligation #2 — OOD surface variation ratio auditor.
Reads B3's public ``report.json`` (cases_correct / cases_total) and the
OOD lane's ``report.json``, computes
ood_ratio = ood_accuracy / public_accuracy
and emits an ``OodRatioReport`` with an ``obligation_2_ratio_satisfied``
flag (gate: ratio 0.95, separate from the ``wrong == 0`` gate).
The auditor is pure and deterministic: same reports produce byte-equal
output. It performs no I/O beyond reading the two JSON files and
writing the audit report.
Auditor pattern mirrors ``core/capability/pack_provenance.py``
(ADR-0114a.10) and ``evals/gsm8k_math/scoring/depth_curve.py``
(ADR-0114a.6).
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_B3_REPORT: Path = (
_REPO_ROOT / "evals" / "math_bounded_grammar" / "v1" / "report.json"
)
DEFAULT_OOD_REPORT: Path = (
_REPO_ROOT / "evals" / "obligation_2_ood_ratio" / "v1" / "report.json"
)
# Gate threshold — changing this requires a new ADR.
OOD_RATIO_GATE: float = 0.95
class OodRatioError(Exception):
"""Raised when a required report cannot be read or is malformed."""
@dataclass(frozen=True, slots=True)
class OodRatioReport:
"""Aggregate obligation-#2 audit result."""
lane_id: str
public_cases_total: int
public_cases_correct: int
public_accuracy: float
ood_cases_total: int
ood_cases_correct: int
ood_cases_wrong: int
ood_accuracy: float
ood_ratio: float
obligation_2_ratio_satisfied: bool
obligation_2_wrong_zero: bool
obligation_2_passed: bool
refusal_reason: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"adr": "0114a.2",
"schema_version": 1,
"lane_id": self.lane_id,
"public_cases_total": self.public_cases_total,
"public_cases_correct": self.public_cases_correct,
"public_accuracy": self.public_accuracy,
"ood_cases_total": self.ood_cases_total,
"ood_cases_correct": self.ood_cases_correct,
"ood_cases_wrong": self.ood_cases_wrong,
"ood_accuracy": self.ood_accuracy,
"ood_ratio": self.ood_ratio,
"obligation_2_ratio_satisfied": self.obligation_2_ratio_satisfied,
"obligation_2_wrong_zero": self.obligation_2_wrong_zero,
"obligation_2_passed": self.obligation_2_passed,
"refusal_reason": self.refusal_reason,
}
def _read_report(path: Path) -> dict[str, Any]:
if not path.exists():
raise OodRatioError(f"report not found: {path}")
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise OodRatioError(f"invalid JSON in {path}: {exc}") from exc
def _refused_report(lane_id: str, reason: str) -> OodRatioReport:
return OodRatioReport(
lane_id=lane_id,
public_cases_total=0,
public_cases_correct=0,
public_accuracy=0.0,
ood_cases_total=0,
ood_cases_correct=0,
ood_cases_wrong=0,
ood_accuracy=0.0,
ood_ratio=0.0,
obligation_2_ratio_satisfied=False,
obligation_2_wrong_zero=False,
obligation_2_passed=False,
refusal_reason=reason,
)
def evaluate_ood_ratio(
*,
lane_id: str = "B3_bounded_grammar",
public_report_path: Path = DEFAULT_B3_REPORT,
ood_report_path: Path = DEFAULT_OOD_REPORT,
) -> OodRatioReport:
"""Compute obligation-#2 OOD ratio for a lane.
Reads the public B3 report and the OOD lane report, extracts
accuracy figures, and checks both gates:
1. ``ood_ratio >= 0.95`` the ratio gate.
2. ``ood_cases_wrong == 0`` the wrong-zero gate.
Both must hold for ``obligation_2_passed`` to be True.
Refusal: public_accuracy == 0 (no baseline to compare against).
"""
try:
pub_data = _read_report(public_report_path)
except OodRatioError as exc:
return _refused_report(lane_id, str(exc))
try:
ood_data = _read_report(ood_report_path)
except OodRatioError as exc:
return _refused_report(lane_id, str(exc))
pub_metrics = pub_data.get("metrics", {})
pub_total = pub_metrics.get("cases_total", 0)
pub_correct = pub_metrics.get("correct", 0)
if not isinstance(pub_total, int) or not isinstance(pub_correct, int):
return _refused_report(lane_id, "public report metrics missing or wrong type")
if pub_total == 0:
return _refused_report(lane_id, "public report has zero cases — no baseline")
public_accuracy = pub_correct / pub_total
if public_accuracy == 0.0:
return _refused_report(
lane_id, "public_accuracy is 0 — no meaningful baseline for ratio"
)
ood_metrics = ood_data.get("metrics", {})
ood_total = ood_metrics.get("cases_total", 0)
ood_correct = ood_metrics.get("correct", 0)
ood_wrong = ood_metrics.get("wrong", 0)
if not isinstance(ood_total, int) or not isinstance(ood_correct, int):
return _refused_report(lane_id, "OOD report metrics missing or wrong type")
if ood_total == 0:
return _refused_report(lane_id, "OOD report has zero cases")
ood_accuracy = ood_correct / ood_total
ood_ratio = ood_accuracy / public_accuracy
ratio_satisfied = ood_ratio >= OOD_RATIO_GATE
wrong_zero = ood_wrong == 0
passed = ratio_satisfied and wrong_zero
refusal_reason = ""
if not passed:
parts = []
if not ratio_satisfied:
parts.append(
f"ratio {ood_ratio:.4f} < gate {OOD_RATIO_GATE} "
f"(ood_accuracy={ood_accuracy:.4f}, public_accuracy={public_accuracy:.4f})"
)
if not wrong_zero:
parts.append(f"{ood_wrong} OOD case(s) wrong")
refusal_reason = "; ".join(parts)
return OodRatioReport(
lane_id=lane_id,
public_cases_total=pub_total,
public_cases_correct=pub_correct,
public_accuracy=public_accuracy,
ood_cases_total=ood_total,
ood_cases_correct=ood_correct,
ood_cases_wrong=ood_wrong,
ood_accuracy=ood_accuracy,
ood_ratio=ood_ratio,
obligation_2_ratio_satisfied=ratio_satisfied,
obligation_2_wrong_zero=wrong_zero,
obligation_2_passed=passed,
refusal_reason=refusal_reason,
)
def emit_ood_ratio_report(report: OodRatioReport, out_path: Path) -> None:
"""Write the deterministic obligation-#2 audit report."""
out_path.write_text(
json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)

View file

@ -754,6 +754,53 @@ def cmd_capability_depth_curve(args: argparse.Namespace) -> int:
return 0 if report.obligation_6_assertion_holds else 1
def cmd_capability_ood_ratio(args: argparse.Namespace) -> int:
"""ADR-0114a Obligation #2 — OOD surface variation ratio auditor.
Reads the B3 public ``report.json`` and the OOD lane ``report.json``,
computes ``ood_ratio = ood_accuracy / public_accuracy``, and exits 0
iff ratio >= 0.95 AND ood wrong == 0. Writes report to ``--out``
(default: ``evals/obligation_2_ood_ratio/<lane_id>.json``)."""
from pathlib import Path
from core.capability.ood_ratio import (
emit_ood_ratio_report,
evaluate_ood_ratio,
)
from evals.obligation_2_ood_ratio.v1.runner import build_report, load_cases, write_report as write_ood_report
_repo_root = Path(__file__).resolve().parent.parent
# Regenerate OOD report so auditor always reads fresh results.
ood_report_path = _repo_root / "evals" / "obligation_2_ood_ratio" / "v1" / "report.json"
ood_cases = load_cases()
ood_runner_report = build_report(ood_cases)
write_ood_report(ood_runner_report, ood_report_path)
report = evaluate_ood_ratio()
out_path = Path(args.out) if args.out else (
_repo_root
/ "evals" / "obligation_2_ood_ratio"
/ f"{report.lane_id}.json"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
emit_ood_ratio_report(report, out_path)
if args.json:
print(json.dumps(report.as_dict(), indent=2, sort_keys=True))
else:
print(f"lane: {report.lane_id}")
print(f"public_accuracy: {report.public_accuracy:.4f} ({report.public_cases_correct}/{report.public_cases_total})")
print(f"ood_accuracy: {report.ood_accuracy:.4f} ({report.ood_cases_correct}/{report.ood_cases_total})")
print(f"ood_ratio: {report.ood_ratio:.4f}")
print(f"obligation_2_ratio_satisfied:{report.obligation_2_ratio_satisfied}")
print(f"obligation_2_wrong_zero: {report.obligation_2_wrong_zero}")
print(f"obligation_2_passed: {report.obligation_2_passed}")
print(f"artifact: {out_path}")
if report.refusal_reason:
print(f"refusal_reason: {report.refusal_reason}")
return 0 if report.obligation_2_passed else 1
def cmd_pack_list(args: argparse.Namespace) -> int:
"""List compiled language packs."""
from language_packs import list_packs
@ -3106,6 +3153,17 @@ def build_parser() -> argparse.ArgumentParser:
help="output path for the depth-curve report (default: evals/obligation_6_depth_curve/<lane_id>.json)",
)
capability_depth_curve.set_defaults(func=cmd_capability_depth_curve)
capability_ood_ratio = capability_sub.add_parser(
"ood-ratio",
help="ADR-0114a Obligation #2 — OOD surface variation ratio auditor for B3",
)
capability_ood_ratio.add_argument("--json", action="store_true", help="emit machine-readable JSON")
capability_ood_ratio.add_argument(
"--out",
default=None,
help="output path for the audit report (default: evals/obligation_2_ood_ratio/<lane_id>.json)",
)
capability_ood_ratio.set_defaults(func=cmd_capability_ood_ratio)
pack = subparsers.add_parser("pack", help="inspect and verify language packs")
pack_sub = pack.add_subparsers(dest="pack_command", metavar="pack-command", required=True)

View file

@ -0,0 +1,212 @@
# ADR-0114a.2 — OOD-Ratio Auditor (Obligation #2 wired for B3)
**Status:** Accepted
**Date:** 2026-05-23
**Author:** CORE agents
**Depends on:** ADR-0114a (anti-overfitting obligations), ADR-0118a (OOD methodology), ADR-0131.3 (B3 grammar contract), PR #189 (ADR-0114a.10 pack-provenance auditor pattern)
---
## Context
ADR-0114a Obligation #2 requires:
> `ood_score.py`'s **OOD/public ratio for the domain's lanes ≥ 0.95**.
The spirit: a pattern-matcher overfits to surface distributions in the public split
and falls off when surface varies; a deterministic reasoner stays approximately flat.
The 0.95 ratio means OOD accuracy must be at least 95% of public accuracy.
ADR-0118a established the methodological blueprint for OOD generation: variants hold
the underlying mathematical graph constant while rotating surface form across three
classes (entity names, unit nouns, sentence ordering). This ADR applies that
methodology to the B3 bounded-grammar lane (ADR-0131.3), which is the lane whose
pipeline (parser → graph → solver → verifier) exercises the end-to-end math
candidate graph contract.
---
## Decision
### OOD case set
**`evals/obligation_2_ood_ratio/v1/cases.jsonl`** — 35 OOD cases, each a
surface-varied sibling of one of B3's 35 `solved_correct` public cases (b3-001
through b3-035). Every case carries a `public_sibling_case_id` field for audit
traceability.
**Three surface-variation families:**
| Family | Description | Coverage |
|---|---|---|
| Entity-name | `Sam` rotated through closed pool; two-entity cases rotate both names | 33 of 35 cases (all named-entity shapes) |
| Unit-noun | Count-noun unit rotated through closed pool when the sibling's unit is in the substitution set | 19 of 35 cases |
| there_are_count | Unit-noun substituted (unit acts as implicit entity; no named entity) | 2 of 35 cases |
**Closed substitution pools (pinned in this ADR):**
Entity-name pool (10 names):
```
Maya, Liam, Noah, Diana, Felix, Priya, Omar, Rosa, Jun, Kai
```
These names do not appear in B3's public cases (`Sam`, `Tom` are the only names
in B3). Each OOD case substitutes at least one name from this pool.
Unit-noun substitution pool (7 count nouns, all allowed by the B3 parser):
```
oranges, marbles, pencils, books, stamps, coins, balls
```
These are in the parser's `allowed_count_nouns` set (math_parser.py).
Units NOT in the substitution pool (kept unchanged): `dollars`, `feet`, `hours`,
`cookies` — these are currency, physical dimension, or time units where
substitution would produce semantically incoherent rate problems.
**Grammar contract:** every OOD case is parseable by the B3 bounded grammar
(ADR-0131.3 `grammar.md`). Out-of-grammar forms are adversarial territory
(obligation #8), not OOD territory. No case in this set is expected to refuse.
**No semantic perturbations:** values, operations, and mathematical invariants
are identical to the public sibling. OOD ≠ perturbation (obligation #5's domain).
### OOD runner
**`evals/obligation_2_ood_ratio/v1/runner.py`** — runs OOD cases through the
B3 parser → solver → verifier pipeline; emits `report.json` mirroring B3's
runner shape. All cases carry `expected == "solved_correct"` because OOD cases
are surface-varied siblings of B3's solved_correct public cases only.
### Auditor
**`core/capability/ood_ratio.py`** — external auditor independent of the runner.
Reads B3's public `report.json` and the OOD lane `report.json`, computes:
```
ood_ratio = ood_accuracy / public_accuracy
```
Emits `OodRatioReport` with two separate gates:
1. **Ratio gate**: `ood_ratio >= 0.95``obligation_2_ratio_satisfied`
2. **Wrong-zero gate**: `ood_wrong == 0``obligation_2_wrong_zero`
Both must hold for `obligation_2_passed = True`.
Gate threshold `0.95` is pinned; changing it requires a new ADR.
Refusal conditions: missing report files, `public_accuracy == 0` (no baseline).
Module shape mirrors `core/capability/pack_provenance.py` (ADR-0114a.10, PR #189)
and `evals/gsm8k_math/scoring/depth_curve.py` (ADR-0114a.6, PR #190).
### CLI
**`core capability ood-ratio`** — regenerates the OOD runner report, runs the
auditor, writes `evals/obligation_2_ood_ratio/<lane_id>.json`. Exit 0 iff both
gates pass.
---
## Invariants
### `obligation_2_ratio_gate_pinned`
The gate threshold is 0.95 in `core/capability/ood_ratio.OOD_RATIO_GATE`.
Changing it requires a new ADR.
### `obligation_2_ood_cases_in_grammar`
Every case in `evals/obligation_2_ood_ratio/v1/cases.jsonl` is parseable by
`generate.math_parser.parse_problem` without `ParseError`.
### `obligation_2_sibling_traceability`
Every OOD case's `public_sibling_case_id` resolves to a real case in
`evals/math_bounded_grammar/v1/cases.jsonl`.
### `obligation_2_entity_pool`
Every OOD named-entity case (all shapes except `there_are_count`) uses at least
one entity name from the closed pool above; no public B3 sibling uses pool names.
### `obligation_2_unit_pool`
OOD cases whose public sibling's unit appears in the substitution pool use a
different unit than their sibling.
### `obligation_2_wrong_zero_gate`
Separate from the ratio gate: `ood_wrong == 0` is independently required.
### `obligation_2_determinism`
Same OOD cases + same report files produce byte-equal auditor output.
---
## Acceptance evidence
Accepted when:
- `evals/obligation_2_ood_ratio/v1/cases.jsonl` contains 35 cases (≥ 30)
- `python3 -m evals.obligation_2_ood_ratio.v1.runner` exits 0 with `wrong == 0`
- `python3 -m core.cli capability ood-ratio` exits 0 with `ratio >= 0.95`
- `tests/test_adr_0114a_2_ood_ratio.py` is green (16 tests)
- B3 public lane (50/50) unchanged
- Obligation #10 pack-provenance auditor still passes
**Measured results on this PR:**
| Metric | Value |
|---|---|
| B3 public accuracy | 50/50 = 1.0000 |
| OOD accuracy | 35/35 = 1.0000 |
| OOD/public ratio | 1.0000 |
| wrong (OOD) | 0 |
| `obligation_2_passed` | True |
**Shape family breakdown:**
| Shape | OOD cases | Entity change | Unit change |
|---|---|---|---|
| canonical_has_buys | 16 | ✓ | mixed (count nouns only) |
| transfer | 2 | ✓ (both actors) | ✓ |
| multiply | 2 | ✓ | ✓ |
| divide | 1 | ✓ | ✓ |
| apply_rate | 3 | ✓ | ✓ (item unit) |
| compare_additive | 2 | ✓ (both actors) | ✓ |
| compare_multiplicative | 7 | ✓ (both actors) | mixed |
| there_are_count | 2 | unit-as-entity | ✓ |
| substance_qualifier | 1 | ✓ | — (feet, not in pool) |
---
## Obligation discharge summary (for B3)
| Obligation | Status under ADR-0114a.2 |
|---|---|
| #2 OOD surface variation ratio | **Discharged for B3** |
| All others | See ADR-0114a, ADR-0118a, ADR-0114a.10 |
---
## Consequences
- ADR-0114a Obligation #2 is now executable evidence for B3: a deterministic
local command (`core capability ood-ratio`) produces the ratio.
- The substitution pools are frozen; extending them requires a new ADR or a PR
that updates this ADR's pool tables and re-runs the tests.
- B1 and B2 OOD equivalents are deferred to separate sub-ADRs (mirror PR #189's
structure) because those lanes have different pipeline paths.
- The wrong-zero gate catches any case where a surface substitution accidentally
produces a valid but incorrectly-solved problem (a dataset authoring error, not
an architecture failure).
---
## Out of scope
- B1 (symbolic equivalence) + B2 (teaching corpus) OOD equivalents.
- Cross-grammar fuzzing and paraphrases (adversarial, obligation #8).
- Reasoning-isolation perturbations (obligation #5).
- Compositional-depth curve (obligation #6).
- Composite-gate or promotion-gate wiring.

View file

@ -0,0 +1,17 @@
{
"adr": "0114a.2",
"lane_id": "B3_bounded_grammar",
"obligation_2_passed": true,
"obligation_2_ratio_satisfied": true,
"obligation_2_wrong_zero": true,
"ood_accuracy": 1.0,
"ood_cases_correct": 35,
"ood_cases_total": 35,
"ood_cases_wrong": 0,
"ood_ratio": 1.0,
"public_accuracy": 1.0,
"public_cases_correct": 50,
"public_cases_total": 50,
"refusal_reason": "",
"schema_version": 1
}

View file

View file

@ -0,0 +1,35 @@
{"case_id": "ood-001", "problem": "Maya has 5 oranges. Maya buys 3 oranges. How many oranges does Maya have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "oranges", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-001"}
{"case_id": "ood-002", "problem": "Liam has 10 marbles. Liam eats 3 marbles. How many marbles does Liam have?", "expected": "solved_correct", "expected_answer": 7.0, "expected_unit": "marbles", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-002"}
{"case_id": "ood-003", "problem": "Noah has 8 pencils. Diana has 2 pencils. Noah gives 3 pencils to Diana. How many pencils does Diana have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "pencils", "shape_category": "transfer", "public_sibling_case_id": "b3-003"}
{"case_id": "ood-004", "problem": "Felix has 5 books. Felix doubles his books. How many books does Felix have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "books", "shape_category": "multiply", "public_sibling_case_id": "b3-004"}
{"case_id": "ood-005", "problem": "Priya has 12 stamps. Priya splits them evenly into 3 groups and keeps one group. How many stamps does Priya have?", "expected": "solved_correct", "expected_answer": 4.0, "expected_unit": "stamps", "shape_category": "divide", "public_sibling_case_id": "b3-005"}
{"case_id": "ood-006", "problem": "Each marble costs $2. Omar buys 5 marbles. How much does Omar spend?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "dollars", "shape_category": "apply_rate", "public_sibling_case_id": "b3-006"}
{"case_id": "ood-007", "problem": "Rosa has 5 oranges. Jun has 3 more oranges than Rosa. How many oranges does Jun have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "oranges", "shape_category": "compare_additive", "public_sibling_case_id": "b3-007"}
{"case_id": "ood-008", "problem": "Kai has 5 marbles. Maya has 2 fewer marbles than Kai. How many marbles does Maya have?", "expected": "solved_correct", "expected_answer": 3.0, "expected_unit": "marbles", "shape_category": "compare_additive", "public_sibling_case_id": "b3-008"}
{"case_id": "ood-009", "problem": "Liam has 5 pencils. Noah has 3 times as many pencils as Liam. How many pencils does Noah have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "pencils", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-009"}
{"case_id": "ood-010", "problem": "Diana has 5 books. Felix has twice as many books as Diana. How many books does Felix have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "books", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-010"}
{"case_id": "ood-011", "problem": "Priya has 10 stamps. Omar has half as many stamps as Priya. How many stamps does Omar have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "stamps", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-011"}
{"case_id": "ood-012", "problem": "There are 12 cookies in the box. Rosa eats 4 cookies. How many cookies do they have in total?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "cookies", "shape_category": "there_are_count", "public_sibling_case_id": "b3-012"}
{"case_id": "ood-013", "problem": "There are 5 balls. Balls eats 3 balls. How many balls do they have left?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "balls", "shape_category": "there_are_count", "public_sibling_case_id": "b3-013"}
{"case_id": "ood-014", "problem": "Maya has 10 feet of rope. Maya buys 5 feet. How many feet does Maya have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "feet", "shape_category": "substance_qualifier", "public_sibling_case_id": "b3-014"}
{"case_id": "ood-015", "problem": "Liam has 5 coins. Liam buys 3 coins. Liam eats 2 coins. How many coins does Liam have?", "expected": "solved_correct", "expected_answer": 6.0, "expected_unit": "coins", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-015"}
{"case_id": "ood-016", "problem": "Noah has 5 marbles. Noah gets 3 marbles. How many marbles does Noah have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "marbles", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-016"}
{"case_id": "ood-017", "problem": "Diana has 5 pencils. Diana finds 3 pencils. How many pencils does Diana have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "pencils", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-017"}
{"case_id": "ood-018", "problem": "Felix has 5 books. Felix receives 3 books. How many books does Felix have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "books", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-018"}
{"case_id": "ood-019", "problem": "Priya has 5 dollars. Priya earns 3 dollars. How many dollars does Priya have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "dollars", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-019"}
{"case_id": "ood-020", "problem": "Omar has 5 stamps. Omar adds 3 stamps. How many stamps does Omar have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "stamps", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-020"}
{"case_id": "ood-021", "problem": "Rosa has 5 oranges. Rosa eats 3 oranges. How many oranges does Rosa have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "oranges", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-021"}
{"case_id": "ood-022", "problem": "Kai has 5 marbles. Kai loses 3 marbles. How many marbles does Kai have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "marbles", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-022"}
{"case_id": "ood-023", "problem": "Maya has 5 pencils. Maya sells 3 pencils. How many pencils does Maya have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "pencils", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-023"}
{"case_id": "ood-024", "problem": "Liam has 5 books. Liam donates 3 books. How many books does Liam have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "books", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-024"}
{"case_id": "ood-025", "problem": "Noah has 5 balls. Noah uses 3 balls. How many balls does Noah have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "balls", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-025"}
{"case_id": "ood-026", "problem": "Diana has 5 dollars. Diana spends 3 dollars. How many dollars does Diana have?", "expected": "solved_correct", "expected_answer": 2.0, "expected_unit": "dollars", "shape_category": "canonical_has_buys", "public_sibling_case_id": "b3-026"}
{"case_id": "ood-027", "problem": "Felix has 5 coins. Priya has 3 coins. Felix sends 2 coins to Priya. How many coins does Priya have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "coins", "shape_category": "transfer", "public_sibling_case_id": "b3-027"}
{"case_id": "ood-028", "problem": "Omar has 5 stamps. Omar triples his stamps. How many stamps does Omar have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "stamps", "shape_category": "multiply", "public_sibling_case_id": "b3-028"}
{"case_id": "ood-029", "problem": "Each marble costs $5. Rosa buys 3 marbles. How much does Rosa pay?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "dollars", "shape_category": "apply_rate", "public_sibling_case_id": "b3-029"}
{"case_id": "ood-030", "problem": "Each hour costs $10. Kai buys 5 hours. How much does Kai earn?", "expected": "solved_correct", "expected_answer": 50.0, "expected_unit": "dollars", "shape_category": "apply_rate", "public_sibling_case_id": "b3-030"}
{"case_id": "ood-031", "problem": "Maya has 5 dollars. Liam has twice as much dollars as Maya. How many dollars does Liam have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "dollars", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-031"}
{"case_id": "ood-032", "problem": "Noah has 5 dollars. Diana has 3 times as much dollars as Noah. How many dollars does Diana have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "dollars", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-032"}
{"case_id": "ood-033", "problem": "Felix has 10 dollars. Priya has half as much dollars as Felix. How many dollars does Priya have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "dollars", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-033"}
{"case_id": "ood-034", "problem": "Omar has 5 oranges. Rosa has twice the number of oranges as Omar. How many oranges does Rosa have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "oranges", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-034"}
{"case_id": "ood-035", "problem": "Kai has 5 marbles. Maya has 3 times the number of marbles as Kai. How many marbles does Maya have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "marbles", "shape_category": "compare_multiplicative", "public_sibling_case_id": "b3-035"}

View file

@ -0,0 +1,232 @@
{
"adr": "0114a.2",
"exit_criterion": {
"ood_accuracy_min": 0.95,
"passed": true,
"wrong_max": 0
},
"metrics": {
"cases_total": 35,
"correct": 35,
"ood_accuracy": 1.0,
"overall_pass": true,
"refused": 0,
"wrong": 0,
"wrong_count_is_zero": true
},
"per_case": [
{
"case_id": "ood-001",
"public_sibling_case_id": "b3-001",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-002",
"public_sibling_case_id": "b3-002",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-003",
"public_sibling_case_id": "b3-003",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-004",
"public_sibling_case_id": "b3-004",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-005",
"public_sibling_case_id": "b3-005",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-006",
"public_sibling_case_id": "b3-006",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-007",
"public_sibling_case_id": "b3-007",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-008",
"public_sibling_case_id": "b3-008",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-009",
"public_sibling_case_id": "b3-009",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-010",
"public_sibling_case_id": "b3-010",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-011",
"public_sibling_case_id": "b3-011",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-012",
"public_sibling_case_id": "b3-012",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-013",
"public_sibling_case_id": "b3-013",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-014",
"public_sibling_case_id": "b3-014",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-015",
"public_sibling_case_id": "b3-015",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-016",
"public_sibling_case_id": "b3-016",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-017",
"public_sibling_case_id": "b3-017",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-018",
"public_sibling_case_id": "b3-018",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-019",
"public_sibling_case_id": "b3-019",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-020",
"public_sibling_case_id": "b3-020",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-021",
"public_sibling_case_id": "b3-021",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-022",
"public_sibling_case_id": "b3-022",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-023",
"public_sibling_case_id": "b3-023",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-024",
"public_sibling_case_id": "b3-024",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-025",
"public_sibling_case_id": "b3-025",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-026",
"public_sibling_case_id": "b3-026",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-027",
"public_sibling_case_id": "b3-027",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-028",
"public_sibling_case_id": "b3-028",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-029",
"public_sibling_case_id": "b3-029",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-030",
"public_sibling_case_id": "b3-030",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-031",
"public_sibling_case_id": "b3-031",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-032",
"public_sibling_case_id": "b3-032",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-033",
"public_sibling_case_id": "b3-033",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-034",
"public_sibling_case_id": "b3-034",
"reason": "",
"verdict": "correct"
},
{
"case_id": "ood-035",
"public_sibling_case_id": "b3-035",
"reason": "",
"verdict": "correct"
}
],
"sample_count": 35,
"sample_path": "evals/obligation_2_ood_ratio/v1/cases.jsonl",
"schema_version": 1
}

View file

@ -0,0 +1,185 @@
"""ADR-0114a Obligation #2 — OOD surface variation ratio runner.
Runs the OOD case set through the B3 bounded-grammar pipeline
(parser solver verifier) and emits a report mirroring the shape
of the B3 public runner's report.json.
All OOD cases carry ``expected == "solved_correct"`` because the OOD
set contains only surface-varied siblings of B3's solved_correct public
cases. The runner verdict table is a subset of B3's full table:
solved_correct + correct correct
solved_correct + wrong wrong
solved_correct + refused refused (dataset bug if this fires)
``wrong == 0`` is a hard gate enforced by the exit code and by the
auditor in core/capability/ood_ratio.py.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from generate.math_parser import ParseError, parse_problem
from generate.math_problem_graph import MathProblemGraph
from generate.math_solver import SolveError, solve
from generate.math_verifier import verify
_HERE = Path(__file__).resolve().parent
_CASES_PATH = _HERE / "cases.jsonl"
_REPORT_PATH = _HERE / "report.json"
@dataclass(frozen=True, slots=True)
class PipelineResult:
outcome: str # "correct" | "wrong" | "refused"
actual_answer: float | None
actual_unit: str | None
reason: str
def _run_pipeline(
problem: str, expected_answer: float | None, expected_unit: str | None
) -> PipelineResult:
try:
graph: MathProblemGraph = parse_problem(problem)
except ParseError as exc:
return PipelineResult("refused", None, None, f"parser: {exc}")
try:
trace = solve(graph)
except SolveError as exc:
return PipelineResult("refused", None, None, f"solver: {exc}")
verdict = verify(graph, trace)
if not verdict.passed:
return PipelineResult(
"wrong",
trace.answer_value,
trace.answer_unit,
f"verifier: {verdict.reason}",
)
if expected_answer is not None:
if expected_unit is not None and expected_unit != "":
if trace.answer_unit != expected_unit:
return PipelineResult(
"wrong",
trace.answer_value,
trace.answer_unit,
f"unit mismatch: got {trace.answer_unit!r}, expected {expected_unit!r}",
)
if trace.answer_value != expected_answer:
return PipelineResult(
"wrong",
trace.answer_value,
trace.answer_unit,
f"answer mismatch: got {trace.answer_value!r}, expected {expected_answer!r}",
)
return PipelineResult("correct", trace.answer_value, trace.answer_unit, "")
@dataclass(frozen=True, slots=True)
class CaseVerdict:
case_id: str
verdict: str # "correct" | "wrong" | "refused"
public_sibling_case_id: str
reason: str
def score_case(case: dict[str, Any]) -> CaseVerdict:
case_id = case["case_id"]
problem = case["problem"]
expected_answer = case["expected_answer"]
expected_unit = case["expected_unit"]
public_sibling = case.get("public_sibling_case_id", "")
result = _run_pipeline(problem, expected_answer, expected_unit)
if result.outcome == "correct":
return CaseVerdict(case_id, "correct", public_sibling, "")
elif result.outcome == "wrong":
return CaseVerdict(case_id, "wrong", public_sibling, result.reason)
else:
return CaseVerdict(case_id, "refused", public_sibling, result.reason)
def build_report(cases: list[dict[str, Any]]) -> dict[str, Any]:
verdicts = [score_case(c) for c in cases]
total = len(verdicts)
correct = sum(1 for v in verdicts if v.verdict == "correct")
wrong = sum(1 for v in verdicts if v.verdict == "wrong")
refused = sum(1 for v in verdicts if v.verdict == "refused")
ood_accuracy = correct / total if total else 0.0
wrong_count_is_zero = wrong == 0
passed = wrong_count_is_zero and (ood_accuracy >= 0.95)
metrics = {
"cases_total": total,
"correct": correct,
"wrong": wrong,
"refused": refused,
"ood_accuracy": ood_accuracy,
"wrong_count_is_zero": wrong_count_is_zero,
"overall_pass": passed,
}
per_case = [
{
"case_id": v.case_id,
"verdict": v.verdict,
"public_sibling_case_id": v.public_sibling_case_id,
"reason": v.reason,
}
for v in verdicts
]
return {
"schema_version": 1,
"adr": "0114a.2",
"sample_path": "evals/obligation_2_ood_ratio/v1/cases.jsonl",
"sample_count": total,
"metrics": metrics,
"exit_criterion": {
"ood_accuracy_min": 0.95,
"wrong_max": 0,
"passed": passed,
},
"per_case": per_case,
}
def write_report(report: dict[str, Any], path: Path = _REPORT_PATH) -> None:
path.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def load_cases(path: Path = _CASES_PATH) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
records.append(json.loads(line))
return records
def main() -> int:
cases = load_cases()
report = build_report(cases)
write_report(report)
print(f"Metrics: {report['metrics']}")
return 0 if report["exit_criterion"]["passed"] else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,343 @@
"""ADR-0114a Obligation #2 — OOD surface variation ratio auditor tests.
Pins the invariants:
- Dataset integrity: 30 cases; every case has public_sibling_case_id
resolving to a real B3 case; every case is in-grammar.
- Entity-name substitution: every OOD case has a different entity name
than its public sibling.
- Unit-noun substitution: every OOD case with a substitutable unit has
a different unit than its public sibling.
- Auditor: ratio computed correctly; gate at 0.95 pinned; wrong == 0
is a separate gate.
- Refusal on missing public report or missing OOD report.
- Determinism: report byte-equal across runs.
- Snapshot: current main satisfies obligation #2 on the shipped OOD set.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from core.capability.ood_ratio import (
OOD_RATIO_GATE,
emit_ood_ratio_report,
evaluate_ood_ratio,
)
from evals.obligation_2_ood_ratio.v1.runner import build_report, load_cases
_REPO_ROOT = Path(__file__).resolve().parent.parent
_B3_CASES = _REPO_ROOT / "evals" / "math_bounded_grammar" / "v1" / "cases.jsonl"
_B3_REPORT = _REPO_ROOT / "evals" / "math_bounded_grammar" / "v1" / "report.json"
_OOD_CASES = _REPO_ROOT / "evals" / "obligation_2_ood_ratio" / "v1" / "cases.jsonl"
# Entity-name substitution pool shipped with this ADR.
_ENTITY_POOL = {"Maya", "Liam", "Noah", "Diana", "Felix", "Priya", "Omar", "Rosa", "Jun", "Kai"}
# Unit-noun substitution pool (count nouns substituted in OOD cases).
_UNIT_SUBSTITUTION_POOL = {"apples", "candies", "birds", "sheets", "books"}
# ---------------------------------------------------------------------------
# Dataset integrity
# ---------------------------------------------------------------------------
def test_ood_case_count_at_least_30() -> None:
cases = load_cases(_OOD_CASES)
assert len(cases) >= 30, f"OOD set has {len(cases)} cases, need ≥ 30"
def test_every_ood_case_has_public_sibling_field() -> None:
cases = load_cases(_OOD_CASES)
for case in cases:
assert "public_sibling_case_id" in case, (
f"{case['case_id']}: missing public_sibling_case_id"
)
assert isinstance(case["public_sibling_case_id"], str) and case["public_sibling_case_id"], (
f"{case['case_id']}: public_sibling_case_id is empty"
)
def test_every_public_sibling_resolves_to_real_b3_case() -> None:
ood_cases = load_cases(_OOD_CASES)
b3_cases = {
json.loads(l)["case_id"]
for l in _B3_CASES.read_text(encoding="utf-8").splitlines()
if l.strip()
}
for case in ood_cases:
sibling = case["public_sibling_case_id"]
assert sibling in b3_cases, (
f"{case['case_id']}: public_sibling_case_id {sibling!r} not in B3 public cases"
)
def test_every_ood_case_is_in_grammar() -> None:
from generate.math_parser import ParseError, parse_problem
cases = load_cases(_OOD_CASES)
failed: list[str] = []
for case in cases:
try:
parse_problem(case["problem"])
except ParseError as exc:
failed.append(f"{case['case_id']}: {exc}")
assert not failed, "OOD cases failed grammar check:\n" + "\n".join(failed)
# ---------------------------------------------------------------------------
# Entity-name substitution
# ---------------------------------------------------------------------------
def _extract_entities_from_problem(problem: str) -> set[str]:
"""Heuristic: title-cased words that are not common English words."""
_common = {"There", "How", "Each", "An", "Sam", "Tom", "The"}
words = problem.split()
return {
w.rstrip(".,?") for w in words
if w[0].isupper() and w.rstrip(".,?") not in _common and not w[0].isdigit()
}
def test_every_ood_case_has_different_entity_than_sibling() -> None:
ood_cases = load_cases(_OOD_CASES)
b3_by_id: dict[str, dict] = {}
for line in _B3_CASES.read_text(encoding="utf-8").splitlines():
if line.strip():
c = json.loads(line)
b3_by_id[c["case_id"]] = c
failures: list[str] = []
for case in ood_cases:
sibling = b3_by_id[case["public_sibling_case_id"]]
ood_entities = _extract_entities_from_problem(case["problem"])
pub_entities = _extract_entities_from_problem(sibling["problem"])
shape = case.get("shape_category", "")
# there_are_count shape: the unit noun acts as implicit entity — no named
# entity is present, so the entity-pool check does not apply. The unit-noun
# substitution test (a separate test) covers surface diversity for that shape.
if shape == "there_are_count":
continue
ood_from_pool = ood_entities & _ENTITY_POOL
pub_in_pool = pub_entities & _ENTITY_POOL
if not ood_from_pool:
failures.append(
f"{case['case_id']}: no OOD entity from pool {_ENTITY_POOL}; "
f"found {ood_entities}"
)
if pub_in_pool:
failures.append(
f"{case['case_id']}: public sibling already uses pool entity {pub_in_pool}; "
f"public entities={pub_entities}"
)
assert not failures, "Entity substitution violations:\n" + "\n".join(failures)
# ---------------------------------------------------------------------------
# Unit-noun substitution
# ---------------------------------------------------------------------------
def test_ood_cases_with_substitutable_unit_differ_from_sibling() -> None:
ood_cases = load_cases(_OOD_CASES)
b3_by_id: dict[str, dict] = {}
for line in _B3_CASES.read_text(encoding="utf-8").splitlines():
if line.strip():
c = json.loads(line)
b3_by_id[c["case_id"]] = c
failures: list[str] = []
for case in ood_cases:
sibling = b3_by_id[case["public_sibling_case_id"]]
pub_unit = sibling.get("expected_unit") or ""
ood_unit = case.get("expected_unit") or ""
if pub_unit in _UNIT_SUBSTITUTION_POOL:
if ood_unit == pub_unit:
failures.append(
f"{case['case_id']}: public unit {pub_unit!r} is in substitution pool "
f"but OOD unit is unchanged ({ood_unit!r})"
)
assert not failures, "Unit substitution violations:\n" + "\n".join(failures)
# ---------------------------------------------------------------------------
# Auditor ratio computation
# ---------------------------------------------------------------------------
def _make_public_report(total: int, correct: int, tmp_path: Path) -> Path:
p = tmp_path / "public_report.json"
p.write_text(json.dumps({
"schema_version": 1,
"metrics": {"cases_total": total, "correct": correct, "wrong": 0, "refused": 0}
}), encoding="utf-8")
return p
def _make_ood_report(total: int, correct: int, wrong: int, tmp_path: Path) -> Path:
p = tmp_path / "ood_report.json"
p.write_text(json.dumps({
"schema_version": 1,
"metrics": {"cases_total": total, "correct": correct, "wrong": wrong, "refused": 0}
}), encoding="utf-8")
return p
def test_auditor_ratio_computation_exact(tmp_path: Path) -> None:
pub = _make_public_report(50, 50, tmp_path)
ood = _make_ood_report(35, 35, 0, tmp_path)
report = evaluate_ood_ratio(public_report_path=pub, ood_report_path=ood)
assert report.public_accuracy == 1.0
assert report.ood_accuracy == 1.0
assert report.ood_ratio == 1.0
assert report.obligation_2_ratio_satisfied is True
assert report.obligation_2_wrong_zero is True
assert report.obligation_2_passed is True
def test_auditor_ratio_gate_pinned_at_0_95(tmp_path: Path) -> None:
# ratio exactly at gate passes
pub = _make_public_report(100, 100, tmp_path)
ood = _make_ood_report(100, 95, 0, tmp_path)
report = evaluate_ood_ratio(public_report_path=pub, ood_report_path=ood)
assert report.ood_ratio == pytest.approx(0.95)
assert report.obligation_2_ratio_satisfied is True
# ratio just below gate fails
ood_fail_path = tmp_path / "ood_fail.json"
ood_fail_path.write_text(json.dumps({
"schema_version": 1,
"metrics": {"cases_total": 100, "correct": 94, "wrong": 0, "refused": 0}
}), encoding="utf-8")
report_fail = evaluate_ood_ratio(public_report_path=pub, ood_report_path=ood_fail_path)
assert report_fail.ood_ratio == pytest.approx(0.94)
assert report_fail.obligation_2_ratio_satisfied is False
assert report_fail.obligation_2_passed is False
def test_gate_constant_is_0_95() -> None:
"""Gate threshold is pinned; changing requires a new ADR."""
assert OOD_RATIO_GATE == 0.95
def test_wrong_zero_gate_is_separate_from_ratio_gate(tmp_path: Path) -> None:
pub = _make_public_report(50, 50, tmp_path)
ood_wrong_path = tmp_path / "ood_wrong.json"
ood_wrong_path.write_text(json.dumps({
"schema_version": 1,
"metrics": {"cases_total": 35, "correct": 35, "wrong": 1, "refused": 0}
}), encoding="utf-8")
report = evaluate_ood_ratio(public_report_path=pub, ood_report_path=ood_wrong_path)
# ratio would pass (35+1=36 correct-ish? actually correct=35 so ratio still fine)
assert report.obligation_2_wrong_zero is False
assert report.obligation_2_passed is False
# ---------------------------------------------------------------------------
# Refusal on missing inputs
# ---------------------------------------------------------------------------
def test_auditor_refuses_on_missing_public_report(tmp_path: Path) -> None:
ood = _make_ood_report(35, 35, 0, tmp_path)
report = evaluate_ood_ratio(
public_report_path=tmp_path / "nonexistent_public.json",
ood_report_path=ood,
)
assert report.obligation_2_passed is False
assert "not found" in report.refusal_reason.lower()
def test_auditor_refuses_on_missing_ood_report(tmp_path: Path) -> None:
pub = _make_public_report(50, 50, tmp_path)
report = evaluate_ood_ratio(
public_report_path=pub,
ood_report_path=tmp_path / "nonexistent_ood.json",
)
assert report.obligation_2_passed is False
assert "not found" in report.refusal_reason.lower()
def test_auditor_refuses_when_public_accuracy_is_zero(tmp_path: Path) -> None:
pub = _make_public_report(50, 0, tmp_path)
ood = _make_ood_report(35, 35, 0, tmp_path)
report = evaluate_ood_ratio(public_report_path=pub, ood_report_path=ood)
assert report.obligation_2_passed is False
assert "baseline" in report.refusal_reason.lower()
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
def test_auditor_is_deterministic() -> None:
# Run twice against the live reports
cases = load_cases(_OOD_CASES)
ood_data = build_report(cases)
import tempfile, os
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w", encoding="utf-8") as f:
json.dump(ood_data, f)
tmp_ood = Path(f.name)
try:
r1 = evaluate_ood_ratio(public_report_path=_B3_REPORT, ood_report_path=tmp_ood)
r2 = evaluate_ood_ratio(public_report_path=_B3_REPORT, ood_report_path=tmp_ood)
assert json.dumps(r1.as_dict(), sort_keys=True) == json.dumps(r2.as_dict(), sort_keys=True)
finally:
os.unlink(tmp_ood)
def test_artifact_emission_byte_equal(tmp_path: Path) -> None:
cases = load_cases(_OOD_CASES)
ood_data = build_report(cases)
import tempfile, os
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w", encoding="utf-8") as f:
json.dump(ood_data, f)
tmp_ood = Path(f.name)
try:
report = evaluate_ood_ratio(public_report_path=_B3_REPORT, ood_report_path=tmp_ood)
out1 = tmp_path / "r1.json"
out2 = tmp_path / "r2.json"
emit_ood_ratio_report(report, out1)
emit_ood_ratio_report(report, out2)
assert out1.read_bytes() == out2.read_bytes()
finally:
os.unlink(tmp_ood)
# ---------------------------------------------------------------------------
# Snapshot: current main satisfies obligation #2
# ---------------------------------------------------------------------------
def test_snapshot_obligation_2_satisfied_on_shipped_ood_set() -> None:
"""Load-bearing snapshot: obligation #2 must be satisfied on the OOD
cases shipped with this PR against the B3 public report.json.
If this fails, either the OOD cases broke the pipeline or B3
degraded below the baseline."""
cases = load_cases(_OOD_CASES)
ood_runner_report = build_report(cases)
import tempfile, os
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w", encoding="utf-8") as f:
json.dump(ood_runner_report, f)
tmp_ood = Path(f.name)
try:
report = evaluate_ood_ratio(public_report_path=_B3_REPORT, ood_report_path=tmp_ood)
assert report.obligation_2_passed is True, (
f"obligation #2 failed: {report.refusal_reason}\n"
f"ood_ratio={report.ood_ratio:.4f} (gate={OOD_RATIO_GATE})\n"
f"ood_wrong={report.ood_cases_wrong}"
)
assert report.obligation_2_wrong_zero is True
assert report.ood_ratio >= OOD_RATIO_GATE
finally:
os.unlink(tmp_ood)