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.
370 lines
13 KiB
Python
370 lines
13 KiB
Python
"""ADR-0131.1 — Deterministic symbolic normalizer for univariate
|
|
integer-coefficient polynomials.
|
|
|
|
Scope (v1, intentionally narrow):
|
|
- Single variable (configurable, default 'x').
|
|
- Integer coefficients only.
|
|
- Operators: +, -, *, ** (positive integer exponents only).
|
|
- Parentheses for grouping.
|
|
- No division (except implicit unary).
|
|
- No transcendental functions, no multi-variable, no rationals.
|
|
|
|
The normalizer is the load-bearing primitive for the symbolic
|
|
equivalence benchmark (ADR-0131 Benchmark 1). Two expressions A and
|
|
B are equivalent iff their canonical forms are byte-equal. This is
|
|
the CGA exact-recall discriminator framed in algebra.
|
|
|
|
Determinism guarantees:
|
|
- Pure functions, no global state, no randomness.
|
|
- Same input string → same canonical string, byte-for-byte.
|
|
- Same canonical string → same Polynomial dataclass.
|
|
- Refuses (raises SymbolicError) rather than guessing on
|
|
out-of-scope input (preserves wrong == 0).
|
|
|
|
Architecture: tokenize → parse to AST → expand + collect → canonical
|
|
serialize. Each stage is independently testable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SymbolicError(ValueError):
|
|
"""Raised on tokens, syntax, or operators the normalizer cannot
|
|
deterministically handle. Refusal is first-class — the caller is
|
|
expected to treat this as an explicit refusal, not a wrong answer.
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Canonical polynomial representation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Polynomial:
|
|
"""A univariate polynomial in canonical form.
|
|
|
|
``coefficients`` is a tuple of integers, index = exponent.
|
|
coefficients[0] = constant term, coefficients[1] = x coefficient,
|
|
coefficients[2] = x^2 coefficient, etc. Trailing zeros are
|
|
stripped; the tuple is empty iff the polynomial is the zero
|
|
polynomial.
|
|
|
|
Two Polynomial instances are equal iff their coefficient tuples
|
|
are equal. This is the equivalence relation the benchmark tests.
|
|
"""
|
|
|
|
coefficients: tuple[int, ...]
|
|
variable: str = "x"
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.variable, str) or not self.variable.isidentifier():
|
|
raise SymbolicError(
|
|
f"Polynomial.variable must be a Python identifier; "
|
|
f"got {self.variable!r}"
|
|
)
|
|
if not all(isinstance(c, int) for c in self.coefficients):
|
|
raise SymbolicError(
|
|
"Polynomial.coefficients must all be int "
|
|
"(no float, no bool, no fraction in v1)"
|
|
)
|
|
# Trailing zeros must be stripped at construction; reject
|
|
# non-canonical input loudly so downstream comparison is
|
|
# unambiguous.
|
|
if self.coefficients and self.coefficients[-1] == 0:
|
|
raise SymbolicError(
|
|
f"Polynomial.coefficients must have no trailing zeros; "
|
|
f"got {self.coefficients}"
|
|
)
|
|
|
|
def to_canonical_string(self) -> str:
|
|
"""Render this polynomial in a single canonical string form.
|
|
|
|
Terms are emitted in descending exponent order with explicit
|
|
signs. The zero polynomial is rendered as ``"0"``. This is
|
|
the byte-level comparison key for equivalence.
|
|
"""
|
|
if not self.coefficients:
|
|
return "0"
|
|
parts: list[str] = []
|
|
for exp in range(len(self.coefficients) - 1, -1, -1):
|
|
coef = self.coefficients[exp]
|
|
if coef == 0:
|
|
continue
|
|
sign = "+" if coef >= 0 else "-"
|
|
abs_coef = abs(coef)
|
|
if exp == 0:
|
|
term = f"{abs_coef}"
|
|
elif exp == 1:
|
|
term = f"{self.variable}" if abs_coef == 1 else f"{abs_coef}*{self.variable}"
|
|
else:
|
|
term = (
|
|
f"{self.variable}^{exp}"
|
|
if abs_coef == 1
|
|
else f"{abs_coef}*{self.variable}^{exp}"
|
|
)
|
|
if not parts:
|
|
# Leading term: no leading "+" sign.
|
|
parts.append(term if sign == "+" else f"-{term}")
|
|
else:
|
|
parts.append(f"{sign}{term}")
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tokenizer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TOKEN_RE: Final[re.Pattern[str]] = re.compile(
|
|
r"\s*(?:(?P<int>\d+)|(?P<ident>[A-Za-z_]\w*)|(?P<op>\*\*|[+\-*()^]))"
|
|
)
|
|
|
|
|
|
def _tokenize(text: str) -> list[tuple[str, str]]:
|
|
"""Return a list of ``(kind, lexeme)`` tokens.
|
|
|
|
Kinds: ``"int"``, ``"ident"``, ``"op"``. The ``"^"`` operator is
|
|
normalized to the canonical Python-style ``"**"`` (both spellings
|
|
accepted on input).
|
|
"""
|
|
pos = 0
|
|
tokens: list[tuple[str, str]] = []
|
|
while pos < len(text):
|
|
m = _TOKEN_RE.match(text, pos)
|
|
if m is None or m.end() == pos:
|
|
raise SymbolicError(
|
|
f"unexpected character at position {pos}: {text[pos:pos+10]!r}"
|
|
)
|
|
for kind in ("int", "ident", "op"):
|
|
lex = m.group(kind)
|
|
if lex is not None:
|
|
if kind == "op" and lex == "^":
|
|
lex = "**"
|
|
tokens.append((kind, lex))
|
|
break
|
|
pos = m.end()
|
|
return tokens
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recursive-descent parser producing a normalized Polynomial.
|
|
#
|
|
# Grammar:
|
|
# expr := term (('+' | '-') term)*
|
|
# term := factor (('*') factor)* # implicit '*' between (expr) and ident
|
|
# factor := unary ('**' unary)?
|
|
# unary := ('+' | '-') unary | atom
|
|
# atom := INT | IDENT | '(' expr ')'
|
|
#
|
|
# Each grammar rule returns a Polynomial; addition / multiplication /
|
|
# negation / exponentiation are implemented as Polynomial operations.
|
|
# This is the "expand + collect" step inlined into parsing.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _Parser:
|
|
def __init__(self, tokens: list[tuple[str, str]], variable: str) -> None:
|
|
self._tokens = tokens
|
|
self._pos = 0
|
|
self._variable = variable
|
|
|
|
def _peek(self) -> tuple[str, str] | None:
|
|
if self._pos >= len(self._tokens):
|
|
return None
|
|
return self._tokens[self._pos]
|
|
|
|
def _consume(self) -> tuple[str, str]:
|
|
if self._pos >= len(self._tokens):
|
|
raise SymbolicError("unexpected end of expression")
|
|
tok = self._tokens[self._pos]
|
|
self._pos += 1
|
|
return tok
|
|
|
|
def parse(self) -> Polynomial:
|
|
result = self._expr()
|
|
if self._pos != len(self._tokens):
|
|
extra = self._tokens[self._pos]
|
|
raise SymbolicError(f"unexpected trailing token {extra!r}")
|
|
return result
|
|
|
|
def _expr(self) -> Polynomial:
|
|
left = self._term()
|
|
while True:
|
|
tok = self._peek()
|
|
if tok is None or tok[0] != "op" or tok[1] not in ("+", "-"):
|
|
break
|
|
self._consume()
|
|
right = self._term()
|
|
if tok[1] == "+":
|
|
left = _add(left, right)
|
|
else:
|
|
left = _sub(left, right)
|
|
return left
|
|
|
|
def _term(self) -> Polynomial:
|
|
left = self._factor()
|
|
while True:
|
|
tok = self._peek()
|
|
if tok is None:
|
|
break
|
|
# Explicit '*'
|
|
if tok[0] == "op" and tok[1] == "*":
|
|
self._consume()
|
|
right = self._factor()
|
|
left = _mul(left, right)
|
|
continue
|
|
break
|
|
return left
|
|
|
|
def _factor(self) -> Polynomial:
|
|
base = self._unary()
|
|
tok = self._peek()
|
|
if tok is not None and tok[0] == "op" and tok[1] == "**":
|
|
self._consume()
|
|
exp_tok = self._unary()
|
|
# Exponent must be a non-negative integer constant polynomial.
|
|
if len(exp_tok.coefficients) > 1:
|
|
raise SymbolicError(
|
|
"exponent must be a non-negative integer constant; "
|
|
"got non-constant polynomial"
|
|
)
|
|
exp_val = exp_tok.coefficients[0] if exp_tok.coefficients else 0
|
|
if exp_val < 0:
|
|
raise SymbolicError(
|
|
f"exponent must be non-negative; got {exp_val}"
|
|
)
|
|
return _pow(base, exp_val)
|
|
return base
|
|
|
|
def _unary(self) -> Polynomial:
|
|
tok = self._peek()
|
|
if tok is not None and tok[0] == "op" and tok[1] in ("+", "-"):
|
|
self._consume()
|
|
inner = self._unary()
|
|
if tok[1] == "-":
|
|
return _neg(inner)
|
|
return inner
|
|
return self._atom()
|
|
|
|
def _atom(self) -> Polynomial:
|
|
tok = self._consume()
|
|
if tok[0] == "int":
|
|
return _const(int(tok[1]), self._variable)
|
|
if tok[0] == "ident":
|
|
if tok[1] != self._variable:
|
|
raise SymbolicError(
|
|
f"v1 supports a single variable {self._variable!r}; "
|
|
f"got identifier {tok[1]!r}"
|
|
)
|
|
return _x(self._variable)
|
|
if tok == ("op", "("):
|
|
inner = self._expr()
|
|
close = self._consume()
|
|
if close != ("op", ")"):
|
|
raise SymbolicError(f"expected ')'; got {close!r}")
|
|
return inner
|
|
raise SymbolicError(f"unexpected token {tok!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Polynomial algebra primitives (the actual "expand and collect" engine)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _strip_trailing_zeros(coeffs: list[int]) -> tuple[int, ...]:
|
|
while coeffs and coeffs[-1] == 0:
|
|
coeffs.pop()
|
|
return tuple(coeffs)
|
|
|
|
|
|
def _const(value: int, variable: str) -> Polynomial:
|
|
if value == 0:
|
|
return Polynomial(coefficients=(), variable=variable)
|
|
return Polynomial(coefficients=(value,), variable=variable)
|
|
|
|
|
|
def _x(variable: str) -> Polynomial:
|
|
return Polynomial(coefficients=(0, 1), variable=variable)
|
|
|
|
|
|
def _add(a: Polynomial, b: Polynomial) -> Polynomial:
|
|
if a.variable != b.variable:
|
|
raise SymbolicError(
|
|
f"variable mismatch: {a.variable!r} vs {b.variable!r}"
|
|
)
|
|
n = max(len(a.coefficients), len(b.coefficients))
|
|
out = [0] * n
|
|
for i, c in enumerate(a.coefficients):
|
|
out[i] += c
|
|
for i, c in enumerate(b.coefficients):
|
|
out[i] += c
|
|
return Polynomial(
|
|
coefficients=_strip_trailing_zeros(out), variable=a.variable
|
|
)
|
|
|
|
|
|
def _neg(a: Polynomial) -> Polynomial:
|
|
return Polynomial(
|
|
coefficients=tuple(-c for c in a.coefficients), variable=a.variable
|
|
)
|
|
|
|
|
|
def _sub(a: Polynomial, b: Polynomial) -> Polynomial:
|
|
return _add(a, _neg(b))
|
|
|
|
|
|
def _mul(a: Polynomial, b: Polynomial) -> Polynomial:
|
|
if a.variable != b.variable:
|
|
raise SymbolicError(
|
|
f"variable mismatch: {a.variable!r} vs {b.variable!r}"
|
|
)
|
|
if not a.coefficients or not b.coefficients:
|
|
return Polynomial(coefficients=(), variable=a.variable)
|
|
out = [0] * (len(a.coefficients) + len(b.coefficients) - 1)
|
|
for i, ca in enumerate(a.coefficients):
|
|
if ca == 0:
|
|
continue
|
|
for j, cb in enumerate(b.coefficients):
|
|
out[i + j] += ca * cb
|
|
return Polynomial(
|
|
coefficients=_strip_trailing_zeros(out), variable=a.variable
|
|
)
|
|
|
|
|
|
def _pow(base: Polynomial, exponent: int) -> Polynomial:
|
|
if exponent == 0:
|
|
return _const(1, base.variable)
|
|
result = base
|
|
for _ in range(exponent - 1):
|
|
result = _mul(result, base)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def normalize(expression: str, *, variable: str = "x") -> Polynomial:
|
|
"""Parse + expand + collect ``expression`` into canonical Polynomial.
|
|
|
|
Raises :class:`SymbolicError` on any input the v1 normalizer
|
|
cannot deterministically handle (multi-variable, division,
|
|
non-integer coefficient, unknown identifier, syntax error,
|
|
negative exponent, non-constant exponent).
|
|
"""
|
|
if not isinstance(expression, str) or not expression.strip():
|
|
raise SymbolicError("empty or non-string expression")
|
|
tokens = _tokenize(expression)
|
|
if not tokens:
|
|
raise SymbolicError("no tokens parsed from expression")
|
|
return _Parser(tokens, variable).parse()
|
|
|
|
|
|
def canonical_string(expression: str, *, variable: str = "x") -> str:
|
|
"""Shortcut: ``normalize(expression).to_canonical_string()``."""
|
|
return normalize(expression, variable=variable).to_canonical_string()
|