Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Semantics.Quantities/Vector0Guards.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Runtime guards used by generated <see cref="IVector0{TSelf, T}"/> quantity types
/// to enforce the non-negativity invariant declared in the unified-vector model.
/// </summary>
/// <remarks>
/// Per the locked design decisions in <c>docs/strategy-unified-vector-quantities.md</c>:
/// <list type="bullet">
/// <item><description>A Vector0 quantity is always non-negative. Construction with a negative value throws <see cref="ArgumentException"/>.</description></item>
/// <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>
/// </list>
/// </remarks>
public static class Vector0Guards
{
/// <summary>
/// Returns <paramref name="value"/> unchanged when it is non-negative; throws
/// <see cref="ArgumentException"/> otherwise. Used in generated <c>From{Unit}</c>
/// factories to enforce the non-negativity invariant on Vector0 quantities.
/// </summary>
/// <typeparam name="T">The numeric storage type.</typeparam>
/// <param name="value">The value (already converted to the SI base unit) to validate.</param>
/// <param name="paramName">Name of the originating parameter, used for the exception message.</param>
/// <returns>The validated, non-negative value.</returns>
/// <exception cref="ArgumentException">When <paramref name="value"/> is negative.</exception>
public static T EnsureNonNegative<T>(T value, string paramName)
where T : struct, INumber<T>
{
if (T.Sign(value) < 0)
{
throw new ArgumentException(
$"Magnitude must be non-negative; received {value}.",
paramName);
}

return value;
}
}
73 changes: 41 additions & 32 deletions Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
public class QuantitiesGenerator : GeneratorBase<DimensionsMetadata>
{
public QuantitiesGenerator() : base("dimensions.json") { }

Check failure on line 24 in Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Enable analyzer release tracking for the analyzer project containing rule 'SEM001' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check failure on line 24 in Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Enable analyzer release tracking for the analyzer project containing rule 'SEM001' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
protected override void Generate(SourceProductionContext context, DimensionsMetadata metadata, CodeBlocker codeBlocker)
{
if (metadata.PhysicalDimensions == null || metadata.PhysicalDimensions.Count == 0)
Expand Down Expand Up @@ -328,7 +328,6 @@
VectorFormDefinition v0 = dim.Quantities.Vector0!;
string typeName = v0.Base;
string fullType = $"{typeName}<T>";
string? v1TypeName = dim.Quantities.Vector1?.Base;

using CodeBlocker cb = CodeBlocker.Create();

Expand Down Expand Up @@ -363,7 +362,9 @@
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];
Expand All @@ -376,36 +377,38 @@
"/// </summary>",
$"/// <param name=\"value\">The value in {firstUnit}.</param>",
$"/// <returns>A new <see cref=\"{typeName}{{T}}\"/> instance.</returns>",
"/// <exception cref=\"System.ArgumentException\">Thrown when the resulting magnitude would be negative.</exception>",
],
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 =
[
"/// <summary>",
$"/// Subtracts two {typeName} values, returning a signed {v1TypeName} result.",
"/// </summary>",
],
Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"],
Keywords = ["public", "static", $"{v1TypeName}<T>"],
Name = "operator -",
Parameters =
[
new ParameterTemplate { Type = fullType, Name = "left" },
new ParameterTemplate { Type = fullType, Name = "right" },
],
BodyFactory = (body) => body.Write($" => {v1TypeName}<T>.Create(left.Quantity - right.Quantity);"),
});
}
Comments =
[
"/// <summary>",
$"/// Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}.",
"/// Magnitude subtraction stays a magnitude (per the unified-vector model).",
"/// </summary>",
],
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);
Expand Down Expand Up @@ -610,7 +613,6 @@
string typeName = overload.Name;
string fullType = $"{typeName}<T>";
string baseFullType = $"{baseTypeName}<T>";
string? v1TypeName = dim.Quantities.Vector1?.Base;

// V0/V1 overloads inherit from PhysicalQuantity
if (vectorForm <= 1)
Expand Down Expand Up @@ -651,17 +653,21 @@
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 = [$"/// <summary>Creates a new {typeName} from a value in {firstUnit}.</summary>"],
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),
});
}

Expand Down Expand Up @@ -695,21 +701,24 @@
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 = [$"/// <summary>Subtracts two {typeName} values, returning a signed {v1TypeName} result.</summary>"],
Comments = [$"/// <summary>Subtracts two {typeName} values, returning the absolute difference as a non-negative {typeName}.</summary>"],
Attributes = ["System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\", Justification = \"Physics quantity operator\")"],
Keywords = ["public", "static", $"{v1TypeName}<T>"],
Keywords = ["public", "static", fullType],
Name = "operator -",
Parameters =
[
new ParameterTemplate { Type = fullType, Name = "left" },
new ParameterTemplate { Type = fullType, Name = "right" },
],
BodyFactory = (body) => body.Write($" => {v1TypeName}<T>.Create(left.Quantity - right.Quantity);"),
BodyFactory = (body) => body.Write(" => Create(T.Abs(left.Quantity - right.Quantity));"),
});
}

