From ddb157f5b730aa851f29fde4b22cd1fc0a667d81 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:04:43 +0800 Subject: [PATCH 01/23] feat(si_unit): add SIUnitNotation and package skeleton --- paxman/capabilities/SIUnit/__init__.py | 7 ++++ .../capabilities/SIUnit/grammar/__init__.py | 1 + .../SIUnit/grammar/data/__init__.py | 1 + paxman/capabilities/SIUnit/notation.py | 38 +++++++++++++++++++ paxman/capabilities/SIUnit/rules/__init__.py | 1 + .../SIUnit/rules/data/__init__.py | 1 + pyproject.toml | 1 + tests/capabilities/si_unit/test_notation.py | 35 +++++++++++++++++ 8 files changed, 85 insertions(+) create mode 100644 paxman/capabilities/SIUnit/__init__.py create mode 100644 paxman/capabilities/SIUnit/grammar/__init__.py create mode 100644 paxman/capabilities/SIUnit/grammar/data/__init__.py create mode 100644 paxman/capabilities/SIUnit/notation.py create mode 100644 paxman/capabilities/SIUnit/rules/__init__.py create mode 100644 paxman/capabilities/SIUnit/rules/data/__init__.py create mode 100644 tests/capabilities/si_unit/test_notation.py diff --git a/paxman/capabilities/SIUnit/__init__.py b/paxman/capabilities/SIUnit/__init__.py new file mode 100644 index 00000000..62aeefab --- /dev/null +++ b/paxman/capabilities/SIUnit/__init__.py @@ -0,0 +1,7 @@ +"""SI Unit capability package.""" + +from __future__ import annotations + +from paxman.capabilities.SIUnit.notation import SIUnitNotation + +__all__ = ["SIUnitNotation"] diff --git a/paxman/capabilities/SIUnit/grammar/__init__.py b/paxman/capabilities/SIUnit/grammar/__init__.py new file mode 100644 index 00000000..0c2a51cb --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/__init__.py @@ -0,0 +1 @@ +"""SI Unit recognition grammars.""" diff --git a/paxman/capabilities/SIUnit/grammar/data/__init__.py b/paxman/capabilities/SIUnit/grammar/data/__init__.py new file mode 100644 index 00000000..c7160944 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/data/__init__.py @@ -0,0 +1 @@ +"""SI Unit grammar data tables.""" diff --git a/paxman/capabilities/SIUnit/notation.py b/paxman/capabilities/SIUnit/notation.py new file mode 100644 index 00000000..9f053cf0 --- /dev/null +++ b/paxman/capabilities/SIUnit/notation.py @@ -0,0 +1,38 @@ +"""SI unit notation — an SI unit expression as written.""" + +from __future__ import annotations + +from dataclasses import dataclass + +_VALID_SHAPES = frozenset({"symbol", "name", "compound"}) + + +@dataclass(frozen=True, slots=True) +class SIUnitNotation: + """An SI unit (no quantity, no magnitude) as written in the input. + + Attributes: + text: The unit text. Symbols keep their exact casing; names are + grammar-folded to lowercase; compounds keep the written form. + shape: One of "symbol", "name", "compound". + """ + + text: str + shape: str + + def __post_init__(self) -> None: + """Validate the shape and non-empty text. + + Raises: + ValueError: If text is empty or shape is not a valid shape. + """ + if not self.text: + raise ValueError("text must be non-empty") + if self.shape not in _VALID_SHAPES: + raise ValueError( + f"invalid shape {self.shape!r}; expected one of {sorted(_VALID_SHAPES)}" + ) + + def as_list(self) -> list[str]: + """Flatten the notation for structural equality checks.""" + return [self.text, self.shape] diff --git a/paxman/capabilities/SIUnit/rules/__init__.py b/paxman/capabilities/SIUnit/rules/__init__.py new file mode 100644 index 00000000..0c7005af --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/__init__.py @@ -0,0 +1 @@ +"""SI Unit validation rules.""" diff --git a/paxman/capabilities/SIUnit/rules/data/__init__.py b/paxman/capabilities/SIUnit/rules/data/__init__.py new file mode 100644 index 00000000..93cfde1a --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/__init__.py @@ -0,0 +1 @@ +"""SI Unit rule data tables.""" diff --git a/pyproject.toml b/pyproject.toml index 7745084d..1277d67f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ markers = [ "isbn: isbn capability tests", "money: money capability tests", "url: url capability tests", + "si_unit: si unit capability tests", ] testpaths = ["tests"] diff --git a/tests/capabilities/si_unit/test_notation.py b/tests/capabilities/si_unit/test_notation.py new file mode 100644 index 00000000..75be0f3c --- /dev/null +++ b/tests/capabilities/si_unit/test_notation.py @@ -0,0 +1,35 @@ +"""Tests for SIUnitNotation.""" + +import dataclasses + +import pytest + +from paxman.capabilities.SIUnit.notation import SIUnitNotation + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSIUnitNotation: + """Tests for SIUnitNotation.""" + + def test_text_and_shape(self) -> None: + n = SIUnitNotation(text="kg", shape="symbol") + assert n.text == "kg" + assert n.shape == "symbol" + + def test_rejects_empty_text(self) -> None: + with pytest.raises(ValueError): + SIUnitNotation(text="", shape="symbol") + + def test_rejects_unknown_shape(self) -> None: + with pytest.raises(ValueError): + SIUnitNotation(text="kg", shape="quantity") + + def test_as_list(self) -> None: + n = SIUnitNotation(text="kg", shape="symbol") + assert n.as_list() == ["kg", "symbol"] + + def test_frozen(self) -> None: + n = SIUnitNotation(text="kg", shape="symbol") + with pytest.raises(dataclasses.FrozenInstanceError): + n.text = "m" # type: ignore[misc] From 3b10ebe8c6d4e133009f7b57ebfee1320502cbe5 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:09:06 +0800 Subject: [PATCH 02/23] feat(si_unit): add SIUnitContract --- paxman/capabilities/SIUnit/contract.py | 31 +++++++++++++ tests/capabilities/si_unit/test_contract.py | 49 +++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 paxman/capabilities/SIUnit/contract.py create mode 100644 tests/capabilities/si_unit/test_contract.py diff --git a/paxman/capabilities/SIUnit/contract.py b/paxman/capabilities/SIUnit/contract.py new file mode 100644 index 00000000..acdebb07 --- /dev/null +++ b/paxman/capabilities/SIUnit/contract.py @@ -0,0 +1,31 @@ +"""SI Unit contract — user-facing configuration for SI Unit capability.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from paxman.core.capability_contract import CapabilityContract + + +@dataclass(frozen=True) +class SIUnitContract(CapabilityContract): + """User-facing configuration for SI Unit capability. + + Attributes: + capability_name: Fixed to "si_unit" (not user-settable). + output_format: Canonical output format — "symbol" (the canonical + unit symbol) is the only format. Optional — None/"default"/ + "symbol" all resolve to "symbol". + excluded_rules: Tuple of rule names to exclude. + pinned_rules: Pin to specific rules (takes precedence over + excluded_rules). + year: Year for temporal filtering. + extra_grammars: Community grammar names (opt-in) to run alongside + the shipped grammars, in order (SEAM — inherited from base). + """ + + DEFAULT_OUTPUT_FORMAT: ClassVar[str] = "symbol" + OFFERED_OUTPUT_FORMATS: ClassVar[frozenset[str]] = frozenset() + + capability_name: str = field(default="si_unit", init=False) diff --git a/tests/capabilities/si_unit/test_contract.py b/tests/capabilities/si_unit/test_contract.py new file mode 100644 index 00000000..a4d3a450 --- /dev/null +++ b/tests/capabilities/si_unit/test_contract.py @@ -0,0 +1,49 @@ +"""Tests for SIUnitContract.""" + +import dataclasses + +import pytest + +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.core.capability_contract import CapabilityContract +from paxman.core.errors import ContractError + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSIUnitContract: + """Tests for SIUnitContract.""" + + def test_is_capability_contract_subclass(self) -> None: + assert issubclass(SIUnitContract, CapabilityContract) + + def test_capability_name(self) -> None: + assert SIUnitContract().capability_name == "si_unit" + + def test_default_output_format(self) -> None: + assert SIUnitContract().output_format == "symbol" + + def test_offered_formats(self) -> None: + assert frozenset() == SIUnitContract.OFFERED_OUTPUT_FORMATS + + def test_default_format_resolution(self) -> None: + for fmt in (None, "default", "symbol"): + assert SIUnitContract(output_format=fmt).output_format == "symbol" + + def test_unknown_format_rejected(self) -> None: + with pytest.raises(ContractError): + SIUnitContract(output_format="name") + + def test_active_grammars_is_base_default(self) -> None: + # SEAM: no feature gating -> the contract does NOT override + # active_grammars; the base returns None and the engine runs every + # shipped grammar in get_grammars() declaration order. + assert SIUnitContract().active_grammars is None + + def test_extra_grammars_defaults_empty(self) -> None: + # SEAM: the community opt-in field is inherited from the base. + assert SIUnitContract().extra_grammars == () + + def test_frozen(self) -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + SIUnitContract().capability_name = "other" # type: ignore[misc] From 24181989ed195f3243006e6a483022e156282dcb Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:23:25 +0800 Subject: [PATCH 03/23] feat(si_unit): add authority data tables --- .../SIUnit/rules/data/si_base_units.py | 8 ++ .../SIUnit/rules/data/si_derived_units.py | 38 ++++++++ .../SIUnit/rules/data/si_nonsi_units.py | 35 +++++++ .../SIUnit/rules/data/si_prefixes.py | 59 ++++++++++++ .../SIUnit/rules/data/unit_names.py | 67 ++++++++++++++ tests/capabilities/si_unit/test_data.py | 91 +++++++++++++++++++ 6 files changed, 298 insertions(+) create mode 100644 paxman/capabilities/SIUnit/rules/data/si_base_units.py create mode 100644 paxman/capabilities/SIUnit/rules/data/si_derived_units.py create mode 100644 paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py create mode 100644 paxman/capabilities/SIUnit/rules/data/si_prefixes.py create mode 100644 paxman/capabilities/SIUnit/rules/data/unit_names.py create mode 100644 tests/capabilities/si_unit/test_data.py diff --git a/paxman/capabilities/SIUnit/rules/data/si_base_units.py b/paxman/capabilities/SIUnit/rules/data/si_base_units.py new file mode 100644 index 00000000..edb5e658 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/si_base_units.py @@ -0,0 +1,8 @@ +"""SI base unit symbols (BIPM SI Brochure, 9th ed., 2019, Table 1). + +Maintained authority snapshot — regenerate via the Task 4 tool after edits. +""" + +from __future__ import annotations + +BASE_UNIT_SYMBOLS: frozenset[str] = frozenset({"m", "kg", "s", "A", "K", "mol", "cd"}) diff --git a/paxman/capabilities/SIUnit/rules/data/si_derived_units.py b/paxman/capabilities/SIUnit/rules/data/si_derived_units.py new file mode 100644 index 00000000..f34833a8 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/si_derived_units.py @@ -0,0 +1,38 @@ +"""SI derived unit symbols with special names. + +From BIPM SI Brochure (9th ed., 2019), Table 3 — the 22 special-name +symbols plus "g" (the gram), the prefix attachment point for mass per +§3.2 (the gram is not itself a Table 3 entry). + +Maintained authority snapshot — regenerate via the Task 4 tool after edits. +""" + +from __future__ import annotations + +DERIVED_UNIT_SYMBOLS: frozenset[str] = frozenset( + { + "rad", + "sr", + "Hz", + "N", + "Pa", + "J", + "W", + "C", + "V", + "F", + "Ω", + "S", + "Wb", + "T", + "H", + "°C", + "lm", + "lx", + "Bq", + "Gy", + "Sv", + "kat", + "g", + } +) diff --git a/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py b/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py new file mode 100644 index 00000000..5ec9478d --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py @@ -0,0 +1,35 @@ +"""Non-SI units accepted for use with the SI. + +From BIPM SI Brochure (9th ed., 2019), Tables 8–9 — the complete union +of both tables' symbols (′ is U+2032 PRIME, ″ is U+2033 DOUBLE PRIME, +Å is U+00C5). LITRE_WRITTEN_FORMS records both written forms of the +litre symbol. + +Maintained authority snapshot — regenerate via the Task 4 tool after edits. +""" + +from __future__ import annotations + +NONSI_UNIT_SYMBOLS: frozenset[str] = frozenset( + { + "min", + "h", + "d", + "°", + "′", + "″", + "ha", + "L", + "l", + "t", + "Da", + "eV", + "u", + "Å", + "b", + "bar", + "mmHg", + } +) + +LITRE_WRITTEN_FORMS: frozenset[str] = frozenset({"L", "l"}) diff --git a/paxman/capabilities/SIUnit/rules/data/si_prefixes.py b/paxman/capabilities/SIUnit/rules/data/si_prefixes.py new file mode 100644 index 00000000..78ccb3fa --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/si_prefixes.py @@ -0,0 +1,59 @@ +"""SI prefix symbols and names (BIPM SI Brochure, 9th ed., 2019, Table 5).""" + +from __future__ import annotations + +PREFIX_SYMBOLS: frozenset[str] = frozenset( + { + "da", + "h", + "k", + "M", + "G", + "T", + "P", + "E", + "Z", + "Y", + "R", + "Q", + "d", + "c", + "m", + "µ", + "n", + "p", + "f", + "a", + "z", + "y", + "r", + "q", + } +) + +PREFIX_NAMES: dict[str, str] = { + "Q": "quetta", + "R": "ronna", + "Y": "yotta", + "Z": "zetta", + "E": "exa", + "P": "peta", + "T": "tera", + "G": "giga", + "M": "mega", + "k": "kilo", + "h": "hecto", + "da": "deca", + "d": "deci", + "c": "centi", + "m": "milli", + "µ": "micro", + "n": "nano", + "p": "pico", + "f": "femto", + "a": "atto", + "z": "zepto", + "y": "yocto", + "r": "ronto", + "q": "quecto", +} diff --git a/paxman/capabilities/SIUnit/rules/data/unit_names.py b/paxman/capabilities/SIUnit/rules/data/unit_names.py new file mode 100644 index 00000000..2928dea8 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/unit_names.py @@ -0,0 +1,67 @@ +"""Maintained unit-name to canonical-symbol mapping. + +Every maintained SI unit name (base, derived, non-SI, and the gram — +the prefix attachment point) maps to its canonical symbol. Names are +lowercase, single- or multi-word, per the BIPM SI Brochure (9th ed., +2019) Tables 1, 3 and 8–9. + +Prefixed names are not maintained here; they live in the generated +prefixed_unit_names.py module (Task 4). + +Maintained authority snapshot — regenerate via the Task 4 tool after edits. +""" + +from __future__ import annotations + +NAME_TO_SYMBOL: dict[str, str] = { + # Table 1 — base units. + "metre": "m", + "kilogram": "kg", + "second": "s", + "ampere": "A", + "kelvin": "K", + "mole": "mol", + "candela": "cd", + # Table 3 — derived units with special names. + "radian": "rad", + "steradian": "sr", + "hertz": "Hz", + "newton": "N", + "pascal": "Pa", + "joule": "J", + "watt": "W", + "coulomb": "C", + "volt": "V", + "farad": "F", + "ohm": "Ω", + "siemens": "S", + "weber": "Wb", + "tesla": "T", + "henry": "H", + "degree celsius": "°C", + "lumen": "lm", + "lux": "lx", + "becquerel": "Bq", + "gray": "Gy", + "sievert": "Sv", + "katal": "kat", + # The gram — the prefix attachment point for mass (§3.2 / D9). + "gram": "g", + # Tables 8–9 — non-SI units accepted for use with the SI. + "minute": "min", + "hour": "h", + "day": "d", + "degree": "°", + "minute of arc": "′", + "second of arc": "″", + "hectare": "ha", + "litre": "L", + "tonne": "t", + "dalton": "Da", + "electronvolt": "eV", + "unified atomic mass unit": "u", + "ångström": "Å", + "barn": "b", + "bar": "bar", + "millimetre of mercury": "mmHg", +} diff --git a/tests/capabilities/si_unit/test_data.py b/tests/capabilities/si_unit/test_data.py new file mode 100644 index 00000000..850d4b93 --- /dev/null +++ b/tests/capabilities/si_unit/test_data.py @@ -0,0 +1,91 @@ +"""Tests for the SI Unit maintained authority tables.""" + +import pytest + +from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import NONSI_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_prefixes import ( + PREFIX_NAMES, + PREFIX_SYMBOLS, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestAuthorityTables: + """Locked counts and rows for the maintained authority tables.""" + + def test_base_unit_symbols(self) -> None: + assert frozenset({"m", "kg", "s", "A", "K", "mol", "cd"}) == BASE_UNIT_SYMBOLS + + def test_derived_unit_symbols(self) -> None: + # 22 BIPM Table 3 special-name units + "g" (the gram — the + # prefix attachment point for mass, per BIPM SI Brochure §3.2). + assert len(DERIVED_UNIT_SYMBOLS) == 23 + assert {"rad", "Hz", "Pa", "Ω", "°C", "kat"} <= DERIVED_UNIT_SYMBOLS + + def test_prefix_symbols(self) -> None: + assert len(PREFIX_SYMBOLS) == 24 + assert ( + frozenset( + { + "da", + "h", + "k", + "M", + "G", + "T", + "P", + "E", + "Z", + "Y", + "R", + "Q", + "d", + "c", + "m", + "µ", + "n", + "p", + "f", + "a", + "z", + "y", + "r", + "q", + } + ) + == PREFIX_SYMBOLS + ) + + def test_prefix_names(self) -> None: + assert len(PREFIX_NAMES) == 24 + assert PREFIX_NAMES["k"] == "kilo" + assert PREFIX_NAMES["µ"] == "micro" + assert PREFIX_NAMES["da"] == "deca" + + def test_non_si_units(self) -> None: + assert { + "min", + "h", + "d", + "°", + "′", + "″", + "ha", + "L", + "l", + "t", + "Da", + "eV", + } <= NONSI_UNIT_SYMBOLS + + def test_name_to_symbol_locked_rows(self) -> None: + assert NAME_TO_SYMBOL["kilogram"] == "kg" + assert NAME_TO_SYMBOL["kelvin"] == "K" + assert NAME_TO_SYMBOL["degree celsius"] == "°C" + assert NAME_TO_SYMBOL["litre"] == "L" + assert NAME_TO_SYMBOL["metre"] == "m" + assert NAME_TO_SYMBOL["hertz"] == "Hz" From 4cbd6a188a253a0b1e59496261378e7cd1af45f7 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:29:54 +0800 Subject: [PATCH 04/23] test(si_unit): strengthen authority-table locks --- .../SIUnit/rules/data/unit_names.py | 3 + tests/capabilities/si_unit/test_data.py | 76 +++++++++++++++---- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/paxman/capabilities/SIUnit/rules/data/unit_names.py b/paxman/capabilities/SIUnit/rules/data/unit_names.py index 2928dea8..aeded093 100644 --- a/paxman/capabilities/SIUnit/rules/data/unit_names.py +++ b/paxman/capabilities/SIUnit/rules/data/unit_names.py @@ -8,6 +8,9 @@ Prefixed names are not maintained here; they live in the generated prefixed_unit_names.py module (Task 4). +Symbol values must stay unique: the Task 4 generator's symbol_to_name +reverse map depends on it (last-write-wins). + Maintained authority snapshot — regenerate via the Task 4 tool after edits. """ diff --git a/tests/capabilities/si_unit/test_data.py b/tests/capabilities/si_unit/test_data.py index 850d4b93..b9eb300e 100644 --- a/tests/capabilities/si_unit/test_data.py +++ b/tests/capabilities/si_unit/test_data.py @@ -4,7 +4,10 @@ from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS -from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import NONSI_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import ( + LITRE_WRITTEN_FORMS, + NONSI_UNIT_SYMBOLS, +) from paxman.capabilities.SIUnit.rules.data.si_prefixes import ( PREFIX_NAMES, PREFIX_SYMBOLS, @@ -24,7 +27,36 @@ def test_derived_unit_symbols(self) -> None: # 22 BIPM Table 3 special-name units + "g" (the gram — the # prefix attachment point for mass, per BIPM SI Brochure §3.2). assert len(DERIVED_UNIT_SYMBOLS) == 23 - assert {"rad", "Hz", "Pa", "Ω", "°C", "kat"} <= DERIVED_UNIT_SYMBOLS + assert ( + frozenset( + { + "rad", + "sr", + "Hz", + "N", + "Pa", + "J", + "W", + "C", + "V", + "F", + "Ω", + "S", + "Wb", + "T", + "H", + "°C", + "lm", + "lx", + "Bq", + "Gy", + "Sv", + "kat", + "g", + } + ) + == DERIVED_UNIT_SYMBOLS + ) def test_prefix_symbols(self) -> None: assert len(PREFIX_SYMBOLS) == 24 @@ -67,20 +99,31 @@ def test_prefix_names(self) -> None: assert PREFIX_NAMES["da"] == "deca" def test_non_si_units(self) -> None: - assert { - "min", - "h", - "d", - "°", - "′", - "″", - "ha", - "L", - "l", - "t", - "Da", - "eV", - } <= NONSI_UNIT_SYMBOLS + assert ( + frozenset( + { + "min", + "h", + "d", + "°", + "′", + "″", + "ha", + "L", + "l", + "t", + "Da", + "eV", + "u", + "Å", + "b", + "bar", + "mmHg", + } + ) + == NONSI_UNIT_SYMBOLS + ) + assert frozenset({"L", "l"}) == LITRE_WRITTEN_FORMS def test_name_to_symbol_locked_rows(self) -> None: assert NAME_TO_SYMBOL["kilogram"] == "kg" @@ -89,3 +132,4 @@ def test_name_to_symbol_locked_rows(self) -> None: assert NAME_TO_SYMBOL["litre"] == "L" assert NAME_TO_SYMBOL["metre"] == "m" assert NAME_TO_SYMBOL["hertz"] == "Hz" + assert NAME_TO_SYMBOL["gram"] == "g" From 8ca1851f4f8aa271f7594944c60e4159d19ec49a Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:37:11 +0800 Subject: [PATCH 05/23] feat(si_unit): add prefixed-unit data generator --- .../SIUnit/grammar/data/compound_tokens.py | 16 + .../SIUnit/grammar/data/unit_name_tokens.py | 922 +++++++++++++++++ .../SIUnit/grammar/data/unit_symbol_tokens.py | 942 ++++++++++++++++++ .../SIUnit/rules/data/prefixed_unit_names.py | 876 ++++++++++++++++ .../SIUnit/rules/data/prefixed_units.py | 878 ++++++++++++++++ tests/capabilities/si_unit/test_data.py | 66 +- tools/regenerate_si_prefix_data.py | 239 +++++ 7 files changed, 3938 insertions(+), 1 deletion(-) create mode 100644 paxman/capabilities/SIUnit/grammar/data/compound_tokens.py create mode 100644 paxman/capabilities/SIUnit/grammar/data/unit_name_tokens.py create mode 100644 paxman/capabilities/SIUnit/grammar/data/unit_symbol_tokens.py create mode 100644 paxman/capabilities/SIUnit/rules/data/prefixed_unit_names.py create mode 100644 paxman/capabilities/SIUnit/rules/data/prefixed_units.py create mode 100644 tools/regenerate_si_prefix_data.py diff --git a/paxman/capabilities/SIUnit/grammar/data/compound_tokens.py b/paxman/capabilities/SIUnit/grammar/data/compound_tokens.py new file mode 100644 index 00000000..2c8a6df1 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/data/compound_tokens.py @@ -0,0 +1,16 @@ +"""GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand. + +Compound-unit token characters (BIPM SI Brochure, 9th ed., §5.4 +and §5.3.4): COMPOUND_SEPARATORS are the multiplication signs +accepted between unit factors (slash, middle dot U+00B7, dot +operator U+22C5); EXPONENT_CHARACTERS is a regex character-class +fragment for integer powers — ASCII digits, superscript digits +(U+2070, U+00B9-U+00B3, U+2074-U+2079), superscript minus (U+207B), +and the hyphen-minus. The Task 6 compound rule keeps its own split +patterns locally — it never imports from grammar/. +""" + +from __future__ import annotations + +COMPOUND_SEPARATORS: str = "/·⋅" +EXPONENT_CHARACTERS: str = r"0-9⁻⁰¹²³⁴⁵⁶⁷⁸⁹\-" diff --git a/paxman/capabilities/SIUnit/grammar/data/unit_name_tokens.py b/paxman/capabilities/SIUnit/grammar/data/unit_name_tokens.py new file mode 100644 index 00000000..4e170d2d --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/data/unit_name_tokens.py @@ -0,0 +1,922 @@ +"""GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand. + +Every recognized unit-name token, longest-first: the maintained +names plus the prefixed names ("mega" + "hertz" -> "megahertz"). +Longest-first ordering lets a scanner prefer the longest match. +Derived from the maintained authority tables — regenerate via the +tool after edits. +""" + +from __future__ import annotations + +NAME_TOKENS: tuple[str, ...] = ( + "quectounified atomic mass unit", + "quettaunified atomic mass unit", + "centiunified atomic mass unit", + "femtounified atomic mass unit", + "hectounified atomic mass unit", + "microunified atomic mass unit", + "milliunified atomic mass unit", + "ronnaunified atomic mass unit", + "rontounified atomic mass unit", + "yoctounified atomic mass unit", + "yottaunified atomic mass unit", + "zeptounified atomic mass unit", + "zettaunified atomic mass unit", + "attounified atomic mass unit", + "decaunified atomic mass unit", + "deciunified atomic mass unit", + "gigaunified atomic mass unit", + "kilounified atomic mass unit", + "megaunified atomic mass unit", + "nanounified atomic mass unit", + "petaunified atomic mass unit", + "picounified atomic mass unit", + "teraunified atomic mass unit", + "exaunified atomic mass unit", + "unified atomic mass unit", + "millimetre of mercury", + "quectoelectronvolt", + "quettaelectronvolt", + "centielectronvolt", + "femtoelectronvolt", + "hectoelectronvolt", + "microelectronvolt", + "millielectronvolt", + "ronnaelectronvolt", + "rontoelectronvolt", + "yoctoelectronvolt", + "yottaelectronvolt", + "zeptoelectronvolt", + "zettaelectronvolt", + "attoelectronvolt", + "decaelectronvolt", + "decielectronvolt", + "gigaelectronvolt", + "kiloelectronvolt", + "megaelectronvolt", + "nanoelectronvolt", + "petaelectronvolt", + "picoelectronvolt", + "teraelectronvolt", + "exaelectronvolt", + "quectobecquerel", + "quectosteradian", + "quettabecquerel", + "quettasteradian", + "centibecquerel", + "centisteradian", + "degree celsius", + "femtobecquerel", + "femtosteradian", + "hectobecquerel", + "hectosteradian", + "microbecquerel", + "microsteradian", + "millibecquerel", + "millisteradian", + "quectoångström", + "quettaångström", + "ronnabecquerel", + "ronnasteradian", + "rontobecquerel", + "rontosteradian", + "yoctobecquerel", + "yoctosteradian", + "yottabecquerel", + "yottasteradian", + "zeptobecquerel", + "zeptosteradian", + "zettabecquerel", + "zettasteradian", + "attobecquerel", + "attosteradian", + "centiångström", + "decabecquerel", + "decasteradian", + "decibecquerel", + "decisteradian", + "femtoångström", + "gigabecquerel", + "gigasteradian", + "hectoångström", + "kilobecquerel", + "kilosteradian", + "megabecquerel", + "megasteradian", + "microångström", + "milliångström", + "minute of arc", + "nanobecquerel", + "nanosteradian", + "petabecquerel", + "petasteradian", + "picobecquerel", + "picosteradian", + "quectocandela", + "quectocoulomb", + "quectosiemens", + "quectosievert", + "quettacandela", + "quettacoulomb", + "quettasiemens", + "quettasievert", + "ronnaångström", + "rontoångström", + "second of arc", + "terabecquerel", + "terasteradian", + "yoctoångström", + "yottaångström", + "zeptoångström", + "zettaångström", + "attoångström", + "centicandela", + "centicoulomb", + "centisiemens", + "centisievert", + "decaångström", + "deciångström", + "electronvolt", + "exabecquerel", + "exasteradian", + "femtocandela", + "femtocoulomb", + "femtosiemens", + "femtosievert", + "gigaångström", + "hectocandela", + "hectocoulomb", + "hectosiemens", + "hectosievert", + "kiloångström", + "megaångström", + "microcandela", + "microcoulomb", + "microsiemens", + "microsievert", + "millicandela", + "millicoulomb", + "millisiemens", + "millisievert", + "nanoångström", + "petaångström", + "picoångström", + "quectoampere", + "quectodalton", + "quectokelvin", + "quectonewton", + "quectopascal", + "quectoradian", + "quectosecond", + "quettaampere", + "quettadalton", + "quettakelvin", + "quettanewton", + "quettapascal", + "quettaradian", + "quettasecond", + "ronnacandela", + "ronnacoulomb", + "ronnasiemens", + "ronnasievert", + "rontocandela", + "rontocoulomb", + "rontosiemens", + "rontosievert", + "teraångström", + "yoctocandela", + "yoctocoulomb", + "yoctosiemens", + "yoctosievert", + "yottacandela", + "yottacoulomb", + "yottasiemens", + "yottasievert", + "zeptocandela", + "zeptocoulomb", + "zeptosiemens", + "zeptosievert", + "zettacandela", + "zettacoulomb", + "zettasiemens", + "zettasievert", + "attocandela", + "attocoulomb", + "attosiemens", + "attosievert", + "centiampere", + "centidalton", + "centikelvin", + "centinewton", + "centipascal", + "centiradian", + "centisecond", + "decacandela", + "decacoulomb", + "decasiemens", + "decasievert", + "decicandela", + "decicoulomb", + "decisiemens", + "decisievert", + "exaångström", + "femtoampere", + "femtodalton", + "femtokelvin", + "femtonewton", + "femtopascal", + "femtoradian", + "femtosecond", + "gigacandela", + "gigacoulomb", + "gigasiemens", + "gigasievert", + "hectoampere", + "hectodalton", + "hectokelvin", + "hectonewton", + "hectopascal", + "hectoradian", + "hectosecond", + "kilocandela", + "kilocoulomb", + "kilosiemens", + "kilosievert", + "megacandela", + "megacoulomb", + "megasiemens", + "megasievert", + "microampere", + "microdalton", + "microkelvin", + "micronewton", + "micropascal", + "microradian", + "microsecond", + "milliampere", + "millidalton", + "millikelvin", + "millinewton", + "millipascal", + "milliradian", + "millisecond", + "nanocandela", + "nanocoulomb", + "nanosiemens", + "nanosievert", + "petacandela", + "petacoulomb", + "petasiemens", + "petasievert", + "picocandela", + "picocoulomb", + "picosiemens", + "picosievert", + "quectofarad", + "quectohenry", + "quectohertz", + "quectojoule", + "quectokatal", + "quectolitre", + "quectolumen", + "quectometre", + "quectotesla", + "quectotonne", + "quectoweber", + "quettafarad", + "quettahenry", + "quettahertz", + "quettajoule", + "quettakatal", + "quettalitre", + "quettalumen", + "quettametre", + "quettatesla", + "quettatonne", + "quettaweber", + "ronnaampere", + "ronnadalton", + "ronnakelvin", + "ronnanewton", + "ronnapascal", + "ronnaradian", + "ronnasecond", + "rontoampere", + "rontodalton", + "rontokelvin", + "rontonewton", + "rontopascal", + "rontoradian", + "rontosecond", + "teracandela", + "teracoulomb", + "terasiemens", + "terasievert", + "yoctoampere", + "yoctodalton", + "yoctokelvin", + "yoctonewton", + "yoctopascal", + "yoctoradian", + "yoctosecond", + "yottaampere", + "yottadalton", + "yottakelvin", + "yottanewton", + "yottapascal", + "yottaradian", + "yottasecond", + "zeptoampere", + "zeptodalton", + "zeptokelvin", + "zeptonewton", + "zeptopascal", + "zeptoradian", + "zeptosecond", + "zettaampere", + "zettadalton", + "zettakelvin", + "zettanewton", + "zettapascal", + "zettaradian", + "zettasecond", + "attoampere", + "attodalton", + "attokelvin", + "attonewton", + "attopascal", + "attoradian", + "attosecond", + "centifarad", + "centihenry", + "centihertz", + "centijoule", + "centikatal", + "centilitre", + "centilumen", + "centimetre", + "centitesla", + "centitonne", + "centiweber", + "decaampere", + "decadalton", + "decakelvin", + "decanewton", + "decapascal", + "decaradian", + "decasecond", + "deciampere", + "decidalton", + "decikelvin", + "decinewton", + "decipascal", + "deciradian", + "decisecond", + "exacandela", + "exacoulomb", + "exasiemens", + "exasievert", + "femtofarad", + "femtohenry", + "femtohertz", + "femtojoule", + "femtokatal", + "femtolitre", + "femtolumen", + "femtometre", + "femtotesla", + "femtotonne", + "femtoweber", + "gigaampere", + "gigadalton", + "gigakelvin", + "giganewton", + "gigapascal", + "gigaradian", + "gigasecond", + "hectofarad", + "hectohenry", + "hectohertz", + "hectojoule", + "hectokatal", + "hectolitre", + "hectolumen", + "hectometre", + "hectotesla", + "hectotonne", + "hectoweber", + "kiloampere", + "kilodalton", + "kilokelvin", + "kilonewton", + "kilopascal", + "kiloradian", + "kilosecond", + "megaampere", + "megadalton", + "megakelvin", + "meganewton", + "megapascal", + "megaradian", + "megasecond", + "microfarad", + "microhenry", + "microhertz", + "microjoule", + "microkatal", + "microlitre", + "microlumen", + "micrometre", + "microtesla", + "microtonne", + "microweber", + "millifarad", + "millihenry", + "millihertz", + "millijoule", + "millikatal", + "millilitre", + "millilumen", + "millimetre", + "millitesla", + "millitonne", + "milliweber", + "nanoampere", + "nanodalton", + "nanokelvin", + "nanonewton", + "nanopascal", + "nanoradian", + "nanosecond", + "petaampere", + "petadalton", + "petakelvin", + "petanewton", + "petapascal", + "petaradian", + "petasecond", + "picoampere", + "picodalton", + "picokelvin", + "piconewton", + "picopascal", + "picoradian", + "picosecond", + "quectobarn", + "quectogram", + "quectogray", + "quectomole", + "quectovolt", + "quectowatt", + "quettabarn", + "quettagram", + "quettagray", + "quettamole", + "quettavolt", + "quettawatt", + "ronnafarad", + "ronnahenry", + "ronnahertz", + "ronnajoule", + "ronnakatal", + "ronnalitre", + "ronnalumen", + "ronnametre", + "ronnatesla", + "ronnatonne", + "ronnaweber", + "rontofarad", + "rontohenry", + "rontohertz", + "rontojoule", + "rontokatal", + "rontolitre", + "rontolumen", + "rontometre", + "rontotesla", + "rontotonne", + "rontoweber", + "teraampere", + "teradalton", + "terakelvin", + "teranewton", + "terapascal", + "teraradian", + "terasecond", + "yoctofarad", + "yoctohenry", + "yoctohertz", + "yoctojoule", + "yoctokatal", + "yoctolitre", + "yoctolumen", + "yoctometre", + "yoctotesla", + "yoctotonne", + "yoctoweber", + "yottafarad", + "yottahenry", + "yottahertz", + "yottajoule", + "yottakatal", + "yottalitre", + "yottalumen", + "yottametre", + "yottatesla", + "yottatonne", + "yottaweber", + "zeptofarad", + "zeptohenry", + "zeptohertz", + "zeptojoule", + "zeptokatal", + "zeptolitre", + "zeptolumen", + "zeptometre", + "zeptotesla", + "zeptotonne", + "zeptoweber", + "zettafarad", + "zettahenry", + "zettahertz", + "zettajoule", + "zettakatal", + "zettalitre", + "zettalumen", + "zettametre", + "zettatesla", + "zettatonne", + "zettaweber", + "attofarad", + "attohenry", + "attohertz", + "attojoule", + "attokatal", + "attolitre", + "attolumen", + "attometre", + "attotesla", + "attotonne", + "attoweber", + "becquerel", + "centibarn", + "centigram", + "centigray", + "centimole", + "centivolt", + "centiwatt", + "decafarad", + "decahenry", + "decahertz", + "decajoule", + "decakatal", + "decalitre", + "decalumen", + "decametre", + "decatesla", + "decatonne", + "decaweber", + "decifarad", + "decihenry", + "decihertz", + "decijoule", + "decikatal", + "decilitre", + "decilumen", + "decimetre", + "decitesla", + "decitonne", + "deciweber", + "exaampere", + "exadalton", + "exakelvin", + "exanewton", + "exapascal", + "exaradian", + "exasecond", + "femtobarn", + "femtogram", + "femtogray", + "femtomole", + "femtovolt", + "femtowatt", + "gigafarad", + "gigahenry", + "gigahertz", + "gigajoule", + "gigakatal", + "gigalitre", + "gigalumen", + "gigametre", + "gigatesla", + "gigatonne", + "gigaweber", + "hectobarn", + "hectogram", + "hectogray", + "hectomole", + "hectovolt", + "hectowatt", + "kilofarad", + "kilohenry", + "kilohertz", + "kilojoule", + "kilokatal", + "kilolitre", + "kilolumen", + "kilometre", + "kilotesla", + "kilotonne", + "kiloweber", + "megafarad", + "megahenry", + "megahertz", + "megajoule", + "megakatal", + "megalitre", + "megalumen", + "megametre", + "megatesla", + "megatonne", + "megaweber", + "microbarn", + "microgram", + "microgray", + "micromole", + "microvolt", + "microwatt", + "millibarn", + "milligram", + "milligray", + "millimole", + "millivolt", + "milliwatt", + "nanofarad", + "nanohenry", + "nanohertz", + "nanojoule", + "nanokatal", + "nanolitre", + "nanolumen", + "nanometre", + "nanotesla", + "nanotonne", + "nanoweber", + "petafarad", + "petahenry", + "petahertz", + "petajoule", + "petakatal", + "petalitre", + "petalumen", + "petametre", + "petatesla", + "petatonne", + "petaweber", + "picofarad", + "picohenry", + "picohertz", + "picojoule", + "picokatal", + "picolitre", + "picolumen", + "picometre", + "picotesla", + "picotonne", + "picoweber", + "quectobar", + "quectolux", + "quectoohm", + "quettabar", + "quettalux", + "quettaohm", + "ronnabarn", + "ronnagram", + "ronnagray", + "ronnamole", + "ronnavolt", + "ronnawatt", + "rontobarn", + "rontogram", + "rontogray", + "rontomole", + "rontovolt", + "rontowatt", + "steradian", + "terafarad", + "terahenry", + "terahertz", + "terajoule", + "terakatal", + "teralitre", + "teralumen", + "terametre", + "teratesla", + "teratonne", + "teraweber", + "yoctobarn", + "yoctogram", + "yoctogray", + "yoctomole", + "yoctovolt", + "yoctowatt", + "yottabarn", + "yottagram", + "yottagray", + "yottamole", + "yottavolt", + "yottawatt", + "zeptobarn", + "zeptogram", + "zeptogray", + "zeptomole", + "zeptovolt", + "zeptowatt", + "zettabarn", + "zettagram", + "zettagray", + "zettamole", + "zettavolt", + "zettawatt", + "attobarn", + "attogram", + "attogray", + "attomole", + "attovolt", + "attowatt", + "centibar", + "centilux", + "centiohm", + "decabarn", + "decagram", + "decagray", + "decamole", + "decavolt", + "decawatt", + "decibarn", + "decigram", + "decigray", + "decimole", + "decivolt", + "deciwatt", + "exafarad", + "exahenry", + "exahertz", + "exajoule", + "exakatal", + "exalitre", + "exalumen", + "exametre", + "exatesla", + "exatonne", + "exaweber", + "femtobar", + "femtolux", + "femtoohm", + "gigabarn", + "gigagram", + "gigagray", + "gigamole", + "gigavolt", + "gigawatt", + "hectobar", + "hectolux", + "hectoohm", + "kilobarn", + "kilogram", + "kilogray", + "kilomole", + "kilovolt", + "kilowatt", + "megabarn", + "megagram", + "megagray", + "megamole", + "megavolt", + "megawatt", + "microbar", + "microlux", + "microohm", + "millibar", + "millilux", + "milliohm", + "nanobarn", + "nanogram", + "nanogray", + "nanomole", + "nanovolt", + "nanowatt", + "petabarn", + "petagram", + "petagray", + "petamole", + "petavolt", + "petawatt", + "picobarn", + "picogram", + "picogray", + "picomole", + "picovolt", + "picowatt", + "ronnabar", + "ronnalux", + "ronnaohm", + "rontobar", + "rontolux", + "rontoohm", + "terabarn", + "teragram", + "teragray", + "teramole", + "teravolt", + "terawatt", + "yoctobar", + "yoctolux", + "yoctoohm", + "yottabar", + "yottalux", + "yottaohm", + "zeptobar", + "zeptolux", + "zeptoohm", + "zettabar", + "zettalux", + "zettaohm", + "ångström", + "attobar", + "attolux", + "attoohm", + "candela", + "coulomb", + "decabar", + "decalux", + "decaohm", + "decibar", + "decilux", + "deciohm", + "exabarn", + "exagram", + "exagray", + "examole", + "exavolt", + "exawatt", + "gigabar", + "gigalux", + "gigaohm", + "hectare", + "kilobar", + "kilolux", + "kiloohm", + "megabar", + "megalux", + "megaohm", + "nanobar", + "nanolux", + "nanoohm", + "petabar", + "petalux", + "petaohm", + "picobar", + "picolux", + "picoohm", + "siemens", + "sievert", + "terabar", + "teralux", + "teraohm", + "ampere", + "dalton", + "degree", + "exabar", + "exalux", + "exaohm", + "kelvin", + "minute", + "newton", + "pascal", + "radian", + "second", + "farad", + "henry", + "hertz", + "joule", + "katal", + "litre", + "lumen", + "metre", + "tesla", + "tonne", + "weber", + "barn", + "gram", + "gray", + "hour", + "mole", + "volt", + "watt", + "bar", + "day", + "lux", + "ohm", +) diff --git a/paxman/capabilities/SIUnit/grammar/data/unit_symbol_tokens.py b/paxman/capabilities/SIUnit/grammar/data/unit_symbol_tokens.py new file mode 100644 index 00000000..7d966158 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/data/unit_symbol_tokens.py @@ -0,0 +1,942 @@ +"""GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand. + +Every recognized unit-symbol token, longest-first: the official +symbols, the SI prefixes, and the prefixed symbols. Longest-first +ordering lets a scanner prefer the longest match. Derived from the +maintained authority tables — regenerate via the tool after edits. +""" + +from __future__ import annotations + +SYMBOL_TOKENS: tuple[str, ...] = ( + "dabar", + "dakat", + "damol", + "darad", + "Ebar", + "Ekat", + "Emol", + "Erad", + "Gbar", + "Gkat", + "Gmol", + "Grad", + "Mbar", + "Mkat", + "Mmol", + "Mrad", + "Pbar", + "Pkat", + "Pmol", + "Prad", + "Qbar", + "Qkat", + "Qmol", + "Qrad", + "Rbar", + "Rkat", + "Rmol", + "Rrad", + "Tbar", + "Tkat", + "Tmol", + "Trad", + "Ybar", + "Ykat", + "Ymol", + "Yrad", + "Zbar", + "Zkat", + "Zmol", + "Zrad", + "abar", + "akat", + "amol", + "arad", + "cbar", + "ckat", + "cmol", + "crad", + "daBq", + "daDa", + "daGy", + "daHz", + "daPa", + "daSv", + "daWb", + "dacd", + "daeV", + "dalm", + "dalx", + "dasr", + "dbar", + "dkat", + "dmol", + "drad", + "fbar", + "fkat", + "fmol", + "frad", + "hbar", + "hkat", + "hmol", + "hrad", + "kbar", + "kkat", + "kmol", + "krad", + "mbar", + "mkat", + "mmHg", + "mmol", + "mrad", + "nbar", + "nkat", + "nmol", + "nrad", + "pbar", + "pkat", + "pmol", + "prad", + "qbar", + "qkat", + "qmol", + "qrad", + "rbar", + "rkat", + "rmol", + "rrad", + "ybar", + "ykat", + "ymol", + "yrad", + "zbar", + "zkat", + "zmol", + "zrad", + "µbar", + "µkat", + "µmol", + "µrad", + "EBq", + "EDa", + "EGy", + "EHz", + "EPa", + "ESv", + "EWb", + "Ecd", + "EeV", + "Elm", + "Elx", + "Esr", + "GBq", + "GDa", + "GGy", + "GHz", + "GPa", + "GSv", + "GWb", + "Gcd", + "GeV", + "Glm", + "Glx", + "Gsr", + "MBq", + "MDa", + "MGy", + "MHz", + "MPa", + "MSv", + "MWb", + "Mcd", + "MeV", + "Mlm", + "Mlx", + "Msr", + "PBq", + "PDa", + "PGy", + "PHz", + "PPa", + "PSv", + "PWb", + "Pcd", + "PeV", + "Plm", + "Plx", + "Psr", + "QBq", + "QDa", + "QGy", + "QHz", + "QPa", + "QSv", + "QWb", + "Qcd", + "QeV", + "Qlm", + "Qlx", + "Qsr", + "RBq", + "RDa", + "RGy", + "RHz", + "RPa", + "RSv", + "RWb", + "Rcd", + "ReV", + "Rlm", + "Rlx", + "Rsr", + "TBq", + "TDa", + "TGy", + "THz", + "TPa", + "TSv", + "TWb", + "Tcd", + "TeV", + "Tlm", + "Tlx", + "Tsr", + "YBq", + "YDa", + "YGy", + "YHz", + "YPa", + "YSv", + "YWb", + "Ycd", + "YeV", + "Ylm", + "Ylx", + "Ysr", + "ZBq", + "ZDa", + "ZGy", + "ZHz", + "ZPa", + "ZSv", + "ZWb", + "Zcd", + "ZeV", + "Zlm", + "Zlx", + "Zsr", + "aBq", + "aDa", + "aGy", + "aHz", + "aPa", + "aSv", + "aWb", + "acd", + "aeV", + "alm", + "alx", + "asr", + "bar", + "cBq", + "cDa", + "cGy", + "cHz", + "cPa", + "cSv", + "cWb", + "ccd", + "ceV", + "clm", + "clx", + "csr", + "dBq", + "dDa", + "dGy", + "dHz", + "dPa", + "dSv", + "dWb", + "daA", + "daC", + "daF", + "daH", + "daJ", + "daK", + "daL", + "daN", + "daS", + "daT", + "daV", + "daW", + "dab", + "dag", + "dam", + "das", + "dat", + "dau", + "daÅ", + "daΩ", + "dcd", + "deV", + "dlm", + "dlx", + "dsr", + "fBq", + "fDa", + "fGy", + "fHz", + "fPa", + "fSv", + "fWb", + "fcd", + "feV", + "flm", + "flx", + "fsr", + "hBq", + "hDa", + "hGy", + "hHz", + "hPa", + "hSv", + "hWb", + "hcd", + "heV", + "hlm", + "hlx", + "hsr", + "kBq", + "kDa", + "kGy", + "kHz", + "kPa", + "kSv", + "kWb", + "kat", + "kcd", + "keV", + "klm", + "klx", + "ksr", + "mBq", + "mDa", + "mGy", + "mHz", + "mPa", + "mSv", + "mWb", + "mcd", + "meV", + "min", + "mlm", + "mlx", + "mol", + "msr", + "nBq", + "nDa", + "nGy", + "nHz", + "nPa", + "nSv", + "nWb", + "ncd", + "neV", + "nlm", + "nlx", + "nsr", + "pBq", + "pDa", + "pGy", + "pHz", + "pPa", + "pSv", + "pWb", + "pcd", + "peV", + "plm", + "plx", + "psr", + "qBq", + "qDa", + "qGy", + "qHz", + "qPa", + "qSv", + "qWb", + "qcd", + "qeV", + "qlm", + "qlx", + "qsr", + "rBq", + "rDa", + "rGy", + "rHz", + "rPa", + "rSv", + "rWb", + "rad", + "rcd", + "reV", + "rlm", + "rlx", + "rsr", + "yBq", + "yDa", + "yGy", + "yHz", + "yPa", + "ySv", + "yWb", + "ycd", + "yeV", + "ylm", + "ylx", + "ysr", + "zBq", + "zDa", + "zGy", + "zHz", + "zPa", + "zSv", + "zWb", + "zcd", + "zeV", + "zlm", + "zlx", + "zsr", + "µBq", + "µDa", + "µGy", + "µHz", + "µPa", + "µSv", + "µWb", + "µcd", + "µeV", + "µlm", + "µlx", + "µsr", + "Bq", + "Da", + "EA", + "EC", + "EF", + "EH", + "EJ", + "EK", + "EL", + "EN", + "ES", + "ET", + "EV", + "EW", + "Eb", + "Eg", + "Em", + "Es", + "Et", + "Eu", + "EÅ", + "EΩ", + "GA", + "GC", + "GF", + "GH", + "GJ", + "GK", + "GL", + "GN", + "GS", + "GT", + "GV", + "GW", + "Gb", + "Gg", + "Gm", + "Gs", + "Gt", + "Gu", + "Gy", + "GÅ", + "GΩ", + "Hz", + "MA", + "MC", + "MF", + "MH", + "MJ", + "MK", + "ML", + "MN", + "MS", + "MT", + "MV", + "MW", + "Mb", + "Mg", + "Mm", + "Ms", + "Mt", + "Mu", + "MÅ", + "MΩ", + "PA", + "PC", + "PF", + "PH", + "PJ", + "PK", + "PL", + "PN", + "PS", + "PT", + "PV", + "PW", + "Pa", + "Pb", + "Pg", + "Pm", + "Ps", + "Pt", + "Pu", + "PÅ", + "PΩ", + "QA", + "QC", + "QF", + "QH", + "QJ", + "QK", + "QL", + "QN", + "QS", + "QT", + "QV", + "QW", + "Qb", + "Qg", + "Qm", + "Qs", + "Qt", + "Qu", + "QÅ", + "QΩ", + "RA", + "RC", + "RF", + "RH", + "RJ", + "RK", + "RL", + "RN", + "RS", + "RT", + "RV", + "RW", + "Rb", + "Rg", + "Rm", + "Rs", + "Rt", + "Ru", + "RÅ", + "RΩ", + "Sv", + "TA", + "TC", + "TF", + "TH", + "TJ", + "TK", + "TL", + "TN", + "TS", + "TT", + "TV", + "TW", + "Tb", + "Tg", + "Tm", + "Ts", + "Tt", + "Tu", + "TÅ", + "TΩ", + "Wb", + "YA", + "YC", + "YF", + "YH", + "YJ", + "YK", + "YL", + "YN", + "YS", + "YT", + "YV", + "YW", + "Yb", + "Yg", + "Ym", + "Ys", + "Yt", + "Yu", + "YÅ", + "YΩ", + "ZA", + "ZC", + "ZF", + "ZH", + "ZJ", + "ZK", + "ZL", + "ZN", + "ZS", + "ZT", + "ZV", + "ZW", + "Zb", + "Zg", + "Zm", + "Zs", + "Zt", + "Zu", + "ZÅ", + "ZΩ", + "aA", + "aC", + "aF", + "aH", + "aJ", + "aK", + "aL", + "aN", + "aS", + "aT", + "aV", + "aW", + "ab", + "ag", + "am", + "as", + "at", + "au", + "aÅ", + "aΩ", + "cA", + "cC", + "cF", + "cH", + "cJ", + "cK", + "cL", + "cN", + "cS", + "cT", + "cV", + "cW", + "cb", + "cd", + "cg", + "cm", + "cs", + "ct", + "cu", + "cÅ", + "cΩ", + "dA", + "dC", + "dF", + "dH", + "dJ", + "dK", + "dL", + "dN", + "dS", + "dT", + "dV", + "dW", + "da", + "db", + "dg", + "dm", + "ds", + "dt", + "du", + "dÅ", + "dΩ", + "eV", + "fA", + "fC", + "fF", + "fH", + "fJ", + "fK", + "fL", + "fN", + "fS", + "fT", + "fV", + "fW", + "fb", + "fg", + "fm", + "fs", + "ft", + "fu", + "fÅ", + "fΩ", + "hA", + "hC", + "hF", + "hH", + "hJ", + "hK", + "hL", + "hN", + "hS", + "hT", + "hV", + "hW", + "ha", + "hb", + "hg", + "hm", + "hs", + "ht", + "hu", + "hÅ", + "hΩ", + "kA", + "kC", + "kF", + "kH", + "kJ", + "kK", + "kL", + "kN", + "kS", + "kT", + "kV", + "kW", + "kb", + "kg", + "km", + "ks", + "kt", + "ku", + "kÅ", + "kΩ", + "lm", + "lx", + "mA", + "mC", + "mF", + "mH", + "mJ", + "mK", + "mL", + "mN", + "mS", + "mT", + "mV", + "mW", + "mb", + "mg", + "mm", + "ms", + "mt", + "mu", + "mÅ", + "mΩ", + "nA", + "nC", + "nF", + "nH", + "nJ", + "nK", + "nL", + "nN", + "nS", + "nT", + "nV", + "nW", + "nb", + "ng", + "nm", + "ns", + "nt", + "nu", + "nÅ", + "nΩ", + "pA", + "pC", + "pF", + "pH", + "pJ", + "pK", + "pL", + "pN", + "pS", + "pT", + "pV", + "pW", + "pb", + "pg", + "pm", + "ps", + "pt", + "pu", + "pÅ", + "pΩ", + "qA", + "qC", + "qF", + "qH", + "qJ", + "qK", + "qL", + "qN", + "qS", + "qT", + "qV", + "qW", + "qb", + "qg", + "qm", + "qs", + "qt", + "qu", + "qÅ", + "qΩ", + "rA", + "rC", + "rF", + "rH", + "rJ", + "rK", + "rL", + "rN", + "rS", + "rT", + "rV", + "rW", + "rb", + "rg", + "rm", + "rs", + "rt", + "ru", + "rÅ", + "rΩ", + "sr", + "yA", + "yC", + "yF", + "yH", + "yJ", + "yK", + "yL", + "yN", + "yS", + "yT", + "yV", + "yW", + "yb", + "yg", + "ym", + "ys", + "yt", + "yu", + "yÅ", + "yΩ", + "zA", + "zC", + "zF", + "zH", + "zJ", + "zK", + "zL", + "zN", + "zS", + "zT", + "zV", + "zW", + "zb", + "zg", + "zm", + "zs", + "zt", + "zu", + "zÅ", + "zΩ", + "°C", + "µA", + "µC", + "µF", + "µH", + "µJ", + "µK", + "µL", + "µN", + "µS", + "µT", + "µV", + "µW", + "µb", + "µg", + "µm", + "µs", + "µt", + "µu", + "µÅ", + "µΩ", + "A", + "C", + "E", + "F", + "G", + "H", + "J", + "K", + "L", + "M", + "N", + "P", + "Q", + "R", + "S", + "T", + "V", + "W", + "Y", + "Z", + "a", + "b", + "c", + "d", + "f", + "g", + "h", + "k", + "l", + "m", + "n", + "p", + "q", + "r", + "s", + "t", + "u", + "y", + "z", + "°", + "µ", + "Å", + "Ω", + "′", + "″", +) diff --git a/paxman/capabilities/SIUnit/rules/data/prefixed_unit_names.py b/paxman/capabilities/SIUnit/rules/data/prefixed_unit_names.py new file mode 100644 index 00000000..58ddbbf0 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/prefixed_unit_names.py @@ -0,0 +1,876 @@ +"""GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand. + +Prefixed unit names -> prefixed symbols (BIPM SI Brochure, 9th ed., +2019): "mega" + "hertz" -> "MHz". Official names win over +regenerated collisions ("kilogram" is never kilo+gram). Sorted by +name for deterministic output. Derived from the maintained authority +tables — regenerate via the tool after edits. +""" + +from __future__ import annotations + +PREFIXED_NAME_TO_SYMBOL: dict[str, str] = { + "attoampere": "aA", + "attobar": "abar", + "attobarn": "ab", + "attobecquerel": "aBq", + "attocandela": "acd", + "attocoulomb": "aC", + "attodalton": "aDa", + "attoelectronvolt": "aeV", + "attofarad": "aF", + "attogram": "ag", + "attogray": "aGy", + "attohenry": "aH", + "attohertz": "aHz", + "attojoule": "aJ", + "attokatal": "akat", + "attokelvin": "aK", + "attolitre": "aL", + "attolumen": "alm", + "attolux": "alx", + "attometre": "am", + "attomole": "amol", + "attonewton": "aN", + "attoohm": "aΩ", + "attopascal": "aPa", + "attoradian": "arad", + "attosecond": "as", + "attosiemens": "aS", + "attosievert": "aSv", + "attosteradian": "asr", + "attotesla": "aT", + "attotonne": "at", + "attounified atomic mass unit": "au", + "attovolt": "aV", + "attowatt": "aW", + "attoweber": "aWb", + "attoångström": "aÅ", + "centiampere": "cA", + "centibar": "cbar", + "centibarn": "cb", + "centibecquerel": "cBq", + "centicandela": "ccd", + "centicoulomb": "cC", + "centidalton": "cDa", + "centielectronvolt": "ceV", + "centifarad": "cF", + "centigram": "cg", + "centigray": "cGy", + "centihenry": "cH", + "centihertz": "cHz", + "centijoule": "cJ", + "centikatal": "ckat", + "centikelvin": "cK", + "centilitre": "cL", + "centilumen": "clm", + "centilux": "clx", + "centimetre": "cm", + "centimole": "cmol", + "centinewton": "cN", + "centiohm": "cΩ", + "centipascal": "cPa", + "centiradian": "crad", + "centisecond": "cs", + "centisiemens": "cS", + "centisievert": "cSv", + "centisteradian": "csr", + "centitesla": "cT", + "centitonne": "ct", + "centiunified atomic mass unit": "cu", + "centivolt": "cV", + "centiwatt": "cW", + "centiweber": "cWb", + "centiångström": "cÅ", + "decaampere": "daA", + "decabar": "dabar", + "decabarn": "dab", + "decabecquerel": "daBq", + "decacandela": "dacd", + "decacoulomb": "daC", + "decadalton": "daDa", + "decaelectronvolt": "daeV", + "decafarad": "daF", + "decagram": "dag", + "decagray": "daGy", + "decahenry": "daH", + "decahertz": "daHz", + "decajoule": "daJ", + "decakatal": "dakat", + "decakelvin": "daK", + "decalitre": "daL", + "decalumen": "dalm", + "decalux": "dalx", + "decametre": "dam", + "decamole": "damol", + "decanewton": "daN", + "decaohm": "daΩ", + "decapascal": "daPa", + "decaradian": "darad", + "decasecond": "das", + "decasiemens": "daS", + "decasievert": "daSv", + "decasteradian": "dasr", + "decatesla": "daT", + "decatonne": "dat", + "decaunified atomic mass unit": "dau", + "decavolt": "daV", + "decawatt": "daW", + "decaweber": "daWb", + "decaångström": "daÅ", + "deciampere": "dA", + "decibar": "dbar", + "decibarn": "db", + "decibecquerel": "dBq", + "decicandela": "dcd", + "decicoulomb": "dC", + "decidalton": "dDa", + "decielectronvolt": "deV", + "decifarad": "dF", + "decigram": "dg", + "decigray": "dGy", + "decihenry": "dH", + "decihertz": "dHz", + "decijoule": "dJ", + "decikatal": "dkat", + "decikelvin": "dK", + "decilitre": "dL", + "decilumen": "dlm", + "decilux": "dlx", + "decimetre": "dm", + "decimole": "dmol", + "decinewton": "dN", + "deciohm": "dΩ", + "decipascal": "dPa", + "deciradian": "drad", + "decisecond": "ds", + "decisiemens": "dS", + "decisievert": "dSv", + "decisteradian": "dsr", + "decitesla": "dT", + "decitonne": "dt", + "deciunified atomic mass unit": "du", + "decivolt": "dV", + "deciwatt": "dW", + "deciweber": "dWb", + "deciångström": "dÅ", + "exaampere": "EA", + "exabar": "Ebar", + "exabarn": "Eb", + "exabecquerel": "EBq", + "exacandela": "Ecd", + "exacoulomb": "EC", + "exadalton": "EDa", + "exaelectronvolt": "EeV", + "exafarad": "EF", + "exagram": "Eg", + "exagray": "EGy", + "exahenry": "EH", + "exahertz": "EHz", + "exajoule": "EJ", + "exakatal": "Ekat", + "exakelvin": "EK", + "exalitre": "EL", + "exalumen": "Elm", + "exalux": "Elx", + "exametre": "Em", + "examole": "Emol", + "exanewton": "EN", + "exaohm": "EΩ", + "exapascal": "EPa", + "exaradian": "Erad", + "exasecond": "Es", + "exasiemens": "ES", + "exasievert": "ESv", + "exasteradian": "Esr", + "exatesla": "ET", + "exatonne": "Et", + "exaunified atomic mass unit": "Eu", + "exavolt": "EV", + "exawatt": "EW", + "exaweber": "EWb", + "exaångström": "EÅ", + "femtoampere": "fA", + "femtobar": "fbar", + "femtobarn": "fb", + "femtobecquerel": "fBq", + "femtocandela": "fcd", + "femtocoulomb": "fC", + "femtodalton": "fDa", + "femtoelectronvolt": "feV", + "femtofarad": "fF", + "femtogram": "fg", + "femtogray": "fGy", + "femtohenry": "fH", + "femtohertz": "fHz", + "femtojoule": "fJ", + "femtokatal": "fkat", + "femtokelvin": "fK", + "femtolitre": "fL", + "femtolumen": "flm", + "femtolux": "flx", + "femtometre": "fm", + "femtomole": "fmol", + "femtonewton": "fN", + "femtoohm": "fΩ", + "femtopascal": "fPa", + "femtoradian": "frad", + "femtosecond": "fs", + "femtosiemens": "fS", + "femtosievert": "fSv", + "femtosteradian": "fsr", + "femtotesla": "fT", + "femtotonne": "ft", + "femtounified atomic mass unit": "fu", + "femtovolt": "fV", + "femtowatt": "fW", + "femtoweber": "fWb", + "femtoångström": "fÅ", + "gigaampere": "GA", + "gigabar": "Gbar", + "gigabarn": "Gb", + "gigabecquerel": "GBq", + "gigacandela": "Gcd", + "gigacoulomb": "GC", + "gigadalton": "GDa", + "gigaelectronvolt": "GeV", + "gigafarad": "GF", + "gigagram": "Gg", + "gigagray": "GGy", + "gigahenry": "GH", + "gigahertz": "GHz", + "gigajoule": "GJ", + "gigakatal": "Gkat", + "gigakelvin": "GK", + "gigalitre": "GL", + "gigalumen": "Glm", + "gigalux": "Glx", + "gigametre": "Gm", + "gigamole": "Gmol", + "giganewton": "GN", + "gigaohm": "GΩ", + "gigapascal": "GPa", + "gigaradian": "Grad", + "gigasecond": "Gs", + "gigasiemens": "GS", + "gigasievert": "GSv", + "gigasteradian": "Gsr", + "gigatesla": "GT", + "gigatonne": "Gt", + "gigaunified atomic mass unit": "Gu", + "gigavolt": "GV", + "gigawatt": "GW", + "gigaweber": "GWb", + "gigaångström": "GÅ", + "hectoampere": "hA", + "hectobar": "hbar", + "hectobarn": "hb", + "hectobecquerel": "hBq", + "hectocandela": "hcd", + "hectocoulomb": "hC", + "hectodalton": "hDa", + "hectoelectronvolt": "heV", + "hectofarad": "hF", + "hectogram": "hg", + "hectogray": "hGy", + "hectohenry": "hH", + "hectohertz": "hHz", + "hectojoule": "hJ", + "hectokatal": "hkat", + "hectokelvin": "hK", + "hectolitre": "hL", + "hectolumen": "hlm", + "hectolux": "hlx", + "hectometre": "hm", + "hectomole": "hmol", + "hectonewton": "hN", + "hectoohm": "hΩ", + "hectopascal": "hPa", + "hectoradian": "hrad", + "hectosecond": "hs", + "hectosiemens": "hS", + "hectosievert": "hSv", + "hectosteradian": "hsr", + "hectotesla": "hT", + "hectotonne": "ht", + "hectounified atomic mass unit": "hu", + "hectovolt": "hV", + "hectowatt": "hW", + "hectoweber": "hWb", + "hectoångström": "hÅ", + "kiloampere": "kA", + "kilobar": "kbar", + "kilobarn": "kb", + "kilobecquerel": "kBq", + "kilocandela": "kcd", + "kilocoulomb": "kC", + "kilodalton": "kDa", + "kiloelectronvolt": "keV", + "kilofarad": "kF", + "kilogray": "kGy", + "kilohenry": "kH", + "kilohertz": "kHz", + "kilojoule": "kJ", + "kilokatal": "kkat", + "kilokelvin": "kK", + "kilolitre": "kL", + "kilolumen": "klm", + "kilolux": "klx", + "kilometre": "km", + "kilomole": "kmol", + "kilonewton": "kN", + "kiloohm": "kΩ", + "kilopascal": "kPa", + "kiloradian": "krad", + "kilosecond": "ks", + "kilosiemens": "kS", + "kilosievert": "kSv", + "kilosteradian": "ksr", + "kilotesla": "kT", + "kilotonne": "kt", + "kilounified atomic mass unit": "ku", + "kilovolt": "kV", + "kilowatt": "kW", + "kiloweber": "kWb", + "kiloångström": "kÅ", + "megaampere": "MA", + "megabar": "Mbar", + "megabarn": "Mb", + "megabecquerel": "MBq", + "megacandela": "Mcd", + "megacoulomb": "MC", + "megadalton": "MDa", + "megaelectronvolt": "MeV", + "megafarad": "MF", + "megagram": "Mg", + "megagray": "MGy", + "megahenry": "MH", + "megahertz": "MHz", + "megajoule": "MJ", + "megakatal": "Mkat", + "megakelvin": "MK", + "megalitre": "ML", + "megalumen": "Mlm", + "megalux": "Mlx", + "megametre": "Mm", + "megamole": "Mmol", + "meganewton": "MN", + "megaohm": "MΩ", + "megapascal": "MPa", + "megaradian": "Mrad", + "megasecond": "Ms", + "megasiemens": "MS", + "megasievert": "MSv", + "megasteradian": "Msr", + "megatesla": "MT", + "megatonne": "Mt", + "megaunified atomic mass unit": "Mu", + "megavolt": "MV", + "megawatt": "MW", + "megaweber": "MWb", + "megaångström": "MÅ", + "microampere": "µA", + "microbar": "µbar", + "microbarn": "µb", + "microbecquerel": "µBq", + "microcandela": "µcd", + "microcoulomb": "µC", + "microdalton": "µDa", + "microelectronvolt": "µeV", + "microfarad": "µF", + "microgram": "µg", + "microgray": "µGy", + "microhenry": "µH", + "microhertz": "µHz", + "microjoule": "µJ", + "microkatal": "µkat", + "microkelvin": "µK", + "microlitre": "µL", + "microlumen": "µlm", + "microlux": "µlx", + "micrometre": "µm", + "micromole": "µmol", + "micronewton": "µN", + "microohm": "µΩ", + "micropascal": "µPa", + "microradian": "µrad", + "microsecond": "µs", + "microsiemens": "µS", + "microsievert": "µSv", + "microsteradian": "µsr", + "microtesla": "µT", + "microtonne": "µt", + "microunified atomic mass unit": "µu", + "microvolt": "µV", + "microwatt": "µW", + "microweber": "µWb", + "microångström": "µÅ", + "milliampere": "mA", + "millibar": "mbar", + "millibarn": "mb", + "millibecquerel": "mBq", + "millicandela": "mcd", + "millicoulomb": "mC", + "millidalton": "mDa", + "millielectronvolt": "meV", + "millifarad": "mF", + "milligram": "mg", + "milligray": "mGy", + "millihenry": "mH", + "millihertz": "mHz", + "millijoule": "mJ", + "millikatal": "mkat", + "millikelvin": "mK", + "millilitre": "mL", + "millilumen": "mlm", + "millilux": "mlx", + "millimetre": "mm", + "millimole": "mmol", + "millinewton": "mN", + "milliohm": "mΩ", + "millipascal": "mPa", + "milliradian": "mrad", + "millisecond": "ms", + "millisiemens": "mS", + "millisievert": "mSv", + "millisteradian": "msr", + "millitesla": "mT", + "millitonne": "mt", + "milliunified atomic mass unit": "mu", + "millivolt": "mV", + "milliwatt": "mW", + "milliweber": "mWb", + "milliångström": "mÅ", + "nanoampere": "nA", + "nanobar": "nbar", + "nanobarn": "nb", + "nanobecquerel": "nBq", + "nanocandela": "ncd", + "nanocoulomb": "nC", + "nanodalton": "nDa", + "nanoelectronvolt": "neV", + "nanofarad": "nF", + "nanogram": "ng", + "nanogray": "nGy", + "nanohenry": "nH", + "nanohertz": "nHz", + "nanojoule": "nJ", + "nanokatal": "nkat", + "nanokelvin": "nK", + "nanolitre": "nL", + "nanolumen": "nlm", + "nanolux": "nlx", + "nanometre": "nm", + "nanomole": "nmol", + "nanonewton": "nN", + "nanoohm": "nΩ", + "nanopascal": "nPa", + "nanoradian": "nrad", + "nanosecond": "ns", + "nanosiemens": "nS", + "nanosievert": "nSv", + "nanosteradian": "nsr", + "nanotesla": "nT", + "nanotonne": "nt", + "nanounified atomic mass unit": "nu", + "nanovolt": "nV", + "nanowatt": "nW", + "nanoweber": "nWb", + "nanoångström": "nÅ", + "petaampere": "PA", + "petabar": "Pbar", + "petabarn": "Pb", + "petabecquerel": "PBq", + "petacandela": "Pcd", + "petacoulomb": "PC", + "petadalton": "PDa", + "petaelectronvolt": "PeV", + "petafarad": "PF", + "petagram": "Pg", + "petagray": "PGy", + "petahenry": "PH", + "petahertz": "PHz", + "petajoule": "PJ", + "petakatal": "Pkat", + "petakelvin": "PK", + "petalitre": "PL", + "petalumen": "Plm", + "petalux": "Plx", + "petametre": "Pm", + "petamole": "Pmol", + "petanewton": "PN", + "petaohm": "PΩ", + "petapascal": "PPa", + "petaradian": "Prad", + "petasecond": "Ps", + "petasiemens": "PS", + "petasievert": "PSv", + "petasteradian": "Psr", + "petatesla": "PT", + "petatonne": "Pt", + "petaunified atomic mass unit": "Pu", + "petavolt": "PV", + "petawatt": "PW", + "petaweber": "PWb", + "petaångström": "PÅ", + "picoampere": "pA", + "picobar": "pbar", + "picobarn": "pb", + "picobecquerel": "pBq", + "picocandela": "pcd", + "picocoulomb": "pC", + "picodalton": "pDa", + "picoelectronvolt": "peV", + "picofarad": "pF", + "picogram": "pg", + "picogray": "pGy", + "picohenry": "pH", + "picohertz": "pHz", + "picojoule": "pJ", + "picokatal": "pkat", + "picokelvin": "pK", + "picolitre": "pL", + "picolumen": "plm", + "picolux": "plx", + "picometre": "pm", + "picomole": "pmol", + "piconewton": "pN", + "picoohm": "pΩ", + "picopascal": "pPa", + "picoradian": "prad", + "picosecond": "ps", + "picosiemens": "pS", + "picosievert": "pSv", + "picosteradian": "psr", + "picotesla": "pT", + "picotonne": "pt", + "picounified atomic mass unit": "pu", + "picovolt": "pV", + "picowatt": "pW", + "picoweber": "pWb", + "picoångström": "pÅ", + "quectoampere": "qA", + "quectobar": "qbar", + "quectobarn": "qb", + "quectobecquerel": "qBq", + "quectocandela": "qcd", + "quectocoulomb": "qC", + "quectodalton": "qDa", + "quectoelectronvolt": "qeV", + "quectofarad": "qF", + "quectogram": "qg", + "quectogray": "qGy", + "quectohenry": "qH", + "quectohertz": "qHz", + "quectojoule": "qJ", + "quectokatal": "qkat", + "quectokelvin": "qK", + "quectolitre": "qL", + "quectolumen": "qlm", + "quectolux": "qlx", + "quectometre": "qm", + "quectomole": "qmol", + "quectonewton": "qN", + "quectoohm": "qΩ", + "quectopascal": "qPa", + "quectoradian": "qrad", + "quectosecond": "qs", + "quectosiemens": "qS", + "quectosievert": "qSv", + "quectosteradian": "qsr", + "quectotesla": "qT", + "quectotonne": "qt", + "quectounified atomic mass unit": "qu", + "quectovolt": "qV", + "quectowatt": "qW", + "quectoweber": "qWb", + "quectoångström": "qÅ", + "quettaampere": "QA", + "quettabar": "Qbar", + "quettabarn": "Qb", + "quettabecquerel": "QBq", + "quettacandela": "Qcd", + "quettacoulomb": "QC", + "quettadalton": "QDa", + "quettaelectronvolt": "QeV", + "quettafarad": "QF", + "quettagram": "Qg", + "quettagray": "QGy", + "quettahenry": "QH", + "quettahertz": "QHz", + "quettajoule": "QJ", + "quettakatal": "Qkat", + "quettakelvin": "QK", + "quettalitre": "QL", + "quettalumen": "Qlm", + "quettalux": "Qlx", + "quettametre": "Qm", + "quettamole": "Qmol", + "quettanewton": "QN", + "quettaohm": "QΩ", + "quettapascal": "QPa", + "quettaradian": "Qrad", + "quettasecond": "Qs", + "quettasiemens": "QS", + "quettasievert": "QSv", + "quettasteradian": "Qsr", + "quettatesla": "QT", + "quettatonne": "Qt", + "quettaunified atomic mass unit": "Qu", + "quettavolt": "QV", + "quettawatt": "QW", + "quettaweber": "QWb", + "quettaångström": "QÅ", + "ronnaampere": "RA", + "ronnabar": "Rbar", + "ronnabarn": "Rb", + "ronnabecquerel": "RBq", + "ronnacandela": "Rcd", + "ronnacoulomb": "RC", + "ronnadalton": "RDa", + "ronnaelectronvolt": "ReV", + "ronnafarad": "RF", + "ronnagram": "Rg", + "ronnagray": "RGy", + "ronnahenry": "RH", + "ronnahertz": "RHz", + "ronnajoule": "RJ", + "ronnakatal": "Rkat", + "ronnakelvin": "RK", + "ronnalitre": "RL", + "ronnalumen": "Rlm", + "ronnalux": "Rlx", + "ronnametre": "Rm", + "ronnamole": "Rmol", + "ronnanewton": "RN", + "ronnaohm": "RΩ", + "ronnapascal": "RPa", + "ronnaradian": "Rrad", + "ronnasecond": "Rs", + "ronnasiemens": "RS", + "ronnasievert": "RSv", + "ronnasteradian": "Rsr", + "ronnatesla": "RT", + "ronnatonne": "Rt", + "ronnaunified atomic mass unit": "Ru", + "ronnavolt": "RV", + "ronnawatt": "RW", + "ronnaweber": "RWb", + "ronnaångström": "RÅ", + "rontoampere": "rA", + "rontobar": "rbar", + "rontobarn": "rb", + "rontobecquerel": "rBq", + "rontocandela": "rcd", + "rontocoulomb": "rC", + "rontodalton": "rDa", + "rontoelectronvolt": "reV", + "rontofarad": "rF", + "rontogram": "rg", + "rontogray": "rGy", + "rontohenry": "rH", + "rontohertz": "rHz", + "rontojoule": "rJ", + "rontokatal": "rkat", + "rontokelvin": "rK", + "rontolitre": "rL", + "rontolumen": "rlm", + "rontolux": "rlx", + "rontometre": "rm", + "rontomole": "rmol", + "rontonewton": "rN", + "rontoohm": "rΩ", + "rontopascal": "rPa", + "rontoradian": "rrad", + "rontosecond": "rs", + "rontosiemens": "rS", + "rontosievert": "rSv", + "rontosteradian": "rsr", + "rontotesla": "rT", + "rontotonne": "rt", + "rontounified atomic mass unit": "ru", + "rontovolt": "rV", + "rontowatt": "rW", + "rontoweber": "rWb", + "rontoångström": "rÅ", + "teraampere": "TA", + "terabar": "Tbar", + "terabarn": "Tb", + "terabecquerel": "TBq", + "teracandela": "Tcd", + "teracoulomb": "TC", + "teradalton": "TDa", + "teraelectronvolt": "TeV", + "terafarad": "TF", + "teragram": "Tg", + "teragray": "TGy", + "terahenry": "TH", + "terahertz": "THz", + "terajoule": "TJ", + "terakatal": "Tkat", + "terakelvin": "TK", + "teralitre": "TL", + "teralumen": "Tlm", + "teralux": "Tlx", + "terametre": "Tm", + "teramole": "Tmol", + "teranewton": "TN", + "teraohm": "TΩ", + "terapascal": "TPa", + "teraradian": "Trad", + "terasecond": "Ts", + "terasiemens": "TS", + "terasievert": "TSv", + "terasteradian": "Tsr", + "teratesla": "TT", + "teratonne": "Tt", + "teraunified atomic mass unit": "Tu", + "teravolt": "TV", + "terawatt": "TW", + "teraweber": "TWb", + "teraångström": "TÅ", + "yoctoampere": "yA", + "yoctobar": "ybar", + "yoctobarn": "yb", + "yoctobecquerel": "yBq", + "yoctocandela": "ycd", + "yoctocoulomb": "yC", + "yoctodalton": "yDa", + "yoctoelectronvolt": "yeV", + "yoctofarad": "yF", + "yoctogram": "yg", + "yoctogray": "yGy", + "yoctohenry": "yH", + "yoctohertz": "yHz", + "yoctojoule": "yJ", + "yoctokatal": "ykat", + "yoctokelvin": "yK", + "yoctolitre": "yL", + "yoctolumen": "ylm", + "yoctolux": "ylx", + "yoctometre": "ym", + "yoctomole": "ymol", + "yoctonewton": "yN", + "yoctoohm": "yΩ", + "yoctopascal": "yPa", + "yoctoradian": "yrad", + "yoctosecond": "ys", + "yoctosiemens": "yS", + "yoctosievert": "ySv", + "yoctosteradian": "ysr", + "yoctotesla": "yT", + "yoctotonne": "yt", + "yoctounified atomic mass unit": "yu", + "yoctovolt": "yV", + "yoctowatt": "yW", + "yoctoweber": "yWb", + "yoctoångström": "yÅ", + "yottaampere": "YA", + "yottabar": "Ybar", + "yottabarn": "Yb", + "yottabecquerel": "YBq", + "yottacandela": "Ycd", + "yottacoulomb": "YC", + "yottadalton": "YDa", + "yottaelectronvolt": "YeV", + "yottafarad": "YF", + "yottagram": "Yg", + "yottagray": "YGy", + "yottahenry": "YH", + "yottahertz": "YHz", + "yottajoule": "YJ", + "yottakatal": "Ykat", + "yottakelvin": "YK", + "yottalitre": "YL", + "yottalumen": "Ylm", + "yottalux": "Ylx", + "yottametre": "Ym", + "yottamole": "Ymol", + "yottanewton": "YN", + "yottaohm": "YΩ", + "yottapascal": "YPa", + "yottaradian": "Yrad", + "yottasecond": "Ys", + "yottasiemens": "YS", + "yottasievert": "YSv", + "yottasteradian": "Ysr", + "yottatesla": "YT", + "yottatonne": "Yt", + "yottaunified atomic mass unit": "Yu", + "yottavolt": "YV", + "yottawatt": "YW", + "yottaweber": "YWb", + "yottaångström": "YÅ", + "zeptoampere": "zA", + "zeptobar": "zbar", + "zeptobarn": "zb", + "zeptobecquerel": "zBq", + "zeptocandela": "zcd", + "zeptocoulomb": "zC", + "zeptodalton": "zDa", + "zeptoelectronvolt": "zeV", + "zeptofarad": "zF", + "zeptogram": "zg", + "zeptogray": "zGy", + "zeptohenry": "zH", + "zeptohertz": "zHz", + "zeptojoule": "zJ", + "zeptokatal": "zkat", + "zeptokelvin": "zK", + "zeptolitre": "zL", + "zeptolumen": "zlm", + "zeptolux": "zlx", + "zeptometre": "zm", + "zeptomole": "zmol", + "zeptonewton": "zN", + "zeptoohm": "zΩ", + "zeptopascal": "zPa", + "zeptoradian": "zrad", + "zeptosecond": "zs", + "zeptosiemens": "zS", + "zeptosievert": "zSv", + "zeptosteradian": "zsr", + "zeptotesla": "zT", + "zeptotonne": "zt", + "zeptounified atomic mass unit": "zu", + "zeptovolt": "zV", + "zeptowatt": "zW", + "zeptoweber": "zWb", + "zeptoångström": "zÅ", + "zettaampere": "ZA", + "zettabar": "Zbar", + "zettabarn": "Zb", + "zettabecquerel": "ZBq", + "zettacandela": "Zcd", + "zettacoulomb": "ZC", + "zettadalton": "ZDa", + "zettaelectronvolt": "ZeV", + "zettafarad": "ZF", + "zettagram": "Zg", + "zettagray": "ZGy", + "zettahenry": "ZH", + "zettahertz": "ZHz", + "zettajoule": "ZJ", + "zettakatal": "Zkat", + "zettakelvin": "ZK", + "zettalitre": "ZL", + "zettalumen": "Zlm", + "zettalux": "Zlx", + "zettametre": "Zm", + "zettamole": "Zmol", + "zettanewton": "ZN", + "zettaohm": "ZΩ", + "zettapascal": "ZPa", + "zettaradian": "Zrad", + "zettasecond": "Zs", + "zettasiemens": "ZS", + "zettasievert": "ZSv", + "zettasteradian": "Zsr", + "zettatesla": "ZT", + "zettatonne": "Zt", + "zettaunified atomic mass unit": "Zu", + "zettavolt": "ZV", + "zettawatt": "ZW", + "zettaweber": "ZWb", + "zettaångström": "ZÅ", +} diff --git a/paxman/capabilities/SIUnit/rules/data/prefixed_units.py b/paxman/capabilities/SIUnit/rules/data/prefixed_units.py new file mode 100644 index 00000000..1825ccfe --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/data/prefixed_units.py @@ -0,0 +1,878 @@ +"""GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand. + +Prefixed SI unit symbols: every BIPM SI Brochure (9th ed., 2019) +Table 5 prefix applied to every prefixable unit, excluding +official-symbol collisions (e.g. "kg"; "cd" never arises because +the day "d" is not prefixable). Derived from the maintained +authority tables — regenerate via the tool after edits. +""" + +from __future__ import annotations + +PREFIXED_UNIT_SYMBOLS: frozenset[str] = frozenset( + { + "EA", + "EBq", + "EC", + "EDa", + "EF", + "EGy", + "EH", + "EHz", + "EJ", + "EK", + "EL", + "EN", + "EPa", + "ES", + "ESv", + "ET", + "EV", + "EW", + "EWb", + "Eb", + "Ebar", + "Ecd", + "EeV", + "Eg", + "Ekat", + "Elm", + "Elx", + "Em", + "Emol", + "Erad", + "Es", + "Esr", + "Et", + "Eu", + "EÅ", + "EΩ", + "GA", + "GBq", + "GC", + "GDa", + "GF", + "GGy", + "GH", + "GHz", + "GJ", + "GK", + "GL", + "GN", + "GPa", + "GS", + "GSv", + "GT", + "GV", + "GW", + "GWb", + "Gb", + "Gbar", + "Gcd", + "GeV", + "Gg", + "Gkat", + "Glm", + "Glx", + "Gm", + "Gmol", + "Grad", + "Gs", + "Gsr", + "Gt", + "Gu", + "GÅ", + "GΩ", + "MA", + "MBq", + "MC", + "MDa", + "MF", + "MGy", + "MH", + "MHz", + "MJ", + "MK", + "ML", + "MN", + "MPa", + "MS", + "MSv", + "MT", + "MV", + "MW", + "MWb", + "Mb", + "Mbar", + "Mcd", + "MeV", + "Mg", + "Mkat", + "Mlm", + "Mlx", + "Mm", + "Mmol", + "Mrad", + "Ms", + "Msr", + "Mt", + "Mu", + "MÅ", + "MΩ", + "PA", + "PBq", + "PC", + "PDa", + "PF", + "PGy", + "PH", + "PHz", + "PJ", + "PK", + "PL", + "PN", + "PPa", + "PS", + "PSv", + "PT", + "PV", + "PW", + "PWb", + "Pb", + "Pbar", + "Pcd", + "PeV", + "Pg", + "Pkat", + "Plm", + "Plx", + "Pm", + "Pmol", + "Prad", + "Ps", + "Psr", + "Pt", + "Pu", + "PÅ", + "PΩ", + "QA", + "QBq", + "QC", + "QDa", + "QF", + "QGy", + "QH", + "QHz", + "QJ", + "QK", + "QL", + "QN", + "QPa", + "QS", + "QSv", + "QT", + "QV", + "QW", + "QWb", + "Qb", + "Qbar", + "Qcd", + "QeV", + "Qg", + "Qkat", + "Qlm", + "Qlx", + "Qm", + "Qmol", + "Qrad", + "Qs", + "Qsr", + "Qt", + "Qu", + "QÅ", + "QΩ", + "RA", + "RBq", + "RC", + "RDa", + "RF", + "RGy", + "RH", + "RHz", + "RJ", + "RK", + "RL", + "RN", + "RPa", + "RS", + "RSv", + "RT", + "RV", + "RW", + "RWb", + "Rb", + "Rbar", + "Rcd", + "ReV", + "Rg", + "Rkat", + "Rlm", + "Rlx", + "Rm", + "Rmol", + "Rrad", + "Rs", + "Rsr", + "Rt", + "Ru", + "RÅ", + "RΩ", + "TA", + "TBq", + "TC", + "TDa", + "TF", + "TGy", + "TH", + "THz", + "TJ", + "TK", + "TL", + "TN", + "TPa", + "TS", + "TSv", + "TT", + "TV", + "TW", + "TWb", + "Tb", + "Tbar", + "Tcd", + "TeV", + "Tg", + "Tkat", + "Tlm", + "Tlx", + "Tm", + "Tmol", + "Trad", + "Ts", + "Tsr", + "Tt", + "Tu", + "TÅ", + "TΩ", + "YA", + "YBq", + "YC", + "YDa", + "YF", + "YGy", + "YH", + "YHz", + "YJ", + "YK", + "YL", + "YN", + "YPa", + "YS", + "YSv", + "YT", + "YV", + "YW", + "YWb", + "Yb", + "Ybar", + "Ycd", + "YeV", + "Yg", + "Ykat", + "Ylm", + "Ylx", + "Ym", + "Ymol", + "Yrad", + "Ys", + "Ysr", + "Yt", + "Yu", + "YÅ", + "YΩ", + "ZA", + "ZBq", + "ZC", + "ZDa", + "ZF", + "ZGy", + "ZH", + "ZHz", + "ZJ", + "ZK", + "ZL", + "ZN", + "ZPa", + "ZS", + "ZSv", + "ZT", + "ZV", + "ZW", + "ZWb", + "Zb", + "Zbar", + "Zcd", + "ZeV", + "Zg", + "Zkat", + "Zlm", + "Zlx", + "Zm", + "Zmol", + "Zrad", + "Zs", + "Zsr", + "Zt", + "Zu", + "ZÅ", + "ZΩ", + "aA", + "aBq", + "aC", + "aDa", + "aF", + "aGy", + "aH", + "aHz", + "aJ", + "aK", + "aL", + "aN", + "aPa", + "aS", + "aSv", + "aT", + "aV", + "aW", + "aWb", + "ab", + "abar", + "acd", + "aeV", + "ag", + "akat", + "alm", + "alx", + "am", + "amol", + "arad", + "as", + "asr", + "at", + "au", + "aÅ", + "aΩ", + "cA", + "cBq", + "cC", + "cDa", + "cF", + "cGy", + "cH", + "cHz", + "cJ", + "cK", + "cL", + "cN", + "cPa", + "cS", + "cSv", + "cT", + "cV", + "cW", + "cWb", + "cb", + "cbar", + "ccd", + "ceV", + "cg", + "ckat", + "clm", + "clx", + "cm", + "cmol", + "crad", + "cs", + "csr", + "ct", + "cu", + "cÅ", + "cΩ", + "dA", + "dBq", + "dC", + "dDa", + "dF", + "dGy", + "dH", + "dHz", + "dJ", + "dK", + "dL", + "dN", + "dPa", + "dS", + "dSv", + "dT", + "dV", + "dW", + "dWb", + "daA", + "daBq", + "daC", + "daDa", + "daF", + "daGy", + "daH", + "daHz", + "daJ", + "daK", + "daL", + "daN", + "daPa", + "daS", + "daSv", + "daT", + "daV", + "daW", + "daWb", + "dab", + "dabar", + "dacd", + "daeV", + "dag", + "dakat", + "dalm", + "dalx", + "dam", + "damol", + "darad", + "das", + "dasr", + "dat", + "dau", + "daÅ", + "daΩ", + "db", + "dbar", + "dcd", + "deV", + "dg", + "dkat", + "dlm", + "dlx", + "dm", + "dmol", + "drad", + "ds", + "dsr", + "dt", + "du", + "dÅ", + "dΩ", + "fA", + "fBq", + "fC", + "fDa", + "fF", + "fGy", + "fH", + "fHz", + "fJ", + "fK", + "fL", + "fN", + "fPa", + "fS", + "fSv", + "fT", + "fV", + "fW", + "fWb", + "fb", + "fbar", + "fcd", + "feV", + "fg", + "fkat", + "flm", + "flx", + "fm", + "fmol", + "frad", + "fs", + "fsr", + "ft", + "fu", + "fÅ", + "fΩ", + "hA", + "hBq", + "hC", + "hDa", + "hF", + "hGy", + "hH", + "hHz", + "hJ", + "hK", + "hL", + "hN", + "hPa", + "hS", + "hSv", + "hT", + "hV", + "hW", + "hWb", + "hb", + "hbar", + "hcd", + "heV", + "hg", + "hkat", + "hlm", + "hlx", + "hm", + "hmol", + "hrad", + "hs", + "hsr", + "ht", + "hu", + "hÅ", + "hΩ", + "kA", + "kBq", + "kC", + "kDa", + "kF", + "kGy", + "kH", + "kHz", + "kJ", + "kK", + "kL", + "kN", + "kPa", + "kS", + "kSv", + "kT", + "kV", + "kW", + "kWb", + "kb", + "kbar", + "kcd", + "keV", + "kkat", + "klm", + "klx", + "km", + "kmol", + "krad", + "ks", + "ksr", + "kt", + "ku", + "kÅ", + "kΩ", + "mA", + "mBq", + "mC", + "mDa", + "mF", + "mGy", + "mH", + "mHz", + "mJ", + "mK", + "mL", + "mN", + "mPa", + "mS", + "mSv", + "mT", + "mV", + "mW", + "mWb", + "mb", + "mbar", + "mcd", + "meV", + "mg", + "mkat", + "mlm", + "mlx", + "mm", + "mmol", + "mrad", + "ms", + "msr", + "mt", + "mu", + "mÅ", + "mΩ", + "nA", + "nBq", + "nC", + "nDa", + "nF", + "nGy", + "nH", + "nHz", + "nJ", + "nK", + "nL", + "nN", + "nPa", + "nS", + "nSv", + "nT", + "nV", + "nW", + "nWb", + "nb", + "nbar", + "ncd", + "neV", + "ng", + "nkat", + "nlm", + "nlx", + "nm", + "nmol", + "nrad", + "ns", + "nsr", + "nt", + "nu", + "nÅ", + "nΩ", + "pA", + "pBq", + "pC", + "pDa", + "pF", + "pGy", + "pH", + "pHz", + "pJ", + "pK", + "pL", + "pN", + "pPa", + "pS", + "pSv", + "pT", + "pV", + "pW", + "pWb", + "pb", + "pbar", + "pcd", + "peV", + "pg", + "pkat", + "plm", + "plx", + "pm", + "pmol", + "prad", + "ps", + "psr", + "pt", + "pu", + "pÅ", + "pΩ", + "qA", + "qBq", + "qC", + "qDa", + "qF", + "qGy", + "qH", + "qHz", + "qJ", + "qK", + "qL", + "qN", + "qPa", + "qS", + "qSv", + "qT", + "qV", + "qW", + "qWb", + "qb", + "qbar", + "qcd", + "qeV", + "qg", + "qkat", + "qlm", + "qlx", + "qm", + "qmol", + "qrad", + "qs", + "qsr", + "qt", + "qu", + "qÅ", + "qΩ", + "rA", + "rBq", + "rC", + "rDa", + "rF", + "rGy", + "rH", + "rHz", + "rJ", + "rK", + "rL", + "rN", + "rPa", + "rS", + "rSv", + "rT", + "rV", + "rW", + "rWb", + "rb", + "rbar", + "rcd", + "reV", + "rg", + "rkat", + "rlm", + "rlx", + "rm", + "rmol", + "rrad", + "rs", + "rsr", + "rt", + "ru", + "rÅ", + "rΩ", + "yA", + "yBq", + "yC", + "yDa", + "yF", + "yGy", + "yH", + "yHz", + "yJ", + "yK", + "yL", + "yN", + "yPa", + "yS", + "ySv", + "yT", + "yV", + "yW", + "yWb", + "yb", + "ybar", + "ycd", + "yeV", + "yg", + "ykat", + "ylm", + "ylx", + "ym", + "ymol", + "yrad", + "ys", + "ysr", + "yt", + "yu", + "yÅ", + "yΩ", + "zA", + "zBq", + "zC", + "zDa", + "zF", + "zGy", + "zH", + "zHz", + "zJ", + "zK", + "zL", + "zN", + "zPa", + "zS", + "zSv", + "zT", + "zV", + "zW", + "zWb", + "zb", + "zbar", + "zcd", + "zeV", + "zg", + "zkat", + "zlm", + "zlx", + "zm", + "zmol", + "zrad", + "zs", + "zsr", + "zt", + "zu", + "zÅ", + "zΩ", + "µA", + "µBq", + "µC", + "µDa", + "µF", + "µGy", + "µH", + "µHz", + "µJ", + "µK", + "µL", + "µN", + "µPa", + "µS", + "µSv", + "µT", + "µV", + "µW", + "µWb", + "µb", + "µbar", + "µcd", + "µeV", + "µg", + "µkat", + "µlm", + "µlx", + "µm", + "µmol", + "µrad", + "µs", + "µsr", + "µt", + "µu", + "µÅ", + "µΩ", + } +) diff --git a/tests/capabilities/si_unit/test_data.py b/tests/capabilities/si_unit/test_data.py index b9eb300e..51e6873b 100644 --- a/tests/capabilities/si_unit/test_data.py +++ b/tests/capabilities/si_unit/test_data.py @@ -1,7 +1,17 @@ -"""Tests for the SI Unit maintained authority tables.""" +"""Tests for the SI Unit maintained authority tables and generated data modules.""" + +import subprocess +import sys +from pathlib import Path import pytest +from paxman.capabilities.SIUnit.grammar.data.unit_name_tokens import NAME_TOKENS +from paxman.capabilities.SIUnit.grammar.data.unit_symbol_tokens import SYMBOL_TOKENS +from paxman.capabilities.SIUnit.rules.data.prefixed_unit_names import ( + PREFIXED_NAME_TO_SYMBOL, +) +from paxman.capabilities.SIUnit.rules.data.prefixed_units import PREFIXED_UNIT_SYMBOLS from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import ( @@ -14,6 +24,9 @@ ) from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL +TOOLS_DIR = Path(__file__).resolve().parents[3] / "tools" +GENERATOR = TOOLS_DIR / "regenerate_si_prefix_data.py" + @pytest.mark.capability @pytest.mark.si_unit @@ -133,3 +146,54 @@ def test_name_to_symbol_locked_rows(self) -> None: assert NAME_TO_SYMBOL["metre"] == "m" assert NAME_TO_SYMBOL["hertz"] == "Hz" assert NAME_TO_SYMBOL["gram"] == "g" + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestGeneratedData: + """Invariants and locked rows for the generated tables.""" + + def test_prefixed_units_disjoint_from_official(self) -> None: + official = BASE_UNIT_SYMBOLS | DERIVED_UNIT_SYMBOLS | NONSI_UNIT_SYMBOLS + assert PREFIXED_UNIT_SYMBOLS.isdisjoint(official) + + def test_locked_prefixed_rows(self) -> None: + for token in ("km", "MHz", "µg", "mg", "cm", "hPa", "keV", "kDa", "dam"): + assert token in PREFIXED_UNIT_SYMBOLS + assert "kg" not in PREFIXED_UNIT_SYMBOLS # official base symbol wins + assert "cd" not in PREFIXED_UNIT_SYMBOLS # candela wins over centi-day + + def test_prefixed_name_to_symbol_locked_rows(self) -> None: + assert PREFIXED_NAME_TO_SYMBOL["megahertz"] == "MHz" + assert PREFIXED_NAME_TO_SYMBOL["kilometre"] == "km" + assert PREFIXED_NAME_TO_SYMBOL["microgram"] == "µg" + assert "kilogram" not in PREFIXED_NAME_TO_SYMBOL # official name wins + assert "kg" not in PREFIXED_NAME_TO_SYMBOL + + def test_no_kg_prefix_stacking(self) -> None: + # BIPM §3.2 (D9): prefixes attach to the gram, so "kg" is never a + # prefixable unit — no "kilokilogram"/"megakilogram" junk. + assert not any(name.endswith("kilogram") for name in PREFIXED_NAME_TO_SYMBOL) + assert set(PREFIXED_NAME_TO_SYMBOL).isdisjoint(NAME_TO_SYMBOL) + + def test_symbol_tokens_cover_and_order(self) -> None: + official = BASE_UNIT_SYMBOLS | DERIVED_UNIT_SYMBOLS | NONSI_UNIT_SYMBOLS + assert set(SYMBOL_TOKENS) == official | PREFIX_SYMBOLS | PREFIXED_UNIT_SYMBOLS + lengths = [len(t) for t in SYMBOL_TOKENS] + assert lengths == sorted(lengths, reverse=True) + + def test_name_tokens_longest_first(self) -> None: + lengths = [len(t) for t in NAME_TOKENS] + assert lengths == sorted(lengths, reverse=True) + assert "degree celsius" in NAME_TOKENS + assert "kilometre" in NAME_TOKENS + assert "megahertz" in NAME_TOKENS + + def test_generator_is_idempotent(self) -> None: + result = subprocess.run( + [sys.executable, str(GENERATOR), "--check"], + capture_output=True, + text=True, + cwd=TOOLS_DIR.parent, + ) + assert result.returncode == 0, result.stderr diff --git a/tools/regenerate_si_prefix_data.py b/tools/regenerate_si_prefix_data.py new file mode 100644 index 00000000..912497e2 --- /dev/null +++ b/tools/regenerate_si_prefix_data.py @@ -0,0 +1,239 @@ +"""Regenerate SI Unit prefixed-unit data modules. + +Computes the prefixed-unit set and the grammar token tables from the +maintained authority tables in paxman/capabilities/SIUnit/rules/data/. +Deterministic: same tables -> byte-identical output. Run after editing +any maintained SIUnit data module: + + uv run python tools/regenerate_si_prefix_data.py + +--check regenerates in memory and exits non-zero if any generated file +differs (used by tests/capabilities/si_unit/test_data.py). Mirrors +tools/regenerate_isbn_range_data.py. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import NONSI_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_prefixes import ( + PREFIX_NAMES, + PREFIX_SYMBOLS, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL + +ROOT = Path(__file__).resolve().parents[1] +RULES_DATA = ROOT / "paxman" / "capabilities" / "SIUnit" / "rules" / "data" +GRAMMAR_DATA = ROOT / "paxman" / "capabilities" / "SIUnit" / "grammar" / "data" + +_GENERATED_HEADER = ( + "GENERATED by tools/regenerate_si_prefix_data.py — do not edit by hand." +) + +# BIPM SI Brochure §3.2 + Table 8 footnote: prefixes are NOT used with +# min/h/d/° ′ ″, °C, or ha; the gram ("g") is the prefix attachment point. +_PREFIXABLE_NONSI = frozenset({"L", "t", "Da", "eV", "u", "Å", "b", "bar"}) +_NO_PREFIX = frozenset({"min", "h", "d", "°", "′", "″", "°C", "ha"}) + + +def _prefixable_units() -> frozenset[str]: + # BIPM §3.2 (D9): prefixes attach to the gram ("g"), not to the + # kilogram — exclude "kg" so "kkg"/"Mkg"/"µkg" are never generated. + return ( + (BASE_UNIT_SYMBOLS - {"kg"}) | DERIVED_UNIT_SYMBOLS | _PREFIXABLE_NONSI + ) - _NO_PREFIX + + +def _official_symbols() -> frozenset[str]: + return BASE_UNIT_SYMBOLS | DERIVED_UNIT_SYMBOLS | NONSI_UNIT_SYMBOLS + + +def _prefixed_symbols() -> frozenset[str]: + """Prefix + prefixable unit, excluding official-symbol collisions.""" + official = _official_symbols() + return frozenset( + prefix + unit + for prefix in PREFIX_SYMBOLS + for unit in _prefixable_units() + if prefix + unit not in official + ) + + +def _longest_first(tokens: set[str] | frozenset[str]) -> tuple[str, ...]: + return tuple(sorted(tokens, key=lambda t: (-len(t), t))) + + +def _symbol_tokens() -> tuple[str, ...]: + return _longest_first(_official_symbols() | PREFIX_SYMBOLS | _prefixed_symbols()) + + +def _prefixed_name_to_symbol() -> dict[str, str]: + """Prefixed name -> prefixed symbol, excluding official-name collisions. + + BIPM §3.2 (D9): prefix name + unit name ("mega" + "hertz") maps to the + prefixed symbol ("MHz"). "kilogram" is never regenerated as kilo+gram — + official names win (D8). + """ + official = frozenset(NAME_TO_SYMBOL) + # Last-write-wins by dict order: if two names share a symbol (e.g. + # "litre"/"liter" -> "L"), the maintained table's later entry wins. + # Both spellings resolve to the same symbol, so the collision is + # harmless for symbol generation — keep the maintained order stable. + symbol_to_name = {v: k for k, v in NAME_TO_SYMBOL.items()} + result: dict[str, str] = {} + for prefix_symbol, prefix_name in PREFIX_NAMES.items(): + for unit_symbol in _prefixable_units(): + unit_name = symbol_to_name.get(unit_symbol) + if unit_name is None: + continue + name = prefix_name + unit_name + symbol = prefix_symbol + unit_symbol + if name not in official and symbol in _prefixed_symbols(): + result[name] = symbol + return result + + +def _name_tokens() -> tuple[str, ...]: + return _longest_first( + frozenset(NAME_TO_SYMBOL) | frozenset(_prefixed_name_to_symbol()) + ) + + +def _emit_module(docstring: str, assignment: str) -> str: + """Assemble a generated module: header docstring, future import, assignment.""" + return ( + '"""' + _GENERATED_HEADER + "\n\n" + docstring + '"""\n\n' + "from __future__ import annotations\n\n" + assignment + "\n" + ) + + +def _emit_prefixed_units() -> str: + symbols = sorted(_prefixed_symbols()) + items = "".join(f' "{symbol}",\n' for symbol in symbols) + assignment = ( + "PREFIXED_UNIT_SYMBOLS: frozenset[str] = frozenset(\n" + " {\n" + items + " }\n" + ")" + ) + docstring = ( + "Prefixed SI unit symbols: every BIPM SI Brochure (9th ed., 2019)\n" + "Table 5 prefix applied to every prefixable unit, excluding\n" + 'official-symbol collisions (e.g. "kg"; "cd" never arises because\n' + 'the day "d" is not prefixable). Derived from the maintained\n' + "authority tables — regenerate via the tool after edits.\n" + ) + return _emit_module(docstring, assignment) + + +def _emit_prefixed_unit_names() -> str: + entries = "".join( + f' "{name}": "{symbol}",\n' + for name, symbol in sorted(_prefixed_name_to_symbol().items()) + ) + assignment = "PREFIXED_NAME_TO_SYMBOL: dict[str, str] = {\n" + entries + "}" + docstring = ( + "Prefixed unit names -> prefixed symbols (BIPM SI Brochure, 9th ed.,\n" + '2019): "mega" + "hertz" -> "MHz". Official names win over\n' + 'regenerated collisions ("kilogram" is never kilo+gram). Sorted by\n' + "name for deterministic output. Derived from the maintained authority\n" + "tables — regenerate via the tool after edits.\n" + ) + return _emit_module(docstring, assignment) + + +def _emit_symbol_tokens() -> str: + tokens = _symbol_tokens() + items = "".join(f' "{token}",\n' for token in tokens) + assignment = "SYMBOL_TOKENS: tuple[str, ...] = (\n" + items + ")" + docstring = ( + "Every recognized unit-symbol token, longest-first: the official\n" + "symbols, the SI prefixes, and the prefixed symbols. Longest-first\n" + "ordering lets a scanner prefer the longest match. Derived from the\n" + "maintained authority tables — regenerate via the tool after edits.\n" + ) + return _emit_module(docstring, assignment) + + +def _emit_name_tokens() -> str: + tokens = _name_tokens() + items = "".join(f' "{token}",\n' for token in tokens) + assignment = "NAME_TOKENS: tuple[str, ...] = (\n" + items + ")" + docstring = ( + "Every recognized unit-name token, longest-first: the maintained\n" + 'names plus the prefixed names ("mega" + "hertz" -> "megahertz").\n' + "Longest-first ordering lets a scanner prefer the longest match.\n" + "Derived from the maintained authority tables — regenerate via the\n" + "tool after edits.\n" + ) + return _emit_module(docstring, assignment) + + +def _emit_compound_tokens() -> str: + assignment = ( + 'COMPOUND_SEPARATORS: str = "/·⋅"\n' + 'EXPONENT_CHARACTERS: str = r"0-9⁻⁰¹²³⁴⁵⁶⁷⁸⁹\\-"' + ) + docstring = ( + "Compound-unit token characters (BIPM SI Brochure, 9th ed., §5.4\n" + "and §5.3.4): COMPOUND_SEPARATORS are the multiplication signs\n" + "accepted between unit factors (slash, middle dot U+00B7, dot\n" + "operator U+22C5); EXPONENT_CHARACTERS is a regex character-class\n" + "fragment for integer powers — ASCII digits, superscript digits\n" + "(U+2070, U+00B9-U+00B3, U+2074-U+2079), superscript minus (U+207B),\n" + "and the hyphen-minus. The Task 6 compound rule keeps its own split\n" + "patterns locally — it never imports from grammar/.\n" + ) + return _emit_module(docstring, assignment) + + +def _modules() -> list[tuple[Path, str]]: + """The five generated modules, in deterministic write order.""" + return [ + (RULES_DATA / "prefixed_units.py", _emit_prefixed_units()), + (RULES_DATA / "prefixed_unit_names.py", _emit_prefixed_unit_names()), + (GRAMMAR_DATA / "unit_symbol_tokens.py", _emit_symbol_tokens()), + (GRAMMAR_DATA / "unit_name_tokens.py", _emit_name_tokens()), + (GRAMMAR_DATA / "compound_tokens.py", _emit_compound_tokens()), + ] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Regenerate the SI Unit prefixed-unit data modules." + ) + parser.add_argument( + "--check", + action="store_true", + help="regenerate in memory and exit non-zero if any module drifts", + ) + args = parser.parse_args() + + modules = _modules() + if args.check: + drifted = [ + path + for path, text in modules + if not path.exists() or path.read_text(encoding="utf-8") != text + ] + if drifted: + for path in drifted: + print( + f"DRIFT: {path.relative_to(ROOT)} differs from generated output", + file=sys.stderr, + ) + raise SystemExit(1) + print("all generated SIUnit data modules are up to date") + return + + for path, text in modules: + path.write_text(text, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() From 0fefe49caa107a7b1f9eb9c09b0b4e8c41b7209e Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:48:35 +0800 Subject: [PATCH 06/23] test(si_unit): guard generator constants and prefixed-name uniqueness --- tools/regenerate_si_prefix_data.py | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/regenerate_si_prefix_data.py b/tools/regenerate_si_prefix_data.py index 912497e2..048d6ef6 100644 --- a/tools/regenerate_si_prefix_data.py +++ b/tools/regenerate_si_prefix_data.py @@ -53,6 +53,31 @@ def _official_symbols() -> frozenset[str]: return BASE_UNIT_SYMBOLS | DERIVED_UNIT_SYMBOLS | NONSI_UNIT_SYMBOLS +def _validate_authority_constants() -> None: + """Fail loudly if the hand-maintained BIPM sets drift from the tables. + + Runs on both the write and --check paths (called from _modules()). + Raises SystemExit, not assert: this is a data-integrity guard and must + survive python -O. + """ + official = _official_symbols() + if not official >= _PREFIXABLE_NONSI: + raise SystemExit( + "_PREFIXABLE_NONSI includes symbols absent from the maintained" + f" authority tables: {sorted(_PREFIXABLE_NONSI - official)}" + ) + if not official >= _NO_PREFIX: + raise SystemExit( + "_NO_PREFIX includes symbols absent from the maintained" + f" authority tables: {sorted(_NO_PREFIX - official)}" + ) + if not _PREFIXABLE_NONSI.isdisjoint(_NO_PREFIX): + raise SystemExit( + "_PREFIXABLE_NONSI and _NO_PREFIX overlap:" + f" {sorted(_PREFIXABLE_NONSI & _NO_PREFIX)}" + ) + + def _prefixed_symbols() -> frozenset[str]: """Prefix + prefixable unit, excluding official-symbol collisions.""" official = _official_symbols() @@ -94,6 +119,11 @@ def _prefixed_name_to_symbol() -> dict[str, str]: name = prefix_name + unit_name symbol = prefix_symbol + unit_symbol if name not in official and symbol in _prefixed_symbols(): + if name in result: + raise SystemExit( + "generated prefixed name collision:" + f" {name!r} from {prefix_name!r}+{unit_name!r}" + ) result[name] = symbol return result @@ -193,6 +223,7 @@ def _emit_compound_tokens() -> str: def _modules() -> list[tuple[Path, str]]: """The five generated modules, in deterministic write order.""" + _validate_authority_constants() return [ (RULES_DATA / "prefixed_units.py", _emit_prefixed_units()), (RULES_DATA / "prefixed_unit_names.py", _emit_prefixed_unit_names()), From e5a9129eece5b0dde660c812df2e95ac78588c94 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:52:39 +0800 Subject: [PATCH 07/23] feat(si_unit): add BIPM rule sections --- .../SIUnit/rules/bipm_si_brochure_ed2019.py | 187 +++++++++++++++++ tests/capabilities/si_unit/test_rules.py | 189 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py create mode 100644 tests/capabilities/si_unit/test_rules.py diff --git a/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py b/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py new file mode 100644 index 00000000..07f498a3 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py @@ -0,0 +1,187 @@ +"""BIPM SI Brochure rules (9th edition, 2019). + +The SI Brochure defines the base units (Table 1), derived units with +special names (Tables 3–4), non-SI units accepted for use with the SI +(Tables 8–9), and the prefix symbols (Table 5). Each section validates +the corresponding symbol shape against its authority table. Syntax-only +recognition is the grammars' job; the rules resolve the canonical +symbol and provide provenance. +""" + +from __future__ import annotations + +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.capabilities.SIUnit.rules.data.prefixed_unit_names import ( + PREFIXED_NAME_TO_SYMBOL, +) +from paxman.capabilities.SIUnit.rules.data.prefixed_units import PREFIXED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import ( + LITRE_WRITTEN_FORMS, + NONSI_UNIT_SYMBOLS, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +# Full name→symbol resolution: maintained official names + generated +# prefixed names (D8). No key overlap by construction (Task 4). +FULL_NAME_TO_SYMBOL = NAME_TO_SYMBOL | PREFIXED_NAME_TO_SYMBOL + +PUBLICATION = Provenance( + authority="BIPM", + specification_name="SI Brochure: The International System of Units (SI)", + kind="specification", + reference_url="https://www.bipm.org/en/publications/si-brochure", + version="9th edition", + lifecycle="active", + publication_year=2019, +) + + +class SectionBaseUnits(Rule[SIUnitNotation]): + """SI Brochure Table 1 — base unit symbols.""" + + name = "Section 2.3.1-base-units" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = "BIPM SI Brochure (9th ed., 2019), Table 1" + target_semantics = frozenset({"symbol_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a base-unit symbol.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "symbol": + return False + return notation.text in BASE_UNIT_SYMBOLS + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical base-unit symbol.""" + return notation.text + + +class SectionDerivedUnits(Rule[SIUnitNotation]): + """SI Brochure Tables 3–4 — derived units with special names.""" + + name = "Section 2.3.2-derived-units" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = "BIPM SI Brochure (9th ed., 2019), Tables 3–4" + target_semantics = frozenset({"symbol_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a derived-unit symbol.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "symbol": + return False + return notation.text in DERIVED_UNIT_SYMBOLS + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical derived-unit symbol.""" + return notation.text + + +class SectionNonSiUnits(Rule[SIUnitNotation]): + """SI Brochure Tables 8–9 — non-SI units accepted for use with the SI. + + "l" (litre) canonicalizes to "L" (D3). "′" and "″" are recognized + but normalize to themselves. + """ + + name = "Section 4.1-non-si-units" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = "BIPM SI Brochure (9th ed., 2019), Tables 8–9" + target_semantics = frozenset({"symbol_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a non-SI unit symbol.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "symbol": + return False + return notation.text in NONSI_UNIT_SYMBOLS + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical symbol ("l" -> "L").""" + if notation.text in LITRE_WRITTEN_FORMS: + return "L" + return notation.text + + +class SectionPrefixes(Rule[SIUnitNotation]): + """SI Brochure Table 5 + §3.2 — prefixed unit symbols. + + A prefixed symbol ("km", "MHz", "µg") is a valid unit: prefix symbol + concatenated with a prefixable unit symbol, generated from the + maintained tables (Task 4). A bare prefix symbol ("k", "da") is not + a unit and never matches — it stays INVALID (recognized, unresolved). + """ + + name = "Section 3.2-prefixes" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = "BIPM SI Brochure (9th ed., 2019), Table 5 and §3.2" + target_semantics = frozenset({"symbol_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a prefixed unit symbol.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "symbol": + return False + return notation.text in PREFIXED_UNIT_SYMBOLS + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical prefixed symbol.""" + return notation.text + + +class SectionNames(Rule[SIUnitNotation]): + """SI Brochure Tables 1, 3–4, 8–9 — unit names resolve to symbols. + + The name grammar case-folds (D4), so this lookup is exact. The table is + FULL_NAME_TO_SYMBOL (maintained official names + generated prefixed + names, D8): "megahertz" -> "MHz", "kilometre" -> "km", "microgram" -> "µg". + """ + + name = "Section-names" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = "BIPM SI Brochure (9th ed., 2019), Tables 1, 3–4, 8–9 (unit names)" + target_semantics = frozenset({"name_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a known unit name.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "name": + return False + return notation.text in FULL_NAME_TO_SYMBOL + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical unit symbol.""" + return FULL_NAME_TO_SYMBOL[notation.text] diff --git a/tests/capabilities/si_unit/test_rules.py b/tests/capabilities/si_unit/test_rules.py new file mode 100644 index 00000000..d9115dde --- /dev/null +++ b/tests/capabilities/si_unit/test_rules.py @@ -0,0 +1,189 @@ +"""Tests for the BIPM SI Brochure rule sections.""" + +import pytest + +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.capabilities.SIUnit.rules.bipm_si_brochure_ed2019 import ( + SectionBaseUnits, + SectionDerivedUnits, + SectionNames, + SectionNonSiUnits, + SectionPrefixes, +) +from paxman.core.domain import RuleStrategy + +CONTRACT = SIUnitContract() + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionBaseUnits: + """BIPM Table 1 — base unit symbols.""" + + def setup_method(self) -> None: + self.rule = SectionBaseUnits() + + def test_rule_metadata(self) -> None: + assert self.rule.name == "Section 2.3.1-base-units" + assert self.rule.strategy is RuleStrategy.LOOKUP_TABLE + assert frozenset({"symbol_recognition"}) == self.rule.target_semantics + assert frozenset() == self.rule.requires_features + assert self.rule.provenance.publication_year == 2019 + + @pytest.mark.parametrize("symbol", ["m", "kg", "s", "A", "K", "mol", "cd"]) + def test_matches(self, symbol: str) -> None: + assert self.rule.matches(SIUnitNotation(text=symbol, shape="symbol"), CONTRACT) + + @pytest.mark.parametrize("text", ["Pa", "km", "pa", "m/s", "da"]) + def test_rejects(self, text: str) -> None: + assert not self.rule.matches( + SIUnitNotation(text=text, shape="symbol"), CONTRACT + ) + + def test_rejects_non_symbol_shape(self) -> None: + assert not self.rule.matches(SIUnitNotation(text="kg", shape="name"), CONTRACT) + + def test_normalize_is_identity(self) -> None: + assert ( + self.rule.normalize(SIUnitNotation(text="kg", shape="symbol"), CONTRACT) + == "kg" + ) + + def test_temporal_gate(self) -> None: + old = SIUnitContract(year=2018) + assert not self.rule.matches(SIUnitNotation(text="kg", shape="symbol"), old) + assert self.rule.matches(SIUnitNotation(text="kg", shape="symbol"), CONTRACT) + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionDerivedUnits: + """BIPM Tables 3–4 — derived units with special names.""" + + def setup_method(self) -> None: + self.rule = SectionDerivedUnits() + + @pytest.mark.parametrize("text", ["rad", "Hz", "Pa", "Ω", "°C", "kat"]) + def test_matches(self, text: str) -> None: + assert self.rule.matches(SIUnitNotation(text=text, shape="symbol"), CONTRACT) + + @pytest.mark.parametrize("text", ["m", "km", "pa", "Hzs"]) + def test_rejects(self, text: str) -> None: + assert not self.rule.matches( + SIUnitNotation(text=text, shape="symbol"), CONTRACT + ) + + def test_normalize_is_identity(self) -> None: + assert ( + self.rule.normalize(SIUnitNotation(text="Pa", shape="symbol"), CONTRACT) + == "Pa" + ) + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionNonSiUnits: + """BIPM Tables 8–9 — non-SI units accepted for use with the SI.""" + + def setup_method(self) -> None: + self.rule = SectionNonSiUnits() + + @pytest.mark.parametrize( + "text", ["min", "h", "d", "°", "ha", "L", "l", "t", "Da", "eV"] + ) + def test_matches(self, text: str) -> None: + assert self.rule.matches(SIUnitNotation(text=text, shape="symbol"), CONTRACT) + + def test_litre_canonicalization(self) -> None: + assert ( + self.rule.normalize(SIUnitNotation(text="l", shape="symbol"), CONTRACT) + == "L" + ) + assert ( + self.rule.normalize(SIUnitNotation(text="L", shape="symbol"), CONTRACT) + == "L" + ) + + def test_rejects(self) -> None: + assert not self.rule.matches(SIUnitNotation(text="m", shape="symbol"), CONTRACT) + assert not self.rule.matches( + SIUnitNotation(text="kelvin", shape="name"), CONTRACT + ) + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionPrefixes: + """BIPM Table 5 + §3.2 — prefixed unit symbols.""" + + def setup_method(self) -> None: + self.rule = SectionPrefixes() + + @pytest.mark.parametrize( + "text", ["km", "MHz", "µg", "mg", "hPa", "keV", "kDa", "dam"] + ) + def test_matches(self, text: str) -> None: + assert self.rule.matches(SIUnitNotation(text=text, shape="symbol"), CONTRACT) + + @pytest.mark.parametrize("text", ["k", "da", "M", "µ", "m", "cd", "kg"]) + def test_bare_prefixes_and_official_do_not_match(self, text: str) -> None: + assert not self.rule.matches( + SIUnitNotation(text=text, shape="symbol"), CONTRACT + ) + + def test_normalize_is_identity(self) -> None: + assert ( + self.rule.normalize(SIUnitNotation(text="MHz", shape="symbol"), CONTRACT) + == "MHz" + ) + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionNames: + """BIPM Tables 1, 3–4, 8–9 — unit names resolve to canonical symbols.""" + + def setup_method(self) -> None: + self.rule = SectionNames() + + def test_rule_metadata(self) -> None: + assert self.rule.name == "Section-names" + assert self.rule.strategy is RuleStrategy.LOOKUP_TABLE + assert frozenset({"name_recognition"}) == self.rule.target_semantics + assert frozenset() == self.rule.requires_features + assert self.rule.provenance.publication_year == 2019 + + @pytest.mark.parametrize( + ("name", "symbol"), + [ + ("kilogram", "kg"), + ("kelvin", "K"), + ("megahertz", "MHz"), # generated prefixed name + ("kilometre", "km"), # generated prefixed name + ("microgram", "µg"), # generated prefixed name + ("degree celsius", "°C"), + ("litre", "L"), + ], + ) + def test_matches_and_normalize(self, name: str, symbol: str) -> None: + notation = SIUnitNotation(text=name, shape="name") + assert self.rule.matches(notation, CONTRACT) + assert self.rule.normalize(notation, CONTRACT) == symbol + + @pytest.mark.parametrize("text", ["quark", "kg", "megahert", "meter"]) + def test_rejects_unknown_names(self, text: str) -> None: + assert not self.rule.matches(SIUnitNotation(text=text, shape="name"), CONTRACT) + + def test_rejects_non_name_shape(self) -> None: + assert not self.rule.matches( + SIUnitNotation(text="kg", shape="symbol"), CONTRACT + ) + assert not self.rule.matches( + SIUnitNotation(text="m/s", shape="compound"), CONTRACT + ) + + def test_temporal_gate(self) -> None: + old = SIUnitContract(year=2018) + assert not self.rule.matches(SIUnitNotation(text="kelvin", shape="name"), old) + assert self.rule.matches(SIUnitNotation(text="kelvin", shape="name"), CONTRACT) From 5f6ac0d8d8ae1afddb555c534d8123899afc0e75 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 12:59:48 +0800 Subject: [PATCH 08/23] feat(si_unit): add ISO 80000-1 compound rule --- .../SIUnit/rules/iso_80000_ed2022.py | 109 ++++++++++++++++++ tests/capabilities/si_unit/test_rules.py | 73 +++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py diff --git a/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py b/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py new file mode 100644 index 00000000..8d115ba4 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py @@ -0,0 +1,109 @@ +"""ISO 80000-1 compound unit rule (edition 2022). + +ISO 80000-1 §6.5 defines how unit symbols combine into product and +quotient compounds: "N·m", "m/s", "kg·m/s²". This rule validates the +compound shape against the full SI symbol lexicon (official + prefixed) +and renders the canonical ASCII-exponent form: superscripts translate +to ASCII digits, "⋅"/"·" normalize to "·", "/" stays "/", "l" -> "L". +The split patterns are kept local (not imported from grammar/data) so +rules never import from the grammar tree (grammar↔rules purity scan). +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.capabilities.SIUnit.rules.data.prefixed_units import PREFIXED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import NONSI_UNIT_SYMBOLS +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +PUBLICATION = Provenance( + authority="ISO", + specification_name="ISO 80000-1:2022 Quantities and units — Part 1: General", + kind="specification", + reference_url="https://www.iso.org/standard/76921.html", + version="2022", + lifecycle="active", + publication_year=2022, +) + +_FULL_SYMBOL_LEXICON = ( + BASE_UNIT_SYMBOLS + | DERIVED_UNIT_SYMBOLS + | NONSI_UNIT_SYMBOLS + | PREFIXED_UNIT_SYMBOLS +) +_EXPONENT_SUFFIX = re.compile(r"[0-9⁻⁰¹²³⁴⁵⁶⁷⁸⁹\-]*$") +_SUPERSCRIPT_TRANSLATE = str.maketrans( + { + "⁰": "0", + "¹": "1", + "²": "2", + "³": "3", + "⁴": "4", + "⁵": "5", + "⁶": "6", + "⁷": "7", + "⁸": "8", + "⁹": "9", + "⁻": "-", + } +) +_SEPARATOR_TRANSLATE = str.maketrans({"⋅": "·"}) # D3: U+22C5 dot → U+00B7 + + +class SectionCompounds(Rule[SIUnitNotation]): + """ISO 80000-1 §6.5 — product and quotient unit compounds. + + Accepts compounds of the shape UNIT (separator UNIT){1,3} where each + UNIT is a known symbol plus an optional exponent ("m/s²", "N·m", + "kg·m/s²", "g/cm³"). "l" canonicalizes to "L" inside compounds. + """ + + name = "Section 6.5-compounds" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "ISO 80000-1:2022, §6.5 (unit symbols in products and quotients)" + target_semantics = frozenset({"compound_recognition"}) + requires_features = frozenset() + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Check if the notation is a valid SI compound.""" + if ( + contract.year is not None + and contract.year < self.provenance.publication_year + ): + return False + if notation.shape != "compound": + return False + return all( + self._symbol_part(group) in _FULL_SYMBOL_LEXICON + for group in re.split(r"[/·⋅]", notation.text) + ) + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Normalize to the canonical compound: ASCII exponents, "·" separators.""" + return "".join( + part if part in ("/", "·", "⋅") else self._canonical_group(part) + for part in re.split(r"([/·⋅])", notation.text) + ).translate(_SEPARATOR_TRANSLATE) + + @staticmethod + def _symbol_part(group: str) -> str: + """The symbol without its trailing exponent ("m/s2" -> "m/s2" group "m").""" + return _EXPONENT_SUFFIX.sub("", group) + + @classmethod + def _canonical_group(cls, group: str) -> str: + """Canonical group: ASCII exponent, "l" -> "L", symbol unchanged.""" + match = _EXPONENT_SUFFIX.search(group) + if match is None: + return group + symbol = group[: match.start()] + exponent = group[match.start() :].translate(_SUPERSCRIPT_TRANSLATE) + canonical_symbol = "L" if symbol == "l" else symbol + return canonical_symbol + exponent diff --git a/tests/capabilities/si_unit/test_rules.py b/tests/capabilities/si_unit/test_rules.py index d9115dde..dffbcfba 100644 --- a/tests/capabilities/si_unit/test_rules.py +++ b/tests/capabilities/si_unit/test_rules.py @@ -1,4 +1,4 @@ -"""Tests for the BIPM SI Brochure rule sections.""" +"""Tests for the SI Unit rule sections (BIPM SI Brochure + ISO 80000-1).""" import pytest @@ -11,6 +11,7 @@ SectionNonSiUnits, SectionPrefixes, ) +from paxman.capabilities.SIUnit.rules.iso_80000_ed2022 import SectionCompounds from paxman.core.domain import RuleStrategy CONTRACT = SIUnitContract() @@ -187,3 +188,73 @@ def test_temporal_gate(self) -> None: old = SIUnitContract(year=2018) assert not self.rule.matches(SIUnitNotation(text="kelvin", shape="name"), old) assert self.rule.matches(SIUnitNotation(text="kelvin", shape="name"), CONTRACT) + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSectionCompounds: + """ISO 80000-1:2022 §6.5 — product and quotient unit compounds.""" + + def setup_method(self) -> None: + self.rule = SectionCompounds() + + def test_rule_metadata(self) -> None: + assert self.rule.name == "Section 6.5-compounds" + assert self.rule.strategy is RuleStrategy.PARSER + assert frozenset({"compound_recognition"}) == self.rule.target_semantics + assert frozenset() == self.rule.requires_features + assert self.rule.provenance.publication_year == 2022 + + @pytest.mark.parametrize( + "text", + [ + "m/s²", + "m/s2", + "km/h", + "N·m", + "N⋅m", + "kg·m/s²", + "g/cm³", + "m·s⁻²", + "µg/mL", + "m/°C", + ], + ) + def test_matches(self, text: str) -> None: + assert self.rule.matches(SIUnitNotation(text=text, shape="compound"), CONTRACT) + + @pytest.mark.parametrize("text", ["QQQ/zzz", "m/", "/s", "m s", "m/2", "/"]) + def test_rejects(self, text: str) -> None: + assert not self.rule.matches( + SIUnitNotation(text=text, shape="compound"), CONTRACT + ) + + def test_rejects_non_compound_shape(self) -> None: + assert not self.rule.matches( + SIUnitNotation(text="m/s", shape="symbol"), CONTRACT + ) + + @pytest.mark.parametrize( + ("text", "canonical"), + [ + ("m/s²", "m/s2"), + ("m/s2", "m/s2"), + ("N·m", "N·m"), + ("N⋅m", "N·m"), + ("kg·m/s²", "kg·m/s2"), + ("g/cm³", "g/cm3"), + ("m·s⁻²", "m·s-2"), + ("l/s", "L/s"), + ("µm/s", "µm/s"), + ], + ) + def test_normalize(self, text: str, canonical: str) -> None: + result = self.rule.normalize( + SIUnitNotation(text=text, shape="compound"), CONTRACT + ) + assert result == canonical + + def test_temporal_gate(self) -> None: + old = SIUnitContract(year=2021) + assert not self.rule.matches(SIUnitNotation(text="m/s", shape="compound"), old) + assert self.rule.matches(SIUnitNotation(text="m/s", shape="compound"), CONTRACT) From 2d03c3b1fde25b39288f9f704b4a0790c03b0242 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 13:12:52 +0800 Subject: [PATCH 09/23] feat(si_unit): add recognition grammars --- .../SIUnit/grammar/compound_recognition.py | 49 +++++ .../SIUnit/grammar/name_recognition.py | 44 ++++ .../SIUnit/grammar/symbol_recognition.py | 47 +++++ tests/capabilities/si_unit/test_grammar.py | 190 ++++++++++++++++++ 4 files changed, 330 insertions(+) create mode 100644 paxman/capabilities/SIUnit/grammar/compound_recognition.py create mode 100644 paxman/capabilities/SIUnit/grammar/name_recognition.py create mode 100644 paxman/capabilities/SIUnit/grammar/symbol_recognition.py create mode 100644 tests/capabilities/si_unit/test_grammar.py diff --git a/paxman/capabilities/SIUnit/grammar/compound_recognition.py b/paxman/capabilities/SIUnit/grammar/compound_recognition.py new file mode 100644 index 00000000..ed9af81d --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/compound_recognition.py @@ -0,0 +1,49 @@ +"""Compound recognition grammar for SI Unit. + +Recognizes product/quotient compound shapes over unit symbols: UNIT +(separator UNIT){1,3} where each UNIT is a symbol character run with an +optional exponent, and the separator is "/", "·" or "⋅" (D3). The +grammar is shape-only: it does not validate that the units are known +(the ISO 80000-1 rule does that). "m/s²", "N·m", "kg·m/s²", "g/cm³" +are recognized as single spans; "m s" (space) is not a compound. +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.SIUnit.grammar.data.compound_tokens import ( + COMPOUND_SEPARATORS, + EXPONENT_CHARACTERS, +) +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.core.domain import Grammar, RecognitionMatch + +# Shape constants come from the Task 4 generated module (grammars may import +# from grammar/data/ — only rules are barred by the grammar↔rules purity +# scan). Keeps the separator/exponent characters in one place. +_EXPONENT = rf"[{EXPONENT_CHARACTERS}]*" +_UNIT = rf"(?:°?[A-Za-zµΩÅ][A-Za-zµΩÅ0-9]*{_EXPONENT})" +_SEP = f"[{COMPOUND_SEPARATORS}]" +_COMPOUND_RE = re.compile( + rf"(?{_UNIT}(?:{_SEP}{_UNIT}){{1,3}})(?![\w\-+\u2212])" +) + + +class CompoundRecognition(Grammar[SIUnitNotation]): + """Grammar: compound_recognition — product/quotient unit shapes.""" + + name = "compound_recognition" + semantics = "compound_recognition" # SEAM (ADR-0003): identity id + + def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: + """Emit one RecognitionMatch per compound shape found in text.""" + return [ + RecognitionMatch( + raw_text=text[m.start() : m.end()], + start=m.start(), + end=m.end(), + notation=SIUnitNotation(text=m.group("body"), shape="compound"), + ) + for m in _COMPOUND_RE.finditer(text) + ] diff --git a/paxman/capabilities/SIUnit/grammar/name_recognition.py b/paxman/capabilities/SIUnit/grammar/name_recognition.py new file mode 100644 index 00000000..ed6f3f77 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/name_recognition.py @@ -0,0 +1,44 @@ +"""Name recognition grammar for SI Unit. + +Recognizes unit names case-insensitively: the grammar folds the input +to lowercase and matches against the longest-first name token table +(D4). "Kilogram", "KILOGRAM", "kilogram" all emit a RecognitionMatch +over the span of the name text. Recognition only: no validation. +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.SIUnit.grammar.data.unit_name_tokens import NAME_TOKENS +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.core.domain import Grammar, RecognitionMatch + +# Same split-boundary structure as symbol_recognition: a combined +# `(?" + _ALTERNATION + r")" + _LOOKAHEAD, re.IGNORECASE +) + + +class NameRecognition(Grammar[SIUnitNotation]): + """Grammar: name_recognition — case-folded unit names.""" + + name = "name_recognition" + semantics = "name_recognition" # SEAM (ADR-0003): identity id + + def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: + """Emit one RecognitionMatch per unit name found in text.""" + return [ + RecognitionMatch( + raw_text=text[m.start() : m.end()], + start=m.start(), + end=m.end(), + notation=SIUnitNotation(text=m.group("name").lower(), shape="name"), + ) + for m in _NAME_RE.finditer(text) + ] diff --git a/paxman/capabilities/SIUnit/grammar/symbol_recognition.py b/paxman/capabilities/SIUnit/grammar/symbol_recognition.py new file mode 100644 index 00000000..e71cca04 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/symbol_recognition.py @@ -0,0 +1,47 @@ +"""Symbol recognition grammar for SI Unit. + +Recognizes unit symbols exactly as written (case-exact): base symbols +("m", "kg"), derived special-name symbols ("Pa", "°C"), non-SI symbols +("min", "L"), prefix symbols ("k", "M") and prefixed units ("km", "MHz"). +Each recognition emits a span-bearing RecognitionMatch over the symbol +text. Recognition only: no validation, no canonicalization (D1/D2/D6). +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.SIUnit.grammar.data.unit_symbol_tokens import SYMBOL_TOKENS +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.core.domain import Grammar, RecognitionMatch + +# Word chars, signs, and the compound separators block a token: they +# never merge into a symbol and never split a compound (D2). The degree +# sign is in the block set too, so "25°C" cannot fall back to a bare "C" +# (coulomb) after the "°C" token is rejected. The left boundary is a +# lookbehind and the right a lookahead — a lookahead placed before the +# token would see the token's own first character (a word char) and +# reject the match before the token is even consumed. +_LOOKBEHIND = r"(?" + _ALTERNATION + r")" + _LOOKAHEAD) + + +class SymbolRecognition(Grammar[SIUnitNotation]): + """Grammar: symbol_recognition — case-exact unit symbol tokens.""" + + name = "symbol_recognition" + semantics = "symbol_recognition" # SEAM (ADR-0003): identity id + + def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: + """Emit one RecognitionMatch per symbol token found in text.""" + return [ + RecognitionMatch( + raw_text=text[m.start() : m.end()], + start=m.start(), + end=m.end(), + notation=SIUnitNotation(text=m.group("token"), shape="symbol"), + ) + for m in _TOKEN_RE.finditer(text) + ] diff --git a/tests/capabilities/si_unit/test_grammar.py b/tests/capabilities/si_unit/test_grammar.py new file mode 100644 index 00000000..32ee8275 --- /dev/null +++ b/tests/capabilities/si_unit/test_grammar.py @@ -0,0 +1,190 @@ +"""Tests for SIUnit recognition grammars. + +Grammars are exercised directly (no rules): each test drives +Grammar.recognize() against raw text and asserts the emitted spans — +half-open [start, end) offsets, raw_text, and the SIUnitNotation +text/shape — mirroring Currency's grammar test structure. +""" + +from __future__ import annotations + +import pytest + +from paxman.capabilities.SIUnit.grammar.compound_recognition import CompoundRecognition +from paxman.capabilities.SIUnit.grammar.name_recognition import NameRecognition +from paxman.capabilities.SIUnit.grammar.symbol_recognition import SymbolRecognition +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.core.domain import Grammar, RecognitionMatch + +pytestmark = [pytest.mark.capability, pytest.mark.si_unit] + +# Expected-span tuple: (raw_text, start, end, notation_text, shape). +Span = tuple[str, int, int, str, str] + + +def _assert_span_invariants(text: str, match: RecognitionMatch[SIUnitNotation]) -> None: + """Verify the RecognitionMatch span contract (half-open [start, end)).""" + assert 0 <= match.start <= match.end + assert len(match.raw_text) == match.end - match.start + assert match.raw_text == text[match.start : match.end] + + +def _assert_spans( + text: str, + expected: list[Span], + results: list[RecognitionMatch[SIUnitNotation]], +) -> None: + """Compare results against (raw_text, start, end, text, shape) tuples.""" + assert len(results) == len(expected) + for match, (raw_text, start, end, notation_text, shape) in zip( + results, expected, strict=True + ): + _assert_span_invariants(text, match) + assert match.start == start + assert match.end == end + assert match.raw_text == raw_text + assert match.notation.text == notation_text + assert match.notation.shape == shape + + +class TestSymbolRecognition: + """Grammar: symbol_recognition — case-exact unit symbol tokens.""" + + def setup_method(self) -> None: + self.grammar: Grammar[SIUnitNotation] = SymbolRecognition() + + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): every shipped grammar declares `semantics`; + # SIUnit grammars use identity ids (no coalesced groups). + assert self.grammar.semantics == "symbol_recognition" + + @pytest.mark.parametrize( + ("text", "token"), + [ + ("m", "m"), + ("kg", "kg"), + ("MHz", "MHz"), + ("Pa", "Pa"), + ("cd", "cd"), + ("°C", "°C"), + ("µg", "µg"), + ("min", "min"), + ("da", "da"), # bare prefix is recognized (the rule rejects -> INVALID) + ("k", "k"), + ], + ) + def test_recognizes(self, text: str, token: str) -> None: + results = self.grammar.recognize(text) + assert len(results) == 1 + match = results[0] + assert match.notation.text == token + assert match.notation.shape == "symbol" + assert match.start == 0 + assert match.end == len(text) + assert match.raw_text == text + + @pytest.mark.parametrize( + "text", + ["pa", "Kg", "KHz", "metre", "m/s²", "N·m", "xkg", "kg5", "2m", "25°C", "5kg"], + ) + def test_rejects(self, text: str) -> None: + assert self.grammar.recognize(text) == [] + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("m s", [("m", 0, 1, "m", "symbol"), ("s", 2, 3, "s", "symbol")]), + ("m; s", [("m", 0, 1, "m", "symbol"), ("s", 3, 4, "s", "symbol")]), + ], + ) + def test_multiple_spans(self, text: str, expected: list[Span]) -> None: + _assert_spans(text, expected, self.grammar.recognize(text)) + + +class TestNameRecognition: + """Grammar: name_recognition — case-folded unit names.""" + + def setup_method(self) -> None: + self.grammar: Grammar[SIUnitNotation] = NameRecognition() + + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): identity semantics id; no coalesced groups. + assert self.grammar.semantics == "name_recognition" + + @pytest.mark.parametrize( + ("text", "name"), + [ + ("kilogram", "kilogram"), + ("Kilogram", "kilogram"), + ("KILOGRAM", "kilogram"), + ("kelvin", "kelvin"), + ("degree celsius", "degree celsius"), + ("Degree Celsius", "degree celsius"), + ("megahertz", "megahertz"), + ("kilometre", "kilometre"), + ], + ) + def test_recognizes(self, text: str, name: str) -> None: + results = self.grammar.recognize(text) + assert len(results) == 1 + match = results[0] + assert match.notation.text == name + assert match.notation.shape == "name" + assert match.start == 0 + assert match.end == len(text) + + @pytest.mark.parametrize( + "text", + ["kilograms", "kilogran", "kg", "kelvins", "xkelvin", "kelvinx"], + ) + def test_rejects(self, text: str) -> None: + assert self.grammar.recognize(text) == [] + + def test_multiple_spans(self) -> None: + _assert_spans( + "kelvin pascal", + [("kelvin", 0, 6, "kelvin", "name"), ("pascal", 7, 13, "pascal", "name")], + self.grammar.recognize("kelvin pascal"), + ) + + +class TestCompoundRecognition: + """Grammar: compound_recognition — product/quotient unit shapes.""" + + def setup_method(self) -> None: + self.grammar: Grammar[SIUnitNotation] = CompoundRecognition() + + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): identity semantics id; no coalesced groups. + assert self.grammar.semantics == "compound_recognition" + + @pytest.mark.parametrize( + ("text", "body"), + [ + ("m/s²", "m/s²"), + ("m/s2", "m/s2"), + ("km/h", "km/h"), + ("N·m", "N·m"), + ("N⋅m", "N⋅m"), + ("kg·m/s²", "kg·m/s²"), + ("g/cm³", "g/cm³"), + ("m·s⁻²", "m·s⁻²"), + ("m/°C", "m/°C"), + ("µg/mL", "µg/mL"), + ("QQQ/zzz", "QQQ/zzz"), # shape-only: the rule rejects unknown groups + ("m/sx", "m/sx"), # shape-only: the rule rejects unknown groups + ("xN·m", "xN·m"), # shape-only: the rule rejects unknown groups + ], + ) + def test_recognizes(self, text: str, body: str) -> None: + results = self.grammar.recognize(text) + assert len(results) == 1 + match = results[0] + assert match.notation.text == body + assert match.notation.shape == "compound" + assert match.start == 0 + assert match.end == len(text) + + @pytest.mark.parametrize("text", ["m", "m s", "m s²", "5m/s"]) + def test_rejects(self, text: str) -> None: + assert self.grammar.recognize(text) == [] From 8e620e8966b56b9d3b69ccad63a7c0bb0266f81e Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 13:27:24 +0800 Subject: [PATCH 10/23] test(si_unit): add cross-layer data-consistency tests --- .../si_unit/test_data_consistency.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/capabilities/si_unit/test_data_consistency.py diff --git a/tests/capabilities/si_unit/test_data_consistency.py b/tests/capabilities/si_unit/test_data_consistency.py new file mode 100644 index 00000000..2a785060 --- /dev/null +++ b/tests/capabilities/si_unit/test_data_consistency.py @@ -0,0 +1,113 @@ +"""Cross-layer data-consistency tests for the SI Unit capability. + +House mandate (Currency precedent): every recognition key shipped by the +grammar/data token tables must resolve through the rules/data authority +tables, and every authority symbol must be reachable from the token +tables. Grammar<->rule key agreement is asserted here, not per-file. +""" + +from __future__ import annotations + +import pytest + +from paxman.capabilities.SIUnit.grammar.data.unit_name_tokens import NAME_TOKENS +from paxman.capabilities.SIUnit.grammar.data.unit_symbol_tokens import SYMBOL_TOKENS +from paxman.capabilities.SIUnit.rules.data.prefixed_unit_names import ( + PREFIXED_NAME_TO_SYMBOL, +) +from paxman.capabilities.SIUnit.rules.data.prefixed_units import PREFIXED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_derived_units import DERIVED_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_nonsi_units import NONSI_UNIT_SYMBOLS +from paxman.capabilities.SIUnit.rules.data.si_prefixes import ( + PREFIX_NAMES, + PREFIX_SYMBOLS, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL + +pytestmark = [pytest.mark.capability, pytest.mark.si_unit] + +OFFICIAL_SYMBOLS = BASE_UNIT_SYMBOLS | DERIVED_UNIT_SYMBOLS | NONSI_UNIT_SYMBOLS +FULL_NAME_TO_SYMBOL = NAME_TO_SYMBOL | PREFIXED_NAME_TO_SYMBOL + + +class TestSymbolCoverage: + """Every grammar symbol token is an authority symbol, and vice versa.""" + + def test_token_set_equals_authority_symbols(self) -> None: + assert set(SYMBOL_TOKENS) == ( + OFFICIAL_SYMBOLS | PREFIX_SYMBOLS | PREFIXED_UNIT_SYMBOLS + ) + + def test_generated_symbols_disjoint_from_official(self) -> None: + assert PREFIXED_UNIT_SYMBOLS.isdisjoint(OFFICIAL_SYMBOLS) + + def test_kg_not_prefixable(self) -> None: + # D9: prefixes attach to the gram, never to the kilogram. + assert "kg" not in PREFIXED_UNIT_SYMBOLS + assert "g" in OFFICIAL_SYMBOLS + + +class TestNameCoverage: + """Every grammar name token resolves; every resolvable name is a token.""" + + def test_name_tokens_equal_full_name_map(self) -> None: + assert set(NAME_TOKENS) == set(FULL_NAME_TO_SYMBOL) + + def test_names_resolve_to_known_symbols(self) -> None: + assert set(FULL_NAME_TO_SYMBOL.values()) <= ( + OFFICIAL_SYMBOLS | PREFIXED_UNIT_SYMBOLS + ) + + def test_prefixed_names_disjoint_from_official_names(self) -> None: + assert set(PREFIXED_NAME_TO_SYMBOL).isdisjoint(NAME_TO_SYMBOL) + + def test_no_kilogram_stacking(self) -> None: + assert not any(n.endswith("kilogram") for n in PREFIXED_NAME_TO_SYMBOL) + + +class TestDecompositionInvariants: + """Generated prefixed symbols/names decompose into known parts.""" + + def test_gram_is_the_prefix_attachment_point(self) -> None: + # D9: "microgram" -> "µg" is reachable only via name "gram" -> "g". + assert NAME_TO_SYMBOL["gram"] == "g" + + def test_prefixed_symbols_decompose(self) -> None: + prefixes = sorted(PREFIX_SYMBOLS, key=lambda p: (-len(p), p)) + for symbol in PREFIXED_UNIT_SYMBOLS: + for prefix in prefixes: + if symbol.startswith(prefix): + unit = symbol[len(prefix) :] + assert unit in OFFICIAL_SYMBOLS + assert unit != "kg" # D9 + break + else: + pytest.fail(f"{symbol!r} does not start with any prefix symbol") + + def test_prefixed_names_decompose(self) -> None: + prefix_names = sorted(set(PREFIX_NAMES.values()), key=lambda n: (-len(n), n)) + for name, symbol in PREFIXED_NAME_TO_SYMBOL.items(): + for prefix_name in prefix_names: + if name.startswith(prefix_name): + unit_name = name[len(prefix_name) :] + assert unit_name in NAME_TO_SYMBOL + assert NAME_TO_SYMBOL[unit_name] in OFFICIAL_SYMBOLS + break + else: + pytest.fail(f"{name!r} does not start with any prefix name") + assert symbol in PREFIXED_UNIT_SYMBOLS + + +class TestTokenShapeInvariants: + """Symbol tokens are bare; name tokens are lowercase (grammar folds).""" + + def test_no_whitespace_in_symbol_tokens(self) -> None: + assert all(" " not in t for t in SYMBOL_TOKENS) + + def test_name_tokens_lowercase(self) -> None: + assert all(t == t.lower() for t in NAME_TOKENS) + + def test_no_empty_tokens(self) -> None: + assert all(SYMBOL_TOKENS) + assert all(NAME_TOKENS) From c9c0f8033199d1591b5c53775f8ebb239720f973 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 13:37:22 +0800 Subject: [PATCH 11/23] feat(si_unit): wire SIUnitCapability with create_contract --- paxman/capabilities/SIUnit/__init__.py | 4 +- paxman/capabilities/SIUnit/capability.py | 98 +++++++++++++++++++ tests/capabilities/si_unit/test_capability.py | 91 +++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 paxman/capabilities/SIUnit/capability.py create mode 100644 tests/capabilities/si_unit/test_capability.py diff --git a/paxman/capabilities/SIUnit/__init__.py b/paxman/capabilities/SIUnit/__init__.py index 62aeefab..36adbdd0 100644 --- a/paxman/capabilities/SIUnit/__init__.py +++ b/paxman/capabilities/SIUnit/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.capabilities.SIUnit.contract import SIUnitContract from paxman.capabilities.SIUnit.notation import SIUnitNotation -__all__ = ["SIUnitNotation"] +__all__ = ["SIUnitCapability", "SIUnitContract", "SIUnitNotation"] diff --git a/paxman/capabilities/SIUnit/capability.py b/paxman/capabilities/SIUnit/capability.py new file mode 100644 index 00000000..4966cc86 --- /dev/null +++ b/paxman/capabilities/SIUnit/capability.py @@ -0,0 +1,98 @@ +"""SI Unit capability — wires grammars and rules together.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.capabilities.SIUnit.grammar.compound_recognition import CompoundRecognition +from paxman.capabilities.SIUnit.grammar.name_recognition import NameRecognition +from paxman.capabilities.SIUnit.grammar.symbol_recognition import SymbolRecognition +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.capabilities.SIUnit.rules.bipm_si_brochure_ed2019 import ( + SectionBaseUnits, + SectionDerivedUnits, + SectionNames, + SectionNonSiUnits, + SectionPrefixes, +) +from paxman.capabilities.SIUnit.rules.iso_80000_ed2022 import SectionCompounds +from paxman.core.capability import Capability +from paxman.core.domain import Grammar, Rule + +__all__ = ["SIUnitCapability", "SIUnitContract", "SIUnitNotation"] + + +class SIUnitCapability(Capability[SIUnitNotation]): + """SI unit canonicalization capability. + + Canonicalizes SI unit expressions — a unit symbol, a unit name, or a + product/quotient compound — to the canonical symbol form, with full + provenance. Identity-only: no quantities, no magnitudes, no + name-compounds ("metre per second" is not recognized; "25°C" is + MISSING). Strategy: BIPM SI Brochure (9th ed., 2019) + ISO 80000-1. + """ + + name = "si_unit" + version = "1.0.0" + + def get_grammars(self) -> list[Grammar[SIUnitNotation]]: + """Return all grammar instances. + + Returns: + List of 3 grammars: symbol, name, compound. + """ + return [SymbolRecognition(), NameRecognition(), CompoundRecognition()] + + def get_rules(self) -> list[Rule[SIUnitNotation]]: + """Return all validation rule instances. + + Returns: + List of 6 rules: 5 BIPM sections (base, derived, non-SI, + prefixes, names) and 1 ISO 80000-1 compound section. + """ + return [ + SectionBaseUnits(), + SectionDerivedUnits(), + SectionNonSiUnits(), + SectionPrefixes(), + SectionNames(), + SectionCompounds(), + ] + + @staticmethod + def create_contract( + *, + excluded_rules: Sequence[str] | None = None, + pinned_rules: Sequence[str] | None = None, + year: int | None = None, + output_format: str | None = None, + extra_grammars: Sequence[str] | None = None, + ) -> SIUnitContract: + """Factory method for creating contracts with proper defaults. + + Args: + excluded_rules: Rule names to exclude. + pinned_rules: Pin to specific rules (takes precedence over + excluded_rules). + year: Year for temporal filtering. + output_format: Output format for canonical values. Optional; + None/"default"/"symbol" resolve to "symbol". + extra_grammars: Community grammar names (opt-in) to run + alongside the shipped grammars, in order (SEAM — the + surface guard's common block ends with this parameter). + + Returns: + Configured SIUnitContract instance. + """ + return SIUnitContract( + excluded_rules=tuple(excluded_rules) if excluded_rules else (), + pinned_rules=tuple(pinned_rules) if pinned_rules is not None else None, + year=year, + output_format=output_format, + extra_grammars=tuple(extra_grammars) if extra_grammars else (), + ) + + # format_value: NOT overridden — the canonical value IS the "symbol" + # format, and there are no offered alternatives. The Capability base + # provides the identity formatter. diff --git a/tests/capabilities/si_unit/test_capability.py b/tests/capabilities/si_unit/test_capability.py new file mode 100644 index 00000000..59747c96 --- /dev/null +++ b/tests/capabilities/si_unit/test_capability.py @@ -0,0 +1,91 @@ +"""Tests for the SIUnit capability wiring.""" + +import pytest + +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.capabilities.SIUnit.notation import SIUnitNotation + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSIUnitCapability: + """Capability wiring — grammars, rules, factory, exports.""" + + def setup_method(self) -> None: + self.capability = SIUnitCapability() + + def test_metadata(self) -> None: + # name == "si_unit"; version == "1.0.0" + assert self.capability.name == "si_unit" + assert self.capability.version == "1.0.0" + + def test_get_grammars(self) -> None: + # 3 instances with names {symbol_recognition, name_recognition, + # compound_recognition} + names = {g.name for g in self.capability.get_grammars()} + assert names == { + "symbol_recognition", + "name_recognition", + "compound_recognition", + } + + def test_get_rules(self) -> None: + # 6 instances: 5 BIPM sections + 1 ISO compound section + names = {r.name for r in self.capability.get_rules()} + assert names == { + "Section 2.3.1-base-units", + "Section 2.3.2-derived-units", + "Section 4.1-non-si-units", + "Section 3.2-prefixes", + "Section-names", + "Section 6.5-compounds", + } + + def test_create_contract_defaults(self) -> None: + # create_contract() returns SIUnitContract with defaults + contract = self.capability.create_contract() + assert isinstance(contract, SIUnitContract) + assert contract.excluded_rules == () + assert contract.pinned_rules is None + assert contract.output_format == "symbol" # DEFAULT_OUTPUT_FORMAT + + def test_create_contract_excluded_rules(self) -> None: + contract = self.capability.create_contract(excluded_rules=["Section-names"]) + assert contract.excluded_rules == ("Section-names",) + + def test_create_contract_extra_grammars(self) -> None: + # SEAM: the community opt-in field is forwarded by the factory + # (surface guard: default () + forwarding through create_contract). + contract = self.capability.create_contract( + extra_grammars=["dot_unit_recognition"] + ) + assert contract.extra_grammars == ("dot_unit_recognition",) + assert self.capability.create_contract().extra_grammars == () + + def test_create_contract_keyword_only(self) -> None: + # ContractFactory conformance: the common block is keyword-only + with pytest.raises(TypeError): + self.capability.create_contract("Section-names") # type: ignore[call-arg] + + def test_format_value_identity(self) -> None: + # offered formats are empty -> base identity is the contract + notation = SIUnitNotation(text="kg", shape="symbol") + assert self.capability.format_value("kg", "symbol", notation) == "kg" + + +def test_package_exports() -> None: + # __all__ exports SIUnitCapability, SIUnitContract, SIUnitNotation + from paxman.capabilities.SIUnit import ( + SIUnitCapability as CapabilityExport, + ) + from paxman.capabilities.SIUnit import ( + SIUnitContract as ContractExport, + ) + from paxman.capabilities.SIUnit import ( + SIUnitNotation as NotationExport, + ) + + assert CapabilityExport is SIUnitCapability + assert ContractExport is SIUnitContract + assert NotationExport is SIUnitNotation From ec505dd1c82d8c628dbb355e5bd4da045aff9420 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 13:56:42 +0800 Subject: [PATCH 12/23] feat(si_unit): register SIUnit capability and extend export/surface guards --- paxman/capabilities/__init__.py | 2 ++ tests/capabilities/si_unit/test_contract.py | 5 +++ tests/unit/test_capability_exports.py | 18 +++++++++-- tests/unit/test_capability_surface.py | 23 +++++++++++++ .../test_grammar_semantics_consistency.py | 32 +++++++++++++++---- tests/unit/test_grammar_semantics_metadata.py | 14 +++++++- 6 files changed, 85 insertions(+), 9 deletions(-) diff --git a/paxman/capabilities/__init__.py b/paxman/capabilities/__init__.py index e75fb9f2..27f67949 100644 --- a/paxman/capabilities/__init__.py +++ b/paxman/capabilities/__init__.py @@ -8,6 +8,7 @@ from paxman.capabilities.ISBN.capability import ISBNCapability as ISBN from paxman.capabilities.Money.capability import MoneyCapability as Money from paxman.capabilities.Phone.capability import PhoneCapability as Phone +from paxman.capabilities.SIUnit.capability import SIUnitCapability as SIUnit from paxman.capabilities.URL.capability import URLCapability as URL __all__ = [ @@ -19,5 +20,6 @@ "ISBN", "Money", "Phone", + "SIUnit", "URL", ] diff --git a/tests/capabilities/si_unit/test_contract.py b/tests/capabilities/si_unit/test_contract.py index a4d3a450..c1fbe808 100644 --- a/tests/capabilities/si_unit/test_contract.py +++ b/tests/capabilities/si_unit/test_contract.py @@ -47,3 +47,8 @@ def test_extra_grammars_defaults_empty(self) -> None: def test_frozen(self) -> None: with pytest.raises(dataclasses.FrozenInstanceError): SIUnitContract().capability_name = "other" # type: ignore[misc] + + def test_no_slots(self) -> None: + """Contracts are @dataclass(frozen=True) WITHOUT slots (project + convention — enforced by the surface guard).""" + assert hasattr(SIUnitContract(), "__dict__") diff --git a/tests/unit/test_capability_exports.py b/tests/unit/test_capability_exports.py index ff1b86b2..371a61ec 100644 --- a/tests/unit/test_capability_exports.py +++ b/tests/unit/test_capability_exports.py @@ -14,6 +14,7 @@ Email, Money, Phone, + SIUnit, ) @@ -113,6 +114,18 @@ def test_money_capability_name(self) -> None: assert Money.name == "money" +class TestSIUnitCapabilityExports: + @pytest.mark.unit + def test_si_unit_capability_importable(self) -> None: + """SIUnit capability is importable from paxman.capabilities.""" + assert SIUnit is not None + + @pytest.mark.unit + def test_si_unit_capability_name(self) -> None: + """SIUnit capability has correct name.""" + assert SIUnit.name == "si_unit" + + class TestURLCapabilityExports: @pytest.mark.unit def test_url_capability_importable(self) -> None: @@ -125,8 +138,8 @@ def test_url_capability_name(self) -> None: assert URL.name == "url" @pytest.mark.unit - def test_export_list_contains_nine_names(self) -> None: - """The registration surface exports exactly nine capabilities.""" + def test_export_list_contains_ten_names(self) -> None: + """The registration surface exports exactly ten capabilities.""" import paxman.capabilities as capabilities assert set(capabilities.__all__) == { @@ -138,5 +151,6 @@ def test_export_list_contains_nine_names(self) -> None: "ISBN", "Money", "Phone", + "SIUnit", "URL", } diff --git a/tests/unit/test_capability_surface.py b/tests/unit/test_capability_surface.py index 15703ef7..13fd889e 100644 --- a/tests/unit/test_capability_surface.py +++ b/tests/unit/test_capability_surface.py @@ -47,6 +47,9 @@ from paxman.capabilities.Phone.capability import PhoneCapability from paxman.capabilities.Phone.contract import PhoneContract from paxman.capabilities.Phone.notation import PhoneNotation +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.capabilities.SIUnit.notation import SIUnitNotation from paxman.capabilities.URL.capability import URLCapability from paxman.capabilities.URL.contract import URLCapabilityContract from paxman.capabilities.URL.notation import URLNotation @@ -110,6 +113,12 @@ "e164", id="phone", ), + pytest.param( + SIUnitCapability, + SIUnitContract, + "symbol", + id="si_unit", + ), pytest.param( URLCapability, URLCapabilityContract, @@ -340,6 +349,13 @@ def test_surface_covers_every_exported_capability(self) -> None: PhoneNotation(shape="e164", value="15551234567"), id="phone", ), + pytest.param( + SIUnitCapability, + SIUnitContract, + "m", + SIUnitNotation(text="m", shape="symbol"), + id="si_unit", + ), pytest.param( URLCapability, URLCapabilityContract, @@ -423,6 +439,13 @@ def test_surface_covers_every_exported_capability(self) -> None: IPNotation(address="192.0.2.1"), id="ip", ), + pytest.param( + SIUnitCapability, + SIUnitContract, + "m", + SIUnitNotation(text="m", shape="symbol"), + id="si_unit", + ), pytest.param( URLCapability, URLCapabilityContract, diff --git a/tests/unit/test_grammar_semantics_consistency.py b/tests/unit/test_grammar_semantics_consistency.py index 1c300138..75f8c50a 100644 --- a/tests/unit/test_grammar_semantics_consistency.py +++ b/tests/unit/test_grammar_semantics_consistency.py @@ -32,6 +32,7 @@ Email, Money, Phone, + SIUnit, ) from paxman.capabilities.Date.contract import DateContract from paxman.capabilities.Date.notation import DateNotation @@ -45,7 +46,18 @@ from paxman.core.capability_contract import CapabilityContract from paxman.core.domain import Grammar, Rule -_SHIPPED_CAPABILITIES = [Country, Currency, Date, Email, IP, ISBN, Money, Phone, URL] +_SHIPPED_CAPABILITIES = [ + Country, + Currency, + Date, + Email, + IP, + ISBN, + Money, + Phone, + SIUnit, + URL, +] # D7 no-coalesce ids: groups that must NEVER grow a second member. The # date formats and the six identity singletons are distinct enough that @@ -295,12 +307,20 @@ def test_d7_no_coalesce_semantics_groups_stay_singleton() -> None: ``us_calendar_date``/``european_calendar_date`` are renamed singletons and the other six are identity singletons; coalescing any of them would change - what the shared semantics resolves to. Each id must total exactly one - member across all capabilities. + what the shared semantics resolves to. Each id must stay a singleton + within every capability: coalescing happens only inside one capability, + so a second member anywhere in the same capability would silently change + what the id resolves to, while reuse across capabilities (Country and + SIUnit both declaring ``name_recognition``) never co-routes and is safe + (R3). Each locked id must still exist in at least one capability. """ groups = _group_shipped_grammars_by_capability_semantics() for semantics in _NO_COALESCE_SEMANTICS: - total = sum( - len(per_capability.get(semantics, ())) for per_capability in groups.values() + counts = [ + len(per_capability.get(semantics, ())) + for per_capability in groups.values() + ] + assert any(counts), f"{semantics!r} must stay a singleton, found none" + assert all(count <= 1 for count in counts), ( + f"{semantics!r} must stay a singleton per capability, found {counts}" ) - assert total == 1, f"{semantics!r} must stay a singleton, found {total}" diff --git a/tests/unit/test_grammar_semantics_metadata.py b/tests/unit/test_grammar_semantics_metadata.py index 0f654093..91996c0c 100644 --- a/tests/unit/test_grammar_semantics_metadata.py +++ b/tests/unit/test_grammar_semantics_metadata.py @@ -16,6 +16,7 @@ Email, Money, Phone, + SIUnit, ) from paxman.core.domain import Grammar, RecognitionMatch @@ -42,7 +43,18 @@ def test_shipped_grammars_declare_semantics_identity(self) -> None: """Every shipped grammar declares ``semantics``: identity with its name for non-coalesced grammars, or one of the coalesced ids (an explicit allowlist) for grammars sharing a semantic group.""" - capabilities = [Country, Currency, Date, Email, IP, ISBN, Money, Phone, URL] + capabilities = [ + Country, + Currency, + Date, + Email, + IP, + ISBN, + Money, + Phone, + SIUnit, + URL, + ] for capability in capabilities: for grammar in capability().get_grammars(): assert isinstance(grammar.semantics, str) From 7c9bd338510fafbfd43a9e1cadbc0e4bb477978c Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 14:08:54 +0800 Subject: [PATCH 13/23] style(si_unit): ruff-format D7 consistency guard rewrite --- tests/unit/test_grammar_semantics_consistency.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_grammar_semantics_consistency.py b/tests/unit/test_grammar_semantics_consistency.py index 75f8c50a..cf8c3771 100644 --- a/tests/unit/test_grammar_semantics_consistency.py +++ b/tests/unit/test_grammar_semantics_consistency.py @@ -317,8 +317,7 @@ def test_d7_no_coalesce_semantics_groups_stay_singleton() -> None: groups = _group_shipped_grammars_by_capability_semantics() for semantics in _NO_COALESCE_SEMANTICS: counts = [ - len(per_capability.get(semantics, ())) - for per_capability in groups.values() + len(per_capability.get(semantics, ())) for per_capability in groups.values() ] assert any(counts), f"{semantics!r} must stay a singleton, found none" assert all(count <= 1 for count in counts), ( From a86e36ec08cf1d00040022508ce77e9461f68a6f Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 14:18:07 +0800 Subject: [PATCH 14/23] test(si_unit): lock SIUnit pipeline semantics and determinism --- tests/integration/test_pipeline.py | 7 + tests/integration/test_si_unit_pipeline.py | 173 +++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 tests/integration/test_si_unit_pipeline.py diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 61485931..408e69f7 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -14,6 +14,7 @@ from paxman.capabilities.IP.capability import IPCapability from paxman.capabilities.ISBN.capability import ISBNCapability from paxman.capabilities.Phone.capability import PhoneCapability +from paxman.capabilities.SIUnit.capability import SIUnitCapability from paxman.core.capability import Capability from paxman.core.contract import Contract from paxman.core.discovery import register_capability, reset_registry @@ -567,6 +568,12 @@ class TestCanonicalDeterminismAndCandidateOrder: "0306406152", id="isbn10-default", ), + pytest.param( + SIUnitCapability, + lambda: SIUnitCapability.create_contract(), + "megahertz", + id="si_unit-prefixed-name", + ), ], ) def test_repeated_run_is_deterministic( diff --git a/tests/integration/test_si_unit_pipeline.py b/tests/integration/test_si_unit_pipeline.py new file mode 100644 index 00000000..274338cb --- /dev/null +++ b/tests/integration/test_si_unit_pipeline.py @@ -0,0 +1,173 @@ +"""Integration tests for the SI Unit capability pipeline.""" + +from collections.abc import Iterator + +import pytest + +from paxman.api import canonicalize +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.core.discovery import register_capability, reset_registry +from paxman.core.domain import Resolution + + +@pytest.fixture(autouse=True) +def _clean_registry() -> Iterator[None]: + """Reset the capability registry before and after each test.""" + reset_registry() + yield + reset_registry() + + +class TestSIUnitPipeline: + """Full-pipeline tests for the SI Unit capability. + + Locked semantics (plan §1 e2e contract): + - identity-only: no quantities, no magnitudes, no name-compounds + ("metre per second" is not recognized). Canonical form is the unit + symbol (D3) — names resolve to their symbol, "l" canonicalizes to + "L", compound exponents render as ASCII digits; + - symbols are case-exact (D6): "pa" and "Kg" are not recognized at + all -> MISSING, and "KHz" is MISSING not INVALID (R1) — the symbol + grammar is a case-exact lexicon, so an invalid prefix casing never + matches; + - bare prefix symbols ("da", "k") are recognized but no rule resolves + them -> INVALID; compound shapes over unknown groups ("QQQ/zzz") + are recognized by the shape grammar but rejected by the ISO 80000-1 + rule -> INVALID; + - "25°C" is quantity-glued: the degree sign blocks a fallback to bare + "C", so nothing matches -> MISSING (D7); "USD" is not an SI token + -> MISSING; + - "m s" (space, no separator) is two independent symbol recognitions + carrying distinct canonical values "m" and "s" -> AMBIGUOUS, never + a compound SUCCESS (R2); + - INVALID rows carry no candidates (recognized, but no rule validated + them) and MISSING rows carry no candidates (nothing recognized); + - provenance: names, symbols, and prefixes resolve via the BIPM SI + Brochure (9th ed., 2019); product/quotient compounds resolve via + ISO 80000-1 §6.5. + """ + + @pytest.mark.integration + @pytest.mark.parametrize( + "text,expected_status,expected_value", + [ + # --- SUCCESS rows --- + ("Kilogram", Resolution.SUCCESS, "kg"), # BIPM Table 1 (name→symbol) + ("Kelvin", Resolution.SUCCESS, "K"), # BIPM Table 1 + ("megahertz", Resolution.SUCCESS, "MHz"), # BIPM §3.2 (prefix) + ("m", Resolution.SUCCESS, "m"), # BIPM Table 1 + ("kg", Resolution.SUCCESS, "kg"), # BIPM Table 1 + ("cd", Resolution.SUCCESS, "cd"), # BIPM Table 1 + ("Pa", Resolution.SUCCESS, "Pa"), # BIPM Tables 3–4 + ("\u00b0C", Resolution.SUCCESS, "\u00b0C"), # BIPM Tables 3–4 + ("l", Resolution.SUCCESS, "L"), # BIPM Tables 8–9 (l→L) + ("L", Resolution.SUCCESS, "L"), # BIPM Tables 8–9 + ("km", Resolution.SUCCESS, "km"), # BIPM §3.2 + ("\u00b5g", Resolution.SUCCESS, "\u00b5g"), # BIPM §3.2 + ("m/s\u00b2", Resolution.SUCCESS, "m/s2"), # ISO 80000-1 §6.5 + ("km/h", Resolution.SUCCESS, "km/h"), # ISO 80000-1 §6.5 + ("N\u00b7m", Resolution.SUCCESS, "N\u00b7m"), # ISO 80000-1 §6.5 + ("kg\u00b7m/s\u00b2", Resolution.SUCCESS, "kg\u00b7m/s2"), # §6.5 + ("g/cm\u00b3", Resolution.SUCCESS, "g/cm3"), # ISO 80000-1 §6.5 + ("m\u00b7s\u207b\u00b2", Resolution.SUCCESS, "m\u00b7s-2"), # §6.5 + # --- INVALID rows --- + ("da", Resolution.INVALID, None), # bare prefix (recognized, no rule) + ("k", Resolution.INVALID, None), # bare prefix (recognized, no rule) + ("QQQ/zzz", Resolution.INVALID, None), # compound shape, unknown groups + # --- MISSING rows --- + ("pa", Resolution.MISSING, None), # case-exact symbols (D6) + ("KHz", Resolution.MISSING, None), # refinement R1 + ("Kg", Resolution.MISSING, None), # case-exact (D6) + ("25\u00b0C", Resolution.MISSING, None), # quantity-glued (D7) + ("USD", Resolution.MISSING, None), # not an SI token + # --- AMBIGUOUS row --- + ("m s", Resolution.AMBIGUOUS, None), # refinement R2 + ], + ) + def test_e2e_contract( + self, + text: str, + expected_status: Resolution, + expected_value: str | None, + ) -> None: + """Every row of the plan §1 e2e contract through canonicalize().""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize(text, contract) + assert result.status == expected_status + assert result.canonicalized_value == expected_value + if expected_status in (Resolution.MISSING, Resolution.INVALID): + assert result.candidates == () + + @pytest.mark.integration + def test_ambiguous_row_m_s_carries_both_canonical_values(self) -> None: + """R2: "m s" is AMBIGUOUS, never a compound SUCCESS. + + The compound grammar requires "/", "·" or "⋅" separators, so "m s" + yields two independent symbol recognitions ("m", "s"); the engine + preserves overlaps, so both distinct canonical values survive into + the candidate set and the status is AMBIGUOUS. + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m s", contract) + assert result.status == Resolution.AMBIGUOUS + assert result.canonicalized_value is None + assert {c.value for c in result.candidates} == {"m", "s"} + + @pytest.mark.integration + def test_provenance_name_bipm_si_brochure(self) -> None: + """A name SUCCESS carries BIPM SI Brochure provenance (Section-names).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("Kilogram", contract) + assert result.status == Resolution.SUCCESS + candidate = result.candidates[0] + assert candidate.validation_rule == "Section-names" + prov = candidate.provenance[0] + assert "SI Brochure" in prov.specification_name + assert prov.authority == "BIPM" + + @pytest.mark.integration + def test_provenance_prefixed_name_bipm_si_brochure(self) -> None: + """A prefixed name SUCCESS carries BIPM provenance (prefix rule).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("megahertz", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "MHz" + candidate = result.candidates[0] + assert candidate.validation_rule == "Section-names" + prov = candidate.provenance[0] + assert "SI Brochure" in prov.specification_name + assert prov.authority == "BIPM" + + @pytest.mark.integration + def test_provenance_compound_iso_80000_1(self) -> None: + """A compound SUCCESS carries ISO 80000-1 provenance (§6.5).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m/s\u00b2", contract) + assert result.status == Resolution.SUCCESS + candidate = result.candidates[0] + assert candidate.validation_rule == "Section 6.5-compounds" + prov = candidate.provenance[0] + assert "ISO 80000-1" in prov.specification_name + assert prov.authority == "ISO" + + @pytest.mark.integration + def test_frozen_registry_second_call_succeeds(self) -> None: + """The registry freezes on first canonicalize(); a second call succeeds. + + The engine freezes the registry at pipeline start and never + re-registers, so two calls through the same registered capability + both resolve (never a CapabilityError on the second call). + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + first = canonicalize("kg", contract) + second = canonicalize("m", contract) + assert first.status == Resolution.SUCCESS + assert first.canonicalized_value == "kg" + assert second.status == Resolution.SUCCESS + assert second.canonicalized_value == "m" From 8f7d9ac36847293d4d69c2997b1702663654b410 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 14:44:05 +0800 Subject: [PATCH 15/23] test(si_unit): add property invariants and e2e coverage --- tests/e2e/test_canonicalize.py | 248 ++++++++++++++++++++++ tests/property/test_si_unit_properties.py | 119 +++++++++++ 2 files changed, 367 insertions(+) create mode 100644 tests/property/test_si_unit_properties.py diff --git a/tests/e2e/test_canonicalize.py b/tests/e2e/test_canonicalize.py index ff336e62..7a7e32d6 100644 --- a/tests/e2e/test_canonicalize.py +++ b/tests/e2e/test_canonicalize.py @@ -9,6 +9,7 @@ from paxman.capabilities.Date.capability import DateCapability from paxman.capabilities.Email.capability import EmailCapability from paxman.capabilities.Phone.capability import PhoneCapability +from paxman.capabilities.SIUnit.capability import SIUnitCapability from paxman.capabilities.URL.capability import URLCapability from paxman.core.discovery import register_capability, reset_registry from paxman.core.domain import Resolution @@ -357,3 +358,250 @@ def test_currency_empty_input_missing(self) -> None: contract = CurrencyCapability.create_contract() result = canonicalize("", contract) assert result.status == Resolution.MISSING + + +class TestSIUnitCapabilityE2E: + """End-to-end tests for the SI Unit capability through paxman.canonicalize. + + Rows are the full plan §1 e2e contract (27 rows, plan lines 192–219): + the three Milestone rows first, then the SUCCESS/INVALID/MISSING/ + AMBIGUOUS remainder, ending with the cross-capability "USD" row. + """ + + @pytest.mark.e2e + def test_si_unit_milestone_name_kilogram(self) -> None: + """Milestone: "Kilogram" resolves to "kg" via the name fold (D4).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("Kilogram", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg" + + @pytest.mark.e2e + def test_si_unit_milestone_prefixed_name_megahertz(self) -> None: + """Milestone: "megahertz" resolves to "MHz" via the generated prefix name.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("megahertz", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "MHz" + + @pytest.mark.e2e + def test_si_unit_milestone_compound(self) -> None: + """Milestone: "m/s²" resolves to "m/s2" via the compound rule.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m/s²", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "m/s2" + + @pytest.mark.e2e + def test_si_unit_name_kelvin(self) -> None: + """The name "Kelvin" resolves to "K" via the BIPM Table 1 name rule.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("Kelvin", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "K" + + @pytest.mark.e2e + def test_si_unit_base_symbol_metre(self) -> None: + """Base-unit symbol "m" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "m" + + @pytest.mark.e2e + def test_si_unit_base_symbol_kilogram(self) -> None: + """Base-unit symbol "kg" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("kg", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg" + + @pytest.mark.e2e + def test_si_unit_base_symbol_candela(self) -> None: + """Base-unit symbol "cd" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("cd", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "cd" + + @pytest.mark.e2e + def test_si_unit_derived_symbol_pascal(self) -> None: + """Derived special-name symbol "Pa" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("Pa", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "Pa" + + @pytest.mark.e2e + def test_si_unit_nonsi_symbol_degree_celsius(self) -> None: + """Non-SI symbol "°C" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("°C", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "°C" + + @pytest.mark.e2e + def test_si_unit_litre_lowercase_l(self) -> None: + """D3: the lowercase written form "l" canonicalizes to "L".""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("l", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "L" + + @pytest.mark.e2e + def test_si_unit_litre_uppercase_l(self) -> None: + """Non-SI symbol "L" resolves to itself.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("L", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "L" + + @pytest.mark.e2e + def test_si_unit_prefixed_symbol_km(self) -> None: + """Prefixed symbol "km" resolves to itself (BIPM §3.2).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("km", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "km" + + @pytest.mark.e2e + def test_si_unit_prefixed_symbol_microgram(self) -> None: + """Prefixed symbol "µg" resolves to itself (BIPM §3.2).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("µg", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "µg" + + @pytest.mark.e2e + def test_si_unit_compound_km_per_hour(self) -> None: + """Compound "km/h" resolves to itself (ISO 80000-1 §6.5).""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("km/h", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "km/h" + + @pytest.mark.e2e + def test_si_unit_compound_newton_metre(self) -> None: + """Compound "N·m" resolves to itself via the middle-dot separator.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("N·m", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "N·m" + + @pytest.mark.e2e + def test_si_unit_compound_kg_m_per_s2(self) -> None: + """Compound "kg·m/s²" resolves with its superscript folded to ASCII.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("kg·m/s²", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg·m/s2" + + @pytest.mark.e2e + def test_si_unit_compound_g_per_cm3(self) -> None: + """Compound "g/cm³" resolves with its superscript folded to ASCII.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("g/cm³", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "g/cm3" + + @pytest.mark.e2e + def test_si_unit_compound_m_per_s_minus2(self) -> None: + """Compound "m·s⁻²" resolves with its superscript minus folded to "-".""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m·s⁻²", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "m·s-2" + + @pytest.mark.e2e + def test_si_unit_bare_prefix_da_invalid(self) -> None: + """Bare prefix "da" is recognized but no rule validates it — INVALID.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("da", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + @pytest.mark.e2e + def test_si_unit_bare_prefix_k_invalid(self) -> None: + """Bare prefix "k" is recognized but no rule validates it — INVALID.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("k", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + @pytest.mark.e2e + def test_si_unit_unknown_compound_invalid(self) -> None: + """Compound shape "QQQ/zzz" is recognized but the groups are unknown.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("QQQ/zzz", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + @pytest.mark.e2e + def test_si_unit_case_mismatch_pa_missing(self) -> None: + """D6: "pa" is not the case-exact symbol — nothing is recognized.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("pa", contract) + assert result.status == Resolution.MISSING + + @pytest.mark.e2e + def test_si_unit_case_mismatch_khz_missing(self) -> None: + """R1: "KHz" (wrong case) never falls back to a prefix+Hz split.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("KHz", contract) + assert result.status == Resolution.MISSING + + @pytest.mark.e2e + def test_si_unit_case_mismatch_kg_missing(self) -> None: + """D6: "Kg" (wrong case) is never partial-matched to "K"+"g".""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("Kg", contract) + assert result.status == Resolution.MISSING + + @pytest.mark.e2e + def test_si_unit_quantity_glued_missing(self) -> None: + """D7: quantity-glued "25°C" is never partial-matched to "°C".""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("25°C", contract) + assert result.status == Resolution.MISSING + + @pytest.mark.e2e + def test_si_unit_space_separated_ambiguous(self) -> None: + """R2: "m s" yields two candidates ("m" and "s") — AMBIGUOUS.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m s", contract) + assert result.status == Resolution.AMBIGUOUS + assert result.canonicalized_value is None + + @pytest.mark.e2e + def test_si_unit_foreign_token_missing(self) -> None: + """Cross-capability: "USD" is not an SI token — MISSING.""" + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("USD", contract) + assert result.status == Resolution.MISSING diff --git a/tests/property/test_si_unit_properties.py b/tests/property/test_si_unit_properties.py new file mode 100644 index 00000000..fb7f60a5 --- /dev/null +++ b/tests/property/test_si_unit_properties.py @@ -0,0 +1,119 @@ +"""Hypothesis property tests for the SI Unit capability. + +Each property locks a mathematical invariant of recognition using an +independently derived expectation: + +- any symbol token from SYMBOL_TOKENS is recognized case-exactly as + itself with shape "symbol" (D6 — the symbol grammar never folds + case); +- any lowercase official unit name folds back from its Title-Case + spelling to exactly one lowercase "name" match (D4 grammar-owned + case folding); +- any prefixed unit name is recognized and passes the BIPM Section + names rule (name → symbol resolution); +- any compound built from two compoundable symbol tokens — SYMBOL_TOKENS + minus the glyph-only factors °, ′, ″ — joined by "/" yields exactly + one "compound" match; +- every recognized match spans exactly its raw text (half-open + [start, end) offsets, raw_text slicing back to the source). +""" + +from __future__ import annotations + +import string + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from paxman.capabilities.SIUnit.contract import SIUnitContract +from paxman.capabilities.SIUnit.grammar.compound_recognition import CompoundRecognition +from paxman.capabilities.SIUnit.grammar.data.unit_symbol_tokens import SYMBOL_TOKENS +from paxman.capabilities.SIUnit.grammar.name_recognition import NameRecognition +from paxman.capabilities.SIUnit.grammar.symbol_recognition import SymbolRecognition +from paxman.capabilities.SIUnit.rules.bipm_si_brochure_ed2019 import SectionNames +from paxman.capabilities.SIUnit.rules.data.prefixed_unit_names import ( + PREFIXED_NAME_TO_SYMBOL, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL + +# Alphabet for the span-invariant fuzz: ASCII letters and digits (symbol +# and name token characters), punctuation (boundary/glue rejection), plus +# the non-ASCII glyphs the grammars match or treat as boundaries — the +# degree sign, micro sign, middle dot, superscript digits, superscript +# minus, prime/double-prime (arcminute/arcsecond), Å, Ω, and the dot +# operator separator. +_SI_ALPHABET = ( + string.ascii_letters + + string.digits + + string.punctuation + + "\u00b0\u00b5\u00b7\u00b2\u00b3\u2070\u2074\u2075\u2076\u2077\u2078\u2079\u207b" + + "\u2032\u2033\u00c5\u03a9\u22c5" +) + +# Glyph-only plane-angle units (BIPM Table 8) are present in SYMBOL_TOKENS +# but are NOT compound factors: the compound grammar's _UNIT class is +# letter-based ([A-Za-zµΩÅ]) with a "°" prefix only to compose "°C" — a +# bare °/′/″ factor is never a unit. No §1 locked row requires a +# bare-glyph compound (e.g. "m/°C" composes via the "°C" token itself), +# so excluding them keeps the compound invariant deterministic over the +# lexicon the grammar actually composes. +_NON_COMPOUND_FACTORS: frozenset[str] = frozenset({"\u00b0", "\u2032", "\u2033"}) +_COMPOUNDABLE_TOKENS: tuple[str, ...] = tuple( + t for t in sorted(SYMBOL_TOKENS) if t not in _NON_COMPOUND_FACTORS +) + + +@pytest.mark.property +@given(t=st.sampled_from(sorted(SYMBOL_TOKENS))) +def test_symbol_token_recognized_case_exact(t: str) -> None: + """D6: every symbol token is recognized case-exactly as itself.""" + matches = SymbolRecognition().recognize(t) + assert len(matches) == 1 + assert matches[0].notation.text == t + assert matches[0].notation.shape == "symbol" + + +@pytest.mark.property +@given(n=st.sampled_from(sorted(NAME_TO_SYMBOL))) +def test_lowercase_name_folds_from_title_case(n: str) -> None: + """D4: a lowercase name folds back from its Title-Case spelling.""" + matches = NameRecognition().recognize(n.title()) + assert len(matches) == 1 + assert matches[0].notation.text == n + assert matches[0].notation.shape == "name" + + +@pytest.mark.property +@given(pn=st.sampled_from(sorted(PREFIXED_NAME_TO_SYMBOL))) +def test_prefixed_name_recognized_and_validated(pn: str) -> None: + """Every prefixed name is recognized and passes the BIPM names rule.""" + matches = NameRecognition().recognize(pn) + assert len(matches) == 1 + assert matches[0].notation.text == pn + assert SectionNames().matches(matches[0].notation, SIUnitContract()) is True + + +@pytest.mark.property +@given( + a=st.sampled_from(_COMPOUNDABLE_TOKENS), + b=st.sampled_from(_COMPOUNDABLE_TOKENS), +) +def test_compound_shape_recognized(a: str, b: str) -> None: + """Any two compoundable symbols joined by "/" form exactly one compound.""" + ct = f"{a}/{b}" + matches = CompoundRecognition().recognize(ct) + assert len(matches) == 1 + assert matches[0].notation.shape == "compound" + + +@pytest.mark.property +@given(text=st.text(alphabet=_SI_ALPHABET, max_size=40)) +def test_recognized_matches_have_consistent_spans(text: str) -> None: + """Every match spans exactly its raw_text (half-open [start, end)).""" + grammars = [SymbolRecognition(), NameRecognition(), CompoundRecognition()] + for grammar in grammars: + for match in grammar.recognize(text): + assert 0 <= match.start <= match.end + assert match.end - match.start == len(match.raw_text) + assert match.raw_text == text[match.start : match.end] From ca6a97bd38d216c7ec3e93554a116fb09317b341 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 15:05:31 +0800 Subject: [PATCH 16/23] docs(si_unit): document SIUnit capability and update capability counts --- AGENTS.md | 20 +++++++++++--------- CONTEXT.md | 26 ++++++++++++++++++-------- README.md | 30 ++++++++++++++++++++++++++++-- paxman/capabilities/AGENTS.md | 8 ++++---- 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ecc9d48..d8b9217b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ **Branch:** feature/CURRENCY-capability ## OVERVIEW -Paxman is a Python 3.11+ canonicalization library: takes ambiguous human input, returns what authoritative specs say it means, with full provenance. Deterministic, provenance-first. 9 capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, URL). Toolchain: uv + hatchling, ruff, strict pyright, import-linter, pytest at 95% coverage. +Paxman is a Python 3.11+ canonicalization library: takes ambiguous human input, returns what authoritative specs say it means, with full provenance. Deterministic, provenance-first. 10 capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SI Unit, URL). Toolchain: uv + hatchling, ruff, strict pyright, import-linter, pytest at 95% coverage. ## STRUCTURE ```text @@ -13,9 +13,9 @@ paxman/ ├── api/ # canonicalize() — sole public entry ├── engine/ # run_capability() pipeline orchestrator ├── core/ # domain objects, Contract protocol, registry, errors -└── capabilities/ # 9 self-contained capability packages +└── capabilities/ # 10 self-contained capability packages tests/ # unit / capabilities/ / integration / property / e2e -tools/ # regenerate_isbn_range_data.py (only script) +tools/ # regenerate_isbn_range_data.py, regenerate_si_prefix_data.py, regenerate_idna_uts46_data.py docs/ # adr/, report/, research/, superpowers/plans+specs ``` @@ -31,7 +31,7 @@ docs/ # adr/, report/, research/, superpowers/plans+specs | Recognition (per cap) | `paxman/capabilities//grammar/` | | Validation (per cap) | `paxman/capabilities//rules/` | | Presentation seam | `paxman/capabilities//capability.py` → `format_value()` | -| Regenerate ISBN data | `tools/regenerate_isbn_range_data.py` | +| Regenerate generated data | `tools/regenerate_isbn_range_data.py` (ISBN range), `tools/regenerate_si_prefix_data.py` (SIUnit prefixed units), `tools/regenerate_idna_uts46_data.py` (URL IDNA mapping) | | Merge-blocking commands | `.github/workflows/ci.yml` (authoritative) | | Past implementation plans | `docs/superpowers/plans/` | @@ -76,17 +76,19 @@ uv run ruff format --check paxman/ tests/ # format check uv run pyright # strict typecheck uv run import-linter lint # layer boundaries uv run pytest # all tests -uv run pytest -m unit|capability|integration|e2e # by marker (also: property, country, isbn, money) +uv run pytest -m unit|capability|integration|e2e # by marker (also: property, country, currency, isbn, money, url, si_unit) uv run pytest --cov=paxman --cov-report=term-missing --tb=short -q uv run coverage report --include="paxman/{core,capabilities,engine,api}/*" --fail-under=95 -uv run python tools/regenerate_isbn_range_data.py # regenerate ISBN data module +uv run python tools/regenerate_isbn_range_data.py # regenerate ISBN range message module +uv run python tools/regenerate_si_prefix_data.py # regenerate SIUnit prefixed-unit modules +uv run python tools/regenerate_idna_uts46_data.py # regenerate URL IDNA UTS #46 mapping ``` Full pre-PR gate: `ruff check . && ruff format --check . && pyright && import-linter lint && pytest` ## NOTES -- `paxman/capabilities/__init__.py` exports all nine shipped capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, URL); export completeness is enforced by `tests/unit/test_capability_exports.py`. A 10th — SI Unit (BIPM SI Brochure) — is in development on `feature/si-unit-capability`; update counts and the export/enumeration lists when it lands. -- CONTEXT.md is the domain glossary for the full shipped set (nine capabilities). It is kept in sync with the code; when adding a capability, update its Notation/table entries there too. +- `paxman/capabilities/__init__.py` exports all ten shipped capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SI Unit, URL); export completeness is enforced by `tests/unit/test_capability_exports.py`. +- CONTEXT.md is the domain glossary for the full shipped set (ten capabilities). It is kept in sync with the code; when adding a capability, update its Notation/table entries there too. - No `pyrightconfig.json` — pyright config is inline `[tool.pyright]` in pyproject.toml. No `.editorconfig`. -- Data modules live under `rules/data/` (Country, Currency, ISBN, Money, Phone, URL) and `grammar/data/` (Country, Currency, Money) — plain module-level tables separating data from logic, maintained in place. Only ISBN's range message is generated: XML snapshot → `range_message.py` via `tools/regenerate_isbn_range_data.py`; unmarked data files are edited directly. +- Data modules live under `rules/data/` (Country, Currency, ISBN, Money, Phone, SI Unit, URL) and `grammar/data/` (Country, Currency, Money, SI Unit) — plain module-level tables separating data from logic, maintained in place. Only the ISBN range message, the URL IDNA UTS #46 mapping, and the SIUnit prefixed-unit tables are generated (each via its `tools/regenerate_*_data.py` script); unmarked data files are edited directly. - Library only — no CLI, no `__main__.py`, no `[project.scripts]`. Version 0.2.0. - Coverage: global `fail_under = 95` + per-package 95% gates in CI. diff --git a/CONTEXT.md b/CONTEXT.md index e40c0d7d..7d13d3bd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,6 +41,7 @@ Capability-defined intermediate representation that Grammars must produce: - **Currency:** `CurrencyNotation(text, shape)` — `shape` is `"code"` / `"qualified_symbol"` / `"symbol"` / `"word"`; codes are grammar-folded to uppercase, words to lowercase, symbols keep exact casing - **Money:** `MoneyNotation(currency_part, amount_part, currency_shape, amount_shape)` — verbatim currency + amount tokens with grammar-assigned shape discriminators - **ISBN:** `ISBNNotation(shape, digits)` — `shape` is `"isbn10"` / `"isbn13"`, `digits` is the digit string (`X` only as final char of an isbn10 shape) +- **SIUnit:** `SIUnitNotation(text, shape)` — `shape` is `"symbol"` / `"name"` / `"compound"`; `text` is the unit expression as written (symbols keep exact casing, names are grammar-folded to lowercase, compounds keep the written form) - **IP / Phone / URL:** capability-defined shapes for address / number / URI components **Note:** Capabilities define Notation using frozen dataclasses for type safety and immutability. The `as_list()` method bridges the typed notation to the generic `list[str]` interface. @@ -65,7 +66,7 @@ class EmailNotation: ## The Capabilities -Paxman ships nine built-in capabilities, each wired to an authoritative specification: +Paxman ships ten built-in capabilities, each wired to an authoritative specification: | Capability | Domain | Authorities | |------------|--------|-------------| @@ -77,11 +78,12 @@ Paxman ships nine built-in capabilities, each wired to an authoritative specific | **ISBN** | ISBNs | ISO 2108, ISBN Users' Manual, ISBN Range Message | | **Money** | Money amounts | ISO 4217, CLDR | | **Phone** | Phone numbers | ITU-T E.164, RFC 3966, NANP | +| **SI Unit** | SI unit expressions | BIPM SI Brochure, ISO 80000-1 | | **URL** | URLs | WHATWG URL Standard | Capability classes are exported from `paxman/capabilities/__init__.py` as acronym aliases (`EmailCapability as Email`, etc.); the export list is enforced by `tests/unit/test_capability_exports.py`. -**In development:** the **SI Unit** capability (BIPM SI Brochure, 9th edition) is the planned 10th capability (MILESTONE row 23; branch `feature/si-unit-capability`). It will be the first capability whose canonical value is case-meaningful (`K` kelvin vs `k` kilo). This document will be updated again once it ships. +**Note:** the **SI Unit** capability is the first whose canonical value is case-meaningful (`K` kelvin vs `k` kilo). It canonicalizes unit expressions only (symbols, names, product/quotient compounds) to the canonical symbol form; no quantities, no magnitudes, no name-compounds. --- @@ -194,7 +196,7 @@ class SectionCode(Rule[CurrencyNotation]): return self.TABLE[notation.text] ``` -Authority-backed lookup tables live in `rules/data/` (e.g., `iso4217_list_one.py`, `cldr_currencies.py`), separated from rule logic; lexicon keys serving grammars live in `grammar/data/`. Only the ISBN range message is generated from a source snapshot (via `tools/regenerate_isbn_range_data.py`) — everything else is maintained in place. +Authority-backed lookup tables live in `rules/data/` (e.g., `iso4217_list_one.py`, `cldr_currencies.py`), separated from rule logic; lexicon keys serving grammars live in `grammar/data/`. Only three data modules are generated via tools: the ISBN range message (`tools/regenerate_isbn_range_data.py`), the SIUnit prefixed-unit tables (`tools/regenerate_si_prefix_data.py`), and the URL IDNA UTS #46 mapping (`tools/regenerate_idna_uts46_data.py`) — everything else is maintained in place. ### Parser Example ```python @@ -619,7 +621,7 @@ for candidate in result.candidates: ### Capability Versioning - Each capability has its own version in `capability.py` - Capability version is independent of engine version -- All nine built-in capabilities currently ship `version = "1.0.0"` +- All ten built-in capabilities currently ship `version = "1.0.0"` - Example: ```python # capabilities/Email/capability.py @@ -775,6 +777,14 @@ paxman/ │ ├── grammar/ # e164, tel_uri, international_00, national_recognition (+ common.py LEGACY) │ ├── rules/ # e164_ed2010, rfc_3966_ed2004, nanp_ed2024 │ └── rules/data/ # e164_country_codes, nanp_tables + ├── SIUnit/ # grammar/ (3) + rules/ (2) + grammar/data/ + rules/data/ — BIPM SI Brochure, ISO 80000-1 + │ ├── capability.py # SIUnitCapability + │ ├── contract.py # SIUnitContract + │ ├── notation.py # SIUnitNotation (text, shape) + │ ├── grammar/ # symbol, name, compound_recognition + │ ├── grammar/data/ # unit_symbol_tokens, unit_name_tokens, compound_tokens + │ ├── rules/ # bipm_si_brochure_ed2019, iso_80000_ed2022 + │ └── rules/data/ # si_base_units, si_derived_units, si_nonsi_units, si_prefixes, unit_names (+ GENERATED prefixed_units, prefixed_unit_names) └── URL/ # grammar/ (1) + rules/ (1) + rules/data/ — WHATWG URL Standard ├── capability.py # URLCapability ├── contract.py # URLContract @@ -785,8 +795,6 @@ paxman/ └── rules/data/ # idna_uts46_mapping ``` -**In development (not yet in tree):** the SI Unit capability (branch `feature/si-unit-capability`, research at `docs/research/2026-08-09-si-unit-canonicalization.md`). Structure will follow the Currency LOOKUP_TABLE template with BIPM SI Brochure data under `rules/data/`. - ### Package Responsibilities | Package | Responsibility | @@ -813,7 +821,7 @@ tests/ │ ├── test_capability_contract.py# CapabilityContract (output_format policy, defaults) │ ├── test_capability.py # Capability ABC │ ├── test_capability_surface.py # Surface homogeneity across capabilities -│ ├── test_capability_exports.py # __init__ export completeness (9 capabilities) +│ ├── test_capability_exports.py # __init__ export completeness (10 capabilities) │ ├── test_version_stamp.py # VersionStamp │ ├── test_discovery.py # Registry register/freeze/reset │ ├── test_errors.py # Exception hierarchy @@ -830,6 +838,7 @@ tests/ │ ├── isbn/ # + test_contract, test_notation, test_data │ ├── money/ # + test_contract, test_notation, test_data, test_parsing │ ├── phone/ # + test_data +│ ├── si_unit/ # test_grammar, test_rules, test_capability, test_contract, test_notation, test_data, test_data_consistency │ └── url/ # + test_contract, test_notation, test_data, test_parsing, test_rule ├── integration/ # -m integration pipeline, ambiguity, temporal, │ │ # feature gating, format_value seam, per-capability pipelines @@ -874,7 +883,7 @@ def test_ambiguity_detection(): ... def test_canonicalize_email_success(): ... ``` -Per-capability markers are registered for `country`, `currency`, `isbn`, `money`, and `url` — run one capability's suite directly with `uv run pytest tests/capabilities/` (or `-m `). Capability dirs are lowercase (`isbn`, not `ISBN`). +Per-capability markers are registered for `country`, `currency`, `isbn`, `money`, `si_unit`, and `url` — run one capability's suite directly with `uv run pytest tests/capabilities/` (or `-m `). Capability dirs are lowercase (`isbn`, not `ISBN`). --- @@ -922,6 +931,7 @@ markers = [ "isbn: isbn capability tests", "money: money capability tests", "url: url capability tests", + "si_unit: si unit capability tests", ] testpaths = ["tests"] ``` diff --git a/README.md b/README.md index a5ba5ccc..00220fad 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ If multiple specifications disagree on the canonical value, the status is `AMBIG ## Capabilities -Paxman ships with nine built-in capabilities: +Paxman ships with ten built-in capabilities: | Capability | Domain | Grammars | Rules | Description | |------------|--------|----------|-------|-------------| @@ -62,6 +62,7 @@ Paxman ships with nine built-in capabilities: | **ISBN** | ISBNs | 2 (isbn13, isbn10) | 4 | ISO 2108, ISBN Users' Manual, ISBN Range Message | | **Money** | Money amounts | 3 (code, symbol, word) | 3 | ISO 4217, CLDR | | **Phone** | Phone numbers | 4 (E.164, tel-URI, 00-prefix, national) | 5 | ITU-T E.164, RFC 3966, NANP | +| **SI Unit** | SI unit expressions | 3 (symbol, name, compound) | 6 | BIPM SI Brochure, ISO 80000-1 | | **URL** | URLs | 1 (absolute-uri) | 1 | WHATWG URL Standard | ### Email Capability @@ -331,6 +332,31 @@ result = paxman.canonicalize("http://münchen.de", contract) # → "http://xn--mnchen-3ya.de/" ``` +### SI Unit Capability + +Recognizes SI unit expressions: symbols, names, and product/quotient compounds, canonicalizing to the canonical symbol form with BIPM SI Brochure (9th ed.) and ISO 80000-1 provenance. Identity-only: no quantities, no magnitudes, no name-compounds ("metre per second" is not recognized; "25°C" is MISSING). + +```python +from paxman.capabilities import SIUnit + +register_capability(SIUnit()) + +# Unit name resolves to its canonical symbol +contract = SIUnit.create_contract() +result = paxman.canonicalize("Kilogram", contract) +# → "kg" + +# Prefixed name resolves to the prefixed symbol +contract = SIUnit.create_contract() +result = paxman.canonicalize("megahertz", contract) +# → "MHz" + +# Compound expression canonicalizes to the symbol form +contract = SIUnit.create_contract() +result = paxman.canonicalize("m/s²", contract) +# → "m/s2" +``` + --- ## Contract Configuration @@ -399,7 +425,7 @@ result = paxman.canonicalize("2026-01-15", contract) ## Community Extensions -Paxman ships with nine built-in capabilities, but a capability is closed for modification yet open for extension: you can add recognition and validation without touching the library. Register a `Grammar` subclass and the `Rule` subclass that validates it, then opt a contract into them by naming the grammar in `extra_grammars`: +Paxman ships with ten built-in capabilities, but a capability is closed for modification yet open for extension: you can add recognition and validation without touching the library. Register a `Grammar` subclass and the `Rule` subclass that validates it, then opt a contract into them by naming the grammar in `extra_grammars`: ```python import re diff --git a/paxman/capabilities/AGENTS.md b/paxman/capabilities/AGENTS.md index 1cd221b4..f129ca54 100644 --- a/paxman/capabilities/AGENTS.md +++ b/paxman/capabilities/AGENTS.md @@ -1,7 +1,7 @@ # CAPABILITIES KNOWLEDGE BASE ## OVERVIEW -The deepest directory in the repo (133 py files): 9 shipped capability packages (Country, Currency, Date, Email, IP, ISBN, Money, Phone, URL), each an independent recognize→validate→resolve mini-system wired into the shared pipeline via `paxman.core`. Each package is self-contained: grammars recognize representations, rules assign meaning with provenance, the contract selects what runs, and `format_value()` renders the result. A 10th — SI Unit (BIPM SI Brochure) — is in development on `feature/si-unit-capability`; update counts and the export list when it lands. +The deepest directory in the repo (157 py files): 10 shipped capability packages (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SI Unit, URL), each an independent recognize→validate→resolve mini-system wired into the shared pipeline via `paxman.core`. Each package is self-contained: grammars recognize representations, rules assign meaning with provenance, the contract selects what runs, and `format_value()` renders the result. **Authoritative spec:** the root `HOW_TO_ADD_NEW_CAPABILITY.md` (62KB — read it before touching this directory). This file is the compact governance reference: intended architecture, hard rules, and known legacy exceptions. Where the two differ, HOW_TO wins. @@ -31,7 +31,7 @@ paxman/capabilities/ | Recognition | `/grammar/` (+ `grammar/data/` for lexicon key tables) | | Validation | `/rules/` (+ `rules/data/` for authority tables) | | Data tables | `rules/data/` (authority data serving rules), `grammar/data/` (keys serving grammars) — plain module-level tables, separated from logic | -| Generated data | only modules with a source snapshot + tool (ISBN range message → `tools/regenerate_isbn_range_data.py`) — edit via the snapshot, then regenerate | +| Generated data | only modules with a source snapshot + tool (ISBN range message → `tools/regenerate_isbn_range_data.py`, SIUnit prefixed units → `tools/regenerate_si_prefix_data.py`, URL IDNA UTS #46 mapping → `tools/regenerate_idna_uts46_data.py`) — edit via the snapshot, then regenerate | | Register a capability | `paxman/capabilities/__init__.py` (import + `__all__`) → `paxman/core/discovery.py` | ## INTENDED ARCHITECTURE (the unanimous surface) @@ -49,7 +49,7 @@ Every capability must conform to the same structural surface. `CapabilityContrac - **Grammar/Rule boundary is absolute**: grammars own syntax, rules own meaning. Grammar tables are key-only; authority-backed mappings live in `rules/data/` and are imported only by rules. A consistency test must cover every shipped recognition key against rule-data mappings. - **No cross-capability imports** — a capability package imports only from `paxman.core`, never from a sibling `paxman.capabilities.*` (enforced by import-linter). - **Rules never read `output_format`**, never raise (best-effort returns; unreachable branches return input unchanged), never gate on `include_*` (declared as `requires_features` instead). -- **Data files are plain tables, most maintained in place** — `rules/data/` and `grammar/data/` exist to separate data from logic, not to mark generated output. Only modules that carry a generator (source snapshot + script — currently the ISBN range message via `tools/regenerate_isbn_range_data.py`) must be edited through the snapshot and regenerated, never by hand, or they drift from their authority. Unmarked data files are edited directly. +- **Data files are plain tables, most maintained in place** — `rules/data/` and `grammar/data/` exist to separate data from logic, not to mark generated output. Only modules that carry a generator (source snapshot + script — currently the ISBN range message via `tools/regenerate_isbn_range_data.py`, the SIUnit prefixed-unit tables via `tools/regenerate_si_prefix_data.py`, and the URL IDNA UTS #46 mapping via `tools/regenerate_idna_uts46_data.py`) must be edited through the snapshot and regenerated, never by hand, or they drift from their authority. Unmarked data files are edited directly. - **No type suppression** — no `# type: ignore` / `# noqa` / `# pyright: ignore` in source; fix the root cause or use a scoped per-file-ignore in pyproject. - **Rule class names are CapWords** — the legacy `Section6_1`-style naming is scoped to `Phone/rules/*.py` (and its tests) via the N801 per-file-ignore: legacy coverage, not a pattern. - **`__init__.py` acronym aliases trip N814** — covered by the scoped per-file-ignore; don't add inline `# noqa`. @@ -65,5 +65,5 @@ Every capability must conform to the same structural surface. `CapabilityContrac - When extending an existing capability, check whether the file you're touching is a flagged legacy exception before copying its style; new code follows the intended architecture. ## NOTES -- `__init__.py` exports all nine shipped capabilities; completeness is enforced by `tests/unit/test_capability_exports.py`. The in-development SI Unit will be the tenth (update `__all__` and this count). +- `__init__.py` exports all ten shipped capabilities; completeness is enforced by `tests/unit/test_capability_exports.py`. - Root AGENTS.md is authoritative for pipeline flow, domain objects, and quality gates; this file adds capability-package structure and governance specifics. From e22029a74daf6f61e39b924e5aedc56fce61dc2a Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 15:17:12 +0800 Subject: [PATCH 17/23] docs(si_unit): correct SIUnit examples and generated-data scope --- AGENTS.md | 2 +- CONTEXT.md | 4 ++-- README.md | 2 +- paxman/capabilities/AGENTS.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8b9217b..ff587f3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,6 @@ Full pre-PR gate: `ruff check . && ruff format --check . && pyright && import-li - `paxman/capabilities/__init__.py` exports all ten shipped capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SI Unit, URL); export completeness is enforced by `tests/unit/test_capability_exports.py`. - CONTEXT.md is the domain glossary for the full shipped set (ten capabilities). It is kept in sync with the code; when adding a capability, update its Notation/table entries there too. - No `pyrightconfig.json` — pyright config is inline `[tool.pyright]` in pyproject.toml. No `.editorconfig`. -- Data modules live under `rules/data/` (Country, Currency, ISBN, Money, Phone, SI Unit, URL) and `grammar/data/` (Country, Currency, Money, SI Unit) — plain module-level tables separating data from logic, maintained in place. Only the ISBN range message, the URL IDNA UTS #46 mapping, and the SIUnit prefixed-unit tables are generated (each via its `tools/regenerate_*_data.py` script); unmarked data files are edited directly. +- Data modules live under `rules/data/` (Country, Currency, ISBN, Money, Phone, SI Unit, URL) and `grammar/data/` (Country, Currency, Money, SI Unit) — plain module-level tables separating data from logic, maintained in place. Only the ISBN range message, the URL IDNA UTS #46 mapping, and the SIUnit prefixed-unit and grammar token tables are generated (each via its `tools/regenerate_*_data.py` script); unmarked data files are edited directly. - Library only — no CLI, no `__main__.py`, no `[project.scripts]`. Version 0.2.0. - Coverage: global `fail_under = 95` + per-package 95% gates in CI. diff --git a/CONTEXT.md b/CONTEXT.md index 7d13d3bd..032cfe67 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -196,7 +196,7 @@ class SectionCode(Rule[CurrencyNotation]): return self.TABLE[notation.text] ``` -Authority-backed lookup tables live in `rules/data/` (e.g., `iso4217_list_one.py`, `cldr_currencies.py`), separated from rule logic; lexicon keys serving grammars live in `grammar/data/`. Only three data modules are generated via tools: the ISBN range message (`tools/regenerate_isbn_range_data.py`), the SIUnit prefixed-unit tables (`tools/regenerate_si_prefix_data.py`), and the URL IDNA UTS #46 mapping (`tools/regenerate_idna_uts46_data.py`) — everything else is maintained in place. +Authority-backed lookup tables live in `rules/data/` (e.g., `iso4217_list_one.py`, `cldr_currencies.py`), separated from rule logic; lexicon keys serving grammars live in `grammar/data/`. Seven data modules across three generators are produced via tools: the ISBN range message (`tools/regenerate_isbn_range_data.py`), the URL IDNA UTS #46 mapping (`tools/regenerate_idna_uts46_data.py`), and the SIUnit prefixed-unit and grammar token tables (`tools/regenerate_si_prefix_data.py`) — everything else is maintained in place. ### Parser Example ```python @@ -782,7 +782,7 @@ paxman/ │ ├── contract.py # SIUnitContract │ ├── notation.py # SIUnitNotation (text, shape) │ ├── grammar/ # symbol, name, compound_recognition - │ ├── grammar/data/ # unit_symbol_tokens, unit_name_tokens, compound_tokens + │ ├── grammar/data/ # unit_symbol_tokens, unit_name_tokens, compound_tokens (+ GENERATED via tools/regenerate_si_prefix_data.py) │ ├── rules/ # bipm_si_brochure_ed2019, iso_80000_ed2022 │ └── rules/data/ # si_base_units, si_derived_units, si_nonsi_units, si_prefixes, unit_names (+ GENERATED prefixed_units, prefixed_unit_names) └── URL/ # grammar/ (1) + rules/ (1) + rules/data/ — WHATWG URL Standard diff --git a/README.md b/README.md index 00220fad..9229ea65 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ result = paxman.canonicalize("http://münchen.de", contract) ### SI Unit Capability -Recognizes SI unit expressions: symbols, names, and product/quotient compounds, canonicalizing to the canonical symbol form with BIPM SI Brochure (9th ed.) and ISO 80000-1 provenance. Identity-only: no quantities, no magnitudes, no name-compounds ("metre per second" is not recognized; "25°C" is MISSING). +Recognizes SI unit expressions: symbols, names, and product/quotient compounds, canonicalizing to the canonical symbol form with BIPM SI Brochure (9th ed.) and ISO 80000-1 provenance. Identity-only: no quantities, no magnitudes, no name-compounds ("metre per second" does not resolve as a compound — its words are recognized separately, yielding AMBIGUOUS; "25°C" is MISSING). ```python from paxman.capabilities import SIUnit diff --git a/paxman/capabilities/AGENTS.md b/paxman/capabilities/AGENTS.md index f129ca54..87730ccc 100644 --- a/paxman/capabilities/AGENTS.md +++ b/paxman/capabilities/AGENTS.md @@ -49,7 +49,7 @@ Every capability must conform to the same structural surface. `CapabilityContrac - **Grammar/Rule boundary is absolute**: grammars own syntax, rules own meaning. Grammar tables are key-only; authority-backed mappings live in `rules/data/` and are imported only by rules. A consistency test must cover every shipped recognition key against rule-data mappings. - **No cross-capability imports** — a capability package imports only from `paxman.core`, never from a sibling `paxman.capabilities.*` (enforced by import-linter). - **Rules never read `output_format`**, never raise (best-effort returns; unreachable branches return input unchanged), never gate on `include_*` (declared as `requires_features` instead). -- **Data files are plain tables, most maintained in place** — `rules/data/` and `grammar/data/` exist to separate data from logic, not to mark generated output. Only modules that carry a generator (source snapshot + script — currently the ISBN range message via `tools/regenerate_isbn_range_data.py`, the SIUnit prefixed-unit tables via `tools/regenerate_si_prefix_data.py`, and the URL IDNA UTS #46 mapping via `tools/regenerate_idna_uts46_data.py`) must be edited through the snapshot and regenerated, never by hand, or they drift from their authority. Unmarked data files are edited directly. +- **Data files are plain tables, most maintained in place** — `rules/data/` and `grammar/data/` exist to separate data from logic, not to mark generated output. Only modules that carry a generator (source snapshot + script — currently the ISBN range message via `tools/regenerate_isbn_range_data.py`, the SIUnit prefixed-unit and grammar token tables via `tools/regenerate_si_prefix_data.py`, and the URL IDNA UTS #46 mapping via `tools/regenerate_idna_uts46_data.py`) must be edited through the snapshot and regenerated, never by hand, or they drift from their authority. Unmarked data files are edited directly. - **No type suppression** — no `# type: ignore` / `# noqa` / `# pyright: ignore` in source; fix the root cause or use a scoped per-file-ignore in pyproject. - **Rule class names are CapWords** — the legacy `Section6_1`-style naming is scoped to `Phone/rules/*.py` (and its tests) via the N801 per-file-ignore: legacy coverage, not a pattern. - **`__init__.py` acronym aliases trip N814** — covered by the scoped per-file-ignore; don't add inline `# noqa`. From e4b5478826e6291e557394320f55fdbea39854ca Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 20:13:37 +0800 Subject: [PATCH 18/23] docs: updated plan file to follow implementation --- .../plans/2026-08-09-si-units-capability.md | 279 +++++++++++++----- 1 file changed, 209 insertions(+), 70 deletions(-) diff --git a/docs/development/plans/2026-08-09-si-units-capability.md b/docs/development/plans/2026-08-09-si-units-capability.md index 833d30b0..67ae15c9 100644 --- a/docs/development/plans/2026-08-09-si-units-capability.md +++ b/docs/development/plans/2026-08-09-si-units-capability.md @@ -7,6 +7,7 @@ | **Milestone** | `docs/development/MILESTONE.md` row #23 — “SI unit” (LOOKUP_TABLE strategy) | | **Sibling template** | `paxman/capabilities/Currency/` (identifier-only LOOKUP_TABLE capability) | | **Authoritative spec** | `HOW_TO_ADD_NEW_CAPABILITY.md` (62 KB) — read before Task 1; where this plan and HOW_TO disagree, HOW_TO wins | +| **Seam rebase** | 2026-08-12 — plan updated for the grammar-extension seam now on main (PRs #19–#21: `Grammar.semantics` + `Rule.target_semantics` affinity routing per ADR-0003, optional `active_grammars` with base-`None` fallback, `extra_grammars` community opt-in) and the replay-hash removal (PR #18). The seam deltas are marked **SEAM** inline; R1/R2 engine refinements were re-verified against the current engine and still hold. | > **For agentic workers.** This plan is written to be executed by a > worker agent one task at a time. Every task is TDD: **Step 1 RED** @@ -57,6 +58,12 @@ symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. (regex shape). Grammar names must be lowercase snake_case; each file is one grammar (the `Grammar` ABC with a `name` class attr + `recognize()`); names are `symbol_recognition` / `name_recognition` / `compound_recognition`. + **SEAM:** each grammar ALSO declares `semantics` — the identity id + (`semantics = "symbol_recognition"`, etc.), mandatory at class-definition + time by `Grammar.__init_subclass__`. SIUnit has no coalesced groups + (ADR-0003): all three ids are singletons, so identity-with-name is the + sanctioned form and nothing is added to the `_COALESCED_SEMANTICS` + allowlist in `tests/unit/test_grammar_semantics_metadata.py`. - **D2 — Symbol lookarounds block separators.** The symbol grammar's lookarounds block `\w`, sign chars (`-`, `+`, U+2212), **and** the compound separators `/`, `·`, `⋅`. This is what keeps `"m/s²"`, `"N·m"`, @@ -90,8 +97,9 @@ symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. INVALID > MISSING). Quantity-glued input (`"25°C"`, `"5kg"`) is MISSING — same house rule as Currency's `"USD500"` → MISSING. - **D8 — Name resolution is a dedicated rule.** Names resolve via a - dedicated `SectionNames` rule (BIPM module, `target_grammars = - {"name_recognition"}`) against `FULL_NAME_TO_SYMBOL` = maintained + dedicated `SectionNames` rule (BIPM module, **SEAM:** `target_semantics = + frozenset({"name_recognition"})` — the affinity-routing replacement for + `target_grammars`, ADR-0003) against `FULL_NAME_TO_SYMBOL` = maintained `NAME_TO_SYMBOL` ∪ generated `PREFIXED_NAME_TO_SYMBOL` (Task 4) — the Currency `SectionNames`/`word_recognition` precedent. This is what makes the locked e2e rows `"megahertz"` → `"MHz"` and `"Kilogram"` → `"kg"` @@ -102,7 +110,7 @@ symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. `"kilokilogram"`, or any kilo-stacked junk. `"kg"` remains the official base-unit symbol and `"kilogram"` the official base-unit name. -### Engine-verified refinements (two research conflicts, locked) +### Engine-verified refinements (three research conflicts, locked) - **R1 — `"KHz"` → MISSING, not INVALID.** §7.6 of the research paper locked `"KHz"` → INVALID, but the locked symbol grammar is a token @@ -118,6 +126,19 @@ symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. a compound; the research's `"m s⁻²"` form is dropped for this reason). Do not suppress the symbol grammar or the compound grammar to force SUCCESS. +- **R3 — Semantics affinity routing (SEAM, ADR-0003).** The engine routes + each recognition only to rules whose `target_semantics` includes the + producing grammar's `semantics` (composition built per capability in + `run_capability` via `semantics_by_name = {g.name: g.semantics}`), and + `_validate_affinity` fails fast with `ContractError` if a rule declares a + semantics id no grammar claims. Because the three SIUnit grammars declare + identity semantics and the six rules target exactly those ids, routing is + a straight rename of the plan's original grammar-name routing — no + behavioral change to any locked row. This also means SIUnit's + `"name_recognition"` semantics id may coincide with Country's same-named + id: semantics groups are scoped per capability (the engine composes per + capability; `tests/unit/test_grammar_semantics_consistency.py` blesses + cross-capability id reuse, e.g. Currency/Money `code_recognition`). ### Capability surface @@ -127,7 +148,8 @@ symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. | `DEFAULT_OUTPUT_FORMAT` | `"symbol"` (ClassVar) | | `OFFERED_OUTPUT_FORMATS` | `frozenset()` (ClassVar) | | `capability_name` | `field(default="si_unit", init=False)` | -| `active_grammars` | `("symbol_recognition", "name_recognition", "compound_recognition")` | +| `active_grammars` | **SEAM:** do NOT override — base default `None` (engine runs all three shipped grammars in `get_grammars()` order; `active_grammars` is implemented only by feature-gated capabilities, HOW_TO §Implementing `active_grammars`) | +| `extra_grammars` | **SEAM:** inherited base field, default `()` — surfaced on `create_contract` (community opt-in; the Task 9 factory forwards it) | | `_extra_dict_fields` | base `{}` (no capability-specific fields — do not override) | | `format_value()` | identity (base — do not override) | @@ -369,12 +391,15 @@ class TestSIUnitContract: with pytest.raises(ContractError): SIUnitContract(output_format="name") - def test_active_grammars(self) -> None: - assert SIUnitContract().active_grammars == ( - "symbol_recognition", - "name_recognition", - "compound_recognition", - ) + def test_active_grammars_is_base_default(self) -> None: + # SEAM: no feature gating -> the contract does NOT override + # active_grammars; the base returns None and the engine runs every + # shipped grammar in get_grammars() declaration order. + assert SIUnitContract().active_grammars is None + + def test_extra_grammars_defaults_empty(self) -> None: + # SEAM: the community opt-in field is inherited from the base. + assert SIUnitContract().extra_grammars == () def test_frozen(self) -> None: with pytest.raises(Exception): @@ -407,29 +432,21 @@ class SIUnitContract(CapabilityContract): pinned_rules: Pin to specific rules (takes precedence over excluded_rules). year: Year for temporal filtering. + extra_grammars: Community grammar names (opt-in) to run alongside + the shipped grammars, in order (SEAM — inherited from base). """ DEFAULT_OUTPUT_FORMAT: ClassVar[str] = "symbol" OFFERED_OUTPUT_FORMATS: ClassVar[frozenset[str]] = frozenset() capability_name: str = field(default="si_unit", init=False) - - @property - def active_grammars(self) -> tuple[str, ...]: - """All grammars active by default. - - All three recognition grammars are always active; SI Unit has no - input-shape feature flags. - - Returns: - The three recognition grammar names. - """ - return ("symbol_recognition", "name_recognition", "compound_recognition") ``` -No `_extra_dict_fields` override (no capability-specific fields — base -`{}` applies) and no `__post_init__` (nothing to validate beyond the -base `Contract` resolution of `output_format`/`year`/pinned-excluded). +No `active_grammars` override (SEAM: the base `None` default runs all three +shipped grammars; SI Unit has no input-shape feature flags), no +`_extra_dict_fields` override (no capability-specific fields — base `{}` +applies) and no `__post_init__` (nothing to validate beyond the base +`Contract` resolution of `output_format`/`year`/pinned-excluded). **Verify:** `uv run pytest tests/capabilities/si_unit -v` and `uv run ruff check paxman/capabilities/SIUnit/ tests/capabilities/si_unit/` @@ -791,7 +808,7 @@ class TestSectionBaseUnits: def test_rule_metadata(self) -> None: assert self.rule.name == "Section 2.3.1-base-units" assert self.rule.strategy is RuleStrategy.LOOKUP_TABLE - assert self.rule.target_grammars == frozenset({"symbol_recognition"}) + assert self.rule.target_semantics == frozenset({"symbol_recognition"}) assert self.rule.requires_features == frozenset() assert self.rule.provenance.publication_year == 2019 @@ -887,7 +904,7 @@ class TestSectionNames: def test_rule_metadata(self) -> None: assert self.rule.name == "Section-names" assert self.rule.strategy is RuleStrategy.LOOKUP_TABLE - assert self.rule.target_grammars == frozenset({"name_recognition"}) + assert self.rule.target_semantics == frozenset({"name_recognition"}) assert self.rule.requires_features == frozenset() assert self.rule.provenance.publication_year == 2019 @@ -924,6 +941,13 @@ class TestSectionNames: **Step 2 GREEN** — `paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py`: +**SEAM:** `Rule.__init_subclass__` enforces all six metadata attributes +(`name`, `strategy`, `provenance`, `citation`, `target_semantics`, +`requires_features` — the last two as non-empty `frozenset[str]`) at +class-definition time: a rule missing any of them fails the module import +with a `TypeError`. Every class below declares all six; `target_semantics` +names the producing grammar's `semantics` id (identity for SIUnit). + ```python """BIPM SI Brochure rules (9th edition, 2019). @@ -972,7 +996,7 @@ class SectionBaseUnits(Rule[SIUnitNotation]): strategy = RuleStrategy.LOOKUP_TABLE provenance = PUBLICATION citation = "BIPM SI Brochure (9th ed., 2019), Table 1" - target_grammars = frozenset({"symbol_recognition"}) + target_semantics = frozenset({"symbol_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -995,7 +1019,7 @@ class SectionDerivedUnits(Rule[SIUnitNotation]): strategy = RuleStrategy.LOOKUP_TABLE provenance = PUBLICATION citation = "BIPM SI Brochure (9th ed., 2019), Tables 3–4" - target_grammars = frozenset({"symbol_recognition"}) + target_semantics = frozenset({"symbol_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -1022,7 +1046,7 @@ class SectionNonSiUnits(Rule[SIUnitNotation]): strategy = RuleStrategy.LOOKUP_TABLE provenance = PUBLICATION citation = "BIPM SI Brochure (9th ed., 2019), Tables 8–9" - target_grammars = frozenset({"symbol_recognition"}) + target_semantics = frozenset({"symbol_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -1053,7 +1077,7 @@ class SectionPrefixes(Rule[SIUnitNotation]): strategy = RuleStrategy.LOOKUP_TABLE provenance = PUBLICATION citation = "BIPM SI Brochure (9th ed., 2019), Table 5 and §3.2" - target_grammars = frozenset({"symbol_recognition"}) + target_semantics = frozenset({"symbol_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -1081,7 +1105,7 @@ class SectionNames(Rule[SIUnitNotation]): strategy = RuleStrategy.LOOKUP_TABLE provenance = PUBLICATION citation = "BIPM SI Brochure (9th ed., 2019), Tables 1, 3–4, 8–9 (unit names)" - target_grammars = frozenset({"name_recognition"}) + target_semantics = frozenset({"name_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -1131,7 +1155,7 @@ class TestSectionCompounds: def test_rule_metadata(self) -> None: assert self.rule.name == "Section 6.5-compounds" assert self.rule.strategy is RuleStrategy.PARSER - assert self.rule.target_grammars == frozenset({"compound_recognition"}) + assert self.rule.target_semantics == frozenset({"compound_recognition"}) assert self.rule.requires_features == frozenset() assert self.rule.provenance.publication_year == 2022 @@ -1232,7 +1256,7 @@ class SectionCompounds(Rule[SIUnitNotation]): strategy = RuleStrategy.PARSER provenance = PUBLICATION citation = "ISO 80000-1:2022, §6.5 (unit symbols in products and quotients)" - target_grammars = frozenset({"compound_recognition"}) + target_semantics = frozenset({"compound_recognition"}) requires_features = frozenset() def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: @@ -1335,6 +1359,11 @@ class TestSymbolRecognition: def setup_method(self) -> None: self.grammar: Grammar[SIUnitNotation] = SymbolRecognition() + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): every shipped grammar declares `semantics`; + # SIUnit grammars use identity ids (no coalesced groups). + assert self.grammar.semantics == "symbol_recognition" + @pytest.mark.parametrize( ("text", "token"), [ @@ -1384,6 +1413,10 @@ class TestNameRecognition: def setup_method(self) -> None: self.grammar: Grammar[SIUnitNotation] = NameRecognition() + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): identity semantics id; no coalesced groups. + assert self.grammar.semantics == "name_recognition" + @pytest.mark.parametrize( ("text", "name"), [ @@ -1424,6 +1457,10 @@ class TestCompoundRecognition: def setup_method(self) -> None: self.grammar: Grammar[SIUnitNotation] = CompoundRecognition() + def test_semantics_identity(self) -> None: + # SEAM (ADR-0003): identity semantics id; no coalesced groups. + assert self.grammar.semantics == "compound_recognition" + @pytest.mark.parametrize( ("text", "body"), [ @@ -1487,6 +1524,7 @@ class SymbolRecognition(Grammar[SIUnitNotation]): """Grammar: symbol_recognition — case-exact unit symbol tokens.""" name = "symbol_recognition" + semantics = "symbol_recognition" # SEAM (ADR-0003): identity id def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: """Emit one RecognitionMatch per symbol token found in text.""" @@ -1531,6 +1569,7 @@ class NameRecognition(Grammar[SIUnitNotation]): """Grammar: name_recognition — case-folded unit names.""" name = "name_recognition" + semantics = "name_recognition" # SEAM (ADR-0003): identity id def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: """Emit one RecognitionMatch per unit name found in text.""" @@ -1584,6 +1623,7 @@ class CompoundRecognition(Grammar[SIUnitNotation]): """Grammar: compound_recognition — product/quotient unit shapes.""" name = "compound_recognition" + semantics = "compound_recognition" # SEAM (ADR-0003): identity id def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: """Emit one RecognitionMatch per compound shape found in text.""" @@ -1796,6 +1836,15 @@ class TestSIUnitCapability: contract = self.capability.create_contract(excluded_rules=["Section-names"]) assert contract.excluded_rules == ("Section-names",) + def test_create_contract_extra_grammars(self) -> None: + # SEAM: the community opt-in field is forwarded by the factory + # (surface guard: default () + forwarding through create_contract). + contract = self.capability.create_contract( + extra_grammars=["dot_unit_recognition"] + ) + assert contract.extra_grammars == ("dot_unit_recognition",) + assert self.capability.create_contract().extra_grammars == () + def test_create_contract_keyword_only(self) -> None: # ContractFactory conformance: the common block is keyword-only with pytest.raises(TypeError): @@ -1894,6 +1943,7 @@ class SIUnitCapability(Capability[SIUnitNotation]): pinned_rules: Sequence[str] | None = None, year: int | None = None, output_format: str | None = None, + extra_grammars: Sequence[str] | None = None, ) -> SIUnitContract: """Factory method for creating contracts with proper defaults. @@ -1904,6 +1954,9 @@ class SIUnitCapability(Capability[SIUnitNotation]): year: Year for temporal filtering. output_format: Output format for canonical values. Optional; None/"default"/"symbol" resolve to "symbol". + extra_grammars: Community grammar names (opt-in) to run + alongside the shipped grammars, in order (SEAM — the + surface guard's common block ends with this parameter). Returns: Configured SIUnitContract instance. @@ -1913,6 +1966,7 @@ class SIUnitCapability(Capability[SIUnitNotation]): pinned_rules=tuple(pinned_rules) if pinned_rules is not None else None, year=year, output_format=output_format, + extra_grammars=tuple(extra_grammars) if extra_grammars else (), ) # format_value: NOT overridden — the canonical value IS the "symbol" @@ -1949,13 +2003,42 @@ registration lands): - `tests/unit/test_capability_exports.py`: add a `TestSIUnitCapabilityExports` class (mirror `TestCurrencyCapabilityExports` — asserts `capabilities.SIUnit` imports and `name == "si_unit"`), and extend the - count test to **ten** names: `{"Country", "Currency", "Date", "Email", - "IP", "ISBN", "Money", "Phone", "SIUnit", "URL"}`. -- `tests/unit/test_capability_surface.py`: add the SIUnit rows (capability - name/version; notation class; contract `capability_name` / - `output_format` / `active_grammars`; grammar count/names; rule - count/names; format_value surface row with an independent literal — - canonical value `"kg"`). + count test (`test_export_list_contains_nine_names` → + `test_export_list_contains_ten_names`) to **ten** names: + `{"Country", "Currency", "Date", "Email", "IP", "ISBN", "Money", + "Phone", "SIUnit", "URL"}`. +- `tests/unit/test_capability_surface.py`: add the SIUnit row to the + `_CAPABILITY_SURFACES` parametrization — + `pytest.param(SIUnitCapability, SIUnitContract, "symbol", id="si_unit")` + (with imports from `paxman.capabilities.SIUnit.capability` / + `.contract`) plus the `SIUnitNotation` import. The parametrized guard + then enforces, for SIUnit, all five surface items: `CapabilityContract` + inheritance, `ContractFactory` conformance, the keyword-only + `create_contract` common block `excluded_rules, pinned_rules, year, + output_format, extra_grammars` in that order, `output_format` + resolving `None`/`"default"`/`"symbol"` to `"symbol"`, and + `extra_grammars` defaulting to `()` on the contract while forwarding a + provided value through `create_contract`. (The guard no longer asserts + per-row `active_grammars` — the base `None` default is the convention + for non-gated capabilities.) +- `tests/unit/test_grammar_semantics_metadata.py` (SEAM): add SIUnit to + the hardcoded capability list. Its three grammars declare identity + `semantics`, so the identity-or-allowlist check passes with **no** + addition to `_COALESCED_SEMANTICS`. +- `tests/unit/test_grammar_semantics_consistency.py` (SEAM): add SIUnit + to `_SHIPPED_CAPABILITIES`. No `_PROBE_ROWS` entry is needed — all + three semantics are singletons (the probe table seeds only coalesced + groups); `test_every_grammar_semantics_claimed_by_rule_target` + auto-verifies that each of SIUnit's semantics ids is targeted by an + in-capability rule (`symbol_recognition` → the four BIPM symbol + sections, `name_recognition` → `SectionNames`, `compound_recognition` + → `SectionCompounds`). The `name_recognition` id coinciding with + Country's same-named id is fine — groups are scoped per capability + (R3). + +Both semantics guards import SIUnit through the `paxman.capabilities` +package, so their RED failure is the missing export — the same commit's +GREEN (registration) turns them green. **Step 2:** `uv run pytest tests/unit -v` → the guards fail (no `SIUnit` export yet). @@ -1994,12 +2077,13 @@ The `si_unit` pytest marker is already registered (Task 1 Step 2) — nothing to add to `pyproject.toml` markers here. **Step 4:** `uv run ruff check paxman/ tests/` → clean; `uv run pytest -tests/unit -v` → pass (guards green with registration); `uv run -import-linter lint` → clean. Commit `feat(si_unit): register SIUnit capability and extend export/surface guards`. +tests/unit -v` → pass (all four extended guards — exports, surface, +semantics metadata, semantics consistency — green with registration); `uv +run import-linter lint` → clean. Commit `feat(si_unit): register SIUnit capability and extend export/surface guards`. --- -### Task 11 — `test(si_unit): lock SIUnit pipeline semantics and replay hash` +### Task 11 — `test(si_unit): lock SIUnit pipeline semantics and determinism` **Step 1 RED** — `tests/integration/test_si_unit_pipeline.py` mirroring `tests/integration/test_currency_pipeline.py`: autouse `_clean_registry` @@ -2026,28 +2110,34 @@ case, plus: **Step 3 GREEN** — no new source needed (Tasks 3–9 already wired everything); this task proves the full `canonicalize()` path (registry → -run_capability → grammar → rule → Resolution → hash) and catches +run_capability → grammar → rule → Resolution) and catches integration regressions early (Traps §T13: the moment SIUnit joins the -registry, every existing replay-hash test must still pass). +registry, every existing integration test must still pass). -**Step 4** — baseline replay hash: extend -`tests/integration/test_default_replay_hashes.py` mirroring the existing -per-capability cases exactly (per-case `register_capability(SIUnitCapability())` -+ `canonicalize(input, year=2026)`), adding **one** SIUnit case: +**Step 4** — canonical determinism (SEAM: the replay-hash baseline suite +was removed in PR #18 — `VersionStamp.replay_hash` no longer exists and +`tests/integration/test_default_replay_hashes.py` is gone; determinism is +now locked by the "run twice, byte-identical" convention in +`tests/integration/test_pipeline.py`'s +`TestCanonicalDeterminismAndCandidateOrder`). Add **one** SIUnit row to +that parametrized class, mirroring the existing rows exactly: ```python -# canonicalize("megahertz", SIUnitCapability.create_contract(), year=2026) -# baseline hash literal: obtain by running once (see Step 5) — never -# edited to green. +pytest.param( + SIUnitCapability, + lambda: SIUnitCapability.create_contract(), + "megahertz", + id="si_unit-prefixed-name", +), ``` -**Step 5** — run `uv run pytest tests/integration/test_default_replay_hashes.py -v` -once to obtain the baseline hash; write that literal into the test; re-run -→ pass (now 10 capability cases). **Do NOT fabricate or back-solve the -literal** — the hash is the replay-safety contract. +The row registers SIUnit, runs `run_capability("megahertz", contract)` +twice, and asserts `second == first`, plus identical status / +`canonicalized_value` ("MHz") / candidate tuple. **No hash literal exists +to obtain** — determinism is asserted structurally, never by snapshot. **Verify:** `uv run pytest tests/integration -v` → pass (all capabilities). -**Commit:** `test(si_unit): lock SIUnit pipeline semantics and replay hash` +**Commit:** `test(si_unit): lock SIUnit pipeline semantics and determinism` --- @@ -2075,6 +2165,16 @@ the documented exception): # and m.raw_text == text[m.start:m.end] # half-open span invariant ``` +> **Controller amendment (2026-08-12):** the compound invariant's sample +> strategy draws from the *compoundable* lexicon — the glyph-only plane-angle +> tokens `°` `′` `″` (BIPM Table 8, present in `SYMBOL_TOKENS`) are excluded +> with a documented module constant. Verified exhaustive: the compound +> grammar's `_UNIT` composes letter-based units (`[A-Za-zµΩÅ]`, `°?` prefix +> for `°C` only), and the 5571 failing pairs are exactly those containing a +> bare `°`/`′`/`″` — no §1 locked row requires a bare-glyph compound +> (`m/°C` composes via the `°C` token itself). Every remaining +> `SYMBOL_TOKENS` pair (exhaustive) composes exactly one compound match. + **Step 2 GREEN** — extend `tests/e2e/test_canonicalize.py` (autouse `_clean_registry` fixture; `from paxman.api import canonicalize`), adding the SIUnit e2e rows from the §1 contract (the Milestone trio `"Kilogram"`, @@ -2128,7 +2228,10 @@ uv run ruff format --check paxman/ tests/ uv run pyright uv run import-linter lint uv run pytest -uv run coverage report --include="paxman/{core,capabilities,engine,api}/*" --fail-under=95 +uv run coverage report --include="paxman/core/*" --fail-under=95 +uv run coverage report --include="paxman/capabilities/*" --fail-under=95 +uv run coverage report --include="paxman/engine/*" --fail-under=95 +uv run coverage report --include="paxman/api/*" --fail-under=95 uv run coverage report --include="paxman/capabilities/SIUnit/*" --fail-under=95 ``` @@ -2151,9 +2254,13 @@ source. - Task 8 depends on Tasks 3–7 (it locks the seam between generated token tables and authority tables). - Tasks 9 → 10 depend on Task 7 (the capability needs all grammars and - rules wired before registration). + rules wired before registration); Task 10 also depends on Task 9 — its + surface row imports `SIUnitCapability`, and its semantics-guard edits + import SIUnit through the `paxman.capabilities` package, so the guard + edits and the registration land in the **same** Task 10 commit (RED: + missing export; GREEN: registration). - Tasks 11 → 12 depend on Task 10 (registration must land before the - pipeline/replay/e2e tasks can run). + pipeline/determinism/e2e tasks can run). - Task 13 is last (docs reflect the registered surface). - Each task commits atomically with the message in its header; never merge tasks' commits. @@ -2185,7 +2292,12 @@ source. tables; authority mappings live in `rules/data/` and are imported only by rules. `tests/capabilities/si_unit/test_data_consistency.py` (Task 8) must cover every shipped recognition key against the rule-data mappings - (house mandate). + (house mandate). **SEAM:** the routing seam is declared, not inferred — + every grammar carries `semantics`, every rule carries `target_semantics` + naming it; the auto-discovering purity guards + (`test_grammar_semantic_purity.py`, `test_rule_output_format_purity.py`) + need no edits but SIUnit must satisfy them (grammars never import from + `rules/`; rule modules contain no `output_format` token). - **T6. The symbol grammar must not fragment compounds.** D2 lookarounds block `/`, `·`, `⋅` — keep them. If a compound input also yields a bare symbol match, the engine's per-grammar `_dedup_spans` cannot rescue it @@ -2194,9 +2306,12 @@ source. Never hand-write `"kkg"`, `"Mkg"`, or `"kilokilogram"` anywhere — the generator excludes `"kg"` from `_prefixable_units()`; `"kilogram"` and `"kg"` stay the official base-unit name/symbol. -- **T8. Replay-hash literals: obtain by running once; never edit to - green** (root AGENTS.md anti-pattern). Do not fabricate or back-solve - the Task 11 baseline. +- **T8. Determinism is structural, not snapshot.** The replay-hash + baseline suite was removed (PR #18): there is no hash literal to obtain + or back-solve. Lock determinism the current way — the + `TestCanonicalDeterminismAndCandidateOrder` "run twice, byte-identical" + row for `"megahertz"` (Task 11 Step 4). Do not reintroduce a snapshot + literal. - **T9. Type safety / style.** No `# type: ignore` / `# noqa` / `# pyright: ignore` in `paxman/` source (tests may use `# type: ignore[misc]` for immutability checks). Rule classes CapWords. @@ -2217,9 +2332,10 @@ source. add a fallback grammar to force a specific status — R1 (`"KHz"` → MISSING) and R2 (`"m s"` → AMBIGUOUS) are locked refinements. - **T13. Registry freezing.** The moment SIUnit joins the registry - (Task 10), every existing integration/replay-hash test runs against 10 - capabilities — all baselines must stay green. Do not touch other - capabilities' replay-hash literals. + (Task 10), every existing integration test runs against 10 + capabilities — all must stay green (including the + `TestCanonicalDeterminismAndCandidateOrder` rows for the shipped nine). + Do not touch other capabilities' rows or registration code. - **T14. Registration surface.** `__init__.py` import + `__all__` together (acronym aliases are already per-file-ignored for N814); export guard count test 9 → 10; the `si_unit` marker is already registered in @@ -2227,6 +2343,24 @@ source. - **T15. Compound split patterns stay local.** The ISO rule keeps its own split regex (never imported from `grammar/data/compound_tokens.py`) — rules must not import from the grammar tree (grammar↔rules purity scan). +- **T16. Semantics metadata is mandatory (SEAM, ADR-0003).** Every + grammar class declares `semantics` (identity id for SIUnit) and every + rule class declares `target_semantics` — the old `target_grammars` + attribute no longer exists. `Grammar.__init_subclass__` and + `Rule.__init_subclass__` fail the **import** with a `TypeError` if the + metadata is missing or mistyped (e.g. `requires_features` must be + `frozenset`, `target_semantics` non-empty), so a rule missing one of the + six required attributes never reaches the pipeline. +- **T17. `active_grammars` is base-default `None` (SEAM, PR #20).** SIUnit + has no input-shape feature flags, so `SIUnitContract` must **not** + override `active_grammars` — the base `None` runs every shipped grammar + in `get_grammars()` order. Adding an override (or a static tuple) is a + convention violation caught by review, not by a guard. +- **T18. `create_contract` ends with `extra_grammars` (SEAM, PR #19).** + The surface guard's `_COMMON_BLOCK` is `excluded_rules, pinned_rules, + year, output_format, extra_grammars` in that order, and it asserts both + the default `()` and forwarding. Do not omit the parameter or reorder + the block; the contract itself inherits the field from the base. ## §5 Definition of Done @@ -2236,11 +2370,16 @@ source. test_data, test_grammar, test_rules, test_capability, test_data_consistency (7 files — **no** test_parsing: SIUnit has no quantities). +- [ ] `tests/unit` guards green with SIUnit registered: capability + exports (ten names), capability surface (`_CAPABILITY_SURFACES` + row), grammar-semantics metadata, grammar-semantics consistency + (SEAM — Tasks 10). - [ ] `tests/integration/test_si_unit_pipeline.py` green with the full §1 e2e contract (27 rows: 18 SUCCESS, 3 INVALID, 5 MISSING, 1 AMBIGUOUS; "USD" is one of the MISSING rows — not an SI token). -- [ ] Replay-hash baseline for `"megahertz"` (year=2026) locked; - `test_default_replay_hashes.py` green for all 10 capabilities. +- [ ] Canonical determinism row for `"megahertz"` in + `TestCanonicalDeterminismAndCandidateOrder` green (SEAM — Task 11; + the replay-hash baseline was removed in PR #18). - [ ] Property + e2e coverage green (Task 12). - [ ] Docs: README (10 capabilities, SI Unit section), root AGENTS.md, capabilities AGENTS.md, CONTEXT.md. From fa2fc62da7d487cc814d7be068e8778871481a0e Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 20:14:20 +0800 Subject: [PATCH 19/23] fix(si_unit): updated the docstring --- paxman/capabilities/SIUnit/capability.py | 3 ++- tests/integration/test_si_unit_pipeline.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/paxman/capabilities/SIUnit/capability.py b/paxman/capabilities/SIUnit/capability.py index 4966cc86..c28014bc 100644 --- a/paxman/capabilities/SIUnit/capability.py +++ b/paxman/capabilities/SIUnit/capability.py @@ -29,7 +29,8 @@ class SIUnitCapability(Capability[SIUnitNotation]): Canonicalizes SI unit expressions — a unit symbol, a unit name, or a product/quotient compound — to the canonical symbol form, with full provenance. Identity-only: no quantities, no magnitudes, no - name-compounds ("metre per second" is not recognized; "25°C" is + name-compounds ("metre per second" does not resolve as a compound — + its words are recognized separately, yielding AMBIGUOUS; "25°C" is MISSING). Strategy: BIPM SI Brochure (9th ed., 2019) + ISO 80000-1. """ diff --git a/tests/integration/test_si_unit_pipeline.py b/tests/integration/test_si_unit_pipeline.py index 274338cb..63bd30b8 100644 --- a/tests/integration/test_si_unit_pipeline.py +++ b/tests/integration/test_si_unit_pipeline.py @@ -23,7 +23,8 @@ class TestSIUnitPipeline: Locked semantics (plan §1 e2e contract): - identity-only: no quantities, no magnitudes, no name-compounds - ("metre per second" is not recognized). Canonical form is the unit + ("metre per second" does not resolve as a compound — its words are + recognized separately, yielding AMBIGUOUS). Canonical form is the unit symbol (D3) — names resolve to their symbol, "l" canonicalizes to "L", compound exponents render as ASCII digits; - symbols are case-exact (D6): "pa" and "Kg" are not recognized at From 12c23bfd2bb640add27ed552056904674b1ed20b Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Wed, 12 Aug 2026 22:56:38 +0800 Subject: [PATCH 20/23] fix(si_unit): address oracle review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix name grammar boundary to block digit/underscore-adjacent unit names (consistency with symbol grammar's digit boundary; identity-only) - Correct SI Brochure citation: non-SI units span Table 8 and §4.2 (not Tables 8-9); update data module docstring and loader docstring - Add metre-per-second AMBIGUOUS case to e2e + integration contracts and a dedicated candidate-lock test (R2 analogue, previously untested) - Add determinism byte-identical-output test and year-temporal-filter test - Downgrade SIUnit export surface to provisional per oracle capability-maturity guidance; document the lock and upgrade trigger in AGENTS.md and the plan All gates green: ruff, pyright (0 errors), import-linter, 2399 tests pass. Co-Authored-By: Sisyphus --- AGENTS.md | 2 +- .../plans/2026-08-09-si-units-capability.md | 8 ++- .../SIUnit/grammar/name_recognition.py | 12 ++-- .../SIUnit/rules/bipm_si_brochure_ed2019.py | 2 +- .../SIUnit/rules/data/si_nonsi_units.py | 11 ++-- tests/capabilities/si_unit/test_grammar.py | 10 ++++ tests/e2e/test_canonicalize.py | 14 +++++ tests/integration/test_si_unit_pipeline.py | 57 +++++++++++++++++++ tests/unit/test_capability_surface.py | 4 +- 9 files changed, 104 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff587f3d..cade2829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ uv run import-linter lint # layer boundaries uv run pytest # all tests uv run pytest -m unit|capability|integration|e2e # by marker (also: property, country, currency, isbn, money, url, si_unit) uv run pytest --cov=paxman --cov-report=term-missing --tb=short -q -uv run coverage report --include="paxman/{core,capabilities,engine,api}/*" --fail-under=95 +uv run coverage report --include="paxman/core/*,paxman/capabilities/*,paxman/engine/*,paxman/api/*" --fail-under=95 uv run python tools/regenerate_isbn_range_data.py # regenerate ISBN range message module uv run python tools/regenerate_si_prefix_data.py # regenerate SIUnit prefixed-unit modules uv run python tools/regenerate_idna_uts46_data.py # regenerate URL IDNA UTS #46 mapping diff --git a/docs/development/plans/2026-08-09-si-units-capability.md b/docs/development/plans/2026-08-09-si-units-capability.md index 67ae15c9..44b5e1b7 100644 --- a/docs/development/plans/2026-08-09-si-units-capability.md +++ b/docs/development/plans/2026-08-09-si-units-capability.md @@ -47,7 +47,8 @@ commit scopes are `feat(si_unit)`. | `"km/h"` | `"km/h"` | Identity-only: no quantities, no magnitudes, no name-compounds -("metre per second" is not recognized). Strategy per MILESTONE row #23: +("metre per second" does not resolve as a compound — its words are recognized +separately, yielding AMBIGUOUS). Strategy per MILESTONE row #23: **LOOKUP_TABLE (unit name/symbol/prefix lookup, case-sensitive canonical symbols)** — BIPM SI Brochure (9th edition, 2019) + ISO 80000-1:2022. @@ -1904,8 +1905,9 @@ class SIUnitCapability(Capability[SIUnitNotation]): Canonicalizes SI unit expressions — a unit symbol, a unit name, or a product/quotient compound — to the canonical symbol form, with full - provenance. Identity-only: no quantities, no magnitudes, no - name-compounds ("metre per second" is not recognized; "25°C" is + provenance. Identity-only: no quantities, no magnitudes, no + name-compounds ("metre per second" does not resolve as a compound — + its words are recognized separately, yielding AMBIGUOUS; "25°C" is MISSING). Strategy: BIPM SI Brochure (9th ed., 2019) + ISO 80000-1. """ diff --git a/paxman/capabilities/SIUnit/grammar/name_recognition.py b/paxman/capabilities/SIUnit/grammar/name_recognition.py index ed6f3f77..e7513971 100644 --- a/paxman/capabilities/SIUnit/grammar/name_recognition.py +++ b/paxman/capabilities/SIUnit/grammar/name_recognition.py @@ -14,11 +14,13 @@ from paxman.capabilities.SIUnit.notation import SIUnitNotation from paxman.core.domain import Grammar, RecognitionMatch -# Same split-boundary structure as symbol_recognition: a combined -# `(?" + _ALTERNATION + r")" + _LOOKAHEAD, re.IGNORECASE diff --git a/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py b/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py index 07f498a3..93d2ee87 100644 --- a/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py +++ b/paxman/capabilities/SIUnit/rules/bipm_si_brochure_ed2019.py @@ -2,7 +2,7 @@ The SI Brochure defines the base units (Table 1), derived units with special names (Tables 3–4), non-SI units accepted for use with the SI -(Tables 8–9), and the prefix symbols (Table 5). Each section validates +(Table 8 and §4.2), and the prefix symbols (Table 5). Each section validates the corresponding symbol shape against its authority table. Syntax-only recognition is the grammars' job; the rules resolve the canonical symbol and provide provenance. diff --git a/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py b/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py index 5ec9478d..6fa30cdc 100644 --- a/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py +++ b/paxman/capabilities/SIUnit/rules/data/si_nonsi_units.py @@ -1,9 +1,12 @@ """Non-SI units accepted for use with the SI. -From BIPM SI Brochure (9th ed., 2019), Tables 8–9 — the complete union -of both tables' symbols (′ is U+2032 PRIME, ″ is U+2033 DOUBLE PRIME, -Å is U+00C5). LITRE_WRITTEN_FORMS records both written forms of the -litre symbol. +From BIPM SI Brochure (9th ed., 2019): the non-SI units accepted for use +with the SI (Table 8) plus the additional units listed in §4.2 (Other +non-SI units) — minute, hour, day, degree (plane angle), minute/second of +arc, hectare, litre, tonne, dalton, electronvolt, unified atomic mass unit, +ångström, barn, bar, and millimetre of mercury (′ is U+2032 PRIME, ″ is +U+2033 DOUBLE PRIME, Å is U+00C5). LITRE_WRITTEN_FORMS records both +written forms of the litre symbol. Maintained authority snapshot — regenerate via the Task 4 tool after edits. """ diff --git a/tests/capabilities/si_unit/test_grammar.py b/tests/capabilities/si_unit/test_grammar.py index 32ee8275..647a5b0c 100644 --- a/tests/capabilities/si_unit/test_grammar.py +++ b/tests/capabilities/si_unit/test_grammar.py @@ -140,6 +140,16 @@ def test_recognizes(self, text: str, name: str) -> None: def test_rejects(self, text: str) -> None: assert self.grammar.recognize(text) == [] + @pytest.mark.parametrize( + "text", + ["5kilogram", "kilogram5", "_kilogram", "kilogram_"], + ) + def test_rejects_quantity_adjacent_names(self, text: str) -> None: + # The grammar boundary blocks digit/underscore-adjacent names so a + # quantity-prefixed unit name is not recognized (identity-only: no + # quantities). Mirrors the symbol grammar's digit boundary. + assert self.grammar.recognize(text) == [] + def test_multiple_spans(self) -> None: _assert_spans( "kelvin pascal", diff --git a/tests/e2e/test_canonicalize.py b/tests/e2e/test_canonicalize.py index 7a7e32d6..aa8d955a 100644 --- a/tests/e2e/test_canonicalize.py +++ b/tests/e2e/test_canonicalize.py @@ -598,6 +598,20 @@ def test_si_unit_space_separated_ambiguous(self) -> None: assert result.status == Resolution.AMBIGUOUS assert result.canonicalized_value is None + @pytest.mark.e2e + def test_si_unit_name_compound_ambiguous(self) -> None: + """R2 analogue: "metre per second" is not a compound — AMBIGUOUS. + + The name grammar recognizes "metre" and "second" independently, so the + words surface as two distinct canonical values rather than one compound. + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("metre per second", contract) + assert result.status == Resolution.AMBIGUOUS + assert result.canonicalized_value is None + assert {c.value for c in result.candidates} == {"m", "s"} + @pytest.mark.e2e def test_si_unit_foreign_token_missing(self) -> None: """Cross-capability: "USD" is not an SI token — MISSING.""" diff --git a/tests/integration/test_si_unit_pipeline.py b/tests/integration/test_si_unit_pipeline.py index 63bd30b8..65ded4eb 100644 --- a/tests/integration/test_si_unit_pipeline.py +++ b/tests/integration/test_si_unit_pipeline.py @@ -83,6 +83,8 @@ class TestSIUnitPipeline: ("USD", Resolution.MISSING, None), # not an SI token # --- AMBIGUOUS row --- ("m s", Resolution.AMBIGUOUS, None), # refinement R2 + # name-compound: words recognized separately -> AMBIGUOUS (R2 analogue) + ("metre per second", Resolution.AMBIGUOUS, None), ], ) def test_e2e_contract( @@ -172,3 +174,58 @@ def test_frozen_registry_second_call_succeeds(self) -> None: assert first.canonicalized_value == "kg" assert second.status == Resolution.SUCCESS assert second.canonicalized_value == "m" + + @pytest.mark.integration + def test_ambiguous_name_compound_metre_per_second(self) -> None: + """R2 analogue: a name-compound yields AMBIGUOUS, never SUCCESS. + + "metre per second" is not a compound — the name grammar recognizes + "metre" and "second" independently, so two distinct canonical values + ("m", "s") survive into the candidate set and the status is + AMBIGUOUS. This is the documented headline case; it must stay locked. + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("metre per second", contract) + assert result.status == Resolution.AMBIGUOUS + assert result.canonicalized_value is None + assert {c.value for c in result.candidates} == {"m", "s"} + + @pytest.mark.integration + def test_determinism_byte_identical_output(self) -> None: + """Same input + contract yields byte-identical output across calls. + + The project mandate is byte-identical canonicalization; this locks it + for an AMBIGUOUS input (also pins candidate ordering, not just the + value set) and a SUCCESS input. + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + + first = canonicalize("m s", contract) + second = canonicalize("m s", contract) + assert first.status == second.status + assert first.canonicalized_value == second.canonicalized_value + assert [c.value for c in first.candidates] == [ + c.value for c in second.candidates + ] + + third = canonicalize("Kilogram", contract) + fourth = canonicalize("Kilogram", contract) + assert third.status == fourth.status + assert third.canonicalized_value == fourth.canonicalized_value + + @pytest.mark.integration + def test_temporal_filter_year_excludes_base_units(self) -> None: + """The `year` common param gates rules by publication year. + + All BIPM SI Brochure rules carry publication_year 2019, so a contract + with year=2018 disables them: "kg" is still recognized by the grammar + but no rule validates it -> INVALID (not MISSING, not SUCCESS). + """ + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(year=2018) + result = canonicalize("kg", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + assert result.candidates == () diff --git a/tests/unit/test_capability_surface.py b/tests/unit/test_capability_surface.py index 13fd889e..225a3bc2 100644 --- a/tests/unit/test_capability_surface.py +++ b/tests/unit/test_capability_surface.py @@ -1,8 +1,8 @@ """Guard tests for the unanimous capability contract & rule surface. These tests lock the homogeneity mandate so it cannot regress: every one of -the nine built-in capabilities (Country, Currency, Date, Email, IP, ISBN, -Money, Phone, URL) must +the ten built-in capabilities (Country, Currency, Date, Email, IP, ISBN, +Money, Phone, SI Unit, URL) must - inherit :class:`CapabilityContract` (item 1), - satisfy the :class:`ContractFactory` protocol (item 2), From 6b6ffafba98e7105225e1e74fa2ef3f9a801b794 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Thu, 13 Aug 2026 02:28:01 +0800 Subject: [PATCH 21/23] feat(si_unit): add CLI tools for SI Unit canonicalization and output --- tests/integration/test_si_unit_pipeline.py | 14 +++--- tools/canonicalize_si_unit.py | 53 ++++++++++++++++++++++ tools/regenerate_si_prefix_data.py | 2 +- tools/si_unit_canonicalize.py | 32 +++++++++++++ 4 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 tools/canonicalize_si_unit.py create mode 100644 tools/si_unit_canonicalize.py diff --git a/tests/integration/test_si_unit_pipeline.py b/tests/integration/test_si_unit_pipeline.py index 65ded4eb..80b79dc3 100644 --- a/tests/integration/test_si_unit_pipeline.py +++ b/tests/integration/test_si_unit_pipeline.py @@ -192,12 +192,14 @@ def test_ambiguous_name_compound_metre_per_second(self) -> None: assert {c.value for c in result.candidates} == {"m", "s"} @pytest.mark.integration - def test_determinism_byte_identical_output(self) -> None: - """Same input + contract yields byte-identical output across calls. - - The project mandate is byte-identical canonicalization; this locks it - for an AMBIGUOUS input (also pins candidate ordering, not just the - value set) and a SUCCESS input. + def test_determinism_reproducible_output(self) -> None: + """Same input + contract yields reproducible output across calls. + + Determinism is a general correctness property (not a byte-identical + mandate — the replay-hash baseline was removed): an identical input + and contract must resolve to the same status, canonical value, and + candidate order on every call. This pins it for an AMBIGUOUS input + and a SUCCESS input. """ register_capability(SIUnitCapability()) contract = SIUnitCapability.create_contract() diff --git a/tools/canonicalize_si_unit.py b/tools/canonicalize_si_unit.py new file mode 100644 index 00000000..b16c943e --- /dev/null +++ b/tools/canonicalize_si_unit.py @@ -0,0 +1,53 @@ +"""Canonicalize a single SI Unit input with paxman. + +Quick CLI helper for trying SI Unit expressions by hand. Edit INPUT below +(or pass it as an argument) and run: + + uv run python tools/canonicalize_si_unit.py + uv run python tools/canonicalize_si_unit.py "N·m" + uv run python tools/canonicalize_si_unit.py "metre per second" --year 2018 + +Prints status, canonical value, and the surviving candidates with provenance. +""" + +from __future__ import annotations + +import argparse + +from paxman.api import canonicalize +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.core.discovery import register_capability, reset_registry + +# >>> EDIT ME: the default input used when no argument is given on the CLI <<< +INPUT = "kg·m²·s⁻²" +# Common contract params you can toggle (see SIUnitCapability.create_contract): +# year: int | None - temporal filter (only rules with publication_year <= year run) +YEAR: int | None = None + + +def main() -> None: + parser = argparse.ArgumentParser(description="Canonicalize an SI Unit input.") + parser.add_argument("input", nargs="?", default=INPUT, help="the SI unit text to canonicalize") + parser.add_argument("--year", type=int, default=YEAR, help="temporal filter year") + args = parser.parse_args() + + reset_registry() + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(year=args.year) + + result = canonicalize(args.input, contract) + + print(f"input : {args.input!r}") + print(f"status : {result.status.name}") + print(f"canonical_value : {result.canonicalized_value!r}") + print(f"candidates ({len(result.candidates)}) :") + for cand in result.candidates: + provs = ", ".join( + f"{p.authority}/{p.specification_name} ({p.version}, {p.publication_year})" + for p in cand.provenance + ) + print(f" - {cand.value!r} <- {provs or 'no provenance'}") + + +if __name__ == "__main__": + main() diff --git a/tools/regenerate_si_prefix_data.py b/tools/regenerate_si_prefix_data.py index 048d6ef6..c82026da 100644 --- a/tools/regenerate_si_prefix_data.py +++ b/tools/regenerate_si_prefix_data.py @@ -2,7 +2,7 @@ Computes the prefixed-unit set and the grammar token tables from the maintained authority tables in paxman/capabilities/SIUnit/rules/data/. -Deterministic: same tables -> byte-identical output. Run after editing +Deterministic: same tables -> identical generated output. Run after editing any maintained SIUnit data module: uv run python tools/regenerate_si_prefix_data.py diff --git a/tools/si_unit_canonicalize.py b/tools/si_unit_canonicalize.py new file mode 100644 index 00000000..8f2c2a78 --- /dev/null +++ b/tools/si_unit_canonicalize.py @@ -0,0 +1,32 @@ +"""Canonicalize an SI Unit input and print only the result. + +Usage: + uv run python tools/si_unit_canonicalize.py "kg" + uv run python tools/si_unit_canonicalize.py "metre per second" + +Output: the canonical value on SUCCESS, otherwise one word: +INVALID / AMBIGUOUS / MISSING. +""" + +from __future__ import annotations + +import sys + +from paxman.api import canonicalize +from paxman.capabilities.SIUnit.capability import SIUnitCapability +from paxman.core.discovery import register_capability, reset_registry + +if len(sys.argv) < 2: + print("usage: si_unit_canonicalize.py ", file=sys.stderr) + sys.exit(2) + +reset_registry() +register_capability(SIUnitCapability()) +contract = SIUnitCapability.create_contract() + +result = canonicalize(sys.argv[1], contract) + +if result.status.name == "SUCCESS": + print(result.canonicalized_value) +else: + print(result.status.name) From a796e83788190215656cea1a1f3c86164b4c80f7 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 14:01:02 +0800 Subject: [PATCH 22/23] feat(si_unit): multi-solidus guard and split-prefix handling (ADR-0005, ADR-0006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject compounds with >1 top-level solidus by default per ISO 80000-1 §6.6.2; opt out via allow_multi_solidus. Resolve parenthesized denominators (kg/(m·s²) -> kg/(m·s2)) instead of silently dropping the numerator. - Capture word-prefix splits ("kilo gram") as one span and merge to the canonical symbol when allow_split_word_prefixes is set; capture prefix-only symbol splits ("k g") as one span and reject them always, preserving valid two-unit expressions (m s stays AMBIGUOUS, m m -> m not mm). Dual-role prefix symbols m/h/a/d stay units. ADRs: - docs/adr/0005-si-unit-multi-solidus-and-parens.md - docs/adr/0006-si-unit-split-prefixes.md --- README.md | 17 ++ .../0005-si-unit-multi-solidus-and-parens.md | 90 ++++++++++ docs/adr/0006-si-unit-split-prefixes.md | 94 +++++++++++ paxman/capabilities/SIUnit/capability.py | 19 ++- paxman/capabilities/SIUnit/contract.py | 3 + .../SIUnit/grammar/compound_recognition.py | 7 +- .../SIUnit/grammar/data/prefix_tokens.py | 71 ++++++++ .../SIUnit/grammar/name_recognition.py | 51 ++++-- .../SIUnit/grammar/symbol_recognition.py | 60 +++++-- paxman/capabilities/SIUnit/notation.py | 13 +- .../SIUnit/rules/iso_80000_ed2022.py | 157 +++++++++++++++--- .../SIUnit/rules/split_prefixes.py | 71 ++++++++ tests/capabilities/si_unit/test_capability.py | 153 ++++++++++++++++- tests/capabilities/si_unit/test_grammar.py | 38 +++++ tests/capabilities/si_unit/test_rules.py | 45 +++++ 15 files changed, 838 insertions(+), 51 deletions(-) create mode 100644 docs/adr/0005-si-unit-multi-solidus-and-parens.md create mode 100644 docs/adr/0006-si-unit-split-prefixes.md create mode 100644 paxman/capabilities/SIUnit/grammar/data/prefix_tokens.py create mode 100644 paxman/capabilities/SIUnit/rules/split_prefixes.py diff --git a/README.md b/README.md index 9229ea65..a5c8cdde 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,21 @@ result = paxman.canonicalize("megahertz", contract) contract = SIUnit.create_contract() result = paxman.canonicalize("m/s²", contract) # → "m/s2" + +# Spoken word-prefix form, merged only when opted in +contract = SIUnit.create_contract(allow_split_word_prefixes=True) +result = paxman.canonicalize("kilo gram", contract) +# → "kg" + +# Symbol-prefix spacing is always rejected (no flag) +contract = SIUnit.create_contract() +result = paxman.canonicalize("k g", contract) +# → Status: INVALID + +# Multi-solidus preserved only when opted in +contract = SIUnit.create_contract(allow_multi_solidus=True) +result = paxman.canonicalize("kg/m/s", contract) +# → "kg/m/s" ``` --- @@ -391,6 +406,8 @@ Every capability provides a `create_contract()` factory method with common and c | Money | `output_format` | `str` | Output format (`"code_amount"` default, `"compact"`) | | Phone | `default_country` | `str` | ISO 3166-1 alpha-2 country code to resolve national numbers (e.g., `"US"`) | | Phone | `output_format` | `str` | Output format (`"e164"` default, `"rfc3966"`, `"national"`) | +| SIUnit | `allow_split_word_prefixes` | `bool` | Merge a word prefix split from its unit by whitespace (e.g. `"kilo gram"` → `"kg"`) when True; default False rejects the spoken form (→ INVALID) | +| SIUnit | `allow_multi_solidus` | `bool` | Preserve the legacy accept-multi-solidus behavior (e.g. `"kg/m/s"`) when True; default False rejects more than one top-level solidus (→ INVALID) per ISO 80000-1 §6.6.2 | ### Rule Pinning and Exclusion diff --git a/docs/adr/0005-si-unit-multi-solidus-and-parens.md b/docs/adr/0005-si-unit-multi-solidus-and-parens.md new file mode 100644 index 00000000..f07c8ee6 --- /dev/null +++ b/docs/adr/0005-si-unit-multi-solidus-and-parens.md @@ -0,0 +1,90 @@ +# ADR-0005: SI Unit Multi-Solidus Rejection and Parenthesized-Denominator Resolution + +## Status + +Accepted + +## Context + +The SI Unit capability recognizes product/quotient compounds (`UNIT (SEP UNIT){1,3}`) +and validates them against the full SI symbol lexicon (ISO 80000-1 §6.5). Two gaps +against ISO 80000-1 §6.6.2 were identified: + +1. **Multi-solidus ambiguity.** The grammar accepted `kg/m/s` (more than one + solidus at the top level, outside any parentheses) and resolved it to + `kg/m/s`. ISO 80000-1 §6.6.2 states: *"a solidus shall not be followed by a + multiplication sign or a division sign on the same line unless parentheses + are inserted to avoid any ambiguity."* The legacy accept-multi-solidus + behavior is useful for best-effort resolution but must not be the default + for a canonicalization authority. + +2. **Silent token dropping in parenthesized denominators.** `kg/(m·s²)` was + recognized only as the inner compound `m·s²` (the symbol grammar's + separator-aware boundaries block `kg`/`m`/`s`, and the compound grammar did + not treat a parenthesized group as a single factor), so the pipeline returned + `m·s2` — dropping the `kg/`. ISO 80000-1 §6.6.2 prescribes parentheses as + *the* disambiguation, so `kg/(m·s²)` MUST resolve (not reject), capturing the + whole expression as one compound span and preserving the parentheses. + +## Decision + +### Multi-solidus guard (default reject, opt-out) + +- Add a contract field `allow_multi_solidus: bool = False` to `SIUnitContract`, + plumbed through `SIUnitCapability.create_contract(allow_multi_solidus=...)`. +- `SectionCompounds.matches()` counts top-level `/` characters (outside + parentheses). When the count exceeds 1 **and** `contract.allow_multi_solidus` + is `False`, the compound is rejected (→ `INVALID`). When `True`, the legacy + accept-multi-solidus behavior is preserved. +- This is a contract configuration flag, not a grammar/rule feature gate: it is + read inside `matches()` via an `isinstance(contract, SIUnitContract)` narrowing + (rules never read `output_format` or `include_*` flags, and never import from + the grammar tree). + +### Parenthesized-denominator resolution + +- Extend the compound grammar's `_COMPOUND_RE` with a `FACTOR` that is either a + bare `_UNIT` or `\(` + `_UNIT` + (`_SEP` + `_UNIT`)* + `\)`. A parenthesized + factor is a single compound factor, so `kg/(m·s²)` is recognized as one span + `[0, 9]`. Unbalanced parentheses (`kg/(m·s²`) do not match as a compound + (the regex requires a closing `)`) and fall back to the symbol grammar — an + acceptable, documented degradation. +- `SectionCompounds.matches()` validates each top-level factor against the full + symbol lexicon; a parenthesized factor's inner content must itself be a valid + compound (each inner part in the lexicon). Any invalid factor (including inside + parentheses) rejects the compound. +- `SectionCompounds.normalize()` renders the canonical form preserving structure + and parentheses: it walks the text tracking parenthesis depth, emits top-level + separators verbatim, and recursively canonicalizes each factor (ASCII + exponents via `_SUPERSCRIPT_TRANSLATE`/`_EXPONENT_SUFFIX`, `·`/`/` preserved, + `l` → `L`). Thus `kg/(m·s²)` → `kg/(m·s2)`. + +### Purity preserved + +- Split patterns (`_SEPARATORS = "/·⋅"`) are kept **local** to the rule file; the + rule does not import from `paxman/capabilities/SIUnit/grammar/...` (the + grammar↔rules purity scan still passes). The rule imports its own + `SIUnitContract` (same capability package, allowed by the import-linter layer + contract) only for the `isinstance` narrowing. + +## Consequences + +- `kg/m/s` is now `INVALID` under the default contract (was `SUCCESS kg/m/s`). +- `kg/m/s` with `allow_multi_solidus=True` remains `SUCCESS kg/m/s`. +- `kg/(m·s²)` is now `SUCCESS kg/(m·s2)` (was `SUCCESS m·s2`, dropping `kg/`). +- Valid single units and valid non-parenthesized compounds (`kg·m/s²` → + `kg·m/s2`, `m/s`, `N·m`, `g/cm³`, names) are unchanged. +- No other capability is affected. + +## Changed files + +- `paxman/capabilities/SIUnit/grammar/compound_recognition.py` — `_FACTOR` + + extended `_COMPOUND_RE`. +- `paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py` — multi-solidus guard, + parenthesized-factor validation, recursive `normalize()`; local `_SEPARATORS` + and helpers `_count_top_level_slash`, `_top_level_factors`, `_valid_factor`, + `_canonical_compound`, `_canonical_factor`, `_symbol_part`, `_canonical_group`. +- `paxman/capabilities/SIUnit/contract.py` — `allow_multi_solidus` field. +- `paxman/capabilities/SIUnit/capability.py` — `create_contract` plumbing. +- `tests/capabilities/si_unit/test_rules.py`, + `tests/capabilities/si_unit/test_capability.py` — RED→GREEN coverage. diff --git a/docs/adr/0006-si-unit-split-prefixes.md b/docs/adr/0006-si-unit-split-prefixes.md new file mode 100644 index 00000000..d6a44741 --- /dev/null +++ b/docs/adr/0006-si-unit-split-prefixes.md @@ -0,0 +1,94 @@ +# ADR-0006: SI Unit Split-Prefix Handling (Word Merge + Symbol Reject) + +## Status + +Accepted + +## Context + +SI prefixes are written attached to their unit ("kilogram", "kg"); a space +between a prefix and its unit is not standard SI (BIPM SI Brochure 9th ed. +§3.2). Natural/spoken input inserts whitespace in two distinct shapes, and a +naive "reject all spaced prefixes" rule is wrong because it also rejects valid +SI expressions: + +1. **Word prefix split** ("kilo gram") — the prefix is a full word. A common + natural-language form. Best-effort resolution can merge it to the canonical + attached prefixed symbol ("kg"). +2. **Symbol prefix split** ("k g") — the prefix is a symbol. ISO is unambiguous + that a prefix symbol must bind tightly with no space, and collapsing it would + corrupt dimensionality (e.g. "m m" → "mm" = 10⁻³ m, catastrophic). So + symbol-prefix spacing must be rejected. + +A blanket "any prefix + space = split" rule wrongly rejected `m s` (the valid +SI expression "metre second") and `m m` (which would collapse to "mm"). The +design must reject broken spaced prefixes *without* breaking valid SI +space-separated expressions. + +## Decision + +### Word prefix: opt-in merge (`allow_split_word_prefixes`) + +- Add `allow_split_word_prefixes: bool = False` to `SIUnitContract`, plumbed + through `SIUnitCapability.create_contract(allow_split_word_prefixes=...)`. +- The **name grammar** captures `"kilo gram"` as ONE span with shape + `split_word_prefix`, subsuming the trailing unit so it is never emitted as a + competing candidate. +- `SectionSplitWordPrefixes` (`rules/split_prefixes.py`) declares + `requires_features = frozenset({"allow_split_word_prefixes"})`, so when the + flag is off the engine drops the rule (recognition stays unvalidated → + `INVALID`). When on, it merges ("kilo gram" → "kg") via the maintained + `FULL_NAME_TO_SYMBOL` lookup (`NAME_TO_SYMBOL | PREFIXED_NAME_TO_SYMBOL`). +- `target_semantics = frozenset({"name_recognition"})`. + +### Symbol prefix: prefix-ONLY rejected, dual-role stays units + +- The **symbol grammar** captures a **prefix-ONLY** symbol split + (`"k g"`, `"da m"`, `"µ g"`) as ONE span, shape `split_symbol_prefix`, + subsuming the trailing unit → `INVALID` (the inner unit can never resolve to a + wrong candidate). +- Four SI prefix symbols are also unit symbols — `m` (metre/milli), `h` + (hour/hecto), `a` (annum/atto), `d` (day/deci). A spaced pair led by one of + these is ambiguous between a broken prefix and a valid two-unit expression, so + it stays two units: + - `"m s"` → valid SI "metre second" → `AMBIGUOUS`. + - `"m m"` → resolves to `"m"` (metre), **never** "mm" (10⁻³ m). +- The set `DUAL_ROLE_PREFIX_SYMBOLS = {"a", "d", "h", "m"}` is subtracted from + the prefix set; the generated `SYMBOL_TOKENS` table leaks every prefix symbol + as a standalone token, so prefix-only cannot be derived by set difference + against it (the four dual-role symbols are subtracted explicitly instead). + +### Purity preserved + +- Grammars emit the split shapes; rules never import grammar token tables. The + word-merge rule reads only the maintained name→symbol authority tables. +- `split_symbol_prefix` is handled entirely by grammar subsumption (no rule); + the inner unit is consumed, so symbol-prefix spacing is rejected implicitly. + +## Consequences + +- `"kilo gram"` → `INVALID` by default; `"kg"` when + `allow_split_word_prefixes=True`. +- `"mega hertz"` → `"MHz"` when allowed. +- `"k g"` → `INVALID` always (no flag; symbol prefixes must bind tightly). +- `"m s"` → `AMBIGUOUS` (valid SI metre second — not rejected). +- `"m m"` → `SUCCESS "m"` (metre), never "mm" (dimensionality preserved). +- No other capability is affected. + +## Changed files + +- `paxman/capabilities/SIUnit/notation.py` — `split_word_prefix` / + `split_symbol_prefix` shapes. +- `paxman/capabilities/SIUnit/grammar/data/prefix_tokens.py` — 24 SI prefix + words + symbols (longest-first). +- `paxman/capabilities/SIUnit/grammar/name_recognition.py` — `split_word_prefix` + capture via prefix+space alternative. +- `paxman/capabilities/SIUnit/grammar/symbol_recognition.py` — + `split_symbol_prefix` capture (prefix-only). +- `paxman/capabilities/SIUnit/contract.py` — `allow_split_word_prefixes` field. +- `paxman/capabilities/SIUnit/capability.py` — `create_contract` plumbing + rule + registration. +- `paxman/capabilities/SIUnit/rules/split_prefixes.py` — + `SectionSplitWordPrefixes`. +- `tests/capabilities/si_unit/test_grammar.py`, + `tests/capabilities/si_unit/test_capability.py` — RED→GREEN coverage. diff --git a/paxman/capabilities/SIUnit/capability.py b/paxman/capabilities/SIUnit/capability.py index c28014bc..c3fbb330 100644 --- a/paxman/capabilities/SIUnit/capability.py +++ b/paxman/capabilities/SIUnit/capability.py @@ -17,6 +17,7 @@ SectionPrefixes, ) from paxman.capabilities.SIUnit.rules.iso_80000_ed2022 import SectionCompounds +from paxman.capabilities.SIUnit.rules.split_prefixes import SectionSplitWordPrefixes from paxman.core.capability import Capability from paxman.core.domain import Grammar, Rule @@ -49,8 +50,9 @@ def get_rules(self) -> list[Rule[SIUnitNotation]]: """Return all validation rule instances. Returns: - List of 6 rules: 5 BIPM sections (base, derived, non-SI, - prefixes, names) and 1 ISO 80000-1 compound section. + List of 7 rules: 5 BIPM sections (base, derived, non-SI, + prefixes, names), 1 ISO 80000-1 compound section, and 1 + split-word-prefix rescue rule (opt-in via the contract flag). """ return [ SectionBaseUnits(), @@ -59,6 +61,7 @@ def get_rules(self) -> list[Rule[SIUnitNotation]]: SectionPrefixes(), SectionNames(), SectionCompounds(), + SectionSplitWordPrefixes(), ] @staticmethod @@ -69,6 +72,8 @@ def create_contract( year: int | None = None, output_format: str | None = None, extra_grammars: Sequence[str] | None = None, + allow_multi_solidus: bool = False, + allow_split_word_prefixes: bool = False, ) -> SIUnitContract: """Factory method for creating contracts with proper defaults. @@ -82,6 +87,14 @@ def create_contract( extra_grammars: Community grammar names (opt-in) to run alongside the shipped grammars, in order (SEAM — the surface guard's common block ends with this parameter). + allow_multi_solidus: When True, preserve the legacy behavior of + accepting compounds with more than one top-level solidus + (e.g. "kg/m/s"). Defaults to False, which rejects such + compounds per ISO 80000-1 §6.6.2. + allow_split_word_prefixes: When True, merge a word prefix split + from its unit by whitespace (e.g. "kilo gram" -> "kg"). + Defaults to False, which rejects such input (a space + between a prefix word and unit is not standard SI). Returns: Configured SIUnitContract instance. @@ -92,6 +105,8 @@ def create_contract( year=year, output_format=output_format, extra_grammars=tuple(extra_grammars) if extra_grammars else (), + allow_multi_solidus=allow_multi_solidus, + allow_split_word_prefixes=allow_split_word_prefixes, ) # format_value: NOT overridden — the canonical value IS the "symbol" diff --git a/paxman/capabilities/SIUnit/contract.py b/paxman/capabilities/SIUnit/contract.py index acdebb07..df90d9a7 100644 --- a/paxman/capabilities/SIUnit/contract.py +++ b/paxman/capabilities/SIUnit/contract.py @@ -29,3 +29,6 @@ class SIUnitContract(CapabilityContract): OFFERED_OUTPUT_FORMATS: ClassVar[frozenset[str]] = frozenset() capability_name: str = field(default="si_unit", init=False) + + allow_multi_solidus: bool = False + allow_split_word_prefixes: bool = False diff --git a/paxman/capabilities/SIUnit/grammar/compound_recognition.py b/paxman/capabilities/SIUnit/grammar/compound_recognition.py index ed9af81d..cba8d7f5 100644 --- a/paxman/capabilities/SIUnit/grammar/compound_recognition.py +++ b/paxman/capabilities/SIUnit/grammar/compound_recognition.py @@ -25,8 +25,13 @@ _EXPONENT = rf"[{EXPONENT_CHARACTERS}]*" _UNIT = rf"(?:°?[A-Za-zµΩÅ][A-Za-zµΩÅ0-9]*{_EXPONENT})" _SEP = f"[{COMPOUND_SEPARATORS}]" +# A factor is either a bare unit or a parenthesized group of 1–4 units +# joined by separators. ISO 80000-1 §6.6.2 prescribes parentheses as the +# disambiguation for a solidus followed by another separator, so a +# parenthesized denominator is a single compound factor (e.g. "(m·s²)"). +_FACTOR = rf"(?:{_UNIT}|\({_UNIT}(?:{_SEP}{_UNIT}){{0,3}}\))" _COMPOUND_RE = re.compile( - rf"(?{_UNIT}(?:{_SEP}{_UNIT}){{1,3}})(?![\w\-+\u2212])" + rf"(?{_FACTOR}(?:{_SEP}{_FACTOR}){{1,3}})(?![\w\-+\u2212])" ) diff --git a/paxman/capabilities/SIUnit/grammar/data/prefix_tokens.py b/paxman/capabilities/SIUnit/grammar/data/prefix_tokens.py new file mode 100644 index 00000000..092cef18 --- /dev/null +++ b/paxman/capabilities/SIUnit/grammar/data/prefix_tokens.py @@ -0,0 +1,71 @@ +"""SI prefix tokens for split-prefix recognition (grammar-only key table). + +Mirrors the 24 SI prefixes from BIPM SI Brochure (9th ed., 2019) Table 5 / +ISO 80000-1. These are recognition keys only — no canonical mapping (that +lives in ``rules/data``). Order is longest-first so multi-character prefixes +(``da``) win over their single-character overlaps (``d``). + +The grammars use these to recognize a prefix split across whitespace from its +unit (``kilo gram``, ``k g``) as a single span, so the trailing unit is never +emitted as a competing candidate. +""" + +from __future__ import annotations + +# Word forms of the 24 SI prefixes. "deca"/"deka" are both kept (international +# and US spellings); they do not collide (they diverge before any overlap). +PREFIX_WORD_TOKENS: tuple[str, ...] = ( + "yotta", + "zetta", + "exa", + "peta", + "tera", + "giga", + "mega", + "kilo", + "hecto", + "deca", + "deka", + "deci", + "centi", + "milli", + "micro", + "nano", + "pico", + "femto", + "atto", + "zepto", + "yocto", + "ronna", + "quetta", + "ronto", + "quecto", +) + +# Symbol forms, longest-first (``da`` before ``d``). +PREFIX_SYMBOL_TOKENS: tuple[str, ...] = ( + "da", + "Y", + "Z", + "E", + "P", + "T", + "G", + "M", + "k", + "h", + "d", + "c", + "m", + "µ", + "n", + "p", + "f", + "a", + "z", + "y", + "r", + "q", + "R", + "Q", +) diff --git a/paxman/capabilities/SIUnit/grammar/name_recognition.py b/paxman/capabilities/SIUnit/grammar/name_recognition.py index e7513971..d95ac52d 100644 --- a/paxman/capabilities/SIUnit/grammar/name_recognition.py +++ b/paxman/capabilities/SIUnit/grammar/name_recognition.py @@ -10,10 +10,14 @@ import re +from paxman.capabilities.SIUnit.grammar.data.prefix_tokens import PREFIX_WORD_TOKENS from paxman.capabilities.SIUnit.grammar.data.unit_name_tokens import NAME_TOKENS from paxman.capabilities.SIUnit.notation import SIUnitNotation from paxman.core.domain import Grammar, RecognitionMatch +# Prefix words for O(1) split detection (see recognize). +PREFIX_WORDS = frozenset(PREFIX_WORD_TOKENS) + # Same split-boundary structure as symbol_recognition: a lookbehind guards # the left edge and a lookahead the right, so a token never merges with an # adjacent word/separator/sign. Digits and underscore are blocked too (via @@ -21,9 +25,23 @@ # recognized — consistent with the symbol grammar's digit boundary. _LOOKBEHIND = r"(?" + _ALTERNATION + r")" + _LOOKAHEAD, re.IGNORECASE + _LOOKBEHIND + r"(?P" + _NAME_BODY + r")" + _LOOKAHEAD, re.IGNORECASE ) @@ -34,13 +52,24 @@ class NameRecognition(Grammar[SIUnitNotation]): semantics = "name_recognition" # SEAM (ADR-0003): identity id def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: - """Emit one RecognitionMatch per unit name found in text.""" - return [ - RecognitionMatch( - raw_text=text[m.start() : m.end()], - start=m.start(), - end=m.end(), - notation=SIUnitNotation(text=m.group("name").lower(), shape="name"), + """Emit one RecognitionMatch per unit name (or split prefix) found.""" + matches: list[RecognitionMatch[SIUnitNotation]] = [] + for m in _NAME_RE.finditer(text): + token = m.group("tok").lower() + parts = token.split() + # A split prefix is a known prefix word followed by a space and a + # unit name (e.g. "kilo gram"); a multi-word unit name like + # "degree celsius" is NOT a split (its first word is not a prefix). + if len(parts) >= 2 and parts[0] in PREFIX_WORDS: + shape = "split_word_prefix" + else: + shape = "name" + matches.append( + RecognitionMatch( + raw_text=text[m.start() : m.end()], + start=m.start(), + end=m.end(), + notation=SIUnitNotation(text=token, shape=shape), + ) ) - for m in _NAME_RE.finditer(text) - ] + return matches diff --git a/paxman/capabilities/SIUnit/grammar/symbol_recognition.py b/paxman/capabilities/SIUnit/grammar/symbol_recognition.py index e71cca04..ec902ef3 100644 --- a/paxman/capabilities/SIUnit/grammar/symbol_recognition.py +++ b/paxman/capabilities/SIUnit/grammar/symbol_recognition.py @@ -11,10 +11,23 @@ import re +from paxman.capabilities.SIUnit.grammar.data.prefix_tokens import PREFIX_SYMBOL_TOKENS from paxman.capabilities.SIUnit.grammar.data.unit_symbol_tokens import SYMBOL_TOKENS from paxman.capabilities.SIUnit.notation import SIUnitNotation from paxman.core.domain import Grammar, RecognitionMatch +# Prefix symbols that coincide with a standalone unit symbol (a fixed SI +# fact): "m" (milli + metre), "h" (hecto + hour), "a" (atto + annum/are), +# "d" (deci + day). A spaced pair led by one of these is ambiguous between a +# broken prefix and a valid two-unit expression (e.g. "m s" = metre second), +# so it stays two units — never a rejectable split. +DUAL_ROLE_PREFIX_SYMBOLS = frozenset({"a", "d", "h", "m"}) +# Prefix-ONLY symbols (e.g. "k", "da", "µ") for O(1) split detection. The +# generated SYMBOL_TOKENS table leaks every prefix symbol as a standalone +# token, so prefix-only cannot be derived by set difference against it; the +# four dual-role symbols above are subtracted explicitly instead. +PREFIX_ONLY_SYMBOLS = frozenset(PREFIX_SYMBOL_TOKENS) - DUAL_ROLE_PREFIX_SYMBOLS + # Word chars, signs, and the compound separators block a token: they # never merge into a symbol and never split a compound (D2). The degree # sign is in the block set too, so "25°C" cannot fall back to a bare "C" @@ -24,8 +37,21 @@ # reject the match before the token is even consumed. _LOOKBEHIND = r"(?" + _ALTERNATION + r")" + _LOOKAHEAD) +_SYMBOL_ALT = "|".join(re.escape(t) for t in SYMBOL_TOKENS) +_PREFIX_ONLY_SYMBOL_ALT = "|".join(re.escape(t) for t in sorted(PREFIX_ONLY_SYMBOLS)) +# A prefix-only symbol split across whitespace from its unit ("k g") is captured +# as ONE span so the inner unit ("g") is consumed and never emitted as a +# competing candidate. The split is always rejected by the rules (a prefix +# symbol must bind tightly with no space — "k g" is not "kg"). Dual-role +# prefix symbols (m, h, a, d) are excluded so spaced unit pairs like "m s" +# (metre second) survive as two units. A single outer named group wraps the +# non-capturing alternatives so the whole token is captured as one span. +_SYMBOL_BODY = ( + r"(?:(?:" + _PREFIX_ONLY_SYMBOL_ALT + r")\s+(?:" + _SYMBOL_ALT + r"))" + r"|" + r"(?:(?:" + _SYMBOL_ALT + r"))" +) +_SYMBOL_RE = re.compile(_LOOKBEHIND + r"(?P" + _SYMBOL_BODY + r")" + _LOOKAHEAD) class SymbolRecognition(Grammar[SIUnitNotation]): @@ -35,13 +61,25 @@ class SymbolRecognition(Grammar[SIUnitNotation]): semantics = "symbol_recognition" # SEAM (ADR-0003): identity id def recognize(self, text: str) -> list[RecognitionMatch[SIUnitNotation]]: - """Emit one RecognitionMatch per symbol token found in text.""" - return [ - RecognitionMatch( - raw_text=text[m.start() : m.end()], - start=m.start(), - end=m.end(), - notation=SIUnitNotation(text=m.group("token"), shape="symbol"), + """Emit one RecognitionMatch per unit symbol (or split prefix) found.""" + matches: list[RecognitionMatch[SIUnitNotation]] = [] + for m in _SYMBOL_RE.finditer(text): + token = m.group("tok") + parts = token.split() + # A split prefix is a prefix-only symbol followed by a space and a + # unit symbol (e.g. "k g"); dual-role symbols (m, h, a, d) are not + # prefix-only, so a spaced "m s" stays two units (metre second). + # A symbol token never contains a space otherwise. + if len(parts) >= 2 and parts[0] in PREFIX_ONLY_SYMBOLS: + shape = "split_symbol_prefix" + else: + shape = "symbol" + matches.append( + RecognitionMatch( + raw_text=text[m.start() : m.end()], + start=m.start(), + end=m.end(), + notation=SIUnitNotation(text=token, shape=shape), + ) ) - for m in _TOKEN_RE.finditer(text) - ] + return matches diff --git a/paxman/capabilities/SIUnit/notation.py b/paxman/capabilities/SIUnit/notation.py index 9f053cf0..d04038e4 100644 --- a/paxman/capabilities/SIUnit/notation.py +++ b/paxman/capabilities/SIUnit/notation.py @@ -4,7 +4,18 @@ from dataclasses import dataclass -_VALID_SHAPES = frozenset({"symbol", "name", "compound"}) +_VALID_SHAPES = frozenset( + { + "symbol", + "name", + "compound", + # A word/symbol prefix split across whitespace from its unit, captured + # as ONE span so the trailing unit is never emitted as a competing + # candidate (see grammar subsumption + split-prefix rules). + "split_word_prefix", + "split_symbol_prefix", + } +) @dataclass(frozen=True, slots=True) diff --git a/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py b/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py index 8d115ba4..8b2ac609 100644 --- a/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py +++ b/paxman/capabilities/SIUnit/rules/iso_80000_ed2022.py @@ -13,6 +13,7 @@ import re +from paxman.capabilities.SIUnit.contract import SIUnitContract from paxman.capabilities.SIUnit.notation import SIUnitNotation from paxman.capabilities.SIUnit.rules.data.prefixed_units import PREFIXED_UNIT_SYMBOLS from paxman.capabilities.SIUnit.rules.data.si_base_units import BASE_UNIT_SYMBOLS @@ -54,6 +55,9 @@ } ) _SEPARATOR_TRANSLATE = str.maketrans({"⋅": "·"}) # D3: U+22C5 dot → U+00B7 +# Split patterns are kept local (not imported from grammar/data) so rules +# never import from the grammar tree (grammar↔rules purity scan). +_SEPARATORS = "/·⋅" class SectionCompounds(Rule[SIUnitNotation]): @@ -80,30 +84,135 @@ def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: return False if notation.shape != "compound": return False - return all( - self._symbol_part(group) in _FULL_SYMBOL_LEXICON - for group in re.split(r"[/·⋅]", notation.text) + # ISO 80000-1 §6.6.2: a solidus shall not be followed by a + # multiplication/division sign on the same line unless parentheses + # disambiguate. More than one TOP-LEVEL "/" (outside parentheses) is + # rejected unless the contract opts into the legacy behavior. + allow_multi = ( + contract.allow_multi_solidus + if isinstance(contract, SIUnitContract) + else False ) + if _count_top_level_slash(notation.text) > 1 and not allow_multi: + return False + # Every top-level factor (a bare unit or a parenthesized group) must + # validate against the full symbol lexicon. + return all(_valid_factor(f) for f in _top_level_factors(notation.text)) def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: - """Normalize to the canonical compound: ASCII exponents, "·" separators.""" - return "".join( - part if part in ("/", "·", "⋅") else self._canonical_group(part) - for part in re.split(r"([/·⋅])", notation.text) - ).translate(_SEPARATOR_TRANSLATE) - - @staticmethod - def _symbol_part(group: str) -> str: - """The symbol without its trailing exponent ("m/s2" -> "m/s2" group "m").""" - return _EXPONENT_SUFFIX.sub("", group) - - @classmethod - def _canonical_group(cls, group: str) -> str: - """Canonical group: ASCII exponent, "l" -> "L", symbol unchanged.""" - match = _EXPONENT_SUFFIX.search(group) - if match is None: - return group - symbol = group[: match.start()] - exponent = group[match.start() :].translate(_SUPERSCRIPT_TRANSLATE) - canonical_symbol = "L" if symbol == "l" else symbol - return canonical_symbol + exponent + """Normalize to the canonical compound: ASCII exponents, "·" separators. + + Parentheses are preserved and their inner content is canonicalized + recursively; "·"/"⋅" stay as separators, "/" stays, superscripts fold + to ASCII, "l" -> "L". + """ + return _canonical_compound(notation.text).translate(_SEPARATOR_TRANSLATE) + + +def _symbol_part(group: str) -> str: + """The symbol without its trailing exponent ("m/s2" -> "m/s2" group "m").""" + return _EXPONENT_SUFFIX.sub("", group) + + +def _canonical_group(group: str) -> str: + """Canonical group: ASCII exponent, "l" -> "L", symbol unchanged.""" + match = _EXPONENT_SUFFIX.search(group) + if match is None: + return group + symbol = group[: match.start()] + exponent = group[match.start() :].translate(_SUPERSCRIPT_TRANSLATE) + canonical_symbol = "L" if symbol == "l" else symbol + return canonical_symbol + exponent + + +def _count_top_level_slash(text: str) -> int: + """Count "/" characters that appear outside any parentheses. + + A "/" inside a parenthesized factor does not count toward the + multi-solidus guard (ISO 80000-1 §6.6.2 allows one solidus per line, + with parentheses as the disambiguation). + """ + count = 0 + depth = 0 + for ch in text: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif depth == 0 and ch == "/": + count += 1 + return count + + +def _top_level_factors(text: str) -> list[str]: + """Split a compound into its top-level factors (outside parentheses). + + Separators ("/", "·", "⋅") at depth 0 break the compound into factors; + a parenthesized group is kept intact as a single factor. + """ + factors: list[str] = [] + depth = 0 + current = "" + for ch in text: + if ch == "(": + depth += 1 + current += ch + elif ch == ")": + depth -= 1 + current += ch + elif depth == 0 and ch in _SEPARATORS: + factors.append(current) + current = "" + else: + current += ch + factors.append(current) + return factors + + +def _valid_factor(factor: str) -> bool: + """Validate one compound factor against the full symbol lexicon. + + A parenthesized factor's inner content must itself be a valid compound + (each inner part in the lexicon); a bare factor must be a known symbol. + """ + if factor.startswith("(") and factor.endswith(")"): + inner = factor[1:-1] + return all( + _symbol_part(part) in _FULL_SYMBOL_LEXICON + for part in re.split(r"[/·⋅]", inner) + ) + return _symbol_part(factor) in _FULL_SYMBOL_LEXICON + + +def _canonical_compound(text: str) -> str: + """Render the canonical form of a compound, preserving parentheses. + + Walks the text tracking parenthesis depth: top-level separators are + emitted verbatim, each top-level factor is canonicalized (recursively + when parenthesized), and inner content is canonicalized the same way. + """ + out: list[str] = [] + depth = 0 + current = "" + for ch in text: + if ch == "(": + depth += 1 + current += ch + elif ch == ")": + depth -= 1 + current += ch + elif depth == 0 and ch in _SEPARATORS: + out.append(_canonical_factor(current)) + out.append(ch) + current = "" + else: + current += ch + out.append(_canonical_factor(current)) + return "".join(out) + + +def _canonical_factor(factor: str) -> str: + """Canonicalize one factor: recurse into a parenthesized group, else a unit.""" + if factor.startswith("(") and factor.endswith(")"): + return "(" + _canonical_compound(factor[1:-1]) + ")" + return _canonical_group(factor) diff --git a/paxman/capabilities/SIUnit/rules/split_prefixes.py b/paxman/capabilities/SIUnit/rules/split_prefixes.py new file mode 100644 index 00000000..a73dc109 --- /dev/null +++ b/paxman/capabilities/SIUnit/rules/split_prefixes.py @@ -0,0 +1,71 @@ +"""SI prefix-spacing rule (BIPM SI Brochure 9th ed., 2019, §3.2). + +SI prefixes are part of the unit name/symbol and are written attached +("kilogram", "kg"); a space between a prefix and its unit is not standard +SI. This rule rescues the common spoken form "kilo gram" behind an opt-in +contract flag: it merges the word prefix to the prefixed symbol ("kg") only +when ``allow_split_word_prefixes`` is set, and is otherwise dropped by the +engine's feature gate (→ INVALID). Symbol-prefix spacing ("k g") is handled +by the grammar (the trailing unit is subsumed into one span) and rejected +implicitly — a prefix symbol must bind tightly with no space, and collapsing +it would corrupt dimensionality (e.g. "m m" ≠ "mm"). +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.capabilities.SIUnit.rules.data.prefixed_unit_names import ( + PREFIXED_NAME_TO_SYMBOL, +) +from paxman.capabilities.SIUnit.rules.data.unit_names import NAME_TO_SYMBOL +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +# Full name→symbol resolution (maintained official names + generated prefixed +# names). "kilogram" -> "kg", "megahertz" -> "MHz". +FULL_NAME_TO_SYMBOL = NAME_TO_SYMBOL | PREFIXED_NAME_TO_SYMBOL + +PUBLICATION = Provenance( + authority="BIPM", + specification_name="SI Brochure: The International System of Units (SI)", + kind="specification", + reference_url="https://www.bipm.org/en/publications/si-brochure", + version="9th edition", + lifecycle="active", + publication_year=2019, +) + +_WHITESPACE = re.compile(r"\s+") + + +class SectionSplitWordPrefixes(Rule[SIUnitNotation]): + """BIPM SI Brochure §3.2 — a word prefix split from its unit by whitespace. + + When the contract opts in via ``allow_split_word_prefixes``, a + ``split_word_prefix`` notation ("kilo gram") merges to its attached + prefixed symbol ("kg"). The rule declares ``requires_features`` so the + engine drops it entirely when the flag is off, leaving the recognition + unvalidated (→ INVALID) by Paxman's standard idiom. + """ + + name = "Section 3.2-split-word-prefixes" + strategy = RuleStrategy.LOOKUP_TABLE + provenance = PUBLICATION + citation = ( + "BIPM SI Brochure (9th ed., 2019), §3.2 (prefixes attached to unit names)" + ) + target_semantics = frozenset({"name_recognition"}) + requires_features = frozenset({"allow_split_word_prefixes"}) + + def matches(self, notation: SIUnitNotation, contract: Contract) -> bool: + """Accept a merged word-prefix notation when the flag is on.""" + if notation.shape != "split_word_prefix": + return False + merged = _WHITESPACE.sub("", notation.text) + return merged in FULL_NAME_TO_SYMBOL + + def normalize(self, notation: SIUnitNotation, contract: Contract) -> str: + """Render the attached prefixed symbol ("kilo gram" -> "kg").""" + return FULL_NAME_TO_SYMBOL[_WHITESPACE.sub("", notation.text)] diff --git a/tests/capabilities/si_unit/test_capability.py b/tests/capabilities/si_unit/test_capability.py index 59747c96..a97e0504 100644 --- a/tests/capabilities/si_unit/test_capability.py +++ b/tests/capabilities/si_unit/test_capability.py @@ -2,9 +2,12 @@ import pytest +from paxman.api import canonicalize from paxman.capabilities.SIUnit.capability import SIUnitCapability from paxman.capabilities.SIUnit.contract import SIUnitContract from paxman.capabilities.SIUnit.notation import SIUnitNotation +from paxman.core.discovery import register_capability, reset_registry +from paxman.core.domain import Resolution @pytest.mark.capability @@ -31,7 +34,8 @@ def test_get_grammars(self) -> None: } def test_get_rules(self) -> None: - # 6 instances: 5 BIPM sections + 1 ISO compound section + # 7 instances: 5 BIPM sections + 1 ISO compound section + + # 1 opt-in split-word-prefix rescue rule names = {r.name for r in self.capability.get_rules()} assert names == { "Section 2.3.1-base-units", @@ -40,6 +44,7 @@ def test_get_rules(self) -> None: "Section 3.2-prefixes", "Section-names", "Section 6.5-compounds", + "Section 3.2-split-word-prefixes", } def test_create_contract_defaults(self) -> None: @@ -89,3 +94,149 @@ def test_package_exports() -> None: assert CapabilityExport is SIUnitCapability assert ContractExport is SIUnitContract assert NotationExport is SIUnitNotation + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSIUnitCapabilityMultiSolidusAndParens: + """End-to-end: ISO 80000-1 §6.6.2 multi-solidus rejection + §6.5 parens.""" + + @pytest.fixture(autouse=True) + def _clean_registry(self) -> None: + """Reset the capability registry before each test (it may be frozen).""" + reset_registry() + yield + reset_registry() + + def test_multi_solidus_invalid_by_default(self) -> None: + # More than one top-level "/" is INVALID under the default contract + # (ISO 80000-1 §6.6.2). + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("kg/m/s", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + def test_parenthesized_denominator_success(self) -> None: + # A parenthesized denominator MUST resolve (ISO 80000-1 §6.6.2 + # prescribes parentheses as the disambiguation). + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("kg/(m·s²)", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg/(m·s2)" + + def test_multi_solidus_success_when_allowed(self) -> None: + # The legacy accept-multi-solidus behavior is preserved when the + # contract opts in via allow_multi_solidus=True. + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(allow_multi_solidus=True) + result = canonicalize("kg/m/s", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg/m/s" + + +@pytest.mark.capability +@pytest.mark.si_unit +class TestSIUnitCapabilitySplitPrefixes: + """End-to-end: spaced SI prefix handling (word merge + symbol reject). + + Word prefixes across a space ("kilo gram") are not standard SI and are + rejected by default, but merge to the prefixed symbol when the contract + opts in via ``allow_split_word_prefixes``. Symbol prefixes across a space + are rejected when the leading symbol is prefix-ONLY (``k g`` → INVALID: + a prefix symbol must bind tightly with no space, and leaving it would + corrupt dimensionality, e.g. ``k g`` must not resolve to ``g``). Dual-role + prefix symbols that are also unit symbols (``m``, ``h``, ``a``, ``d``) stay + as separate units, so ``m s`` is valid metre-second and ``m m`` resolves to + ``m`` (metre) — crucially never collapsing to ``mm`` (10⁻³ m). + """ + + @pytest.fixture(autouse=True) + def _clean_registry(self) -> None: + reset_registry() + yield + reset_registry() + + # --- Word prefix: strict by default --- + def test_word_prefix_invalid_by_default(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("kilo gram", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + # --- Word prefix: merge when opted in --- + def test_word_prefix_merges_when_allowed(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(allow_split_word_prefixes=True) + result = canonicalize("kilo gram", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "kg" + + def test_word_prefix_merges_megahertz_when_allowed(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(allow_split_word_prefixes=True) + result = canonicalize("mega hertz", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "MHz" + + # --- Symbol prefix: ALWAYS rejected (no flag) --- + def test_symbol_prefix_always_invalid(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("k g", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + def test_symbol_prefix_always_invalid_even_when_word_allowed(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract(allow_split_word_prefixes=True) + result = canonicalize("k g", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + def test_symbol_prefix_m_m_not_collapsed_to_mm(self) -> None: + # "m m" is the dual-role prefix "m" (milli + metre). It MUST NOT + # collapse to "mm" (millimetre, 10⁻³ m). It resolves to "m" (metre) + # as two unit tokens, never to the prefixed millimetre symbol. + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m m", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "m" + + def test_dual_role_spaced_prefix_stays_units(self) -> None: + # "m s" is the valid SI expression "metre second": "m" is also the + # metre unit (not prefix-only), so the pair stays two units and is + # AMBIGUOUS — never a rejectable split. Contrast with "k g" below. + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("m s", contract) + assert result.status == Resolution.AMBIGUOUS + assert result.canonicalized_value is None + + def test_prefix_only_spaced_symbol_invalid(self) -> None: + # "k g": "k" is a prefix-ONLY symbol (not a unit), so the only reading + # is a broken spaced prefix → INVALID; the inner "g" is consumed so it + # cannot resolve to the gram candidate. + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + result = canonicalize("k g", contract) + assert result.status == Resolution.INVALID + assert result.canonicalized_value is None + + # --- Regression guards: existing behavior preserved --- + def test_no_space_word_still_success(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + assert canonicalize("kilogram", contract).canonicalized_value == "kg" + assert canonicalize("kg", contract).canonicalized_value == "kg" + assert canonicalize("gram", contract).canonicalized_value == "g" + + def test_phase1_behavior_preserved(self) -> None: + register_capability(SIUnitCapability()) + contract = SIUnitCapability.create_contract() + assert canonicalize("m/s", contract).canonicalized_value == "m/s" + assert canonicalize("kg/(m·s²)", contract).canonicalized_value == "kg/(m·s2)" + assert canonicalize("kg/m/s", contract).status == Resolution.INVALID diff --git a/tests/capabilities/si_unit/test_grammar.py b/tests/capabilities/si_unit/test_grammar.py index 647a5b0c..60357d59 100644 --- a/tests/capabilities/si_unit/test_grammar.py +++ b/tests/capabilities/si_unit/test_grammar.py @@ -93,13 +93,51 @@ def test_rejects(self, text: str) -> None: @pytest.mark.parametrize( ("text", "expected"), [ + # "m s" is the valid SI expression "metre second": two units, not a + # broken spaced prefix (m is also the metre unit, not prefix-only). ("m s", [("m", 0, 1, "m", "symbol"), ("s", 2, 3, "s", "symbol")]), + # Separated by a non-space keeps the two units distinct. ("m; s", [("m", 0, 1, "m", "symbol"), ("s", 3, 4, "s", "symbol")]), ], ) def test_multiple_spans(self, text: str, expected: list[Span]) -> None: _assert_spans(text, expected, self.grammar.recognize(text)) + @pytest.mark.parametrize( + ("text", "notation_text", "shape"), + [ + # Prefix-ONLY symbols split across whitespace are rejectable spans. + ("k g", "k g", "split_symbol_prefix"), + ("µ g", "µ g", "split_symbol_prefix"), + ("da m", "da m", "split_symbol_prefix"), + ], + ) + def test_split_prefix_shape( + self, text: str, notation_text: str, shape: str + ) -> None: + results = self.grammar.recognize(text) + assert results + first = results[0] + assert first.notation.text == notation_text + assert first.notation.shape == shape + + @pytest.mark.parametrize( + ("text", "notation_text", "shape"), + [ + # Dual-role prefix symbols stay two distinct units when spaced. + ("m s", "m", "symbol"), + ("N m", "N", "symbol"), + ], + ) + def test_spaced_units_not_split( + self, text: str, notation_text: str, shape: str + ) -> None: + results = self.grammar.recognize(text) + assert results + first = results[0] + assert first.notation.text == notation_text + assert first.notation.shape == shape + class TestNameRecognition: """Grammar: name_recognition — case-folded unit names.""" diff --git a/tests/capabilities/si_unit/test_rules.py b/tests/capabilities/si_unit/test_rules.py index dffbcfba..70d67028 100644 --- a/tests/capabilities/si_unit/test_rules.py +++ b/tests/capabilities/si_unit/test_rules.py @@ -15,6 +15,8 @@ from paxman.core.domain import RuleStrategy CONTRACT = SIUnitContract() +# Opt-in contract that preserves the legacy accept-multi-solidus behavior. +CONTRACT_MULTI_SOLIDUS = SIUnitContract(allow_multi_solidus=True) @pytest.mark.capability @@ -258,3 +260,46 @@ def test_temporal_gate(self) -> None: old = SIUnitContract(year=2021) assert not self.rule.matches(SIUnitNotation(text="m/s", shape="compound"), old) assert self.rule.matches(SIUnitNotation(text="m/s", shape="compound"), CONTRACT) + + def test_rejects_multi_solidus_by_default(self) -> None: + # ISO 80000-1 §6.6.2: a solidus shall not be followed by a + # multiplication/division sign unless parentheses disambiguate. + # More than one top-level "/" is INVALID under the default contract. + assert not self.rule.matches( + SIUnitNotation(text="kg/m/s", shape="compound"), CONTRACT + ) + + def test_matches_multi_solidus_when_allowed(self) -> None: + # The legacy accept-multi-solidus behavior is preserved when the + # contract opts in via allow_multi_solidus=True. + assert self.rule.matches( + SIUnitNotation(text="kg/m/s", shape="compound"), CONTRACT_MULTI_SOLIDUS + ) + + def test_matches_parenthesized_denominator(self) -> None: + # ISO 80000-1 §6.6.2 prescribes parentheses as THE disambiguation, + # so a parenthesized denominator MUST resolve (not reject). + assert self.rule.matches( + SIUnitNotation(text="kg/(m·s²)", shape="compound"), CONTRACT + ) + + def test_rejects_unbalanced_parentheses(self) -> None: + # A parenthesized factor requires a closing ")"; an unbalanced + # expression is not a valid compound. + assert not self.rule.matches( + SIUnitNotation(text="kg/(m·s²", shape="compound"), CONTRACT + ) + + def test_normalize_parenthesized_denominator(self) -> None: + # The whole expression is captured as one compound span; parens are + # preserved, superscripts ASCII-ized, "·"/"/" preserved. + result = self.rule.normalize( + SIUnitNotation(text="kg/(m·s²)", shape="compound"), CONTRACT + ) + assert result == "kg/(m·s2)" + + def test_normalize_multi_solidus_when_allowed(self) -> None: + result = self.rule.normalize( + SIUnitNotation(text="kg/m/s", shape="compound"), CONTRACT_MULTI_SOLIDUS + ) + assert result == "kg/m/s" From 502c89356e38794198a98d11c4bcde81c2cdaf62 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 14:32:44 +0800 Subject: [PATCH 23/23] docs(si_unit): sync docs/contract to committed SI Unit split-prefix layer The review pass over the split-prefix layer surfaced doc and consistency gaps. Applied the valid ones: - AGENTS.md: quote the pytest marker (unquoted `|` was a silent shell pipe); run the full pre-PR gate through `uv run`. - notation.py / CONTEXT.md / README.md: list all five SIUnitNotation shapes (split_word_prefix, split_symbol_prefix) and correct the grammar (5) and rule (7) counts now that split_word_recognition, split_symbol_recognition, and the split_prefixes rule exist. - tools: replace the unsupported "metre per second" CLI example with "m/s". Skipped review comments (verified against the code, not defects): - symbol_recognition d/da ordering: "d" is excluded from PREFIX_ONLY_SYMBOLS (dual-role symbol), so the alternation never collides -- no bug. - SectionNames rename: unit names span SI Brochure Tables 1/3-4/8-9 (no single section number) and the class is unreferenced; renaming risks a wrong value. - si_nonsi "au": BIPM Table 8 spells the astronomical unit "ua"; correct as-is. - 2022 SI prefix set: out-of-scope overhaul needing a separate rule set, requires_features gating, regenerate, and new tests. - compound boundary lookahead: the proposed fix does not prevent 5-factor truncation, so it does not address the stated failure. - profile-driven restructure, MappingProxyType/extract-helper nitpicks, and test relocations to e2e: speculative or organizational churn with no behavioral defect (2434 tests green). --- AGENTS.md | 4 ++-- CONTEXT.md | 8 ++++---- README.md | 2 +- paxman/capabilities/SIUnit/notation.py | 2 +- tools/canonicalize_si_unit.py | 2 +- tools/si_unit_canonicalize.py | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cade2829..941c20da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,14 +76,14 @@ uv run ruff format --check paxman/ tests/ # format check uv run pyright # strict typecheck uv run import-linter lint # layer boundaries uv run pytest # all tests -uv run pytest -m unit|capability|integration|e2e # by marker (also: property, country, currency, isbn, money, url, si_unit) +uv run pytest -m "unit or capability or integration or e2e" # by marker (also: property, country, currency, isbn, money, url, si_unit) uv run pytest --cov=paxman --cov-report=term-missing --tb=short -q uv run coverage report --include="paxman/core/*,paxman/capabilities/*,paxman/engine/*,paxman/api/*" --fail-under=95 uv run python tools/regenerate_isbn_range_data.py # regenerate ISBN range message module uv run python tools/regenerate_si_prefix_data.py # regenerate SIUnit prefixed-unit modules uv run python tools/regenerate_idna_uts46_data.py # regenerate URL IDNA UTS #46 mapping ``` -Full pre-PR gate: `ruff check . && ruff format --check . && pyright && import-linter lint && pytest` +Full pre-PR gate: `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run import-linter lint && uv run pytest` ## NOTES - `paxman/capabilities/__init__.py` exports all ten shipped capabilities (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SI Unit, URL); export completeness is enforced by `tests/unit/test_capability_exports.py`. diff --git a/CONTEXT.md b/CONTEXT.md index 032cfe67..1829c05a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,7 +41,7 @@ Capability-defined intermediate representation that Grammars must produce: - **Currency:** `CurrencyNotation(text, shape)` — `shape` is `"code"` / `"qualified_symbol"` / `"symbol"` / `"word"`; codes are grammar-folded to uppercase, words to lowercase, symbols keep exact casing - **Money:** `MoneyNotation(currency_part, amount_part, currency_shape, amount_shape)` — verbatim currency + amount tokens with grammar-assigned shape discriminators - **ISBN:** `ISBNNotation(shape, digits)` — `shape` is `"isbn10"` / `"isbn13"`, `digits` is the digit string (`X` only as final char of an isbn10 shape) -- **SIUnit:** `SIUnitNotation(text, shape)` — `shape` is `"symbol"` / `"name"` / `"compound"`; `text` is the unit expression as written (symbols keep exact casing, names are grammar-folded to lowercase, compounds keep the written form) + - **SIUnit:** `SIUnitNotation(text, shape)` — `shape` is `"symbol"` / `"name"` / `"compound"` / `"split_word_prefix"` / `"split_symbol_prefix"`; `text` is the unit expression as written (symbols keep exact casing, names are grammar-folded to lowercase, compounds keep the written form) - **IP / Phone / URL:** capability-defined shapes for address / number / URI components **Note:** Capabilities define Notation using frozen dataclasses for type safety and immutability. The `as_list()` method bridges the typed notation to the generic `list[str]` interface. @@ -777,13 +777,13 @@ paxman/ │ ├── grammar/ # e164, tel_uri, international_00, national_recognition (+ common.py LEGACY) │ ├── rules/ # e164_ed2010, rfc_3966_ed2004, nanp_ed2024 │ └── rules/data/ # e164_country_codes, nanp_tables - ├── SIUnit/ # grammar/ (3) + rules/ (2) + grammar/data/ + rules/data/ — BIPM SI Brochure, ISO 80000-1 + ├── SIUnit/ # grammar/ (5) + rules/ (3) + grammar/data/ + rules/data/ — BIPM SI Brochure, ISO 80000-1 │ ├── capability.py # SIUnitCapability │ ├── contract.py # SIUnitContract │ ├── notation.py # SIUnitNotation (text, shape) - │ ├── grammar/ # symbol, name, compound_recognition + │ ├── grammar/ # symbol, name, compound_recognition, split_word_recognition, split_symbol_recognition │ ├── grammar/data/ # unit_symbol_tokens, unit_name_tokens, compound_tokens (+ GENERATED via tools/regenerate_si_prefix_data.py) - │ ├── rules/ # bipm_si_brochure_ed2019, iso_80000_ed2022 + │ ├── rules/ # bipm_si_brochure_ed2019, iso_80000_ed2022, split_prefixes │ └── rules/data/ # si_base_units, si_derived_units, si_nonsi_units, si_prefixes, unit_names (+ GENERATED prefixed_units, prefixed_unit_names) └── URL/ # grammar/ (1) + rules/ (1) + rules/data/ — WHATWG URL Standard ├── capability.py # URLCapability diff --git a/README.md b/README.md index a5c8cdde..1e0f91a1 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Paxman ships with ten built-in capabilities: | **ISBN** | ISBNs | 2 (isbn13, isbn10) | 4 | ISO 2108, ISBN Users' Manual, ISBN Range Message | | **Money** | Money amounts | 3 (code, symbol, word) | 3 | ISO 4217, CLDR | | **Phone** | Phone numbers | 4 (E.164, tel-URI, 00-prefix, national) | 5 | ITU-T E.164, RFC 3966, NANP | -| **SI Unit** | SI unit expressions | 3 (symbol, name, compound) | 6 | BIPM SI Brochure, ISO 80000-1 | +| **SI Unit** | SI unit expressions | 5 (symbol, name, compound, split_word_prefix, split_symbol_prefix) | 7 | BIPM SI Brochure, ISO 80000-1 | | **URL** | URLs | 1 (absolute-uri) | 1 | WHATWG URL Standard | ### Email Capability diff --git a/paxman/capabilities/SIUnit/notation.py b/paxman/capabilities/SIUnit/notation.py index d04038e4..07a179c2 100644 --- a/paxman/capabilities/SIUnit/notation.py +++ b/paxman/capabilities/SIUnit/notation.py @@ -25,7 +25,7 @@ class SIUnitNotation: Attributes: text: The unit text. Symbols keep their exact casing; names are grammar-folded to lowercase; compounds keep the written form. - shape: One of "symbol", "name", "compound". + shape: One of "symbol", "name", "compound", "split_word_prefix", "split_symbol_prefix". """ text: str diff --git a/tools/canonicalize_si_unit.py b/tools/canonicalize_si_unit.py index b16c943e..0bf200e4 100644 --- a/tools/canonicalize_si_unit.py +++ b/tools/canonicalize_si_unit.py @@ -5,7 +5,7 @@ uv run python tools/canonicalize_si_unit.py uv run python tools/canonicalize_si_unit.py "N·m" - uv run python tools/canonicalize_si_unit.py "metre per second" --year 2018 + uv run python tools/canonicalize_si_unit.py "m/s" --year 2018 Prints status, canonical value, and the surviving candidates with provenance. """ diff --git a/tools/si_unit_canonicalize.py b/tools/si_unit_canonicalize.py index 8f2c2a78..229694a0 100644 --- a/tools/si_unit_canonicalize.py +++ b/tools/si_unit_canonicalize.py @@ -2,7 +2,7 @@ Usage: uv run python tools/si_unit_canonicalize.py "kg" - uv run python tools/si_unit_canonicalize.py "metre per second" + uv run python tools/si_unit_canonicalize.py "m/s" Output: the canonical value on SUCCESS, otherwise one word: INVALID / AMBIGUOUS / MISSING.