core/generate/math_symbolic_equivalence.py
Shay a76834cd3f
feat(ADR-0131.1): symbolic equivalence benchmark v1 + lane PASSED (#167)
ADR-0131 Benchmark 1 substrate — the primary discriminator for the
mathematics_logic expert promotion under the architecture-aligned
benchmark composite proposed in ADR-0131.

WHAT LANDED:

generate/math_symbolic_normalizer.py
  Deterministic univariate polynomial normalizer. Scope: single
  variable, integer coefficients, +/-/*/** operators, parens, no
  division, no transcendentals. Pipeline: tokenize -> recursive-
  descent parse -> expand-and-collect -> canonical string. Refusal
  is first-class via SymbolicError; out-of-scope inputs refuse
  rather than guess (preserves wrong == 0).

generate/math_symbolic_equivalence.py
  check_equivalence(a, b) -> EquivalenceVerdict
  Returns EQUIVALENT / NOT_EQUIVALENT / REFUSED with canonical
  strings + reason. Compares byte-equal canonical forms.

evals/math_symbolic_equivalence/v1/
  cases.jsonl   — 30 hand-curated cases across 18 algebraic
                  identity categories + 2 out-of-scope refusals.
                  Coverage: commutative, distributive, square +
                  cube of binomial, difference of squares, FOIL,
                  collect like terms, zero cancellation, factoring,
                  exponent combination, unary negation.
  runner.py     — CLI entry point. Loads cases, builds report,
                  writes JSON, exits 0/1 on gate pass/fail.
  README.md     — methodology, scope, dataset categorization,
                  exit criterion, baseline result.

tests/
  test_math_symbolic_normalizer.py     — 44 tests covering parser,
                                          algebra primitives,
                                          canonical-form invariants,
                                          and every refusal path.
  test_math_symbolic_equivalence.py    — 16 tests on the public
                                          check_equivalence API.
  test_adr_0131_1_symbolic_equivalence_lane.py
                                       — 8 tests gating the lane:
                                          dataset integrity, exit
                                          criterion, wrong == 0,
                                          determinism (byte-equal
                                          report across runs).

EMPIRICAL RESULT (the lane PASSED):

  correct       = 30 / 30   (100.0%)
  wrong         =  0 / 30   (wrong == 0 invariant satisfied)
  refused       =  0 / 30   (refusals all matched expected)
  correct_rate  = 1.00
  exit_criterion: PASSED  (>= 0.95 required)

CONTRAST WITH ADR-0127-0128 GSM8K TRAIN-SAMPLE RESULT (0/0/50):
  This is the first benchmark on the mathematics_logic lane where
  the architecture's structural strengths fully express. The result
  is the empirical inverse of the GSM8K result — and that's
  exactly the architecture-benchmark fit ADR-0131 was written to
  re-target toward.

REGRESSION: 1033/1033 existing tests green across math + ADR-0126
+ pack ratification + runner. Zero regressions.

SCOPE DISCIPLINE (per ADR-0131.1 v1 plan):
  v1 deliberately narrow (univariate, integer, polynomial). Future
  ADR-0131.1.B expansions documented in README: multi-variable,
  rationals, larger dataset (~500), sealed holdout per ADR-0119.7
  pattern.

PARALLEL WORK (per ADR-0131 plan to run all 3 sub-phases concurrently):
  - ADR-0131.2: CORE-native teaching-corpus eval (separate PR)
  - ADR-0131.3: bounded-grammar word-problem set (separate PR)

  These are independent of ADR-0131.1; no shared files, no
  cross-PR coordination required beyond final composite gate.
2026-05-23 09:58:26 -07:00

99 lines
3.2 KiB
Python

"""ADR-0131.1 — Symbolic equivalence check (Benchmark 1 primitive).
Given two algebraic expressions A and B, produces an
:class:`EquivalenceVerdict` of EQUIVALENT, NOT_EQUIVALENT, or REFUSED
(with reason). REFUSED preserves wrong == 0: the engine refuses to
guess on out-of-scope input rather than emit a wrong verdict.
Algorithm (v1, polynomial scope):
1. Normalize A via :func:`generate.math_symbolic_normalizer.normalize`.
2. Normalize B via the same function.
3. Compare canonical strings byte-for-byte.
If either normalization raises :class:`SymbolicError`, the verdict is
REFUSED with the propagating reason. This is the wrong-answer
firewall for the benchmark — anything the normalizer cannot prove
equivalent (or prove distinct) deterministically is refused.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Final
from generate.math_symbolic_normalizer import (
SymbolicError,
normalize,
)
class Verdict(str, Enum):
EQUIVALENT = "equivalent"
NOT_EQUIVALENT = "not_equivalent"
REFUSED = "refused"
@dataclass(frozen=True, slots=True)
class EquivalenceVerdict:
verdict: Verdict
canonical_a: str | None # None when verdict is REFUSED and a couldn't normalize
canonical_b: str | None
reason: str # empty on EQUIVALENT / NOT_EQUIVALENT; non-empty on REFUSED
REFUSED_VERDICTS: Final[frozenset[Verdict]] = frozenset({Verdict.REFUSED})
"""Helper set for callers that need to gate on refusal vs decision."""
def check_equivalence(
expression_a: str,
expression_b: str,
*,
variable: str = "x",
) -> EquivalenceVerdict:
"""Return whether ``expression_a`` and ``expression_b`` are
algebraically equivalent under the v1 polynomial-normalizer scope.
Refusal cases (each surfaces a typed reason):
- Either expression is empty or non-string.
- Either expression uses an out-of-scope identifier (multi-
variable, undefined name).
- Either expression contains a syntactically invalid construct.
- Either expression uses division, transcendental functions,
non-integer coefficients, negative exponents, or non-constant
exponents.
"""
try:
canon_a = normalize(expression_a, variable=variable).to_canonical_string()
except SymbolicError as exc:
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=None,
canonical_b=None,
reason=f"normalize(a) refused: {exc}",
)
try:
canon_b = normalize(expression_b, variable=variable).to_canonical_string()
except SymbolicError as exc:
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=canon_a,
canonical_b=None,
reason=f"normalize(b) refused: {exc}",
)
if canon_a == canon_b:
return EquivalenceVerdict(
verdict=Verdict.EQUIVALENT,
canonical_a=canon_a,
canonical_b=canon_b,
reason="",
)
return EquivalenceVerdict(
verdict=Verdict.NOT_EQUIVALENT,
canonical_a=canon_a,
canonical_b=canon_b,
reason="",
)