diff --git a/generate/constraint_comprehension/__init__.py b/generate/constraint_comprehension/__init__.py new file mode 100644 index 00000000..014f0786 --- /dev/null +++ b/generate/constraint_comprehension/__init__.py @@ -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", +] diff --git a/generate/constraint_comprehension/expr.py b/generate/constraint_comprehension/expr.py new file mode 100644 index 00000000..17aca356 --- /dev/null +++ b/generate/constraint_comprehension/expr.py @@ -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 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"] diff --git a/generate/constraint_comprehension/model.py b/generate/constraint_comprehension/model.py new file mode 100644 index 00000000..d0bcecb2 --- /dev/null +++ b/generate/constraint_comprehension/model.py @@ -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", +] diff --git a/tests/test_constraint_comprehension_model.py b/tests/test_constraint_comprehension_model.py new file mode 100644 index 00000000..b2a4e65d --- /dev/null +++ b/tests/test_constraint_comprehension_model.py @@ -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))