Guidance for Claude Code working in this repository. Read this together with docs/strategy-unified-vector-quantities.md (the architecture spec for the semantic-quantity system) and docs/physics-generator.md (the metadata workflow).
This is a multi-target .NET library using ktsu MSBuild SDKs. Strings and Paths target net8.0–net10.0 plus netstandard2.0/netstandard2.1; Quantities and the storage-type alias packages target net8.0–net10.0 (they require INumber<T>).
- Build:
dotnet build - Test:
dotnet test - Test (verbose):
dotnet test --logger "console;verbosity=detailed" - Clean:
dotnet clean - Restore:
dotnet restore - Format:
dotnet format
Tests use MSTest. Generator output is emitted to Semantics.Quantities/Generated/ (committed) so the project can be inspected without first running the generator.
CI analyses this repository with the SonarCloud scanner, which injects the Sonar analyzers into the compilation. A plain dotnet build does not run them, so Sonar findings are invisible locally and only surface after a push — a ~10 minute round trip per attempt. To run the same analyzers:
dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.propsdotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.propsNote After, not Before. CustomBeforeMicrosoftCommonProps reaches only Semantics.SourceGenerators, the one project declaring its SDK with the <Project Sdk="..."> attribute form; the ktsu.Sdk projects use <Project> with <Sdk Name="..." /> elements, which that hook does not reach. CustomAfterMicrosoftCommonProps does reach them, and is still early enough for restore to pick up the analyzer PackageReference.
The opt-in lives in .sonarlint/sonar-local.props (analyzer package) and .sonarlint/sonar-local.globalconfig (rule severities — it raises the rules CI reports that the analyzer package ships disabled, and silences the ones CI's quality profile does not report). Nothing imports these automatically, so normal builds, the CI pipeline, and packaging are unaffected.
Caveat: the globalconfig was calibrated against this repository's quality profile. If a project sits under a different profile the local rule set will not match it exactly, so treat a clean local run as strong evidence rather than proof.
| Project | Responsibility |
|---|---|
Semantics.Strings |
Strongly-typed string wrappers (SemanticString<T>) and validation attributes/strategies. |
Semantics.Strings.Identifiers |
Concrete identifier string types (Uuid, Ulid, Iban, Isbn, CreditCardNumber, JwtToken) built on the Semantics.Strings framework. |
Semantics.Paths |
Polymorphic file system path types (IPath, IFilePath, IDirectoryPath, …). |
Semantics.Music |
Immutable musical value types (Pitch, Interval, Scale, Chord, Key, Duration, TimeSignature) plus an analysis aggregate layer (Progression, Section, Arrangement, Form) computing roman numerals, cadences, key inference, chromatic identification, and named forms. Targets net8.0–net10.0 + netstandard2.0/netstandard2.1. |
Semantics.Color |
Physically-grounded color types. Canonical linear-RGB Color hub plus color-space satellites (Srgb, Hsl, Hsv, Oklab, Oklch); every type converts to and from every other, routed through the nearest shared hub (Srgb within the sRGB family, Oklab within the perceptual family, linear Color across families) so no conversion takes a redundant gamma round-trip. Also WCAG accessibility tooling, color vision deficiency simulation (ColorVision.Matrix(deficiency, severity) and Color.Simulate, the Machado et al. 2009 tables at 0.1 severity steps, interpolated linearly between them and applied to the linear channels), HSL/perceptual adjustment operations (lighten/saturate/hue/invert), and NamedColors. Targets net8.0–net10.0 + netstandard2.0/netstandard2.1. |
Semantics.Quantities |
Hand-written runtime types (IPhysicalQuantity<TSelf, T>, PhysicalQuantityCore, IVector0..IVector4, UnitSystem) plus generator output under Generated/. Every generated quantity is a readonly record struct. |
Semantics.SourceGenerators |
Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. Only the physics-specific half lives here — Models/, Metadata/, Generators/, and the bindings in SemanticsGenerator/SemanticsDiagnostics/Emit. The C# syntax templates come from ktsu.CodeBlocker.Templates; the metadata-driven generator base, metadata loading and the diagnostic catalogue come from ktsu.SourceGeneratorToolkit (#181, #192). |
Semantics.Quantities.{Double,Float,Decimal,Precise} |
Props-only satellite packages. Each ships a build props file (generated by scripts/Generate-AliasProps.ps1) that injects global-using aliases binding every quantity to one storage type, so consumers write Mass instead of Mass<double>. build rather than buildTransitive on purpose: the aliases are project-wide global usings keyed on the bare type name, so two of these packages reaching one project define every alias twice and the compile fails with one CS1537 per quantity. build binds the aliases in the project that declares the reference and nowhere else, which is what "one alias package per project" means; a project downstream of that one references the package it wants for itself. Precise binds to ktsu.PreciseNumber.PreciseNumber and is the one whose storage type comes from a package rather than being a C# keyword, so it carries a PackageReference the others do not; the core Semantics.Quantities still has no PreciseNumber dependency. |
Semantics.Benchmarks |
BenchmarkDotNet suite covering quantities, strings and paths. Not shipped and not covered by tests, so it carries SonarQubeExclude. Feeds the per-release charts in docs/benchmarks/. |
Semantics.Vocabulary |
Shared source, not a project. Resolves dimensions.json into the quantities and operators it describes and separates out what cannot be honoured. Compiled into both Semantics.SourceGenerators and Semantics.Cpp via Compile Include; see its README for why source rather than an assembly, and what that costs. |
Semantics.Cpp |
The C++ projection of the quantity vocabulary, in its own project because ktsu.Coder ships no net8.0. Reads dimensions.json and emits one C++ class per dimension, per vector form and per named overload, plus the declared relationships as operators. |
Semantics.Cpp.Test |
Its tests, including ones that compile the whole generated vocabulary with g++/clang++, assert what it means through static_assert, and check that a dimensionally wrong product — scalar or componentwise — is refused by the compiler. Also SevenTargetProjectionTests, which is not about C++ at all: see below. |
Semantics.Test |
MSTest project covering all of the above. |
The quantity system is metadata-driven. The single source of truth is
Semantics.SourceGenerators/Metadata/dimensions.json, which lists every physical dimension and the vector forms it supports.
Every quantity is a vector. Dimensionality of the direction space is part of the type:
| Form | Meaning | Sign | Examples |
|---|---|---|---|
IVector0<TSelf, T> |
Magnitude only | Always >= 0 |
Speed, Mass, Energy, Distance, Area |
IVector1<TSelf, T> |
Signed 1D | Signed | Velocity1D, Force1D, Temperature, ElectricCharge |
IVector2<TSelf, T> |
2D directional | Per-component | Velocity2D, Force2D, Acceleration2D |
IVector3<TSelf, T> |
3D directional | Per-component | Velocity3D, Force3D, Position3D |
IVector4<TSelf, T> |
4D directional | Per-component | (reserved for relativistic / spacetime) |
IVectorN.Magnitude() (for N >= 1) returns the corresponding IVector0, and DistanceTo(other)
answers with the same type. Both are emitted onto every vector form, overloads included, and both
return the vector0 base rather than one of its overloads — Velocity3D.Magnitude() is a
Speed, Position3D.Magnitude() is a Length and not a Distance — because the base is what
every overload widens from. A caller who wants the overload narrows explicitly.
The return type is written fully qualified in the generated source, which looks like noise and is
not: every vector form has a Length() method, so inside Displacement3D, whose magnitude form is
called Length, an unqualified Length<T> return type puts a method group and a generic type
under one identifier. It parses, because the type argument list disambiguates it — which is the
problem, since it compiles and then misleads the next reader.
Length() and LengthSquared() keep answering with a bare T, and the squared one has no typed
counterpart on purpose: the square of a dimension usually has no declared name, and where it has one
it is not unique, so there would be nothing to return. Magnitude() and DistanceTo() build with
Create rather than a From{Unit} factory, bypassing Vector0Guards — correct, because a value
that came out of a square root is non-negative by construction, and it keeps the guard off a call
that belongs in a hot loop.
Every vector form also gets the per-unit surface the scalars have: one From{Unit}(T x, T y, T z)
factory per entry in the dimension's availableUnits, named by the unit's singular lemma exactly as
the scalar factories are, and an In(unit) reader. A vector form reaches its units through the
dimension, so an overload gets them too — Position3D<T>.FromKilometer(6778, 0, 0) is the call that
used to be a hand-written ×1000 at every construction site. The factories convert each component
through the same Values<T> holder the scalar ones read, so a decimal or PreciseNumber vector
converts at its own precision rather than through double.
Two things the vector surface does differently, both deliberate. It carries no Vector0Guards:
components are signed by construction, a position with a negative X is ordinary, and the V0
non-negativity rule must not leak into the vectors. And In(unit) returns a tuple of components,
(T X, T Y, T Z), not the vector type — it cannot return the vector type, because the result is no
longer in base units and a Position3D that is not in base units would be a lie in the type system.
A dimension with an offset unit gets no vector unit surface at all; see SEM010.
All generated types are generic over a numeric storage type: where T : struct, INumber<T>.
These are now baked into the generator and enforced by tests. Do not reopen without an architecture discussion.
V0 - V0returns the sameV0ofT.Abs(a - b). Magnitude subtraction stays non-negative; signed subtraction must use the V1 form explicitly.- Dimensionless and angular quantities have both
Ratio(V0) andSignedRatio(V1) bases. Ratios that semantically must be non-negative (e.g.RefractiveIndex,MachNumber,SpecificGravity) are V0 overloads ofRatio. - Semantic overloads widen implicitly to their base, narrow explicitly from it. A
Weightis implicitly aForceMagnitude; the reverse requiresWeight.From(forceMagnitude)or an explicit cast. - Physical constraints are enforced structurally via the V0 (magnitude) form.
Vector0factories runVector0Guards.EnsureNonNegativeand throwArgumentExceptionon a negative value. That covers absolute zero (Temperature is V0, so Kelvin must be ≥ 0), non-negative frequency, non-negative absolute pressure, etc. A V0 overload can opt into a stricter rule by declaringphysicalConstraints: { "minExclusive": "0" }indimensions.json(#51); the generator then emitsVector0Guards.EnsurePositiveand rejects zero too. Used today forWavelength,Period, andHalfLife— quantities for which zero is unphysical. - Logarithmic-scale quantities are generated from
logarithmic.json, not declared as dimensions. Decibel scales (Decibels,SoundPressureLevel,SoundIntensityLevel,SoundPowerLevel,DirectionalityIndex), pitch intervals (Cents,Semitones), andPHdon't obey linear arithmetic, so they are emitted byLogarithmicScalesGeneratoras standalonereadonly partial record structs built aroundscale = multiplier · log_base(linear / reference), converting to and from their linear generated counterparts (Gain,Ratio,SoundPressure,SoundIntensity,SoundPower,Concentration). Bespoke members (named constants likePH.Neutral, cross-scale conversions likeCents↔Semitones) live in hand-written partials next to the metadata-generated core. Adding a new log-scale quantity means adding alogarithmic.jsonentry, plus a partial only if it needs bespoke members.
Semantics.Cpp emits the same vocabulary as C++, for consumers that are not .NET — Holotype is the
one driving it. Two layers, and both earn their place:
-
Structural.
Quantity<D>over aDimensionof eight integer exponents. Shipped as a prelude rather than generated, because none of it is derived from the metadata. It is what gives a product nobody declared a type at all. -
Nominal. One class per dimension, per vector form and per named overload —
Length,Displacement3D,Weight. This is what the exponents cannot do: 75 dimensions share 64 exponent vectors, soAreaandNuclearCrossSection,TorqueandEnergy,AbsorbedDoseandEquivalentDoseare each one vector between two names. 220 classes in all — 153 magnitudes, 29 signed scalars, and 38 vectors of two to four components.The clearest illustration of the point is the newest:
SpecificEnergyisL² T⁻², which is whatAbsorbedDoseandEquivalentDosealready are — specific orbital energy and absorbed radiation dose are both joules per kilogram, and nothing in the exponents separates the energy of an orbit from a radiation dose.SpecificAngularMomentumlands onL² T⁻¹besideKinematicViscositythe same way. OnlyGravitationalParameterbrought a vector of its own,L³ T⁻², which is the 64th.
Eight axes, not the seven in dimensionalFormula before. angle is carried by
AngularDisplacement, AngularVelocity, AngularAcceleration and AngularJerk, and that is the
whole of it. Without it an angle is the same type as a ratio and an angular speed the same type as
a frequency; with it, 62 distinct exponent vectors become 64. It is read by the C++ projection and
carried through DimensionInfo on the .NET side, where ktsu.Schema reads it off a unit to fill
the eight exponents in its C++ reflection table.
A unit claimed by two dimensions reports the one with exponents. Radian, Degree, Gradian,
Milliradian and Revolution are in availableUnits on both AngularDisplacement and
Dimensionless, and the marker interfaces carry both — it is the singular IUnit.Dimension that
has to pick one. Picking the first declared picked by file position, and Dimensionless is the
first entry in dimensions.json, so every angular unit reported no exponents at all: the same
answer a unitless count gives, which is the conflation the axis was added to prevent. A claim that
says something now beats one that says nothing. Where several say something the first still wins,
which decides the other two units claimed twice: SquareMeter is Area and NuclearCrossSection,
and SquareMeterPerSecond is KinematicViscosity and SpecificAngularMomentum. Both are
75-over-64 collisions, so in each the two answers differ in name and not in exponents.
A relationship is checked before it is emitted. The operator is written as
Result{ lhs.value() * rhs.value() }, so the exponents have to agree with the declared result or it
does not compile — which makes every claim in integrals and derivatives checkable. A claim they
contradict is refused by name, with both dimensions written out, rather than emitted as something
broken. Four are refused as the metadata stands.
The check itself lives in Semantics.Vocabulary, and the vocabulary is what both generators emit
from rather than merely check against — QuantityVocabulary.Types is the list of classes to write
and QuantityVocabulary.Relationships the list of operators, on both sides. So a relationship the
exponents refuse is not among the ones there are to write, in either target, and the table below is
simply what neither writes. C# additionally reports each one as SEM008, having somewhere to report
it to; C++ prints them.
The two sides still differ in how many operators one relationship becomes, and that is the language
rather than the physics: the vocabulary states each relationship once, in the direction the metadata
declares it, and the C# generator expands it into the declared direction, its commutation and the
divisions that undo it, because a caller who writes duration * velocity is not making a different
claim. That expansion lives in QuantitiesGenerator.CollectOperators.
| Refused | Why |
|---|---|
Torque * AngularDisplacement -> Energy |
rotational cluster |
MomentOfInertia * AngularVelocity -> AngularMomentum |
rotational cluster |
MomentOfInertia * AngularAcceleration -> Torque |
rotational cluster |
dot(Force, Length) -> Energy |
signed value, magnitude result |
The first three are not fixable by choosing different angle exponents, and that is provable rather
than a matter of opinion: Torque * AngularDisplacement -> Energy forces torque's angle exponent to
−1, and Force x Length -> Torque forces it to 0. It is the classic r×F versus τ·θ contradiction,
and is why SI keeps the radian dimensionless. The nominal layer is what separates torque from
energy; the exponents cannot.
A fifth was refused and is now fixed. Sensitivity * Pressure -> ElectricPotential was the one
unrelated to angle, and it needed no physics judgement in the end: the dimension read M⁻¹ L⁻¹ T² I,
which is amperes per newton, while availableUnits beside it said VoltPerPascal and the
relationship agreed with the unit. Two witnesses against one, so the formula was simply wrong.
Sensitivity is L³ T⁻¹ I⁻¹ — volts per pascal — and the product now lands on
ElectricPotential exactly.
The last is a second kind of refusal, and the vector forms are what surfaced it. The exponents
agree — L M T⁻² · L is L² M T⁻², which is what Energy is — and the claim is still unkeepable,
because a force opposing a displacement does negative work and a magnitude form cannot be negative.
Emitting it would produce a type that fails its own assertion on an ordinary input. The fix is named
in the message rather than guessed at: Energy needs a vector1 form for the result to land in.
One thing the exponents cannot check, so the metadata has to say it correctly. Torque is
τ = r × F, and the relationship is declared on Length — cross(Displacement3D, Force3D) —
which is what puts the operands in that order. It was declared on Force and emitted F × r, its
negation. No exponent can catch that: a cross product and its negation have identical dimensions, so
the only thing standing between the two is which dimension declares the relationship. A test pins
the sign: a force of +10 ŷ at a lever arm of +0.5 x̂ gives +5 about z, and the other order gives −5.
Specific angular momentum is the second relationship under that rule, and it is declared the same
way for the same reason: h = r × v, so cross(Displacement3D, Velocity3D) is declared on
Length. SEM008 passes either declaration — the exponents of h and −h are identical — and
OrbitalMechanicsQuantityTests pins it the way the torque test does. Declaring it on Velocity
instead does not produce a wrong answer quietly; it produces no Displacement3D.Cross(Velocity3D)
at all, because only the declared direction is emitted, so the test stops compiling rather than
silently inverting. That is worth knowing: for a cross product the declaration site is checked by
the call site, and it is the sign of an existing call that nothing but a test can check.
How the generated code is written is measured, not chosen. See the header of
CppQuantityGenerator — the same vocabulary written two ways measured 0.9896 and 1.4004 against
bare floats on MSVC while GCC and clang folded both away, so the wrong formulation passes on three
compilers of four.
The vector forms are distinct classes, not aliases. A Displacement3D holds three
Quantity<Dimension<1>> and is exactly that — sizeof is three floats, trivially copyable,
standard layout — because Holotype copies one whole across a language boundary and onto the wire.
Its arithmetic is componentwise and written out, which is the fourth measured rule (expand at
compile time, never loop over an index; a runtime subscript took the same spike from 1.01 to 4.51
on MSVC). That rule costs a generator nothing, and it is worth knowing why: a hand-written library
spells Vector3<Q> once over every Q, so expanding rather than looping means an index-sequence
fold and the machinery around it; a generator has the components in hand while it writes the class,
so the expanded form is simply what there is to write.
Each form answers magnitude() with the magnitude form of the same dimension, and the dimension
works out rather than being arranged: the sum of the squares of the components has twice a
component's dimension and sqrt halves it again, so the structural layer checks the bridge between
the two halves of the vocabulary. magnitude_squared() answers with a bare Quantity for the
honest reason — the square of a dimension usually has no name, and where it has one it is not
unique, since Area and NuclearCrossSection are the same exponents.
A relationship reaches the vector forms by carrying its form on the left operand and the result,
with the right operand staying a magnitude: Velocity3D * Duration -> Displacement3D. There is no
reading in which the duration has three components. That is the rule the .NET generator follows
too, and forms on a relationship constrains it — a cross product is declared at [3] because
that is where a cross product exists.
Two boundaries, both deliberate. Arithmetic belongs to the signed forms and stops there:
Length - Length has a question in it that Displacement3D - Displacement3D does not — what it
means when the answer would be negative — which the .NET side settled as the absolute difference,
and which is a decision about the magnitude form rather than something to settle alongside the
vectors. And a relationship is emitted in the direction the metadata declares it, so
Duration * Velocity3D is not an overload; the .NET generator emits the commutative and inverse
forms as well, and matching it is a change to every form at once rather than part of this.
SevenTargetProjectionTests is the probe that answers it, and the answer is now seven of
seven. It builds one quantity — the magnitude form of Length, read from the real
dimensions.json — as a language-agnostic ktsu.Coder AST and writes it in all seven of that
library's targets. The neutral shape is the whole of what a generated magnitude is: a record struct
over a storage type, with a value and a unit factory.
C# comes out as exactly what QuantitiesGenerator writes today, which is the result that matters:
the AST is expressive enough for the quantities, so what the other six do is a question about those
languages rather than about the model.
Two targets used to write source their own toolchain refuses, both recorded upstream and both since fixed there. Each was pinned rather than skipped, so the day it was fixed the test failed and was updated to assert the fix — which is how both fixes were noticed at all. The tests assert the fixes now, so a regression upstream fails the same way round.
Python was the first to be fixed. It wrote class Length(IVector0[Length[T], T]), which raises
NameError on import because Python evaluates a base list eagerly. ktsu-dev/Coder#64 fixed that to
the string forward reference IVector0["Length[T]", T], and the first build after the bump from
ktsu.Coder 3.14.0 to 3.14.3 failed here.
Go was the second. A generic type is deliberately written down rather than emitted, and the
constructor was not given the same treatment: it wrote type Length struct with no parameters, then
func LengthFromMeter(value T) Length[T], and go vet said undefined: T. ktsu-dev/Coder#63 wrote
everything over the parameter down too — the field is Value any and the constructor is
func LengthFromMeter(value any) Length — and the first build after the bump from ktsu.Coder 3.14.3
to 3.18.12 failed here.
The probe lives in Semantics.Cpp.Test because that is where the reader of dimensions.json is,
and that is itself the finding about this repository: QuantityMetadata and MetadataProjection
decide nothing about C++ and would have to be shared the way Semantics.Vocabulary already is
before a neutral projection could be a project of its own.
PhysicalConstants is generated from domains.json. Public surface:
// Domain-grouped generic accessors:
PhysicalConstants.Fundamental.SpeedOfLight<T>()
PhysicalConstants.Fundamental.PlanckConstant<T>()
PhysicalConstants.AngularMechanics.DegreesPerRadian<T>()
// Flat accessors over every constant, regardless of domain:
PhysicalConstants.Generic.SpeedOfLight<T>()
PhysicalConstants.Generic.PlanckConstant<T>()
PhysicalConstants.Generic.DegreesPerRadian<T>()Each constant is emitted as a property over its own lazily filled field in a private Values<T>
holder nested in its domain class, filled by StorageLiteral.Constant<T>(literal, name) the first
time it is read, so the literal is parsed once per constant and closed generic type and the package
needs no arbitrary-precision dependency. The literal is parsed with NumberStyles.Float, which is
mandatory, not incidental: several literals are in exponent form (6.62607015e-34, 20e-6), and the
Parse(string, IFormatProvider) overload resolves to NumberStyles.Number for some numeric types,
which rejects an exponent.
Keep constants out of a shared initializer, for the same reason as conversion factors below
(#392): with every constant of a domain in one static initializer, one literal int could not hold
made every constant of that domain throw TypeInitializationException, so PH<int>.Neutral failed
because GasConstant has a fraction. Now a constant T cannot hold throws from its own accessor:
OverflowException when it is too large, and ArithmeticException when T keeps less than about six
significant digits of it (StandardGravity<int> truncating to 9, PlanckConstant<decimal> rounding
to zero). A whole number in range, such as SpeedOfLight<long>(), works.
Unit conversion factors and metric magnitudes reach a storage type the same way constants do.
ConversionsGenerator and MagnitudesGenerator still emit double constants, which back the
public IUnit.ToBaseFactor and ToBaseOffset properties, and alongside them a Values<T> holder.
Each value in the holder is a property over a private nullable field that StorageLiteral.Parse<T>
or StorageLiteral.Divide<T> fills once per closed generic type, and the property converts the
double constant with T.CreateChecked when that field is null. The generated From{Unit} factories
multiply by these properties, and each generated unit implements IUnit.ToBaseFactorAs<T>() and
ToBaseOffsetAs<T>() explicitly from them, which is what ToBase/FromBase and every In(unit)
read. Before 5.2.0 every factor went through T.CreateChecked(double), so a decimal quantity
converted with the 15 significant digits that conversion keeps.
- A value in
conversions.jsonis a decimal literal or an exact fraction of two,"5/9", that adoublecan hold. Write a repeating ratio as the fraction, not as its rounded decimal. SEM009 reports a value that is neither form, is beyond the range ofdouble, or is non-zero and rounds to zero in it. - Write anything built on π as a long literal computed from π itself and correctly rounded to 150
significant digits, never derived from another literal.
DegreeToRadiansonce carried an error from its 98th digit into every factor taken from it.PiLiteralTestschecks each literal built on π inconversions.jsonanddomains.jsonagainst π computed there by Machin's formula. - An integer storage type, or one that cannot parse the literal, gets
nullfromStorageLiteraland converts thedoubleat each read, so integer quantities convert exactly as they did before 5.2.0. A factor too large for the type (CurieToBecquerelsforint,Yottaforlong) throwsOverflowExceptionfrom the factory that uses it, and every other factory keeps working. 5.2.0 converted every value for a type in one static initializer, so that single overflow made every conversion forintthrowTypeInitializationException. Keep conversions out of the initializer.IntegerStorageConversionTestspins the behavior against the 5.1 expressions. - A parse that throws
NotSupportedExceptionorArgumentExceptionforNumberStyles.Float, as a numeric type outside the base library may, counts as one that cannot parse the literal. Nothing else is caught. - Writing factors as their exact definitions in 5.2.0 moved two
doubleconstants to the adjacent representable value, each closer to the true value than before:PsiToPascalsfrom 6894.757293168361 to 6894.757293168362, andRevolutionPerMinuteToRadianPerSecondfrom 0.10471975511965977 to 0.10471975511965978. The publicIUnit.ToBaseFactorofPsiandRevolutionPerMinutemoved with them. Every other constant kept its value. Each constant is written with adsuffix, because a literal such as100000000000000000000is otherwise an integer literal the compiler rejects. ToBaseFactorAs<T>()andToBaseOffsetAs<T>()are default-implemented onIUnit, so a unit written outside the library needs nothing new. They are not namedGet…, because CA1721 rejects aGetToBaseFactormethod next to theToBaseFactorproperty.
Vector Length() and Distance() call StorageMath.Hypot, never a raw sum of squares (#327): the
two-component form is the public Hypot, and three and four components go through internal overloads
of it. A fractional type is scaled by its largest component first, so Position3D<decimal> at 1e15 m
still has a length, and the primitives take double.Hypot, so an int component of 65536 no longer
squares to zero. Normalize() of a zero-length vector throws DivideByZeroException, as every
generated division by zero does (#480). The square root itself is StorageMath.Sqrt: the binary
floating point and integer primitives take the Math.Sqrt round trip. Any other type is seeded from that root and refined with Newton steps in its own
arithmetic, so a decimal length has 28 significant digits. A value a double cannot hold, such as a
BigInteger of 2^2048, is scaled by powers of four into [1, 4) for the seed, and the root is scaled
back by the matching power of two. A root that does not settle throws ArithmeticException rather
than returning an estimate. A type with no fixed precision, such as PreciseNumber, never settles,
because its products keep every digit and each Newton estimate doubles in length; Cbrt and RootN
stop it once a step moves the estimate by less than one part in 10^60 (#457), a tolerance a
fixed-precision type rounds to zero and so never sees. The logarithmic scales and the hand-written audio types still compute
through double, and as of #235 every generated conversion says so in its own XML <remarks>
rather than only here — the method tooltip is where a decimal or PreciseNumber consumer spends
the precision, so that is where the cost is stated. SourceGeneratorTests counts the remarks
against the conversions that actually drop to double, so a conversion added later cannot ship
undocumented. If the scales ever compute in T's own arithmetic (option 3 on #235, which wants
Exp/Log on PreciseNumber first — ktsu-dev/PreciseNumber#81), the remarks become false and
should be removed with the double path rather than left to rot.
StorageMath is public API, not just the generator's helper (#239): an application doing its own
vector math over quantities would otherwise reimplement the root, and worse. Cbrt, RootN and
Hypot ship alongside Sqrt on the same seeding and the same Newton loop. Sqrt keeps its round
trip through double for every primitive, integers included, because the generated code always
inlined it; Cbrt and RootN take that route only for the binary floating point primitives and
refine every integer type in integer arithmetic, so their floor is exact rather than whatever
Math.Pow rounded to. Hypot computes a fractional type from the ratio of its legs, so a pair whose
squares leave the type still has its hypotenuse, and squares an integer type directly, since the
ratio of two integers is not a ratio. The seeding, the double round trip and the Newton loop itself
stay private — StorageMathTests.TheRootsArePublicAndTheirWorkingsAreNot pins both halves of that,
since this is a package with a compatibility baseline and the shape is frozen once it ships.
StorageConversionTests<T> runs the same conversions, relationships and vector lengths over
double and decimal, exactly where the answer terminates and to a relative tolerance where it
does not. Adding a storage type is one derived class.
Semantics.Benchmarks measures three libraries. .github/workflows/benchmark-history.yml runs a
fixed set once per release, appends to a history file per subject under docs/benchmarks/, and
redraws the chart the README shows. Four things about it are not guessable from the code:
BenchmarkAgainstVersionmust be set in the environment, never with-p:. BenchmarkDotNet generates and builds a project of its own for each run, which a property passed on the command line never reaches: the benchmark assembly would build against the version asked for and the harness against the one pinned centrally, which fails to compile if a type changed shape between them. MSBuild reads environment variables as properties in every project, so the environment form reaches both. It swaps all four shipped packages at once, which is correct because this repository ships one version across every package.- The histories and the SVG files are committed output that a bot pushes. A local
rendermust be diffed before commit rather than assumed. The quantities chart in particular is a regression test: a change to the renderer that moves a byte in it has broken something. - Nothing here touches an internal member. The
InternalsVisibleTothat would expose one names only the test assembly, and a benchmark built on internals could only ever measure the working copy, never a published package — which would make the release history impossible to backfill. - All three subjects' benchmarks live in one project, so a compile error in any one file blocks
every subject's run regardless of
--filter.AbstractionCostBenchmarks.csdemonstrated this: it held aLength<T>field with no initializer, valid only onceLength<T>became areadonly record struct, so against a pre-4.0.0 package it failed to build and took every subject's backfill down with it. The fields carrydefault!now and all three histories reach 3.3.1. Keep the pattern when adding a field to any benchmark class the backfill compiles: an error introduced for one subject costs the other two their history, and the failure reads as a build error in a file the person backfilling was not touching.
BaselineBenchmarks.ReferenceWork measures a fixed workload that touches none of this library, so
timings from different CI runners can be compared. Its body must never change. Editing it silently
rescales every comparison drawn against history recorded before the edit.
Cross-dimensional relationships are also declared in dimensions.json (integrals, derivatives, dotProducts, crossProducts). The generator emits operators like:
public static Energy<T> operator *(Force1D<T> f, Length<T> d) =>
Energy<T>.Create(f.Value * d.Value);All values are stored in SI base units, so operators read .Value directly. Suppress CA2225 on physics operators because "named alternates" (Add, Multiply) don't carry the dimensional meaning:
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Usage", "CA2225:Operator overloads have named alternates",
Justification = "Physics relationship operators represent fundamental equations.")]// Copyright (c) 2023-2026 ktsu-dev contributorsThe text comes from COPYRIGHT.md; ktsu.Sdk syncs .editorconfig's file_header_template from that file on every build, and IDE0073 enforces it. Generated files are written with LF line endings, pinned in CreateCodeBlocker to match * text=auto eol=lf in .gitattributes. If COPYRIGHT.md changes, update GeneratorBase.WriteHeaderTo to match — generator output is committed source, so a drift there shows up as a diff rather than a build error (.g.cs is exempt from IDE0073). SourceGeneratorTests asserts the emitted header, so the drift fails a test instead.
Generator-emitted files additionally carry // <auto-generated />.
- Throw
ArgumentExceptionfor validation failures (notFormatException). - Throw
DivideByZeroExceptionwhen dividing by zero inDivideToStorage. - Use the most specific exception type available.
- Use explicit types (no
var) in test bodies. - Pre-create fixtures outside measurement loops in performance tests.
- Mark OS-specific tests with
[TestCategory("OS-Specific")]. - Use 259-character path limit for cross-platform path tests.
- Force GC before memory measurements:
GC.Collect(); GC.WaitForPendingFinalizers();
/// <summary>Gets the physical dimension of <quantity> [<symbol>].</summary>style for dimension properties.- Include
<param>,<returns>,<exception>, and<see cref="">tags on public APIs.
var email = EmailAddress.Create("user@example.com");
var userId = UserId.Create("USER_123");
// Extension method conversion
var email2 = "user@example.com".As<EmailAddress>();
// Cross-type conversion
var converted = sourceString.As<SourceType, TargetType>();AsAbsolute()— convert to absolute using current working directory.AsAbsolute(baseDirectory)— convert to absolute using a specific base.AsRelative(baseDirectory)— convert to relative against a specific base.
- A generator declares the metadata files it reads via
MetadataFileNamesand derives fromSemanticsGenerator<T>(one file) orSemanticsMultiFileGenerator(several). Neither needs to overrideInitialize. Those two bind this repository's diagnostic catalogue and file header ontoGeneratorBase/GeneratorBase<T>fromktsu.SourceGeneratorToolkit; that binding is the seam that keeps the package consumer-agnostic, so keep repository-specific detail on this side of it. - Emitted C# is described with the template model from
ktsu.CodeBlocker.Templates—SourceFileTemplate,ClassTemplate,FieldTemplate,MethodTemplateand friends. The model owns punctuation, spacing, member ordering and indentation, so aBodyFactorywrites only the body:=> Create(value);for an expression body, or a braced block. Do not prefix it with a space; the model supplies the separator. - A type's declaration keyword comes from
ClassTemplate.Kind(TypeKind.Class,TypeKind.Record, …), not fromKeywords, which carries only modifiers. Collection properties are read-only, so use collection-initializer syntax (Keywords = { "public" }) rather than assignment, or theWithComments/WithInterfaceshelpers when you already hold a sequence. - Edit
Semantics.SourceGenerators/Metadata/dimensions.jsonto add a dimension, vector form, semantic overload, or relationship. - Rebuild
Semantics.SourceGeneratorsand the consumingSemantics.Quantitiesproject; emitted files appear inSemantics.Quantities/Generated/Semantics.SourceGenerators/<GeneratorName>/. - Treat generator output as committed source. Diff it before commit so accidental regressions are visible.
- After adding or renaming a quantity, regenerate the storage-type alias props with
pwsh scripts/Generate-AliasProps.ps1(it reads the generated catalogue and rewritesSemantics.Quantities.{Double,Float,Decimal,Precise}/build/*.props) and commit them. Theverify-generatedworkflow rebuilds, regenerates, and fails the PR if either the generated sources or the alias props drift. - Adding a storage type does not mean adding an entry to
precision.json. A new storage type is a new alias package alongsideSemantics.Quantities.{Double,Float,Decimal,Precise}, its generatedbuild/*.props, and one derivedStorageConversionTests<T>class — nothing else.precision.jsonlooks like the place to declare it and is not, for two reasons (#236).PrecisionGeneratoremits itsStorageTypesclass into the corektsu.Semantics.Quantitiesnamespace astypeof({entry}), so an entry whose type comes from a package would force the core assembly to reference that package — undoing the separation the alias packages exist to keep, which is exactly whyPreciseNumberwas left out. And the field name is the entry put throughToUpperInvariant(), so only a C# keyword yields an identifier:ktsu.PreciseNumber.PreciseNumberbecomesKTSU.PRECISENUMBER.PRECISENUMBER, which does not compile.StorageTypeshas no reader anywhere in the repository, so the omission costs nothing; it is public surface that enumerates three of the four shipped storage types, and #236 holds the open decision about whether it should exist at all. - Factory names are the singular lemma (#49). The generator emits
From{name}using each unit'snamefromunits.jsonverbatim (e.g.Length.FromMeter,Mass.FromKilogram,Speed.FromMeterPerSecond,Length.FromFoot,Frequency.FromHertz). The rule is purely mechanical, sonamemust itself be the singular lemma — including compounds, whose leading noun is singular too (MeterPerSecond,RevolutionPerMinute,PartPerMillion, notMeterPerSecond/RevolutionPerMinute/PartPerMillion). There is nofactoryNamefield and no pluralisation step; the generator never has to know English pluralisation. - Generator diagnostics:
- SEM001 — a relationship in
dimensions.jsonreferences a dimension that does not exist (typo or rename). The operator is silently dropped. - SEM002 — schema-level validation issue (missing
name/symbol, emptyavailableUnits, duplicate type names, no vector forms declared). - SEM003 — a relationship's explicit
formslist references a vector form not declared on a participating dimension. Useformsto constrain a relationship to specific vector forms (e.g.crossProducts: [{ "other": "Length", "result": "Torque", "forms": [3] }]); when omitted, the legacy "emit at every common form" behaviour is preserved. - SEM004 — a dimension's
availableUnitsarray references a unit name that isn't declared anywhere inunits.json. Without the diagnostic the generator silently emits an identity-conversionFrom{Unit}factory, which is wrong for any non-base unit; SEM004 catches the typo at build time. - SEM005 — schema-level validation issue in
logarithmic.json(missing or duplicate scale names, a conversion with no linear type). - SEM006 — a metadata file a generator declared in
MetadataFileNameswas not supplied as anAdditionalFile. Previously this produced no output and no explanation, which is indistinguishable from a generator that simply had nothing to emit. - SEM007 — a metadata file could not be parsed. Replaces the base generator's
CONV001in categorySourceGenerator, and covers the path that used to swallow the exception, where a malformedunits.jsonsilently produced factories with no scale factor. - SEM008 — a relationship's declared result does not follow from the dimensions of its operands, or its value is signed and the declared result is a magnitude. The check comes from
Semantics.Vocabulary, shared with the C++ projection; before that this side checked the names (SEM001) and the forms (SEM003) and then emitted the operator, soSensitivity * Pressure -> ElectricPotentialshipped as a working C# operator computing the wrong physics — which is what found that bug, and it is now fixed. No operator is generated for a refused relationship, in any of the directions C# spells a product in — that followed from making the vocabulary drive emission rather than only check it, and the removal is documented indocs/migration-guide-5.0.md. Suppressed inSemantics.Quantities.csprojbecause ktsu.Sdk builds warnings as errors and the four below are outstanding;UnkeepableRelationshipTestspins the set, and asserts that none of them is in the compiled surface, so a fifth fails there rather than disappearing into the suppression. - SEM009: a factor's
valueinconversions.jsonis neither a decimal literal nor a fraction of two with a non-zero denominator, or adoublecannot hold it (a literal, operand, or quotient beyond its range, or a non-zero value that rounds to zero). An error, and no constant is generated for it, because every unit using the factor would otherwise fail to compile far from the metadata line that caused it, or convert with a wrong factor. - SEM010: a dimension declares both a vector form and a unit converting with an additive offset. Adding 273.15 to each component of a displacement is not a unit change, so the whole per-unit surface — every
From{Unit}factory and theIn(unit)reader — is withheld from that dimension's vector types rather than emitted quietly wrong. Withheld as a whole rather than per-unit, becauseIntakes the dimension'sI{Dimension}Unitand would accept the offset unit at runtime even if only its factory were skipped. The scalar forms are unaffected: a V0 applies the offset, and a V1 (a difference, such asTemperatureDelta) converts with the factor alone in bothFrom{Unit}andIn(unit)(#283). Defensive — no dimension declaring a vector form has an offset unit today, andTheRealMetadataReportsNothingUnexpectedis what keeps that true. - Descriptors are allocated from
SemanticsDiagnostics, which is the one place to add a new one.AnalyzerReleaseTrackingTestsfails if the identifier is missing fromAnalyzerReleases.Unshipped.md, so RS2008 no longer surfaces only after a push.
- SEM001 — a relationship in
- See
docs/physics-generator.mdfor the full schema and an end-to-end "add a dimension" walk-through.
This file is the entry point. For deeper material:
docs/strategy-unified-vector-quantities.md— architecture spec for the unified vector model.docs/physics-generator.md— generator +dimensions.jsonschema.docs/architecture.md— semantic strings/paths/validation architecture (SOLID, design patterns).docs/complete-library-guide.md— user-facing guide to all components.docs/validation-reference.md— list of validation attributes.docs/advanced-usage.md— advanced patterns for strings/paths.docs/migration-guide-2.0.md— 1.x → 2.0 upgrade guide (renames, namespace moves, behavioral changes).docs/migration-guide-3.0.md— 2.x → 3.0 upgrade guide (removed first-class .NET type attributes, chord flag enum renames).docs/migration-guide-3.1.md— 3.0 → 3.1 upgrade guide (JSON converter is now opt-in,PhysicalConstantsdomain fields became generic accessors).docs/migration-guide-4.0.md— 3.x → 4.0 upgrade guide (every quantity became areadonly record struct).docs/migration-guide-5.0.md— 4.x → 5.0 upgrade guide (the four dimensionally unkeepable relationships no longer generate operators).