Skip to content

Commit 0ee52bd

Browse files
Merge pull request #68 from ktsu-dev/work/issue-50-52
feat(quantities): enforce V0 non-negativity and absolute V0-V0 subtraction (closes #50, #52)
2 parents 7440cfb + 039afc4 commit 0ee52bd

3 files changed

Lines changed: 274 additions & 32 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Quantities;
6+
7+
using System;
8+
using System.Numerics;
9+
10+
/// <summary>
11+
/// Runtime guards used by generated <see cref="IVector0{TSelf, T}"/> quantity types
12+
/// to enforce the non-negativity invariant declared in the unified-vector model.
13+
/// </summary>
14+
/// <remarks>
15+
/// Per the locked design decisions in <c>docs/strategy-unified-vector-quantities.md</c>:
16+
/// <list type="bullet">
17+
/// <item><description>A Vector0 quantity is always non-negative. Construction with a negative value throws <see cref="ArgumentException"/>.</description></item>
18+
/// <item><description>The conversion from a non-base unit can flip the sign (e.g. -460&#176;F is below absolute zero in Kelvin); the guard runs after conversion to catch that.</description></item>
19+
/// </list>
20+
/// </remarks>
21+
public static class Vector0Guards
22+
{
23+
/// <summary>
24+
/// Returns <paramref name="value"/> unchanged when it is non-negative; throws
25+
/// <see cref="ArgumentException"/> otherwise. Used in generated <c>From{Unit}</c>
26+
/// factories to enforce the non-negativity invariant on Vector0 quantities.
27+
/// </summary>
28+
/// <typeparam name="T">The numeric storage type.</typeparam>
29+
/// <param name="value">The value (already converted to the SI base unit) to validate.</param>
30+
/// <param name="paramName">Name of the originating parameter, used for the exception message.</param>
31+
/// <returns>The validated, non-negative value.</returns>
32+
/// <exception cref="ArgumentException">When <paramref name="value"/> is negative.</exception>
33+
public static T EnsureNonNegative<T>(T value, string paramName)
34+
where T : struct, INumber<T>
35+
{
36+
if (T.Sign(value) < 0)
37+
{
38+
throw new ArgumentException(
39+
$"Magnitude must be non-negative; received {value}.",
40+
paramName);
41+
}
42+
43+
return value;
44+
}
45+
}

Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,6 @@ private void EmitV0BaseType(
354354
VectorFormDefinition v0 = dim.Quantities.Vector0!;
355355
string typeName = v0.Base;
356356
string fullType = $"{typeName}<T>";
357-
string? v1TypeName = dim.Quantities.Vector1?.Base;
358357

359358
using CodeBlocker cb = CodeBlocker.Create();
360359

@@ -389,7 +388,9 @@ private void EmitV0BaseType(
389388
Name = "Zero => Create(T.Zero)",
390389
});
391390

392-
// Factory methods from available units
391+
// Factory methods from available units. The body wraps Create(...) with
392+
// Vector0Guards.EnsureNonNegative so a negative input throws ArgumentException —
393+
// the V0 non-negativity invariant locked in #50.
393394
if (dim.AvailableUnits.Count > 0)
394395
{
395396
string firstUnit = dim.AvailableUnits[0];
@@ -402,36 +403,38 @@ private void EmitV0BaseType(
402403
"/// </summary>",
403404
$"/// <param name=\"value\">The value in {firstUnit}.</param>",
404405
$"/// <returns>A new <see cref=\"{typeName}{{T}}\"/> instance.</returns>",
406+
"/// <exception cref=\"System.ArgumentException\">Thrown when the resulting magnitude would be negative.</exception>",
405407
],
406408
Keywords = ["public", "static", fullType],
407409
Name = $"From{firstUnit}",
408410
Parameters = [new ParameterTemplate { Type = "T", Name = "value" }],
409-
BodyFactory = (body) => body.Write(" => Create(value);"),
411+
BodyFactory = (body) => body.Write(" => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));"),
410412
});
411413
}
412414

