feat(constraint): R2 linear-constraint IR — typed problem model (R2 C1)

generate/constraint_comprehension/{expr,model}.py: frozen, slots'd dataclasses, no behavior. expr = LinearExpr (sum(coeff*symbol)+constant) + LinearConstraint (lhs eq rhs, optional source_span). model = Unknown (symbol/entity/unit/finite-integer domain), AttributeFact (per-category coefficient provenance), ConstraintQuery (symbol+unit), ConstraintProblem (unknowns/facts/constraints/query).

Terms pinned as (symbol, coefficient) to match the gold serialization. Query is a minimal dedicated type, not R1's BoundUnknown (no degenerate fit). Off-serving package; no generate.derivation / reliability_gate import. 9 IR tests (shape + frozen + defaults).
This commit is contained in:
Shay 2026-06-07 07:10:20 -07:00
parent 60b40d3e3a
commit e71531c0c9
4 changed files with 284 additions and 0 deletions

View file

@ -0,0 +1,37 @@
"""R2 finite-integer linear-constraint comprehension organ (off-serving).
A parallel typed organ the R2 twin of the R1 quantitative-comprehension reader. It
compiles two-category constraint word problems (buses/seats, chickens/legs, tickets/prices)
into a typed :class:`ConstraintProblem` graded by an independent setup oracle and solved by
an independent integer solver. Disjoint from the GSM8K serving path (imports no
``generate.derivation`` / ``core.reliability_gate``), so it cannot regress the serving metric.
C1 ships the IR only (this package's ``expr`` + ``model``); the gold/oracle (C2), the
integer solver (C3), the answer-choice verifier (C4), and the reader (C5+) land on top.
"""
from __future__ import annotations
from generate.constraint_comprehension.expr import (
LinearConstraint,
LinearExpr,
Relation,
)
from generate.constraint_comprehension.model import (
AttributeFact,
ConstraintProblem,
ConstraintQuery,
Domain,
Unknown,
)
__all__ = [
"AttributeFact",
"ConstraintProblem",
"ConstraintQuery",
"Domain",
"LinearConstraint",
"LinearExpr",
"Relation",
"Unknown",
]

View file

@ -0,0 +1,60 @@
"""Typed linear-constraint IR for the R2 finite-integer constraint organ.
The algebraic layer: a linear combination over unknown symbols (:class:`LinearExpr`) and a
single linear equation (:class:`LinearConstraint`). This is the R2 twin of
``generate.quantitative_expr`` the reader's/gold's SOURCE OF MEANING for a constraint,
kept above the string-serialization boundary. Strings are serialization only: meaning lives
in these typed terms, never recovered by parsing an expression string.
Pure data no behavior. Canonicalization (sorting terms, comparing constraints) lives in
the setup signature (C2); the solver (C3) reads these terms directly. Deterministic; no
clock, no randomness.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from generate.binding_graph.model import SourceSpanLink
#: v1 admits only equality constraints. Inequalities (``<=`` / ``>=``) are a deliberate
#: future extension — not representable here, so they cannot be silently half-supported.
Relation = Literal["eq"]
@dataclass(frozen=True, slots=True)
class LinearExpr:
"""A linear combination over unknown symbols: ``sum(coeff * symbol) + constant``.
``terms`` pairs each symbol with its INTEGER coefficient as ``(symbol, coefficient)``
matching the gold serialization ``["large_bus", 1]`` (the design sketch's prose comment
said "coefficient, symbol"; the concrete JSON artifact and the idiomatic ``{var: coeff}``
form both put the symbol first, so the symbol-first pairing is the one pinned here). The
canonical form sorts terms by symbol and merges duplicates; that canonicalization lives
in the setup signature, so two equal combinations written in different orders compare
equal there. No floats: every coefficient and the constant are integers (the domain is
finite-integer by construction).
"""
terms: tuple[tuple[str, int], ...]
constant: int = 0
@dataclass(frozen=True, slots=True)
class LinearConstraint:
"""A single linear equation ``lhs <relation> rhs`` (v1: ``relation == "eq"``).
``source_span`` is provenance populated by the reader (C5+); it is ``None`` for
gold-authored constraints (which have no input span). It never participates in canonical
equality two constraints are setup-equal iff their ``lhs`` / ``relation`` / ``rhs``
match (the signature in C2 strips the span before comparing).
"""
lhs: LinearExpr
relation: Relation
rhs: int
source_span: SourceSpanLink | None = None
__all__ = ["LinearConstraint", "LinearExpr", "Relation"]

View file

@ -0,0 +1,91 @@
"""Problem model for the R2 finite-integer constraint organ.
The structural layer above ``generate.constraint_comprehension.expr``: the unknowns, the
raw per-category attribute coefficients (provenance), the assembled linear system, and the
query. This is the R2 twin of the binding-graph model a typed :class:`ConstraintProblem`
the setup oracle grades and the solver consumes.
Pure data no behavior. Deterministic.
Deviation from the design sketch: the query is a minimal dedicated :class:`ConstraintQuery`
(symbol + unit), NOT the binding-graph ``BoundUnknown`` R2 has no state-index /
question-form axis, and forcing R1's unknown type onto it would be a degenerate fit.
Multiple-choice options and the provided answer key are NOT part of the problem IR; they are
answer-choice concerns graded separately (C4).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from generate.constraint_comprehension.expr import LinearConstraint
#: A finite-integer domain for an unknown. Count categories are nonnegative integers; the
#: broader signed-integer domain is reserved for future signed-quantity problems (so the
#: distinction is explicit, not a silent assumption that every unknown is a count).
Domain = Literal["nonnegative_integer", "integer"]
@dataclass(frozen=True, slots=True)
class Unknown:
"""One unknown category — ``large_bus``, ``chicken``, ``adult_ticket``.
``symbol`` is the canonical identifier used in ``LinearExpr.terms``; ``entity`` is the
surface category noun (provenance); ``unit`` is the category's own count unit (``bus``,
``animal``); ``domain`` constrains the solution set (a count -> ``nonnegative_integer``).
"""
symbol: str
entity: str
unit: str
domain: Domain
@dataclass(frozen=True, slots=True)
class AttributeFact:
"""A per-category attribute coefficient read from the prose: ``large bus holds 50
students`` -> ``AttributeFact("large_bus", "student", 50)``.
This is the RAW reading (provenance); the weighted-total constraint
``50*large_bus + 30*small_bus = 260`` is assembled FROM these. ``value`` is the integer
coefficient positivity and cross-category distinctness are the reader's/oracle's gate
(C3/C6), not enforced in this pure-data layer.
"""
category: str
measured_unit: str
value: int
@dataclass(frozen=True, slots=True)
class ConstraintQuery:
"""The asked unknown: which category's count is the answer, and in what unit."""
symbol: str
unit: str
@dataclass(frozen=True, slots=True)
class ConstraintProblem:
"""A complete finite-integer constraint setup: the unknowns, the raw attribute
coefficients, the assembled linear system, and the query.
The setup oracle (C2) grades unknowns / units / domains / constraints / query
canonically; the solver (C3) consumes ``unknowns`` + ``constraints``. ``facts`` is
provenance the coefficients the constraints were built from.
"""
unknowns: tuple[Unknown, ...]
facts: tuple[AttributeFact, ...]
constraints: tuple[LinearConstraint, ...]
query: ConstraintQuery
__all__ = [
"AttributeFact",
"ConstraintProblem",
"ConstraintQuery",
"Domain",
"Unknown",
]

