Skip to content

Commit 17a5c24

Browse files
committed
feat(quantities): per-overload physicalConstraints + EnsurePositive guard (closes #51)
Adds an optional `physicalConstraints` block to OverloadDefinition in dimensions.json. When a V0 overload declares `minExclusive: "0"`, its generated From{Unit} factories use the new Vector0Guards.EnsurePositive (which rejects zero and negative inputs) instead of the default EnsureNonNegative (which only rejects negative). The guard runs after the unit conversion to the SI base unit, mirroring the existing #50 invariant — so e.g. Wavelength.FromNanometers(0) correctly throws even though the SI value is exactly zero. Applied today to: - Wavelength (Length V0 overload) — no zero-wavelength wave - Period (Time V0 overload) — no zero-period oscillation - HalfLife (Time V0 overload) — no zero half-life The base types (Length, Duration) and other zero-allowing overloads (Distance, Latency, etc.) keep the V0 default and continue to allow zero. Verified by spot-checking the generated output: public static Wavelength<T> FromMeters(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value))); public static Length<T> FromMeters(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value))); Tests added in Vector0InvariantTests.cs cover zero/positive/negative across Wavelength/Period/HalfLife and the unconstrained Length/Duration/ Distance/Latency baselines, plus direct EnsurePositive helper coverage. CLAUDE.md "Resolved design decisions" §4 updated to mention the overload-level opt-in. Other dimensions/overloads that need stricter bounds in the future can extend the PhysicalConstraints class — the generator currently honours minExclusive == "0"; future fields (maxInclusive, dimension-level constraints, etc.) would need additional generator support.
1 parent f41624b commit 17a5c24

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)