Skip to content

Name the quantity, not the unit - #189

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/blissful-euler-fzuap2
Sep 14, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
claude/blissful-euler-fzuap2

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

A schema that wanted a mass had to declare one. It wrote a semantic type called Kilograms, put "kg" on it as metadata, and every member of it said the unit twice — once as a name nothing reads and once as text something does. The kilograms are how the stored number is read, which is a fact about the field; the mass is what the value is.

ktsu.Semantics already names all of it: 212 physical quantities in C#, the same 212 as C++ classes from ktsu.Semantics.Cpp, and both are things a target already has. So this adds a Quantity type that names one rather than asking for a copy of it — Quantity(Mass), Quantity(Velocity3D), Quantity(Ratio) — and nothing is generated for it.

That is the whole difference from Semantic, and both are wanted. A semantic type is the schema's own — an entity id is not a texture id, and nothing outside the schema has heard of either. A quantity is everybody's.

The registry

QuantityRegistry reads the vocabulary out of the assembly, the same arrangement UnitRegistry has and for the same reason: keeping a list here would fall behind it.

A quantity's dimension takes three routes, in order:

  1. A magnitude declares one directly, through IPhysicalQuantity.
  2. A vector form does not — IVectorN above zero declares components and no dimension — so its dimension is what Magnitude() answers with.
  3. A named overload of a vector form answers neither. Position3D is a Displacement3D under another name and the vocabulary gives it no Magnitude() of its own, so what is followed is the implicit widening onto what it is an overload of.

Six of the 212 are reachable only by that third route, and 212 is not a number chosen here — it is what the C++ projection emits (148 magnitudes, 27 signed scalars, 37 vectors). The two counts agreeing is what says a schema can name every quantity a C++ target has; it was 206 before the widening was followed.

The ten it leaves out are the logarithmic scales and two hand-written audio types. A decibel does not add and a pH does not scale, which is why ktsu.Semantics emits them from logarithmic.json rather than as dimensions — they have no dimensional formula and no vector form, so accepting one would put a type in the schema whose arithmetic means nothing and whose eight exponents a reflection table would have to invent.

Three things follow from a quantity knowing its own dimension

A unit on one has to agree with it. The check the semantic type could never make: a unit is text resolved through UnitRegistry and Kilograms was a name nothing reads, so the two had no way to disagree. The exponents are compared and not the names, because 72 of the vocabulary's dimensions share 63 exponent vectors — a joule and a newton metre are the same eight numbers, and refusing what the physics allows is worse than not checking.

The reflection table asks the type first. Its eight exponents used to come from the member's unit text and nowhere else, so a member that measured something but named no unit was written down as dimensionless — indistinguishable from a flag. A Velocity3D is a length over a time because of what it is.

Storage is the rest of the type, since every quantity is generic over one. It defaults to Float and is omitted from the file when it is, exactly as a vector's ElementType is, and it is held to a bare numeric: a generated semantic type is a record struct over a float implementing no INumber<T>, so Mass<Kilograms> is not a type anything could write.

The two generators

C# spells it ktsu.Semantics.Quantities.Mass<float>, and ClrTypeImporter reads it straight back off the closed type — the one thing generated C# carries that needs no attribute recording what it is. Everything else does, because a sequential struct or a record struct over a float is a shape a hand-written type may have for its own reasons; a Mass<float> is not something a target happened to write, it is the quantity.

C++ spells it wherever the target's vocabulary went, which CppGeneratorOptions.Quantities says in one entry for all 212 — the target named none of them, it ran the generator. That entry also says what the vocabulary is stored in, because a C++ quantity is a class rather than a template and the storage was fixed when it was generated:

"quantities": { "namespace": "holo", "include": "<holotype/quantities/quantities.hpp>" }

A member the schema keeps in a double is refused on a float target rather than emitted as two languages quietly disagreeing about the bytes — the same class of bug the enum width and the bool marshalling were already spelled out to prevent.

Format version 7

Unlike every step since version 1 this is not something an older reader drops. It fails to deserialize the member at all, on a discriminator it has never heard of, so the version moves to say so instead.

The modernised sample says it the new way

Second commit. samples/modernised.schema.json declared Kilograms, Metres, MetresPerSecond and MetresPerSecondSquared — four semantic types named after units, each also carrying the unit as metadata. Those become Quantity(Mass), Quantity(Radius), Quantity(Speed) and Quantity(Acceleration1D) with the unit on the member.

Coin stays a semantic type, and that is the point of keeping it: a price is this schema's own idea and nothing outside it has heard of a coin. The two ways of saying "this number is not just a number" now sit beside each other in one file, which is what a sample is for.

gravity is Acceleration1D rather than a magnitude because its default is -9.81, and a magnitude form refuses a negative value at construction.

Tests

772 → 795, all passing.

  • QuantityTypeTests (15) — the vocabulary count, component arities, the three dimension routes including the widening, the log-scale exclusion, the unknown-name and non-numeric-storage refusals, the unit agreement check and the shared-exponent case that must not be refused, travelsAsBytes, the omit-when-default storage, the file round trip, the C# spelling, and generate-compile-reimport.
  • QuantityCppTests (8) — the spelling and its include, the refusal for a target with no vocabulary, the storage mismatch both ways, the options-file checks, a promising class of quantities compiled under -std=c++20 -Wall -Wextra with its static_assert(std::is_trivially_copyable_v<T>), and the reflection table's exponents coming from the quantity with no unit written.
  • The modernised sample's C++ target gains a stub vocabulary, so TheModernisedSampleCompiles exercises the new mapping over real types.

