Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2530,6 +2555,8 @@ Deprecated surfaces are scheduled for removal in v2.0.
- Initial commit

<!-- Links -->
[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
Expand Down
107 changes: 94 additions & 13 deletions tests/ucon/kinds/test_arithmetic_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,33 +104,114 @@ 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)

registry = FormulaRegistry() # empty
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)
Expand Down Expand Up @@ -187,15 +268,15 @@ 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)

registry = FormulaRegistry() # empty
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
Expand Down
2 changes: 1 addition & 1 deletion tests/ucon/kinds/test_builtin_kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions tests/ucon/kinds/test_lattice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
34 changes: 34 additions & 0 deletions tests/ucon/system/test_adopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
34 changes: 34 additions & 0 deletions tests/ucon/system/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading