core/tests/test_binding_graph_units.py
Shay 6cbaa74076
feat(binding-graph): Phase 3 unit-aware admissibility (ADR-0134) (#176)
Wires deterministic, refusal-first dimensional analysis into the
binding-graph adapter. Every BoundEquation emitted by
bind_math_problem_graph now carries either admissibility_status='admitted'
+ populated unit_proof or admissibility_status='refused' + typed
refusal_reason. No silent coercion; no invented units; no solver.

Adds:
- generate/binding_graph/units.py — pure unit algebra over a 6-dim
  integer exponent vector (length, time, mass, money, count,
  temperature). Closed vocabulary loaded once from en_units_v1
  (ADR-0127) and memoized; composite "<num>_per_<denom>" resolved
  recursively; conservative depluralization; refusal-first.
- generate/binding_graph/admissibility.py — check_admissibility with
  per-operation-kind dispatch over the closed 8-string vocab, typed
  AdmissibilityError (closed reason set), frozen UnitProof.
- ADR-0134 documenting the contract, invariants, and Phase 4-5
  deferrals.

Adapter changes are surgical: synthesizes operand-literal symbols where
the verifier needs them (op<NNN>__multiplicand / __divisor / __rate),
then stamps each equation via check_admissibility. Input/output types
unchanged; bind_math_problem_graph still byte-equal across runs.

Tests: 226 total in the binding-graph lane (110 Phase 1+2 still pass; 47
units + 40 admissibility + 29 adapter-units new). Pyright clean on all
new files. No runtime wiring outside generate/binding_graph/.

Phase 4 (question-target binding) and Phase 5 (B3 / bounded grammar)
remain deferred per the brief.
2026-05-23 11:07:05 -07:00

280 lines
8.4 KiB
Python

"""ADR-0134 — Unit algebra primitives.
Pure algebra over the closed dimensional vocabulary in ``en_units_v1``.
No I/O at call time; no coercion; refusal-first on unknown ids.
"""
from __future__ import annotations
import pytest
from generate.binding_graph.units import (
BASE_DIMENSIONS,
DIMENSIONLESS,
UnitAlgebraError,
UnitVector,
parse_unit,
unit_inverse,
unit_product,
unit_quotient,
units_equal,
)
# ---------------------------------------------------------------------------
# UnitVector construction
# ---------------------------------------------------------------------------
def test_base_dimensions_are_load_bearing_closed_tuple() -> None:
assert isinstance(BASE_DIMENSIONS, tuple)
assert len(BASE_DIMENSIONS) == 6
assert set(BASE_DIMENSIONS) == {
"length",
"time",
"mass",
"money",
"count",
"temperature",
}
def test_dimensionless_is_all_zero() -> None:
assert DIMENSIONLESS.exponents == (0, 0, 0, 0, 0, 0)
def test_unit_vector_requires_tuple() -> None:
with pytest.raises(UnitAlgebraError):
UnitVector(exponents=[1, 0, 0, 0, 0, 0]) # type: ignore[arg-type]
def test_unit_vector_requires_correct_arity() -> None:
with pytest.raises(UnitAlgebraError):
UnitVector(exponents=(1, 0, 0))
def test_unit_vector_rejects_non_int_exponents() -> None:
with pytest.raises(UnitAlgebraError):
UnitVector(exponents=(1.0, 0, 0, 0, 0, 0)) # type: ignore[arg-type]
def test_unit_vector_rejects_bool_exponents() -> None:
with pytest.raises(UnitAlgebraError):
UnitVector(exponents=(True, 0, 0, 0, 0, 0)) # type: ignore[arg-type]
def test_unit_vector_is_frozen() -> None:
v = UnitVector(exponents=(1, 0, 0, 0, 0, 0))
with pytest.raises(Exception):
v.exponents = (0, 0, 0, 0, 0, 0) # type: ignore[misc]
def test_unit_vector_canonical_string_dimensionless() -> None:
assert DIMENSIONLESS.to_canonical_string() == "dimensionless"
def test_unit_vector_canonical_string_simple_numerator() -> None:
assert parse_unit("foot").to_canonical_string() == "length"
def test_unit_vector_canonical_string_squared() -> None:
assert parse_unit("square_foot").to_canonical_string() == "length^2"
def test_unit_vector_canonical_string_quotient() -> None:
assert parse_unit("mile_per_hour").to_canonical_string() == "length/time"
def test_unit_vector_canonical_string_inverse_only() -> None:
inv = unit_inverse(parse_unit("hour"))
assert inv.to_canonical_string() == "1/time"
# ---------------------------------------------------------------------------
# parse_unit
# ---------------------------------------------------------------------------
def test_parse_unit_exact_pack_lemma_money() -> None:
assert parse_unit("dollar").exponents == (0, 0, 0, 1, 0, 0)
def test_parse_unit_exact_pack_lemma_length() -> None:
assert parse_unit("foot").exponents == (1, 0, 0, 0, 0, 0)
def test_parse_unit_exact_pack_lemma_mass() -> None:
assert parse_unit("pound").exponents == (0, 0, 1, 0, 0, 0)
def test_parse_unit_exact_pack_lemma_count() -> None:
assert parse_unit("item").exponents == (0, 0, 0, 0, 1, 0)
def test_parse_unit_exact_pack_lemma_temperature() -> None:
assert parse_unit("Celsius").exponents == (0, 0, 0, 0, 0, 1)
def test_parse_unit_derived_speed() -> None:
assert parse_unit("mile_per_hour").exponents == (1, -1, 0, 0, 0, 0)
def test_parse_unit_derived_wage() -> None:
assert parse_unit("dollar_per_hour").exponents == (0, -1, 0, 1, 0, 0)
def test_parse_unit_depluralize_s() -> None:
assert units_equal(parse_unit("dollars"), parse_unit("dollar"))
def test_parse_unit_depluralize_es() -> None:
# 'inches' → 'inch'
assert units_equal(parse_unit("inches"), parse_unit("inch"))
def test_parse_unit_depluralize_ies() -> None:
# 'centuries' → 'century'
assert units_equal(parse_unit("centuries"), parse_unit("century"))
def test_parse_unit_dimensionless_symbol() -> None:
# Pack 'units.symbol' lemmas (e.g., 'percent') are dimensionless.
assert units_equal(parse_unit("percent"), DIMENSIONLESS)
def test_parse_unit_container_is_count_dim() -> None:
assert parse_unit("dozen").exponents == (0, 0, 0, 0, 1, 0)
def test_parse_unit_composite_decomposes() -> None:
# foot_per_second → length/time
assert parse_unit("foot_per_second").exponents == (1, -1, 0, 0, 0, 0)
def test_parse_unit_composite_with_unknown_inner_refuses() -> None:
with pytest.raises(UnitAlgebraError) as ei:
parse_unit("dollar_per_apple")
assert "unknown_unit" in str(ei.value)
def test_parse_unit_unknown_refuses() -> None:
with pytest.raises(UnitAlgebraError) as ei:
parse_unit("apples")
assert "unknown_unit" in str(ei.value)
def test_parse_unit_unknown_widgets_refuses() -> None:
with pytest.raises(UnitAlgebraError):
parse_unit("widgets")
def test_parse_unit_empty_string_refuses() -> None:
with pytest.raises(UnitAlgebraError):
parse_unit("")
def test_parse_unit_non_string_refuses() -> None:
with pytest.raises(UnitAlgebraError):
parse_unit(None) # type: ignore[arg-type]
def test_parse_unit_rate_connector_not_a_unit() -> None:
# 'per' is a connector in units.rate; refuse rather than treat as a unit.
with pytest.raises(UnitAlgebraError):
parse_unit("per")
def test_parse_unit_dimension_header_not_a_unit() -> None:
# 'length' is a dimension header; the lemma is in units.dimension and
# must be rejected — only concrete units resolve.
with pytest.raises(UnitAlgebraError):
parse_unit("length")
# ---------------------------------------------------------------------------
# Algebra primitives
# ---------------------------------------------------------------------------
def test_unit_product_commutes() -> None:
a, b = parse_unit("foot"), parse_unit("hour")
assert unit_product(a, b).exponents == unit_product(b, a).exponents
def test_unit_product_byte_equal_swap() -> None:
a, b = parse_unit("foot"), parse_unit("hour")
assert unit_product(a, b) == unit_product(b, a)
def test_unit_product_squares_length() -> None:
foot = parse_unit("foot")
assert unit_product(foot, foot).exponents == (2, 0, 0, 0, 0, 0)
def test_unit_quotient_speed() -> None:
# foot / second = speed (length/time)
q = unit_quotient(parse_unit("foot"), parse_unit("second"))
assert q.exponents == (1, -1, 0, 0, 0, 0)
def test_unit_quotient_not_commutative() -> None:
a, b = parse_unit("foot"), parse_unit("hour")
assert unit_quotient(a, b).exponents != unit_quotient(b, a).exponents
def test_unit_inverse_of_inverse_is_identity() -> None:
v = parse_unit("dollar_per_hour")
assert unit_inverse(unit_inverse(v)) == v
def test_unit_inverse_of_dimensionless_is_dimensionless() -> None:
assert unit_inverse(DIMENSIONLESS) == DIMENSIONLESS
def test_unit_quotient_self_is_dimensionless() -> None:
foot = parse_unit("foot")
assert unit_quotient(foot, foot) == DIMENSIONLESS
def test_unit_product_with_dimensionless_is_identity() -> None:
foot = parse_unit("foot")
assert unit_product(foot, DIMENSIONLESS) == foot
def test_units_equal_reflexive() -> None:
foot = parse_unit("foot")
assert units_equal(foot, foot)
def test_units_equal_strict_on_dimensions() -> None:
assert not units_equal(parse_unit("foot"), parse_unit("hour"))
def test_units_equal_strict_on_exponent_magnitude() -> None:
foot = parse_unit("foot")
sq = unit_product(foot, foot)
assert not units_equal(foot, sq)
def test_unit_algebra_no_io_at_call_time(tmp_path, monkeypatch) -> None:
# After first call the pack lexicon is memoized; subsequent calls must
# not touch the filesystem. Smoke this by pointing the resolver path at
# an invalid location post-warmup; calls must still succeed.
import generate.binding_graph.units as U
_ = parse_unit("foot") # warm cache
monkeypatch.setattr(U, "_UNITS_PACK_LEXICON", tmp_path / "missing.jsonl")
# Cached table is still in use.
assert parse_unit("foot").exponents == (1, 0, 0, 0, 0, 0)
def test_parse_unit_idempotent_across_repeat_calls() -> None:
a = parse_unit("dollar_per_hour")
b = parse_unit("dollar_per_hour")
assert a == b
assert a.exponents == b.exponents
def test_unit_vector_is_hashable() -> None:
# Frozen dataclasses are hashable by default; the binding graph relies
# on this when comparing UnitProof tuples.
assert hash(parse_unit("foot")) == hash(parse_unit("foot"))