413-
// V0 subtraction hiding: returns V1 if V1 exists for this dimension
414-
if (v1TypeName != null)
415+
// V0 - V0 returns the same V0 of T.Abs(left - right) (locked decision in #52).
416+
// We emit this on every V0 base type so the derived operator wins overload resolution
417+
// over PhysicalQuantity's plain subtraction (which can produce a negative magnitude
418+
// and would trip the non-negativity guard from #50).
419+
cls.Members.Add(new MethodTemplate()
415420
{
416-
cls.Members.Add(new MethodTemplate()
417-
{
418-
Comments =
419-
[
420-
"/// <summary>",
421-
$"/// Subtracts two {typeName} values, returning a signed {v1TypeName} result.",
422-
"/// </summary>",
423-
],
424-
Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"],
425-
Keywords = ["public", "static", $"{v1TypeName}<T>"],
426-
Name = "operator -",
427-
Parameters =
428-
[
429-
new ParameterTemplate { Type = fullType, Name = "left" },
430-
new ParameterTemplate { Type = fullType, Name = "right" },
431-
],
432-
BodyFactory = (body) => body.Write($" => {v1TypeName}<T>.Create(left.Quantity - right.Quantity);"),
433-
});
434-
}
421+
Comments =
422+
[
423+
"/// <summary>",
424+
$"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}.",
425+
"/// Magnitude subtraction stays a magnitude (per the unified-vector model).",
426+
"/// </summary>",
427+
],
428+
Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"],
429+
Keywords = ["public", "static", fullType],
430+
Name = "operator -",
431+
Parameters =
432+
[
433+
new ParameterTemplate { Type = fullType, Name = "left" },
434+
new ParameterTemplate { Type = fullType, Name = "right" },
435+
],
436+
BodyFactory = (body) => body.Write(" => Create(T.Abs(left.Quantity - right.Quantity));"),
437+
});
435438

436439
// Cross-dimensional operators
437440
EmitScalarOperators(cls, typeName, operatorsByOwner, typeFormMap);
@@ -636,7 +639,6 @@ private void EmitOverloadType(
636639
string typeName = overload.Name;
637640
string fullType = $"{typeName}<T>";
638641
string baseFullType = $"{baseTypeName}<T>";
639-
string? v1TypeName = dim.Quantities.Vector1?.Base;
640642

641643
// V0/V1 overloads inherit from PhysicalQuantity
642644
if (vectorForm <= 1)
@@ -677,17 +679,21 @@ private void EmitOverloadType(
677679
Name = "Zero => Create(T.Zero)",
678680
});
679681

680-
// Factory methods
682+
// Factory methods. V0 overloads enforce the same non-negativity invariant as
683+
// their V0 base type (#50); V1 overloads accept any sign.
681684
if (dim.AvailableUnits.Count > 0)
682685
{
683686
string firstUnit = dim.AvailableUnits[0];
687+
string body = vectorForm == 0
688+
? " => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));"
689+
: " => Create(value);";
684690
cls.Members.Add(new MethodTemplate()
685691
{
686692
Comments = [$"/// <summary>Creates a new {typeName} from a value in {firstUnit}.</summary>"],
687693
Keywords = ["public", "static", fullType],
688694
Name = $"From{firstUnit}",
689695
Parameters = [new ParameterTemplate { Type = "T", Name = "value" }],
690-
BodyFactory = (body) => body.Write(" => Create(value);"),
696+
BodyFactory = (b) => b.Write(body),
691697
});
692698
}
693699

@@ -721,21 +727,24 @@ private void EmitOverloadType(
721727
BodyFactory = (body) => body.Write(" => Create(value.Value);"),
722728
});
723729

