diff --git a/CLAUDE.md b/CLAUDE.md index 5166473..9f4970c 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. Four are refused as the metadata stands. -The check itself now lives in `Semantics.Vocabulary` and both generators run it. C++ refuses the -four; C# reports them as SEM008 and emits the operators anyway, because removing an operator from a -shipped package is a breaking change. 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 | |---|---| @@ -289,7 +297,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 — which is what found that bug, and it is now fixed. The operator is **still emitted** for the four that remain, because dropping one would be a breaking change to a shipped package. Suppressed in `Semantics.Quantities.csproj` because ktsu.Sdk builds warnings as errors and those four are outstanding; `UnkeepableRelationshipTests` pins the set so a fifth 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 — which is what found that bug, and it is now fixed. **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 four below are outstanding; `UnkeepableRelationshipTests` pins the set, and asserts that none of them is in the compiled surface, so a fifth 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. @@ -304,3 +312,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 four dimensionally unkeepable relationships no longer generate operators). diff --git a/Semantics.Cpp/MetadataProjection.cs b/Semantics.Cpp/MetadataProjection.cs index 1944e9d..97b1dc1 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 c241248..b7485ca 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 4083a9c..5528dd5 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 c34a652..7443c16 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 e6e8cd1..e4e3aba 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 b27a322..2765923 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 fec7582..f1d9d84 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 62d7650..b9fd1d5 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,7 +110,4 @@ 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)); - }; 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 13f3e39..4d3f574 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 26ccccd..604c4b3 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/TorqueMagnitude.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/TorqueMagnitude.g.cs index 4c55247..545ec02 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/Semantics.Quantities.csproj b/Semantics.Quantities/Semantics.Quantities.csproj index 86bfcbf..91da7a7 100644 --- a/Semantics.Quantities/Semantics.Quantities.csproj +++ b/Semantics.Quantities/Semantics.Quantities.csproj @@ -4,15 +4,17 @@ net10.0;net9.0;net8.0 + UnkeepableRelationshipTests pins the set to exactly those four — and asserts that none of + them is in the compiled surface — so a fifth 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 ec3ea3b..bf46291 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 a9297b4..6f575a7 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 5718abf..c3bd29b 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 8123e80..b788a02 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/UnkeepableRelationshipTests.cs b/Semantics.Test/Quantities/UnkeepableRelationshipTests.cs index ed09add..4381fc0 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,10 +18,11 @@ 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 four 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, and one needs a vector1 form on Energy. +/// None of the four 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 four 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, and one needs a vector1 form on Energy. /// /// There were five. Sensitivity * Pressure -> ElectricPotential was the one unrelated to /// angle, and it is fixed: the dimension said amperes per newton while the unit beside it said @@ -30,7 +33,8 @@ namespace ktsu.Semantics.Test.Quantities; /// 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 @@ -113,6 +117,83 @@ public void TheSensitivityRelationshipIsNoLongerRefused() Vocabulary().Refused.Select(refused => refused.Subject).ToArray()); } + /// + /// None of the four 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(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."); + + // The counterpart, so this says what is gone rather than only that something is: the cross + // product between the same two is dimensionally true and is still generated, now from + // Length where r x F puts it. + Assert.IsTrue( + typeof(Displacement3D).GetMethod( + "Cross", + BindingFlags.Public | BindingFlags.Instance, + binder: null, + [typeof(Force3D)], + modifiers: null) is not null, + "cross(Length, Force) -> Torque is keepable and should still be generated."); + } + + /// + /// 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 e8ba618..18f2805 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 b18c2c3..588ee6e 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 0000000..1eb2aeb --- /dev/null +++ b/docs/migration-guide-5.0.md @@ -0,0 +1,132 @@ +# Migrating from Semantics 4.x to 5.0 + +Semantics 5.0 removes twelve 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 quantity, unit, factory, constant or +conversion changed, and nothing was renamed. + +## 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 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. 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. That is what found the `Sensitivity` bug, since fixed in +4.3.1. + +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 three products 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. + +## What was removed + +Three products, each in the four spellings C# generated for it — the declared direction, +its commutation, and the two divisions that undo it: + +| Removed | Why | +|---|---| +| `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 `Displacement3D.Cross(Force3D)`. + +## 1. 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. + +## 2. 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. +- `Sensitivity * Pressure -> VoltageMagnitude` is **not** affected. It was refused + in 4.3 and is the reason `SEM008` exists, but the metadata bug behind it — `Sensitivity` + declared as amperes per newton while the unit beside it said `VoltPerPascal` — was fixed + in 4.3.1. The relationship is dimensionally true now and the operator is generated. +- `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 four and asserts that none of + them is in the compiled surface, so a fifth 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 753c5e3..af44b8e 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`.