The Kind-of-Quantity Problem¶
Core conceptual foundation for understanding dimensional ambiguity and how ucon addresses it.
The Problem¶
When working with physical quantities, a fundamental limitation of dimensional analysis becomes apparent: units and dimensions do not uniquely identify the kind of quantity being measured.
Examples of Ambiguity¶
| Unit | Possible Quantities |
|---|---|
kg·m²·s⁻² |
Energy, Torque, Work, Heat |
s⁻¹ |
Frequency, Angular velocity, Radioactive activity |
1 (dimensionless) |
Angle, Refractive index, Efficiency, Count |
This is known as the Kind-of-Quantity (KOQ) problem in metrology literature.
Why It Matters¶
- Silent errors: Adding energy to torque produces nonsense but passes dimensional checks
- Ambiguous conversions:
s⁻¹ → Hzis valid for frequency but not for activity - Lost semantics: Dimensionless quantities lose all physical meaning
ucon's Three Solutions¶
ucon addresses KOQ through three complementary mechanisms:
- Pseudo-dimensions — Semantic tags for quantities dimensionless within a basis
- Basis abstraction — Coordinate transforms across dimensional systems
- Kind lattices — Sortal refinements within a single dimension
The first two solutions build on the relationship between BasisVector and
Dimension. The third layers a partial order on top of dimensions to
distinguish quantities that share the same BasisVector but represent
physically distinct kinds.
BasisVector and Dimension¶
Before diving into solutions, it's important to understand how ucon represents dimensions internally.
BasisVector: The Coordinate Representation¶
A BasisVector is the raw numerical representation of a dimension—a tuple of
exponents over a basis. Think of it as coordinates in dimensional space:
from ucon import BasisVector, SI
from fractions import Fraction
# Velocity = L/T = L¹·T⁻¹ in SI
# SI order: (T, L, M, I, Θ, J, N, B)
velocity_vector = BasisVector(SI, (
Fraction(-1), # T⁻¹
Fraction(1), # L¹
Fraction(0), # M⁰
Fraction(0), # I⁰
Fraction(0), # Θ⁰
Fraction(0), # J⁰
Fraction(0), # N⁰
Fraction(0), # B⁰
))
velocity_vector.is_dimensionless() # False
BasisVector supports algebraic operations (multiplication, division, exponentiation)
that add, subtract, or scale exponents:
# length_vec * length_vec = area_vec (exponents add)
# length_vec / time_vec = velocity_vec (exponents subtract)
Dimension: The Named, Semantic Wrapper¶
A Dimension wraps a BasisVector with additional metadata:
- name: Human-readable identifier (e.g., "velocity", "energy")
- symbol: Short notation (e.g., "L", "T")
- tag: For pseudo-dimensions (e.g., "angle", "count")
from ucon import Dimension
# Access predefined dimensions
Dimension.velocity.name # "velocity"
Dimension.velocity.vector # The underlying BasisVector
# Dimensions resolve from vectors automatically
from ucon import resolve_dimension
resolved = resolve_dimension(velocity_vector)
resolved == Dimension.velocity # True
The Key Relationship¶
flowchart LR
subgraph BasisVector["BasisVector"]
bv_basis["basis"]
bv_components["components"]
bv_algebra["algebra"]
end
subgraph Dimension["Dimension"]
d_vector["vector"]
d_name["name"]
d_symbol["symbol"]
d_tag["tag (pseudo)"]
end
Dimension -- "wraps" --> BasisVector
BasisVector --> coords["(−1, 1, 0, 0, 0, 0, 0, 0)<br/><i>raw coordinates in dimensional space</i>"]
Dimension --> semantic["Dimension(velocity)<br/><i>semantic identity for human reasoning</i>"]
Why does this matter for KOQ?
BasisVectorhandles the algebra—it knows nothing about "torque" vs "energy"Dimensionprovides the semantic layer—it can distinguish quantities via names or tags- The KOQ problem arises when the same
BasisVectormaps to multiple physical meanings - ucon's solutions work by either:
- Adding a tag to the
Dimension(pseudo-dimensions) - Choosing a different basis so the
BasisVectordiffers (basis abstraction)
Solution 1: Pseudo-Dimensions¶
Deprecated
Pseudo-dimensions are deprecated as of v2.2.0 and will be removed in
3.0.0. Dimension.pseudo(...) emits PendingDeprecationWarning.
Migrate to a Kind over the dimensionless dimension — kinds carry
the semantic isolation pseudo-dimensions approximate, and
participate in lattice joins and formula dispatch (Solution 3).
For quantities that are mathematically dimensionless but physically distinct, ucon provides pseudo-dimensions:
from ucon import Dimension, units
# These are all dimensionless (zero vector) but semantically isolated
Dimension.angle # radians, degrees
Dimension.solid_angle # steradians
Dimension.ratio # percent, ppm, ppb
Dimension.count # discrete items
# Semantic isolation prevents nonsensical operations
angle = units.radian(3.14)
count = units.each(5)
# This raises TypeError — incompatible pseudo-dimensions
result = angle + count # Error!
# But algebra within the same pseudo-dimension works
total_angle = units.radian(1.57) + units.radian(1.57) # OK
How Pseudo-Dimensions Work¶
| Aspect | Behavior |
|---|---|
| Algebraic | Zero vector — behaves as dimensionless in multiplication |
| Semantic | Tagged — prevents mixing incompatible types |
| Cancellation | angle / angle = dimensionless (not angle) |
When to Use¶
- Angles (radian, degree, steradian)
- Counts of discrete items
- Ratios and proportions (percent, ppm)
- Any quantity dimensionless in all reasonable unit systems
Solution 2: Basis Abstraction¶
The key insight is that dimensional ambiguity is basis-dependent. Two quantities that share the same dimension in one basis may be distinguished in another.
Example: Torque vs Energy¶
In standard SI, torque and energy are dimensionally identical:
| Quantity | SI Dimension | Problem |
|---|---|---|
| Energy | M·L²·T⁻² | Same as torque! |
| Torque | M·L²·T⁻² | Same as energy! |
But physically, torque = force × lever arm × angle. If we choose a basis where angle is NOT dimensionless, the ambiguity resolves:
from ucon import Basis, BasisComponent, BasisVector
from fractions import Fraction
# Define a basis where angle (θ) is a base dimension
MECHANICAL_WITH_ANGLE = Basis(
"mechanical_angle",
[
BasisComponent("time", "T"),
BasisComponent("length", "L"),
BasisComponent("mass", "M"),
BasisComponent("angle", "θ"), # Angle as base dimension!
],
)
# In this basis:
# Energy = M·L²·T⁻²·θ⁰ (no angle dependence)
# Torque = M·L²·T⁻²·θ¹ (depends on angle!)
energy_vec = BasisVector(MECHANICAL_WITH_ANGLE, (
Fraction(-2), Fraction(2), Fraction(1), Fraction(0) # T⁻² L² M¹ θ⁰
))
torque_vec = BasisVector(MECHANICAL_WITH_ANGLE, (
Fraction(-2), Fraction(2), Fraction(1), Fraction(1) # T⁻² L² M¹ θ¹
))
energy_vec == torque_vec # False! Now distinguishable
Example: Gray (Gy) vs Sievert (Sv)¶
In radiation dosimetry, absorbed dose (Gy) and dose equivalent (Sv) have the same SI dimension (m²·s⁻²) but measure different things:
- Gray: Physical energy absorbed per unit mass
- Sievert: Biological effect (weighted by radiation type)
A domain-specific basis can distinguish them:
from ucon import Basis, BasisComponent, BasisVector
from fractions import Fraction
DOSIMETRY = Basis(
"dosimetry",
[
BasisComponent("time", "T"),
BasisComponent("length", "L"),
BasisComponent("mass", "M"),
BasisComponent("biological_weight", "Q"), # Quality factor dimension
],
)
# Absorbed dose (Gy): L²·T⁻²·Q⁰
# Dose equivalent (Sv): L²·T⁻²·Q¹
absorbed_dose = BasisVector(DOSIMETRY, (
Fraction(-2), Fraction(2), Fraction(0), Fraction(0)
))
dose_equivalent = BasisVector(DOSIMETRY, (
Fraction(-2), Fraction(2), Fraction(0), Fraction(1) # Q¹ factor!
))
# Now they're dimensionally distinct
absorbed_dose == dose_equivalent # False
Natural Units (Advanced)¶
For particle physics, where c = ℏ = k_B = 1, dimensions collapse further:
| Quantity | SI Dimension | Natural Units |
|---|---|---|
| Velocity | L·T⁻¹ | E⁰ (dimensionless!) |
| Length | L | E⁻¹ |
| Mass | M | E |
from ucon import SI, NATURAL, SI_TO_NATURAL, BasisVector
from fractions import Fraction
# Velocity becomes dimensionless in natural units
si_velocity = BasisVector(SI, (
Fraction(-1), Fraction(1), Fraction(0), Fraction(0),
Fraction(0), Fraction(0), Fraction(0), Fraction(0),
))
natural_velocity = SI_TO_NATURAL(si_velocity)
natural_velocity.is_dimensionless() # True! (c = 1)
When to Use Basis Abstraction¶
| Scenario | Basis Strategy |
|---|---|
| Torque vs Energy | Add angle as base dimension |
| Gy vs Sv | Add biological weighting dimension |
| SI ↔ CGS | Standard basis transforms |
| Particle physics | Natural units (E only) |
| Electromagnetism | CGS-ESU (charge as base) |
Solution 3: Kind Lattices¶
Some KOQ distinctions are too fine-grained to justify a basis split and too structural to be captured by a pseudo-dimension tag. Two examples:
- Absorbed dose (Gy) and equivalent dose (Sv) share the SI dimension
L²·T⁻². We can pull them apart with a domain basis (Solution 2), but in many radiation-protection workflows the operator wants to keep working in ordinary SI and still have the type system know that "Sv" is a refinement of "Gy" — every Sv is a kind of absorbed dose, but not vice versa. - Kinetic energy and potential energy share the dimension of energy.
Adding them is meaningful (total mechanical energy), but mixing torque with
kinetic energy is not — even though all three share
M·L²·T⁻².
A kind lattice captures this kind of refinement structure explicitly.
Each Kind is a sortal — a name and a parent — and the lattice records the
partial order: kinetic_energy ≤ energy, gravitational_pe ≤ potential_energy
≤ energy, and so on. The dimension is carried along, so a child kind cannot
refine across dimensions.
from ucon.kinds import Kind, KindLattice, JoinPolicy
from ucon.dimension import LENGTH, MASS, TIME
ENERGY_DIM = (LENGTH ** 2) * MASS / (TIME ** 2)
energy = Kind("energy", dimension=ENERGY_DIM)
kinetic = Kind("kinetic_energy", dimension=ENERGY_DIM, parent=energy)
potential = Kind("potential_energy", dimension=ENERGY_DIM, parent=energy)
grav = Kind("gravitational_pe", dimension=ENERGY_DIM, parent=potential)
elastic = Kind("elastic_pe", dimension=ENERGY_DIM, parent=potential)
lat = KindLattice([energy, kinetic, potential, grav, elastic])
# Mixed potential-energy operations meet at their LCA.
ancestor, policy = lat.lca(grav, elastic)
ancestor.name # "potential_energy"
policy # JoinPolicy.LCA
Join Policies¶
A Kind carries a JoinPolicy that determines what happens when two
distinct sibling kinds are combined (e.g., added):
| Policy | Meaning | Use case |
|---|---|---|
LCA (default) |
Result takes the least-common-ancestor kind | Energies, doses, masses |
REFUSE |
Operation is rejected at the kind layer | ratio, dimensionless, anything where the union has no physical interpretation |
This is enough machinery to distinguish refinement (Sv ≤ Gy) from incommensurability (ratio ⊥ dimensionless) without changing the underlying dimensional algebra.
Formula Registry¶
Kinds also unlock a small algebraic layer: a FormulaRegistry records
named relationships between input kinds and an output kind. The radiation
weighting equation H = D · w_R is a typical entry — given absorbed dose
and a weighting factor, the registry yields equivalent dose:
from ucon.formulas import KindFormula, FormulaRegistry
D = Kind("absorbed_dose", dimension=ABSORBED_DOSE_DIM)
wR = Kind("radiation_weighting_factor", dimension=DIMENSIONLESS)
H = Kind("equivalent_dose", dimension=ABSORBED_DOSE_DIM)
f = KindFormula(
name="radiation_weighting",
expression="D * w_R",
input_kinds={"D": D, "w_R": wR},
output_kind=H,
commutative=True,
)
reg = FormulaRegistry([f])
reg.lookup(D, wR).name # "radiation_weighting"
reg.lookup(wR, D).name # "radiation_weighting" (commutative)
Lookup Tiers¶
The registry's resolve() method checks four match tiers in strict
priority order — the first to produce a match wins:
| Tier | Name | Gate | Behaviour |
|---|---|---|---|
| 1 | EXACT | always on | Direct input-kind tuple match |
| 2 | COMMUTATIVE | commutative=True |
Canonical sorted-key match (any arity) |
| 3 | GENERALIZED | generalizes=True + lattice= |
Ancestor-walk at increasing L1 distance |
| 4 | DIMENSIONAL | dimension_fallback=True |
Dimension-tuple match ignoring kind identity |
The generalized tier uses a bounded integer-composition algorithm: at each
L1 distance d, it enumerates all ways to distribute d parent-climbs
across n input positions. If exactly one formula matches at a given
distance, it wins. If more than one match at the same distance, an
AmbiguousFormula error is raised — the caller must disambiguate.
result = reg.resolve(kinetic_energy, scale_factor, lattice=lat)
result.match_kind # MatchKind.GENERALIZED
result.distance # 1 (kinetic_energy → energy = 1 climb)
The Aspect Stratum¶
Kinds tell you what a quantity is; aspects qualify it on terms the kind cannot express — the weighting standard behind a dose equivalent, the dry/wet basis of a mass fraction, the coverage factor on an expanded uncertainty. Two values with the same dimension, unit, and kind may still refuse to combine.
Aspects are tree nodes, peers of Kind. The root of a tree is the
family; children are positions within it:
from ucon import Aspect, Number, units
family = Aspect("weighting_standard",
applies_to=frozenset({"dose_equivalent"}))
icrp60 = Aspect("icrp60", parent=family)
icrp103 = Aspect("icrp103", parent=family)
a = Number(2.0, units.gray, kind=dose_eq, aspects=[icrp103])
b = Number(3.0, units.gray, kind=dose_eq, aspects=[icrp60])
a + b # AspectRefused — the kind stratum cannot see this difference
Family-Wise Resolution¶
Both operands' aspects group by family root; each family resolves independently, and cross-family comparison never happens:
- Equal positions carry through unchanged.
- Differing positions join at their lowest common ancestor, honoring
the ancestor's
join_policy—refuse(the aspect default) raisesAspectRefused;lcacarries the ancestor. - Partial presence (one operand silent in a family) splits by
operation: addition consults the ambient strict bit (strict refuses —
silence is not agreement; permissive inherits with a warning), while
multiplication follows the family's
multiplication_policy.
flowchart LR
subgraph inputs ["Inputs"]
D["absorbed dose (Gy)<br/><b>aspects:</b> —"]
wR["w_R: weighting factor<br/><b>aspects:</b> #icrp103"]
end
subgraph carry ["× : the carry rule"]
rule["weighting_standard → carry"]
end
subgraph output ["Output"]
H["dose equivalent (Sv)<br/><b>aspects:</b> #icrp103"]
end
D --> rule --> H
wR --> rule
The Carry Rule¶
Under multiplication a factor's provenance rides the product — even when the product's kind degrades. This is what keeps refusals alive through round trips:
p60 = a60 * t # kind may degrade; #icrp60 carried
p103 = a103 * t # #icrp103 carried
p60 + p103 # AspectRefused — the verdict never depended on kinds
There is no drop: no resolution path silently discards a position.
Aspect resolution reads only the operand aspect sets and the strict
bit — kind values never influence an aspect verdict (mock every kind to
garbage and every verdict is unchanged; the test suite pins exactly
this).
Attachment¶
applies_to on a family root restricts which kinds the family may
attach to, checked once at Number(...) construction — the aspect
layer's one sanctioned kind-read. A weighting standard claimed on an
absorbed dose refuses at the moment the false claim is made
(AspectNotApplicable). Empty applies_to means unrestricted; {"*"}
attaches kind-independently (coverage factors on unkinded numbers).
Status¶
As of v2.2.0 the aspect stratum is first-class:
Aspect,AspectError,AspectRefused, andAspectNotApplicableare exported from the top-leveluconpackage.Numbercarries an additiveaspectsfrozenset; family-wise resolution is wired into+ − × ÷, and single-operand operations thread aspects unchanged.- Formulas do kind work only:
FormulaRegistry.applytakes kinds and returns(formula, output_kind, match_kind). The earlier flat aspect-set model and formula projection machinery were removed. [[aspects]]TOML declarations load viaload_aspects_fileand round-trip through graph serialization.
TOML Authoring¶
Both kinds and formulas can be declared in TOML and loaded with
load_kinds_file / load_formulas_file. The two sections are siblings and
can share a file:
[[kinds]]
name = "absorbed_dose"
dimension = "L^2/T^2"
aliases = ["D"]
[[kinds]]
name = "equivalent_dose"
dimension = "L^2/T^2"
parent = "absorbed_dose"
aliases = ["H"]
[[formulas]]
name = "radiation_weighting"
expression = "D * w_R"
output_kind = "equivalent_dose"
commutative = true
[formulas.inputs]
D = { kind = "absorbed_dose" }
w_R = { kind = "radiation_weighting_factor" }
Status¶
Kinds and formulas are first-class as of v2.0, aspects as of v2.2.0:
Kind,KindLattice,FormulaRegistry, andActiveContextare re-exported from the top-leveluconpackage.Numbercarries an optionalkindfield, validated against the unit's dimension at construction.- Kind-aware arithmetic dispatch:
__mul__/__truediv__consult the activeFormulaRegistry;__add__/__sub__consult the activeKindLattice. @enforce_dimensionssupportsNumber[kind]andNumber[Dimension.X, kind]annotations for kind-constrained parameters.- 25 built-in kinds ship via
comprehensive.ucon.toml, accessible throughactive_kinds().
When to Use a Kind Lattice¶
| Scenario | Why kinds fit |
|---|---|
| Distinguishing refinements (Gy → Sv, energy → kinetic_energy) | Partial order is the natural model; basis splitting would be heavyweight |
| Locking down algebra at boundaries (no torque + energy, no ratio + dimensionless) | REFUSE policy at the lattice level is more precise than a tag |
| Driving formula lookup from declared inputs | FormulaRegistry indexes by (Kind, …) directly |
| Authoring domain knowledge in TOML | Kinds and formulas serialize cleanly alongside unit packages |
Comparing the Three Approaches¶
| Aspect | Pseudo-Dimensions | Basis Abstraction | Kind Lattices |
|---|---|---|---|
| Problem solved | Dimensionless quantities that differ semantically | Quantities with same dimension that differ physically | Refinements within a dimension (sortal structure) |
| Mechanism | Semantic tag on zero-vector | Choose a basis where dimensions differ | Partial order over named kinds |
| Scope | Works within any single basis | Requires defining/choosing appropriate basis | Works within any single dimension |
| Examples | angle vs count vs ratio | torque vs energy, Gy vs Sv | kinetic_energy ≤ energy, equivalent_dose ≤ absorbed_dose |
| Declaration | Dimension.pseudo("tag") |
Basis(name, components) |
Kind(name, dimension, parent=...) |
| Trade-off | Simple but limited to "truly" dimensionless | Powerful but requires domain modeling | Lightweight refinement, but opt-in (not yet wired to Number) |
The Unified Picture¶
All three solutions preserve kind-of-quantity information that pure dimensional analysis loses. The choice depends on the nature of the ambiguity:
flowchart TB
KOQ["KOQ Ambiguity"]
KOQ --> dimensionless["Dimensionless but<br/>semantically distinct"]
KOQ --> same_dim["Same dimension but<br/>physically distinct"]
KOQ --> refinement["Refinement <i>within</i><br/>a dimension"]
dimensionless --> pseudo["<b>PSEUDO-DIMENSION</b><br/><br/>angle<br/>count<br/>ratio"]
same_dim --> basis["<b>BASIS ABSTRACTION</b><br/><br/>Expand basis to<br/>include hidden<br/>dimension"]
refinement --> kind["<b>KIND LATTICE</b><br/><br/>Sortal partial order<br/>over named kinds"]
pseudo --> tag["Tag on zero-vector<br/><code>(0,0,0,...,'angle')</code>"]
basis --> vectors["Different vectors<br/>in richer basis"]
kind --> parent["Parent edges<br/>+ JoinPolicy"]
Key insight: The SI's 7-dimensional basis is a pragmatic choice, not a physical necessity. Some KOQ problems arise because the SI basis is too coarse to distinguish certain quantities; others arise because the quantities do share a dimension but stand in a refinement relation. ucon lets you:
- Add semantic tags for quantities the SI treats as dimensionless
- Define richer bases for quantities the SI conflates
- Declare a kind lattice for quantities that share a dimension but refine one another
Further Reading¶
- Hall, B.D. (2022). "Representing quantities and units in digital systems." Measurement: Sensors
- Hall, B.D. (2023). "Modelling expressions of physical quantities." IC3K 2023
- Hall, B.D. (2025). "Interpreting SI notation for units and dimensions." Measurement: Sensors