§3 landed — one pint-defined canonical unit authority ✓ landed 2026-07-22

Evergreen: sn-dd-unit-curation §3. Resolves followup f-sdu-001. Delivers the ISN-side canonical_unit() helper the downstream imas-codex mismatch axis imports, collapses the three divergent unit-formatting paths (§2) onto one authority, and adds the stability + round-trip tests that prove ordering-only and spelling-only mismatches vanish.

What shipped

#ChangeFile
1 canonical_unit(s) added — the single source-of-truth formatter (pint parse + ~F, integer exponents, ASCII short symbols). Exported as from imas_standard_names import canonical_unit. imas_standard_names/__init__.py
2 The divergent "U" formatter (long names, float exponents, unsorted) is removed. Grep confirmed no in-repo consumer of it or of the units module registry; the DD-alias registry is retained. imas_standard_names/units/__init__.py
3 The scalar/vector/name-only unit validators (_canonicalize_unit_order) delegate ordering to canonical_unit() instead of the hand-rolled regex parse+sort — one ordering authority. Authoring guards (no whitespace, no //*, "1" for dimensionless) stay. imas_standard_names/models.py
4 Stability test: canonical_unit() output must match the dot-exponent grammar (sorted ASCII short tokens, integer exponents, no .0) over the full catalog unit set — a pint bump or symbol re-spelling fails CI. Plus round-trip and validator-parity tests. tests/name_schema/test_canonical_unit.py

The spelling-drift trap §2 warned about — caught and closed

§2 finding #3 flagged that pint's short symbols can diverge from authored ASCII tokens. A catalog scan proved it live: the authored unit m.ohm renders through pint ~F as m.Ω (Unicode Ω) — and micro-prefixed units would render µs, degrees Celsius °C. A naïve pint switch would have silently rewritten stored units and injected non-ASCII glyphs, breaking the migration invariant (do not rewrite stored units).

Fix: the single ~F formatter now maps pint's non-ASCII short glyphs to their ASCII spellings (Ω→ohm, µ→u, °→deg) before sorting, so ordering matches the retired regex byte-for-byte. Verified: across all 20 distinct catalog units, the pint form and the old regex form now agree exactly (0 divergences). Any future glyph not in the ASCII map survives into the output and trips the stability test rather than corrupting a stored unit.

Reconciliation property proven

Ordering and spelling variants of one physical unit normalize equal — the exact property the downstream axis relies on:

inputcanonical_unit()
s^-1.m, m.s^-1m.s^-1 (float-exp .0 artifact gone)
T.m^-2.A, m^-2.A.TA.T.m^-2
s.kg.m^2, s.m^2.kgkg.m^2.s
m.ohm, ohm.mm.ohm (ASCII, not m.Ω)

Downstream comparison becomes canonical_unit(sn_unit) == canonical_unit(dd_unit) — a pure string compare after both sides are normalized through the one authority.

Verification

New tests/name_schema/test_canonical_unit.py green; the unit-focused groups (catalog validation, storage/loader, codegen drift, JSON-schema contract, semantic units, name-schema, name-only) pass. The one pre-existing suite failure (test_find_git_root_raises_when_no_repo, an environmental tmp-path flake) is unrelated to units and predates this change.

What's next — §4 curation. With normalization in place the residual mismatch list is finally trustworthy. §4 re-checks effective_thermal_ion_charge_state_energy_velocity_due_to_convection against the live catalog + DD facet (the user's read: the ion_charge finding is stale — prune it) and triages each genuine residual, correcting at source via sn edit. Tracked as the §4 followup on the evergreen.