From 23554a7aca31cba144ef248b9771d4668815dd1c Mon Sep 17 00:00:00 2001 From: Diego Prada Date: Thu, 24 Sep 2026 02:59:46 -0600 Subject: [PATCH 1/3] feat(record): add QuantityRecord, the inert interchange form (#82) A form, "record", whose objects carry values with a verified description of their unit: canonical name plus SI factor, offset and exponents, an optional quantity kind, and a blake2b seal over a language-independent canonical manifest and the values' little-endian bytes. Reading refuses a missing manifest (no default unit), a broken seal, or a name that disagrees with its SI description; to_quantity() is the reader's handshake (field, unit, dimensionality, kind). QuantityRecordBundle seals many scalars of one document. The form is registered like "string": get_form detects records, convert() delegates when the source or target is "record", and the dispatch entries make get_value, get_unit, get_dimensionality, change_value and quantity(..., form="record") work. RecordError (PUW-ERR-REC-001) joins the SMonitor catalog. A standard-library reference reader and frozen test vectors let third parties verify records without PyUnitWizard. Provisional API: design and alternatives in #83. Co-Authored-By: Claude Opus 5.5 --- devtools/generate_quantity_record_vectors.py | 63 ++ pyunitwizard/__init__.py | 3 + pyunitwizard/_private/exceptions/__init__.py | 1 + .../_private/exceptions/record_error.py | 19 + pyunitwizard/_private/forms.py | 4 +- .../_private/record_reference_reader.py | 84 +++ pyunitwizard/_private/smonitor/catalog.py | 14 + pyunitwizard/api/conversion.py | 17 +- pyunitwizard/api/introspection.py | 6 +- pyunitwizard/forms/__init__.py | 20 + pyunitwizard/forms/api_record.py | 77 +++ pyunitwizard/record.py | 603 ++++++++++++++++++ .../affine_celsius.json | 35 + .../compound_unit.json | 35 + .../coordinates_float32_2d.json | 48 ++ .../counts_int32_dalton.json | 35 + .../scalar_concentration_with_kind.json | 31 + tests/test_catalog_exception_contract.py | 2 + tests/test_quantity_record.py | 356 +++++++++++ 19 files changed, 1448 insertions(+), 5 deletions(-) create mode 100644 devtools/generate_quantity_record_vectors.py create mode 100644 pyunitwizard/_private/exceptions/record_error.py create mode 100644 pyunitwizard/_private/record_reference_reader.py create mode 100644 pyunitwizard/forms/api_record.py create mode 100644 pyunitwizard/record.py create mode 100644 tests/quantity_record_vectors/affine_celsius.json create mode 100644 tests/quantity_record_vectors/compound_unit.json create mode 100644 tests/quantity_record_vectors/coordinates_float32_2d.json create mode 100644 tests/quantity_record_vectors/counts_int32_dalton.json create mode 100644 tests/quantity_record_vectors/scalar_concentration_with_kind.json create mode 100644 tests/test_quantity_record.py diff --git a/devtools/generate_quantity_record_vectors.py b/devtools/generate_quantity_record_vectors.py new file mode 100644 index 00000000..9d4d548b --- /dev/null +++ b/devtools/generate_quantity_record_vectors.py @@ -0,0 +1,63 @@ +"""Regenerate the published QuantityRecord test vectors (tests/quantity_record_vectors/). + +The vectors freeze the format: tests assert that the library reproduces them byte for +byte, and third parties use them to check their own readers. The expected SI values are +written by hand here, independently of the implementation. Regenerate only with a format +version change, and say so in the changelog. +""" + +import json +from pathlib import Path + +import numpy as np + +import pyunitwizard as puw +from pyunitwizard.record import QuantityRecord + +FOLDER = Path(__file__).resolve().parents[1] / "tests" / "quantity_record_vectors" + +CASES = { + "scalar_concentration_with_kind": ( + puw.quantity(3.0, "nM", form="pint"), + "bioactivity.ic50", + "http://qudt.org/vocab/quantitykind/AmountOfSubstanceConcentration", + [3.0e-6], # 3 nmol/L = 3e-9 mol / 1e-3 m^3 + ), + "coordinates_float32_2d": ( + puw.quantity(np.array([[0.5, 1.0, 1.5], [2.0, 2.5, 3.0]], dtype=np.float32), "nm", form="pint"), + "structures.coordinates", + None, + [0.5e-9, 1.0e-9, 1.5e-9, 2.0e-9, 2.5e-9, 3.0e-9], + ), + "counts_int32_dalton": ( + puw.quantity(np.array([180, 18], dtype=np.int32), "dalton", form="pint"), + "properties.molecular_weight", + None, + [180 * 1.66053906892e-27, 18 * 1.66053906892e-27], + ), + "affine_celsius": ( + puw.quantity(np.array([20.0, 37.0]), "degC", form="pint"), + "assay.temperature", + None, + [293.15, 310.15], + ), + "compound_unit": ( + puw.quantity(np.array([1.0e-3]), "N*s/m**2", form="pint"), + "fluid.viscosity", + None, + [1.0e-3], + ), +} + + +def main() -> None: + FOLDER.mkdir(parents=True, exist_ok=True) + for name, (quantity, field, kind, si_values) in CASES.items(): + record = QuantityRecord.from_quantity(quantity, field=field, kind=kind) + case = {"record": record.to_dict(), "si_values": si_values} + (FOLDER / f"{name}.json").write_text(json.dumps(case, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/pyunitwizard/__init__.py b/pyunitwizard/__init__.py index af4e6f51..11cbf0af 100644 --- a/pyunitwizard/__init__.py +++ b/pyunitwizard/__init__.py @@ -69,6 +69,9 @@ def __print_version__() -> None: "to_string": (".api", "to_string"), "unit": (".api", "unit"), "context": (".api", "context"), + # Inert interchange form (provisional until the 1.0 checklist promotes it; #82) + "QuantityRecord": (".record", "QuantityRecord"), + "QuantityRecordBundle": (".record", "QuantityRecordBundle"), } __all__ = sorted(list(_LAZY_ATTRIBUTES.keys())) diff --git a/pyunitwizard/_private/exceptions/__init__.py b/pyunitwizard/_private/exceptions/__init__.py index 67bb0f0b..b940a6c4 100644 --- a/pyunitwizard/_private/exceptions/__init__.py +++ b/pyunitwizard/_private/exceptions/__init__.py @@ -8,3 +8,4 @@ from .not_implemented_form_error import NotImplementedFormError from .not_implemented_method_error import NotImplementedMethodError from .not_implemented_parser_error import NotImplementedParserError +from .record_error import RecordError diff --git a/pyunitwizard/_private/exceptions/record_error.py b/pyunitwizard/_private/exceptions/record_error.py new file mode 100644 index 00000000..72720d0a --- /dev/null +++ b/pyunitwizard/_private/exceptions/record_error.py @@ -0,0 +1,19 @@ +from .base import PyUnitWizardCatalogException + + +class RecordError(PyUnitWizardCatalogException): + """A quantity record that must not be read or written. + + Raised when a record fails its integrity seal, its self-description, or the reader's + handshake. It is never a warning and there is never a fallback unit + (uibcdf/pyunitwizard#82). + """ + + catalog_key = "RecordError" + + def __init__(self, message=None, *, reason=None, field=None, caller=None): + extra = {"reason": reason, "field": field} + if caller: + extra["caller"] = caller + + super().__init__(message=message, extra=extra) diff --git a/pyunitwizard/_private/forms.py b/pyunitwizard/_private/forms.py index bbc9a5b8..faa3b6e9 100644 --- a/pyunitwizard/_private/forms.py +++ b/pyunitwizard/_private/forms.py @@ -1,6 +1,6 @@ from pyunitwizard import kernel -forms = ["openmm.unit", "pint", "unyt", "astropy.units", "physipy", "quantities", "string"] +forms = ["openmm.unit", "pint", "unyt", "astropy.units", "physipy", "quantities", "string", "record"] _LOADING = False @@ -41,7 +41,7 @@ def digest_form(form: str, load: bool = True) -> str: raise ValueError # Dynamic on-demand lazy library loading with re-entrancy guard - if load and form_name is not None and form_name != "string": + if load and form_name is not None and form_name not in ("string", "record"): if form_name not in kernel.loaded_libraries and not _LOADING: _LOADING = True try: diff --git a/pyunitwizard/_private/record_reference_reader.py b/pyunitwizard/_private/record_reference_reader.py new file mode 100644 index 00000000..70eb953d --- /dev/null +++ b/pyunitwizard/_private/record_reference_reader.py @@ -0,0 +1,84 @@ +"""Reference reader for quantity records, using only the Python standard library. + +It exists to show that the format needs no PyUnitWizard, pint or numpy to be read and +verified, and to serve as a template for readers in other languages: every byte it hashes +is defined by the format, not by a library. It reads JSON-encoded and base64 records and +returns SI values after checking the seal and the expected SI dimensions. +""" + +import base64 +import hashlib +import struct + +FORMAT = "qrec/0.3" +_STRUCT = {"float64": "d", "float32": "f", "int64": "q", "int32": "i", "int16": "h", "uint8": "B", "uint16": "H"} + + +def canonical(obj): + """Tagged little-endian encoding of manifest data (see pyunitwizard.record).""" + if obj is None: + return b"n" + if isinstance(obj, bool): + return b"t" if obj else b"f" + if isinstance(obj, int): + return b"i" + struct.pack(" str: - if form != "string" and form not in kernel.loaded_libraries: + if form not in ("string", "record") and form not in kernel.loaded_libraries: from pyunitwizard.configure import load_library load_library(form) @@ -108,6 +108,10 @@ def get_form(quantity_or_unit: QuantityOrUnit, raise_exception: bool = True) -> _TYPE_TO_FORM_CACHE[obj_type] = "string" return "string" + if obj_type.__name__ == "QuantityRecord" and obj_type.__module__ == "pyunitwizard.record": + _TYPE_TO_FORM_CACHE[obj_type] = "record" + return "record" + # Fast class name detection to avoid loading all libraries or calling many functions type_str = str(obj_type) if "pint" in type_str: diff --git a/pyunitwizard/forms/__init__.py b/pyunitwizard/forms/__init__.py index 1acc60cb..d1ec0d60 100644 --- a/pyunitwizard/forms/__init__.py +++ b/pyunitwizard/forms/__init__.py @@ -186,3 +186,23 @@ def _unit_bridge(x, _in=in_form, _out=out_form): dict_compatibility["string"] = api.compatibility del api + +# Load the record api: PyUnitWizard's inert interchange form (uibcdf/pyunitwizard#82). +# It imports no backend at load time; pint is used only when a record is built or read. + +api = _import_module(".api_record", _base_package) + +dict_is_form["record"] = api.is_form +dict_is_unit["record"] = api.is_unit +dict_is_quantity["record"] = api.is_quantity +dict_get_value["record"] = api.get_value +dict_get_unit["record"] = api.get_unit +dict_change_value["record"] = api.change_value +dict_make_quantity["record"] = api.make_quantity +dict_convert["record"] = api.convert +dict_translate_quantity["record"] = {} +dict_translate_unit["record"] = {} +dict_dimensionality["record"] = api.dimensionality +dict_compatibility["record"] = api.compatibility + +del api diff --git a/pyunitwizard/forms/api_record.py b/pyunitwizard/forms/api_record.py new file mode 100644 index 00000000..8c709476 --- /dev/null +++ b/pyunitwizard/forms/api_record.py @@ -0,0 +1,77 @@ +"""Adapter for the ``record`` form: PyUnitWizard's inert interchange form. + +Every function reads through :class:`pyunitwizard.record.QuantityRecord`, which verifies +the record; computations go through pint and come back as a new, sealed record. See +``pyunitwizard/record.py`` and uibcdf/pyunitwizard#82. +""" + +from typing import Any, Dict, Union + +import numpy as np + +from pyunitwizard._private.quantity_or_unit import ArrayLike +from pyunitwizard.record import QuantityRecord, _canonical_name, _registry + +form_name = "record" +parser = False + + +def _unit_name(unit: Any) -> str: + return unit if isinstance(unit, str) else _canonical_name(unit) + + +def is_form(quantity_or_unit: Any) -> bool: + """``True`` for a :class:`QuantityRecord`.""" + return isinstance(quantity_or_unit, QuantityRecord) + + +def is_quantity(quantity_or_unit: Any) -> bool: + """A record always holds a quantity.""" + return isinstance(quantity_or_unit, QuantityRecord) + + +def is_unit(quantity_or_unit: Any) -> bool: + """Records hold quantities; there are no unit-only records.""" + return False + + +def dimensionality(quantity_or_unit: QuantityRecord) -> Dict[str, int]: + """Dimensionality in PyUnitWizard notation.""" + from .api_pint import dimensionality as _pint_dimensionality + + return _pint_dimensionality(quantity_or_unit.to_quantity(form="pint")) + + +def compatibility(quantity_or_unit_1: QuantityRecord, quantity_or_unit_2: Any) -> bool: + """Two records are compatible when their SI dimensions agree.""" + if isinstance(quantity_or_unit_2, QuantityRecord): + return quantity_or_unit_1.si["exponents"] == quantity_or_unit_2.si["exponents"] + return False + + +def make_quantity(value: Union[int, float, ArrayLike], unit_name: Any) -> QuantityRecord: + """A sealed record from a value and a unit.""" + return QuantityRecord.from_quantity(_registry().Quantity(np.asarray(value), _unit_name(unit_name))) + + +def get_value(quantity: QuantityRecord): + """A copy of the values: a Python scalar for 0-d records, an array otherwise.""" + value = np.array(quantity.values, copy=True) + return value.item() if value.ndim == 0 else value + + +def get_unit(quantity: QuantityRecord) -> str: + """The canonical unit name.""" + return quantity.unit + + +def change_value(quantity: QuantityRecord, value: Union[int, float, ArrayLike]) -> QuantityRecord: + """A new sealed record with other values, the same unit, field and kind.""" + q = _registry().Quantity(np.asarray(value), quantity.unit) + return QuantityRecord.from_quantity(q, field=quantity.field, kind=quantity.kind) + + +def convert(quantity: QuantityRecord, unit_name: Any) -> QuantityRecord: + """A new sealed record in another unit, keeping field and kind.""" + q = quantity.to_quantity(form="pint", unit=_unit_name(unit_name)) + return QuantityRecord.from_quantity(q, field=quantity.field, kind=quantity.kind) diff --git a/pyunitwizard/record.py b/pyunitwizard/record.py new file mode 100644 index 00000000..5a3c8413 --- /dev/null +++ b/pyunitwizard/record.py @@ -0,0 +1,603 @@ +"""QuantityRecord: PyUnitWizard's inert interchange form (provisional). + +A quantity record carries values together with a verified description of their unit, so +that a number is never read in a unit it was not written in. It is a *form* like +``"string"``: it converts to and from every other form, and it does not compute. To +compute, convert it to ``pint``, ``openmm.unit``, ``astropy.units`` or ``unyt``. + +Design, alternatives and measurements: uibcdf/pyunitwizard#83. Implementation issue: +uibcdf/pyunitwizard#82. The API is **provisional** until the 1.0 checklist promotes it +(``devguide/release_1.0.0_checklist.md``). + +What a record guarantees: + +- **The unit travels inside the same object as the values.** Its canonical name and its + description in SI base units (factor, offset, exponents) are both recorded, and readers + check that they agree. +- **A seal.** A blake2b digest covers the manifest and the values' bytes. Only this module + produces it, so any change made elsewhere (a hand edit, a raw append, a renamed unit) + fails on read. +- **A handshake.** Readers may declare the field, unit, dimensionality or kind they + expect; a disagreement is an error. +- **No defaults.** A record without a manifest is refused; there is no fallback unit. + +It is not a "passport" (see ``standards/PYUNITWIZARD_GUIDE.md``). It remembers nothing +about live objects and never lets a check be skipped; it is verified every time it is read. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import struct +from functools import lru_cache +from typing import Any, Dict, Mapping, Optional, Tuple + +import numpy as np + +from ._private.exceptions import RecordError + +__all__ = ["FORMAT", "BUNDLE_FORMAT", "QuantityRecord", "QuantityRecordBundle", "RecordError"] + +FORMAT = "qrec/0.3" +BUNDLE_FORMAT = "qrec-bundle/0.3" +#: Relative tolerance between a record's SI description and this reader's definition of the +#: same unit. Definitions differ across CODATA revisions (dalton: ~1.4e-9 between 2018 and +#: 2022) and libraries (UDUNITS' ``u`` differs from pint's dalton by ~7e-7). Distinct units +#: are never this close, so a larger disagreement means the unit is not what it claims. +REL_TOL = 1e-6 +DTYPES = ("float64", "float32", "int64", "int32", "int16", "uint8", "uint16") +_SI_BASE = { + "[length]": "m", + "[mass]": "kg", + "[time]": "s", + "[current]": "A", + "[temperature]": "K", + "[substance]": "mol", + "[luminosity]": "cd", +} + + +# --- canonical bytes --------------------------------------------------------------------------- + + +def _canonical(obj: Any) -> bytes: + """Language-independent encoding of manifest data. + + JSON text is not canonical across languages (Python writes ``1e-06``, JavaScript + ``0.000001``), so the digest is computed over this tagged little-endian encoding. + Map keys are sorted by their UTF-8 bytes. + """ + if obj is None: + return b"n" + if isinstance(obj, bool): + return b"t" if obj else b"f" + if isinstance(obj, int): + return b"i" + struct.pack(" memoryview: + """C-order little-endian bytes, with every NaN written as the canonical quiet NaN.""" + dtype = values.dtype.newbyteorder("<") + if values.dtype.kind == "f" and np.isnan(values).any(): + values = np.where(np.isnan(values), np.array(np.nan, dtype=dtype), values) + if values.dtype != dtype or not values.flags.c_contiguous: + values = np.ascontiguousarray(values, dtype=dtype) + return memoryview(values).cast("B") + + +def _digest(*parts: Any) -> str: + h = hashlib.blake2b(digest_size=16) + for part in parts: + h.update(part if isinstance(part, (bytes, memoryview)) else _canonical(part)) + return "blake2b-128:" + h.hexdigest() + + +def _seal(manifest: Dict[str, Any], values: np.ndarray) -> Tuple[list, str]: + """Block digests and record digest. Only the codec may call this.""" + flat = np.asarray(values, dtype=np.dtype(manifest["dtype"])).reshape(-1) + block_digests, start = [], 0 + for index, size in enumerate(manifest["blocks"]): + block = flat[start : start + size] + block_digests.append(_digest({"block": index, "dtype": manifest["dtype"]}, _little_endian(block))) + start += size + return block_digests, _digest(manifest, *[d.encode("ascii") for d in block_digests]) + + +# --- unit description -------------------------------------------------------------------------- + + +def _registry(): + from .forms.api_pint import ureg + + return ureg + + +def _canonical_name(unit: Any) -> str: + """PyUnitWizard's canonical spelling: pint's default long form, whatever the display + format a user configured on the registry.""" + return format(unit, "D") + + +@lru_cache(maxsize=None) +def _describe(unit: str) -> str: + ureg = _registry() + one = ureg.Quantity(1.0, unit) + zero = ureg.Quantity(0.0, unit) + base_one, base_zero = one.to_base_units(), zero.to_base_units() + exponents: Dict[str, Any] = {} + for dimension, exponent in dict(one.dimensionality).items(): + if not exponent: + continue + if dimension not in _SI_BASE: + raise RecordError(reason=f"unit {unit!r} has a dimension outside the SI base: {dimension}") + exponents[_SI_BASE[dimension]] = int(exponent) if float(exponent).is_integer() else float(exponent) + offset = float(base_zero.magnitude) + return json.dumps( + { + "unit": _canonical_name(one.units), + "si": { + "factor": float(base_one.magnitude) - offset, + "offset": offset, + "exponents": dict(sorted(exponents.items())), + }, + } + ) + + +def _descriptor(unit: str) -> Dict[str, Any]: + """A fresh description every call: a record must never share state with the cache + that checks it.""" + return json.loads(_describe(unit)) + + +def _cross_check(description: Mapping[str, Any], field: Optional[str]) -> None: + """The unit's name and its SI description must tell the same story.""" + try: + reference = _descriptor(description["unit"]) + except RecordError: + raise + except Exception as exc: + raise RecordError(reason=f"unit {description['unit']!r} is not known to this reader", field=field) from exc + if reference["unit"] != description["unit"]: + raise RecordError(reason=f"unit {description['unit']!r} is not in canonical spelling", field=field) + si, ref = description["si"], reference["si"] + if si["exponents"] != ref["exponents"]: + raise RecordError(reason=f"unit {description['unit']!r} disagrees with its SI dimensions", field=field) + for key in ("factor", "offset"): + a, b = si[key], ref[key] + if not (a == b == 0) and abs(a - b) > REL_TOL * max(abs(a), abs(b)): + raise RecordError(reason=f"unit {description['unit']!r} disagrees with its SI {key}", field=field) + + +# --- values ---------------------------------------------------------------------------------------- + + +def _normalize_dtype(values: np.ndarray) -> np.ndarray: + if values.dtype.name in DTYPES: + return values + if values.dtype.kind == "f": + return values.astype(np.float64) + if values.dtype.kind == "i" or (values.dtype.kind == "u" and values.dtype.itemsize <= 4): + return values.astype(np.int64) + raise RecordError(reason=f"values of dtype {values.dtype.name} cannot be recorded") + + +def _to_pint(quantity: Any, unit: Optional[str] = None): + from .api.conversion import convert + from .api.introspection import is_quantity + + if isinstance(quantity, QuantityRecord): + return quantity.to_quantity(form="pint", unit=unit) + if not is_quantity(quantity): + raise RecordError(reason="a record is built from a quantity, not from a bare value") + return convert(quantity, to_form="pint", to_unit=unit) + + +def _decode(payload: Any, manifest: Mapping[str, Any], field: Optional[str]) -> np.ndarray: + dtype = np.dtype(manifest["dtype"]).newbyteorder("<") + try: + if isinstance(payload, dict): + values = np.frombuffer(base64.b64decode(payload["base64"], validate=True), dtype=dtype) + else: + values = np.asarray(payload, dtype=dtype) + return values.reshape(manifest["shape"]).astype(dtype.newbyteorder("="), copy=True) + except Exception as exc: + raise RecordError(reason="values do not match the manifest's dtype and shape", field=field) from exc + + +def _encode(values: np.ndarray, encoding: str, field: Optional[str]) -> Any: + if encoding == "base64": + return {"base64": base64.b64encode(_little_endian(values)).decode("ascii")} + if encoding != "json": + raise RecordError(reason=f"unknown encoding {encoding!r}; use 'json' or 'base64'", field=field) + if values.dtype.kind == "f" and not np.all(np.isfinite(values)): + raise RecordError( + reason="JSON has no NaN or infinity; use encoding='base64' for non-finite values", field=field + ) + return values.tolist() + + +# --- QuantityRecord ---------------------------------------------------------------------------------- + + +class QuantityRecord: + """Values plus a verified description of their unit. Inert: it does not compute. + + Build one with :meth:`from_quantity` (or ``puw.convert(q, to_form="record")``), store + it with :meth:`to_dict`, read it back with :meth:`from_dict`, and get a computing + quantity with :meth:`to_quantity`. + + Examples + -------- + >>> import pyunitwizard as puw + >>> from pyunitwizard.record import QuantityRecord + >>> record = QuantityRecord.from_quantity(puw.quantity(3.0, "nM"), field="ic50") + >>> data = record.to_dict() # JSON-ready, sealed + >>> q = QuantityRecord.from_dict(data).to_quantity(field="ic50", unit="uM") + """ + + __slots__ = ("_manifest", "_values", "_block_digests", "_digest") + + def __init__(self) -> None: # pragma: no cover - construction goes through classmethods + raise TypeError("build a QuantityRecord with from_quantity() or from_dict()") + + @classmethod + def _new(cls, manifest, values, block_digests, digest) -> "QuantityRecord": + record = object.__new__(cls) + values = np.array(values, copy=True) + values.setflags(write=False) + record._manifest = manifest + record._values = values + record._block_digests = list(block_digests) + record._digest = digest + return record + + @classmethod + def from_quantity( + cls, + quantity: Any, + *, + field: Optional[str] = None, + unit: Optional[str] = None, + kind: Optional[str] = None, + ) -> "QuantityRecord": + """Seal a quantity. The value and its unit are taken from the same object. + + Parameters + ---------- + quantity : quantity in any PyUnitWizard form + The quantity to record. Bare numbers are refused. + field : str, optional + The name the values are bound to. A reader that declares a field refuses a + record bound to another one, or to none. + unit : str, optional + The negotiated unit to record in. Defaults to the quantity's own unit. + kind : str, optional + A quantity-kind identifier (a QUDT or OBO UO IRI, for instance). It tells apart + units with equal SI dimensions, such as hertz and becquerel. + + Returns + ------- + QuantityRecord + + Raises + ------ + RecordError + If ``quantity`` is not a quantity, or its values cannot be recorded. + """ + q = _to_pint(quantity, unit) + values = _normalize_dtype(np.asarray(q.magnitude)) + manifest: Dict[str, Any] = {"format": FORMAT, "field": field, **_descriptor(_canonical_name(q.units))} + if kind is not None: + manifest["kind"] = str(kind) + manifest.update({"dtype": values.dtype.name, "shape": list(values.shape), "blocks": [int(values.size)]}) + block_digests, digest = _seal(manifest, values) + return cls._new(manifest, values, block_digests, digest) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "QuantityRecord": + """Read a stored record, refusing it unless every check passes. + + Raises + ------ + RecordError + If the manifest is missing or malformed, the seal does not verify, or the unit's + name and SI description disagree. + """ + if not isinstance(data, Mapping) or not isinstance(data.get("manifest"), Mapping): + raise RecordError(reason="no manifest; there is no default unit") + manifest = copy.deepcopy(dict(data["manifest"])) + field = manifest.get("field") + if manifest.get("format") != FORMAT: + raise RecordError(reason=f"format {manifest.get('format')!r} is not {FORMAT!r}", field=field) + for key in ("unit", "si", "dtype", "shape", "blocks"): + if key not in manifest: + raise RecordError(reason=f"the manifest has no {key!r}", field=field) + if manifest["dtype"] not in DTYPES: + raise RecordError(reason=f"dtype {manifest['dtype']!r} is not allowed", field=field) + values = _decode(data.get("values"), manifest, field) + if sum(manifest["blocks"]) != values.size or len(data.get("block_digests", [])) != len(manifest["blocks"]): + raise RecordError(reason="the blocks do not cover the values", field=field) + block_digests, digest = _seal(manifest, values) + if list(data.get("block_digests", [])) != block_digests: + raise RecordError(reason="the values were changed outside the codec", field=field) + if data.get("digest") != digest: + raise RecordError(reason="the manifest was changed outside the codec", field=field) + _cross_check(manifest, field) + return cls._new(manifest, values, block_digests, digest) + + def to_dict(self, *, encoding: str = "json") -> Dict[str, Any]: + """The sealed, JSON-ready representation. + + Parameters + ---------- + encoding : {"json", "base64"} + ``"json"`` writes readable lists and refuses non-finite values, which JSON + cannot hold. ``"base64"`` writes the little-endian bytes (about 1.33 times their + raw size) and holds any value exactly. + """ + return { + "manifest": copy.deepcopy(self._manifest), + "values": _encode(self._values, encoding, self.field), + "block_digests": list(self._block_digests), + "digest": self._digest, + } + + def to_quantity( + self, + *, + field: Optional[str] = None, + unit: Optional[str] = None, + dimensionality: Optional[Dict[str, int]] = None, + kind: Optional[str] = None, + form: Optional[str] = None, + ): + """A computing quantity, after the reader's handshake. + + Parameters + ---------- + field : str, optional + The field the reader expects. A record bound to another field, or to none, is + refused. + unit : str, optional + Convert to this unit. A unit of other dimensions is refused. + dimensionality : dict, optional + Expected dimensionality in PyUnitWizard notation, e.g. ``{"[L]": 1}``. + kind : str, optional + The quantity kind the reader expects. + form : str, optional + The form to return. Defaults to the session's default form, or ``"pint"``. + + Raises + ------ + RecordError + If any expectation is not met. + """ + from . import kernel + from .api.conversion import convert + from .api.validation import check + + if field is not None and self.field != field: + raise RecordError(reason=f"the record belongs to {self.field!r}, not {field!r}", field=field) + if kind is not None and self.kind != kind: + raise RecordError(reason=f"expected kind {kind!r}, the record holds {self.kind!r}", field=self.field) + q = _registry().Quantity(np.array(self._values, copy=True), self.unit) + if unit is not None: + target = _descriptor(str(unit)) + if target["si"]["exponents"] != self.si["exponents"]: + raise RecordError(reason=f"{self.unit!r} cannot be read as {unit!r}", field=self.field) + if target["unit"] != self.unit: + q = q.to(target["unit"]) + if dimensionality is not None and not check(q, dimensionality=dimensionality): + raise RecordError( + reason=f"expected dimensionality {dimensionality}, the record holds {self.unit!r}", field=self.field + ) + target_form = form or kernel.default_form or "pint" + return q if target_form == "pint" else convert(q, to_form=target_form) + + # --- read-only views ------------------------------------------------------------- + + @property + def field(self) -> Optional[str]: + return self._manifest.get("field") + + @property + def unit(self) -> str: + return self._manifest["unit"] + + @property + def si(self) -> Dict[str, Any]: + return copy.deepcopy(self._manifest["si"]) + + @property + def kind(self) -> Optional[str]: + return self._manifest.get("kind") + + @property + def values(self) -> np.ndarray: + """The recorded values, read-only: they cannot change behind the seal.""" + return self._values + + @property + def digest(self) -> str: + return self._digest + + def __repr__(self) -> str: + return ( + f"QuantityRecord(field={self.field!r}, unit={self.unit!r}, " + f"shape={tuple(self._values.shape)}, dtype={self._values.dtype.name})" + ) + + def __eq__(self, other: object) -> bool: + return isinstance(other, QuantityRecord) and other._digest == self._digest + + def __hash__(self) -> int: + return hash(self._digest) + + +# --- bundles --------------------------------------------------------------------------------------- + + +class QuantityRecordBundle: + """Many small quantities under one seal, for documents such as a card. + + A separate record per scalar costs about 350 bytes; a bundle about half, with a single + digest over every entry, its unit description and its values. + """ + + __slots__ = ("_entries", "_digest") + + def __init__(self) -> None: # pragma: no cover + raise TypeError("build a QuantityRecordBundle with from_quantities() or from_dict()") + + @classmethod + def _new(cls, entries, digest) -> "QuantityRecordBundle": + bundle = object.__new__(cls) + bundle._entries = entries + bundle._digest = digest + return bundle + + @staticmethod + def _parts(entries: Mapping[str, Tuple[Dict[str, Any], np.ndarray]]) -> list: + parts: list = [{"format": BUNDLE_FORMAT}] + for name in sorted(entries): + description, values = entries[name] + parts += [name, description, _little_endian(values)] + return parts + + @classmethod + def from_quantities( + cls, quantities: Mapping[str, Any], *, kinds: Optional[Mapping[str, str]] = None + ) -> "QuantityRecordBundle": + """Seal a mapping of field names to quantities (any PyUnitWizard form).""" + entries = {} + for name, quantity in quantities.items(): + q = _to_pint(quantity) + values = _normalize_dtype(np.asarray(q.magnitude)) + description = _descriptor(_canonical_name(q.units)) + if kinds and name in kinds: + description["kind"] = str(kinds[name]) + description.update({"dtype": values.dtype.name, "shape": list(values.shape)}) + values = np.array(values, copy=True) + values.setflags(write=False) + entries[str(name)] = (description, values) + return cls._new(entries, _digest(*cls._parts(entries))) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "QuantityRecordBundle": + """Read a stored bundle, refusing it unless the seal and every unit check pass.""" + if not isinstance(data, Mapping) or data.get("format") != BUNDLE_FORMAT: + raise RecordError(reason=f"not a {BUNDLE_FORMAT!r} bundle") + entries = {} + for name, stored in dict(data.get("entries") or {}).items(): + description = {k: copy.deepcopy(v) for k, v in stored.items() if k != "values"} + for key in ("unit", "si", "dtype", "shape"): + if key not in description: + raise RecordError(reason=f"the entry has no {key!r}", field=name) + if description["dtype"] not in DTYPES: + raise RecordError(reason=f"dtype {description['dtype']!r} is not allowed", field=name) + values = _decode(stored.get("values"), description, name) + values.setflags(write=False) + entries[name] = (description, values) + if data.get("digest") != _digest(*cls._parts(entries)): + raise RecordError(reason="the bundle was changed outside the codec") + for name, (description, _) in entries.items(): + _cross_check(description, name) + return cls._new(entries, data["digest"]) + + def to_dict(self) -> Dict[str, Any]: + """The sealed, JSON-ready representation (strict JSON; non-finite values refused).""" + entries = {} + for name in sorted(self._entries): + description, values = self._entries[name] + entries[name] = {**copy.deepcopy(description), "values": _encode(values, "json", name)} + return {"format": BUNDLE_FORMAT, "entries": entries, "digest": self._digest} + + @property + def fields(self) -> list: + return sorted(self._entries) + + def to_quantities( + self, *, expected: Optional[Mapping[str, Optional[Dict[str, int]]]] = None, form: Optional[str] = None + ) -> Dict[str, Any]: + """Computing quantities, after the handshake. + + Parameters + ---------- + expected : dict, optional + Every field the reader expects, mapped to its expected dimensionality (or + ``None``). A bundle holding other fields, or missing one, is refused. + form : str, optional + The form to return. Defaults to the session's default form, or ``"pint"``. + """ + from . import kernel + from .api.conversion import convert + from .api.validation import check + + if expected is not None and set(expected) != set(self._entries): + raise RecordError(reason=f"the bundle holds {self.fields}, expected {sorted(expected)}") + target_form = form or kernel.default_form or "pint" + out = {} + for name, (description, values) in self._entries.items(): + q = _registry().Quantity(np.array(values, copy=True), description["unit"]) + wanted = (expected or {}).get(name) + if wanted is not None and not check(q, dimensionality=wanted): + raise RecordError( + reason=f"expected dimensionality {wanted}, the entry holds {description['unit']!r}", field=name + ) + out[name] = q if target_form == "pint" else convert(q, to_form=target_form) + return out + + def __repr__(self) -> str: + return f"QuantityRecordBundle(fields={self.fields})" + + +# --- the "record" form inside convert() ------------------------------------------------------------- + + +def _convert_with_records(obj, *, form_in, to_unit, to_form, parser, to_type): + """convert() delegates here whenever the source or the target form is "record".""" + from ._private.exceptions import ArgumentError + from .api.conversion import convert + + if to_type not in ("quantity", "unit", "value"): + raise ArgumentError(argument="to_type") + unit_name = None + if to_unit is not None: + unit_name = ( + to_unit if isinstance(to_unit, str) else _canonical_name(convert(to_unit, to_form="pint", to_type="unit")) + ) + if form_in == "record": + q = obj.to_quantity(form="pint", unit=unit_name) + field, kind = obj.field, obj.kind + else: + q = convert(obj, to_form="pint", to_unit=unit_name, parser=parser) + field = kind = None + if to_form != "record": + return convert(q, to_form=to_form, to_type=to_type, parser=parser) + if to_type == "unit": + return _canonical_name(q.units) + if to_type == "value": + value = np.array(q.magnitude, copy=True) + return value.item() if value.ndim == 0 else value + return QuantityRecord.from_quantity(q, field=field, kind=kind) diff --git a/tests/quantity_record_vectors/affine_celsius.json b/tests/quantity_record_vectors/affine_celsius.json new file mode 100644 index 00000000..995c12e6 --- /dev/null +++ b/tests/quantity_record_vectors/affine_celsius.json @@ -0,0 +1,35 @@ +{ + "record": { + "block_digests": [ + "blake2b-128:d6c6e03474428b859698d3146757148b" + ], + "digest": "blake2b-128:ebabe38f097315eb3ccb74e35d7e4b05", + "manifest": { + "blocks": [ + 2 + ], + "dtype": "float64", + "field": "assay.temperature", + "format": "qrec/0.3", + "shape": [ + 2 + ], + "si": { + "exponents": { + "K": 1 + }, + "factor": 1.0, + "offset": 273.15 + }, + "unit": "degree_Celsius" + }, + "values": [ + 20.0, + 37.0 + ] + }, + "si_values": [ + 293.15, + 310.15 + ] +} diff --git a/tests/quantity_record_vectors/compound_unit.json b/tests/quantity_record_vectors/compound_unit.json new file mode 100644 index 00000000..3619f585 --- /dev/null +++ b/tests/quantity_record_vectors/compound_unit.json @@ -0,0 +1,35 @@ +{ + "record": { + "block_digests": [ + "blake2b-128:438a680675108035cc7306af8b0d2d5a" + ], + "digest": "blake2b-128:2d92ab58f4fa8eda4b77833abce928a2", + "manifest": { + "blocks": [ + 1 + ], + "dtype": "float64", + "field": "fluid.viscosity", + "format": "qrec/0.3", + "shape": [ + 1 + ], + "si": { + "exponents": { + "kg": 1, + "m": -1, + "s": -1 + }, + "factor": 1.0, + "offset": 0.0 + }, + "unit": "newton * second / meter ** 2" + }, + "values": [ + 0.001 + ] + }, + "si_values": [ + 0.001 + ] +} diff --git a/tests/quantity_record_vectors/coordinates_float32_2d.json b/tests/quantity_record_vectors/coordinates_float32_2d.json new file mode 100644 index 00000000..cfbb0de2 --- /dev/null +++ b/tests/quantity_record_vectors/coordinates_float32_2d.json @@ -0,0 +1,48 @@ +{ + "record": { + "block_digests": [ + "blake2b-128:9eb79b04ab5b2c8e16406fcdba54e6d6" + ], + "digest": "blake2b-128:ba035ddf1f57a532f2b0d136d026df52", + "manifest": { + "blocks": [ + 6 + ], + "dtype": "float32", + "field": "structures.coordinates", + "format": "qrec/0.3", + "shape": [ + 2, + 3 + ], + "si": { + "exponents": { + "m": 1 + }, + "factor": 1e-09, + "offset": 0.0 + }, + "unit": "nanometer" + }, + "values": [ + [ + 0.5, + 1.0, + 1.5 + ], + [ + 2.0, + 2.5, + 3.0 + ] + ] + }, + "si_values": [ + 5e-10, + 1e-09, + 1.5e-09, + 2e-09, + 2.5e-09, + 3e-09 + ] +} diff --git a/tests/quantity_record_vectors/counts_int32_dalton.json b/tests/quantity_record_vectors/counts_int32_dalton.json new file mode 100644 index 00000000..b3516a7a --- /dev/null +++ b/tests/quantity_record_vectors/counts_int32_dalton.json @@ -0,0 +1,35 @@ +{ + "record": { + "block_digests": [ + "blake2b-128:032afeb78e1a176e91751a6e8d396ff7" + ], + "digest": "blake2b-128:639fc7f9cc2be715d7679a4965bc5614", + "manifest": { + "blocks": [ + 2 + ], + "dtype": "int32", + "field": "properties.molecular_weight", + "format": "qrec/0.3", + "shape": [ + 2 + ], + "si": { + "exponents": { + "kg": 1 + }, + "factor": 1.66053906892e-27, + "offset": 0.0 + }, + "unit": "dalton" + }, + "values": [ + 180, + 18 + ] + }, + "si_values": [ + 2.988970324056e-25, + 2.988970324056e-26 + ] +} diff --git a/tests/quantity_record_vectors/scalar_concentration_with_kind.json b/tests/quantity_record_vectors/scalar_concentration_with_kind.json new file mode 100644 index 00000000..3e1a2cb3 --- /dev/null +++ b/tests/quantity_record_vectors/scalar_concentration_with_kind.json @@ -0,0 +1,31 @@ +{ + "record": { + "block_digests": [ + "blake2b-128:7680177f7cc17b8a86589f0b5edfa0b3" + ], + "digest": "blake2b-128:46d3340bea3df37c3eda18498d701662", + "manifest": { + "blocks": [ + 1 + ], + "dtype": "float64", + "field": "bioactivity.ic50", + "format": "qrec/0.3", + "kind": "http://qudt.org/vocab/quantitykind/AmountOfSubstanceConcentration", + "shape": [], + "si": { + "exponents": { + "m": -3, + "mol": 1 + }, + "factor": 1e-06, + "offset": 0.0 + }, + "unit": "nanomolar" + }, + "values": 3.0 + }, + "si_values": [ + 3e-06 + ] +} diff --git a/tests/test_catalog_exception_contract.py b/tests/test_catalog_exception_contract.py index 0421e8a6..3998bfac 100644 --- a/tests/test_catalog_exception_contract.py +++ b/tests/test_catalog_exception_contract.py @@ -14,6 +14,7 @@ NotImplementedFormError, NotImplementedMethodError, NotImplementedParserError, + RecordError, ) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -36,6 +37,7 @@ (NotImplementedFormError, {"form": "future-form"}), (NotImplementedMethodError, {}), (NotImplementedParserError, {"parser": "future-parser"}), + (RecordError, {"reason": "digest mismatch", "field": "bioactivity.ic50"}), ] diff --git a/tests/test_quantity_record.py b/tests/test_quantity_record.py new file mode 100644 index 00000000..db51215d --- /dev/null +++ b/tests/test_quantity_record.py @@ -0,0 +1,356 @@ +"""QuantityRecord: the inert interchange form (uibcdf/pyunitwizard#82, design in #83). + +These tests encode the contract: exact round trips, a seal that refuses every change made +outside the codec, a reader handshake, no default unit, and a record that stays inert. +""" + +import copy +import json + +import numpy as np +import pytest + +import pyunitwizard as puw +from pyunitwizard._private.exceptions import RecordError +from pyunitwizard.record import QuantityRecord, QuantityRecordBundle + +CONCENTRATION = {"[L]": -3, "[mol]": 1} + + +def _through_json(data): + return json.loads(json.dumps(data)) + + +def _record(values=(3.0, 12.5, 0.4), unit="nM", field="bioactivity.ic50", **kwargs): + return QuantityRecord.from_quantity(puw.quantity(np.array(values), unit, form="pint"), field=field, **kwargs) + + +# --- round trips --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("encoding", ["json", "base64"]) +@pytest.mark.parametrize( + "values, unit", + [ + (3.0, "nM"), + (np.array([3.0, 12.5, 0.4, np.nan, -0.0]), "nM"), + (np.random.default_rng(0).random((4, 5, 3)).astype(np.float32), "nm"), + (np.arange(10, dtype=np.int32), "dalton"), + (np.array([1.5, 2.25]), "angstrom**2"), + (np.array([0.1, 0.2]), "N*s/m**2"), + (np.array([20.0, 37.0]), "degC"), + (np.array([0.5, 1.0]), "radian"), + (np.array([0.25]), "dimensionless"), + ], +) +def test_round_trip_is_exact(values, unit, encoding): + if encoding == "json" and not np.all(np.isfinite(values)): + pytest.skip("strict JSON has no NaN; covered by test_json_refuses_non_finite_values") + q = puw.quantity(values, unit, form="pint") + record = QuantityRecord.from_dict( + _through_json(QuantityRecord.from_quantity(q, field="f").to_dict(encoding=encoding)) + ) + back = record.to_quantity(field="f", form="pint") + + a, b = np.asarray(puw.get_value(q)), np.asarray(puw.get_value(back)) + assert a.dtype == b.dtype and a.shape == b.shape + assert np.array_equal(a, b, equal_nan=True) + assert np.array_equal(np.signbit(a), np.signbit(b)) + assert str(puw.get_unit(back)) == str(puw.get_unit(q)) + + +def test_json_refuses_non_finite_values(): + # Python writes NaN, which is not JSON: a JavaScript or Go reader would reject the file. + record = _record(values=(1.0, np.nan)) + with pytest.raises(RecordError, match="base64"): + record.to_dict(encoding="json") + assert np.isnan(QuantityRecord.from_dict(_through_json(record.to_dict(encoding="base64"))).values[1]) + + +def test_the_descriptor_describes_the_unit_in_si(): + record = _record(unit="nM") + assert record.unit == "nanomolar" + assert record.si == {"factor": 1e-06, "offset": 0.0, "exponents": {"m": -3, "mol": 1}} + celsius = _record(values=(20.0,), unit="degC", field="t") + assert celsius.si["offset"] == pytest.approx(273.15) + assert _record(values=(1.0,), unit="radian", field="a").si["exponents"] == {} + + +def test_reading_in_another_unit_converts_and_the_same_unit_does_not_round(): + record = _record(values=(5.0,), unit="pM") + assert puw.get_value(record.to_quantity(unit="pM", form="pint"))[0] == 5.0 + assert puw.get_value(record.to_quantity(unit="picomolar", form="pint"))[0] == 5.0 + assert puw.get_value(record.to_quantity(unit="uM", form="pint"))[0] == pytest.approx(5e-6) + + +@pytest.mark.parametrize("form", ["pint", "openmm.unit", "astropy.units"]) +def test_a_record_reaches_other_backends(form): + pytest.importorskip({"pint": "pint", "openmm.unit": "openmm", "astropy.units": "astropy"}[form]) + record = _record(values=(1.5, 2.5), unit="nm", field="x") + q = record.to_quantity(form=form, unit="angstrom") + assert puw.get_form(q) == form + assert np.allclose(puw.get_value(q, to_unit="angstrom"), [15.0, 25.0]) + + +# --- integrity: every change made outside the codec is refused --------------------------------- + + +def _slip(mutate, field="bioactivity.ic50"): + data = _through_json(_record().to_dict()) + mutate(data) + return data + + +SLIPS = { + "raw append": lambda d: ( + d["values"].append(7.0), + d["manifest"]["shape"].__setitem__(0, 4), + d["manifest"]["blocks"].__setitem__(0, 4), + ), + "hand edit of a value": lambda d: d["values"].__setitem__(1, 12.6), + "unit renamed": lambda d: d["manifest"].__setitem__("unit", "picomolar"), + "unit respelled": lambda d: d["manifest"].__setitem__("unit", "nM"), + "SI factor edited": lambda d: d["manifest"]["si"].__setitem__("factor", 1e-9), + "manifest deleted": lambda d: d.pop("manifest"), + "values reordered": lambda d: d["values"].reverse(), + "truncated": lambda d: ( + d["values"].pop(), + d["manifest"]["shape"].__setitem__(0, 2), + d["manifest"]["blocks"].__setitem__(0, 2), + ), + "pasted values": lambda d: d.__setitem__("values", [2.1, 1.8, 2.5]), + "digest removed": lambda d: d.pop("digest"), + "format changed": lambda d: d["manifest"].__setitem__("format", "qrec/9"), +} + + +@pytest.mark.parametrize("name", SLIPS) +def test_every_change_outside_the_codec_is_refused(name): + with pytest.raises(RecordError): + QuantityRecord.from_dict(_slip(SLIPS[name])) + + +def test_a_resealed_lie_is_caught_by_the_redundant_si_description(): + # Someone renames the unit AND recomputes the digest with the library's own helper. + from pyunitwizard.record import _seal + + data = _slip(lambda d: d["manifest"].__setitem__("unit", "picomolar")) + data["block_digests"], data["digest"] = _seal(data["manifest"], np.asarray(data["values"])) + with pytest.raises(RecordError, match="SI factor"): + QuantityRecord.from_dict(data) + + +def test_unit_definitions_may_differ_by_codata_revision_but_not_more(): + from pyunitwizard.record import _seal + + def with_factor(relative_change): + data = _through_json(_record(values=(180.16,), unit="dalton", field="mw").to_dict()) + data["manifest"]["si"]["factor"] *= 1 + relative_change + data["block_digests"], data["digest"] = _seal(data["manifest"], np.asarray(data["values"])) + return data + + QuantityRecord.from_dict(with_factor(1.4e-9)) # CODATA 2018 vs 2022 dalton + with pytest.raises(RecordError): + QuantityRecord.from_dict(with_factor(1e-3)) + + +def test_a_record_never_shares_state_with_the_unit_cache(): + from pyunitwizard.record import _descriptor + + data = _record().to_dict() + data["manifest"]["si"]["factor"] = 123.0 + assert _descriptor("nanomolar")["si"]["factor"] == 1e-06 + + +def test_values_held_in_memory_cannot_be_changed_behind_the_seal(): + record = _record() + with pytest.raises(ValueError): + record.values[0] = 99.0 + + +# --- the handshake ---------------------------------------------------------------------------- + + +def test_the_reader_must_name_the_field_it_expects(): + record = QuantityRecord.from_dict(_through_json(_record().to_dict())) + with pytest.raises(RecordError, match="belongs to"): + record.to_quantity(field="bioactivity.ki") + unbound = QuantityRecord.from_quantity(puw.quantity(3.0, "nM", form="pint")) + with pytest.raises(RecordError, match="belongs to"): + unbound.to_quantity(field="bioactivity.ic50") + + +def test_the_reader_refuses_another_dimension(): + with pytest.raises(RecordError, match="dimensionality"): + _record().to_quantity(dimensionality={"[L]": 1}) + with pytest.raises(Exception): + _record().to_quantity(unit="nm") + _record().to_quantity(dimensionality=CONCENTRATION) + + +def test_the_kind_distinguishes_units_that_share_dimensions(): + record = QuantityRecord.from_quantity(puw.quantity(1.0, "1/s", form="pint"), field="rate", kind="qudt:Frequency") + record.to_quantity(kind="qudt:Frequency") + with pytest.raises(RecordError, match="kind"): + record.to_quantity(kind="qudt:Activity") + + +def test_there_is_no_default_unit(): + with pytest.raises(RecordError): + QuantityRecord.from_dict({"values": [3.0], "digest": "x"}) + + +def test_the_writer_refuses_bare_numbers(): + with pytest.raises(RecordError, match="quantity"): + QuantityRecord.from_quantity(np.array([3.0]), field="f") + + +def test_a_consistent_writer_bug_is_not_detectable_by_any_format(): + # Meant nM, built pM, wrote pM: coherent with itself. Canaries and tests catch this. + record = _record(values=(3.0,), unit="pM") + assert record.unit == "picomolar" + + +# --- inert on purpose --------------------------------------------------------------------------- + + +def test_a_record_does_not_compute(): + record = _record() + with pytest.raises(TypeError): + _ = record + record + with pytest.raises(TypeError): + _ = record * 2 + + +# --- a form of PyUnitWizard ------------------------------------------------------------------------ + + +def test_record_is_a_form(): + q = puw.quantity(np.array([3.0, 12.5]), "nM", form="pint") + record = puw.convert(q, to_form="record") + assert isinstance(record, QuantityRecord) + assert puw.get_form(record) == "record" + assert puw.is_quantity(record) and not puw.is_unit(record) + assert np.array_equal(puw.get_value(record), [3.0, 12.5]) + assert puw.get_unit(record) == "nanomolar" + assert puw.get_dimensionality(record) == puw.get_dimensionality(q) + back = puw.convert(record, to_form="pint", to_unit="uM") + assert np.allclose(puw.get_value(back), [0.003, 0.0125]) + + +def test_quantity_can_be_built_directly_in_the_record_form(): + record = puw.quantity(np.array([1.0, 2.0]), "nm", form="record") + assert isinstance(record, QuantityRecord) and record.unit == "nanometer" + + +def test_converting_a_record_to_another_unit_keeps_it_a_record(): + record = puw.convert(_record(), to_unit="uM") + assert isinstance(record, QuantityRecord) + assert record.unit == "micromolar" and record.field == "bioactivity.ic50" + assert np.allclose(record.values, [0.003, 0.0125, 0.0004]) + + +def test_records_are_independent_of_the_session_policy(): + # A record states its own unit; standard units belong to the user, never to the data. + q = puw.quantity(np.array([1.5]), "nm", form="pint") + with puw.context(standard_units=["angstrom", "ps", "K", "mole", "dalton"]): + record = QuantityRecord.from_quantity(q, field="x") + assert record.unit == "nanometer" + assert puw.get_value(record.to_quantity(field="x", form="pint"))[0] == 1.5 + + +def test_canonical_spelling_does_not_follow_pint_display_settings(): + from pyunitwizard.forms.api_pint import ureg + + previous = ureg.formatter.default_format + try: + ureg.formatter.default_format = "~P" + assert _record().unit == "nanomolar" + finally: + ureg.formatter.default_format = previous + + +# --- bundles -------------------------------------------------------------------------------------- + +CARD = { + "properties.physchem.molecular_weight": puw.quantity(180.16, "dalton", form="pint"), + "properties.physchem.tpsa": puw.quantity(63.6, "angstrom**2", form="pint"), + "structures.resolution": puw.quantity(2.1, "angstrom", form="pint"), +} +EXPECTED = { + "properties.physchem.molecular_weight": {"[M]": 1}, + "properties.physchem.tpsa": {"[L]": 2}, + "structures.resolution": {"[L]": 1}, +} + + +def test_bundle_round_trip(): + bundle = QuantityRecordBundle.from_dict(_through_json(QuantityRecordBundle.from_quantities(CARD).to_dict())) + out = bundle.to_quantities(expected=EXPECTED, form="pint") + assert float(puw.get_value(out["properties.physchem.tpsa"])) == 63.6 + assert str(puw.get_unit(out["structures.resolution"])) == "angstrom" + + +BUNDLE_SLIPS = { + "a scalar edited": lambda b: b["entries"]["structures.resolution"].__setitem__("values", 2.2), + "a unit renamed": lambda b: b["entries"]["structures.resolution"].__setitem__("unit", "nanometer"), + "an entry removed": lambda b: b["entries"].pop("properties.physchem.tpsa"), + "two entries swapped": lambda b: b["entries"].update( + { + "properties.physchem.tpsa": b["entries"]["structures.resolution"], + "structures.resolution": b["entries"]["properties.physchem.tpsa"], + } + ), +} + + +@pytest.mark.parametrize("name", BUNDLE_SLIPS) +def test_bundle_slips_are_refused(name): + data = _through_json(QuantityRecordBundle.from_quantities(CARD).to_dict()) + BUNDLE_SLIPS[name](data) + with pytest.raises(RecordError): + QuantityRecordBundle.from_dict(data).to_quantities(expected=EXPECTED) + + +def test_bundle_reader_declares_every_field_it_expects(): + bundle = QuantityRecordBundle.from_quantities(CARD) + with pytest.raises(RecordError, match="expected"): + bundle.to_quantities(expected={"structures.resolution": {"[L]": 1}}) + with pytest.raises(RecordError, match="dimensionality"): + bundle.to_quantities(expected={**EXPECTED, "structures.resolution": {"[M]": 1}}) + + +# --- published test vectors: the format stays stable, and third parties can check readers --------- + + +def test_published_vectors_verify_and_are_reproduced_exactly(): + from pathlib import Path + + folder = Path(__file__).parent / "quantity_record_vectors" + for path in sorted(folder.glob("*.json")): + case = json.loads(path.read_text(encoding="utf-8")) + record = QuantityRecord.from_dict(case["record"]) + q = record.to_quantity(field=case["record"]["manifest"]["field"], form="pint") + rebuilt = QuantityRecord.from_quantity(q, field=record.field, kind=record.kind) + assert rebuilt.to_dict() == case["record"], path.name + si = np.asarray(record.values, dtype=float).reshape(-1) * record.si["factor"] + record.si["offset"] + assert np.allclose(si, case["si_values"]), path.name + + +def test_the_standard_library_reference_reader_agrees(): + from pathlib import Path + + from pyunitwizard._private.record_reference_reader import read_si + + folder = Path(__file__).parent / "quantity_record_vectors" + cases = sorted(folder.glob("*.json")) + assert len(cases) >= 4 + for path in cases: + case = json.loads(path.read_text(encoding="utf-8")) + manifest = case["record"]["manifest"] + values = read_si(case["record"], manifest["field"], manifest["si"]["exponents"]) + assert np.allclose(values, case["si_values"]), path.name + tampered = copy.deepcopy(case["record"]) + tampered["manifest"]["unit"] = "tampered" + with pytest.raises(ValueError): + read_si(tampered, manifest["field"], manifest["si"]["exponents"]) From 01a5fba55585b952f7e50b6dc36224350e57e02d Mon Sep 17 00:00:00 2001 From: Diego Prada Date: Thu, 24 Sep 2026 02:59:46 -0600 Subject: [PATCH 2/3] docs(record): document QuantityRecord for users and integrators (#82) A user guide page (what a record guarantees, bundles, encodings, kinds, and why it is not a passport), the API reference entries, and a section of the canonical integration guide on storing and exchanging quantities. The guide's passport anti-pattern now says why QuantityRecord is not one, and names a second anti-pattern: handing a standardized value to another tool as a bare number (uibcdf/molsysviewer#96). Co-Authored-By: Claude Opus 5.5 --- docs/api/users/api_user.rst | 13 ++++ docs/content/user/index.md | 6 ++ docs/content/user/quantity-records.md | 103 ++++++++++++++++++++++++++ standards/PYUNITWIZARD_GUIDE.md | 26 ++++++- 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docs/content/user/quantity-records.md diff --git a/docs/api/users/api_user.rst b/docs/api/users/api_user.rst index 06e9697d..6411d64c 100644 --- a/docs/api/users/api_user.rst +++ b/docs/api/users/api_user.rst @@ -53,6 +53,19 @@ Core Introspection and Extraction pyunitwizard.get_value_and_unit pyunitwizard.change_value +Interchange Form (provisional) +------------------------------ + +Quantity records carry values with a verified description of their unit, for storage +and exchange between tools. See :doc:`../../content/user/quantity-records`. The API is +provisional until the 1.0 checklist promotes it. + +.. autosummary:: + :toctree: autosummary + + pyunitwizard.QuantityRecord + pyunitwizard.QuantityRecordBundle + Standardization and Context --------------------------- diff --git a/docs/content/user/index.md b/docs/content/user/index.md index 28d9fe1d..8d88ce5d 100644 --- a/docs/content/user/index.md +++ b/docs/content/user/index.md @@ -29,6 +29,11 @@ In this route you will learn how to: - diagnose common failures quickly, - communicate reproducible unit issues to library maintainers. +## Storing and Exchanging Quantities + +To write quantities to files, databases or messages without ever losing or misreading +their unit, read [Quantity Records](quantity-records.md). + Both routes converge on the same objective: unit-safe workflows across scientific Python libraries without hidden assumptions. @@ -68,6 +73,7 @@ numpy-pandas-matplotlib.md quickstart.md mini-library-walkthrough.md configuration.md +quantity-records.md integrating-your-library.md troubleshooting.md production-checklist.md diff --git a/docs/content/user/quantity-records.md b/docs/content/user/quantity-records.md new file mode 100644 index 00000000..997ed476 --- /dev/null +++ b/docs/content/user/quantity-records.md @@ -0,0 +1,103 @@ +# Quantity Records + +A number without its unit is the most dangerous value in a scientific pipeline. One tool +stores *3 nanomolar*; the next reads *3 picomolar*; nothing fails and the result is simply +wrong. A dimension check cannot catch it, because both are concentrations. Only a unit +that never leaves its values, and a reader that never assumes one, prevent it. + +`QuantityRecord` is PyUnitWizard's form for storing and exchanging quantities. It is +**inert**, like the `"string"` form: it converts to and from every other form, but it does +not compute. To compute, convert it to pint, openmm.unit, astropy.units or unyt. + +```{note} +Provisional API. `QuantityRecord` and the `qrec/0.3` format may still change before the +1.0 checklist promotes them. The design, the alternatives evaluated and the measurements +are recorded in [uibcdf/pyunitwizard#83](https://github.com/uibcdf/pyunitwizard/issues/83). +``` + +## Writing and reading + +```python +import json +import pyunitwizard as puw +from pyunitwizard import QuantityRecord + +ic50 = puw.quantity([3.0, 12.5, 0.4], "nM") + +record = QuantityRecord.from_quantity(ic50, field="bioactivity.ic50") +text = json.dumps(record.to_dict()) # store or send it + +record = QuantityRecord.from_dict(json.loads(text)) # verified, or refused +q = record.to_quantity(field="bioactivity.ic50", unit="uM") +``` + +`record` is also a form, so the generic API works on it: + +```python +record = puw.convert(ic50, to_form="record") +puw.get_form(record) # 'record' +puw.get_unit(record) # 'nanomolar' +puw.convert(record, to_form="openmm.unit", to_unit="uM") +``` + +## What a record guarantees + +| Guarantee | How | +|---|---| +| The unit travels with the values | Every record holds its unit's canonical name *and* its description in SI base units (factor, offset, exponents). Readers check that both agree. | +| No change goes unnoticed | A blake2b digest covers the unit description and the values' bytes. Only `QuantityRecord` produces it, so a hand edit, a raw append, reordered or truncated values, or a renamed unit all fail on read. | +| The reader says what it expects | `to_quantity(field=..., unit=..., dimensionality=..., kind=...)` refuses a record bound to another field, of another dimension, or of another kind. | +| No defaults | A record without its manifest is refused. There is no fallback unit, ever. | +| Independent of the session | A record states its own unit; the session's standard units never change what it means. | +| Readable without PyUnitWizard | A reader needs a hash function, JSON and IEEE-754 floats. `pyunitwizard/_private/record_reference_reader.py` does it with the Python standard library alone, and `tests/quantity_record_vectors/` holds test vectors for readers in other languages. | + +What no format can detect is a writer that is wrong *and* consistent: it meant nM, built pM, +and wrote pM. Contracts at your API boundaries and tests that compare values across tools +cover that case. + +## Kinds + +Some different quantities share SI dimensions: hertz and becquerel, joule and newton-metre. +Record the kind when it matters, and ask for it when reading: + +```python +rate = QuantityRecord.from_quantity(puw.quantity(1.0, "1/s"), field="rate", + kind="http://qudt.org/vocab/quantitykind/Frequency") +rate.to_quantity(kind="http://qudt.org/vocab/quantitykind/Frequency") +``` + +## Many small values: bundles + +A document such as a data card holds many scalars. A `QuantityRecordBundle` seals them +together, at about half the size of one record each: + +```python +from pyunitwizard import QuantityRecordBundle + +card = QuantityRecordBundle.from_quantities({ + "molecular_weight": puw.quantity(180.16, "dalton"), + "tpsa": puw.quantity(63.6, "angstrom**2"), +}) +data = card.to_dict() +values = QuantityRecordBundle.from_dict(data).to_quantities( + expected={"molecular_weight": {"[M]": 1}, "tpsa": {"[L]": 2}} +) +``` + +## Encodings + +- `to_dict(encoding="json")` writes readable lists. It refuses NaN and infinity, which JSON + cannot hold, so that every JSON reader, not only Python's, can load the file. +- `to_dict(encoding="base64")` writes the values' little-endian bytes (about 1.33 times + their raw size), holds any value exactly, and is faster for large arrays. + +Bindings for HDF5 (with a CF-compatible `units` attribute), Arrow and Zarr, and a layout +for collections that mix units, are planned; see +[uibcdf/pyunitwizard#82](https://github.com/uibcdf/pyunitwizard/issues/82). + +## Not a passport + +PyUnitWizard's integration guide lists "passports" as an anti-pattern: registries or caches +that remember a quantity was once canonical, so that a check can be skipped. A quantity +record is the opposite. It remembers nothing about live objects, and it is verified, never +trusted, every time it is read. diff --git a/standards/PYUNITWIZARD_GUIDE.md b/standards/PYUNITWIZARD_GUIDE.md index 5ded73df..cc49f997 100644 --- a/standards/PYUNITWIZARD_GUIDE.md +++ b/standards/PYUNITWIZARD_GUIDE.md @@ -309,6 +309,26 @@ Fast tracks do not make a user-facing boundary trusted. Preserve dimensionality, shape, dtype, and local exception validation unless the caller has already established those invariants. +## Storing and exchanging quantities (provisional) + +When a quantity leaves your library, whether written to a file or database, sent to +another tool, or passed to a frontend, its unit must leave with it, and the receiver +must not have to assume one. Use the `"record"` form (`QuantityRecord`): + +```python +record = puw.convert(q, to_form="record") # or QuantityRecord.from_quantity(q, field=...) +data = record.to_dict() # sealed, JSON-ready +q = QuantityRecord.from_dict(data).to_quantity(field="...", unit="nm") # verified, then handshake +``` + +- A record carries the unit's canonical name and its SI description, both covered by a + digest. Any change made outside PyUnitWizard fails on read, and there is no default unit. +- Readers declare what they expect (field, unit, dimensionality, kind); a mismatch is an + error. +- For many small values in one document, use `QuantityRecordBundle`. +- Questions about serializing quantities belong in uibcdf/pyunitwizard#83 (design record), + not in each consumer. The API is provisional until the 1.0 checklist promotes it. + ## Consumer anti-patterns Avoid these patterns in new code: @@ -319,7 +339,11 @@ Avoid these patterns in new code: `has_unit()` for that exact question. - direct access to backend attributes such as Pint's `.units` in scientific logic. - identity registries, passports, or mutable-object caches whose only purpose is to - remember that a quantity was canonical. + remember that a quantity was canonical. (`QuantityRecord` is not one: it remembers + nothing about live objects and is verified, never trusted, every time it is read.) +- stripping a standardized quantity with `get_value()` and handing the bare number to + another tool, file or frontend. Standard units belong to the user's session, so the + number's unit is not what the receiver assumes; use `to_unit=` or a `QuantityRecord`. - unconditional `standardize()` or conversion of a quantity constructed locally in an already-canonical unit. - adding `has_unit()` to an unmeasured cold path. The general API is clearer and its From 163520330637d8829f75f6f8d2af106d98658712 Mon Sep 17 00:00:00 2001 From: Diego Prada Date: Thu, 24 Sep 2026 02:59:46 -0600 Subject: [PATCH 3/3] docs(devguide): record QuantityRecord and admit it as provisional (#82) The report for #82 (implemented MVP, deferred scope, measurements, refuted alternatives, acceptance criteria). The serialization contract draft is marked superseded: its safety rules are met and its promotion gate is met by uibcdf/sabueso#32 and uibcdf/molsysmt#240. The 1.0 checklist admits the new surface as provisional, as the pre-1.0 API freeze requires. Co-Authored-By: Claude Opus 5.5 --- devguide/pending_proposals/README.md | 4 + devguide/pending_proposals/quantity_record.md | 136 ++++++++++++++++++ devguide/release_1.0.0_checklist.md | 3 +- devguide/serialization_contract_draft.md | 9 +- 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 devguide/pending_proposals/quantity_record.md diff --git a/devguide/pending_proposals/README.md b/devguide/pending_proposals/README.md index 8bac8e02..593ecb42 100644 --- a/devguide/pending_proposals/README.md +++ b/devguide/pending_proposals/README.md @@ -5,6 +5,10 @@ Edit report front matter and regenerate this index; do not edit the generated bl +### Active (1) + +- [`quantity_record.md`](quantity_record.md) — [#82](https://github.com/uibcdf/pyunitwizard/issues/82) — QuantityRecord — an inert interchange form that never loses or misreads a unit (provisional MVP implemented). *(measured)* + ### Open (2) - [`forallpeople_integration.md`](forallpeople_integration.md) — [#44](https://github.com/uibcdf/pyunitwizard/issues/44) — Decide whether forallpeople should become a supported quantity form. *(inspected)* diff --git a/devguide/pending_proposals/quantity_record.md b/devguide/pending_proposals/quantity_record.md new file mode 100644 index 00000000..951b0403 --- /dev/null +++ b/devguide/pending_proposals/quantity_record.md @@ -0,0 +1,136 @@ +--- +summary: QuantityRecord — an inert interchange form that never loses or misreads a unit (provisional MVP implemented). +issue: uibcdf/pyunitwizard#82 +status: active +opened: 2026-09-24 +closed: +verification: measured +area: [serialization, interoperability, forms] +guard: +normative: +blocked_by: [] +supersedes: [] +--- + +# QuantityRecord: PyUnitWizard's inert interchange form + +## What + +A form, `"record"`, whose objects (`QuantityRecord`) carry values together with a verified +description of their unit, so that a number is never read in a unit it was not written in. +It is inert like `"string"`: it converts to and from every form and does not compute. +`QuantityRecordBundle` seals many small quantities of one document (a card) together. + +The design, its motivation, the alternatives evaluated (per-value `{value, unit}`, unit +strings, pint tuples, unenforced container manifests, ASDF, CF/UDUNITS, OpenFF, HDF5 and +Parquet checksums, UCUM, QUDT, UO, D-SI) and the prior art (ArgDigest's removed passport +and declined value certification) are recorded in uibcdf/pyunitwizard#83. It supersedes +`devguide/serialization_contract_draft.md`, whose safety rules it meets and whose +promotion gate (two real integration cases) is met by uibcdf/sabueso#32 and +uibcdf/molsysmt#240. + +## How + +Implemented in this MVP (`pyunitwizard/record.py`, `forms/api_record.py`): + +- **Manifest**: `format` (`qrec/0.3`), `field` (or null), `unit` (canonical name: pint's + default long form, whatever display format a registry is configured with), `si` + (`factor`, `offset`, `exponents` over `m, kg, s, A, K, mol, cd`, with QUDT's semantics), + optional `kind` (quantity-kind IRI), `dtype`, `shape` and `blocks`. +- **Seal**: blake2b-128 per block, and a record digest over the canonical manifest and the + block digests. The manifest is encoded with a tagged little-endian binary encoding, + because JSON text is not canonical across languages; the values are C-order + little-endian bytes with NaN canonicalized. +- **Reading**: `from_dict` refuses a missing manifest (there is no default unit), an + unknown format, a malformed dtype or shape, a broken seal, or a unit whose canonical name + disagrees with its SI description beyond a relative 1e-6. `to_quantity(field=, unit=, + dimensionality=, kind=, form=)` is the handshake. +- **Encodings**: strict JSON, which refuses NaN and infinity because other languages + cannot read them, and base64. +- **Form integration**: `get_form` detects records. `convert` delegates whenever the + source or the target is `"record"`. The form's dispatch entries make `get_value`, + `get_unit`, `get_dimensionality`, `change_value` and `quantity(..., form="record")` work. + Records never import a backend at load time. +- **Diagnostics**: `RecordError` (`PUW-ERR-REC-001`) in the SMonitor catalog. +- **Third parties**: `_private/record_reference_reader.py` reads and verifies records with + the Python standard library only. `tests/quantity_record_vectors/` holds frozen vectors, + with SI values written by hand, and `devtools/generate_quantity_record_vectors.py` + regenerates them. + +Deferred, each to be justified by a consumer case: + +- the HDF5 binding with a CF `units` attribute (needed by uibcdf/molsysmt#240); +- the tagged layout for mixed units (TaggedQuantities; #83); +- UCUM and CF spellings derived with real parsers (#85); +- Arrow and Zarr bindings; +- growing records by appended blocks; +- OpenFF (#86); +- foreign pint registries (#84); +- a live, computing form (#87). + +## Why + +A unit lost at a boundary produces a wrong number with no error: the Mars Climate Orbiter +case, uibcdf/molsysviewer#96 (10× under a user Å policy), and the latent H5MSM mechanisms of +uibcdf/molsysmt#240. PyUnitWizard owns units across MOLI and MolSysSuite +(uibcdf/moli#13, uibcdf/molsyssuite#46), so one implementation here prevents every +consumer from inventing a format. + +## What is measured and what is assumed + +Measured with the prototype (#82, `qrec/0.2`; Python 3.13, pint 0.25.3): + +- JSON base64: 1.33× raw size; 100k values written in 5 ms and read in 4 ms. +- A bundle holds 50 scalars at 166 B each, against 347 B for separate records. +- The digest runs at about 0.3–0.6 GB/s. +- 20 deliberate slips were refused across records, HDF5 and bundles. +- CF strings were validated with cf-units 3.3.1. + +Measured with this implementation: + +- 58 tests: exact round trips (NaN, −0.0, float32, int32, n-D, compound and affine + units); 11 slips refused; a resealed lie caught by the SI redundancy; the CODATA + tolerance; records independent of the session policy; canonical spelling independent of + pint's display format; the form's integration; bundles; published vectors reproduced + byte for byte; the reference reader agreeing. +- Full suite: 626 passed. + +Assumed: the 1e-6 tolerance separates definition drift (≤ ~7e-7 observed between UDUNITS +and pint) from different units. It must be re-measured if a consumer meets a larger +legitimate difference. + +## What was refuted + +- **Per-value `{value, unit}` for collections**: 4.3× the size, 5× the load time. +- **A container manifest without a seal**: this is the H5MSM failure mode. +- **Container-native checksums** (Fletcher32, Parquet CRC, ASDF MD5): any writer + recomputes them. +- **A global unit-code catalogue**: codes that change meaning between versions misread old + files. +- **A separate TaggedQuantities class**: two classes for one concept diverge, as the + passport showed. + +## Scope and exclusions + +- Protection against deliberate falsification needs signatures and is out of scope. +- A writer that is consistently wrong (meant nM, wrote pM) cannot be detected by any + format. That is covered by API contracts (ArgDigest), cross-tool canaries and + conformance tests under a non-default session policy. + +## Acceptance criteria + +- MVP: `record` form, bundles, strict JSON and base64 encodings, seal, handshake, no + defaults, `RecordError`, reference reader, vectors and user documentation. Done in this + change. +- Closure of #82: the deferred items above are either implemented or split into their own + issues; Sabueso (#32) and one MolSysSuite member consume the form; the 1.0 checklist + decides between promotion and continued provisional status. The guard at closure will be + `tests/test_quantity_record.py::test_every_change_outside_the_codec_is_refused`. + +## Dependencies and risks + +- **The format may change before promotion.** Consumers pin the `qrec/` they + read, and the vectors make any change visible. +- **pint is required to build or read records into quantities.** Verification alone needs + only hashlib (see the reference reader). +- **The first record in a process builds pint's registry** (0.3–0.5 s). diff --git a/devguide/release_1.0.0_checklist.md b/devguide/release_1.0.0_checklist.md index 3f9f255e..486939d1 100644 --- a/devguide/release_1.0.0_checklist.md +++ b/devguide/release_1.0.0_checklist.md @@ -40,6 +40,7 @@ Use these documents as the primary evidence sources while closing each section: - [ ] Transparent frontend mode contract is frozen and test-backed (`devguide/frontend_transparent_mode_contract.md`). - [ ] Legacy `pyunitwizard.main` deprecation contract is tested and documented. - [ ] No open blockers in conversion, standardization, parsing, or compatibility workflows. +- [ ] Provisional `QuantityRecord` / `QuantityRecordBundle` API and `qrec` format (#82): either promoted (frozen, contract-tested, format version fixed) or explicitly kept provisional in the release notes. Admitted as a provisional surface on 2026-09-24 under the change-control policy of `api_freeze_pre_1.0_decision.md`. ## 3. Diagnostics and observability @@ -61,7 +62,7 @@ Use these documents as the primary evidence sources while closing each section: - [ ] Linux and macOS build and installation workflows are green (`sdist`, wheel, conda). - [ ] Release artifacts do not claim or publish Windows support. - [ ] README and devguide documents are synchronized with shipped behavior. -- [ ] Serialization draft status is explicit in release notes (`devguide/serialization_contract_draft.md`: draft/non-stable unless promoted). +- [ ] Serialization status is explicit in release notes: `devguide/serialization_contract_draft.md` is superseded by `QuantityRecord` (#82), whose API and `qrec` format are provisional unless promoted. - [ ] Tag/release notes prepared with migration notes (if any). ## 6. Final go/no-go diff --git a/devguide/serialization_contract_draft.md b/devguide/serialization_contract_draft.md index 19599dec..03373c84 100644 --- a/devguide/serialization_contract_draft.md +++ b/devguide/serialization_contract_draft.md @@ -1,6 +1,13 @@ # Serialization Contract Draft (Post-1.0 Candidate) -Status: draft for future promotion. Not a committed public API yet. +Status: **superseded** (2026-09-24) by `QuantityRecord`, the inert interchange form +(`pyunitwizard/record.py`; issue uibcdf/pyunitwizard#82; design record #83; report +`pending_proposals/quantity_record.md`). Kept as history. Its safety rules are all met +by the new design: versioned format (`qrec/0.3`), dimensional validation on read, explicit +errors for missing or malformed fields, and no implicit backend import. Its promotion +gate is met by two real integration cases: uibcdf/sabueso#32 and uibcdf/molsysmt#240. The +`form` field of the payload below was dropped: a record is independent of the backend it +came from. ## Goal