Skip to content

Commit d81c5e0

Browse files
Merge pull request #75 from ktsu-dev/claude/issue-51-strict-positive
feat(quantities): per-overload physicalConstraints + EnsurePositive guard (closes #51)
2 parents fffc635 + 17a5c24 commit d81c5e0

9 files changed

Lines changed: 194 additions & 33 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ These are now baked into the generator and enforced by tests. **Do not reopen wi
5151
1. **`V0 - V0` returns the same `V0` of `T.Abs(a - b)`.** Magnitude subtraction stays non-negative; signed subtraction must use the V1 form explicitly.
5252
2. **Dimensionless and angular quantities have both `Ratio` (V0) and `SignedRatio` (V1) bases.** Ratios that semantically must be non-negative (e.g. `RefractiveIndex`, `MachNumber`, `SpecificGravity`) are V0 overloads of `Ratio`.
5353
3. **Semantic overloads widen implicitly to their base, narrow explicitly from it.** A `Weight` is implicitly a `ForceMagnitude`; the reverse requires `Weight.From(forceMagnitude)` or an explicit cast.
54-
4. **Physical constraints are enforced structurally via the V0 (magnitude) form.** `Vector0` factories run `Vector0Guards.EnsureNonNegative` and throw `ArgumentException` on a negative value. That covers absolute zero (Temperature is V0, so Kelvin must be ≥ 0), non-negative frequency, non-negative absolute pressure, etc. Strict-positive or upper-bound constraints are not yet declared in metadata (tracked separately).
54+
4. **Physical constraints are enforced structurally via the V0 (magnitude) form.** `Vector0` factories run `Vector0Guards.EnsureNonNegative` and throw `ArgumentException` on a negative value. That covers absolute zero (Temperature is V0, so Kelvin must be ≥ 0), non-negative frequency, non-negative absolute pressure, etc. A V0 *overload* can opt into a stricter rule by declaring `physicalConstraints: { "minExclusive": "0" }` in `dimensions.json` (#51); the generator then emits `Vector0Guards.EnsurePositive` and rejects zero too. Used today for `Wavelength`, `Period`, and `HalfLife` — quantities for which zero is unphysical.
5555

5656
### Physical constants
5757

Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/HalfLife.g.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,49 +24,49 @@ public record HalfLife<T> : PhysicalQuantity<HalfLife<T>, T>, IVector0<HalfLife<
2424
/// <param name="value">The value in Second.</param>
2525
/// <returns>A new HalfLife instance.</returns>
2626
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
27-
public static HalfLife<T> FromSeconds(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));
27+
public static HalfLife<T> FromSeconds(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value)));
2828
/// <summary>
2929
/// Creates a new HalfLife from a value in Millisecond.
3030
/// </summary>
3131
/// <param name="value">The value in Millisecond.</param>
3232
/// <returns>A new HalfLife instance.</returns>
3333
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
34-
public static HalfLife<T> FromMilliseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
34+
public static HalfLife<T> FromMilliseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
3535
/// <summary>
3636
/// Creates a new HalfLife from a value in Microsecond.
3737
/// </summary>
3838
/// <param name="value">The value in Microsecond.</param>
3939
/// <returns>A new HalfLife instance.</returns>
4040
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
41-
public static HalfLife<T> FromMicroseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
41+
public static HalfLife<T> FromMicroseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
4242
/// <summary>
4343
/// Creates a new HalfLife from a value in Minute.
4444
/// </summary>
4545
/// <param name="value">The value in Minute.</param>
4646
/// <returns>A new HalfLife instance.</returns>
4747
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
48-
public static HalfLife<T> FromMinutes(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value)));
48+
public static HalfLife<T> FromMinutes(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value)));
4949
/// <summary>
5050
/// Creates a new HalfLife from a value in Hour.
5151
/// </summary>
5252
/// <param name="value">The value in Hour.</param>
5353
/// <returns>A new HalfLife instance.</returns>
5454
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
55-
public static HalfLife<T> FromHours(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value)));
55+
public static HalfLife<T> FromHours(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value)));
5656
/// <summary>
5757
/// Creates a new HalfLife from a value in Day.
5858
/// </summary>
5959
/// <param name="value">The value in Day.</param>
6060
/// <returns>A new HalfLife instance.</returns>
6161
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
62-
public static HalfLife<T> FromDays(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value)));
62+
public static HalfLife<T> FromDays(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value)));
6363
/// <summary>
6464
/// Creates a new HalfLife from a value in Year.
6565
/// </summary>
6666
/// <param name="value">The value in Year.</param>
6767
/// <returns>A new HalfLife instance.</returns>
6868
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
69-
public static HalfLife<T> FromYears(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value)));
69+
public static HalfLife<T> FromYears(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value)));
7070
/// <summary>Implicit conversion to Duration.</summary>
7171
public static implicit operator Duration<T>(HalfLife<T> value) => Duration<T>.Create(value.Value);
7272
/// <summary>Explicit conversion from Duration.</summary>

Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Period.g.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,49 +24,49 @@ public record Period<T> : PhysicalQuantity<Period<T>, T>, IVector0<Period<T>, T>
2424
/// <param name="value">The value in Second.</param>
2525
/// <returns>A new Period instance.</returns>
2626
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
27-
public static Period<T> FromSeconds(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));
27+
public static Period<T> FromSeconds(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value)));
2828
/// <summary>
2929
/// Creates a new Period from a value in Millisecond.
3030
/// </summary>
3131
/// <param name="value">The value in Millisecond.</param>
3232
/// <returns>A new Period instance.</returns>
3333
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
34-
public static Period<T> FromMilliseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
34+
public static Period<T> FromMilliseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
3535
/// <summary>
3636
/// Creates a new Period from a value in Microsecond.
3737
/// </summary>
3838
/// <param name="value">The value in Microsecond.</param>
3939
/// <returns>A new Period instance.</returns>
4040
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
41-
public static Period<T> FromMicroseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
41+
public static Period<T> FromMicroseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
4242
/// <summary>
4343
/// Creates a new Period from a value in Minute.
4444
/// </summary>
4545
/// <param name="value">The value in Minute.</param>
4646
/// <returns>A new Period instance.</returns>
4747
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
48-
public static Period<T> FromMinutes(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value)));
48+
public static Period<T> FromMinutes(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value)));
4949
/// <summary>
5050
/// Creates a new Period from a value in Hour.
5151
/// </summary>
5252
/// <param name="value">The value in Hour.</param>
5353
/// <returns>A new Period instance.</returns>
5454
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
55-
public static Period<T> FromHours(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value)));
55+
public static Period<T> FromHours(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value)));
5656
/// <summary>
5757
/// Creates a new Period from a value in Day.
5858
/// </summary>
5959
/// <param name="value">The value in Day.</param>
6060
/// <returns>A new Period instance.</returns>
6161
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
62-
public static Period<T> FromDays(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value)));
62+
public static Period<T> FromDays(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value)));
6363
/// <summary>
6464
/// Creates a new Period from a value in Year.
6565
/// </summary>
6666
/// <param name="value">The value in Year.</param>
6767
/// <returns>A new Period instance.</returns>
6868
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
69-
public static Period<T> FromYears(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value)));
69+
public static Period<T> FromYears(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value)));
7070
/// <summary>Implicit conversion to Duration.</summary>
7171
public static implicit operator Duration<T>(Period<T> value) => Duration<T>.Create(value.Value);
7272
/// <summary>Explicit conversion from Duration.</summary>

Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Wavelength.g.cs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,77 +24,77 @@ public record Wavelength<T> : PhysicalQuantity<Wavelength<T>, T>, IVector0<Wavel
2424
/// <param name="value">The value in Meter.</param>
2525
/// <returns>A new Wavelength instance.</returns>
2626
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
27-
public static Wavelength<T> FromMeters(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));
27+
public static Wavelength<T> FromMeters(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value)));
2828
/// <summary>
2929
/// Creates a new Wavelength from a value in Kilometer.
3030
/// </summary>
3131
/// <param name="value">The value in Kilometer.</param>
3232
/// <returns>A new Wavelength instance.</returns>
3333
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
34-
public static Wavelength<T> FromKilometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Kilo)), nameof(value)));
34+
public static Wavelength<T> FromKilometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Kilo)), nameof(value)));
3535
/// <summary>
3636
/// Creates a new Wavelength from a value in Centimeter.
3737
/// </summary>
3838
/// <param name="value">The value in Centimeter.</param>
3939
/// <returns>A new Wavelength instance.</returns>
4040
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
41-
public static Wavelength<T> FromCentimeters(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Centi)), nameof(value)));
41+
public static Wavelength<T> FromCentimeters(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Centi)), nameof(value)));
4242
/// <summary>
4343
/// Creates a new Wavelength from a value in Millimeter.
4444
/// </summary>
4545
/// <param name="value">The value in Millimeter.</param>
4646
/// <returns>A new Wavelength instance.</returns>
4747
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
48-
public static Wavelength<T> FromMillimeters(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
48+
public static Wavelength<T> FromMillimeters(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value)));
4949
/// <summary>
5050
/// Creates a new Wavelength from a value in Micrometer.
5151
/// </summary>
5252
/// <param name="value">The value in Micrometer.</param>
5353
/// <returns>A new Wavelength instance.</returns>
5454
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
55-
public static Wavelength<T> FromMicrometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
55+
public static Wavelength<T> FromMicrometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value)));
5656
/// <summary>
5757
/// Creates a new Wavelength from a value in Nanometer.
5858
/// </summary>
5959
/// <param name="value">The value in Nanometer.</param>
6060
/// <returns>A new Wavelength instance.</returns>
6161
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
62-
public static Wavelength<T> FromNanometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Nano)), nameof(value)));
62+
public static Wavelength<T> FromNanometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Nano)), nameof(value)));
6363
/// <summary>
6464
/// Creates a new Wavelength from a value in Angstrom.
6565
/// </summary>
6666
/// <param name="value">The value in Angstrom.</param>
6767
/// <returns>A new Wavelength instance.</returns>
6868
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
69-
public static Wavelength<T> FromAngstroms(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.AngstromToMeters)), nameof(value)));
69+
public static Wavelength<T> FromAngstroms(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.AngstromToMeters)), nameof(value)));
7070
/// <summary>
7171
/// Creates a new Wavelength from a value in Foot.
7272
/// </summary>
7373
/// <param name="value">The value in Foot.</param>
7474
/// <returns>A new Wavelength instance.</returns>
7575
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
76-
public static Wavelength<T> FromFeet(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.FeetToMeters)), nameof(value)));
76+
public static Wavelength<T> FromFeet(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.FeetToMeters)), nameof(value)));
7777
/// <summary>
7878
/// Creates a new Wavelength from a value in Inch.
7979
/// </summary>
8080
/// <param name="value">The value in Inch.</param>
8181
/// <returns>A new Wavelength instance.</returns>
8282
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
83-
public static Wavelength<T> FromInches(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.InchesToMeters)), nameof(value)));
83+
public static Wavelength<T> FromInches(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.InchesToMeters)), nameof(value)));
8484
/// <summary>
8585
/// Creates a new Wavelength from a value in Yard.
8686
/// </summary>
8787
/// <param name="value">The value in Yard.</param>
8888
/// <returns>A new Wavelength instance.</returns>
8989
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
90-
public static Wavelength<T> FromYards(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YardToMeters)), nameof(value)));
90+
public static Wavelength<T> FromYards(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YardToMeters)), nameof(value)));
9191
/// <summary>
9292
/// Creates a new Wavelength from a value in Mile.
9393
/// </summary>
9494
/// <param name="value">The value in Mile.</param>
9595
/// <returns>A new Wavelength instance.</returns>
9696
/// <exception cref="System.ArgumentException">Thrown when the resulting magnitude would be negative.</exception>
97-
public static Wavelength<T> FromMiles(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MileToMeters)), nameof(value)));
97+
public static Wavelength<T> FromMiles(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MileToMeters)), nameof(value)));
9898
/// <summary>Implicit conversion to Length.</summary>
9999
public static implicit operator Length<T>(Wavelength<T> value) => Length<T>.Create(value.Value);
100100
/// <summary>Explicit conversion from Length.</summary>

