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
62 changes: 51 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +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.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. |
| `Semantics.Test` | MSTest project covering all of the above. |

## Semantic quantities architecture (the unified vector model)
Expand Down Expand Up @@ -86,10 +86,11 @@ 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.
- **Nominal.** One class per dimension, per vector form and per named overload — `Length`,
`Displacement3D`, `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. 212 classes in all — 148 magnitudes,
27 signed scalars, and 37 vectors of two to four components.

**Eight axes, not the seven in `dimensionalFormula` before.** `angle` is carried by
`AngularDisplacement`, `AngularVelocity`, `AngularAcceleration` and `AngularJerk`, and that is the
Expand All @@ -101,14 +102,15 @@ carried through `DimensionInfo` on the .NET side, where nothing depends on it ye
`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:
broken. Five 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** |
| `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
Expand All @@ -120,15 +122,53 @@ The fourth is unrelated to angle and was already wrong: `Sensitivity` is declare
(`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.

The fifth 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, and do not.** `Force × Length → Torque` is emitted as
`cross(Force3D, Displacement3D)`, which is **F × r**, and the convention is τ = **r × F**. The two
differ by a sign, and no exponent can tell them apart — a cross product and its negation have
identical dimensions. It is left as declared rather than quietly reordered, because which operand
comes first is a claim the metadata makes and a physics call to change, the same as `Sensitivity`.

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

### Physical constants

Expand Down
198 changes: 195 additions & 3 deletions Semantics.Cpp.Test/CppQuantityGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
}

/// <summary>
/// A class per dimension and per named overload, plus the prelude and the two roll-ups.
/// A class per dimension, per vector form and per named overload, plus the prelude and the two
/// roll-ups.
/// </summary>
[TestMethod]
public void GeneratesAHeaderPerQuantity()
Expand Down Expand Up @@ -198,8 +199,199 @@

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)}");
|| issue.Contains("does not declare", StringComparison.Ordinal)
|| issue.Contains("reduces to a signed value", StringComparison.Ordinal)),
$"every refusal should say which of the three things went wrong; got: {string.Join(" | ", refused)}");
}

/// <summary>
/// A vector form is a class of its own with as many components as it has dimensions, not an
/// alias for the magnitude and not an array.
/// </summary>
[TestMethod]
public void GeneratesAClassPerVectorForm()
{
Assert.Contains("Displacement1D.hpp", Output.Files.Keys);
Assert.Contains("Displacement2D.hpp", Output.Files.Keys);
Assert.Contains("Displacement3D.hpp", Output.Files.Keys);
Assert.Contains("Displacement4D.hpp", Output.Files.Keys);

string displacement = Output.Files["Displacement3D.hpp"];

Assert.Contains("explicit constexpr Displacement3D(component x, component y, component z)", displacement, StringComparison.Ordinal);
Assert.Contains("component x_{};", displacement, StringComparison.Ordinal);
Assert.Contains("component z_{};", displacement, StringComparison.Ordinal);
Assert.DoesNotContain("component w_{};", displacement, StringComparison.Ordinal);
}

/// <summary>
/// An overload of a vector form is a distinct class too, and widens and narrows across every
/// component rather than only the first.
/// </summary>
[TestMethod]
public void WidensAndNarrowsAVectorAcrossAllOfItsComponents()
{
string position = Output.Files["Position3D.hpp"];

Assert.Contains("return Displacement3D{ x_, y_, z_ };", position, StringComparison.Ordinal);
Assert.Contains("return Position3D{ value.x(), value.y(), value.z() };", position, StringComparison.Ordinal);
}

/// <summary>
/// Rule four of the four the zero-cost measurement produced: a componentwise operation is
/// expanded at compile time rather than looped over an index.
/// </summary>
/// <remarks>
/// A loop over a runtime subscript is what took the same spike from 1.01 to 4.51 on MSVC. It
/// costs this generator nothing to obey, because the components are in hand while the class is
/// being written -- so what is asserted here is that the expansion is in the text, with no
/// index anywhere for a compiler to have an opinion about.
/// </remarks>
[TestMethod]
public void ExpandsAComponentwiseOperationRatherThanLoopingOverIt()
{
string displacement = Output.Files["Displacement3D.hpp"];

Assert.Contains(
"return Displacement3D{ lhs.x_ + rhs.x_, lhs.y_ + rhs.y_, lhs.z_ + rhs.z_ };",
displacement,
StringComparison.Ordinal);

Assert.DoesNotContain("for(", displacement, StringComparison.Ordinal);
Assert.DoesNotContain("operator[]", displacement, StringComparison.Ordinal);
}

