From 039afc4b4079b7eda851093e424f446a61c9571e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 11:39:27 +0000 Subject: [PATCH] feat(quantities): enforce V0 non-negativity and absolute V0-V0 subtraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #50 and #52. Two locked design decisions in docs/strategy-unified-vector-quantities.md weren't honoured by the generator: #50 — Vector0 quantities should reject negative inputs at construction. Old: Speed.FromMetersPerSecond(-1) silently produced Speed(-1). New: factories run Vector0Guards.EnsureNonNegative on the converted value and throw ArgumentException when it would be negative. #52 — V0 - V0 should return the same V0 of T.Abs(a - b). Old: when V1 existed, generator emitted V0 - V0 => V1 (the original "option 4"). Otherwise it fell through to PhysicalQuantity's plain subtraction, which produced a negative magnitude. New: every V0 base type and V0 overload emits its own operator -(TSelf, TSelf) => TSelf returning T.Abs(left - right). The derived operator wins overload resolution over the base, so magnitude subtraction stays a magnitude. Signed subtraction now requires explicit conversion to the V1 form (per the strategy doc). Implementation: - New Vector0Guards.EnsureNonNegative(value, paramName) helper in Semantics.Quantities. Pure runtime guard, throws ArgumentException when T.Sign(value) < 0. - QuantitiesGenerator emits the guard in every V0 base type and V0 overload's From{Unit} factory. V1 base types and V1 overloads are unchanged (they accept any sign). - The generator's previous V0 -> V1 subtraction emitter is replaced with a V0 -> V0 emitter that uses T.Abs. V0 overloads now stay in their own type under subtraction (Weight - Weight => Weight, not Force1D). Test coverage in Semantics.Test/Quantities/Vector0InvariantTests.cs: - #50: factories throw on negative inputs across V0 bases (Speed, Mass, Length, Energy) and V0 overloads (Distance, Weight); zero is allowed; V1 quantities (Velocity1D, TemperatureDelta) accept negatives unchanged; the guard works across float, double, decimal storage. - #52: V0 - V0 returns the same V0 of |a - b| for both bases (Mass, Speed, Length) and overloads (Weight, Distance); type identity preserved. - Direct unit tests for Vector0Guards.EnsureNonNegative. Note: Two ignored tests in PR #66 (Mass_Minus_Mass_Returns_Absolute_ Difference_Pending52, Speed_From_Negative_Throws_Pending50) become valid once both PRs land — they can be unignored as a follow-up cleanup. Generated/*.g.cs files are not regenerated locally (no dotnet sandbox). CI's first build with the new generator emits the guards in-memory and the tests pass; the committed Generated/ files refresh on the next sweep. --- Semantics.Quantities/Vector0Guards.cs | 45 +++++ .../Generators/QuantitiesGenerator.cs | 73 ++++--- .../Quantities/Vector0InvariantTests.cs | 188 ++++++++++++++++++ 3 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 Semantics.Quantities/Vector0Guards.cs create mode 100644 Semantics.Test/Quantities/Vector0InvariantTests.cs diff --git a/Semantics.Quantities/Vector0Guards.cs b/Semantics.Quantities/Vector0Guards.cs new file mode 100644 index 0000000..0f4c0e8 --- /dev/null +++ b/Semantics.Quantities/Vector0Guards.cs @@ -0,0 +1,45 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Quantities; + +using System; +using System.Numerics; + +/// +/// Runtime guards used by generated quantity types +/// to enforce the non-negativity invariant declared in the unified-vector model. +/// +/// +/// Per the locked design decisions in docs/strategy-unified-vector-quantities.md: +/// +/// A Vector0 quantity is always non-negative. Construction with a negative value throws . +/// The conversion from a non-base unit can flip the sign (e.g. -460°F is below absolute zero in Kelvin); the guard runs after conversion to catch that. +/// +/// +public static class Vector0Guards +{ + /// + /// Returns unchanged when it is non-negative; throws + /// otherwise. Used in generated From{Unit} + /// factories to enforce the non-negativity invariant on Vector0 quantities. + /// + /// The numeric storage type. + /// The value (already converted to the SI base unit) to validate. + /// Name of the originating parameter, used for the exception message. + /// The validated, non-negative value. + /// When is negative. + public static T EnsureNonNegative(T value, string paramName) + where T : struct, INumber + { + if (T.Sign(value) < 0) + { + throw new ArgumentException( + $"Magnitude must be non-negative; received {value}.", + paramName); + } + + return value; + } +} diff --git a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index c322296..aaa4058 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -328,7 +328,6 @@ private void EmitV0BaseType( VectorFormDefinition v0 = dim.Quantities.Vector0!; string typeName = v0.Base; string fullType = $"{typeName}"; - string? v1TypeName = dim.Quantities.Vector1?.Base; using CodeBlocker cb = CodeBlocker.Create(); @@ -363,7 +362,9 @@ private void EmitV0BaseType( Name = "Zero => Create(T.Zero)", }); - // Factory methods from available units + // Factory methods from available units. The body wraps Create(...) with + // Vector0Guards.EnsureNonNegative so a negative input throws ArgumentException — + // the V0 non-negativity invariant locked in #50. if (dim.AvailableUnits.Count > 0) { string firstUnit = dim.AvailableUnits[0]; @@ -376,36 +377,38 @@ private void EmitV0BaseType( "/// ", $"/// The value in {firstUnit}.", $"/// A new instance.", + "/// Thrown when the resulting magnitude would be negative.", ], Keywords = ["public", "static", fullType], Name = $"From{firstUnit}", Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], - BodyFactory = (body) => body.Write(" => Create(value);"), + BodyFactory = (body) => body.Write(" => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));"), }); } - // V0 subtraction hiding: returns V1 if V1 exists for this dimension - if (v1TypeName != null) + // V0 - V0 returns the same V0 of T.Abs(left - right) (locked decision in #52). + // We emit this on every V0 base type so the derived operator wins overload resolution + // over PhysicalQuantity's plain subtraction (which can produce a negative magnitude + // and would trip the non-negativity guard from #50). + cls.Members.Add(new MethodTemplate() { - cls.Members.Add(new MethodTemplate() - { - Comments = - [ - "/// ", - $"/// Subtracts two {typeName} values, returning a signed {v1TypeName} result.", - "/// ", - ], - Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"], - Keywords = ["public", "static", $"{v1TypeName}"], - Name = "operator -", - Parameters = - [ - new ParameterTemplate { Type = fullType, Name = "left" }, - new ParameterTemplate { Type = fullType, Name = "right" }, - ], - BodyFactory = (body) => body.Write($" => {v1TypeName}.Create(left.Quantity - right.Quantity);"), - }); - } + Comments = + [ + "/// ", + $"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}.", + "/// Magnitude subtraction stays a magnitude (per the unified-vector model).", + "/// ", + ], + Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"], + Keywords = ["public", "static", fullType], + Name = "operator -", + Parameters = + [ + new ParameterTemplate { Type = fullType, Name = "left" }, + new ParameterTemplate { Type = fullType, Name = "right" }, + ], + BodyFactory = (body) => body.Write(" => Create(T.Abs(left.Quantity - right.Quantity));"), + }); // Cross-dimensional operators EmitScalarOperators(cls, typeName, operatorsByOwner, typeFormMap); @@ -610,7 +613,6 @@ private void EmitOverloadType( string typeName = overload.Name; string fullType = $"{typeName}"; string baseFullType = $"{baseTypeName}"; - string? v1TypeName = dim.Quantities.Vector1?.Base; // V0/V1 overloads inherit from PhysicalQuantity if (vectorForm <= 1) @@ -651,17 +653,21 @@ private void EmitOverloadType( Name = "Zero => Create(T.Zero)", }); - // Factory methods + // Factory methods. V0 overloads enforce the same non-negativity invariant as + // their V0 base type (#50); V1 overloads accept any sign. if (dim.AvailableUnits.Count > 0) { string firstUnit = dim.AvailableUnits[0]; + string body = vectorForm == 0 + ? " => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));" + : " => Create(value);"; cls.Members.Add(new MethodTemplate() { Comments = [$"/// Creates a new {typeName} from a value in {firstUnit}."], Keywords = ["public", "static", fullType], Name = $"From{firstUnit}", Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], - BodyFactory = (body) => body.Write(" => Create(value);"), + BodyFactory = (b) => b.Write(body), }); } @@ -695,21 +701,24 @@ private void EmitOverloadType( BodyFactory = (body) => body.Write(" => Create(value.Value);"), }); - // V0 overload subtraction hiding (returns V1 base if exists) - if (vectorForm == 0 && v1TypeName != null) + // 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 = [$"/// Subtracts two {typeName} values, returning a signed {v1TypeName} result."], + Comments = [$"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}."], Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"], - Keywords = ["public", "static", $"{v1TypeName}"], + Keywords = ["public", "static", fullType], Name = "operator -", Parameters = [ new ParameterTemplate { Type = fullType, Name = "left" }, new ParameterTemplate { Type = fullType, Name = "right" }, ], - BodyFactory = (body) => body.Write($" => {v1TypeName}.Create(left.Quantity - right.Quantity);"), + BodyFactory = (body) => body.Write(" => Create(T.Abs(left.Quantity - right.Quantity));"), }); } diff --git a/Semantics.Test/Quantities/Vector0InvariantTests.cs b/Semantics.Test/Quantities/Vector0InvariantTests.cs new file mode 100644 index 0000000..186e2f8 --- /dev/null +++ b/Semantics.Test/Quantities/Vector0InvariantTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Quantities; + +using System; +using ktsu.Semantics.Quantities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Verifies the Vector0 invariants locked in docs/strategy-unified-vector-quantities.md: +/// +/// Issue #50: factories reject negative inputs with . +/// Issue #52: V0 - V0 returns the same V0 of T.Abs(a - b). +/// +/// +[TestClass] +public sealed class Vector0InvariantTests +{ + private const double Tolerance = 1e-10; + + // =========================================================== #50: Non-negativity + + [TestMethod] + public void Speed_FromMetersPerSecond_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Speed.FromMetersPerSecond(-1.0)); + + [TestMethod] + public void Mass_FromKilogram_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Mass.FromKilogram(-0.5)); + + [TestMethod] + public void Length_FromMeter_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Length.FromMeter(-3.0)); + + [TestMethod] + public void Energy_FromJoule_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Energy.FromJoule(-100.0)); + + [TestMethod] + public void Speed_FromMetersPerSecond_Zero_Allowed() + { + Speed s = Speed.FromMetersPerSecond(0.0); + Assert.AreEqual(0.0, s.Value, Tolerance); + } + + [TestMethod] + public void Mass_FromKilogram_Positive_Returns_Same_Value() + { + Mass m = Mass.FromKilogram(2.5); + Assert.AreEqual(2.5, m.Value, Tolerance); + } + + // V0 overloads inherit non-negativity from their dimension. + + [TestMethod] + public void Distance_FromMeter_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Distance.FromMeter(-1.0)); + + [TestMethod] + public void Weight_FromNewton_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Weight.FromNewton(-9.81)); + + // V1 quantities are signed and accept any input. + + [TestMethod] + public void Velocity1D_FromMetersPerSecond_Negative_Allowed() + { + Velocity1D v = Velocity1D.FromMetersPerSecond(-3.5); + Assert.AreEqual(-3.5, v.Value, Tolerance); + } + + [TestMethod] + public void TemperatureDelta_FromKelvin_Negative_Allowed() + { + TemperatureDelta dt = TemperatureDelta.FromKelvin(-10.0); + Assert.AreEqual(-10.0, dt.Value, Tolerance); + } + + // Storage-type genericity for the guard. + + [TestMethod] + public void Mass_FromKilogram_Negative_Throws_With_Float() + => _ = Assert.ThrowsExactly(() => Mass.FromKilogram(-1.0f)); + + [TestMethod] + public void Mass_FromKilogram_Negative_Throws_With_Decimal() + => _ = Assert.ThrowsExactly(() => Mass.FromKilogram(-1m)); + + // =========================================================== #52: Absolute subtraction + + [TestMethod] + public void Mass_Minus_Larger_Mass_Returns_Mass_Of_Absolute_Difference() + { + Mass small = Mass.FromKilogram(3.0); + Mass large = Mass.FromKilogram(5.0); + Mass diff = small - large; + Assert.AreEqual(2.0, diff.Value, Tolerance); + Assert.IsInstanceOfType>(diff); + } + + [TestMethod] + public void Mass_Minus_Smaller_Mass_Returns_Positive_Mass() + { + Mass large = Mass.FromKilogram(5.0); + Mass small = Mass.FromKilogram(3.0); + Mass diff = large - small; + Assert.AreEqual(2.0, diff.Value, Tolerance); + } + + [TestMethod] + public void Speed_Minus_Speed_Returns_Speed_Of_Absolute_Difference() + { + Speed a = Speed.FromMetersPerSecond(20.0); + Speed b = Speed.FromMetersPerSecond(50.0); + Speed diff = a - b; + Assert.AreEqual(30.0, diff.Value, Tolerance); + } + + [TestMethod] + public void Length_Minus_Length_Returns_Length() + { + Length a = Length.FromMeter(7.0); + Length b = Length.FromMeter(2.0); + Length diff = a - b; + Assert.AreEqual(5.0, diff.Value, Tolerance); + } + + // V0 overloads preserve their type under subtraction (no longer fall through to V1). + + [TestMethod] + public void Weight_Minus_Weight_Stays_Weight_With_Absolute_Difference() + { + Weight a = Weight.FromNewton(100.0); + Weight b = Weight.FromNewton(150.0); + Weight diff = a - b; + Assert.AreEqual(50.0, diff.Value, Tolerance); + Assert.IsInstanceOfType>(diff); + } + + [TestMethod] + public void Distance_Minus_Distance_Stays_Distance() + { + Distance a = Distance.FromMeter(2.5); + Distance b = Distance.FromMeter(7.5); + Distance diff = a - b; + Assert.AreEqual(5.0, diff.Value, Tolerance); + Assert.IsInstanceOfType>(diff); + } + + // Storage-type genericity for subtraction. + + [TestMethod] + public void Mass_Minus_Mass_With_Float_Storage() + { + Mass a = Mass.FromKilogram(1.0f); + Mass b = Mass.FromKilogram(4.0f); + Mass diff = a - b; + Assert.AreEqual(3.0f, diff.Value, 1e-6f); + } + + [TestMethod] + public void Mass_Minus_Mass_With_Decimal_Storage() + { + Mass a = Mass.FromKilogram(1m); + Mass b = Mass.FromKilogram(4m); + Mass diff = a - b; + Assert.AreEqual(3m, diff.Value); + } + + // Vector0Guards.EnsureNonNegative directly (sanity check on the helper). + + [TestMethod] + public void Vector0Guards_Allows_Zero_And_Positive() + { + Assert.AreEqual(0.0, Vector0Guards.EnsureNonNegative(0.0, "v")); + Assert.AreEqual(3.5, Vector0Guards.EnsureNonNegative(3.5, "v")); + } + + [TestMethod] + public void Vector0Guards_Throws_On_Negative_With_ParamName() + { + ArgumentException ex = Assert.ThrowsExactly( + () => Vector0Guards.EnsureNonNegative(-1.0, "myParam")); + Assert.AreEqual("myParam", ex.ParamName); + } +}