Semantics.Quantities/Vector0Guards.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,30 @@ public static T EnsureNonNegative<T>(T value, string paramName)
4242

4343
return value;
4444
}
45+
46+
/// <summary>
47+
/// Returns <paramref name="value"/> unchanged when it is strictly positive; throws
48+
/// <see cref="ArgumentException"/> otherwise. Used in generated <c>From{Unit}</c>
49+
/// factories on V0 overloads that declare <c>physicalConstraints.minExclusive: "0"</c>
50+
/// in <c>dimensions.json</c> (per #51). Examples: <c>Wavelength</c>, <c>Period</c>,
51+
/// <c>HalfLife</c> — quantities for which zero is unphysical, distinct from the V0
52+
/// default that allows zero.
53+
/// </summary>
54+
/// <typeparam name="T">The numeric storage type.</typeparam>
55+
/// <param name="value">The value (already converted to the SI base unit) to validate.</param>
56+
/// <param name="paramName">Name of the originating parameter, used for the exception message.</param>
57+
/// <returns>The validated, strictly-positive value.</returns>
58+
/// <exception cref="ArgumentException">When <paramref name="value"/> is zero or negative.</exception>
59+
public static T EnsurePositive<T>(T value, string paramName)
60+
where T : struct, INumber<T>
61+
{
62+
if (T.Sign(value) <= 0)
63+
{
64+
throw new ArgumentException(
65+
$"Value must be strictly positive; received {value}.",
66+
paramName);
67+
}
68+
69+
return value;
70+
}
4571
}

0 commit comments

Comments
 (0)