/// <summary>
/// Arithmetic belongs to the signed forms and stops there.
/// </summary>
/// <remarks>
/// Not an oversight. <c>Length - Length</c> has a question in it that the vector forms do not:
/// what it means when the answer would be negative. The .NET side settled that as the absolute
/// difference, and settling it here is a decision about the magnitude form rather than
/// something to smuggle in alongside the vectors.
/// </remarks>
[TestMethod]
public void GivesArithmeticToTheSignedFormsOnly()
{
Assert.Contains("operator+(Displacement1D lhs, Displacement1D rhs)", Output.Files["Displacement1D.hpp"], StringComparison.Ordinal);
Assert.Contains("operator+(Displacement3D lhs, Displacement3D rhs)", Output.Files["Displacement3D.hpp"], StringComparison.Ordinal);
Assert.DoesNotContain("operator+(Length lhs, Length rhs)", Output.Files["Length.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// A vector compares for equality and for nothing else: there is no reading in which one
/// displacement is less than another.
/// </summary>
[TestMethod]
public void OrdersAScalarAndOnlyEquatesAVector()
{
Assert.Contains("operator<=>(Length, Length)", Output.Files["Length.hpp"], StringComparison.Ordinal);
Assert.Contains("operator<=>(Displacement1D, Displacement1D)", Output.Files["Displacement1D.hpp"], StringComparison.Ordinal);

string displacement = Output.Files["Displacement3D.hpp"];

Assert.Contains("operator==(Displacement3D, Displacement3D)", displacement, StringComparison.Ordinal);
Assert.DoesNotContain("operator<=>", displacement, StringComparison.Ordinal);
}

/// <summary>
/// Every signed form answers its size with the magnitude form of the same dimension, which is
/// the one place the signed half of the vocabulary reaches back into the unsigned half.
/// </summary>
/// <remarks>
/// The dimension works out rather than being arranged: the sum of the squares of the
/// components has twice a component's dimension, and <c>sqrt</c> halves it again. The
/// magnitude form's constructor takes exactly that, so the two would not compile together if
/// the generator had this wrong -- which is what
/// <c>GeneratedCppCompilesTests</c> then actually checks.
/// </remarks>
[TestMethod]
public void AnswersItsSizeWithTheMagnitudeForm()
{
Assert.Contains("Length magnitude() const", Output.Files["Displacement1D.hpp"], StringComparison.Ordinal);
Assert.Contains("return Length{ abs(value_) };", Output.Files["Displacement1D.hpp"], StringComparison.Ordinal);

string displacement = Output.Files["Displacement3D.hpp"];

Assert.Contains("Length magnitude() const", displacement, StringComparison.Ordinal);
Assert.Contains("return Length{ sqrt(magnitude_squared()) };", displacement, StringComparison.Ordinal);

// A length squared is an area, and the structural layer is what says so without having to
// choose between Area and NuclearCrossSection, which are the same exponents.
Assert.Contains("constexpr Quantity<Dimension<2>> magnitude_squared() const", displacement, StringComparison.Ordinal);

Assert.DoesNotContain("magnitude()", Output.Files["Length.hpp"], StringComparison.Ordinal);
}

/// <summary>
/// A relationship reaches the vector forms by carrying its form on the left operand and the
/// result, with the right operand staying a magnitude.
/// </summary>
/// <remarks>
/// There is no reading in which the duration in <c>Velocity3D * Duration</c> has three
/// components, which is why the form propagates along one side rather than all three. It is
/// the same rule the .NET generator follows.
/// </remarks>
[TestMethod]
public void CarriesARelationshipToEveryFormItsParticipantsShare()
{
string relationships = Output.Files["relationships.hpp"];

Assert.Contains("Length operator*(Speed lhs, Duration rhs)", relationships, StringComparison.Ordinal);
Assert.Contains("Displacement1D operator*(Velocity1D lhs, Duration rhs)", relationships, StringComparison.Ordinal);
Assert.Contains("Displacement3D operator*(Velocity3D lhs, Duration rhs)", relationships, StringComparison.Ordinal);

Assert.Contains(
"return Displacement3D{ lhs.x() * rhs.value(), lhs.y() * rhs.value(), lhs.z() * rhs.value() };",
relationships,
StringComparison.Ordinal);
}

/// <summary>
/// A cross product is emitted at three components and nowhere else.
/// </summary>
/// <remarks>
/// That is the definition rather than a limitation: the cross product exists in 3D and 7D and
/// nowhere else, and the metadata says so with <c>forms: [3]</c>. It is a named call rather
/// than an operator because C++ has no symbol for it.
/// </remarks>
[TestMethod]
public void GeneratesACrossProductAtThreeComponentsOnly()
{
string relationships = Output.Files["relationships.hpp"];

Assert.Contains("Torque3D cross(Force3D lhs, Displacement3D rhs)", relationships, StringComparison.Ordinal);
Assert.Contains(
"return Torque3D{ lhs.y() * rhs.z() - lhs.z() * rhs.y(), lhs.z() * rhs.x() - lhs.x() * rhs.z(), lhs.x() * rhs.y() - lhs.y() * rhs.x() };",
relationships,
StringComparison.Ordinal);

Assert.DoesNotContain("cross(Force2D", relationships, StringComparison.Ordinal);
Assert.DoesNotContain("cross(ForceMagnitude", relationships, StringComparison.Ordinal);
}

/// <summary>
/// The second kind of refusal, which the vector forms are what surfaced: a claim the exponents
/// agree with and the sign does not.
/// </summary>
/// <remarks>
/// A force opposing a displacement does negative work, so <c>dot</c> answers with a signed
/// value; the metadata names <c>Energy</c> for the result, and a magnitude form cannot be
/// negative. Emitting it would produce a type that fails its own assertion on a perfectly
/// ordinary input, so it is refused with the fix named -- a <c>vector1</c> form on
/// <c>Energy</c> -- rather than generated.
/// </remarks>
[TestMethod]
public void RefusesADotProductThatWouldLandInAMagnitude()
{
IReadOnlyList<string> refused = Output.Refused;

Assert.IsTrue(
refused.Any(issue => issue.Contains("dot(Force, Length) -> Energy", StringComparison.Ordinal)
&& issue.Contains("vector1", StringComparison.Ordinal)),
$"the refusal should name the relationship and what would fix it; got: {string.Join(" | ", refused)}");

Check warning on line 392 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=AaCQqW2UFJb5fFm1ZQC_&open=AaCQqW2UFJb5fFm1ZQC_&pullRequest=215

Assert.DoesNotContain("dot(", Output.Files["relationships.hpp"], StringComparison.Ordinal);
}

/// <summary>
Expand Down
Loading
Loading