724-
// V0 overload subtraction hiding (returns V1 base if exists)
725-
if (vectorForm == 0 && v1TypeName != null)
730+
// V0 overload subtraction returns the same V0 of T.Abs(left - right) (locked
731+
// in #52). The overload-typed operator hides the base PhysicalQuantity's plain
732+
// subtraction so overloads stay in their own type and the magnitude invariant
733+
// is preserved.
734+
if (vectorForm == 0)
726735
{
727736
cls.Members.Add(new MethodTemplate()
728737
{
729-
Comments = [$"/// <summary>Subtracts two {typeName} values, returning a signed {v1TypeName} result.</summary>"],
738+
Comments = [$"/// <summary>Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}.</summary>"],
730739
Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"],
731-
Keywords = ["public", "static", $"{v1TypeName}<T>"],
740+
Keywords = ["public", "static", fullType],
732741
Name = "operator -",
733742
Parameters =
734743
[
735744
new ParameterTemplate { Type = fullType, Name = "left" },
736745
new ParameterTemplate { Type = fullType, Name = "right" },
737746
],
738-
BodyFactory = (body) => body.Write($" => {v1TypeName}<T>.Create(left.Quantity - right.Quantity);"),
747+
BodyFactory = (body) => body.Write(" => Create(T.Abs(left.Quantity - right.Quantity));"),
739748
});
740749
}
741750

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Test.Quantities;
6+
7+
using System;
8+
using ktsu.Semantics.Quantities;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
/// <summary>
12+
/// Verifies the Vector0 invariants locked in <c>docs/strategy-unified-vector-quantities.md</c>:
13+
/// <list type="bullet">
14+
/// <item><description>Issue #50: factories reject negative inputs with <see cref="ArgumentException"/>.</description></item>
15+
/// <item><description>Issue #52: V0 - V0 returns the same V0 of <c>T.Abs(a - b)</c>.</description></item>
16+
/// </list>
17+
/// </summary>
18+
[TestClass]
19+
public sealed class Vector0InvariantTests
20+
{
21+
private const double Tolerance = 1e-10;
22+
23+
// =========================================================== #50: Non-negativity
24+
25+
[TestMethod]
26+
public void Speed_FromMetersPerSecond_Negative_Throws()
27+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Speed<double>.FromMetersPerSecond(-1.0));
28+
29+
[TestMethod]
30+
public void Mass_FromKilogram_Negative_Throws()
31+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Mass<double>.FromKilogram(-0.5));
32+
33+
[TestMethod]
34+
public void Length_FromMeter_Negative_Throws()
35+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Length<double>.FromMeter(-3.0));
36+
37+
[TestMethod]
38+
public void Energy_FromJoule_Negative_Throws()
39+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Energy<double>.FromJoule(-100.0));
40+
41+
[TestMethod]
42+
public void Speed_FromMetersPerSecond_Zero_Allowed()
43+
{
44+
Speed<double> s = Speed<double>.FromMetersPerSecond(0.0);
45+
Assert.AreEqual(0.0, s.Value, Tolerance);
46+
}
47+
48+
[TestMethod]
49+
public void Mass_FromKilogram_Positive_Returns_Same_Value()
50+
{
51+
Mass<double> m = Mass<double>.FromKilogram(2.5);
52+
Assert.AreEqual(2.5, m.Value, Tolerance);
53+
}
54+
55+
// V0 overloads inherit non-negativity from their dimension.
56+
57+
[TestMethod]
58+
public void Distance_FromMeter_Negative_Throws()
59+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Distance<double>.FromMeter(-1.0));
60+
61+
[TestMethod]
62+
public void Weight_FromNewton_Negative_Throws()
63+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Weight<double>.FromNewton(-9.81));
64+
65+
// V1 quantities are signed and accept any input.
66+
67+
[TestMethod]
68+
public void Velocity1D_FromMetersPerSecond_Negative_Allowed()
69+
{
70+
Velocity1D<double> v = Velocity1D<double>.FromMetersPerSecond(-3.5);
71+
Assert.AreEqual(-3.5, v.Value, Tolerance);
72+
}
73+
74+
[TestMethod]
75+
public void TemperatureDelta_FromKelvin_Negative_Allowed()
76+
{
77+
TemperatureDelta<double> dt = TemperatureDelta<double>.FromKelvin(-10.0);
78+
Assert.AreEqual(-10.0, dt.Value, Tolerance);
79+
}
80+
81+
// Storage-type genericity for the guard.
82+
83+
[TestMethod]
84+
public void Mass_FromKilogram_Negative_Throws_With_Float()
85+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Mass<float>.FromKilogram(-1.0f));
86+
87+
[TestMethod]
88+
public void Mass_FromKilogram_Negative_Throws_With_Decimal()
89+
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Mass<decimal>.FromKilogram(-1m));
90+
91+
// =========================================================== #52: Absolute subtraction
92+
93+
[TestMethod]
94+
public void Mass_Minus_Larger_Mass_Returns_Mass_Of_Absolute_Difference()
95+
{
96+
Mass<double> small = Mass<double>.FromKilogram(3.0);
97+
Mass<double> large = Mass<double>.FromKilogram(5.0);
98+
Mass<double> diff = small - large;
99+
Assert.AreEqual(2.0, diff.Value, Tolerance);
100+
Assert.IsInstanceOfType<Mass<double>>(diff);
101+
}
102+
103+
[TestMethod]
104+
public void Mass_Minus_Smaller_Mass_Returns_Positive_Mass()
105+
{
106+
Mass<double> large = Mass<double>.FromKilogram(5.0);
107+
Mass<double> small = Mass<double>.FromKilogram(3.0);
108+
Mass<double> diff = large - small;
109+
Assert.AreEqual(2.0, diff.Value, Tolerance);
110+
}
111+
112+
[TestMethod]
113+
public void Speed_Minus_Speed_Returns_Speed_Of_Absolute_Difference()
114+
{
115+
Speed<double> a = Speed<double>.FromMetersPerSecond(20.0);
116+
Speed<double> b = Speed<double>.FromMetersPerSecond(50.0);
117+
Speed<double> diff = a - b;
118+
Assert.AreEqual(30.0, diff.Value, Tolerance);
119+
}
120+
121+
[TestMethod]
122+
public void Length_Minus_Length_Returns_Length()
123+
{
124+
Length<double> a = Length<double>.FromMeter(7.0);
125+
Length<double> b = Length<double>.FromMeter(2.0);
126+
Length<double> diff = a - b;
127+
Assert.AreEqual(5.0, diff.Value, Tolerance);
128+
}
129+
130+
// V0 overloads preserve their type under subtraction (no longer fall through to V1).
131+
132+
[TestMethod]
133+
public void Weight_Minus_Weight_Stays_Weight_With_Absolute_Difference()
134+
{
135+
Weight<double> a = Weight<double>.FromNewton(100.0);
136+
Weight<double> b = Weight<double>.FromNewton(150.0);
137+
Weight<double> diff = a - b;
138+
Assert.AreEqual(50.0, diff.Value, Tolerance);
139+
Assert.IsInstanceOfType<Weight<double>>(diff);
140+
}
141+
142+
[TestMethod]
143+
public void Distance_Minus_Distance_Stays_Distance()
144+
{
145+
Distance<double> a = Distance<double>.FromMeter(2.5);
146+
Distance<double> b = Distance<double>.FromMeter(7.5);
147+
Distance<double> diff = a - b;
148+
Assert.AreEqual(5.0, diff.Value, Tolerance);
149+
Assert.IsInstanceOfType<Distance<double>>(diff);
150+
}
151+
152+
// Storage-type genericity for subtraction.
153+
154+
[TestMethod]
155+
public void Mass_Minus_Mass_With_Float_Storage()
156+
{
157+
Mass<float> a = Mass<float>.FromKilogram(1.0f);
158+
Mass<float> b = Mass<float>.FromKilogram(4.0f);
159+
Mass<float> diff = a - b;
160+
Assert.AreEqual(3.0f, diff.Value, 1e-6f);
161+
}
162+
163+
[TestMethod]
164+
public void Mass_Minus_Mass_With_Decimal_Storage()
165+
{
166+
Mass<decimal> a = Mass<decimal>.FromKilogram(1m);
167+
Mass<decimal> b = Mass<decimal>.FromKilogram(4m);
168+
Mass<decimal> diff = a - b;
169+
Assert.AreEqual(3m, diff.Value);
170+
}
171+
172+
// Vector0Guards.EnsureNonNegative directly (sanity check on the helper).
173+
174+
[TestMethod]
175+
public void Vector0Guards_Allows_Zero_And_Positive()
176+
{
177+
Assert.AreEqual(0.0, Vector0Guards.EnsureNonNegative(0.0, "v"));
178+
Assert.AreEqual(3.5, Vector0Guards.EnsureNonNegative(3.5, "v"));
179+
}
180+
181+
[TestMethod]
182+
public void Vector0Guards_Throws_On_Negative_With_ParamName()
183+
{
184+
ArgumentException ex = Assert.ThrowsExactly<ArgumentException>(
185+
() => Vector0Guards.EnsureNonNegative(-1.0, "myParam"));
186+
Assert.AreEqual("myParam", ex.ParamName);
187+
}
188+
}

0 commit comments

Comments
 (0)