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.
280 lines
8.4 KiB
Python
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"))
|