What this sets up

Holotype's rigid_body.schema.json declares Mass, Heading, Radius and Restitution as semantic types and names them in holotype.cppgen.json's existingTypes. Those become Quantity(Mass), Quantity(Heading), Quantity(Radius) and Quantity(Ratio), and the existingTypes entries go away — which is the change this was built for.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu


Generated by Claude Code

A schema that wanted a mass had to declare one. It wrote a semantic type
called Kilograms, put "kg" on it as metadata, and every member of it said
the unit twice - once as a name nothing reads and once as text something
does. The kilograms are how the stored number is read, which is a fact
about the field; the mass is what the value is.

ktsu.Semantics already names all of it. 212 physical quantities in C#,
the same 212 as C++ classes from ktsu.Semantics.Cpp, and both are things
a target already has. So Quantity names one rather than asking for a copy
of it - Quantity(Mass), Quantity(Velocity3D), Quantity(Ratio) - and
nothing is generated for it. That is the whole difference from Semantic,
and both are wanted: a semantic type is the schema's own, a quantity is
everybody's.

QuantityRegistry reads the vocabulary out of the assembly, the same
arrangement UnitRegistry has. A quantity's dimension takes three routes,
in order: a magnitude declares one through IPhysicalQuantity; a vector
form does not, so its dimension is what Magnitude() answers with; and a
named overload of a vector form answers neither, so what is followed is
the implicit widening onto what it is an overload of. Six of the 212 are
reachable only by that third route, and 212 is not a number chosen here -
it is what the C++ projection emits, so the two counts agreeing is what
says a schema can name every quantity a C++ target has. The ten it leaves
out are the logarithmic scales and two hand-written audio types: a
decibel does not add and a pH does not scale, so neither has a
dimensional formula for a reflection table to carry.

Three things follow from a quantity knowing its own dimension.

A unit on one has to agree with it - the check the semantic type could
never make, since a unit is text and Kilograms was a name. The exponents
are compared and not the names, because 72 of the vocabulary's dimensions
share 63 exponent vectors: a joule and a newton metre are the same eight
numbers, and refusing what the physics allows is worse than not checking.

The reflection table asks the type first. Its eight exponents used to
come from the member's unit text and nowhere else, so a member that
measured something but named no unit was written down as dimensionless,
indistinguishable from a flag.

Storage is the rest of the type, since every quantity is generic over
one. It defaults to Float and is omitted from the file when it is,
exactly as a vector's ElementType is, and it is held to a bare numeric: a
generated semantic type is a record struct over a float implementing no
INumber<T>, so Mass<Kilograms> is not a type anything could write.

C# spells it ktsu.Semantics.Quantities.Mass<float> and ClrTypeImporter
reads it straight back off the closed type - the one thing generated C#
carries that needs no attribute recording what it is. Everything else
does, because a sequential struct or a record struct over a float is a
shape a hand-written type may have for its own reasons; a Mass<float> is
not something a target happened to write, it is the quantity.

C++ spells it wherever the target's vocabulary went, which
CppGeneratorOptions.Quantities says in one entry for all 212 - the target
named none of them, it ran the generator. That entry also says what the
vocabulary is stored in, because a C++ quantity is a class rather than a
template and the storage was fixed when it was generated: a member the
schema keeps in a double is refused on a float target rather than emitted
as two languages quietly disagreeing about the bytes.

Format version 7. Unlike every step since version 1 this is not something
an older reader drops - it fails to deserialize the member at all, on a
discriminator it has never heard of - so the version moves to say so
instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
The sample declared Kilograms, Metres, MetresPerSecond and
MetresPerSecondSquared: four semantic types named after units, each also
carrying the unit as metadata. That states the presentation twice and
makes the wrong copy load-bearing. A weight is a mass and the kilograms
are how its number is read, so the member now says Quantity(Mass) with
"kg" beside it - each thing once, and validation notices when the two
disagree.

Coin stays a semantic type, and that is the point of keeping it. A price
is this schema's own idea and nothing outside it has heard of a coin, so
the schema declares one and a generator emits it; a mass is everybody's,
so the schema names it and nothing is emitted at all. The two ways of
saying "this number is not just a number" now sit beside each other in
one file, which is what a sample is for.

lightRadius, hitboxRadius and influenceRadius are Radius rather than
Length, and gravity is Acceleration1D rather than a magnitude, because
its default is -9.81 and a magnitude form refuses a negative value at
construction. The vocabulary has names for both, which is the argument
for using it rather than restating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
Comment thread Schema/Models/Metadata/QuantityRegistry.cs Fixed
`property?.PropertyType == typeof(DimensionInfo)` is false when the
property is absent, so the conditional never dereferenced a null. Neither
the compiler's null-state analysis nor CodeQL can follow that, though,
and a warning that has to be reasoned about every time it is read is
worth two lines to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit f6e20c8 into main Sep 14, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/blissful-euler-fzuap2 branch September 14, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants