Skip to content

Latest commit

 

History

History
535 lines (438 loc) · 48.2 KB

CLAUDE.md

File metadata and controls

535 lines (438 loc) · 48.2 KB

CLAUDE.md

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).

Build commands

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.

Reproducing SonarCloud warnings locally

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.props
dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props

Note 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 layout

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.

Semantic quantities architecture (the unified vector model)

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>.

Resolved design decisions

These are now baked into the generator and enforced by tests. Do not reopen without an architecture discussion.

  1. V0 - V0 returns the same V0 of T.Abs(a - b). Magnitude subtraction stays non-negative; signed subtraction must use the V1 form explicitly.
  2. Dimensionless and angular quantities have both Ratio (V0) and SignedRatio (V1) bases. Ratios that semantically must be non-negative (e.g. RefractiveIndex, MachNumber, SpecificGravity) are V0 overloads of Ratio.
  3. Semantic overloads widen implicitly to their base, narrow explicitly from it. A Weight is implicitly a ForceMagnitude; the reverse requires Weight.From(forceMagnitude) or an explicit cast.
  4. Physical constraints are enforced structurally via the V0 (magnitude) form. Vector0 factories run Vector0Guards.EnsureNonNegative and throw ArgumentException on 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 declaring physicalConstraints: { "minExclusive": "0" } in dimensions.json (#51); the generator then emits Vector0Guards.EnsurePositive and rejects zero too. Used today for Wavelength, Period, and HalfLife — quantities for which zero is unphysical.
  5. Logarithmic-scale quantities are generated from logarithmic.json, not declared as dimensions. Decibel scales (Decibels, SoundPressureLevel, SoundIntensityLevel, SoundPowerLevel, DirectionalityIndex), pitch intervals (Cents, Semitones), and PH don't obey linear arithmetic, so they are emitted by LogarithmicScalesGenerator as standalone readonly partial record structs built around scale = multiplier · log_base(linear / reference), converting to and from their linear generated counterparts (Gain, Ratio, SoundPressure, SoundIntensity, SoundPower, Concentration). Bespoke members (named constants like PH.Neutral, cross-scale conversions like Cents↔Semitones) live in hand-written partials next to the metadata-generated core. Adding a new log-scale quantity means adding a logarithmic.json entry, plus a partial only if it needs bespoke members.

The C++ projection

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 a Dimension of 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, so Area and NuclearCrossSection, Torque and Energy, AbsorbedDose and EquivalentDose are 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: SpecificEnergy is L² T⁻², which is what AbsorbedDose and EquivalentDose already 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. SpecificAngularMomentum lands on L² T⁻¹ beside KinematicViscosity the same way. Only GravitationalParameter brought 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.

Can the quantities be generated for languages other than C# and C++?

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.

Physical constants

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.

Conversion factors and square roots at the storage type's precision

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.json is a decimal literal or an exact fraction of two, "5/9", that a double can 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 of double, 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. DegreeToRadians once carried an error from its 98th digit into every factor taken from it. PiLiteralTests checks each literal built on π in conversions.json and domains.json against π computed there by Machin's formula.
  • An integer storage type, or one that cannot parse the literal, gets null from StorageLiteral and converts the double at each read, so integer quantities convert exactly as they did before 5.2.0. A factor too large for the type (CurieToBecquerels for int, Yotta for long) throws OverflowException from 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 for int throw TypeInitializationException. Keep conversions out of the initializer. IntegerStorageConversionTests pins the behavior against the 5.1 expressions.
  • A parse that throws NotSupportedException or ArgumentException for NumberStyles.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 double constants to the adjacent representable value, each closer to the true value than before: PsiToPascals from 6894.757293168361 to 6894.757293168362, and RevolutionPerMinuteToRadianPerSecond from 0.10471975511965977 to 0.10471975511965978. The public IUnit.ToBaseFactor of Psi and RevolutionPerMinute moved with them. Every other constant kept its value. Each constant is written with a d suffix, because a literal such as 100000000000000000000 is otherwise an integer literal the compiler rejects.
  • ToBaseFactorAs<T>() and ToBaseOffsetAs<T>() are default-implemented on IUnit, so a unit written outside the library needs nothing new. They are not named Get…, because CA1721 rejects a GetToBaseFactor method next to the ToBaseFactor property.

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.

Benchmarks and the release charts

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:

  • BenchmarkAgainstVersion must 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 render must 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 InternalsVisibleTo that 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.cs demonstrated this: it held a Length<T> field with no initializer, valid only once Length<T> became a readonly 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 carry default! 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.

Operators and physics relationships

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.")]

Code standards

File headers

// Copyright (c) 2023-2026 ktsu-dev contributors

The 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 />.

Validation and error handling

  • Throw ArgumentException for validation failures (not FormatException).
  • Throw DivideByZeroException when dividing by zero in DivideToStorage.
  • Use the most specific exception type available.

Testing

  • 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();

XML documentation

  • /// <summary>Gets the physical dimension of <quantity> [<symbol>].</summary> style for dimension properties.
  • Include <param>, <returns>, <exception>, and <see cref=""> tags on public APIs.

Important implementation notes

Semantic string creation

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>();

Path conversion

  • 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.

Working with the source generator

  • A generator declares the metadata files it reads via MetadataFileNames and derives from SemanticsGenerator<T> (one file) or SemanticsMultiFileGenerator (several). Neither needs to override Initialize. Those two bind this repository's diagnostic catalogue and file header onto GeneratorBase/GeneratorBase<T> from ktsu.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, MethodTemplate and friends. The model owns punctuation, spacing, member ordering and indentation, so a BodyFactory writes 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 from Keywords, which carries only modifiers. Collection properties are read-only, so use collection-initializer syntax (Keywords = { "public" }) rather than assignment, or the WithComments/WithInterfaces helpers when you already hold a sequence.
  • Edit Semantics.SourceGenerators/Metadata/dimensions.json to add a dimension, vector form, semantic overload, or relationship.
  • Rebuild Semantics.SourceGenerators and the consuming Semantics.Quantities project; emitted files appear in Semantics.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 rewrites Semantics.Quantities.{Double,Float,Decimal,Precise}/build/*.props) and commit them. The verify-generated workflow 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 alongside Semantics.Quantities.{Double,Float,Decimal,Precise}, its generated build/*.props, and one derived StorageConversionTests<T> class — nothing else. precision.json looks like the place to declare it and is not, for two reasons (#236). PrecisionGenerator emits its StorageTypes class into the core ktsu.Semantics.Quantities namespace as typeof({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 why PreciseNumber was left out. And the field name is the entry put through ToUpperInvariant(), so only a C# keyword yields an identifier: ktsu.PreciseNumber.PreciseNumber becomes KTSU.PRECISENUMBER.PRECISENUMBER, which does not compile. StorageTypes has 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's name from units.json verbatim (e.g. Length.FromMeter, Mass.FromKilogram, Speed.FromMeterPerSecond, Length.FromFoot, Frequency.FromHertz). The rule is purely mechanical, so name must itself be the singular lemma — including compounds, whose leading noun is singular too (MeterPerSecond, RevolutionPerMinute, PartPerMillion, not MeterPerSecond/RevolutionPerMinute/PartPerMillion). There is no factoryName field and no pluralisation step; the generator never has to know English pluralisation.
  • Generator diagnostics:
    • SEM001 — a relationship in dimensions.json references a dimension that does not exist (typo or rename). The operator is silently dropped.
    • SEM002 — schema-level validation issue (missing name/symbol, empty availableUnits, duplicate type names, no vector forms declared).
    • SEM003 — a relationship's explicit forms list references a vector form not declared on a participating dimension. Use forms to 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 availableUnits array references a unit name that isn't declared anywhere in units.json. Without the diagnostic the generator silently emits an identity-conversion From{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 MetadataFileNames was not supplied as an AdditionalFile. 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 CONV001 in category SourceGenerator, and covers the path that used to swallow the exception, where a malformed units.json silently 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, so Sensitivity * Pressure -> ElectricPotential shipped 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 in docs/migration-guide-5.0.md. Suppressed in Semantics.Quantities.csproj because ktsu.Sdk builds warnings as errors and the four below are outstanding; UnkeepableRelationshipTests pins 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 value in conversions.json is neither a decimal literal nor a fraction of two with a non-zero denominator, or a double cannot 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 the In(unit) reader — is withheld from that dimension's vector types rather than emitted quietly wrong. Withheld as a whole rather than per-unit, because In takes the dimension's I{Dimension}Unit and 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 as TemperatureDelta) converts with the factor alone in both From{Unit} and In(unit) (#283). Defensive — no dimension declaring a vector form has an offset unit today, and TheRealMetadataReportsNothingUnexpected is what keeps that true.
    • Descriptors are allocated from SemanticsDiagnostics, which is the one place to add a new one. AnalyzerReleaseTrackingTests fails if the identifier is missing from AnalyzerReleases.Unshipped.md, so RS2008 no longer surfaces only after a push.
  • See docs/physics-generator.md for 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.json schema.
  • 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, PhysicalConstants domain fields became generic accessors).
  • docs/migration-guide-4.0.md — 3.x → 4.0 upgrade guide (every quantity became a readonly record struct).
  • docs/migration-guide-5.0.md — 4.x → 5.0 upgrade guide (the four dimensionally unkeepable relationships no longer generate operators).