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, 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 (PhysicalQuantity<TSelf, T>, IVector0..IVector4, UnitSystem) plus generator output under Generated/. |
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} |
Props-only satellite packages. Each ships a buildTransitive 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>. |
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.
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.
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 T.Parse(literal, NumberStyles.Float, CultureInfo.InvariantCulture)
into a private Values<T> holder nested in its domain class, so the literal is parsed once per
closed generic type and the package needs no arbitrary-precision dependency. NumberStyles.Float
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.
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}/buildTransitive/*.props) and commit them. Theverify-generatedworkflow rebuilds, regenerates, and fails the PR if either the generated sources or the alias props drift. - 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. - 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).