View file

@ -0,0 +1,96 @@
"""Unit tests for the R2 constraint IR (C1) — pure dataclasses, no behavior.
Pins the typed shape the gold/oracle (C2) and solver (C3) build on: unknowns with a
finite-integer domain, attribute coefficients, a canonical linear system, and a minimal
query. Frozen-ness is load-bearing (the immutability doctrine the IR is value data); the
bus + chickens systems are pinned as IR so later slices can assert the reader reconstructs
exactly these.
"""
from __future__ import annotations
import dataclasses
from typing import Any
import pytest
from generate.constraint_comprehension import (
AttributeFact,
ConstraintProblem,
ConstraintQuery,
LinearConstraint,
LinearExpr,
Unknown,
)
def _bus_problem() -> ConstraintProblem:
# 6 buses total; large holds 50, small holds 30; 260 students; ask large.
return ConstraintProblem(
unknowns=(
Unknown("large_bus", "large bus", "bus", "nonnegative_integer"),
Unknown("small_bus", "small bus", "bus", "nonnegative_integer"),
),
facts=(
AttributeFact("large_bus", "student", 50),
AttributeFact("small_bus", "student", 30),
),
constraints=(
LinearConstraint(LinearExpr((("large_bus", 1), ("small_bus", 1))), "eq", 6),
LinearConstraint(LinearExpr((("large_bus", 50), ("small_bus", 30))), "eq", 260),
),
query=ConstraintQuery("large_bus", "bus"),
)
def test_bus_problem_ir_shape() -> None:
p = _bus_problem()
assert tuple(u.symbol for u in p.unknowns) == ("large_bus", "small_bus")
assert all(u.domain == "nonnegative_integer" for u in p.unknowns)
assert p.constraints[0].relation == "eq" and p.constraints[0].rhs == 6
assert p.constraints[1].lhs.terms == (("large_bus", 50), ("small_bus", 30))
assert p.query == ConstraintQuery("large_bus", "bus")
def test_chickens_problem_ir_shape() -> None:
# 18 animals; chickens 2 legs, cows 4 legs; 50 legs; ask chickens.
p = ConstraintProblem(
unknowns=(
Unknown("chicken", "chicken", "animal", "nonnegative_integer"),
Unknown("cow", "cow", "animal", "nonnegative_integer"),
),
facts=(AttributeFact("chicken", "leg", 2), AttributeFact("cow", "leg", 4)),
constraints=(
LinearConstraint(LinearExpr((("chicken", 1), ("cow", 1))), "eq", 18),
LinearConstraint(LinearExpr((("chicken", 2), ("cow", 4))), "eq", 50),
),
query=ConstraintQuery("chicken", "animal"),
)
assert {f.measured_unit for f in p.facts} == {"leg"}
assert p.constraints[1].lhs.terms == (("chicken", 2), ("cow", 4))
def test_linear_expr_constant_defaults_zero() -> None:
assert LinearExpr((("x", 1),)).constant == 0
def test_constraint_source_span_defaults_none() -> None:
# Gold-authored constraints carry no input span; the reader (C5+) populates it.
assert LinearConstraint(LinearExpr((("x", 1),)), "eq", 3).source_span is None
@pytest.mark.parametrize(
"obj",
[
Unknown("x", "x", "item", "integer"),
AttributeFact("x", "leg", 2),
ConstraintQuery("x", "item"),
LinearExpr((("x", 1),)),
LinearConstraint(LinearExpr((("x", 1),)), "eq", 1),
],
)
def test_ir_dataclasses_are_frozen(obj: Any) -> None:
# Immutability doctrine: the IR is value data — mutation must raise, never silently alias.
field = dataclasses.fields(obj)[0].name
with pytest.raises(dataclasses.FrozenInstanceError):
setattr(obj, field, getattr(obj, field))