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);
+ }
+}