Expand Down
188 changes: 188 additions & 0 deletions Semantics.Test/Quantities/Vector0InvariantTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies the Vector0 invariants locked in <c>docs/strategy-unified-vector-quantities.md</c>:
/// <list type="bullet">
/// <item><description>Issue #50: factories reject negative inputs with <see cref="ArgumentException"/>.</description></item>
/// <item><description>Issue #52: V0 - V0 returns the same V0 of <c>T.Abs(a - b)</c>.</description></item>
/// </list>
/// </summary>
[TestClass]
public sealed class Vector0InvariantTests
{
private const double Tolerance = 1e-10;

// =========================================================== #50: Non-negativity

[TestMethod]
public void Speed_FromMetersPerSecond_Negative_Throws()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Speed<double>.FromMetersPerSecond(-1.0));

[TestMethod]
public void Mass_FromKilogram_Negative_Throws()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Mass<double>.FromKilogram(-0.5));

[TestMethod]
public void Length_FromMeter_Negative_Throws()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Length<double>.FromMeter(-3.0));

[TestMethod]
public void Energy_FromJoule_Negative_Throws()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Energy<double>.FromJoule(-100.0));

[TestMethod]
public void Speed_FromMetersPerSecond_Zero_Allowed()
{
Speed<double> s = Speed<double>.FromMetersPerSecond(0.0);
Assert.AreEqual(0.0, s.Value, Tolerance);
}

[TestMethod]
public void Mass_FromKilogram_Positive_Returns_Same_Value()
{
Mass<double> m = Mass<double>.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<ArgumentException>(() => Distance<double>.FromMeter(-1.0));

[TestMethod]
public void Weight_FromNewton_Negative_Throws()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Weight<double>.FromNewton(-9.81));

// V1 quantities are signed and accept any input.

[TestMethod]
public void Velocity1D_FromMetersPerSecond_Negative_Allowed()
{
Velocity1D<double> v = Velocity1D<double>.FromMetersPerSecond(-3.5);
Assert.AreEqual(-3.5, v.Value, Tolerance);
}

[TestMethod]
public void TemperatureDelta_FromKelvin_Negative_Allowed()
{
TemperatureDelta<double> dt = TemperatureDelta<double>.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<ArgumentException>(() => Mass<float>.FromKilogram(-1.0f));

[TestMethod]
public void Mass_FromKilogram_Negative_Throws_With_Decimal()
=> _ = Assert.ThrowsExactly<ArgumentException>(() => Mass<decimal>.FromKilogram(-1m));

// =========================================================== #52: Absolute subtraction

[TestMethod]
public void Mass_Minus_Larger_Mass_Returns_Mass_Of_Absolute_Difference()
{
Mass<double> small = Mass<double>.FromKilogram(3.0);
Mass<double> large = Mass<double>.FromKilogram(5.0);
Mass<double> diff = small - large;
Assert.AreEqual(2.0, diff.Value, Tolerance);
Assert.IsInstanceOfType<Mass<double>>(diff);
}

[TestMethod]
public void Mass_Minus_Smaller_Mass_Returns_Positive_Mass()
{
Mass<double> large = Mass<double>.FromKilogram(5.0);
Mass<double> small = Mass<double>.FromKilogram(3.0);
Mass<double> diff = large - small;
Assert.AreEqual(2.0, diff.Value, Tolerance);
}

[TestMethod]
public void Speed_Minus_Speed_Returns_Speed_Of_Absolute_Difference()
{
Speed<double> a = Speed<double>.FromMetersPerSecond(20.0);
Speed<double> b = Speed<double>.FromMetersPerSecond(50.0);
Speed<double> diff = a - b;
Assert.AreEqual(30.0, diff.Value, Tolerance);
}

[TestMethod]
public void Length_Minus_Length_Returns_Length()
{
Length<double> a = Length<double>.FromMeter(7.0);
Length<double> b = Length<double>.FromMeter(2.0);
Length<double> 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<double> a = Weight<double>.FromNewton(100.0);
Weight<double> b = Weight<double>.FromNewton(150.0);
Weight<double> diff = a - b;
Assert.AreEqual(50.0, diff.Value, Tolerance);
Assert.IsInstanceOfType<Weight<double>>(diff);
}

[TestMethod]
public void Distance_Minus_Distance_Stays_Distance()
{
Distance<double> a = Distance<double>.FromMeter(2.5);
Distance<double> b = Distance<double>.FromMeter(7.5);
Distance<double> diff = a - b;
Assert.AreEqual(5.0, diff.Value, Tolerance);
Assert.IsInstanceOfType<Distance<double>>(diff);
}

// Storage-type genericity for subtraction.

[TestMethod]
public void Mass_Minus_Mass_With_Float_Storage()
{
Mass<float> a = Mass<float>.FromKilogram(1.0f);
Mass<float> b = Mass<float>.FromKilogram(4.0f);
Mass<float> diff = a - b;
Assert.AreEqual(3.0f, diff.Value, 1e-6f);
}

[TestMethod]
public void Mass_Minus_Mass_With_Decimal_Storage()
{
Mass<decimal> a = Mass<decimal>.FromKilogram(1m);
Mass<decimal> b = Mass<decimal>.FromKilogram(4m);
Mass<decimal> 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<ArgumentException>(
() => Vector0Guards.EnsureNonNegative(-1.0, "myParam"));
Assert.AreEqual("myParam", ex.ParamName);
}
}
Loading