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:
parent
1f90cb6cf6
commit
1babef946e
10 changed files with 1288 additions and 0 deletions
206
core/capability/ood_ratio.py
Normal file
206
core/capability/ood_ratio.py
Normal 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",
|
||||
)
|
||||
58
core/cli.py
58
core/cli.py
|
|
@ -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)
|
||||
|
|
|
|||
212
docs/decisions/ADR-0114a.2-ood-ratio-auditor.md
Normal file
212
docs/decisions/ADR-0114a.2-ood-ratio-auditor.md
Normal 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.
|
||||
17
evals/obligation_2_ood_ratio/B3_bounded_grammar.json
Normal file
17
evals/obligation_2_ood_ratio/B3_bounded_grammar.json
Normal 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
|
||||
}
|
||||
0
evals/obligation_2_ood_ratio/__init__.py
Normal file
0
evals/obligation_2_ood_ratio/__init__.py
Normal file
0
evals/obligation_2_ood_ratio/v1/__init__.py
Normal file
0
evals/obligation_2_ood_ratio/v1/__init__.py
Normal file
35
evals/obligation_2_ood_ratio/v1/cases.jsonl
Normal file
35
evals/obligation_2_ood_ratio/v1/cases.jsonl
Normal 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"}
|
||||
232
evals/obligation_2_ood_ratio/v1/report.json
Normal file
232
evals/obligation_2_ood_ratio/v1/report.json
Normal 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
|
||||
}
|
||||
185
evals/obligation_2_ood_ratio/v1/runner.py
Normal file
185
evals/obligation_2_ood_ratio/v1/runner.py
Normal 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())
|
||||
343
tests/test_adr_0114a_2_ood_ratio.py
Normal file
343
tests/test_adr_0114a_2_ood_ratio.py
Normal 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)
|
||||
Loading…
Reference in a new issue