Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona
| `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}` | 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.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 and per named overload, plus the declared relationships as operators. |
| `Semantics.Cpp.Test` | Its tests, including one that compiles the whole generated vocabulary with `g++`/`clang++` and one that checks a dimensionally wrong product is refused by the compiler. |
| `Semantics.Test` | MSTest project covering all of the above. |

## Semantic quantities architecture (the unified vector model)
Expand Down Expand Up @@ -76,6 +78,58 @@ These are now baked into the generator and enforced by tests. **Do not reopen wi
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 struct`s 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 and per named overload — `Length`, `Speed`, `Weight`. This is
what the exponents cannot do: **72 dimensions share 63 exponent vectors**, so `Area` and
`NuclearCrossSection`, `Torque` and `Energy`, `AbsorbedDose` and `EquivalentDose` are each one
vector between two names.

**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, 61 distinct exponent vectors become 63. It is read by the C++ projection and
carried through `DimensionInfo` on the .NET side, where nothing depends on it yet.

**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:

| Refused | Why |
|---|---|
| `Torque * AngularDisplacement -> Energy` | rotational cluster |
| `MomentOfInertia * AngularVelocity -> AngularMomentum` | rotational cluster |
| `MomentOfInertia * AngularAcceleration -> Torque` | rotational cluster |
| `Sensitivity * Pressure -> ElectricPotential` | **pre-existing metadata bug** |

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.

The fourth is unrelated to angle and was already wrong: `Sensitivity` is declared as A/Pa
(`M⁻¹L⁻¹T²I`) while the relationship treats it as V/Pa. One of the two is wrong and it is a physics
call, so it is reported rather than guessed at.

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

**Not generated yet:** the vector forms. `dimensions.json` declares 122 dimension-and-form entries
and this projects the 72 magnitude forms plus their 90 overloads. The vector forms are distinct
classes too and need componentwise operations, which have their own rule (expand at compile time,
never loop over an index) — so they are deliberately not half-done.

### Physical constants

`PhysicalConstants` is **generated** from `domains.json`. Public surface:
Expand Down
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Numerics.Vectors" Version="4.6.1" />
<!-- The C++ projection of the quantity vocabulary. ktsu.Coder publishes net10.0 and net9.0
only, which is why Semantics.Cpp is a project of its own rather than part of a library
that also ships net8.0. -->
<PackageVersion Include="ktsu.Coder" Version="3.6.0" />
<!-- Source generator packages -->
<PackageVersion Include="ktsu.CodeBlocker" Version="2.0.4" />
<PackageVersion Include="ktsu.SourceGeneratorToolkit" Version="1.0.2" />
Expand Down
229 changes: 229 additions & 0 deletions Semantics.Cpp.Test/CppQuantityGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Cpp.Test;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

using ktsu.Semantics.Cpp;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Covers the C++ projection of the quantity vocabulary, driven by the real metadata.
/// </summary>
[TestClass]
public sealed class CppQuantityGeneratorTests
{
/// <summary>
/// Generated once. Every test below reads the same output, because generating it is the
/// expensive part and none of them change it.
/// </summary>
private static CppQuantityOutput Output { get; } = new CppQuantityGenerator(
new CppQuantityOptions { Namespace = "holo" }).Generate(Metadata());

private static QuantityMetadata Metadata() =>
QuantityMetadata.Parse(File.ReadAllText(Path.Join(AppContext.BaseDirectory, "Metadata", "dimensions.json")));

/// <summary>
/// The metadata is read at all, which is the one thing every other test rests on.
/// </summary>
[TestMethod]
public void ReadsTheMetadata()
{
QuantityMetadata metadata = Metadata();

Assert.IsGreaterThan(60, metadata.PhysicalDimensions.Count);
Assert.IsTrue(
metadata.PhysicalDimensions.Any(dimension => string.Equals(dimension.Name, "Length", StringComparison.Ordinal)),
"Length is the dimension everything else is checked against; the file should declare it.");

Check warning on line 41 in Semantics.Cpp.Test/CppQuantityGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Semantics&issues=AaCQimYnfSCQZXWFvwWV&open=AaCQimYnfSCQZXWFvwWV&pullRequest=214
}

/// <summary>
/// A class per dimension and per named overload, plus the prelude and the two roll-ups.
/// </summary>
[TestMethod]
public void GeneratesAHeaderPerQuantity()
{
Assert.Contains("Length.hpp", Output.Files.Keys);
Assert.Contains("Speed.hpp", Output.Files.Keys);
Assert.Contains("Weight.hpp", Output.Files.Keys, "an overload is a type of its own, not an alias");
Assert.Contains("dimension.hpp", Output.Files.Keys);
Assert.Contains("quantity.hpp", Output.Files.Keys);
Assert.Contains("relationships.hpp", Output.Files.Keys);
Assert.Contains("quantities.hpp", Output.Files.Keys);
}

/// <summary>
/// The target's namespace reaches the prelude as well as the generated classes: the prelude is
/// substituted rather than fixed, which is the one thing about it that is not literal.
/// </summary>
[TestMethod]
public void PutsEverythingInTheTargetsNamespace()
{
Assert.Contains("namespace holo", Output.Files["quantity.hpp"], StringComparison.Ordinal);
Assert.Contains("namespace holo", Output.Files["Length.hpp"], StringComparison.Ordinal);
Assert.DoesNotContain("@NAMESPACE@", Output.Files["dimension.hpp"], StringComparison.Ordinal);
Assert.DoesNotContain("@BANNER@", Output.Files["dimension.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// A quantity is a distinct class over a <c>Quantity</c> of its exponents, and the exponents
/// are written as the significant prefix rather than as eight numbers.
/// </summary>
[TestMethod]
public void WritesADimensionAsItsSignificantPrefix()
{
Assert.Contains("using underlying = Quantity<Dimension<1>>;", Output.Files["Length.hpp"], StringComparison.Ordinal);

// Velocity is L T⁻¹, so the prefix runs to the third axis and stops.
Assert.Contains("using underlying = Quantity<Dimension<1, 0, -1>>;", Output.Files["Speed.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// The eighth axis is what the four angular dimensions are for, and this is the pair that
/// justified adding it: an angular speed and a frequency are both per-second and must not be
/// the same type.
/// </summary>
[TestMethod]
public void SeparatesAnAngularSpeedFromAFrequency()
{
string angular = Output.Files["AngularSpeed.hpp"];
string frequency = Output.Files["Frequency.hpp"];

Assert.Contains("Quantity<Dimension<0, 0, -1, 1>>", angular, StringComparison.Ordinal);
Assert.Contains("Quantity<Dimension<0, 0, -1>>", frequency, StringComparison.Ordinal);
}

/// <summary>
/// An overload widens implicitly into its base and narrows back explicitly, which is the
/// convention <c>ktsu.Schema</c> holds for a semantic type and this library holds for an
/// overload. The two agreed about it independently.
/// </summary>
[TestMethod]
public void WidensImplicitlyAndNarrowsExplicitly()
{
string weight = Output.Files["Weight.hpp"];

Assert.Contains("operator ForceMagnitude()", weight, StringComparison.Ordinal);
Assert.Contains("static constexpr Weight from(ForceMagnitude value)", weight, StringComparison.Ordinal);
Assert.Contains("using refines = ForceMagnitude;", weight, StringComparison.Ordinal);
}

/// <summary>
/// A magnitude cannot be negative, and the check is compiled out of the build the zero-cost
/// claim is measured in.
/// </summary>
[TestMethod]
public void GuardsAMagnitudeInDebugOnly()
{
Assert.Contains("assert(value.count() >= 0", Output.Files["Length.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// Three overloads declare that zero is unphysical for them too, and they get the stricter
/// comparison rather than the shared one.
/// </summary>
[TestMethod]
public void GuardsAStrictlyPositiveQuantityMoreTightly()
{
Assert.Contains("assert(value.count() > 0", Output.Files["Wavelength.hpp"], StringComparison.Ordinal);
Assert.Contains("assert(value.count() >= 0", Output.Files["Length.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// Rule two of the four the zero-cost measurement produced: an accessor returns a reference.
/// </summary>
/// <remarks>
/// Returning the component by value instead was one of the three differences that took the
/// generated shape from 0.9896 to 1.4004 on MSVC while GCC and clang folded both away.
/// </remarks>
[TestMethod]
public void ReturnsTheUnderlyingValueByReference()
{
Assert.Contains("const underlying& value() const", Output.Files["Length.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// Rule three: the arithmetic stays in <c>Quantity</c> space rather than unwrapping to a
/// number and rewrapping the result.
/// </summary>
/// <remarks>
/// This is also what makes a declared relationship checkable. <c>value()</c> returns a
/// <c>Quantity</c>, so its product carries the summed exponents and only converts to the
/// declared result when they agree.
/// </remarks>
[TestMethod]
public void KeepsRelationshipArithmeticInQuantitySpace()
{
string relationships = Output.Files["relationships.hpp"];

Assert.Contains("lhs.value() * rhs.value()", relationships, StringComparison.Ordinal);
Assert.DoesNotContain("lhs.count()", relationships, StringComparison.Ordinal);
}

/// <summary>
/// The operators the metadata declares are generated as free functions.
/// </summary>
[TestMethod]
public void GeneratesTheDeclaredRelationships()
{
string relationships = Output.Files["relationships.hpp"];

// Speed integrated over time is a length, which is the relationship every other one is
// checked by analogy with.
Assert.Contains("Length operator*(Speed lhs, Duration rhs)", relationships, StringComparison.Ordinal);
}

/// <summary>
/// A relationship the exponents contradict is refused by name rather than emitted as something
/// that cannot compile.
/// </summary>
/// <remarks>
/// Four are refused on the metadata as it stands. Three are the rotational cluster, where no
/// assignment of angle exponents can satisfy both <c>Force x Length -&gt; Torque</c> and
/// <c>Torque * AngularDisplacement -&gt; Energy</c> -- the two force the same exponent to be 0
/// and -1. The fourth was already wrong before angle existed.
/// </remarks>
[TestMethod]
public void RefusesARelationshipTheExponentsContradict()
{
IReadOnlyList<string> refused = Output.Refused;

Assert.IsTrue(
refused.Any(issue => issue.Contains("Sensitivity * Pressure", StringComparison.Ordinal)),
$"expected the pre-existing metadata error to be caught; got: {string.Join(" | ", refused)}");

Check warning on line 197 in Semantics.Cpp.Test/CppQuantityGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Semantics&issues=AaCQimYnfSCQZXWFvwWW&open=AaCQimYnfSCQZXWFvwWW&pullRequest=214

Assert.IsTrue(
refused.All(issue => issue.Contains("is not dimensionally true", StringComparison.Ordinal)
|| issue.Contains("does not declare", StringComparison.Ordinal)),
$"every refusal should say which of the two things went wrong; got: {string.Join(" | ", refused)}");
}

/// <summary>
/// Nothing refused is also generated: a refusal has to mean the operator is absent, or it is
/// only a log line.
/// </summary>
[TestMethod]
public void DoesNotGenerateWhatItRefused()
{
string relationships = Output.Files["relationships.hpp"];

Assert.DoesNotContain("ElectricPotential operator*(Sensitivity", relationships, StringComparison.Ordinal);
Assert.DoesNotContain("Torque operator*(MomentOfInertia", relationships, StringComparison.Ordinal);
}

/// <summary>
/// Every generated file says where it came from and that editing it is pointless.
/// </summary>
[TestMethod]
public void SaysItIsGenerated()
{
foreach ((string name, string text) in Output.Files)
{
Assert.Contains("Do not edit", text, StringComparison.Ordinal, $"{name} should say it is generated");
}
}
}
Loading
Loading