From 0d35b7c877cda1decf6bc91b86ac68aa276cfcbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 12:25:17 +0000 Subject: [PATCH 1/2] [major] Make the shared vocabulary drive C# emission, not just check it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `QuantityVocabulary` has resolved the metadata into the quantities and operators it describes since #219, and refused the relationships whose exponents contradict their declared result. The C++ projection emitted from that resolution. The C# generator did not: it built its own type and operator lists by walking `dimensions.json` a second time, and called the vocabulary only to report SEM008 against operators it then emitted anyway. So `QuantitiesGenerator` now emits from `vocabulary.Types` and `vocabulary.Relationships`. One physics model, literally: which classes exist, what each one is and how it is bounded, and which operators relate them, all decided once and read by both targets. Two things stay on this side, and both are the language rather than the physics. A relationship becomes four C# operators — as declared, commuted, and the two divisions that undo it — because a caller who writes `duration * velocity` is not making a different claim; that expansion is `CollectOperators`. And an overload's `relationships` are C# expressions written in the metadata and pasted through, so there is nothing in them for a shared model to hold. **Breaking.** Sixteen operators and three methods are gone, being the five refused relationships in each direction C# spelled them: Sensitivity * Pressure -> VoltageMagnitude TorqueMagnitude * Angle -> Energy MomentOfInertia * AngularSpeed -> AngularMomentumMagnitude MomentOfInertia * AngularAccelerationMagnitude -> TorqueMagnitude Force{2,3,4}D.Dot(Displacement{2,3,4}D) -> Energy That is the whole of the generated diff: 105 deletions across 13 files and not one line added. `docs/migration-guide-5.0.md` says what to write instead of each, and why the fix is a physics call rather than a spelling one in all five cases. SEM008 still reports them and now says no operator is generated; `UnkeepableRelationshipTests` asserts their absence from the compiled surface by reflection, because a call that does not compile cannot be written down. Along the way: - `VocabularyIssue` carries an `IssueSite` — the names, the kind and the form — so SEM001, SEM002, SEM003 and SEM008 are all reported from the vocabulary's refusals, each with the location it had before, rather than from a second walk that reported what it dropped. - `Missing` checks each participant at the form it actually has to supply. The right operand of a product is the magnitude the vector is scaled by, so what it needs is a `vector0` however many components the relationship asks for; checking it at the declared form could name no participant at all. - The SEM003 fixtures declare real physics (`Length x Force -> Torque`). They multiplied two lengths into a length, which the vocabulary refuses for its exponents before it ever reaches the question of forms — so they would have reported SEM008 and never SEM003. They now assert which participant the message names, not merely that the name appears in it. - Both readers derive the strict-positive floor from the constraint's value rather than from the presence of the object holding it, so a constraint of another kind will not silently turn it on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 20 +- Semantics.Cpp/MetadataProjection.cs | 4 +- .../Angle.g.cs | 6 - .../AngularAccelerationMagnitude.g.cs | 6 - .../AngularMomentumMagnitude.g.cs | 12 - .../AngularSpeed.g.cs | 6 - .../Energy.g.cs | 12 - .../Force2D.g.cs | 3 - .../Force3D.g.cs | 3 - .../Force4D.g.cs | 3 - .../MomentOfInertia.g.cs | 12 - .../Pressure.g.cs | 6 - .../Sensitivity.g.cs | 6 - .../TorqueMagnitude.g.cs | 18 - .../VoltageMagnitude.g.cs | 12 - .../Semantics.Quantities.csproj | 16 +- .../Generators/QuantitiesGenerator.cs | 1166 +++++++---------- .../Models/VocabularyProjection.cs | 6 +- .../SemanticsDiagnostics.cs | 20 +- .../Quantities/GeneratorDiagnosticTests.cs | 140 +- .../Quantities/QuantityBackfillTests.cs | 24 +- .../Quantities/UnkeepableRelationshipTests.cs | 85 +- .../Quantities/VectorQuantityTests.cs | 25 +- Semantics.Vocabulary/QuantityVocabulary.cs | 143 +- docs/migration-guide-5.0.md | 160 +++ docs/physics-generator.md | 2 +- 26 files changed, 1040 insertions(+), 876 deletions(-) create mode 100644 docs/migration-guide-5.0.md diff --git a/CLAUDE.md b/CLAUDE.md index 6d9d42bf..49ec74ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,10 +105,18 @@ does not compile — which makes every claim in `integrals` and `derivatives` ch contradict is refused by name, with both dimensions written out, rather than emitted as something broken. Five are refused as the metadata stands. -The check itself now lives in `Semantics.Vocabulary` and both generators run it. C++ refuses the -five; C# reports them as SEM008 and emits the operators anyway, because removing an operator from a -shipped package is a breaking change and fixing the metadata is a physics call. So the table below -is what C++ does not write **and** what C# writes with a warning against its name: +The check itself lives in `Semantics.Vocabulary`, and the vocabulary is what both generators emit +*from* rather than merely check against — `QuantityVocabulary.Types` is the list of classes to write +and `QuantityVocabulary.Relationships` the list of operators, on both sides. So a relationship the +exponents refuse is not among the ones there are to write, in either target, and the table below is +simply what neither writes. C# additionally reports each one as SEM008, having somewhere to report +it to; C++ prints them. + +The two sides still differ in how many operators one relationship becomes, and that is the language +rather than the physics: the vocabulary states each relationship once, in the direction the metadata +declares it, and the C# generator expands it into the declared direction, its commutation and the +divisions that undo it, because a caller who writes `duration * velocity` is not making a different +claim. That expansion lives in `QuantitiesGenerator.CollectOperators`. | Refused | Why | |---|---| @@ -286,7 +294,7 @@ var converted = sourceString.As(); - **SEM005** — schema-level validation issue in `logarithmic.json` (missing or duplicate scale names, a conversion with no linear type). - **SEM006** — a metadata file a generator declared in `MetadataFileNames` was not supplied as an `AdditionalFile`. Previously this produced no output and no explanation, which is indistinguishable from a generator that simply had nothing to emit. - **SEM007** — a metadata file could not be parsed. Replaces the base generator's `CONV001` in category `SourceGenerator`, and covers the path that used to swallow the exception, where a malformed `units.json` silently produced factories with no scale factor. - - **SEM008** — a relationship's declared result does not follow from the dimensions of its operands, or its value is signed and the declared result is a magnitude. The check comes from `Semantics.Vocabulary`, shared with the C++ projection; before that this side checked the names (SEM001) and the forms (SEM003) and then emitted the operator, so `Sensitivity * Pressure -> ElectricPotential` shipped as a working C# operator computing the wrong physics. The operator is **still emitted** — dropping it would be a breaking change to a shipped package, and the metadata's own bug is a physics call rather than a spelling one. Suppressed in `Semantics.Quantities.csproj` because ktsu.Sdk builds warnings as errors and the five below are outstanding; `UnkeepableRelationshipTests` pins the set so a sixth fails there rather than disappearing into the suppression. + - **SEM008** — a relationship's declared result does not follow from the dimensions of its operands, or its value is signed and the declared result is a magnitude. The check comes from `Semantics.Vocabulary`, shared with the C++ projection; before that this side checked the names (SEM001) and the forms (SEM003) and then emitted the operator, so `Sensitivity * Pressure -> ElectricPotential` shipped as a working C# operator computing the wrong physics. **No operator is generated** for a refused relationship, in any of the directions C# spells a product in — that followed from making the vocabulary drive emission rather than only check it, and the removal is documented in `docs/migration-guide-5.0.md`. Suppressed in `Semantics.Quantities.csproj` because ktsu.Sdk builds warnings as errors and the five below are outstanding; `UnkeepableRelationshipTests` pins the set, and asserts that none of them is in the compiled surface, so a sixth fails there rather than disappearing into the suppression. - Descriptors are allocated from `SemanticsDiagnostics`, which is the one place to add a new one. `AnalyzerReleaseTrackingTests` fails if the identifier is missing from `AnalyzerReleases.Unshipped.md`, so RS2008 no longer surfaces only after a push. - See `docs/physics-generator.md` for the full schema and an end-to-end "add a dimension" walk-through. @@ -301,3 +309,5 @@ This file is the entry point. For deeper material: - `docs/migration-guide-2.0.md` — 1.x → 2.0 upgrade guide (renames, namespace moves, behavioral changes). - `docs/migration-guide-3.0.md` — 2.x → 3.0 upgrade guide (removed first-class .NET type attributes, chord flag enum renames). - `docs/migration-guide-3.1.md` — 3.0 → 3.1 upgrade guide (JSON converter is now opt-in, `PhysicalConstants` domain fields became generic accessors). +- `docs/migration-guide-4.0.md` — 3.x → 4.0 upgrade guide (every quantity became a `readonly record struct`). +- `docs/migration-guide-5.0.md` — 4.x → 5.0 upgrade guide (the five dimensionally unkeepable relationships no longer generate operators). diff --git a/Semantics.Cpp/MetadataProjection.cs b/Semantics.Cpp/MetadataProjection.cs index 1944e9d4..97b1dc14 100644 --- a/Semantics.Cpp/MetadataProjection.cs +++ b/Semantics.Cpp/MetadataProjection.cs @@ -41,8 +41,10 @@ [.. dimension.DotProducts.Select(Relationship)], // A constraint is carried as the flag the vocabulary reads rather than as its value: only a // strict-positive floor is declared anywhere, and what the vocabulary needs is whether it is // there. The value itself is the C# generator's business, which is where the guard is emitted. + // Read off the constraint's value rather than off the presence of the object holding it, so a + // constraint of some other kind, when one is added, does not silently turn the strict floor on. private static OverloadDeclaration Overload(MetadataOverload overload) => - new(overload.Name, overload.Description, overload.PhysicalConstraints is not null); + new(overload.Name, overload.Description, overload.PhysicalConstraints?.MinExclusive == "0"); private static RelationshipDeclaration Relationship(MetadataRelationship relationship) => new(relationship.Other, relationship.Result, [.. relationship.Forms]); diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Angle.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Angle.g.cs index c241248e..b7485caa 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Angle.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Angle.g.cs @@ -159,11 +159,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Duration operator /(Angle left, AngularSpeed right) => Duration.Create(left.Quantity / right.Quantity); - - /// - /// Multiplies Angle by TorqueMagnitude to produce Energy. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static Energy operator *(Angle left, TorqueMagnitude right) => Energy.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularAccelerationMagnitude.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularAccelerationMagnitude.g.cs index 4083a9cf..5528dd55 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularAccelerationMagnitude.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularAccelerationMagnitude.g.cs @@ -133,11 +133,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Duration operator /(AngularAccelerationMagnitude left, AngularJerkMagnitude right) => Duration.Create(left.Quantity / right.Quantity); - - /// - /// Multiplies AngularAccelerationMagnitude by MomentOfInertia to produce TorqueMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static TorqueMagnitude operator *(AngularAccelerationMagnitude left, MomentOfInertia right) => TorqueMagnitude.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularMomentumMagnitude.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularMomentumMagnitude.g.cs index c34a652a..7443c16d 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularMomentumMagnitude.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularMomentumMagnitude.g.cs @@ -121,17 +121,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static TorqueMagnitude operator /(AngularMomentumMagnitude left, Duration right) => TorqueMagnitude.Create(left.Quantity / right.Quantity); - - /// - /// Divides AngularMomentumMagnitude by AngularSpeed to produce MomentOfInertia. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static MomentOfInertia operator /(AngularMomentumMagnitude left, AngularSpeed right) => MomentOfInertia.Create(left.Quantity / right.Quantity); - - /// - /// Divides AngularMomentumMagnitude by MomentOfInertia to produce AngularSpeed. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static AngularSpeed operator /(AngularMomentumMagnitude left, MomentOfInertia right) => AngularSpeed.Create(left.Quantity / right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularSpeed.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularSpeed.g.cs index e6e8cd13..e4e3aba3 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularSpeed.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/AngularSpeed.g.cs @@ -141,11 +141,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Duration operator /(AngularSpeed left, AngularAccelerationMagnitude right) => Duration.Create(left.Quantity / right.Quantity); - - /// - /// Multiplies AngularSpeed by MomentOfInertia to produce AngularMomentumMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static AngularMomentumMagnitude operator *(AngularSpeed left, MomentOfInertia right) => AngularMomentumMagnitude.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Energy.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Energy.g.cs index b27a3229..27659233 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Energy.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Energy.g.cs @@ -180,18 +180,6 @@ namespace ktsu.Semantics.Quantities; [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Energy operator -(Energy left, Energy right) => Create(T.Abs(left.Quantity - right.Quantity)); - /// - /// Divides Energy by Angle to produce TorqueMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static TorqueMagnitude operator /(Energy left, Angle right) => TorqueMagnitude.Create(left.Quantity / right.Quantity); - - /// - /// Divides Energy by TorqueMagnitude to produce Angle. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static Angle operator /(Energy left, TorqueMagnitude right) => Angle.Create(left.Quantity / right.Quantity); - /// /// Divides Energy by Length to produce ForceMagnitude. /// diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force2D.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force2D.g.cs index fec75824..f1d9d845 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force2D.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force2D.g.cs @@ -96,7 +96,4 @@ public Force2D Normalize() /// Force2D * Duration = Momentum2D. public static Momentum2D operator *(Force2D left, Duration right) => new() { X = left.X * right.Value, Y = left.Y * right.Value }; - /// Typed dot product: Force2D . Displacement2D = Energy. - public Energy Dot(Displacement2D other) => Energy.Create((X * other.X) + (Y * other.Y)); - }; diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force3D.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force3D.g.cs index 6674e3a4..6932615a 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force3D.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force3D.g.cs @@ -110,9 +110,6 @@ public Force3D Normalize() /// Force3D * Duration = Momentum3D. public static Momentum3D operator *(Force3D left, Duration right) => new() { X = left.X * right.Value, Y = left.Y * right.Value, Z = left.Z * right.Value }; - /// Typed dot product: Force3D . Displacement3D = Energy. - public Energy Dot(Displacement3D other) => Energy.Create((X * other.X) + (Y * other.Y) + (Z * other.Z)); - /// Typed cross product: Force3D x Displacement3D = Torque3D. public Torque3D Cross(Displacement3D other) { diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force4D.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force4D.g.cs index 13f3e391..4d3f5743 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force4D.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Force4D.g.cs @@ -112,7 +112,4 @@ public Force4D Normalize() /// Force4D * Duration = Momentum4D. public static Momentum4D operator *(Force4D left, Duration right) => new() { X = left.X * right.Value, Y = left.Y * right.Value, Z = left.Z * right.Value, W = left.W * right.Value }; - /// Typed dot product: Force4D . Displacement4D = Energy. - public Energy Dot(Displacement4D other) => Energy.Create((X * other.X) + (Y * other.Y) + (Z * other.Z) + (W * other.W)); - }; diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/MomentOfInertia.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/MomentOfInertia.g.cs index 26ccccdd..604c4b39 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/MomentOfInertia.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/MomentOfInertia.g.cs @@ -115,17 +115,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static MomentOfInertia operator -(MomentOfInertia left, MomentOfInertia right) => Create(T.Abs(left.Quantity - right.Quantity)); - - /// - /// Multiplies MomentOfInertia by AngularSpeed to produce AngularMomentumMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static AngularMomentumMagnitude operator *(MomentOfInertia left, AngularSpeed right) => AngularMomentumMagnitude.Create(left.Quantity * right.Quantity); - - /// - /// Multiplies MomentOfInertia by AngularAccelerationMagnitude to produce TorqueMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static TorqueMagnitude operator *(MomentOfInertia left, AngularAccelerationMagnitude right) => TorqueMagnitude.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Pressure.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Pressure.g.cs index ab48b62e..9813817f 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Pressure.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Pressure.g.cs @@ -167,11 +167,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Energy operator *(Pressure left, Volume right) => Energy.Create(left.Quantity * right.Quantity); - - /// - /// Multiplies Pressure by Sensitivity to produce VoltageMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static VoltageMagnitude operator *(Pressure left, Sensitivity right) => VoltageMagnitude.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Sensitivity.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Sensitivity.g.cs index 2039d2a7..a99c8780 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Sensitivity.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Sensitivity.g.cs @@ -115,11 +115,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static Sensitivity operator -(Sensitivity left, Sensitivity right) => Create(T.Abs(left.Quantity - right.Quantity)); - - /// - /// Multiplies Sensitivity by Pressure to produce VoltageMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static VoltageMagnitude operator *(Sensitivity left, Pressure right) => VoltageMagnitude.Create(left.Quantity * right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/TorqueMagnitude.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/TorqueMagnitude.g.cs index 4c55247b..545ec028 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/TorqueMagnitude.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/TorqueMagnitude.g.cs @@ -124,28 +124,10 @@ namespace ktsu.Semantics.Quantities; [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static TorqueMagnitude operator -(TorqueMagnitude left, TorqueMagnitude right) => Create(T.Abs(left.Quantity - right.Quantity)); - /// - /// Multiplies TorqueMagnitude by Angle to produce Energy. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static Energy operator *(TorqueMagnitude left, Angle right) => Energy.Create(left.Quantity * right.Quantity); - /// /// Multiplies TorqueMagnitude by Duration to produce AngularMomentumMagnitude. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static AngularMomentumMagnitude operator *(TorqueMagnitude left, Duration right) => AngularMomentumMagnitude.Create(left.Quantity * right.Quantity); - - /// - /// Divides TorqueMagnitude by AngularAccelerationMagnitude to produce MomentOfInertia. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static MomentOfInertia operator /(TorqueMagnitude left, AngularAccelerationMagnitude right) => MomentOfInertia.Create(left.Quantity / right.Quantity); - - /// - /// Divides TorqueMagnitude by MomentOfInertia to produce AngularAccelerationMagnitude. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static AngularAccelerationMagnitude operator /(TorqueMagnitude left, MomentOfInertia right) => AngularAccelerationMagnitude.Create(left.Quantity / right.Quantity); } diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/VoltageMagnitude.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/VoltageMagnitude.g.cs index c3529c09..335599ff 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/VoltageMagnitude.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/VoltageMagnitude.g.cs @@ -171,17 +171,5 @@ namespace ktsu.Semantics.Quantities; /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] public static MagneticFlux operator *(VoltageMagnitude left, Duration right) => MagneticFlux.Create(left.Quantity * right.Quantity); - - /// - /// Divides VoltageMagnitude by Pressure to produce Sensitivity. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static Sensitivity operator /(VoltageMagnitude left, Pressure right) => Sensitivity.Create(left.Quantity / right.Quantity); - - /// - /// Divides VoltageMagnitude by Sensitivity to produce Pressure. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Physics quantity operator")] - public static Pressure operator /(VoltageMagnitude left, Sensitivity right) => Pressure.Create(left.Quantity / right.Quantity); } diff --git a/Semantics.Quantities/Semantics.Quantities.csproj b/Semantics.Quantities/Semantics.Quantities.csproj index f6f16fe1..46529b13 100644 --- a/Semantics.Quantities/Semantics.Quantities.csproj +++ b/Semantics.Quantities/Semantics.Quantities.csproj @@ -4,14 +4,16 @@ net10.0;net9.0;net8.0 + UnkeepableRelationshipTests pins the set to exactly those five — and asserts that none of + them is in the compiled surface — so a sixth fails there rather than disappearing into this + line. Delete this when dimensions.json is fixed. --> $(NoWarn);CA1716;CA2225;KTSU0003;IDE0032;SEM008 true Generated diff --git a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index ec3ea3bd..bf462918 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -53,34 +53,6 @@ protected override void Generate(SourceProductionContext context, MetadataSet me GenerateInner(context, dimensions, units, dimensionsFile); } - /// - /// Reports the relationships the metadata declares that cannot be true, as SEM008. - /// - /// Where the diagnostics go. - /// The deserialised dimensions.json. - /// - /// The check comes from , shared with the C++ projection, which - /// has had it since it needed the exponents to write Quantity<D> and could not - /// avoid multiplying them out. This side never had it: it checked that a relationship's names - /// resolved and that its forms existed, and then emitted the operator. - /// - /// Only the two kinds this project has no diagnostic of its own for are reported. An unknown - /// dimension is already SEM001 and a missing vector form is already SEM003, both of them from - /// the code that actually drops the operator, and reporting them twice from two places would - /// say the same thing in two voices. - /// - /// - private static void ReportUnkeepableRelationships(SourceProductionContext context, DimensionsMetadata metadata) - { - QuantityVocabulary vocabulary = QuantityVocabulary.FromDimensions(metadata.ToDeclarations()); - - foreach (VocabularyIssue issue in vocabulary.Refused.Where(issue => - issue.Kind is VocabularyIssueKind.NotDimensionallyTrue or VocabularyIssueKind.SignedResultInMagnitudeForm)) - { - context.Report(SemanticsDiagnostics.RelationshipNotDimensionallyTrue, issue.Subject, issue.Reason); - } - } - private void GenerateInner(SourceProductionContext context, DimensionsMetadata metadata, UnitsMetadata units, MetadataFile? dimensionsFile) { if (metadata.PhysicalDimensions == null || metadata.PhysicalDimensions.Count == 0) @@ -96,7 +68,13 @@ private void GenerateInner(SourceProductionContext context, DimensionsMetadata m context.Report(SemanticsDiagnostics.MetadataValidationFailed, issue); } - ReportUnkeepableRelationships(context, metadata); + // One physics model, resolved once. Everything below is a spelling of what it says: which + // types exist, what each one is, and which operators relate them. The C++ projection reads + // the same resolution from the same code, which is what keeps the two from drifting -- and + // what stops a relationship the exponents refuse from being written by one side and not + // the other. + QuantityVocabulary vocabulary = QuantityVocabulary.FromDimensions(metadata.ToDeclarations()); + ReportRefusals(context, vocabulary, dimensionsFile); Dictionary unitMap = BuildUnitMap(units); @@ -107,82 +85,101 @@ private void GenerateInner(SourceProductionContext context, DimensionsMetadata m // with no scale factor. SEM004 catches that at build time. ReportUnknownUnitReferences(context, metadata, unitMap, dimensionsFile); - // Phase A: Build maps and collect operators - Dictionary dimensionMap = BuildDimensionMap(metadata); - Dictionary typeFormMap = BuildTypeFormMap(metadata); - List allOperators = CollectAllOperators(context, metadata, dimensionMap, dimensionsFile); - List allProducts = CollectAllProducts(context, metadata, dimensionMap, dimensionsFile); - Dictionary> operatorsByOwner = GroupBy(allOperators, o => o.OwnerTypeName); - Dictionary> productsByOwner = GroupBy(allProducts, p => p.SelfTypeName); + Emission emission = new( + BuildDimensionMap(metadata), + BuildOverloadMap(metadata), + GroupBy(CollectOperators(vocabulary), op => op.OwnerTypeName), + GroupBy(CollectProducts(vocabulary), product => product.SelfTypeName), + BuildTypeFormMap(vocabulary), + unitMap); - // Phase B: Generate types - foreach (PhysicalDimension dim in metadata.PhysicalDimensions) + foreach (QuantityType type in vocabulary.Types) { - EmitDimensionTypes(context, dim, operatorsByOwner, productsByOwner, typeFormMap, unitMap); + EmitType(context, type, emission); } } /// - /// Emits every type declared by a single dimension: its V0/V1 base types, its V2+ vector - /// types, and the semantic overloads of each declared form. + /// Everything an emitter needs beyond the quantity it is writing. /// - private void EmitDimensionTypes( - SourceProductionContext context, - PhysicalDimension dim, - Dictionary> operatorsByOwner, - Dictionary> productsByOwner, - Dictionary typeFormMap, - Dictionary unitMap) - { - if (dim.Quantities.Vector0 != null) - { - EmitV0BaseType(context, dim, operatorsByOwner, typeFormMap, unitMap); - } - - if (dim.Quantities.Vector1 != null) - { - EmitV1BaseType(context, dim, operatorsByOwner, typeFormMap, unitMap); - } - - int[] vectorDims = [2, 3, 4]; - foreach (int d in vectorDims) - { - VectorFormDefinition? form = GetFormDef(dim, d); - if (form != null) - { - EmitVectorType(context, dim, d, form, operatorsByOwner, productsByOwner, typeFormMap); - } - } - - EmitOverloadTypes(context, dim, unitMap); - } + /// Every dimension, keyed by name. + /// Every semantic overload, keyed by the type name it produces. + /// The cross-dimensional operators each type declares. + /// The dot and cross products each vector type declares. + /// How many components each generated type has. + /// Every unit, keyed by name. + /// + /// Six things that travel together to every emitter and are built once. Passed individually + /// they put every one of these methods over the analyzer's parameter limit, and made the call + /// sites hard to read for no gain: not one of them varies per type. + /// + private sealed record Emission( + IReadOnlyDictionary Dimensions, + IReadOnlyDictionary Overloads, + IReadOnlyDictionary> OperatorsByOwner, + IReadOnlyDictionary> ProductsByOwner, + IReadOnlyDictionary TypeFormMap, + IReadOnlyDictionary Units); /// - /// Emits the semantic overloads declared on every vector form of . + /// Writes one quantity, as whichever kind of type the vocabulary says it is. /// - private void EmitOverloadTypes( - SourceProductionContext context, - PhysicalDimension dim, - Dictionary unitMap) + /// Where the source goes. + /// The quantity to write. + /// Everything shared between the types being written. + /// + /// Two questions decide it, and the vocabulary answers both: whether the quantity refines + /// another one, and how many components it stores. A base and an overload differ in what they + /// declare — an overload has the conversions to and from its base and nothing else does — and a + /// scalar and a vector differ in how they store a value at all. + /// + private void EmitType(SourceProductionContext context, QuantityType type, Emission emission) { - int[] allForms = [0, 1, 2, 3, 4]; - foreach (int f in allForms) + PhysicalDimension dim = emission.Dimensions[type.Owner]; + + if (type.Refines is null) { - VectorFormDefinition? form = GetFormDef(dim, f); - if (form == null) + switch (type.Form) { - continue; - } + case 0: + EmitV0BaseType(context, type, dim, emission); + break; - foreach (OverloadDefinition overload in form.Overloads) - { - EmitOverloadType(context, dim, f, form.Base, overload, unitMap); + case 1: + EmitV1BaseType(context, type, dim, emission); + break; + + default: + EmitVectorType(context, type, dim, emission); + break; } + + return; + } + + if (type.Form <= 1) + { + EmitOverloadType(context, type, dim, emission); + } + else + { + // V2/3/4 overloads are rare — the strategy document shows them mainly for V3 + // (Position3D, Translation3D) — and carry no units or relationships of their own. + EmitVectorOverloadType(context, type); } } - #region Phase A: Map Building and Operator Collection + #region Phase A: Resolving the vocabulary + /// + /// Every dimension, keyed by name, so an emitter holding a can get + /// back to the entry it was read from. + /// + /// + /// What the emitters need from that entry is the part the vocabulary has no opinion on: which + /// units a quantity can be built from, and what its dimension is called in the generated + /// PhysicalDimensions table. The physics itself comes from the vocabulary. + /// private static Dictionary BuildDimensionMap(DimensionsMetadata metadata) { Dictionary map = []; @@ -194,22 +191,32 @@ private static Dictionary BuildDimensionMap(Dimension return map; } - private static Dictionary BuildTypeFormMap(DimensionsMetadata metadata) + /// + /// Every semantic overload, keyed by the type name it produces. + /// + /// + /// The one thing an overload declares that is not physics: relationships, the + /// Diameter.ToRadius() pairs. They are C# expressions written in the metadata and + /// pasted into the generated source, so there is nothing in them for a shared model to hold — + /// the same reason SourceFile.Imports is per-language in ktsu.Coder. + /// + private static Dictionary BuildOverloadMap(DimensionsMetadata metadata) { - Dictionary map = []; + Dictionary map = []; foreach (PhysicalDimension dim in metadata.PhysicalDimensions) { int[] forms = [0, 1, 2, 3, 4]; foreach (int f in forms) { VectorFormDefinition? form = GetFormDef(dim, f); - if (form != null) + if (form == null) { - map[form.Base] = f; - foreach (OverloadDefinition overload in form.Overloads) - { - map[overload.Name] = f; - } + continue; + } + + foreach (OverloadDefinition overload in form.Overloads) + { + map[overload.Name] = overload; } } } @@ -217,324 +224,199 @@ private static Dictionary BuildTypeFormMap(DimensionsMetadata metad return map; } - private static List CollectAllOperators( - SourceProductionContext context, - DimensionsMetadata metadata, - Dictionary dimMap, - MetadataFile? dimensionsFile) + /// + /// How many components each generated type has, keyed by its name. + /// + /// + /// Read off the vocabulary rather than recomputed from the metadata: an operator is emitted + /// differently depending on whether its operands store one value or several, and the + /// vocabulary is what decided which types exist at which form in the first place. + /// + private static Dictionary BuildTypeFormMap(QuantityVocabulary vocabulary) { - HashSet seen = []; - List result = []; - - foreach (PhysicalDimension dim in metadata.PhysicalDimensions) + Dictionary map = []; + foreach (QuantityType type in vocabulary.Types) { - CollectIntegralOperators(context, dim, dimMap, result, seen, dimensionsFile); - CollectDerivativeOperators(context, dim, dimMap, result, seen, dimensionsFile); + map[type.Name] = type.Form; } - return result; + return map; } /// - /// Collects the operators implied by one dimension's integrals (Self * Other = Result). + /// Every operator to emit, expanded from the relationships the vocabulary resolved. /// - private static void CollectIntegralOperators( - SourceProductionContext context, - PhysicalDimension dim, - Dictionary dimMap, - List result, - HashSet seen, - MetadataFile? dimensionsFile) + /// + /// The vocabulary states each relationship once, in the direction the metadata declares it: + /// Velocity3D * Duration -> Displacement3D. C# emits four operators from that — the + /// one declared, its commutation, and the two divisions that undo it — because a caller who + /// writes duration * velocity is not making a different claim about physics, and an + /// overload that is missing is a compile error rather than a wrong answer. + /// + /// That expansion is the language's business rather than the model's, which is why it lives + /// here and the C++ projection, which emits only the declared direction, does not have it. + /// What both take from the vocabulary is which relationships there are at all — so a + /// relationship the exponents refuse produces no operator here either, in any of its four + /// directions. + /// + /// + private static List CollectOperators(QuantityVocabulary vocabulary) { - foreach (RelationshipDefinition integral in dim.Integrals) - { - if (!dimMap.TryGetValue(integral.Other, out PhysicalDimension? otherDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, integral.Other, $"integrals[{integral.Other} -> {integral.Result}].other"); - continue; - } - - if (!dimMap.TryGetValue(integral.Result, out PhysicalDimension? resultDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, integral.Result, $"integrals[{integral.Other} -> {integral.Result}].result"); - continue; - } - - // V0(Other) is the scalar multiplier - string? v0Other = otherDim.Quantities.Vector0?.Base; - if (v0Other == null) - { - continue; - } + HashSet seen = []; + List result = []; - // For integrals the "Other" multiplier is V0 only; the form propagates - // between Self and Result, so SEM003 should fire if either Self or - // Result is missing a declared form. (V0-only Other was already - // rejected above via the v0Other null check.) - int[] forms = ResolveForms( - new RelationshipSite(context, dimensionsFile, dim, integral, $"integrals[{integral.Other} -> {integral.Result}]"), - [0, 1, 2, 3, 4], - resultDim); - foreach (int vn in forms) + foreach (QuantityRelationship relationship in vocabulary.Relationships) + { + switch (relationship.Kind) { - AddIntegralOpsForForm(result, seen, dim, resultDim, vn, v0Other); + case RelationshipKind.Product: + AddProductOperators(result, seen, relationship); + break; + + case RelationshipKind.Quotient: + AddQuotientOperators(result, seen, relationship); + break; + + default: + // A dot or a cross product is a method rather than an operator, because + // neither has a symbol in C#. They are collected as products below. + break; } } - } - private static void AddIntegralOpsForForm( - List result, - HashSet seen, - PhysicalDimension dim, - PhysicalDimension resultDim, - int vn, - string v0Other) - { - string? selfType = GetBaseTypeName(dim, vn); - string? resultType = GetBaseTypeName(resultDim, vn); - if (selfType == null || resultType == null) - { - return; - } - - // Forward: VN(Self) * V0(Other) => VN(Result) - AddOp(result, seen, "*", selfType, v0Other, resultType, selfType); - // Commutative: V0(Other) * VN(Self) => VN(Result) - AddOp(result, seen, "*", v0Other, selfType, resultType, v0Other); - // Inverse: VN(Result) / V0(Other) => VN(Self) - AddOp(result, seen, "/", resultType, v0Other, selfType, resultType); - // Inverse: VN(Result) / VN(Self) => V0(Other) -- only if VN == V0 - if (vn == 0) - { - AddOp(result, seen, "/", resultType, selfType, v0Other, resultType); - } + return result; } /// - /// Collects the operators implied by one dimension's derivatives (Self / Other = Result). + /// The four spellings of Left * Right -> Result. /// - private static void CollectDerivativeOperators( - SourceProductionContext context, - PhysicalDimension dim, - Dictionary dimMap, - List result, - HashSet seen, - MetadataFile? dimensionsFile) + /// + /// The last of them only exists at the magnitude form: Displacement3D / Velocity3D + /// would have to be a componentwise division to produce a Duration, and that is not + /// what dividing one vector by another means. + /// + private static void AddProductOperators(List operators, HashSet seen, QuantityRelationship relationship) { - foreach (RelationshipDefinition derivative in dim.Derivatives) - { - if (!dimMap.TryGetValue(derivative.Other, out PhysicalDimension? otherDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, derivative.Other, $"derivatives[{derivative.Other} -> {derivative.Result}].other"); - continue; - } + // Written against the relationship's own parts rather than through locals, so each line + // says which of the three each operand is. As declared, commuted, and undone: + AddOp(operators, seen, "*", relationship.Left, relationship.Right, relationship.Result, relationship.Left); + AddOp(operators, seen, "*", relationship.Right, relationship.Left, relationship.Result, relationship.Right); + AddOp(operators, seen, "/", relationship.Result, relationship.Right, relationship.Left, relationship.Result); - if (!dimMap.TryGetValue(derivative.Result, out PhysicalDimension? resultDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, derivative.Result, $"derivatives[{derivative.Other} -> {derivative.Result}].result"); - continue; - } - - string? v0Other = otherDim.Quantities.Vector0?.Base; - if (v0Other == null) - { - continue; - } - - int[] forms = ResolveForms( - new RelationshipSite(context, dimensionsFile, dim, derivative, $"derivatives[{derivative.Other} -> {derivative.Result}]"), - [0, 1, 2, 3, 4], - resultDim); - foreach (int vn in forms) - { - AddDerivativeOpsForForm(result, seen, dim, resultDim, vn, v0Other); - } - } - } - - private static void AddDerivativeOpsForForm( - List result, - HashSet seen, - PhysicalDimension dim, - PhysicalDimension resultDim, - int vn, - string v0Other) - { - string? selfType = GetBaseTypeName(dim, vn); - string? resultType = GetBaseTypeName(resultDim, vn); - if (selfType == null || resultType == null) + if (relationship.Form == 0) { - return; + AddOp(operators, seen, "/", relationship.Result, relationship.Left, relationship.Right, relationship.Result); } - - // Forward: VN(Self) / V0(Other) => VN(Result) - AddOp(result, seen, "/", selfType, v0Other, resultType, selfType); - // Inverse integral: VN(Result) * V0(Other) => VN(Self) - AddOp(result, seen, "*", resultType, v0Other, selfType, resultType); - // Commutative inverse: V0(Other) * VN(Result) => VN(Self) - AddOp(result, seen, "*", v0Other, resultType, selfType, v0Other); } - private static List CollectAllProducts( - SourceProductionContext context, - DimensionsMetadata metadata, - Dictionary dimMap, - MetadataFile? dimensionsFile) + /// + /// The three spellings of Left / Right -> Result. + /// + /// + /// Three rather than four, because the fourth — Left / Result -> Right — is the + /// division the metadata already declares in the other direction wherever it holds, and + /// asserting it from here would emit it for the cases where it does not. + /// + private static void AddQuotientOperators(List operators, HashSet seen, QuantityRelationship relationship) { - HashSet seen = []; - List result = []; - - foreach (PhysicalDimension dim in metadata.PhysicalDimensions) - { - CollectDotProducts(context, dim, dimMap, result, seen, dimensionsFile); - CollectCrossProducts(context, dim, dimMap, result, seen, dimensionsFile); - } - - return result; + AddOp(operators, seen, "/", relationship.Left, relationship.Right, relationship.Result, relationship.Left); + AddOp(operators, seen, "*", relationship.Result, relationship.Right, relationship.Left, relationship.Result); + AddOp(operators, seen, "*", relationship.Right, relationship.Result, relationship.Left, relationship.Right); } /// - /// Collects one dimension's dot products: VN(Self) . VN(Other) => V0(Result). + /// Every dot and cross product to emit, which is one method each in the direction declared. /// - private static void CollectDotProducts( - SourceProductionContext context, - PhysicalDimension dim, - Dictionary dimMap, - List result, - HashSet seen, - MetadataFile? dimensionsFile) + /// + /// No commutation and no inverse: cross(a, b) is -cross(b, a) rather than the + /// same thing, and neither product has one. + /// + private static List CollectProducts(QuantityVocabulary vocabulary) { - foreach (RelationshipDefinition dot in dim.DotProducts) - { - if (!dimMap.TryGetValue(dot.Other, out PhysicalDimension? otherDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, dot.Other, $"dotProducts[{dot.Other} -> {dot.Result}].other"); - continue; - } + HashSet seen = []; + List result = []; - if (!dimMap.TryGetValue(dot.Result, out PhysicalDimension? resultDim)) + foreach (QuantityRelationship relationship in vocabulary.Relationships) + { + string method = relationship.Kind switch { - ReportUnknownReference(context, dimensionsFile, dim.Name, dot.Result, $"dotProducts[{dot.Other} -> {dot.Result}].result"); - continue; - } + RelationshipKind.Dot => "Dot", + RelationshipKind.Cross => "Cross", + _ => string.Empty, + }; - string? v0Result = resultDim.Quantities.Vector0?.Base; - if (v0Result == null) + if (method.Length == 0) { continue; } - // Dot product is undefined for V0; default forms are V1+. - int[] forms = ResolveForms( - new RelationshipSite(context, dimensionsFile, dim, dot, $"dotProducts[{dot.Other} -> {dot.Result}]"), - [1, 2, 3, 4], - otherDim); - foreach (int vn in forms) + string key = $"{method}:{relationship.Left}:{relationship.Right}:{relationship.Result}"; + if (seen.Add(key)) { - AddDotProductForForm(result, seen, dim, otherDim, vn, v0Result); + result.Add(new ProductInfo(method, relationship.Left, relationship.Right, relationship.Result, relationship.Form)); } } + + return result; } - private static void AddDotProductForForm( - List result, - HashSet seen, - PhysicalDimension dim, - PhysicalDimension otherDim, - int vn, - string v0Result) + private static void AddOp(List list, HashSet seen, string op, string left, string right, string ret, string owner) { - string? selfType = GetBaseTypeName(dim, vn); - string? otherType = GetBaseTypeName(otherDim, vn); - if (selfType == null || otherType == null) + // Skip self-division (base class already handles TSelf / TSelf => TStorage) + if (op == "/" && left == right) { return; } - string key = $"Dot:{selfType}:{otherType}:{v0Result}"; + string key = $"{op}:{left}:{right}:{ret}"; if (seen.Add(key)) { - result.Add(new ProductInfo("Dot", selfType, otherType, v0Result, vn)); + list.Add(new OperatorInfo(op, left, right, ret, owner)); } } /// - /// Collects one dimension's cross products: V3(Self) x V3(Other) => V3(Result). + /// Reports everything the vocabulary refused, each as the diagnostic this project already has + /// for that kind of problem. /// - private static void CollectCrossProducts( + /// Where the diagnostics go. + /// The resolved vocabulary. + /// The file the metadata was read from, for locations. + /// + /// Every refusal now costs an operator or a type, which is what makes reporting all five kinds + /// worth doing from one place. Before the vocabulary drove emission this reported only the two + /// kinds SEM008 covers, because the other three were dropped by the metadata-walking code that + /// also reported them — and saying the same thing twice in two voices would have been the only + /// effect. + /// + private static void ReportRefusals( SourceProductionContext context, - PhysicalDimension dim, - Dictionary dimMap, - List result, - HashSet seen, + QuantityVocabulary vocabulary, MetadataFile? dimensionsFile) { - foreach (RelationshipDefinition cross in dim.CrossProducts) + foreach (VocabularyIssue issue in vocabulary.Refused) { - if (!dimMap.TryGetValue(cross.Other, out PhysicalDimension? otherDim)) - { - ReportUnknownReference(context, dimensionsFile, dim.Name, cross.Other, $"crossProducts[{cross.Other} -> {cross.Result}].other"); - continue; - } - - if (!dimMap.TryGetValue(cross.Result, out PhysicalDimension? resultDim)) + switch (issue.Kind) { - ReportUnknownReference(context, dimensionsFile, dim.Name, cross.Result, $"crossProducts[{cross.Other} -> {cross.Result}].result"); - continue; - } - - // Cross product is intrinsically 3D. Default to V3 only; explicit Forms - // other than [3] are accepted but the operator emit below only handles V3. - // Pass resultDim so SEM003 surfaces when the declared form is missing on - // the result type too (e.g. Force × Length → Torque at V2: Torque has no V2). - int[] forms = ResolveForms( - new RelationshipSite(context, dimensionsFile, dim, cross, $"crossProducts[{cross.Other} -> {cross.Result}]"), - [3], - otherDim, - resultDim); - if (Array.IndexOf(forms, 3) < 0) - { - continue; + case VocabularyIssueKind.UnknownDimension: + ReportUnknownReference(context, dimensionsFile, issue); + break; + + case VocabularyIssueKind.MissingVectorForm: + ReportFormMissing(context, dimensionsFile, issue); + break; + + case VocabularyIssueKind.NoMagnitudeForm: + // Schema-level rather than physics: a dimension with no vector0 has nothing + // for its other forms to answer Magnitude() with, and now generates no types + // at all rather than generating some of them without it. + context.Report(SemanticsDiagnostics.MetadataValidationFailed, $"dimension '{issue.Subject}' {issue.Reason}"); + break; + + default: + context.Report(SemanticsDiagnostics.RelationshipNotDimensionallyTrue, issue.Subject, issue.Reason); + break; } - - AddCrossProduct(result, seen, dim, otherDim, resultDim); - } - } - - private static void AddCrossProduct( - List result, - HashSet seen, - PhysicalDimension dim, - PhysicalDimension otherDim, - PhysicalDimension resultDim) - { - string? selfV3 = GetBaseTypeName(dim, 3); - string? otherV3 = GetBaseTypeName(otherDim, 3); - string? resultV3 = GetBaseTypeName(resultDim, 3); - if (selfV3 == null || otherV3 == null || resultV3 == null) - { - return; - } - - string key = $"Cross:{selfV3}:{otherV3}:{resultV3}"; - if (seen.Add(key)) - { - result.Add(new ProductInfo("Cross", selfV3, otherV3, resultV3, 3)); - } - } - - private static void AddOp(List list, HashSet seen, string op, string left, string right, string ret, string owner) - { - // Skip self-division (base class already handles TSelf / TSelf => TStorage) - if (op == "/" && left == right) - { - return; - } - - string key = $"{op}:{left}:{right}:{ret}"; - if (seen.Add(key)) - { - list.Add(new OperatorInfo(op, left, right, ret, owner)); } } @@ -549,85 +431,20 @@ private static void AddOp(List list, HashSet seen, string private static void ReportUnknownReference( SourceProductionContext context, MetadataFile? dimensionsFile, - string owningDimension, - string unknownReference, - string fieldPath) => - context.ReportAt( - SemanticsDiagnostics.UnknownDimensionReference, - dimensionsFile?.FindLocation(unknownReference), - owningDimension, - unknownReference, - fieldPath); - - /// - /// One relationship, plus everything a diagnostic about it needs to say where it is. - /// - /// The source production context to report to. - /// The metadata file the relationship was read from, for locations. - /// The dimension whose entry declares the relationship. - /// The relationship itself. - /// The relationship's path in the metadata, for the message text. - /// - /// These five travel together through form resolution and reporting. Passed individually they - /// pushed to eight parameters, which is both over the analyzer's - /// limit and genuinely hard to read at the call site. - /// - private readonly record struct RelationshipSite( - SourceProductionContext Context, - MetadataFile? File, - PhysicalDimension Owner, - RelationshipDefinition Relationship, - string FieldPath); - - /// - /// Resolves the forms at which a relationship should emit operators. When the metadata - /// declares explicitly, that list wins and - /// any form missing from one of the participating dimensions is reported as - /// SEM003. When the list is empty, returns - /// (which the caller filters silently — preserving the legacy behaviour for relationships - /// that haven't opted into form-specific declarations). - /// - private static int[] ResolveForms( - RelationshipSite site, - int[] defaultForms, - PhysicalDimension otherDim, - PhysicalDimension? resultDim = null) + VocabularyIssue issue) { - if (site.Relationship.Forms.Count == 0) - { - return defaultForms; - } - - List kept = []; - foreach (int form in site.Relationship.Forms) - { - if (form < 0 || form > 4) - { - continue; - } - - if (GetBaseTypeName(site.Owner, form) == null) - { - ReportFormMissing(site, form, site.Owner.Name); - continue; - } - - if (GetBaseTypeName(otherDim, form) == null) - { - ReportFormMissing(site, form, otherDim.Name); - continue; - } - - if (resultDim != null && GetBaseTypeName(resultDim, form) == null) - { - ReportFormMissing(site, form, resultDim.Name); - continue; - } + IssueSite site = issue.Site!; - kept.Add(form); - } + // The vocabulary checks "other" before "result" and stops at the first it cannot resolve, + // so the name it refused is whichever of the two it matches. + string field = string.Equals(site.Offending, site.Other, StringComparison.Ordinal) ? "other" : "result"; - return [.. kept]; + context.ReportAt( + SemanticsDiagnostics.UnknownDimensionReference, + dimensionsFile?.FindLocation(site.Offending), + site.Owner, + site.Offending, + $"{FieldPath(site)}.{field}"); } /// @@ -639,14 +456,42 @@ private static int[] ResolveForms( /// dimension's own "name" property and then looking for the relationship's "other" /// within it puts the location on the declaration that is actually wrong. /// - private static void ReportFormMissing(RelationshipSite site, int form, string offendingDimension) => - site.Context.ReportAt( + private static void ReportFormMissing( + SourceProductionContext context, + MetadataFile? dimensionsFile, + VocabularyIssue issue) + { + IssueSite site = issue.Site!; + + context.ReportAt( SemanticsDiagnostics.RelationshipFormMissing, - site.File?.FindLocation($"\"name\": \"{site.Owner.Name}\"", $"\"other\": \"{site.Relationship.Other}\""), - site.Owner.Name, - site.FieldPath, - form, - offendingDimension); + dimensionsFile?.FindLocation($"\"name\": \"{site.Owner}\"", $"\"other\": \"{site.Other}\""), + site.Owner, + FieldPath(site), + site.Form, + site.Offending); + } + + /// + /// Where a relationship is written in the metadata, in the form the diagnostics quote. + /// + /// The refusal's site. + /// A path like integrals[Time -> Length]. + /// + /// The array names belong to this reader rather than to the vocabulary: the vocabulary knows a + /// product from a quotient, and integrals and derivatives are what + /// dimensions.json happens to call the two arrays they are read from. + /// + private static string FieldPath(IssueSite site) => + $"{ArrayName(site.Kind)}[{site.Other} -> {site.Result}]"; + + private static string ArrayName(RelationshipKind kind) => kind switch + { + RelationshipKind.Product => "integrals", + RelationshipKind.Quotient => "derivatives", + RelationshipKind.Cross => "crossProducts", + _ => "dotProducts", + }; private static Dictionary BuildUnitMap(UnitsMetadata units) { @@ -731,7 +576,7 @@ private static void ReportUnknownUnitReferences( private static void AddUnitFactories( ClassTemplate cls, List availableUnits, - Dictionary unitMap, + IReadOnlyDictionary unitMap, string fullType, string crefForComment, bool applyV0Guard, @@ -790,7 +635,7 @@ private static void AddUnitFactories( /// base unit. Honours magnitude (Kilo, Centi, …), conversionFactor (lookup in /// ), and offset (additive, after scaling). /// - private static string BuildToBaseExpression(string unitName, Dictionary unitMap) + private static string BuildToBaseExpression(string unitName, IReadOnlyDictionary unitMap) { // If we don't have unit metadata, fall back to identity. The dimensions.json author is // responsible for keeping availableUnits in sync with units.json; if a unit is missing, @@ -1068,13 +913,11 @@ private static Dictionary> GroupBy(List items, Func> operatorsByOwner, - Dictionary typeFormMap, - Dictionary unitMap) + Emission emission) { - VectorFormDefinition v0 = dim.Quantities.Vector0!; - string typeName = v0.Base; + string typeName = type.Name; string fullType = $"{typeName}"; using CodeBlocker cb = CreateCodeBlocker(); @@ -1120,7 +963,7 @@ private void EmitV0BaseType( AddUnitFactories( cls, dim.AvailableUnits, - unitMap, + emission.Units, fullType, "", applyV0Guard: true); @@ -1153,8 +996,7 @@ private void EmitV0BaseType( }); // Cross-dimensional operators - EmitScalarOperators(cls, typeName, operatorsByOwner, typeFormMap); - + EmitScalarOperators(cls, typeName, emission); sourceFile.Classes.Add(cls); WriteSourceFileTo(cb, sourceFile); @@ -1163,15 +1005,16 @@ private void EmitV0BaseType( private void EmitV1BaseType( SourceProductionContext context, + QuantityType type, PhysicalDimension dim, - Dictionary> operatorsByOwner, - Dictionary typeFormMap, - Dictionary unitMap) + Emission emission) { - VectorFormDefinition v1 = dim.Quantities.Vector1!; - string typeName = v1.Base; + string typeName = type.Name; string fullType = $"{typeName}"; - string? v0TypeName = dim.Quantities.Vector0?.Base; + + // Never absent: a dimension with no magnitude form generates no types at all, because + // there would be nothing for this one to answer Magnitude() with. + string v0TypeName = type.MagnitudeType; using CodeBlocker cb = CreateCodeBlocker(); @@ -1214,7 +1057,7 @@ private void EmitV1BaseType( AddUnitFactories( cls, dim.AvailableUnits, - unitMap, + emission.Units, fullType, "", applyV0Guard: false); @@ -1223,26 +1066,23 @@ private void EmitV1BaseType( AddDimensionAndInMembers(cls, dim); // Magnitude method returning V0 base - if (v0TypeName != null) + cls.Members.Add(new MethodTemplate() { - cls.Members.Add(new MethodTemplate() + Comments = { - Comments = - { - Emit.SummaryOpen, - $"/// Gets the magnitude of this quantity as a .", - Emit.SummaryClose, - $"/// The non-negative magnitude.", - }, - Keywords = {Emit.Public, $"{v0TypeName}"}, - Name = "Magnitude", - Parameters = {}, - BodyFactory = (body) => body.Write($"=> {v0TypeName}.Create(T.Abs(Value));"), - }); - } + Emit.SummaryOpen, + $"/// Gets the magnitude of this quantity as a .", + Emit.SummaryClose, + $"/// The non-negative magnitude.", + }, + Keywords = {Emit.Public, $"{v0TypeName}"}, + Name = "Magnitude", + Parameters = {}, + BodyFactory = (body) => body.Write($"=> {v0TypeName}.Create(T.Abs(Value));"), + }); // Cross-dimensional operators - EmitScalarOperators(cls, typeName, operatorsByOwner, typeFormMap); + EmitScalarOperators(cls, typeName, emission); sourceFile.Classes.Add(cls); WriteSourceFileTo(cb, sourceFile); @@ -1251,25 +1091,17 @@ private void EmitV1BaseType( private static void EmitVectorType( SourceProductionContext context, + QuantityType type, PhysicalDimension dim, - int dims, - VectorFormDefinition form, - Dictionary> operatorsByOwner, - Dictionary> productsByOwner, - Dictionary typeFormMap) + Emission emission) { - string[] components = dims switch - { - 2 => ["X", "Y"], - 3 => ["X", "Y", "Z"], - 4 => ["X", "Y", "Z", "W"], - _ => throw new ArgumentOutOfRangeException(nameof(dims)), - }; + int dims = type.Form; + string[] components = Components(dims); - string typeName = form.Base; + string typeName = type.Name; string fullType = $"{typeName}"; string interfaceName = $"IVector{dims}<{fullType}, T>"; - string? v0TypeName = dim.Quantities.Vector0?.Base; + string v0TypeName = type.MagnitudeType; using CodeBlocker cb = CreateCodeBlocker(); @@ -1297,21 +1129,18 @@ private static void EmitVectorType( WriteVectorStaticProperties(cb, fullType, components); // Typed Magnitude() method returning V0 base - if (v0TypeName != null) - { - cb.WriteLine($"/// Gets the magnitude as a ."); - cb.WriteLine($"public {v0TypeName} Magnitude() => {v0TypeName}.Create(Length());"); - cb.NewLine(); - } + cb.WriteLine($"/// Gets the magnitude as a ."); + cb.WriteLine($"public {v0TypeName} Magnitude() => {v0TypeName}.Create(Length());"); + cb.NewLine(); WriteVectorMethods(cb, fullType, components, dims); WriteVectorOperators(cb, fullType, components); // Cross-dimensional operators (inlined for VN types) - EmitVectorCrossDimOperators(cb, typeName, operatorsByOwner, typeFormMap); + EmitVectorCrossDimOperators(cb, typeName, emission); // Typed dot product methods - if (productsByOwner.TryGetValue(typeName, out List? products)) + if (emission.ProductsByOwner.TryGetValue(typeName, out List? products)) { foreach (ProductInfo prod in products) { @@ -1342,196 +1171,179 @@ private static void EmitVectorType( private void EmitOverloadType( SourceProductionContext context, + QuantityType type, PhysicalDimension dim, - int vectorForm, - string baseTypeName, - OverloadDefinition overload, - Dictionary unitMap) + Emission emission) { - string typeName = overload.Name; + int vectorForm = type.Form; + string typeName = type.Name; + string baseTypeName = type.Refines!; string fullType = $"{typeName}"; string baseFullType = $"{baseTypeName}"; - // V0/V1 overloads inherit from PhysicalQuantity - if (vectorForm <= 1) - { - using CodeBlocker cb = CreateCodeBlocker(); + using CodeBlocker cb = CreateCodeBlocker(); - string interfaceName = vectorForm == 0 ? $"IVector0<{fullType}, T>" : $"IVector1<{fullType}, T>"; + string interfaceName = vectorForm == 0 ? $"IVector0<{fullType}, T>" : $"IVector1<{fullType}, T>"; - SourceFileTemplate sourceFile = new() - { - FileName = $"{typeName}.g.cs", - Namespace = "ktsu.Semantics.Quantities", - Usings = {"System.Numerics"}, - }; + SourceFileTemplate sourceFile = new() + { + FileName = $"{typeName}.g.cs", + Namespace = "ktsu.Semantics.Quantities", + Usings = {"System.Numerics"}, + }; - ClassTemplate cls = new() + ClassTemplate cls = new() + { + Comments = { - Comments = - { - Emit.SummaryOpen, - $"/// {overload.Description}", - $"/// Semantic overload of .", - Emit.SummaryClose, - "/// The numeric storage type.", - }, - Kind = TypeKind.RecordStruct, - Keywords = {Emit.Public, "readonly", "partial"}, - Name = fullType, - Interfaces = {interfaceName, $"IPhysicalQuantity<{fullType}, T>"}, - Constraints = {"where T : struct, INumber"}, - }; + Emit.SummaryOpen, + $"/// {type.Description}", + $"/// Semantic overload of .", + Emit.SummaryClose, + "/// The numeric storage type.", + }, + Kind = TypeKind.RecordStruct, + Keywords = {Emit.Public, "readonly", "partial"}, + Name = fullType, + Interfaces = {interfaceName, $"IPhysicalQuantity<{fullType}, T>"}, + Constraints = {"where T : struct, INumber"}, + }; - // Everything a quantity used to inherit from the PhysicalQuantity record. - AddValueTypeCore(cls, fullType, isV0: vectorForm == 0); + // Everything a quantity used to inherit from the PhysicalQuantity record. + AddValueTypeCore(cls, fullType, isV0: vectorForm == 0); - // Zero property - cls.Members.Add(new FieldTemplate() - { - Comments = {"/// Gets a quantity with value zero."}, - Keywords = {Emit.Public, Emit.Static, fullType}, - Name = "Zero => Create(T.Zero)", - }); + // Zero property + cls.Members.Add(new FieldTemplate() + { + Comments = {"/// Gets a quantity with value zero."}, + Keywords = {Emit.Public, Emit.Static, fullType}, + Name = "Zero => Create(T.Zero)", + }); - // Factory methods for every available unit (#48); overloads inherit the dimension's - // units. V0 overloads enforce the same non-negativity invariant as their V0 base - // type (#50). V0 overloads that declare physicalConstraints.minExclusive in - // dimensions.json (#51, e.g. Wavelength, Period, HalfLife) get the stricter - // EnsurePositive guard so a zero input is rejected too. V1 overloads accept - // any sign. - bool strictPositive = vectorForm == 0 - && overload.PhysicalConstraints?.MinExclusive == "0"; - AddUnitFactories( - cls, - dim.AvailableUnits, - unitMap, - fullType, - typeName, - applyV0Guard: vectorForm == 0, - strictPositive: strictPositive); - - // Dimension override + typed In() (#59). - AddDimensionAndInMembers(cls, dim); - - // Implicit widening to base type - cls.Members.Add(new MethodTemplate() - { - Comments = {$"/// Implicit conversion to {baseTypeName}."}, - Keywords = {Emit.Public, Emit.Static, "implicit", "operator"}, - Name = baseFullType, - Parameters = {new ParameterTemplate { Type = fullType, Name = Emit.ValueParameter }}, - BodyFactory = (body) => body.Write($"=> {baseFullType}.Create(value.Value);"), - }); + // Factory methods for every available unit (#48); overloads inherit the dimension's + // units. V0 overloads enforce the same non-negativity invariant as their V0 base + // type (#50). V0 overloads that declare physicalConstraints.minExclusive in + // dimensions.json (#51, e.g. Wavelength, Period, HalfLife) get the stricter + // EnsurePositive guard so a zero input is rejected too. V1 overloads accept + // any sign. + bool strictPositive = type.Magnitude == Magnitude.Positive; + AddUnitFactories( + cls, + dim.AvailableUnits, + emission.Units, + fullType, + typeName, + applyV0Guard: vectorForm == 0, + strictPositive: strictPositive); - // Explicit narrowing from base type - cls.Members.Add(new MethodTemplate() - { - Comments = {$"/// Explicit conversion from {baseTypeName}."}, - Keywords = {Emit.Public, Emit.Static, "explicit", "operator"}, - Name = fullType, - Parameters = {new ParameterTemplate { Type = baseFullType, Name = Emit.ValueParameter }}, - BodyFactory = (body) => body.Write($"=> Create(value.Value);"), - }); + // Dimension override + typed In() (#59). + AddDimensionAndInMembers(cls, dim); + + // Implicit widening to base type + cls.Members.Add(new MethodTemplate() + { + Comments = {$"/// Implicit conversion to {baseTypeName}."}, + Keywords = {Emit.Public, Emit.Static, "implicit", "operator"}, + Name = baseFullType, + Parameters = {new ParameterTemplate { Type = fullType, Name = Emit.ValueParameter }}, + BodyFactory = (body) => body.Write($"=> {baseFullType}.Create(value.Value);"), + }); + + // Explicit narrowing from base type + cls.Members.Add(new MethodTemplate() + { + Comments = {$"/// Explicit conversion from {baseTypeName}."}, + Keywords = {Emit.Public, Emit.Static, "explicit", "operator"}, + Name = fullType, + Parameters = {new ParameterTemplate { Type = baseFullType, Name = Emit.ValueParameter }}, + BodyFactory = (body) => body.Write($"=> Create(value.Value);"), + }); - // Factory-style narrowing from base + // Factory-style narrowing from base + cls.Members.Add(new MethodTemplate() + { + Comments = {$"/// Creates a {typeName} from a {baseTypeName} value."}, + Keywords = {Emit.Public, Emit.Static, fullType}, + Name = "From", + Parameters = {new ParameterTemplate { Type = baseFullType, Name = Emit.ValueParameter }}, + BodyFactory = (body) => body.Write("=> Create(value.Value);"), + }); + + // V0 overload subtraction returns the same V0 of T.Abs(left - right) (locked + // in #52). The overload-typed operator hides the base PhysicalQuantity's plain + // subtraction so overloads stay in their own type and the magnitude invariant + // is preserved. + if (vectorForm == 0) + { cls.Members.Add(new MethodTemplate() { - Comments = {$"/// Creates a {typeName} from a {baseTypeName} value."}, + Comments = {$"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}."}, + Attributes = {Emit.PhysicsOperatorSuppression}, Keywords = {Emit.Public, Emit.Static, fullType}, - Name = "From", - Parameters = {new ParameterTemplate { Type = baseFullType, Name = Emit.ValueParameter }}, - BodyFactory = (body) => body.Write("=> Create(value.Value);"), + Name = "operator -", + Parameters = + { + new ParameterTemplate { Type = fullType, Name = "left" }, + new ParameterTemplate { Type = fullType, Name = Emit.RightParameter }, + }, + BodyFactory = (body) => body.Write("=> Create(T.Abs(left.Quantity - right.Quantity));"), }); + } - // V0 overload subtraction returns the same V0 of T.Abs(left - right) (locked - // in #52). The overload-typed operator hides the base PhysicalQuantity's plain - // subtraction so overloads stay in their own type and the magnitude invariant - // is preserved. - if (vectorForm == 0) + // Relationship methods (e.g., Diameter.ToRadius(), Diameter.FromRadius()). The one + // thing an overload declares that the vocabulary does not carry: these are C# + // expressions written in the metadata and pasted through, so there is nothing in + // them for a language-agnostic model to hold. + foreach (KeyValuePair rel in emission.Overloads[typeName].Relationships) + { + // rel.Key is like "toRadius" or "fromRadius", rel.Value is the C# expression + string methodName = char.ToUpperInvariant(rel.Key[0]) + rel.Key.Substring(1); + + if (methodName.StartsWith("To", StringComparison.Ordinal)) { + // Instance method: e.g., ToRadius() returns Radius + string targetName = methodName.Substring(2); + string targetType = $"{targetName}"; + string expr = rel.Value; // uses "Value" referring to this instance cls.Members.Add(new MethodTemplate() { - Comments = {$"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}."}, - Attributes = {Emit.PhysicsOperatorSuppression}, - Keywords = {Emit.Public, Emit.Static, fullType}, - Name = "operator -", - Parameters = - { - new ParameterTemplate { Type = fullType, Name = "left" }, - new ParameterTemplate { Type = fullType, Name = Emit.RightParameter }, - }, - BodyFactory = (body) => body.Write("=> Create(T.Abs(left.Quantity - right.Quantity));"), + Comments = {$"/// Converts this {typeName} to a {targetName}."}, + Keywords = {Emit.Public, targetType}, + Name = methodName, + Parameters = {}, + BodyFactory = (body) => body.Write($"=> {targetType}.Create({expr});"), }); } - - // Relationship methods (e.g., Diameter.ToRadius(), Diameter.FromRadius()) - foreach (KeyValuePair rel in overload.Relationships) + else if (methodName.StartsWith("From", StringComparison.Ordinal)) { - // rel.Key is like "toRadius" or "fromRadius", rel.Value is the C# expression - string methodName = char.ToUpperInvariant(rel.Key[0]) + rel.Key.Substring(1); - - if (methodName.StartsWith("To", StringComparison.Ordinal)) - { - // Instance method: e.g., ToRadius() returns Radius - string targetName = methodName.Substring(2); - string targetType = $"{targetName}"; - string expr = rel.Value; // uses "Value" referring to this instance - cls.Members.Add(new MethodTemplate() - { - Comments = {$"/// Converts this {typeName} to a {targetName}."}, - Keywords = {Emit.Public, targetType}, - Name = methodName, - Parameters = {}, - BodyFactory = (body) => body.Write($"=> {targetType}.Create({expr});"), - }); - } - else if (methodName.StartsWith("From", StringComparison.Ordinal)) + // Static factory: e.g., FromRadius(Radius value) returns this type + string sourceName = methodName.Substring(4); + string sourceType = $"{sourceName}"; + // Replace "Value" with "source.Value" since this is a static method + string expr = rel.Value.Replace("Value", "source.Value"); + cls.Members.Add(new MethodTemplate() { - // Static factory: e.g., FromRadius(Radius value) returns this type - string sourceName = methodName.Substring(4); - string sourceType = $"{sourceName}"; - // Replace "Value" with "source.Value" since this is a static method - string expr = rel.Value.Replace("Value", "source.Value"); - cls.Members.Add(new MethodTemplate() - { - Comments = {$"/// Creates a {typeName} from a {sourceName} value."}, - Keywords = {Emit.Public, Emit.Static, fullType}, - Name = methodName, - Parameters = {new ParameterTemplate { Type = sourceType, Name = "source" }}, - BodyFactory = (body) => body.Write($"=> Create({expr});"), - }); - } + Comments = {$"/// Creates a {typeName} from a {sourceName} value."}, + Keywords = {Emit.Public, Emit.Static, fullType}, + Name = methodName, + Parameters = {new ParameterTemplate { Type = sourceType, Name = "source" }}, + BodyFactory = (body) => body.Write($"=> Create({expr});"), + }); } - - sourceFile.Classes.Add(cls); - WriteSourceFileTo(cb, sourceFile); - context.AddSource(sourceFile.FileName, cb.ToString()); - } - else - { - // V2/3/4 overloads: these are more complex, generate as standalone records - // For now, V2+ overloads are rare and can be added later - // The strategy document shows them mainly for V3 (Position3D, Translation3D) - EmitVectorOverloadType(context, vectorForm, baseTypeName, overload); } + + sourceFile.Classes.Add(cls); + WriteSourceFileTo(cb, sourceFile); + context.AddSource(sourceFile.FileName, cb.ToString()); } - private static void EmitVectorOverloadType( - SourceProductionContext context, - int dims, - string baseTypeName, - OverloadDefinition overload) + private static void EmitVectorOverloadType(SourceProductionContext context, QuantityType type) { - string[] components = dims switch - { - 2 => ["X", "Y"], - 3 => ["X", "Y", "Z"], - 4 => ["X", "Y", "Z", "W"], - _ => throw new ArgumentOutOfRangeException(nameof(dims)), - }; + int dims = type.Form; + string[] components = Components(dims); - string typeName = overload.Name; + string typeName = type.Name; + string baseTypeName = type.Refines!; string fullType = $"{typeName}"; string baseFullType = $"{baseTypeName}"; string interfaceName = $"IVector{dims}<{fullType}, T>"; @@ -1550,7 +1362,7 @@ private static void EmitVectorOverloadType( cb.NewLine(); cb.WriteLine(Emit.SummaryOpen); - cb.WriteLine($"/// {overload.Description}"); + cb.WriteLine($"/// {type.Description}"); cb.WriteLine($"/// Semantic overload of ."); cb.WriteLine(Emit.SummaryClose); cb.WriteLine($"public readonly partial record struct {fullType} : {interfaceName}"); @@ -1582,21 +1394,17 @@ private static void EmitVectorOverloadType( #region Operator Emission Helpers - private static void EmitScalarOperators( - ClassTemplate cls, - string ownerTypeName, - Dictionary> operatorsByOwner, - Dictionary typeFormMap) + private static void EmitScalarOperators(ClassTemplate cls, string ownerTypeName, Emission emission) { - if (!operatorsByOwner.TryGetValue(ownerTypeName, out List? ops)) + if (!emission.OperatorsByOwner.TryGetValue(ownerTypeName, out List? ops)) { return; } foreach (OperatorInfo op in ops) { - int leftForm = GetFormOrDefault(typeFormMap, op.LeftTypeName); - int rightForm = GetFormOrDefault(typeFormMap, op.RightTypeName); + int leftForm = GetFormOrDefault(emission.TypeFormMap, op.LeftTypeName); + int rightForm = GetFormOrDefault(emission.TypeFormMap, op.RightTypeName); // For V0/V1 owner types, use Multiply/Divide helpers when both operands are V0/V1 if (leftForm <= 1 && rightForm <= 1) @@ -1629,19 +1437,16 @@ private static void EmitScalarOperators( // One operand is V2+ (VN type, multi-component) // The owner is V0/V1, the other operand is VN // Generate inline: left.Value {op} right.X, etc. OR left.X {op} right.Value, etc. - EmitInlineCrossDimOp(cls, op, typeFormMap); + EmitInlineCrossDimOp(cls, op, emission); } } } - private static void EmitInlineCrossDimOp( - ClassTemplate cls, - OperatorInfo op, - Dictionary typeFormMap) + private static void EmitInlineCrossDimOp(ClassTemplate cls, OperatorInfo op, Emission emission) { - int leftForm = GetFormOrDefault(typeFormMap, op.LeftTypeName); - int rightForm = GetFormOrDefault(typeFormMap, op.RightTypeName); - int resultForm = GetFormOrDefault(typeFormMap, op.ReturnTypeName); + int leftForm = GetFormOrDefault(emission.TypeFormMap, op.LeftTypeName); + int rightForm = GetFormOrDefault(emission.TypeFormMap, op.RightTypeName); + int resultForm = GetFormOrDefault(emission.TypeFormMap, op.ReturnTypeName); string[] resultComponents = resultForm switch { @@ -1702,22 +1507,18 @@ private static void EmitInlineCrossDimOp( }); } - private static void EmitVectorCrossDimOperators( - CodeBlocker cb, - string ownerTypeName, - Dictionary> operatorsByOwner, - Dictionary typeFormMap) + private static void EmitVectorCrossDimOperators(CodeBlocker cb, string ownerTypeName, Emission emission) { - if (!operatorsByOwner.TryGetValue(ownerTypeName, out List? ops)) + if (!emission.OperatorsByOwner.TryGetValue(ownerTypeName, out List? ops)) { return; } foreach (OperatorInfo op in ops) { - int leftForm = GetFormOrDefault(typeFormMap, op.LeftTypeName); - int rightForm = GetFormOrDefault(typeFormMap, op.RightTypeName); - int resultForm = GetFormOrDefault(typeFormMap, op.ReturnTypeName); + int leftForm = GetFormOrDefault(emission.TypeFormMap, op.LeftTypeName); + int rightForm = GetFormOrDefault(emission.TypeFormMap, op.RightTypeName); + int resultForm = GetFormOrDefault(emission.TypeFormMap, op.ReturnTypeName); string[] resultComponents = resultForm switch { @@ -1913,9 +1714,7 @@ private static void WriteVectorOperators(CodeBlocker cb, string fullType, string _ => null, }; - private static string? GetBaseTypeName(PhysicalDimension dim, int form) => GetFormDef(dim, form)?.Base; - - private static int GetFormOrDefault(Dictionary map, string key) + private static int GetFormOrDefault(IReadOnlyDictionary map, string key) { if (map.TryGetValue(key, out int value)) { @@ -1925,6 +1724,19 @@ private static int GetFormOrDefault(Dictionary map, string key) return -1; } + /// + /// What a vector type's components are called, by how many it has. + /// + /// The component count, from two to four. + /// The component names, in order. + private static string[] Components(int dims) => dims switch + { + 2 => ["X", "Y"], + 3 => ["X", "Y", "Z"], + 4 => ["X", "Y", "Z", "W"], + _ => throw new ArgumentOutOfRangeException(nameof(dims)), + }; + #endregion #region Internal Types diff --git a/Semantics.SourceGenerators/Models/VocabularyProjection.cs b/Semantics.SourceGenerators/Models/VocabularyProjection.cs index a9297b49..6f575a75 100644 --- a/Semantics.SourceGenerators/Models/VocabularyProjection.cs +++ b/Semantics.SourceGenerators/Models/VocabularyProjection.cs @@ -46,9 +46,11 @@ [.. dimension.DotProducts.Select(Relationship)], // The constraint is carried as the flag the vocabulary reads rather than as its value: what the // vocabulary needs is whether a stricter floor is declared. The value itself stays here, where - // the Vector0Guards.EnsurePositive call is emitted from. + // the Vector0Guards.EnsurePositive call is emitted from — and the flag is read off that value + // rather than off the presence of the object holding it, so a constraint of some other kind, + // when one is added, does not silently turn the strict floor on. private static OverloadDeclaration Overload(OverloadDefinition overload) => - new(overload.Name, overload.Description, overload.PhysicalConstraints is not null); + new(overload.Name, overload.Description, overload.PhysicalConstraints?.MinExclusive == "0"); private static RelationshipDeclaration Relationship(RelationshipDefinition relationship) => new(relationship.Other, relationship.Result, [.. relationship.Forms]); diff --git a/Semantics.SourceGenerators/SemanticsDiagnostics.cs b/Semantics.SourceGenerators/SemanticsDiagnostics.cs index 5718abf3..c3bd29b7 100644 --- a/Semantics.SourceGenerators/SemanticsDiagnostics.cs +++ b/Semantics.SourceGenerators/SemanticsDiagnostics.cs @@ -86,17 +86,23 @@ public static class SemanticsDiagnostics /// A relationship is a claim, and until this existed nothing on the C# side checked it. The /// names were checked (SEM001) and the forms were checked (SEM003), and then the operator was /// emitted — so Sensitivity * Pressure -> ElectricPotential, whose exponents are off - /// by M L⁴ T⁻⁵ I⁻², has been shipping as a working operator that computes the wrong - /// physics. The C++ projection had the check and refused it by name; the two now share it. + /// by M L⁴ T⁻⁵ I⁻², shipped for several versions as a working operator computing the + /// wrong physics. The C++ projection had the check and refused it by name; the two now share + /// it, and share what is done about it. /// - /// A warning rather than an error, and the operator is still emitted. Both of those are - /// deliberate: the metadata's own bug predates this, fixing it is a physics call rather than a - /// spelling one, and dropping an operator that is in a shipped package is a breaking change - /// nobody has asked for yet. What this buys is that the claim is no longer silent. + /// No operator is generated. That is what changed when the shared vocabulary went from + /// checking emission to driving it: a relationship it refuses is simply not among the ones + /// there are to write, in any of the directions C# spells a product in. Reporting it and + /// emitting it anyway was the earlier, narrower step — it made the claim audible without + /// breaking a shipped package, and the package has since had a major version to break in. + /// + /// + /// A warning rather than an error, because the metadata's bug is a physics call rather than a + /// spelling one and a build that cannot complete is no way to ask for one. /// /// public static DiagnosticDescriptor RelationshipNotDimensionallyTrue { get; } = Catalog.Warning( 8, "Physics relationship does not follow from the dimensions of its operands", - "Relationship {0} {1} The operator is still generated, so this is reported rather than dropped; fix dimensions.json."); + "Relationship {0} {1} No operator is generated for it; fix dimensions.json."); } diff --git a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs index 8123e80e..b788a022 100644 --- a/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs +++ b/Semantics.Test/Quantities/GeneratorDiagnosticTests.cs @@ -80,6 +80,67 @@ private static string TwoDimensionsDocument(string relationships) => } """; + /// + /// Three dimensions that make a real cross product: Length x Force -> Torque, whose + /// exponents work out — L times L M T⁻² is L² M T⁻². + /// + /// The vector forms Length declares. + /// The vector forms Force declares. + /// The vector forms Torque declares. + /// The document, with the cross product declared on Length at form 3. + /// + /// The physics has to be right for this to test what its name says. A relationship the + /// exponents contradict is refused for that reason and never reaches the question of which + /// forms its participants declare, so a fixture that multiplied two lengths into a length + /// would report SEM008 and never SEM003, whatever forms it asked for. + /// + /// Each participant's forms are a parameter because which one is short of the form is the + /// whole of what these tests vary: the relationship names three dimensions and the diagnostic + /// has to name the right one of them. + /// + /// + private static string CrossProductDocument(string length, string force, string torque) => + $$""" + { + "physicalDimensions": [ + { + "name": "Length", + "symbol": "L", + "dimensionalFormula": { "length": 1 }, + "availableUnits": [ "Meter" ], + "quantities": { {{length}} }, + "crossProducts": [ { "other": "Force", "result": "Torque", "forms": [ 3 ] } ] + }, + { + "name": "Force", + "symbol": "F", + "dimensionalFormula": { "length": 1, "mass": 1, "time": -2 }, + "availableUnits": [ "Newton" ], + "quantities": { {{force}} } + }, + { + "name": "Torque", + "symbol": "M", + "dimensionalFormula": { "length": 2, "mass": 1, "time": -2 }, + "availableUnits": [ "NewtonMeter" ], + "quantities": { {{torque}} } + } + ] + } + """; + + /// A dimension that declares a magnitude form and nothing else. + private static string MagnitudeOnly(string name) => $$""" + + "vector0": { "base": "{{name}}" } + """; + + /// A dimension that declares a magnitude form and a three-component one. + private static string WithVector3(string name) => $$""" + + "vector0": { "base": "{{name}}" }, "vector3": { "base": "{{name}}3D" } + """; + private static IReadOnlyList Run(string generatorMetadata, IIncrementalGenerator generator, string fileName) => [.. Harness.Run(generator, new Dictionary { [fileName] = generatorMetadata }).Diagnostics]; @@ -123,6 +184,27 @@ private static void AssertPointsAt(string metadata, IReadOnlyList di $"{id} pointed at the wrong place in the metadata."); } + /// + /// Asserts that SEM003 was reported and that it names the participant that is short of the + /// form, rather than one of the other two. + /// + /// Everything the generator reported. + /// The dimension that does not declare the form. + /// + /// On the quoted clause rather than on the bare name, because all three participants are named + /// in the message either way — the field path spells the relationship out — so asserting that + /// the name appears somewhere would pass for a diagnostic blaming the wrong one. + /// + private static void AssertNamesTheDimensionMissingTheForm(IReadOnlyList diagnostics, string expected) + { + AssertReports(diagnostics, "SEM003"); + + Assert.Contains( + $"but '{expected}' does not declare that form", + diagnostics.First(candidate => candidate.Id == "SEM003").GetMessage(), + "SEM003 named the wrong participant."); + } + [TestMethod] public void Sem001_IsReportedForARelationshipNamingAnUnknownDimension() { @@ -195,63 +277,67 @@ public void Sem002_IsReportedForADimensionMissingItsSymbol() AssertReports(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM002"); } + /// + /// The first of the three participants. Length declares a magnitude form and nothing else, so + /// the cross product it declares at V3 cannot be honoured by the dimension declaring it. + /// [TestMethod] public void Sem003_IsReportedWhenARelationshipRequestsAnUndeclaredForm() { - // Length declares vector0 and vector3; asking for the cross product at V2 cannot be honoured. - string metadata = DimensionsDocument( - relationships: ",\n \"crossProducts\": [ { \"other\": \"Length\", \"result\": \"Length\", \"forms\": [ 2 ] } ]"); + string metadata = CrossProductDocument( + MagnitudeOnly("Length"), + WithVector3("Force"), + WithVector3("Torque")); - AssertReports(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM003"); + AssertNamesTheDimensionMissingTheForm(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "Length"); } [TestMethod] public void Sem003_PointsAtTheRelationshipRatherThanAtNothing() { - string metadata = DimensionsDocument( - relationships: ",\n \"crossProducts\": [ { \"other\": \"Length\", \"result\": \"Length\", \"forms\": [ 2 ] } ]"); + string metadata = CrossProductDocument( + MagnitudeOnly("Length"), + WithVector3("Force"), + WithVector3("Torque")); - // Not the bare name: "Length" is spelled correctly and appears several times before the - // relationship that is wrong. The location has to be the relationship's own "other". + // Not the bare name: "Force" is spelled correctly and appears as a dimension of its own + // further down the file. The location has to be the relationship's own "other". AssertPointsAt( metadata, Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "SEM003", - "\"other\": \"Length\""); + "\"other\": \"Force\""); } /// - /// The self branch is covered above. This is the second participant: Length has a vector3 and - /// Time does not, so the cross product cannot be honoured at form 3 — and the diagnostic has to - /// name Time rather than Length. + /// The second participant: Length has a vector3 and Force does not, so the cross product + /// cannot be honoured at form 3 — and the diagnostic has to name Force rather than Length. /// [TestMethod] public void Sem003_NamesTheOtherParticipantWhenItIsTheOneMissingTheForm() { - string metadata = TwoDimensionsDocument( - ",\n \"crossProducts\": [ { \"other\": \"Time\", \"result\": \"Length\", \"forms\": [ 3 ] } ]"); + string metadata = CrossProductDocument( + WithVector3("Length"), + MagnitudeOnly("Force"), + WithVector3("Torque")); - IReadOnlyList diagnostics = Run(metadata, new QuantitiesGenerator(), "dimensions.json"); - - AssertReports(diagnostics, "SEM003"); - Assert.Contains("Time", diagnostics.First(candidate => candidate.Id == "SEM003").GetMessage()); + AssertNamesTheDimensionMissingTheForm(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "Force"); } /// - /// The third participant. A cross product also needs its result to have the form — - /// Force x Length -> Torque at V2 fails because Torque has no V2, not because either operand - /// is missing one. + /// The third participant. A cross product also needs its result to have the form: + /// Length x Force -> Torque at V3 fails when Torque has no V3, not because either operand is + /// missing one. /// [TestMethod] public void Sem003_NamesTheResultWhenItIsTheOneMissingTheForm() { - string metadata = TwoDimensionsDocument( - ",\n \"crossProducts\": [ { \"other\": \"Length\", \"result\": \"Time\", \"forms\": [ 3 ] } ]"); + string metadata = CrossProductDocument( + WithVector3("Length"), + WithVector3("Force"), + MagnitudeOnly("Torque")); - IReadOnlyList diagnostics = Run(metadata, new QuantitiesGenerator(), "dimensions.json"); - - AssertReports(diagnostics, "SEM003"); - Assert.Contains("Time", diagnostics.First(candidate => candidate.Id == "SEM003").GetMessage()); + AssertNamesTheDimensionMissingTheForm(Run(metadata, new QuantitiesGenerator(), "dimensions.json"), "Torque"); } [TestMethod] diff --git a/Semantics.Test/Quantities/QuantityBackfillTests.cs b/Semantics.Test/Quantities/QuantityBackfillTests.cs index f7475cf2..85919b91 100644 --- a/Semantics.Test/Quantities/QuantityBackfillTests.cs +++ b/Semantics.Test/Quantities/QuantityBackfillTests.cs @@ -51,13 +51,23 @@ public void ElectricPowerDensity_Times_Volume_Is_Power() Assert.AreEqual(1000.0, power.Value, Tolerance); } - [TestMethod] - public void Sensitivity_Times_Pressure_Is_Voltage() - { - // A 50 mV/Pa microphone at 1 Pa (94 dB SPL) produces 50 mV. - Sensitivity mic = Sensitivity.FromVoltPerPascal(0.05); - VoltageMagnitude output = mic * Pressure.FromPascal(1.0); - Assert.AreEqual(0.05, output.Value, Tolerance); + /// + /// A sensitivity is still constructible from volts per pascal, which is the part of the + /// metadata that is not in doubt. + /// + /// + /// What used to be asserted here was mic * pressure answering with a voltage, and that + /// operator is gone: Sensitivity is declared as A/Pa (M⁻¹L⁻¹T²I) while the + /// relationship treats it as V/Pa, so the product it claimed was off by M L⁴ T⁻⁵ I⁻². + /// The test passed because it multiplied two stored values and compared the result to their + /// product; no arithmetic over SI base units could have caught it. See + /// UnkeepableRelationshipTests and the migration note for 4.0. + /// + [TestMethod] + public void Sensitivity_Factory_Reads_VoltPerPascal() + { + // A 50 mV/Pa microphone. + Assert.AreEqual(0.05, Sensitivity.FromVoltPerPascal(0.05).Value, Tolerance); } [TestMethod] diff --git a/Semantics.Test/Quantities/UnkeepableRelationshipTests.cs b/Semantics.Test/Quantities/UnkeepableRelationshipTests.cs index e2c6c8d5..2888bec3 100644 --- a/Semantics.Test/Quantities/UnkeepableRelationshipTests.cs +++ b/Semantics.Test/Quantities/UnkeepableRelationshipTests.cs @@ -5,8 +5,10 @@ namespace ktsu.Semantics.Test.Quantities; using System; using System.IO; using System.Linq; +using System.Reflection; using System.Text.Json; +using global::ktsu.Semantics.Quantities; using global::ktsu.Semantics.Vocabulary; using Microsoft.VisualStudio.TestTools.UnitTesting; using global::Semantics.SourceGenerators.Models; @@ -16,16 +18,18 @@ namespace ktsu.Semantics.Test.Quantities; /// documented and accepted. /// /// -/// SEM008 reports these, and Semantics.Quantities.csproj suppresses it, because -/// ktsu.Sdk builds warnings as errors and all five are outstanding for reasons that are not -/// spelling: three are the r x F versus tau . theta contradiction and no assignment of -/// angle exponents satisfies both, one is a metadata bug whose fix is a physics call, and one needs -/// a vector1 form on Energy. +/// None of the five generates an operator, and SEM008 says so on every build. +/// Semantics.Quantities.csproj suppresses that warning, because ktsu.Sdk builds warnings as +/// errors and all five are outstanding for reasons that are not spelling: three are the +/// r x F versus tau . theta contradiction and no assignment of angle exponents +/// satisfies both, one is a metadata bug whose fix is a physics call, and one needs a +/// vector1 form on Energy. /// /// A suppression with no floor under it would swallow a sixth, which is what this exists to stop. /// The assertion is on the exact set rather than on a count, so a relationship that stops being /// refused fails here too — that would mean somebody fixed one, and the list and the suppression -/// should shrink with it. +/// should shrink with it. A sixth now costs an operator rather than only a warning, which is what +/// makes the floor worth having. /// /// /// Driven by the real production metadata, read through the same model the generator reads it with @@ -111,6 +115,75 @@ public void TheSensitivityBugIsCaughtWithBothDimensionsWrittenOut() Assert.Contains("L² M T⁻³ I⁻¹", issue.Reason); } + /// + /// None of the five reaches the generated surface. + /// + /// + /// The refusal used to be a warning against an operator that was emitted anyway, so the whole + /// of what SEM008 bought was that the claim was no longer silent. Now the shared vocabulary + /// drives emission rather than only checking it, and a relationship it refuses produces no + /// operator on either side — which is what this asserts, against the compiled package rather + /// than against the metadata, because that is where the breaking change is. + /// + /// By reflection rather than by failing to compile: a call that does not compile cannot be + /// written down in a test at all, so the only way to state the absence is to look for it. + /// Each operator is looked for on both operands, because C# finds one declared on either. + /// + /// + [TestMethod] + public void NoneOfTheUnkeepableRelationshipsIsGenerated() + { + Assert.IsFalse( + HasOperator("op_Multiply", typeof(Sensitivity), typeof(Pressure)), + "Sensitivity * Pressure computes L⁻² I and claims a voltage."); + + Assert.IsFalse( + HasOperator("op_Multiply", typeof(TorqueMagnitude), typeof(Angle)), + "Torque * AngularDisplacement -> Energy is the r x F versus tau . theta contradiction."); + + Assert.IsFalse( + HasOperator("op_Multiply", typeof(MomentOfInertia), typeof(AngularSpeed)), + "MomentOfInertia * AngularVelocity -> AngularMomentum is the same contradiction."); + + Assert.IsFalse( + HasOperator("op_Multiply", typeof(MomentOfInertia), typeof(AngularAccelerationMagnitude)), + "MomentOfInertia * AngularAcceleration -> Torque is the same contradiction."); + + Assert.IsFalse( + HasTypedDot(typeof(Force3D), typeof(Displacement3D)), + "dot(Force, Length) is signed and Energy is a magnitude form."); + } + + /// + /// Whether either operand declares the operator. + /// + /// The operator's compiled method name, such as op_Multiply. + /// The type on the left. + /// The type on the right. + /// Whether a call would bind. + private static bool HasOperator(string name, Type left, Type right) + { + Type[] operands = [left, right]; + + return Declared(left, name, operands) || Declared(right, name, operands); + } + + private static bool Declared(Type type, string name, Type[] operands) => + type.GetMethod(name, BindingFlags.Public | BindingFlags.Static, binder: null, operands, modifiers: null) is not null; + + /// + /// Whether a vector type declares a dot product taking another quantity's vector form. + /// + /// The type the method would be declared on. + /// The type it would take. + /// Whether the typed overload exists. + /// + /// The untyped Dot over two vectors of the same kind is always generated and answers + /// with the storage type; what this looks for is the overload taking a different one. + /// + private static bool HasTypedDot(Type self, Type other) => + self.GetMethod("Dot", BindingFlags.Public | BindingFlags.Instance, binder: null, [other], modifiers: null) is not null; + /// /// The refusals the C# generator already has diagnostics for are not counted among these, so /// SEM008 never says what SEM001 or SEM003 has already said. diff --git a/Semantics.Test/Quantities/VectorQuantityTests.cs b/Semantics.Test/Quantities/VectorQuantityTests.cs index 0ce27721..3a183e77 100644 --- a/Semantics.Test/Quantities/VectorQuantityTests.cs +++ b/Semantics.Test/Quantities/VectorQuantityTests.cs @@ -49,24 +49,31 @@ public void Velocity3D_Magnitude_Of_Zero_Vector_Is_Zero() Assert.AreEqual(0.0, s.Value, Tolerance); } - // ------------------------------------------------------ Typed dot product + // ------------------------------------------------------ Untyped dot product + /// + /// The dot product of a vector with one of its own kind answers with the storage type. + /// + /// + /// The typed one — Force3D.Dot(Displacement3D) answering with an + /// Energy — used to be generated here and is not any more, because a force opposing a + /// displacement does negative work and Energy is a magnitude form, which cannot hold + /// one. UnkeepableRelationshipTests is where that is pinned. + /// [TestMethod] - public void Force3D_Dot_Displacement3D_Returns_Energy_Aligned() + public void Force3D_Dot_Force3D_Returns_The_Storage_Type() { Force3D f = new() { X = 10.0, Y = 0.0, Z = 0.0 }; - Displacement3D r = new() { X = 2.0, Y = 0.0, Z = 0.0 }; - Energy work = f.Dot(r); - Assert.AreEqual(20.0, work.Value, Tolerance); + Force3D g = new() { X = 2.0, Y = 0.0, Z = 0.0 }; + Assert.AreEqual(20.0, f.Dot(g), Tolerance); } [TestMethod] - public void Force3D_Dot_Displacement3D_Is_Zero_For_Perpendicular() + public void Force3D_Dot_Force3D_Is_Zero_For_Perpendicular() { Force3D f = new() { X = 10.0, Y = 0.0, Z = 0.0 }; - Displacement3D r = new() { X = 0.0, Y = 5.0, Z = 0.0 }; - Energy work = f.Dot(r); - Assert.AreEqual(0.0, work.Value, Tolerance); + Force3D g = new() { X = 0.0, Y = 5.0, Z = 0.0 }; + Assert.AreEqual(0.0, f.Dot(g), Tolerance); } // ---------------------------------------------------- Typed cross product diff --git a/Semantics.Vocabulary/QuantityVocabulary.cs b/Semantics.Vocabulary/QuantityVocabulary.cs index b18c2c3a..588ee6e0 100644 --- a/Semantics.Vocabulary/QuantityVocabulary.cs +++ b/Semantics.Vocabulary/QuantityVocabulary.cs @@ -2,6 +2,7 @@ namespace ktsu.Semantics.Vocabulary; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -37,6 +38,17 @@ internal enum Magnitude /// /// The name of the same dimension's magnitude form, which is what magnitude() answers with. /// +/// +/// The dimension that declares it, as dimensions.json names it. +/// +/// +/// is the one field here that is not physics: the exponents already say +/// what the quantity is, and the name of the entry it was read from says nothing further +/// about it. It is carried because a target spells a quantity using things the vocabulary has no +/// opinion on -- which units it can be built from, what its symbol is -- and those live on the +/// reader's own model of the dimension. Without a way back to that entry a consumer has to +/// rediscover which dimension produced a type, which is the drift this exists to stop. +/// internal sealed record QuantityType( string Name, string Description, @@ -44,7 +56,8 @@ internal sealed record QuantityType( string? Refines, Magnitude Magnitude, int Form, - string MagnitudeType) + string MagnitudeType, + string Owner) { /// Gets a value indicating whether this holds several components rather than one. internal bool IsVector => Form >= 2; @@ -107,10 +120,11 @@ public override string ToString() => /// Why something the metadata asked for was refused. /// /// -/// Carried so a consumer can report only what it does not already diagnose for itself. The C# -/// generator has had its own diagnostics for an unknown dimension (SEM001) and a missing vector -/// form (SEM003) since before this was shared, so it reports the two kinds that are genuinely new -/// to it and leaves those alone; the C++ projection prints all of them, having no other channel. +/// Carried so a consumer can report each kind the way it already reports that kind. The C# +/// generator has a diagnostic per kind — SEM001 for an unknown dimension, SEM003 for a missing +/// vector form, SEM002 for a dimension with no magnitude form, and SEM008 for the two the +/// exponents refuse — and switches on this to pick between them; the C++ projection prints all of +/// them together, having no other channel. /// internal enum VocabularyIssueKind { @@ -130,13 +144,50 @@ internal enum VocabularyIssueKind MissingVectorForm, } +/// +/// Which part of the metadata a refusal is about, in the metadata's own names. +/// +/// The dimension whose entry declares the relationship. +/// The dimension on the other side of the operator. +/// The dimension the operator claims to produce. +/// How the two were to be combined. +/// +/// The name the refusal is about: the one that could not be resolved, or the one that does not +/// declare the form. Empty when the refusal is about the relationship as a whole rather than about +/// one of its names. +/// +/// The form asked for, or -1 when the refusal is not about a form. +/// +/// is prose, which is all a consumer that prints its refusals +/// needs. One that reports them as compiler diagnostics needs the pieces back: a message with the +/// names in their own fields, and a position in the file to point at, which it finds by searching +/// for the text it already knows. Re-parsing the prose to recover them would be the same drift in +/// a new place. +/// +internal sealed record IssueSite( + string Owner, + string Other, + string Result, + RelationshipKind Kind, + string Offending, + int Form); + /// /// Something the metadata says that will not be emitted, and why. /// /// Which kind of problem it is, so a consumer can report only what it needs to. /// What was refused, named the way the metadata names it. /// Why, in terms a person editing the metadata can act on. -internal sealed record VocabularyIssue(VocabularyIssueKind Kind, string Subject, string Reason) +/// +/// Where it came from, for a consumer that reports it against the file. Null when the refusal is +/// about a dimension rather than about a relationship, which is the case. +/// +internal sealed record VocabularyIssue( + VocabularyIssueKind Kind, + string Subject, + string Reason, + IssueSite? Site = null) { /// public override string ToString() => $"{Subject}: {Reason}"; @@ -265,7 +316,8 @@ private static IEnumerable Declared( Refines: null, form == 0 ? Magnitude.NonNegative : Magnitude.Signed, form, - magnitudeType); + magnitudeType, + dimension.Name); foreach (OverloadDeclaration overload in declared.Overloads) { @@ -276,7 +328,8 @@ private static IEnumerable Declared( Refines: declared.Base, BoundOf(form, overload), form, - magnitudeType); + magnitudeType, + dimension.Name); } } @@ -361,7 +414,8 @@ private static List Resolve( refused.Add(new VocabularyIssue( VocabularyIssueKind.UnknownDimension, subject, - $"names '{named}', which dimensions.json does not declare.")); + $"names '{named}', which dimensions.json does not declare.", + Site(dimension.Name, relationship, kind, named))); return []; } } @@ -376,7 +430,8 @@ private static List Resolve( refused.Add(new VocabularyIssue( VocabularyIssueKind.NotDimensionallyTrue, subject, - $"is not dimensionally true: {left} {(kind == RelationshipKind.Quotient ? "/" : "*")} {right} is {combined}, and {relationship.Result} is {result}.")); + $"is not dimensionally true: {left} {(kind == RelationshipKind.Quotient ? "/" : "*")} {right} is {combined}, and {relationship.Result} is {result}.", + Site(dimension.Name, relationship, kind, string.Empty))); return []; } @@ -388,7 +443,8 @@ private static List Resolve( refused.Add(new VocabularyIssue( VocabularyIssueKind.SignedResultInMagnitudeForm, 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.")); + $"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.", + Site(dimension.Name, relationship, kind, relationship.Result))); return []; } @@ -438,11 +494,18 @@ private static List At( if (relationship.Forms.Count > 0) { // The same gap SEM003 reports: a form asked for by name that one of the - // participants does not have. - refused.Add(new VocabularyIssue( - VocabularyIssueKind.MissingVectorForm, - subject, - $"is declared at vector{form.ToString(CultureInfo.InvariantCulture)}, which {Missing(declarations, form, self, relationship, crossed)} does not declare.")); + // participants does not have. One refusal per participant rather than one + // naming them together, because each is its own thing to go and declare, and + // a consumer reporting these as diagnostics has a single name to put in each + // message rather than a list to phrase around. + foreach (string missing in Missing(declarations, form, self, relationship, crossed)) + { + refused.Add(new VocabularyIssue( + VocabularyIssueKind.MissingVectorForm, + subject, + $"is declared at vector{form.ToString(CultureInfo.InvariantCulture)}, which {missing} does not declare.", + Site(self, relationship, kind, missing, form))); + } } continue; @@ -475,20 +538,56 @@ private static IReadOnlyList Wanted(RelationshipDeclaration relationship, b return crossed ? [3] : [.. Enumerable.Range(0, DimensionDeclaration.FormCount)]; } - private static string Missing( + /// + /// The participants that cannot supply the form a relationship was declared at. + /// + /// + /// Each participant is checked at the form it actually has to supply, which is not the same + /// form for all three: the right operand of a product or a quotient is the magnitude the + /// vector is scaled by, so what it needs is a vector0, however many components the + /// relationship is declared at. Checking it at the declared form instead would refuse + /// Velocity3D * Duration for the absence of a three-component duration, which is not a + /// thing to go and declare. + /// + private static IEnumerable Missing( IReadOnlyDictionary declarations, int form, string self, RelationshipDeclaration relationship, bool crossed) { - IEnumerable participants = crossed - ? [self, relationship.Other, relationship.Result] - : [self, relationship.Result]; - - return string.Join(" and ", participants.Where(named => Base(declarations, named, form) is null)); + (string Name, int At)[] participants = + [ + (self, form), + (relationship.Other, crossed ? form : 0), + (relationship.Result, form), + ]; + + return participants + .Where(participant => Base(declarations, participant.Name, participant.At) is null) + .Select(participant => participant.Name) + .Distinct(StringComparer.Ordinal); } + /// + /// Where a refusal about one relationship came from. + /// + /// The dimension whose entry declares it. + /// The relationship itself. + /// How its two operands were to be combined. + /// + /// The name the refusal is about, or empty when it is about the relationship as a whole. + /// + /// The form asked for, or -1 when the refusal is not about a form. + /// The site. + private static IssueSite Site( + string owner, + RelationshipDeclaration relationship, + RelationshipKind kind, + string offending, + int form = -1) => + new(owner, relationship.Other, relationship.Result, kind, offending, form); + private static string? Base(IReadOnlyDictionary declarations, string dimension, int form) { string? name = declarations.TryGetValue(dimension, out DimensionDeclaration? declared) diff --git a/docs/migration-guide-5.0.md b/docs/migration-guide-5.0.md new file mode 100644 index 00000000..feb6d01d --- /dev/null +++ b/docs/migration-guide-5.0.md @@ -0,0 +1,160 @@ +# Migrating from Semantics 4.x to 5.0 + +Semantics 5.0 removes sixteen generated operators and three generated methods from +`ktsu.Semantics.Quantities`. Every one of them computed a result the dimensions of its +operands do not support, and each was found by the same check the C++ projection has +always run. + +Strings, Paths, Music and Color are unaffected. No other quantity, unit, factory, +constant or conversion changed. + +## Why + +A relationship in `dimensions.json` is a claim — `Force * Length -> Torque` — and the +dimensional exponents are what check it. The C++ projection multiplies the exponents out +because it has to: it writes `Result{ lhs.value() * rhs.value() }` over a +`Quantity>`, so a claim the exponents contradict does not compile. It has +refused these five relationships by name since it existed. + +The C# generator had no such check. It verified that a relationship's names resolved +(`SEM001`) and that the vector forms it asked for existed (`SEM003`), and then emitted the +operator. So this shipped, and passed a test: + +```csharp +Sensitivity mic = Sensitivity.FromVoltPerPascal(0.05); +VoltageMagnitude output = mic * Pressure.FromPascal(1.0); // 0.05 +``` + +`Sensitivity` is declared as amperes per pascal (`M⁻¹L⁻¹T²I`) and `Pressure` is +`ML⁻¹T⁻²`, so the product is `L⁻²I` — not the `ML²T⁻³I⁻¹` of a voltage, but off by +`ML⁴T⁻⁵I⁻²`. Every value in the library is stored in SI base units, so the operator +multiplied two doubles and handed back the right number with the wrong type on it. No +arithmetic could have caught that; only the exponents. + +4.3 moved the check into `Semantics.Vocabulary`, shared source compiled into both +generators, and reported it as `SEM008` while still emitting the operator — audible, but +not yet a broken package. 5.0 lets the shared vocabulary drive C# emission rather than +only check it, so a relationship it refuses is not among the ones there are to write. The +two generators now disagree about nothing. + +## Quick checklist + +1. Search your code for the four operators and one method listed below. +2. For each, decide what you meant: the dimensions say the result is not what the + operator claimed, so there is a real modelling question underneath every call site. +3. Nothing else to do. No type was removed or renamed, and every other operator, factory + and conversion is unchanged. + +## What was removed + +Four products, each in the four spellings C# generated for it — the declared direction, +its commutation, and the two divisions that undo it: + +| Removed | Why | +|---|---| +| `Sensitivity * Pressure -> VoltageMagnitude` | **metadata bug**: `Sensitivity` is declared A/Pa and the relationship treats it as V/Pa | +| `TorqueMagnitude * Angle -> Energy` | the `r × F` versus `τ · θ` contradiction | +| `MomentOfInertia * AngularSpeed -> AngularMomentumMagnitude` | the same contradiction | +| `MomentOfInertia * AngularAccelerationMagnitude -> TorqueMagnitude` | the same contradiction | + +and one dot product, at each of the three vector forms it reached: + +| Removed | Why | +|---|---| +| `Force2D.Dot(Displacement2D) -> Energy` | signed value, magnitude result | +| `Force3D.Dot(Displacement3D) -> Energy` | signed value, magnitude result | +| `Force4D.Dot(Displacement4D) -> Energy` | signed value, magnitude result | + +The untyped `Dot` over two vectors of the same quantity — `Force3D.Dot(Force3D)`, which +answers with the storage type — is unaffected, as is `Force3D.Cross(Displacement3D)`. + +## 1. Sensitivity times pressure + +This one is a straightforward mistake in the metadata, and fixing it is a physics call +that has not been made. One of the two declarations is wrong: either `Sensitivity` should +be volts per pascal (`ML²T⁻³I⁻¹` over `ML⁻¹T⁻²`, so `L³T⁻¹I⁻¹`), or the relationship +should not produce a voltage. + +Until that is settled, compute it yourself and say which reading you are taking: + +```csharp +// Was: +VoltageMagnitude output = mic * pressure; + +// Now, if your sensitivity really is volts per pascal: +VoltageMagnitude output = VoltageMagnitude.FromVolt(mic.Value * pressure.Value); +``` + +That is deliberately not pretty. The multiplication is the same one the operator did; what +is gone is the library agreeing that the result is a voltage. + +## 2. The rotational three + +`Torque * AngularDisplacement -> Energy`, `MomentOfInertia * AngularVelocity -> +AngularMomentum` and `MomentOfInertia * AngularAcceleration -> Torque` are **not** fixable +by choosing different angle exponents, and that is provable rather than a matter of taste. +`Torque * AngularDisplacement -> Energy` forces torque's angle exponent to −1; +`Force × Length -> Torque` forces it to 0. It is the classic `r × F` versus `τ · θ` +contradiction, and it is why SI keeps the radian dimensionless. + +What separates a torque from an energy in this library is the *name*, not the exponents — +that is the whole job of the nominal layer. So do the arithmetic and name the result: + +```csharp +// Was: +Energy work = torque * angle; + +// Now: +Energy work = Energy.FromJoule(torque.Value * angle.Value); +``` + +```csharp +// Was: +AngularMomentumMagnitude l = inertia * spin; + +// Now: +AngularMomentumMagnitude l = + AngularMomentumMagnitude.FromKilogramMeterSquaredPerSecond(inertia.Value * spin.Value); +``` + +The inverse directions (`energy / angle`, `angularMomentum / inertia`, and so on) went +with them and come back the same way. + +## 3. Force dotted with displacement + +The exponents agree here — `LMT⁻² · L` is `L²MT⁻²`, which is what `Energy` is — and the +claim is still unkeepable, for a reason the vector forms surfaced. A dot product is +signed: a force opposing a displacement does negative work. `Energy` declares only a +magnitude form, and a magnitude cannot be negative, so its factories run +`Vector0Guards.EnsureNonNegative` and throw. The generated method would therefore have +thrown `ArgumentException` on an ordinary input — a braking force, a spring under +compression, anything pushing back. + +If you only ever dotted aligned vectors, you were relying on that. Take the sign +explicitly: + +```csharp +// Was: +Energy work = force.Dot(displacement); // threw when the two opposed each other + +// Now — the signed work, as a bare number: +double work = (force.X * displacement.X) + (force.Y * displacement.Y) + (force.Z * displacement.Z); + +// Or, if you want the magnitude and know you are discarding the direction: +Energy magnitude = Energy.FromJoule(Math.Abs(work)); +``` + +The fix on the library's side is named rather than guessed at: `Energy` needs a `vector1` +form for a signed result to land in. That is a metadata change, and when it is made the +relationship becomes keepable and the method comes back — answering with the signed form, +not this one. + +## What this does not change + +- No quantity type was added, removed or renamed, so the storage-type alias packages + (`ktsu.Semantics.Quantities.Double` and friends) are unchanged. +- `SEM008` still reports each refused relationship when you build metadata of your own; it + now says no operator is generated, where it used to say one was generated anyway. +- `UnkeepableRelationshipTests` pins the set to exactly these five and asserts that none of + them is in the compiled surface, so a sixth fails a test rather than disappearing into + the `NoWarn` in `Semantics.Quantities.csproj`. diff --git a/docs/physics-generator.md b/docs/physics-generator.md index 753c5e3b..af44b8e9 100644 --- a/docs/physics-generator.md +++ b/docs/physics-generator.md @@ -175,7 +175,7 @@ type. | SEM005 | A schema-level problem in `logarithmic.json`. | | SEM006 | A metadata file a generator declared that was not supplied as an `AdditionalFile`. | | SEM007 | A metadata file that could not be parsed. | - | SEM008 | A relationship whose declared result does not follow from the dimensions of its operands, or whose signed value cannot land in a magnitude result. The operator is still emitted. | + | SEM008 | A relationship whose declared result does not follow from the dimensions of its operands, or whose signed value cannot land in a magnitude result. No operator is generated for it. | Adding one means adding it to `SemanticsDiagnostics` and to `AnalyzerReleases.Unshipped.md`; `AnalyzerReleaseTrackingTests` fails if the second step is forgotten. `GeneratorDiagnosticTests` proves each one still fires on the input it is meant to catch. - `availableUnits` order matters: the first entry is treated as the SI base unit by `UnitsGenerator`. From fe57f59db69cb786cbd875dad54de8e679748523 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 12:38:49 +0000 Subject: [PATCH 2/2] Say four rather than five in the migration guide's one-line summary Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7dc6fef1..9f4970c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -313,4 +313,4 @@ This file is the entry point. For deeper material: - `docs/migration-guide-3.0.md` — 2.x → 3.0 upgrade guide (removed first-class .NET type attributes, chord flag enum renames). - `docs/migration-guide-3.1.md` — 3.0 → 3.1 upgrade guide (JSON converter is now opt-in, `PhysicalConstants` domain fields became generic accessors). - `docs/migration-guide-4.0.md` — 3.x → 4.0 upgrade guide (every quantity became a `readonly record struct`). -- `docs/migration-guide-5.0.md` — 4.x → 5.0 upgrade guide (the five dimensionally unkeepable relationships no longer generate operators). +- `docs/migration-guide-5.0.md` — 4.x → 5.0 upgrade guide (the four dimensionally unkeepable relationships no longer generate operators).