core/generate/math_symbolic_equivalence.py
Shay 169cec710e
feat(ADR-0131.1.B): harden symbolic equivalence lane with generated corpus + exact algebra (#169)
* feat(evals): add deterministic symbolic equivalence generated corpus

* feat(evals): add symbolic equivalence replay helpers

* feat(evals): load generated symbolic equivalence corpus

* feat(evals): emit symbolic equivalence replay manifest

* feat(symbolic): support multivariable integer polynomials

* feat(symbolic): support exact rational polynomial coefficients

* feat(symbolic): align equivalence API with multivariable normalization

* test(ADR-0131.1.B): reconcile v1 expectations to v1.B scope expansion

The v1.B refactor (univariate int → sparse multivariable Fraction) deliberately
admits multivariable polynomials and constant-denominator division. The v1
dataset and tests pinned the old refusal behavior, so the lane runner reported
wrong=4 and 10 unit tests failed.

Reconcile:

- cases.jsonl: flip sym-eq-v1-0029 ('x+y' vs 'x+1') and sym-eq-v1-0030
  ('x/2' vs 'x') from expected=refused to expected=not_equivalent; rename
  categories to multivariable_distinct / constant_denominator_distinct;
  extend provenance with adr-0131.1b:scope-expanded.
- generated_cases.py: split _refusal_cases into scope_expanded (admits)
  and templates (still refused); the first two adversarial cases move to
  the scope-expanded list with expected=not_equivalent.
- test_math_symbolic_normalizer.py: replace test_undefined_variable and
  test_unknown_operator_division with positive scope-expansion tests +
  symbolic-denominator refusal; rewrite TestPolynomialInvariants for the
  new terms/variables constructor (Polynomial(terms={...}, variables=(...)))
  with float-rejection and zero-coef-collapse invariants.
- test_math_symbolic_equivalence.py: TestRefused.test_empty_left reason
  string matches new normalizer error; flip multivariable + constant-
  denominator cases to NOT_EQUIVALENT; add symbolic-denominator-refused
  case; relax canonical_a assertion in test_a_normalizes_b_refuses (engine
  now zeroes both on either-side refusal).
- report.json + manifest.json: regenerated; lane PASS 185/185 wrong=0.

Lane invariants reaffirmed by the new tests: wrong==0, refusal-first for
truly out-of-scope inputs (symbolic denominator, transcendental, malformed,
negative exponent), determinism via byte-equal report.
2026-05-23 10:47:57 -07:00

97 lines
2.8 KiB
Python

"""ADR-0131.1.B — Symbolic equivalence check.
Given two algebraic expressions A and B, produces an
:class:`EquivalenceVerdict` of EQUIVALENT, NOT_EQUIVALENT, or REFUSED.
REFUSED preserves wrong == 0: the engine refuses to guess on
out-of-scope input rather than emit a wrong verdict.
"""
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
canonical_b: str | None
reason: str
REFUSED_VERDICTS: Final[frozenset[Verdict]] = frozenset({Verdict.REFUSED})
"""Helper set for callers that need to gate on refusal vs decision."""
def _normalize_pair(
expression_a: str,
expression_b: str,
*,
variable: str | None,
variables: tuple[str, ...] | None,
) -> tuple[str, str]:
if variables is None and variable is None:
# Infer variables from the union of both expressions so `x + y` and
# `y + x` normalize in the same variable space.
poly_a_probe = normalize(expression_a)
poly_b_probe = normalize(expression_b)
variables = tuple(sorted(set(poly_a_probe.variables) | set(poly_b_probe.variables)))
canon_a = normalize(expression_a, variable=variable, variables=variables).to_canonical_string()
canon_b = normalize(expression_b, variable=variable, variables=variables).to_canonical_string()
return canon_a, canon_b
def check_equivalence(
expression_a: str,
expression_b: str,
*,
variable: str | None = None,
variables: tuple[str, ...] | None = None,
) -> EquivalenceVerdict:
"""Return whether two expressions are algebraically equivalent.
``variable`` is retained for backward compatibility with the v1
univariate API. New callers can omit it and allow variable inference, or
pass an explicit sorted ``variables`` tuple.
"""
try:
canon_a, canon_b = _normalize_pair(
expression_a,
expression_b,
variable=variable,
variables=variables,
)
except SymbolicError as exc:
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=None,
canonical_b=None,
reason=f"normalize 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="",
)