You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue is the design record for how MOLI and MolSysSuite components serialize and exchange physical quantities. It records why the discussion happened, what was measured, what was discarded, what was chosen and what remains open. Questions or new proposals about quantity serialization in any MOLI or MolSysSuite component belong here, or in the implementation issue #82. They should not restart in a consumer repository. The durable form of this record should become a PyUnitWizard devguide page shipped with the implementation (#82), so that the reasoning outlives this thread.
The discussion took place on 2026-09-24 between the Sabueso lead (Diego) and an agent working on Sabueso. Diego took the decisions below.
1. Why the discussion happened
The failure to rule out: one tool stores or computes 3 nanomolar and the next reads 3 picomolar. Nothing fails; the number is just wrong. A dimension check cannot catch it, because both are concentrations. Only a unit that never leaves the value, and a reader that never assumes one, prevent it.
Evidence found while studying the components (origin/main, 2026-09-24):
MolSysViewer (Standardized lengths are sent to the frontend as nm, 10x off under a user Å policy molsysviewer#96). It takes a standardized value from MolSysMT, strips the unit and assumes nm. Under a user session policy in Å, the simulation box is drawn 10× too large, silently. Measured on 1tcd.h5msm: msm.get(..., box=True) returns 4.371 under MolSysMT's policy and 43.71 under a user Å policy, and both are treated as nm.
MolSysMT H5MSM (uibcdf/molsysmt, reported alongside). The unit of the same data lives in three places: root attributes, the structures group and each dataset. Different readers consult different places: get_structural_attributes reads the root, to_molsysmt_Structures reads the dataset, then the root, then a literal default ("nm", "ps", "nanometer**2"). The writer writes the values and their unit in separate statements.
in the field name (resolution_angstrom, test_concentration_uM);
in metadata ({"unit": "Da"});
as the source's verbatim string (ChEMBL units: "nM");
nowhere at all (tpsa, where Ų is implicit).
Unit symbols are case-sensitive and collide across dimensions.nM is nanomolar and nm is nanometre. NM is rejected, correctly. Source notations such as ChEMBL's ug.mL-1 fail in pint, which reads -1 as a subtraction.
Environment: Python 3.13.14, pint 0.25.3, numpy 2.4.6, pyunitwizard 0.25.0+7.g00d756c, Linux, Xeon E5-2630 v4. Timings vary by about 2× between runs on this machine, so treat them as orders of magnitude. The data is 100,000 concentrations unless stated otherwise.
Representation
JSON size
gzip
Load
Memory per value
bare numbers (unit implied)
0.98 MB
0.42 MB
12 ms
24 B (Python float)
{value, unit} per value
4.18 MB
0.50 MB
58 ms
192 B (dict)
unit once per container
0.98 MB
0.42 MB
12 ms
≈ bare
unit-tagged array (per-value 1-byte code + local unit table), text
1.28 MB
0.46 MB
25 ms
9 B
unit-tagged array, base64 binary
1.20 MB
—
2.9 ms
9 B
Operation
Cost
puw.quantity(value, unit) for one scalar
17 µs (100k scalars: 1.7 s)
puw.quantity for one 100k array
4.4 ms; conversion of that array: 0.13 ms
get_value(q, to_unit=...) for a scalar
25 µs
puw.check(q, dimensionality=...)
14 µs
puw.conversion_factor
1.8 µs
decode and convert 100k records with a factor cached per distinct unit
19 ms (0.19 µs/value)
comparing two scalar quantities vs two floats
28 µs vs 45 ns
pint Quantity.from_tuple vs parsing "3.0 nanomolar"
5.3 µs vs 13.8 µs
first quantity in a process (registry build)
490 ms; 311 ms with set_pint_registry_cache(True). MolSysMT's _pyunitwizard.py comments claim 180 → 17 ms; worth re-measuring.
80–150 ms (≈0.3–0.6 GB/s); sha256 similar; crc32 3–5× faster
Other facts checked:
Using PyUnitWizard without configuring it does not activate a policy (has_active_policy() stays false).
pint quantities from different UnitRegistry instances cannot be combined (ValueError), so every component must obtain quantities through PyUnitWizard's registry.
pint normalizes spellings: N*s/m**2, s*N/m^2 and newton*second/meter**2 all become 'newton * second / meter ** 2'. Dimensionally equal units stay distinct (kg/(m*s), Pa*s), with a factor of 1 between them.
conversion_factor("degC", "K") raises: affine units fail loudly instead of multiplying.
{value, unit} on every value, for collections. 4× the size and 5× the load time, 192 B per value, and clumsy for arrays. It is still fine for a lone scalar.
pint's structured tuple as the canonical format. It is fast for one scalar, but it creates one object per value and ties the format to pint.
A unit manifest per container with nothing enforcing it. It costs nothing, but it permits exactly the H5MSM mechanisms: separate writes, several unit sources and silent defaults. Rejected as the contract.
A global catalogue of unit tags. If code 7 changed meaning between versions, old files would be misread silently. Unit tables must be local to each object or document.
Treating logarithmic scales as units. They are not; see open question 3.
crc32 or xxhash for the digest. crc32 is not collision-resistant, and xxhash is an extra dependency. blake2b ships with Python and is fast enough.
4. What was chosen: negotiated containers with an integrity digest
Diego's decisions:
Quantity-valued answers are returned as quantities.
Canonical unit strings are ones PyUnitWizard parses and that round-trip, in the unambiguous long form.
The balance of memory, speed and safety favours negotiated containers with a digest.
The design has three layers, each with a clear owner:
Layer
Guarantee
Owner
API contract
Writers hand over a quantity with the expected dimensionality, never a bare number
ArgDigest (contrib/pyunitwizard_support.check)
Codec
The only writer and reader. It writes the manifest next to the values, converts with an explicit to_unit, and reads with a handshake
The negotiated unit is written in the document, inside the same serialized object as the values, never only in a schema.
Each field has one unit source, with no root-level fallback.
There are no defaults. A missing manifest is an error.
Values enter and leave only through the codec, from and to quantities. Appends convert incoming quantities to the negotiated unit.
Reader handshake. The reader declares the field and the expected unit or dimensionality: an exact match, or an explicit conversion. A dimension mismatch is an error.
Digest. blake2b-128 over the canonical manifest (format, field, canonical unit, dtype, shape) plus the values' little-endian bytes, not the JSON text. Reformatting a document is harmless; changing a number is not.
Tests:
conformance under a non-default session policy in every component (it would have caught molsysviewer#96);
cross-component canaries (one component writes 3 nM, another asserts that it reads 3 nM);
a static guard against low-level writes (h5py, SQL, raw JSON) outside the codec module.
A prototype subjected to deliberate slips detected 9 of 9:
a raw append;
a hand edit of a value;
a unit changed in the manifest (nM → pM);
a unit respelled by hand;
a deleted manifest;
reordered values;
truncated values;
a record copied into another field;
length values pasted into a concentration field.
It verified an indented re-serialization. It cannot detect a writer that is consistently wrong, for example one that meant nM but built and labelled pM. ArgDigest covers that case when the dimension differs; the canaries and conformance tests cover it when the dimension is the same. The threat model is mistakes, not adversaries: someone who recomputes the digest is out of scope, and that would need signatures.
The digest detects more than per-value tags do. With {value, unit} or tags without a digest, a hand edit, a reorder, a truncation or pasted values of the same unit all pass unnoticed.
Unit-tagged arrays (working name TaggedQuantities: values plus a small per-value code into a local unit table) remain useful for genuinely heterogeneous collections, protected by the same digest. At 9 B per value, conversion of mixed units is vectorized (0.38 ms for 100k across 4 units), and the round trip is bit-identical. The layout maps onto Arrow and Parquet dictionary columns and onto HDF5.
Prototype codec used for the slip tests (sketch, not the API)
defwrite(field, quantity, unit):
ifnotpuw.is_quantity(quantity):
raiseTypeError("the codec writes quantities, not bare numbers")
values=np.asarray(puw.get_value(quantity, to_unit=unit))
manifest= {"format": 1, "field": field, "unit": canonical(unit),
"dtype": values.dtype.str.lstrip("<>=|"), "shape": list(values.shape)}
return {"manifest": manifest, "values": values.tolist(), "digest": digest(manifest, values)}
defread(record, field, expected_unit=None, dimensionality=None):
m=record.get("manifest") orerror("no manifest; there is no default unit")
m["field"] ==fieldorerror("record belongs to another field")
values=np.asarray(record["values"], dtype=m["dtype"]).reshape(m["shape"])
digest(m, values) ==record["digest"] orerror("changed outside the codec")
q=puw.quantity(values, m["unit"]) # then check dimensionality / convert to expected_unitdefdigest(manifest, values): # blake2b-128 over canonical JSON manifest + little-endian bytes
5. Open questions (decide here, not downstream)
Broadcast codes. One code for a whole array, or per column, avoids +25 % memory on float32 trajectories. It stays inside the same object, always written explicitly and never absent. The alternative is always one code per value. Leaning towards broadcast, but this touches the core concern and needs an explicit decision.
Offset units (°C). Affine conversion in the codec, or a policy of storing absolute temperatures in K (lossy relative to the source)?
Logarithmic scales (pChEMBL, pIC50, pKa). Store the underlying quantity and derive the scale? Some sources give only pKa. The alternative is a named quantity kind with its definition ("kind": "pchembl"), never a bare dimensionless number.
Granularity of the digest. Per record for write-once data. Per block plus a top digest over the block list for growing data (frames, rows). Per document for small scalar sets (a card), where a per-scalar digest would outweigh the value.
Canonicalization. NaN payloads, −0.0, float32 through JSON text (the dtype in the manifest makes the recast exact), and stable canonical spelling owned by PyUnitWizard even if pint changes its formatting.
Verification policy. Always on read, possibly lazily on first access. Never disabled silently: an explicit opt-out emits an SMonitor warning.
Legacy data and tooling. A marked legacy read mode for files without a digest, and an explicit "reseal" tool for deliberate edits (e.g. fixtures).
Stated precision. A float does not keep the decimals a source stated (33.0 vs 33). Consumers that need them, such as Sabueso's asserted_value, keep the source text themselves.
This issue is the design record for how MOLI and MolSysSuite components serialize and exchange physical quantities. It records why the discussion happened, what was measured, what was discarded, what was chosen and what remains open. Questions or new proposals about quantity serialization in any MOLI or MolSysSuite component belong here, or in the implementation issue #82. They should not restart in a consumer repository. The durable form of this record should become a PyUnitWizard devguide page shipped with the implementation (#82), so that the reasoning outlives this thread.
The discussion took place on 2026-09-24 between the Sabueso lead (Diego) and an agent working on Sabueso. Diego took the decisions below.
1. Why the discussion happened
The failure to rule out: one tool stores or computes 3 nanomolar and the next reads 3 picomolar. Nothing fails; the number is just wrong. A dimension check cannot catch it, because both are concentrations. Only a unit that never leaves the value, and a reader that never assumes one, prevent it.
Evidence found while studying the components (origin/main, 2026-09-24):
1tcd.h5msm:msm.get(..., box=True)returns 4.371 under MolSysMT's policy and 43.71 under a user Špolicy, and both are treated as nm.structuresgroup and each dataset. Different readers consult different places:get_structural_attributesreads the root,to_molsysmt_Structuresreads the dataset, then the root, then a literal default ("nm","ps","nanometer**2"). The writer writes the values and their unit in separate statements.resolution_angstrom,test_concentration_uM);{"unit": "Da"});units: "nM");tpsa, where Ų is implicit).nMis nanomolar andnmis nanometre.NMis rejected, correctly. Source notations such as ChEMBL'sug.mL-1fail in pint, which reads-1as a subtraction.stringform does not round-trip arrays (The string form of an array quantity cannot be parsed back #81).Related platform work: uibcdf/moli#11 (who configures the shared kernel), uibcdf/moli#12 (quantity interchange contract), uibcdf/molsyssuite#18 (unit-configuration authority).
2. What was measured
Environment: Python 3.13.14, pint 0.25.3, numpy 2.4.6, pyunitwizard 0.25.0+7.g00d756c, Linux, Xeon E5-2630 v4. Timings vary by about 2× between runs on this machine, so treat them as orders of magnitude. The data is 100,000 concentrations unless stated otherwise.
{value, unit}per valuepuw.quantity(value, unit)for one scalarpuw.quantityfor one 100k arrayget_value(q, to_unit=...)for a scalarpuw.check(q, dimensionality=...)puw.conversion_factorQuantity.from_tuplevs parsing"3.0 nanomolar"set_pint_registry_cache(True). MolSysMT's_pyunitwizard.pycomments claim 180 → 17 ms; worth re-measuring.Other facts checked:
has_active_policy()stays false).UnitRegistryinstances cannot be combined (ValueError), so every component must obtain quantities through PyUnitWizard's registry.N*s/m**2,s*N/m^2andnewton*second/meter**2all become'newton * second / meter ** 2'. Dimensionally equal units stay distinct (kg/(m*s),Pa*s), with a factor of 1 between them.conversion_factor("degC", "K")raises: affine units fail loudly instead of multiplying.-log10(M)(pIC50, pChEMBL, pKa) is not a unit.5 pM→4.9999999999999996e-06 µM.3. What was discarded, and why
{value, unit}on every value, for collections. 4× the size and 5× the load time, 192 B per value, and clumsy for arrays. It is still fine for a lone scalar.stringform as the canonical format. Parsing costs about 14 µs per value, it cannot be indexed numerically, and it does not round-trip arrays today (The string form of an array quantity cannot be parsed back #81).4. What was chosen: negotiated containers with an integrity digest
Diego's decisions:
The design has three layers, each with a clear owner:
contrib/pyunitwizard_support.check)to_unit, and reads with a handshakeRules:
h5py, SQL, raw JSON) outside the codec module.A prototype subjected to deliberate slips detected 9 of 9:
It verified an indented re-serialization. It cannot detect a writer that is consistently wrong, for example one that meant nM but built and labelled pM. ArgDigest covers that case when the dimension differs; the canaries and conformance tests cover it when the dimension is the same. The threat model is mistakes, not adversaries: someone who recomputes the digest is out of scope, and that would need signatures.
The digest detects more than per-value tags do. With
{value, unit}or tags without a digest, a hand edit, a reorder, a truncation or pasted values of the same unit all pass unnoticed.Unit-tagged arrays (working name
TaggedQuantities: values plus a small per-value code into a local unit table) remain useful for genuinely heterogeneous collections, protected by the same digest. At 9 B per value, conversion of mixed units is vectorized (0.38 ms for 100k across 4 units), and the round trip is bit-identical. The layout maps onto Arrow and Parquet dictionary columns and onto HDF5.Prototype codec used for the slip tests (sketch, not the API)
5. Open questions (decide here, not downstream)
"kind": "pchembl"), never a bare dimensionless number.33.0vs33). Consumers that need them, such as Sabueso'sasserted_value, keep the source text themselves.6. Where things go