From 54c0f4334061b2f79a9226b22caad704470c39a9 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:45:16 -0500 Subject: [PATCH 01/11] enforces strict kind guards on multiplication and division --- tests/ucon/kinds/test_arithmetic_dispatch.py | 107 ++++++++++++++++--- ucon/core/_types.py | 28 ++++- ucon/core/exceptions.py | 3 +- 3 files changed, 122 insertions(+), 16 deletions(-) diff --git a/tests/ucon/kinds/test_arithmetic_dispatch.py b/tests/ucon/kinds/test_arithmetic_dispatch.py index e2ee0cd..b7c4baf 100644 --- a/tests/ucon/kinds/test_arithmetic_dispatch.py +++ b/tests/ucon/kinds/test_arithmetic_dispatch.py @@ -104,9 +104,10 @@ def test_mul_commutative_formula(self) -> None: class TestMulNoFormula: - """__mul__: no formula match → kind=None.""" + """__mul__: no formula match behavior under strict vs permissive.""" - def test_mul_no_formula_yields_none(self) -> None: + def test_mul_no_formula_strict_raises(self) -> None: + """strict=True: kinded × kinded without formula → FormulaNotFound.""" kind_a = Kind("kind_a", dimension=FORCE) kind_b = Kind("kind_b", dimension=LENGTH) @@ -114,23 +115,103 @@ def test_mul_no_formula_yields_none(self) -> None: lattice = KindLattice([kind_a, kind_b]) sys = active_system() - with use(sys, formulas=registry, kinds=lattice): + with use(sys, formulas=registry, kinds=lattice, strict=True): + a = Number(10, newton, kind=kind_a) + b = Number(5, meter, kind=kind_b) + with pytest.raises(FormulaNotFound): + a * b + + def test_mul_no_formula_permissive_yields_none(self) -> None: + """strict=False: kinded × kinded without formula → kind=None.""" + kind_a = Kind("kind_a", dimension=FORCE) + kind_b = Kind("kind_b", dimension=LENGTH) + + registry = FormulaRegistry() # empty + lattice = KindLattice([kind_a, kind_b]) + sys = active_system() + + with use(sys, formulas=registry, kinds=lattice, strict=False): a = Number(10, newton, kind=kind_a) b = Number(5, meter, kind=kind_b) result = a * b assert result.kind is None + def test_div_no_formula_strict_raises(self) -> None: + """strict=True: kinded / kinded without formula → FormulaNotFound.""" + kind_a = Kind("kind_a", dimension=FORCE) + kind_b = Kind("kind_b", dimension=LENGTH) -class TestMulUnkindedFastPath: - """__mul__: Q19 fast path — one or both operands unkinded → kind=None, - no registry consulted.""" + registry = FormulaRegistry() # empty + lattice = KindLattice([kind_a, kind_b]) + sys = active_system() + + with use(sys, formulas=registry, kinds=lattice, strict=True): + a = Number(10, newton, kind=kind_a) + b = Number(5, meter, kind=kind_b) + with pytest.raises(FormulaNotFound): + a / b + + +class TestMulKindedUnkindedStrict: + """__mul__: kinded × unkinded under strict=True → KindMismatch.""" - def test_mul_one_unkinded_yields_none(self) -> None: + def test_mul_kinded_left_unkinded_right_raises(self) -> None: force_kind = Kind("force", dimension=FORCE) - a = Number(10, newton, kind=force_kind) - b = Number(5, meter) # unkinded - result = a * b - assert result.kind is None + sys = active_system() + + with use(sys, strict=True): + a = Number(10, newton, kind=force_kind) + b = Number(5, meter) + with pytest.raises(KindMismatch) as exc_info: + a * b + exc = exc_info.value + assert exc.kinded is force_kind + assert exc.unkinded_side == "right" + + def test_mul_unkinded_left_kinded_right_raises(self) -> None: + distance_kind = Kind("distance", dimension=LENGTH) + sys = active_system() + + with use(sys, strict=True): + a = Number(10, newton) + b = Number(5, meter, kind=distance_kind) + with pytest.raises(KindMismatch) as exc_info: + a * b + exc = exc_info.value + assert exc.kinded is distance_kind + assert exc.unkinded_side == "left" + + def test_div_kinded_unkinded_strict_raises(self) -> None: + force_kind = Kind("force", dimension=FORCE) + sys = active_system() + + with use(sys, strict=True): + a = Number(10, newton, kind=force_kind) + b = Number(5, meter) + with pytest.raises(KindMismatch): + a / b + + +class TestMulKindedUnkindedPermissive: + """__mul__: kinded × unkinded under strict=False → warns, kind=None.""" + + def test_mul_kinded_unkinded_permissive_warns(self) -> None: + force_kind = Kind("force", dimension=FORCE) + sys = active_system() + + with use(sys, strict=False): + a = Number(10, newton, kind=force_kind) + b = Number(5, meter) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = a * b + assert result.kind is None + assert len(w) == 1 + assert "force" in str(w[0].message) + + +class TestMulUnkindedFastPath: + """__mul__: both operands unkinded → kind=None, no registry consulted.""" def test_mul_both_unkinded_yields_none(self) -> None: a = Number(10, newton) @@ -187,7 +268,7 @@ def test_div_formula_stamps_kind(self) -> None: result = p / v assert result.kind is force_kind - def test_div_no_formula_yields_none(self) -> None: + def test_div_no_formula_permissive_yields_none(self) -> None: kind_a = Kind("kind_a", dimension=FORCE) kind_b = Kind("kind_b", dimension=LENGTH) @@ -195,7 +276,7 @@ def test_div_no_formula_yields_none(self) -> None: lattice = KindLattice([kind_a, kind_b]) sys = active_system() - with use(sys, formulas=registry, kinds=lattice): + with use(sys, formulas=registry, kinds=lattice, strict=False): a = Number(10, newton, kind=kind_a) b = Number(5, meter, kind=kind_b) result = a / b diff --git a/ucon/core/_types.py b/ucon/core/_types.py index e824335..f5dd7d6 100644 --- a/ucon/core/_types.py +++ b/ucon/core/_types.py @@ -2083,11 +2083,33 @@ def _resolve_mul_kind(self, other: 'Number') -> 'Kind | None': """Resolve the result kind for multiplication or division. Consults the active ``FormulaRegistry`` when both operands - carry a ``kind``. Returns the formula's ``output_kind``, or - ``None`` if no formula matches or either operand is unkinded. + carry a ``kind``. Under ``strict=True``, kinded × unkinded + raises ``KindMismatch`` and kinded × kinded without a matching + formula raises ``FormulaNotFound``. Under permissive mode, + mixed operations warn and return ``None``; missing formulas + silently return ``None``. """ + # Both unkinded — nothing to resolve + if self.kind is None and other.kind is None: + return None + # Mixed: one kinded, one unkinded if self.kind is None or other.kind is None: + ctx = _sys_active_var.get() + if ctx is not None and ctx.strict: + present = self.kind if self.kind is not None else other.kind + side = "right" if self.kind is not None else "left" + raise KindMismatch(kinded=present, unkinded_side=side) + # permissive: warn, return None (no kind to inherit for mul) + if _sys_active_var.get() is not None: + import warnings + present = self.kind or other.kind + warnings.warn( + f"Multiplying kinded ({present.name!r}) and unkinded " + f"Numbers; kind=None assumed", + stacklevel=3, + ) return None + # Both kinded — consult the formula registry ctx = _sys_active_var.get() if ctx is None: return None @@ -2099,6 +2121,8 @@ def _resolve_mul_kind(self, other: 'Number') -> 'Kind | None': ) return result_kind except FormulaNotFound: + if ctx.strict: + raise return None def _resolve_add_kind(self, other: 'Number') -> 'Kind | None': diff --git a/ucon/core/exceptions.py b/ucon/core/exceptions.py index 307ebf4..b57ce0a 100644 --- a/ucon/core/exceptions.py +++ b/ucon/core/exceptions.py @@ -102,7 +102,8 @@ def __init__( class KindMismatch(Exception): """Kinded and unkinded Numbers combined under strict mode. - Raised by ``Number.__add__`` / ``Number.__sub__`` when one operand + Raised by ``Number.__add__`` / ``Number.__sub__`` / + ``Number.__mul__`` / ``Number.__truediv__`` when one operand has ``kind`` set and the other does not, and the active context has ``strict=True``. From ba4e1a2b0a9a5b07d272e05a51a9cfe9aeca6417 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:45:27 -0500 Subject: [PATCH 02/11] preserves Number.kind through adopt() and Bridge.apply() --- tests/ucon/system/test_adopt.py | 34 ++++++++++++++++++++++++++++++++ tests/ucon/system/test_bridge.py | 34 ++++++++++++++++++++++++++++++++ ucon/system/__init__.py | 16 +++++++++++---- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/tests/ucon/system/test_adopt.py b/tests/ucon/system/test_adopt.py index 4ef547b..2ecc722 100644 --- a/tests/ucon/system/test_adopt.py +++ b/tests/ucon/system/test_adopt.py @@ -115,5 +115,39 @@ def test_quantity_unchanged_for_unit_product(self): self.assertEqual(out.quantity, n.quantity) +class TestAdoptKindPreservation(unittest.TestCase): + """``adopt`` preserves ``Number.kind`` through cross-system movement.""" + + def test_adopt_preserves_kind_plain_unit(self): + from ucon.dimension import ENERGY + from ucon.kinds import Kind + s = _active() + ke = Kind("kinetic_energy", dimension=ENERGY) + joule = s.units["joule"] + n = Number(100.0, joule, kind=ke) + out = s.adopt(n) + self.assertIs(out.kind, ke) + self.assertEqual(out.quantity, 100.0) + + def test_adopt_preserves_kind_unit_product(self): + from ucon.dimension import VELOCITY + from ucon.kinds import Kind + s = _active() + speed_kind = Kind("speed", dimension=VELOCITY) + meter = s.units["meter"] + second = s.units["second"] + product = UnitProduct({meter: 1.0, second: -1.0}) + n = Number(10.0, product, kind=speed_kind) + out = s.adopt(n) + self.assertIs(out.kind, speed_kind) + + def test_adopt_unkinded_stays_none(self): + s = _active() + meter = s.units["meter"] + n = Number(5.0, meter) + out = s.adopt(n) + self.assertIsNone(out.kind) + + if __name__ == "__main__": unittest.main() diff --git a/tests/ucon/system/test_bridge.py b/tests/ucon/system/test_bridge.py index db2866e..11dfab2 100644 --- a/tests/ucon/system/test_bridge.py +++ b/tests/ucon/system/test_bridge.py @@ -262,5 +262,39 @@ def test_apply_returns_dst_owned_unit_object(self): self.assertIsNot(out.unit, src.units["meter"]) +class TestBridgeKindPreservation(unittest.TestCase): + """``Bridge.apply`` preserves ``Number.kind``.""" + + def test_bridge_apply_preserves_kind(self): + from ucon.dimension import ENERGY + from ucon.kinds import Kind + s = _active() + ke = Kind("kinetic_energy", dimension=ENERGY) + n = Number(100.0, s.units["joule"], kind=ke) + b = Bridge(src=s, dst=s) + out = b.apply(n) + self.assertIs(out.kind, ke) + self.assertEqual(out.quantity, 100.0) + + def test_bridge_apply_unkinded_stays_none(self): + s = _active() + n = Number(100.0, s.units["joule"]) + b = Bridge(src=s, dst=s) + out = b.apply(n) + self.assertIsNone(out.kind) + + def test_bridge_apply_preserves_kind_with_rename(self): + from ucon.dimension import LENGTH + from ucon.kinds import Kind + src = _active() + dst = _system_with_metre_synonym() + distance_kind = Kind("distance", dimension=LENGTH) + b = Bridge(src=src, dst=dst, rename={"meter": "metre"}) + n = Number(5.0, src.units["meter"], kind=distance_kind) + out = b.apply(n) + self.assertIs(out.kind, distance_kind) + self.assertEqual(out.unit.name, "metre") + + if __name__ == "__main__": unittest.main() diff --git a/ucon/system/__init__.py b/ucon/system/__init__.py index 6327aad..4b468ae 100644 --- a/ucon/system/__init__.py +++ b/ucon/system/__init__.py @@ -874,7 +874,8 @@ def adopt(self, n: 'Number') -> 'Number': raise UnknownUnitError(unit.unit.name) rebound = UnitFactor(self.units[unit.unit.name], unit.scale) return Number( - quantity=n.quantity, unit=rebound, uncertainty=n.uncertainty + quantity=n.quantity, unit=rebound, uncertainty=n.uncertainty, + kind=n.kind, ) if isinstance(unit, Unit): if unit.name not in self.units: @@ -883,6 +884,7 @@ def adopt(self, n: 'Number') -> 'Number': quantity=n.quantity, unit=self.units[unit.name], uncertainty=n.uncertainty, + kind=n.kind, ) if isinstance(unit, UnitProduct): rebound_factors: Dict['UnitFactor', float] = {} @@ -897,10 +899,12 @@ def adopt(self, n: 'Number') -> 'Number': quantity=n.quantity, unit=UnitProduct(rebound_factors), uncertainty=n.uncertainty, + kind=n.kind, ) # Number with no unit — return as-is. return Number( - quantity=n.quantity, unit=unit, uncertainty=n.uncertainty + quantity=n.quantity, unit=unit, uncertainty=n.uncertainty, + kind=n.kind, ) @@ -1187,7 +1191,8 @@ def _rebind_name(name: str) -> str: raise UnknownUnitError(target_name) rebound = UnitFactor(self.dst.units[target_name], unit.scale) return Number( - quantity=n.quantity, unit=rebound, uncertainty=n.uncertainty + quantity=n.quantity, unit=rebound, uncertainty=n.uncertainty, + kind=n.kind, ) if isinstance(unit, Unit): target_name = _rebind_name(unit.name) @@ -1197,6 +1202,7 @@ def _rebind_name(name: str) -> str: quantity=n.quantity, unit=self.dst.units[target_name], uncertainty=n.uncertainty, + kind=n.kind, ) if isinstance(unit, UnitProduct): rebound_factors: Dict['UnitFactor', float] = {} @@ -1212,9 +1218,11 @@ def _rebind_name(name: str) -> str: quantity=n.quantity, unit=UnitProduct(rebound_factors), uncertainty=n.uncertainty, + kind=n.kind, ) return Number( - quantity=n.quantity, unit=unit, uncertainty=n.uncertainty + quantity=n.quantity, unit=unit, uncertainty=n.uncertainty, + kind=n.kind, ) def inverse(self) -> 'Bridge': From 937e14c7aa3e389c5ba1ed5224067971a0f0f094 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:45:38 -0500 Subject: [PATCH 03/11] adds KindLattice.kinds_for_dimension() reverse lookup --- tests/ucon/kinds/test_lattice.py | 28 ++++++++++++++++++++++++++++ ucon/kinds/lattice.py | 24 +++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/ucon/kinds/test_lattice.py b/tests/ucon/kinds/test_lattice.py index ccea13b..f1ba63d 100644 --- a/tests/ucon/kinds/test_lattice.py +++ b/tests/ucon/kinds/test_lattice.py @@ -124,3 +124,31 @@ def test_lattice_register_adds_kind(): lat.register(work) assert lat.get("work") is work assert lat.is_descendant(work, energy) + + +# --------------------------------------------------------------------------- +# kinds_for_dimension +# --------------------------------------------------------------------------- + +def test_kinds_for_dimension_returns_matching(): + lat, energy, ke, pe, grav = _energy_lattice() + result = lat.kinds_for_dimension(ENERGY_DIM) + assert len(result) == 4 + names = {k.name for k in result} + assert names == {"energy", "kinetic_energy", "potential_energy", "gravitational_pe"} + + +def test_kinds_for_dimension_unknown_returns_empty(): + lat, *_ = _energy_lattice() + result = lat.kinds_for_dimension(TIME) + assert result == [] + + +def test_kinds_for_dimension_filters_correctly(): + """Kinds of a different dimension are excluded.""" + energy = Kind("energy", dimension=ENERGY_DIM) + mass_kind = Kind("inertial_mass", dimension=MASS) + lat = KindLattice([energy, mass_kind]) + result = lat.kinds_for_dimension(ENERGY_DIM) + assert len(result) == 1 + assert result[0].name == "energy" diff --git a/ucon/kinds/lattice.py b/ucon/kinds/lattice.py index 7fae2df..1551549 100644 --- a/ucon/kinds/lattice.py +++ b/ucon/kinds/lattice.py @@ -27,7 +27,10 @@ from __future__ import annotations -from typing import Iterable, Iterator +from typing import TYPE_CHECKING, Iterable, Iterator + +if TYPE_CHECKING: + from ucon.dimension import Dimension from ucon.kinds.exceptions import ( AliasCollision, @@ -224,6 +227,25 @@ def register(self, kind: Kind) -> None: self._add(kind) self._validate_structure() + # ---------- reverse lookup ---------- + + def kinds_for_dimension(self, dimension: Dimension) -> list[Kind]: + """Return all kinds registered for the given dimension. + + Returns an empty list if no kinds refine the dimension. + + Parameters + ---------- + dimension : Dimension + The dimension to query. + + Returns + ------- + list[Kind] + Kinds whose :attr:`~Kind.dimension` equals *dimension*. + """ + return [k for k in self._by_name.values() if k.dimension == dimension] + # ---------- copying ---------- def copy(self) -> 'KindLattice': From e4ec5eafa28ea58f9ff8310b7b85947e184fff44 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:48:16 -0500 Subject: [PATCH 04/11] ships built-in radiation_weighting formula and wires into ActiveContext --- ucon/_bootstrap.py | 3 ++- ucon/comprehensive.ucon.toml | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ucon/_bootstrap.py b/ucon/_bootstrap.py index 6889fee..9a566e2 100644 --- a/ucon/_bootstrap.py +++ b/ucon/_bootstrap.py @@ -78,9 +78,10 @@ def install_default_active_context() -> None: system = build_default_system() graph = system.conversion_graph kinds = getattr(graph, '_kind_lattice', None) or KindLattice() + formulas = getattr(graph, '_formula_registry', None) or FormulaRegistry() _active_var.set(ActiveContext( system=system, - formulas=FormulaRegistry(), + formulas=formulas, kinds=kinds, strict=True, )) diff --git a/ucon/comprehensive.ucon.toml b/ucon/comprehensive.ucon.toml index bb8d16c..27d71d7 100644 --- a/ucon/comprehensive.ucon.toml +++ b/ucon/comprehensive.ucon.toml @@ -5166,3 +5166,24 @@ parent = "pressure" name = "mechanical_stress" dimension = "pressure" parent = "pressure" + +[[kinds]] +name = "radiation_weighting_factor" +dimension = "none" + +# ── Formulas ────────────────────────────────────────────────────────── +# +# Kind-aware arithmetic dispatch tables. Each formula declares its +# input kinds, output kind, and optional aspect rules. + +[[formulas]] +name = "radiation_weighting" +expression = "D * w_R" +output_kind = "dose_equivalent" +commutative = true +notes = "H = D * w_R (ICRP 103). Radiation weighting converts absorbed dose (gray) to dose equivalent (sievert)." + [formulas.inputs] + D = { kind = "absorbed_dose" } + w_R = { kind = "radiation_weighting_factor" } + [formulas.aspect_rules] + w_R = "consume" From 2ea3207c72e2149e338c27e818c4cd6400bc71dc Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:48:22 -0500 Subject: [PATCH 05/11] extends binary cache codec with formula support (schema v1 to v2) --- ucon/_cache.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/ucon/_cache.py b/ucon/_cache.py index c7c9f1f..5fd6f54 100644 --- a/ucon/_cache.py +++ b/ucon/_cache.py @@ -40,7 +40,7 @@ # --------------------------------------------------------------------------- _MAGIC = b"UCM\x01" -_CACHE_SCHEMA = 1 # Bump when _to_primitives/_from_primitives change shape +_CACHE_SCHEMA = 2 # Bump when _to_primitives/_from_primitives change shape # Header layout: magic(4) + format_ver(2) + py_major(1) + py_minor(1) + cache_ver(1) + reserved(3) = 12 _HEADER_FMT = "!4sHBBB3s" @@ -344,6 +344,23 @@ def _to_primitives(graph: "Graph") -> dict: "a": kind.aliases, } + # --- Pass 5b: Formulas --- + if hasattr(graph, '_formula_registry') and graph._formula_registry is not None: + from ucon.aspects.types import AspectRule + + for formula in graph._formula_registry: + out[f"f:{formula.name}"] = { + "_t": "F", + "n": formula.name, + "e": formula.expression, + "ik": {b: k.name for b, k in formula.input_kinds.items()}, + "ok": formula.output_kind.name, + "ar": {b: r.value for b, r in formula.aspect_rules.items()}, + "g": formula.generalizes, + "c": formula.commutative, + "no": formula.notes, + } + # --- Pass 6: Constants --- for const in graph._package_constants: out[f"c:{const.symbol}"] = { @@ -689,6 +706,46 @@ def _from_primitives(raw: dict) -> "Graph": kind_lattice = KindLattice(kind_obj_map.values()) graph._kind_lattice = kind_lattice + # --- Pass 7b: Formulas --- + formula_data: list[dict] = [] + for key, val in raw.items(): + if not key.startswith("f:"): + continue + if val.get("_t") != "F": + continue + formula_data.append(val) + + if formula_data and kind_obj_map: + from ucon.aspects.types import AspectRule + from ucon.formulas import FormulaRegistry, KindFormula + + formulas = [] + for fd in formula_data: + input_kinds = {} + for binding, kind_name in fd["ik"].items(): + kind = kind_obj_map.get(kind_name) + if kind is None: + break + input_kinds[binding] = kind + else: + output_kind = kind_obj_map.get(fd["ok"]) + if output_kind is not None: + aspect_rules = { + b: AspectRule(r) for b, r in fd.get("ar", {}).items() + } + formulas.append(KindFormula( + name=fd["n"], + expression=fd["e"], + input_kinds=input_kinds, + output_kind=output_kind, + aspect_rules=aspect_rules, + generalizes=fd.get("g", False), + commutative=fd.get("c", True), + notes=fd.get("no", ""), + )) + if formulas: + graph._formula_registry = FormulaRegistry(formulas) + # --- Pass 8: Constants --- constants = [] for key, val in raw.items(): From 492c231fa07670585fc434a9f3dd8d748e1c4cbd Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:48:31 -0500 Subject: [PATCH 06/11] adds formula TOML round-trip in to_toml/from_toml --- ucon/conversion.py | 6 +++- ucon/serialization.py | 72 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/ucon/conversion.py b/ucon/conversion.py index e600ead..eb0ba8f 100644 --- a/ucon/conversion.py +++ b/ucon/conversion.py @@ -1182,6 +1182,7 @@ def to_toml( path: Union[str, 'Path'], *, kinds: 'KindLattice | None' = None, + formulas: 'FormulaRegistry | None' = None, ) -> None: """Export this graph to a TOML file. @@ -1191,6 +1192,9 @@ def to_toml( Destination file path. kinds : KindLattice or None Optional kind lattice to serialize as ``[[kinds]]`` sections. + formulas : FormulaRegistry or None + Optional formula registry to serialize as ``[[formulas]]`` + sections. Raises ------ @@ -1198,7 +1202,7 @@ def to_toml( If ``tomli_w`` is not installed. """ from ucon.serialization import to_toml - to_toml(self, path, kinds=kinds) + to_toml(self, path, kinds=kinds, formulas=formulas) @classmethod def from_toml(cls, path: Union[str, 'Path'], *, strict: bool = True) -> 'Graph': diff --git a/ucon/serialization.py b/ucon/serialization.py index b944b8c..084c148 100644 --- a/ucon/serialization.py +++ b/ucon/serialization.py @@ -41,12 +41,15 @@ from ucon.basis.transforms import ConstantBoundBasisTransform, ConstantBinding from ucon.constants import Constant from ucon.contexts import ConversionContext, ContextEdge +from ucon.aspects.types import AspectRule from ucon.core import BaseForm, RebasedUnit, Scale, Unit, UnitFactor, UnitProduct from ucon.dimension import Dimension, resolve from ucon.expressions import ExprResult, evaluate +from ucon.formulas import FormulaRegistry from ucon.graph import ConversionGraph, using_conversion_graph from ucon.kinds import JoinPolicy, Kind, KindLattice from ucon.kinds.exceptions import KindNotFound +from ucon.parsing.formulas import parse_formulas_payload from ucon.parsing.kinds import parse_kinds_payload from ucon.maps import ( AffineMap, @@ -55,7 +58,7 @@ ) from ucon.packages import _build_map, _parse_factor from ucon.resolver import parse_unit -from ucon.system import active_kinds +from ucon.system import active_formulas, active_kinds __all__ = [ "FORMAT_VERSION", @@ -444,6 +447,57 @@ def _dimension_to_expression(dim: Dimension) -> str: return f"{num}/{'*'.join(parts_den)}" if parts_den else num +def _serialize_formula(formula) -> dict: + """Serialize a KindFormula to TOML dict.""" + d: dict = {"name": formula.name, "expression": formula.expression} + d["output_kind"] = formula.output_kind.name + if formula.commutative is not True: + d["commutative"] = formula.commutative + if formula.generalizes: + d["generalizes"] = formula.generalizes + if formula.notes: + d["notes"] = formula.notes + # inputs — {binding: {kind: "..."}} + inputs: dict = {} + for binding, kind in formula.input_kinds.items(): + inputs[binding] = {"kind": kind.name} + d["inputs"] = inputs + # aspect_rules — only non-default (non-CARRY) + aspect_rules: dict = {} + for binding, rule in formula.aspect_rules.items(): + if rule is not AspectRule.CARRY: + aspect_rules[binding] = rule.value + if aspect_rules: + d["aspect_rules"] = aspect_rules + return d + + +def _collect_formulas(graph, explicit_formulas=None, kind_names=None) -> list[dict]: + """Collect formulas for serialization. + + Uses *explicit_formulas* if provided, then ``graph._formula_registry``. + Unlike ``_collect_kinds``, does **not** fall through to the active + context — formulas reference kinds by name, and the export lattice + may not contain all kinds the active registry references. + + When *kind_names* is provided, formulas whose input or output kinds + are not in the set are silently omitted. + """ + registry = explicit_formulas + if registry is None: + registry = getattr(graph, '_formula_registry', None) + if registry is None or len(registry) == 0: + return [] + result = [] + for f in registry: + if kind_names is not None: + all_kinds = {k.name for k in f.input_kinds.values()} | {f.output_kind.name} + if not all_kinds <= kind_names: + continue + result.append(_serialize_formula(f)) + return result + + def _collect_kinds(graph, explicit_kinds: 'KindLattice | None') -> list[dict]: """Collect kinds for serialization. @@ -472,6 +526,7 @@ def to_toml( path: Union[str, Path], *, kinds: 'KindLattice | None' = None, + formulas: 'FormulaRegistry | None' = None, ) -> None: """Export a ConversionGraph to a TOML file. @@ -485,6 +540,10 @@ def to_toml( Optional kind lattice to serialize as ``[[kinds]]`` sections. When ``None``, falls back to ``graph._kind_lattice`` then ``active_kinds()``. + formulas : FormulaRegistry or None + Optional formula registry to serialize as ``[[formulas]]`` + sections. When ``None``, falls back to + ``graph._formula_registry`` then ``active_formulas()``. """ try: import tomli_w @@ -567,6 +626,12 @@ def to_toml( if kinds_list: doc["kinds"] = kinds_list + # [[formulas]] + exported_kind_names = {k["name"] for k in kinds_list} if kinds_list else None + formulas_list = _collect_formulas(graph, formulas, kind_names=exported_kind_names) + if formulas_list: + doc["formulas"] = formulas_list + # [[edges]] edges = _extract_forward_edges(graph) if edges: @@ -889,6 +954,11 @@ def from_toml(path: Union[str, Path], *, strict: bool = True): kind_lattice = parse_kinds_payload(doc) graph._kind_lattice = kind_lattice + # 6c. Parse formulas (requires kind_lattice to resolve kind references) + if "formulas" in doc and kind_lattice is not None: + formula_registry = parse_formulas_payload(doc, lattice=kind_lattice) + graph._formula_registry = formula_registry + # 7. Materialize constants (before edges so expression factors can # resolve constant symbols like "1 / Eh"). constants = [] From 8f94b108ec3f6bb89dd5029e7930c079120c47c1 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:48:38 -0500 Subject: [PATCH 07/11] updates kind counts, import audit, and serialization tests for v2.1.0 --- tests/ucon/kinds/test_builtin_kinds.py | 2 +- tests/ucon/test_import_dag.py | 5 +- tests/ucon/test_serialization.py | 165 ++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/tests/ucon/kinds/test_builtin_kinds.py b/tests/ucon/kinds/test_builtin_kinds.py index 56568b2..dccf520 100644 --- a/tests/ucon/kinds/test_builtin_kinds.py +++ b/tests/ucon/kinds/test_builtin_kinds.py @@ -18,7 +18,7 @@ class TestBuiltinKindsLoaded: """Verify the lattice boots with the expected 25 kinds.""" def test_lattice_count(self, lattice: KindLattice) -> None: - assert len(lattice) == 25 + assert len(lattice) == 26 def test_all_root_kinds_present(self, lattice: KindLattice) -> None: roots = ["energy", "frequency", "specific_energy", "voltage", diff --git a/tests/ucon/test_import_dag.py b/tests/ucon/test_import_dag.py index 06bd148..aaf8f4e 100644 --- a/tests/ucon/test_import_dag.py +++ b/tests/ucon/test_import_dag.py @@ -66,6 +66,9 @@ ("ucon._cache", "_to_primitives", "ucon.core"), ("ucon._cache", "_to_primitives", "ucon.dimension"), ("ucon._cache", "_to_primitives", "ucon.kinds.types"), + ("ucon._cache", "_to_primitives", "ucon.aspects.types"), # formula cache codec + ("ucon._cache", "_from_primitives", "ucon.aspects.types"), # formula cache codec + ("ucon._cache", "_from_primitives", "ucon.formulas"), # formula cache codec ("ucon._cache", "_from_primitives", "ucon.basis"), ("ucon._cache", "_from_primitives", "ucon.basis.transforms"), ("ucon._cache", "_from_primitives", "ucon.constants"), @@ -290,7 +293,7 @@ def test_known_deferred_count(self): eliminated, update this number downward. """ self.assertEqual( - len(KNOWN_DEFERRED), 28, + len(KNOWN_DEFERRED), 31, "Update this count when adding or removing KNOWN_DEFERRED entries" ) diff --git a/tests/ucon/test_serialization.py b/tests/ucon/test_serialization.py index e049f3c..3d1d6dd 100644 --- a/tests/ucon/test_serialization.py +++ b/tests/ucon/test_serialization.py @@ -2892,7 +2892,7 @@ def test_builtin_kinds_roundtrip(self, tmp_path): from ucon.system import active_kinds original = active_kinds() - assert len(original) == 25, "Expected 25 built-in kinds" + assert len(original) == 26, "Expected 26 built-in kinds" graph = get_default_graph() path = tmp_path / "builtin_kinds_rt.ucon.toml" @@ -2996,3 +2996,166 @@ def test_collect_kinds_runtime_error_fallback(self): with patch('ucon.serialization.active_kinds', side_effect=RuntimeError("no context")): result = _collect_kinds(None, None) assert result == [] + + +class TestFormulasSerialization: + """Tests for formula TOML round-trip (v2.1.0).""" + + def test_serialize_formula_basic(self): + """_serialize_formula produces expected keys.""" + from ucon.dimension import ENERGY, FORCE, LENGTH + from ucon.kinds import Kind + from ucon.formulas import KindFormula + from ucon.serialization import _serialize_formula + + force_kind = Kind("force", dimension=FORCE) + distance_kind = Kind("distance", dimension=LENGTH) + work_kind = Kind("work", dimension=ENERGY) + + formula = KindFormula( + name="work", + expression="F * d", + input_kinds={"F": force_kind, "d": distance_kind}, + output_kind=work_kind, + ) + d = _serialize_formula(formula) + assert d["name"] == "work" + assert d["expression"] == "F * d" + assert d["output_kind"] == "work" + assert d["inputs"]["F"]["kind"] == "force" + assert d["inputs"]["d"]["kind"] == "distance" + # commutative=True is default, so omitted + assert "commutative" not in d + # no aspect_rules + assert "aspect_rules" not in d + + def test_serialize_formula_with_aspect_rules(self): + """_serialize_formula emits aspect_rules for non-CARRY rules.""" + from ucon.dimension import ENERGY, NONE + from ucon.kinds import Kind + from ucon.formulas import KindFormula + from ucon.aspects.types import AspectRule + from ucon.serialization import _serialize_formula + + absorbed = Kind("absorbed_dose", dimension=ENERGY) + weight_factor = Kind("weighting_factor", dimension=NONE) + equivalent = Kind("equivalent_dose", dimension=ENERGY) + + formula = KindFormula( + name="weighting", + expression="D * w_R", + input_kinds={"D": absorbed, "w_R": weight_factor}, + output_kind=equivalent, + aspect_rules={"w_R": AspectRule.CONSUME}, + commutative=False, + ) + d = _serialize_formula(formula) + assert d["commutative"] is False + assert d["aspect_rules"] == {"w_R": "consume"} + + def test_formulas_roundtrip(self, tmp_path): + """Formulas survive to_toml → from_toml round-trip.""" + from ucon.dimension import ENERGY, FORCE, LENGTH + from ucon.kinds import Kind, KindLattice + from ucon.formulas import FormulaRegistry, KindFormula + + force_kind = Kind("force", dimension=FORCE) + distance_kind = Kind("distance", dimension=LENGTH) + work_kind = Kind("work", dimension=ENERGY) + lattice = KindLattice([force_kind, distance_kind, work_kind]) + + formula = KindFormula( + name="work", + expression="F * d", + input_kinds={"F": force_kind, "d": distance_kind}, + output_kind=work_kind, + notes="W = F × d", + ) + registry = FormulaRegistry([formula]) + + graph = get_default_graph() + path = tmp_path / "formulas_rt.ucon.toml" + graph.to_toml(path, kinds=lattice, formulas=registry) + + restored = from_toml(path) + assert restored._formula_registry is not None + assert "work" in restored._formula_registry + rt = restored._formula_registry.get("work") + assert rt.expression == "F * d" + assert rt.output_kind.name == "work" + assert set(rt.input_kinds.keys()) == {"F", "d"} + assert rt.notes == "W = F × d" + + def test_formulas_with_aspect_rules_roundtrip(self, tmp_path): + """Aspect rules survive round-trip.""" + from ucon.dimension import ENERGY, NONE + from ucon.kinds import Kind, KindLattice + from ucon.formulas import FormulaRegistry, KindFormula + from ucon.aspects.types import AspectRule + + absorbed = Kind("absorbed_dose", dimension=ENERGY) + wf = Kind("weight_factor", dimension=NONE) + equivalent = Kind("equivalent_dose", dimension=ENERGY) + lattice = KindLattice([absorbed, wf, equivalent]) + + formula = KindFormula( + name="dose_weighting", + expression="D * w", + input_kinds={"D": absorbed, "w": wf}, + output_kind=equivalent, + aspect_rules={"w": AspectRule.CONSUME}, + commutative=False, + ) + registry = FormulaRegistry([formula]) + + graph = get_default_graph() + path = tmp_path / "aspect_rt.ucon.toml" + graph.to_toml(path, kinds=lattice, formulas=registry) + + restored = from_toml(path) + rt = restored._formula_registry.get("dose_weighting") + assert rt.commutative is False + assert rt.aspect_rules["w"] == AspectRule.CONSUME + + def test_no_formulas_section_when_empty(self, tmp_path): + """TOML without formulas omits the [[formulas]] section.""" + from ucon.formulas import FormulaRegistry + + graph = get_default_graph() + path = tmp_path / "no_formulas.ucon.toml" + graph.to_toml(path, formulas=FormulaRegistry()) + + with open(path, "rb") as f: + doc = tomllib.load(f) + assert "formulas" not in doc + + def test_from_toml_without_formulas_backward_compat(self, tmp_path): + """TOML without [[formulas]] loads with _formula_registry = None.""" + doc = _with_preamble({ + "package": {"format_version": FORMAT_VERSION}, + "units": [{"name": "meter", "dimension": "length"}], + }) + path = _write_toml(tmp_path, doc) + g = from_toml(path) + assert not hasattr(g, '_formula_registry') or g._formula_registry is None + + +class TestBuiltinFormulas: + """Tests for built-in formulas shipped in comprehensive.ucon.toml.""" + + def test_radiation_formula_loaded_at_boot(self): + """radiation_weighting formula exists in the active context.""" + from ucon.system import active_formulas + registry = active_formulas() + formula = registry.get("radiation_weighting") + assert formula.name == "radiation_weighting" + assert formula.output_kind.name == "dose_equivalent" + assert "D" in formula.input_kinds + assert "w_R" in formula.input_kinds + + def test_radiation_weighting_factor_kind_exists(self): + """radiation_weighting_factor kind is in the active lattice.""" + from ucon.system import active_kinds + lattice = active_kinds() + kind = lattice.get("radiation_weighting_factor") + assert kind.dimension.name == "none" From 7bcbfb69be46b603947f892def9ea24d79cafbbc Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:48:45 -0500 Subject: [PATCH 08/11] documents v2.1.0 changelog entry --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b8329..379bf77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.0] - YYYY-MM-DD + ### Changed +- **Strict multiplication now guards kinded/unkinded mixing.** Under + `strict=True`, multiplying or dividing a kinded `Number` by an unkinded + `Number` raises `KindMismatch`, matching addition/subtraction behavior + since v2.0.0. Under `strict=False`, a warning is emitted and `kind=None` + is returned. +- **Strict multiplication now requires formulas for kinded operands.** + Under `strict=True`, multiplying two kinded `Number`s without a + matching formula raises `FormulaNotFound`. Under `strict=False`, + `kind=None` is returned (unchanged). - Updated README and ROADMAP to reflect v2.0.0 release status. +### Fixed + +- `UnitSystem.adopt()` and `Bridge.apply()` now preserve `Number.kind` + through cross-system value movement. + +### Added + +- Built-in `radiation_weighting` formula and `radiation_weighting_factor` + kind (dimensionless) in `comprehensive.ucon.toml`. The default + `FormulaRegistry` ships pre-populated at boot. +- `KindLattice.kinds_for_dimension(dimension)` reverse-lookup method. +- Formula TOML round-trip in serialization (`to_toml` / `from_toml`). +- Formula support in binary cache codec (`_cache.py`, schema version 2). + ## [2.0.1] - 2026-06-15 ### Fixed From 86b33c364d7599de56e6d467592dc8ae33eaf1c4 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 06:57:27 -0500 Subject: [PATCH 09/11] adds CHANGELOG links --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379bf77..067cf34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2555,6 +2555,8 @@ Deprecated surfaces are scheduled for removal in v2.0. - Initial commit +[2.1.0]: https://github.com/withtwoemms/ucon/compare/2.0.1...2.1.0 +[2.0.1]: https://github.com/withtwoemms/ucon/compare/2.0.0...2.0.1 [2.0.0]: https://github.com/withtwoemms/ucon/compare/1.12.0...2.0.0 [1.12.0]: https://github.com/withtwoemms/ucon/compare/1.11.0...1.12.0 [1.11.0]: https://github.com/withtwoemms/ucon/compare/1.10.0...1.11.0 From 3e845ac5139c2b9817e278750dec71a5f4feaf35 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 10:32:51 -0500 Subject: [PATCH 10/11] ensures FormulaRegistry is also copied on Graph.copy() --- ucon/conversion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ucon/conversion.py b/ucon/conversion.py index eb0ba8f..539a6a4 100644 --- a/ucon/conversion.py +++ b/ucon/conversion.py @@ -589,6 +589,7 @@ def copy(self) -> 'Graph': new._package_constants = self._package_constants # tuple is immutable, share reference new._contexts = dict(self._contexts) # ConversionContext is frozen, share refs new._kind_lattice = self._kind_lattice.copy() if self._kind_lattice is not None else None + new._formula_registry = self._formula_registry if hasattr(self, '_formula_registry') else None return new def register_context(self, ctx: 'ConversionContext') -> None: From 70506aff63424b1cdf3f35083f9977c2c004abce Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Tue, 16 Jun 2026 10:53:08 -0500 Subject: [PATCH 11/11] improves patch coverage --- tests/ucon/test_cache.py | 259 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/tests/ucon/test_cache.py b/tests/ucon/test_cache.py index d2442f8..e2264a5 100644 --- a/tests/ucon/test_cache.py +++ b/tests/ucon/test_cache.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import marshal import os import struct import unittest @@ -682,5 +683,263 @@ def test_integer_as_fraction(self): self.assertEqual(restored, Fraction(5)) +class TestFormulaCodec(unittest.TestCase): + """Coverage for formula serialization in the binary cache (v2.1.0).""" + + def test_formula_roundtrip(self): + """Formulas survive _to_primitives → _from_primitives on the full graph.""" + from ucon.serialization import from_toml + + original = from_toml(TOML_PATH) + raw = _to_primitives(original) + + # Verify formula key is in the raw dict + formula_keys = [k for k in raw if k.startswith("f:")] + self.assertGreaterEqual(len(formula_keys), 1) + + restored = _from_primitives(raw) + self.assertTrue(hasattr(restored, '_formula_registry')) + self.assertIsNotNone(restored._formula_registry) + + rt = restored._formula_registry.get("radiation_weighting") + self.assertEqual(rt.name, "radiation_weighting") + self.assertEqual(rt.expression, "D * w_R") + self.assertEqual(rt.output_kind.name, "dose_equivalent") + self.assertEqual(set(rt.input_kinds.keys()), {"D", "w_R"}) + self.assertTrue(rt.commutative) + + from ucon.aspects.types import AspectRule + self.assertEqual(rt.aspect_rules["w_R"], AspectRule.CONSUME) + + def test_formula_missing_input_kind_skipped(self): + """Formula with unknown input kind is silently dropped.""" + from ucon.serialization import from_toml + + original = from_toml(TOML_PATH) + raw = _to_primitives(original) + + # Corrupt the formula's input kind reference + formula_key = [k for k in raw if k.startswith("f:")][0] + raw[formula_key]["ik"]["D"] = "nonexistent_kind" + + restored = _from_primitives(raw) + reg = getattr(restored, '_formula_registry', None) + # Formula should be skipped — either registry is None or formula is absent + if reg is not None: + with self.assertRaises(Exception): + reg.get(raw[formula_key]["n"]) + + def test_formula_missing_output_kind_skipped(self): + """Formula with unknown output kind is silently dropped.""" + from ucon.serialization import from_toml + + original = from_toml(TOML_PATH) + raw = _to_primitives(original) + + # Corrupt the formula's output kind reference + formula_key = [k for k in raw if k.startswith("f:")][0] + raw[formula_key]["ok"] = "nonexistent_kind" + + restored = _from_primitives(raw) + reg = getattr(restored, '_formula_registry', None) + if reg is not None: + with self.assertRaises(Exception): + reg.get(raw[formula_key]["n"]) + + def test_formula_without_kinds_skipped(self): + """Formula data with no kind data produces no registry.""" + raw = { + "f:orphan": { + "_t": "F", + "n": "orphan", + "e": "x * y", + "ik": {"x": "missing_a", "y": "missing_b"}, + "ok": "missing_c", + "ar": {}, + "g": False, + "c": True, + "no": "", + }, + "_meta": {"loaded_packages": ()}, + } + restored = _from_primitives(raw) + reg = getattr(restored, '_formula_registry', None) + self.assertIsNone(reg) + + def test_full_graph_formula_roundtrip(self): + """Formulas survive the full write_cached_graph → load_cached_graph path.""" + import shutil + import tempfile + + from ucon.serialization import from_toml + + tmpdir = Path(tempfile.mkdtemp()) + try: + toml_copy = tmpdir / "test.ucon.toml" + shutil.copy2(TOML_PATH, toml_copy) + + graph = from_toml(toml_copy) + self.assertIsNotNone(getattr(graph, '_formula_registry', None)) + + ok = write_cached_graph(graph, toml_copy) + self.assertTrue(ok) + + restored = load_cached_graph(toml_copy) + self.assertIsNotNone(restored) + reg = getattr(restored, '_formula_registry', None) + self.assertIsNotNone(reg) + + rt = reg.get("radiation_weighting") + self.assertEqual(rt.name, "radiation_weighting") + self.assertEqual(rt.output_kind.name, "dose_equivalent") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +class TestNonDictPayload(unittest.TestCase): + """load_cached_graph rejects non-dict marshal payloads.""" + + def test_non_dict_payload_returns_none(self): + import shutil + import sys as _sys + import tempfile + + from ucon.serialization import FORMAT_VERSION + + tmpdir = Path(tempfile.mkdtemp()) + try: + toml_copy = tmpdir / "test.ucon.toml" + shutil.copy2(TOML_PATH, toml_copy) + + our_major, _ = (int(x) for x in FORMAT_VERSION.split(".")) + header = struct.pack( + _HEADER_FMT, + _MAGIC, + our_major, + _sys.version_info.major, + _sys.version_info.minor, + _CACHE_SCHEMA, + b"\x00\x00\x00", + ) + payload = marshal.dumps([1, 2, 3]) # list, not dict + cache_path = toml_copy.with_suffix(".cache") + cache_path.write_bytes(header + payload) + + result = load_cached_graph(toml_copy) + self.assertIsNone(result) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +class TestWriteOsReplaceFailure(unittest.TestCase): + """write_cached_graph handles os.replace failure.""" + + def test_os_replace_failure_returns_false(self): + import shutil + import tempfile + + from ucon.serialization import from_toml + + tmpdir = Path(tempfile.mkdtemp()) + try: + toml_copy = tmpdir / "test.ucon.toml" + shutil.copy2(TOML_PATH, toml_copy) + graph = from_toml(toml_copy) + + with mock.patch("os.replace", side_effect=OSError("mock failure")): + result = write_cached_graph(graph, toml_copy) + self.assertFalse(result) + + # No .cache file should be left behind + cache_path = toml_copy.with_suffix(".cache") + self.assertFalse(cache_path.exists()) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +class TestFractionFromString(unittest.TestCase): + """_prim_to_fraction handles string input.""" + + def test_fraction_from_string(self): + from fractions import Fraction + + from ucon._cache import _prim_to_fraction + + result = _prim_to_fraction("3/7") + self.assertEqual(result, Fraction(3, 7)) + + def test_fraction_from_decimal_string(self): + from fractions import Fraction + + from ucon._cache import _prim_to_fraction + + result = _prim_to_fraction("0.5") + self.assertEqual(result, Fraction(1, 2)) + + +class TestProductTupleKeyUnknownScale(unittest.TestCase): + """_deserialize_product_tuple_key falls back to Scale.one for unknown scales.""" + + def test_unknown_scale_defaults_to_one(self): + from ucon._cache import _deserialize_product_tuple_key + from ucon.core import Scale, Unit + from ucon.dimension import LENGTH + + meter = Unit(name="meter", dimension=LENGTH) + unit_map = {"meter": meter} + + ser = [("meter", "length", "bogus_scale", 1)] + result = _deserialize_product_tuple_key(ser, unit_map) + self.assertIsNotNone(result) + self.assertEqual(len(result), 1) + self.assertEqual(result[0][2], Scale.one) + + +class TestContextCodecPrimitives(unittest.TestCase): + """Context serialization/deserialization at primitives level.""" + + def test_context_codec_roundtrip(self): + """ConversionContext survives _to_primitives → _from_primitives.""" + from ucon.contexts import ContextEdge, ConversionContext + from ucon.core import Unit + from ucon.dimension import LENGTH + from ucon.maps import LinearMap + + meter = Unit(name="meter", dimension=LENGTH) + foot = Unit(name="foot", dimension=LENGTH) + + edge = ContextEdge(src=meter, dst=foot, map=LinearMap(a=3.28084)) + ctx = ConversionContext( + name="test_ctx", + edges=(edge,), + description="A test context", + ) + + graph = Graph() + dim = meter.dimension + graph._unit_edges[dim] = {} + graph._unit_edges[dim][meter] = {foot: LinearMap(a=3.28084)} + graph._unit_edges[dim][foot] = {meter: LinearMap(a=1.0 / 3.28084)} + graph._name_registry["meter"] = meter + graph._name_registry_cs["meter"] = meter + graph._name_registry["foot"] = foot + graph._name_registry_cs["foot"] = foot + graph.register_context(ctx) + + raw = _to_primitives(graph) + cx_keys = [k for k in raw if k.startswith("cx:")] + self.assertEqual(len(cx_keys), 1) + self.assertEqual(raw[cx_keys[0]]["n"], "test_ctx") + self.assertEqual(raw[cx_keys[0]]["desc"], "A test context") + self.assertEqual(len(raw[cx_keys[0]]["edges"]), 1) + + restored = _from_primitives(raw) + self.assertIn("test_ctx", restored._contexts) + rest_ctx = restored._contexts["test_ctx"] + self.assertEqual(rest_ctx.name, "test_ctx") + self.assertEqual(rest_ctx.description, "A test context") + self.assertEqual(len(rest_ctx.edges), 1) + + if __name__ == "__main__": unittest.main()