diff --git a/CLAUDE.md b/CLAUDE.md index 681191f..128bfc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,8 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona | `Semantics.Quantities` | Hand-written runtime types (`IPhysicalQuantity`, `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`. | -| `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) @@ -86,10 +86,11 @@ one driving it. Two layers, and both earn their place: - **Structural.** `Quantity` 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 @@ -101,7 +102,7 @@ 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 | |---|---| @@ -109,6 +110,7 @@ broken. Four are refused as the metadata stands: | `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 @@ -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>` 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` 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 diff --git a/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs index a6171a8..418a7f3 100644 --- a/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs +++ b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs @@ -42,7 +42,8 @@ public void ReadsTheMetadata() } /// - /// 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. /// [TestMethod] public void GeneratesAHeaderPerQuantity() @@ -198,8 +199,199 @@ public void RefusesARelationshipTheExponentsContradict() 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)}"); + } + + /// + /// 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. + /// + [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); + } + + /// + /// An overload of a vector form is a distinct class too, and widens and narrows across every + /// component rather than only the first. + /// + [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); + } + + /// + /// Rule four of the four the zero-cost measurement produced: a componentwise operation is + /// expanded at compile time rather than looped over an index. + /// + /// + /// 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. + /// + [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); + } + + /// + /// Arithmetic belongs to the signed forms and stops there. + /// + /// + /// Not an oversight. Length - Length 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. + /// + [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); + } + + /// + /// A vector compares for equality and for nothing else: there is no reading in which one + /// displacement is less than another. + /// + [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); + } + + /// + /// 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. + /// + /// + /// 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. The + /// magnitude form's constructor takes exactly that, so the two would not compile together if + /// the generator had this wrong -- which is what + /// GeneratedCppCompilesTests then actually checks. + /// + [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> magnitude_squared() const", displacement, StringComparison.Ordinal); + + Assert.DoesNotContain("magnitude()", Output.Files["Length.hpp"], StringComparison.Ordinal); + } + + /// + /// A relationship reaches the vector forms by carrying its form on the left operand and the + /// result, with the right operand staying a magnitude. + /// + /// + /// There is no reading in which the duration in Velocity3D * Duration 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. + /// + [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); + } + + /// + /// A cross product is emitted at three components and nowhere else. + /// + /// + /// 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 forms: [3]. It is a named call rather + /// than an operator because C++ has no symbol for it. + /// + [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); + } + + /// + /// The second kind of refusal, which the vector forms are what surfaced: a claim the exponents + /// agree with and the sign does not. + /// + /// + /// A force opposing a displacement does negative work, so dot answers with a signed + /// value; the metadata names Energy 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 vector1 form on + /// Energy -- rather than generated. + /// + [TestMethod] + public void RefusesADotProductThatWouldLandInAMagnitude() + { + IReadOnlyList 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)}"); + + Assert.DoesNotContain("dot(", Output.Files["relationships.hpp"], StringComparison.Ordinal); } /// diff --git a/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs index 5f39b33..74f22a3 100644 --- a/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs +++ b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs @@ -79,6 +79,99 @@ public void AProductWithTheWrongDimensionDoesNotCompile() Assert.AreNotEqual(0, exitCode, "a product whose exponents do not match the result type should be refused"); } + /// + /// The vector forms mean what they say, and the compiler is what says so. + /// + /// + /// Every claim below is a static_assert, so this needs no run: a wrong answer is a + /// compile error and -fsyntax-only reaches it. That matters more for the vector forms + /// than it did for the magnitudes, because a good deal of what they promise is arithmetic + /// rather than shape -- that the length of (3, 4, 0) is 5, that scaling by a duration lands in + /// the right type, that an overload survives the trip out to its base and back. + /// + /// The layout assertions are the other half. Holotype copies a vector whole across a language + /// boundary and onto the wire, which only works if the class is exactly its components with + /// nothing added. + /// + /// + [TestMethod] + public void TheVectorFormsMeanWhatTheySay() + { + string directory = Emit(); + File.WriteAllText(Path.Join(directory, "meaning.cpp"), """ + #include "quantities.hpp" + #include + + using namespace holo; + + // Exactly its components, which is what lets one be copied whole. + static_assert(sizeof(Displacement3D) == 3 * sizeof(float)); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v); + + constexpr Displacement3D d{ Displacement3D::component{ 3.0f }, Displacement3D::component{ 4.0f }, Displacement3D::component{ 0.0f } }; + static_assert(d.magnitude_squared().count() == 25.0f); + + // A velocity scaled by a duration is a displacement, and it is componentwise. + constexpr Duration t{ Duration::underlying{ 2.0f } }; + constexpr Velocity3D v{ Velocity3D::component{ 1.0f }, Velocity3D::component{ 2.0f }, Velocity3D::component{ 3.0f } }; + static_assert((v * t).x().count() == 2.0f); + static_assert((v * t).z().count() == 6.0f); + + // The one-component form is signed, and its magnitude is not. + constexpr Displacement1D back{ Displacement1D::underlying{ -5.0f } }; + static_assert(back.magnitude().value().count() == 5.0f); + static_assert((-back).value().count() == 5.0f); + + // An overload widens to its base implicitly and narrows back by name, across every + // component rather than only the first. + constexpr Position3D p{ Position3D::component{ 1.0f }, Position3D::component{ 2.0f }, Position3D::component{ 3.0f } }; + constexpr Displacement3D widened = p; + static_assert(widened.z().count() == 3.0f); + static_assert(Position3D::from(widened).z().count() == 3.0f); + + int main() { return 0; } + """); + + (int exitCode, string output) = Compile(directory, "meaning.cpp"); + + Assert.AreEqual(0, exitCode, $"the vector forms should behave as generated:\n{output}"); + } + + /// + /// The structural layer checks a componentwise relationship the same way it checks a scalar + /// one. + /// + /// + /// The vector half of , and worth + /// having separately: a generator that expanded the components correctly but lost the + /// dimension on the way would pass the scalar test and fail here. + /// + [TestMethod] + public void AComponentwiseProductWithTheWrongDimensionDoesNotCompile() + { + string directory = Emit(); + File.WriteAllText(Path.Join(directory, "wrongvector.cpp"), """ + #include "Displacement3D.hpp" + #include "Velocity3D.hpp" + #include "Duration.hpp" + + // A displacement times a duration is L T, and a velocity is L T⁻¹. The components are + // expanded correctly and the dimension is still wrong, which is the case that would + // slip past a test that only looked at the shape. + holo::Velocity3D wrong(holo::Displacement3D l, holo::Duration d) + { + return holo::Velocity3D{ l.x() * d.value(), l.y() * d.value(), l.z() * d.value() }; + } + + int main() { return 0; } + """); + + (int exitCode, _) = Compile(directory, "wrongvector.cpp"); + + Assert.AreNotEqual(0, exitCode, "a componentwise product whose exponents do not match the result type should be refused"); + } + private static string Emit() { string directory = Path.Join(Path.GetTempPath(), $"semantics-cpp-{Guid.NewGuid():N}"); diff --git a/Semantics.Cpp/CppQuantityGenerator.cs b/Semantics.Cpp/CppQuantityGenerator.cs index e012c59..5acd0c4 100644 --- a/Semantics.Cpp/CppQuantityGenerator.cs +++ b/Semantics.Cpp/CppQuantityGenerator.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Cpp; using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -18,11 +19,11 @@ namespace ktsu.Semantics.Cpp; /// /// The vocabulary is two layers and this generates the upper one. Underneath is /// Quantity<D> over an eight-exponent Dimension, which is shipped rather than -/// generated because no part of it is derived from the metadata. On top is one class per dimension -/// and per named overload -- Length, Speed, Weight -- because the exponents -/// cannot tell every pair of quantities apart: 72 dimensions share 63 exponent vectors, so -/// Area and NuclearCrossSection are one vector between two names, and naming them is -/// the only thing that separates them. +/// generated because no part of it is derived from the metadata. On top is one class per dimension, +/// per vector form and per named overload -- Length, Displacement3D, Weight -- +/// because the exponents cannot tell every pair of quantities apart: 72 dimensions share 63 +/// exponent vectors, so Area and NuclearCrossSection are one vector between two +/// names, and naming them is the only thing that separates them. /// /// How the generated code is written is not a matter of taste, and this is the part to read /// before changing anything here. The same vocabulary written two ways measured 0.9896 and @@ -36,10 +37,19 @@ namespace ktsu.Semantics.Cpp; /// An accessor returns a reference, not a copy. /// Arithmetic stays in Quantity space rather than unwrapping to a number and /// rewrapping the result, which is work the optimiser then has to undo. -/// A componentwise operation expands at compile time rather than looping over an index. -/// That one applies to the vector forms, which this does not generate yet. +/// A componentwise operation expands at compile time rather than looping over an index. A +/// loop over a runtime subscript is what took the same spike from 1.01 to 4.51 on MSVC. /// /// +/// Rule four is the one the vector forms brought, and it costs this generator nothing, which is +/// worth saying because it costs a hand-written library a great deal. A library writes +/// Vector3<Q> once over every Q, so its componentwise operations have to be +/// written once too, and expanding them at compile time rather than looping means an index-sequence +/// fold and the machinery around it. A generator has the components in hand when it writes the +/// class, so it writes them out: Displacement3D{ a.x() * s, a.y() * s, a.z() * s } is the +/// fully expanded form already, with no fold to arrange and no index to be a runtime value. +/// +/// /// Rule three is also why a relationship is checked before it is emitted. The operator is written /// as Energy{ f.value() * d.value() }, so the exponents have to agree with the declared /// result or it does not compile -- which makes the metadata's claims checkable, and means a claim @@ -51,13 +61,23 @@ namespace ktsu.Semantics.Cpp; public sealed class CppQuantityGenerator(CppQuantityOptions options) { private const string UnderlyingAlias = "underlying"; - private const string ValueField = "value_"; + private const string ComponentAlias = "component"; private const string ValueName = "value"; private const string DimensionTemplate = "Dimension"; private const string QuantityTemplate = "Quantity"; private const string PreludeNamespaceToken = "@NAMESPACE@"; private const string PreludeBannerToken = "@BANNER@"; + /// + /// What a vector's components are called, in order. + /// + /// + /// Names rather than an index, because x and y are what a reader wants and + /// [0] is not -- and because a component reached by name is reached at compile time, + /// which is rule four. + /// + private static readonly string[] ComponentNames = ["x", "y", "z", "w"]; + /// /// Initializes a new instance of the class with the /// defaults. @@ -129,7 +149,23 @@ public CppQuantityOutput Generate(QuantityMetadata metadata) private string Banner() => $"Generated by {Options.GeneratedBy}. Do not edit."; /// - /// One quantity class: a distinct type wrapping a Quantity of its dimension. + /// What a type's components are called: one value for a scalar, and x through + /// w for a vector. + /// + private static string[] Components(QuantityType type) => + type.IsVector ? ComponentNames[..type.Form] : [ValueName]; + + /// The member behind one component's accessor. + private static string Field(string component) => $"{component}_"; + + /// + /// What one component is spelled as: a scalar's whole value, or one of a vector's several. + /// + private static string Storage(QuantityType type) => type.IsVector ? ComponentAlias : UnderlyingAlias; + + /// + /// One quantity class: a distinct type wrapping one Quantity of its dimension, or as + /// many of them as it has components. /// private SourceFile Quantity(QuantityType type) { @@ -149,8 +185,15 @@ private SourceFile Quantity(QuantityType type) includes.Add(""); } + if (type.Form > 0) + { + // Every 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. + includes.Add($"\"{type.MagnitudeType}{Options.HeaderExtension}\""); + } + declaration.Members.Add(new UsingAlias( - UnderlyingAlias, + Storage(type), $"{QuantityTemplate}<{type.Dimension.ToCpp(DimensionTemplate)}>")); if (type.Refines is not null) @@ -167,8 +210,22 @@ private SourceFile Quantity(QuantityType type) Definition = FunctionDefinition.Defaulted, }); - declaration.Members.Add(FromUnderlying(type)); - declaration.Members.Add(Accessor()); + declaration.Members.Add(FromComponents(type)); + + foreach (string component in Components(type)) + { + declaration.Members.Add(Accessor(type, component)); + } + + if (type.Form > 0) + { + declaration.Members.Add(MagnitudeOf(type)); + } + + if (type.IsVector) + { + declaration.Members.Add(MagnitudeSquared(type)); + } if (type.Refines is not null) { @@ -176,9 +233,32 @@ private SourceFile Quantity(QuantityType type) declaration.Members.Add(Narrowing(type)); } + if (type.Magnitude is Magnitude.Signed) + { + // Arithmetic belongs to the signed forms and stops at them. A magnitude has the + // question of what `Length - Length` means when the answer would be negative, which + // the .NET side settled as the absolute difference; that is a decision about the + // magnitude form rather than about the vector forms, and it is not made here. + foreach (AstNode member in Arithmetic(type)) + { + declaration.Members.Add(member); + } + } + declaration.Members.Add(Comparison(type.Name, "==", "bool")); - declaration.Members.Add(Comparison(type.Name, "<=>", "auto")); - declaration.Members.Add(new FieldDeclaration(ValueField, UnderlyingAlias) { Visibility = Visibility.Private }); + + if (!type.IsVector) + { + // A vector has no order: there is no reading in which one displacement is less than + // another, so it gets equality and nothing more. + declaration.Members.Add(Comparison(type.Name, "<=>", "auto")); + } + + foreach (string component in Components(type)) + { + declaration.Members.Add( + new FieldDeclaration(Field(component), Storage(type)) { Visibility = Visibility.Private }); + } SourceFile file = new(type.Name) { IsHeader = true }; Preamble(file, includes); @@ -195,7 +275,7 @@ private SourceFile Quantity(QuantityType type) /// to elide. NDEBUG is what tells the two apart, and assert is already compiled /// out by it, so no guard of our own is needed around it. /// - private static FunctionDeclaration FromUnderlying(QuantityType type) + private static FunctionDeclaration FromComponents(QuantityType type) { FunctionDeclaration constructor = new(type.Name) { @@ -205,9 +285,18 @@ private static FunctionDeclaration FromUnderlying(QuantityType type) IsNoThrow = true, }; - constructor.Documentation.Add($"Explicit: a bare value never becomes {Article(type.Name)} {type.Name} by accident."); - constructor.Parameters.Add(new Parameter(ValueName, UnderlyingAlias)); - constructor.Initialisers.Add(new MemberInitialiser(ValueField, new VariableReference(Guarded(type)))); + string[] components = Components(type); + + constructor.Documentation.Add(type.IsVector + ? $"Explicit: {components.Length.ToString(CultureInfo.InvariantCulture)} bare values never become {Article(type.Name)} {type.Name} by accident." + : $"Explicit: a bare value never becomes {Article(type.Name)} {type.Name} by accident."); + + foreach (string component in components) + { + constructor.Parameters.Add(new Parameter(component, Storage(type))); + constructor.Initialisers.Add( + new MemberInitialiser(Field(component), new VariableReference(Guarded(type, component)))); + } return constructor; } @@ -229,11 +318,11 @@ private static FunctionDeclaration FromUnderlying(QuantityType type) /// in has nothing to elide. /// /// - private static string Guarded(QuantityType type) + private static string Guarded(QuantityType type, string component) { if (type.Magnitude is Magnitude.Signed) { - return ValueName; + return component; } string comparison = type.Magnitude == Magnitude.Positive ? ">" : ">="; @@ -241,27 +330,189 @@ private static string Guarded(QuantityType type) ? $"{Article(type.Name)} {type.Name} of zero is not a physical value" : $"{Article(type.Name)} {type.Name} cannot be negative"; - return $"(assert({ValueName}.count() {comparison} 0 && \"{says}\"), {ValueName})"; + return $"(assert({component}.count() {comparison} 0 && \"{says}\"), {component})"; } - private static FunctionDeclaration Accessor() + private static FunctionDeclaration Accessor(QuantityType type, string component) { - FunctionDeclaration accessor = new(ValueName) + FunctionDeclaration accessor = new(component) { // A reference rather than a copy, which on MSVC is the difference between a register // and a spill. Rule two. - ReturnType = $"const {UnderlyingAlias}&", + ReturnType = $"const {Storage(type)}&", IsPure = true, IsCompileTimeEvaluable = true, IsReadOnly = true, IsNoThrow = true, }; - accessor.Documentation.Add("Named, because getting the value back out is a decision too."); - accessor.Body.Add(new ReturnStatement(new VariableReference(ValueField))); + accessor.Documentation.Add(type.IsVector + ? $"The {component} component." + : "Named, because getting the value back out is a decision too."); + + accessor.Body.Add(new ReturnStatement(new VariableReference(Field(component)))); return accessor; } + /// + /// How big this is, without its direction: the bridge from a signed form back to the magnitude + /// form of the same dimension. + /// + /// + /// A vector's length is not constant-evaluable because a square root is not, so only the + /// one-component form -- where the answer is an absolute value -- is constexpr. + /// + /// The dimension works out on its own and that is worth noticing rather than arranging: the sum + /// of the squares of the components has twice the dimension of one of them, and halving it + /// again is what sqrt does, so the result is a component's dimension whatever that was. + /// The magnitude form's constructor takes exactly that, so a mistake here would not compile. + /// + /// + private static FunctionDeclaration MagnitudeOf(QuantityType type) + { + FunctionDeclaration magnitude = new("magnitude") + { + ReturnType = type.MagnitudeType, + IsPure = true, + IsCompileTimeEvaluable = !type.IsVector, + IsReadOnly = true, + IsNoThrow = true, + }; + + magnitude.Documentation.Add(type.IsVector + ? $"How long this is, as {Article(type.MagnitudeType)} {type.MagnitudeType}." + : $"How big this is without its sign, as {Article(type.MagnitudeType)} {type.MagnitudeType}."); + + string size = type.IsVector ? "sqrt(magnitude_squared())" : $"abs({Field(ValueName)})"; + + magnitude.Body.Add(new ReturnStatement(new ConstructionExpression(type.MagnitudeType) + { + Arguments = { new VariableReference(size) }, + })); + + return magnitude; + } + + /// + /// The square of the length, which needs no square root and so stays constant-evaluable. + /// + /// + /// It answers with a bare Quantity rather than a named type, and that is the honest + /// answer rather than a shortcut: the square of a dimension usually has no name in the + /// metadata, and where it does -- a length squared is an area -- the name is not unique, since + /// Area and NuclearCrossSection are the same exponents. The structural layer is + /// exactly what exists for a value whose dimension is real and whose name is not. + /// + private static FunctionDeclaration MagnitudeSquared(QuantityType type) + { + FunctionDeclaration squared = new("magnitude_squared") + { + ReturnType = $"{QuantityTemplate}<{(type.Dimension + type.Dimension).ToCpp(DimensionTemplate)}>", + IsPure = true, + IsCompileTimeEvaluable = true, + IsReadOnly = true, + IsNoThrow = true, + }; + + squared.Documentation.Add("The square of the length, for a comparison that does not need the root."); + squared.Body.Add(new ReturnStatement(new VariableReference( + string.Join(" + ", Components(type).Select(component => $"{Field(component)} * {Field(component)}"))))); + + return squared; + } + + /// + /// What a signed form can do with another of itself: add, subtract, negate and scale. + /// + /// + /// Componentwise and written out, which is rule four with nothing clever about it. A generated + /// class knows how many components it has while it is being written, so the expanded form is + /// simply what there is to write. + /// + private static IEnumerable Arithmetic(QuantityType type) + { + yield return Binary(type, "+"); + yield return Binary(type, "-"); + yield return Negation(type); + yield return Scaled(type, "*", scaleFirst: false); + yield return Scaled(type, "*", scaleFirst: true); + yield return Scaled(type, "/", scaleFirst: false); + } + + private static FunctionDeclaration Binary(QuantityType type, string symbol) + { + FunctionDeclaration declaration = Friend(symbol, type.Name); + declaration.Parameters.Add(new Parameter("lhs", type.Name)); + declaration.Parameters.Add(new Parameter("rhs", type.Name)); + declaration.Body.Add(Built(type, component => $"lhs.{Field(component)} {symbol} rhs.{Field(component)}")); + return declaration; + } + + private static FunctionDeclaration Negation(QuantityType type) + { + FunctionDeclaration declaration = Friend("-", type.Name); + declaration.Parameters.Add(new Parameter("operand", type.Name)); + declaration.Body.Add(Built(type, component => $"-operand.{Field(component)}")); + return declaration; + } + + /// + /// Scaling by a bare number, which leaves the dimension alone. + /// + /// + /// Both orders, because a reader writes 2.0f * v as readily as v * 2.0f and C++ + /// will not find the second overload from the first. Division has only the one order: a number + /// divided by a displacement is not a displacement. + /// + private static FunctionDeclaration Scaled(QuantityType type, string symbol, bool scaleFirst) + { + FunctionDeclaration declaration = Friend(symbol, type.Name); + string scale = "scale"; + string storage = $"{Storage(type)}::rep"; + + if (scaleFirst) + { + declaration.Parameters.Add(new Parameter(scale, storage)); + declaration.Parameters.Add(new Parameter("operand", type.Name)); + } + else + { + declaration.Parameters.Add(new Parameter("operand", type.Name)); + declaration.Parameters.Add(new Parameter(scale, storage)); + } + + declaration.Body.Add(Built(type, component => scaleFirst + ? $"{scale} {symbol} operand.{Field(component)}" + : $"operand.{Field(component)} {symbol} {scale}")); + + return declaration; + } + + /// + /// A result built from all of its components at once, which is rule one. + /// + private static ReturnStatement Built(QuantityType type, Func perComponent) + { + ConstructionExpression construction = new(type.Name); + + foreach (string component in Components(type)) + { + construction.Arguments.Add(new VariableReference(perComponent(component))); + } + + return new ReturnStatement(construction); + } + + private static FunctionDeclaration Friend(string symbol, string returnType) => new(symbol) + { + Kind = FunctionKind.Operator, + ReturnType = returnType, + IsPure = true, + IsFriend = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + }; + private static FunctionDeclaration Widening(QuantityType type) { FunctionDeclaration widening = new(type.Refines!) @@ -275,9 +526,14 @@ private static FunctionDeclaration Widening(QuantityType type) }; widening.Documentation.Add($"Widening is implicit: this is {Article(type.Refines!)} {type.Refines}."); - widening.Body.Add(new ReturnStatement( - new ConstructionExpression(type.Refines) { Arguments = { new VariableReference(ValueField) } })); + ConstructionExpression construction = new(type.Refines); + foreach (string component in Components(type)) + { + construction.Arguments.Add(new VariableReference(Field(component))); + } + + widening.Body.Add(new ReturnStatement(construction)); return widening; } @@ -295,11 +551,14 @@ private static FunctionDeclaration Narrowing(QuantityType type) narrowing.Documentation.Add( $"Narrowing is explicit and named: not every {type.Refines} is {Article(type.Name)} {type.Name}."); narrowing.Parameters.Add(new Parameter(ValueName, type.Refines!)); - narrowing.Body.Add(new ReturnStatement(new ConstructionExpression(type.Name) + + ConstructionExpression construction = new(type.Name); + foreach (string component in Components(type)) { - Arguments = { new VariableReference($"{ValueName}.{ValueName}()") }, - })); + construction.Arguments.Add(new VariableReference($"{ValueName}.{component}()")); + } + narrowing.Body.Add(new ReturnStatement(construction)); return narrowing; } @@ -343,41 +602,87 @@ private SourceFile Relationships(QuantityVocabulary vocabulary) includes.Add($"\"{named}{Options.HeaderExtension}\""); } - FunctionDeclaration declaration = new(relationship.Symbol) - { - Kind = FunctionKind.Operator, - ReturnType = relationship.Result, - IsPure = true, - IsCompileTimeEvaluable = true, - IsNoThrow = true, - }; - - declaration.Documentation.Add(relationship.ToString()); - declaration.Parameters.Add(new Parameter("lhs", relationship.Left)); - declaration.Parameters.Add(new Parameter("rhs", relationship.Right)); - - // Rule three: the arithmetic stays in Quantity space. That is also what makes the - // declared relationship checkable -- if the exponents disagreed with the result type - // this would not compile, which is why a relationship that disagrees is never - // generated in the first place. - declaration.Body.Add(new ReturnStatement(new ConstructionExpression(relationship.Result) - { - Arguments = { new VariableReference($"lhs.{ValueName}() {relationship.Symbol} rhs.{ValueName}()") }, - })); - - operators.Add(declaration); + operators.Add(Operator(relationship)); } Preamble(file, includes); file.HeaderComment.Add(string.Empty); - file.HeaderComment.Add($"{vocabulary.Relationships.Count} relationships, from the integrals and derivatives"); - file.HeaderComment.Add("dimensions.json declares. Each is checked against the exponents before it is"); - file.HeaderComment.Add("written, so every operator here is one the dimensions agree with."); + file.HeaderComment.Add($"{vocabulary.Relationships.Count} relationships, from what dimensions.json declares as"); + file.HeaderComment.Add("integrals, derivatives and cross products. Each is checked against the exponents"); + file.HeaderComment.Add("before it is written, so every operator here is one the dimensions agree with."); file.Members.Add(Namespaced([.. operators])); return file; } + /// + /// One declared relationship, at one form. + /// + /// + /// Rule three: the arithmetic stays in Quantity space. That is also what makes the + /// declared relationship checkable -- if the exponents disagreed with the result type this + /// would not compile, which is why a relationship that disagrees is never generated in the + /// first place. + /// + private static FunctionDeclaration Operator(QuantityRelationship relationship) + { + FunctionDeclaration declaration = new(relationship.Symbol) + { + Kind = relationship.IsOperator ? FunctionKind.Operator : FunctionKind.Method, + ReturnType = relationship.Result, + IsPure = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + }; + + declaration.Documentation.Add(relationship.ToString()); + declaration.Parameters.Add(new Parameter("lhs", relationship.Left)); + declaration.Parameters.Add(new Parameter("rhs", relationship.Right)); + + ConstructionExpression construction = new(relationship.Result); + foreach (string term in relationship.Kind == RelationshipKind.Cross + ? Crossed() + : Scaling(relationship)) + { + construction.Arguments.Add(new VariableReference(term)); + } + + declaration.Body.Add(new ReturnStatement(construction)); + return declaration; + } + + /// + /// Every component of the left operand against the whole of the right, which is what scaling a + /// vector by a magnitude is. + /// + private static IEnumerable Scaling(QuantityRelationship relationship) => + Reached(relationship.Form).Select(component => + $"lhs.{component}() {relationship.Symbol} rhs.{ValueName}()"); + + /// + /// The three components of a cross product, written out. + /// + /// + /// Only in three dimensions, which is the definition rather than a limitation of this + /// generator: the cross product exists in 3D and 7D and nowhere else, and the metadata + /// declares it at forms: [3] for that reason. + /// + private static IEnumerable Crossed() + { + string[] axes = ["x", "y", "z"]; + + for (int axis = 0; axis < axes.Length; axis++) + { + string next = axes[(axis + 1) % axes.Length]; + string after = axes[(axis + 2) % axes.Length]; + yield return $"lhs.{next}() * rhs.{after}() - lhs.{after}() * rhs.{next}()"; + } + } + + /// What the accessors of a type at one form are called. + private static string[] Reached(int form) => + form >= 2 ? ComponentNames[..form] : [ValueName]; + /// /// One header that includes the whole vocabulary, for a program that does not want to track /// which quantity lives where. @@ -394,10 +699,21 @@ private SourceFile Umbrella(QuantityVocabulary vocabulary) Preamble(file, includes); file.HeaderComment.Add(string.Empty); - file.HeaderComment.Add($"{vocabulary.Types.Count} quantity types over {DistinctDimensions(vocabulary)} distinct dimensions."); + file.HeaderComment.Add($"{vocabulary.Types.Count} quantity types over {DistinctDimensions(vocabulary)} distinct dimensions,"); + file.HeaderComment.Add($"in {Forms(vocabulary)}."); return file; } + private static string Forms(QuantityVocabulary vocabulary) + { + IEnumerable counted = vocabulary.Types + .GroupBy(type => type.Form) + .OrderBy(group => group.Key) + .Select(group => $"{group.Count().ToString(CultureInfo.InvariantCulture)} at vector{group.Key.ToString(CultureInfo.InvariantCulture)}"); + + return string.Join(", ", counted); + } + private static int DistinctDimensions(QuantityVocabulary vocabulary) => vocabulary.Types.Select(type => type.Dimension).Distinct().Count(); @@ -434,7 +750,7 @@ private static string Article(string name) => /// /// Every file to write, keyed by name. /// -/// What the metadata asked for that the exponents contradict, each named with both dimensions +/// What the metadata asked for that the generator will not honour, each named with the reason /// written out. Empty is the expected state; anything here is a metadata bug rather than a /// generator limitation. /// diff --git a/Semantics.Cpp/Prelude/quantity.hpp b/Semantics.Cpp/Prelude/quantity.hpp index 6efd578..2fed466 100644 --- a/Semantics.Cpp/Prelude/quantity.hpp +++ b/Semantics.Cpp/Prelude/quantity.hpp @@ -133,6 +133,15 @@ namespace @NAMESPACE@ return Quantity, Rep>{ scale / q.count() }; } + // The size of a quantity regardless of its sign, with the dimension untouched. Written out + // rather than calling std::abs because std::abs is not constexpr before C++23, and a generated + // magnitude() that stops being usable in a constant expression would be a silent loss. + template + [[nodiscard]] constexpr Quantity abs(Quantity q) noexcept + { + return Quantity{ q.count() < Rep{} ? -q.count() : q.count() }; + } + // The square root of a quantity halves its dimension, which only exists when every exponent is // even. `sqrt(SquareMetres)` is a length; `sqrt(Metres)` is rejected at compile time. template diff --git a/Semantics.Cpp/QuantityMetadata.cs b/Semantics.Cpp/QuantityMetadata.cs index 8c7f2ee..9c28e08 100644 --- a/Semantics.Cpp/QuantityMetadata.cs +++ b/Semantics.Cpp/QuantityMetadata.cs @@ -77,14 +77,59 @@ public sealed class MetadataDimension /// Gets what this dimension divided by another produces. public Collection Derivatives { get; } = []; + + /// Gets what this dimension dotted with another produces. + public Collection DotProducts { get; } = []; + + /// Gets what this dimension crossed with another produces. + public Collection CrossProducts { get; } = []; } -/// The vector forms a dimension declares. Only the magnitude form is read so far. +/// The vector forms a dimension declares, from the magnitude up to four components. +/// +/// Indexed rather than named individually by everything that walks them, because every rule about +/// a form -- what it is called, how many components it has, whether a relationship reaches it -- +/// is the same rule with a different number in it. +/// public sealed class MetadataForms { /// Gets or sets the magnitude form, or null when the dimension has none. [JsonPropertyName("vector0")] public MetadataForm? Vector0 { get; set; } + + /// Gets or sets the signed one-dimensional form. + [JsonPropertyName("vector1")] + public MetadataForm? Vector1 { get; set; } + + /// Gets or sets the two-component form. + [JsonPropertyName("vector2")] + public MetadataForm? Vector2 { get; set; } + + /// Gets or sets the three-component form. + [JsonPropertyName("vector3")] + public MetadataForm? Vector3 { get; set; } + + /// Gets or sets the four-component form. + [JsonPropertyName("vector4")] + public MetadataForm? Vector4 { get; set; } + + /// The number of forms a dimension can declare, counting the magnitude. + internal const int Count = 5; + + /// + /// Gets one form by how many components it has. + /// + /// The component count, from zero to four. + /// The form, or null when the dimension does not declare it. + internal MetadataForm? this[int form] => form switch + { + 0 => Vector0, + 1 => Vector1, + 2 => Vector2, + 3 => Vector3, + 4 => Vector4, + _ => null, + }; } /// One vector form: a base type and the names that refine it. @@ -131,6 +176,11 @@ public sealed class MetadataConstraints } /// One declared relationship between dimensions. +/// +/// The collection property is get-only and populated in place, which is what +/// asks for. +/// +[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public sealed class MetadataRelationship { /// Gets or sets the dimension on the other side of the operator. @@ -138,4 +188,14 @@ public sealed class MetadataRelationship /// Gets or sets the dimension the operator produces. public string Result { get; set; } = string.Empty; + + /// + /// Gets the vector forms this relationship is declared at, or nothing to mean every form the + /// participants share. + /// + /// + /// A constraint rather than a request: a cross product is declared at [3] because it is + /// only defined in three dimensions, not because three is the form someone happened to want. + /// + public Collection Forms { get; } = []; } diff --git a/Semantics.Cpp/QuantityVocabulary.cs b/Semantics.Cpp/QuantityVocabulary.cs index 2dbc081..bd08244 100644 --- a/Semantics.Cpp/QuantityVocabulary.cs +++ b/Semantics.Cpp/QuantityVocabulary.cs @@ -4,6 +4,7 @@ namespace ktsu.Semantics.Cpp; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; /// @@ -29,12 +30,25 @@ internal enum Magnitude /// Its exponents. /// The base it widens into, or null when it is the base. /// How it is bounded below. +/// +/// How many components it has: zero for a magnitude, one for a signed scalar, and two to four for +/// a vector. +/// +/// +/// The name of the same dimension's magnitude form, which is what magnitude() answers with. +/// internal sealed record QuantityType( string Name, string Description, DimensionVector Dimension, string? Refines, - Magnitude Magnitude); + Magnitude Magnitude, + int Form, + string MagnitudeType) +{ + /// Gets a value indicating whether this holds several components rather than one. + internal bool IsVector => Form >= 2; +} /// /// What combining two quantities produces. @@ -46,18 +60,43 @@ internal enum RelationshipKind /// The left operand divided by the right. Quotient, + + /// Two vectors reduced to how much of one lies along the other. + Dot, + + /// Two three-component vectors combined into the one perpendicular to both. + Cross, } /// /// One generated operator: two quantities in, a named quantity out. /// -internal sealed record QuantityRelationship(string Left, string Right, string Result, RelationshipKind Kind) +/// The type on the left, with components. +/// +/// The type on the right. For a product or a quotient this is always a magnitude, because scaling +/// a vector is the only way those reach the vector forms; for a cross product it has as many +/// components as the left. +/// +/// The type produced. +/// How the two are combined. +/// How many components the left operand and the result have. +internal sealed record QuantityRelationship(string Left, string Right, string Result, RelationshipKind Kind, int Form) { - /// Gets the operator as C++ spells it. - internal string Symbol => Kind == RelationshipKind.Product ? "*" : "/"; + /// Gets the operator as C++ spells it, or the function's name when it has no symbol. + internal string Symbol => Kind switch + { + RelationshipKind.Product => "*", + RelationshipKind.Quotient => "/", + RelationshipKind.Cross => "cross", + _ => "dot", + }; + + /// Gets a value indicating whether C++ spells this as an operator rather than a call. + internal bool IsOperator => Kind is RelationshipKind.Product or RelationshipKind.Quotient; /// - public override string ToString() => $"{Left} {Symbol} {Right} -> {Result}"; + public override string ToString() => + IsOperator ? $"{Left} {Symbol} {Right} -> {Result}" : $"{Symbol}({Left}, {Right}) -> {Result}"; } /// @@ -87,6 +126,13 @@ internal sealed record VocabularyIssue(string Subject, string Reason) /// wrong before angle existed and had never been noticed, because nothing had ever multiplied the /// exponents out. /// +/// +/// The exponents are not the only thing that can make a claim unkeepable, and the vector forms +/// brought the second kind: a magnitude cannot be negative, so a relationship whose value is +/// signed but whose declared result is a magnitude is refused for that reason instead. A dot +/// product is the case in the metadata. The message says the same two things either way -- what is +/// wrong, and what would fix it. +/// /// internal sealed class QuantityVocabulary { @@ -120,44 +166,31 @@ internal static QuantityVocabulary FromMetadata(QuantityMetadata metadata) List refused = []; Dictionary byDimensionName = []; - Dictionary baseTypeOf = []; + Dictionary formsOf = []; foreach (MetadataDimension dimension in metadata.PhysicalDimensions) { DimensionVector exponents = DimensionVector.FromFormula(dimension.DimensionalFormula); byDimensionName[dimension.Name] = exponents; + formsOf[dimension.Name] = dimension.Quantities; - // Only the magnitude form is projected so far. The vector forms are distinct classes - // too, and they are the next thing this generator grows; they need componentwise - // operations, which have their own rules, so they are deliberately not half-done here. MetadataForm? magnitude = dimension.Quantities.Vector0; if (magnitude is null || string.IsNullOrEmpty(magnitude.Base)) { + // Every other form reports its length through this one, so a dimension without it + // has nothing for the vector forms to answer with either. refused.Add(new VocabularyIssue(dimension.Name, "has no vector0 form, so it has no magnitude type to generate.")); continue; } - baseTypeOf[dimension.Name] = magnitude.Base; - types.Add(new QuantityType( - magnitude.Base, - $"The magnitude of {Article(dimension.Name)} {Spaced(dimension.Name)}.", - exponents, - Refines: null, - Magnitude.NonNegative)); - - foreach (MetadataOverload overload in magnitude.Overloads) + for (int form = 0; form < MetadataForms.Count; form++) { - types.Add(new QuantityType( - overload.Name, - overload.Description, - exponents, - Refines: magnitude.Base, - overload.PhysicalConstraints is null ? Magnitude.NonNegative : Magnitude.Positive)); + types.AddRange(Declared(dimension, form, exponents, magnitude.Base)); } } List relationships = - [.. ResolveRelationships(metadata, byDimensionName, baseTypeOf, refused)]; + [.. ResolveRelationships(metadata, byDimensionName, formsOf, refused)]; return new QuantityVocabulary( new ReadOnlyCollection(types), @@ -165,39 +198,102 @@ internal static QuantityVocabulary FromMetadata(QuantityMetadata metadata) new ReadOnlyCollection(refused)); } + /// + /// One dimension's classes at one form: the base, and each name that refines it. + /// + /// + /// Only the magnitude form is bounded below, and that is the whole reason the forms are + /// separate types rather than one type used several ways: a Speed cannot be negative and + /// a component of a Velocity3D obviously can. A stricter floor declared on an overload + /// belongs to the magnitude for the same reason -- a wavelength is never zero, but a component + /// of a displacement along one axis routinely is. + /// + private static IEnumerable Declared( + MetadataDimension dimension, + int form, + DimensionVector exponents, + string magnitudeType) + { + MetadataForm? declared = dimension.Quantities[form]; + if (declared is null || string.IsNullOrEmpty(declared.Base)) + { + yield break; + } + + yield return new QuantityType( + declared.Base, + Describe(dimension.Name, form), + exponents, + Refines: null, + form == 0 ? Magnitude.NonNegative : Magnitude.Signed, + form, + magnitudeType); + + foreach (MetadataOverload overload in declared.Overloads) + { + yield return new QuantityType( + overload.Name, + overload.Description, + exponents, + Refines: declared.Base, + form != 0 ? Magnitude.Signed + : overload.PhysicalConstraints is null ? Magnitude.NonNegative : Magnitude.Positive, + form, + magnitudeType); + } + } + + private static string Describe(string dimension, int form) => form switch + { + 0 => $"The magnitude of {Article(dimension)} {Spaced(dimension)}.", + 1 => $"A signed {Spaced(dimension)} along one axis.", + _ => $"{Capitalised(Article(dimension))} {Spaced(dimension)} in {form.ToString(CultureInfo.InvariantCulture)} dimensions.", + }; + private static IEnumerable ResolveRelationships( QuantityMetadata metadata, IReadOnlyDictionary byDimensionName, - IReadOnlyDictionary baseTypeOf, + IReadOnlyDictionary formsOf, List refused) { foreach (MetadataDimension dimension in metadata.PhysicalDimensions) { - // A dot or a cross product is a statement about vector forms rather than magnitudes, - // so neither belongs here: `dot` on two magnitudes is just their product, and a cross - // product of two magnitudes is not defined at all. - // A relationship Resolve refused is null, and has already said why in `refused`; OfType - // drops those and hands the loop a relationship that is there. - foreach (QuantityRelationship resolved in dimension.Integrals - .Select(relationship => Resolve(dimension, relationship, RelationshipKind.Product, byDimensionName, baseTypeOf, refused)) - .Concat(dimension.Derivatives - .Select(relationship => Resolve(dimension, relationship, RelationshipKind.Quotient, byDimensionName, baseTypeOf, refused))) - .OfType()) + IEnumerable<(MetadataRelationship Declared, RelationshipKind Kind)> declared = + [ + .. dimension.Integrals.Select(relationship => (relationship, RelationshipKind.Product)), + .. dimension.Derivatives.Select(relationship => (relationship, RelationshipKind.Quotient)), + .. dimension.DotProducts.Select(relationship => (relationship, RelationshipKind.Dot)), + .. dimension.CrossProducts.Select(relationship => (relationship, RelationshipKind.Cross)), + ]; + + foreach ((MetadataRelationship relationship, RelationshipKind kind) in declared) { - yield return resolved; + foreach (QuantityRelationship resolved in + Resolve(dimension, relationship, kind, byDimensionName, formsOf, refused)) + { + yield return resolved; + } } } } - private static QuantityRelationship? Resolve( + /// + /// One declared relationship, at every form it reaches. + /// + /// + /// The dimensional check does not depend on the form -- the exponents of a Velocity3D + /// are the exponents of a Speed -- so it happens once, before the forms are walked. A + /// claim that is not dimensionally true is one refusal rather than five. + /// + private static List Resolve( MetadataDimension dimension, MetadataRelationship relationship, RelationshipKind kind, IReadOnlyDictionary byDimensionName, - IReadOnlyDictionary baseTypeOf, + IReadOnlyDictionary formsOf, List refused) { - string subject = $"{dimension.Name} {(kind == RelationshipKind.Product ? "*" : "/")} {relationship.Other} -> {relationship.Result}"; + string subject = Subject(dimension.Name, relationship, kind); foreach (string named in (string[])[relationship.Other, relationship.Result]) { @@ -205,7 +301,7 @@ private static IEnumerable ResolveRelationships( { // The same gap SEM001 reports on the .NET side, seen from here. refused.Add(new VocabularyIssue(subject, $"names '{named}', which dimensions.json does not declare.")); - return null; + return []; } } @@ -213,22 +309,117 @@ private static IEnumerable ResolveRelationships( DimensionVector right = byDimensionName[relationship.Other]; DimensionVector result = byDimensionName[relationship.Result]; - DimensionVector combined = kind == RelationshipKind.Product ? left + right : left - right; + DimensionVector combined = kind == RelationshipKind.Quotient ? left - right : left + right; if (!combined.Equals(result)) { refused.Add(new VocabularyIssue( subject, - $"is not dimensionally true: {left} {(kind == RelationshipKind.Product ? "*" : "/")} {right} is {combined}, and {relationship.Result} is {result}.")); - return null; + $"is not dimensionally true: {left} {(kind == RelationshipKind.Quotient ? "/" : "*")} {right} is {combined}, and {relationship.Result} is {result}.")); + return []; } - return new QuantityRelationship( - baseTypeOf[dimension.Name], - baseTypeOf[relationship.Other], - baseTypeOf[relationship.Result], - kind); + // A dot product answers with a signed value -- a force opposing a displacement does + // negative work -- and the metadata names a magnitude for its result, which cannot hold + // one. No spelling fixes that: the result needs a signed form to land in. + if (kind == RelationshipKind.Dot) + { + refused.Add(new VocabularyIssue( + subject, + $"reduces to a signed value -- two vectors that oppose each other give a negative one -- and '{relationship.Result}' declares only a magnitude form, which cannot be negative. A vector1 form on it is what would let this be generated.")); + return []; + } + + return At(subject, dimension.Name, relationship, kind, formsOf, refused); } + /// + /// The forms one relationship is emitted at. + /// + /// + /// A product or a quotient carries its form on the left operand and the result, with the right + /// operand always a magnitude: a Velocity3D times a Duration is a + /// Displacement3D, and there is no reading in which the duration has three components. + /// A cross product is the other shape -- three components on all three sides -- and is defined + /// in three dimensions and nowhere else, which is why it defaults to that one form rather than + /// to all of them. + /// + /// When the metadata lists forms explicitly, a form a participant does not declare is refused + /// by name; when it lists none, the relationship is emitted at whatever forms the participants + /// share and saying nothing about the rest is the intended answer. That is the same split + /// SEM003 makes on the .NET side. + /// + /// + private static List At( + string subject, + string self, + MetadataRelationship relationship, + RelationshipKind kind, + IReadOnlyDictionary formsOf, + List refused) + { + bool crossed = kind == RelationshipKind.Cross; + IReadOnlyList wanted = relationship.Forms.Count > 0 + ? [.. relationship.Forms] + : crossed ? [3] : [.. Enumerable.Range(0, MetadataForms.Count)]; + + List emitted = []; + + foreach (int form in wanted) + { + // The right operand of a cross product has the same shape as the left; of a product or + // a quotient it is the magnitude the vector is scaled by. + string? leftType = Base(formsOf, self, form); + string? rightType = Base(formsOf, relationship.Other, crossed ? form : 0); + string? resultType = Base(formsOf, relationship.Result, form); + + if (leftType is null || rightType is null || resultType is null) + { + if (relationship.Forms.Count > 0) + { + // The same gap SEM003 reports on the .NET side: a form asked for by name that + // one of the participants does not have. + refused.Add(new VocabularyIssue( + subject, + $"is declared at vector{form.ToString(CultureInfo.InvariantCulture)}, which {Missing(formsOf, form, self, relationship, crossed)} does not declare.")); + } + + continue; + } + + emitted.Add(new QuantityRelationship(leftType, rightType, resultType, kind, form)); + } + + return emitted; + } + + private static string Missing( + IReadOnlyDictionary formsOf, + int form, + string self, + MetadataRelationship relationship, + bool crossed) + { + IEnumerable participants = crossed + ? [self, relationship.Other, relationship.Result] + : [self, relationship.Result]; + + return string.Join(" and ", participants.Where(named => Base(formsOf, named, form) is null)); + } + + private static string? Base(IReadOnlyDictionary formsOf, string dimension, int form) + { + string? name = formsOf.TryGetValue(dimension, out MetadataForms? forms) ? forms[form]?.Base : null; + return string.IsNullOrEmpty(name) ? null : name; + } + + private static string Subject(string self, MetadataRelationship relationship, RelationshipKind kind) => kind switch + { + RelationshipKind.Product => $"{self} * {relationship.Other} -> {relationship.Result}", + RelationshipKind.Quotient => $"{self} / {relationship.Other} -> {relationship.Result}", + RelationshipKind.Cross => $"cross({self}, {relationship.Other}) -> {relationship.Result}", + _ => $"dot({self}, {relationship.Other}) -> {relationship.Result}", + }; + /// /// Splits a PascalCase dimension name for prose, so a comment reads "an angular velocity" /// rather than "an AngularVelocity". @@ -239,5 +430,7 @@ private static string Spaced(string name) => ? $" {char.ToLowerInvariant(character)}" : $"{(index == 0 ? char.ToLowerInvariant(character) : character)}")); + private static string Capitalised(string word) => $"{char.ToUpperInvariant(word[0])}{word[1..]}"; + private static string Article(string name) => "AEIOU".Contains(name[0]) ? "an" : "a"; }