From 17a5c24f13ad5510c3ce49bb5be0ea7fb84f52fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 13:33:30 +0000 Subject: [PATCH] feat(quantities): per-overload physicalConstraints + EnsurePositive guard (closes #51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 FromMeters(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value))); public static Length 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. --- CLAUDE.md | 2 +- .../HalfLife.g.cs | 14 +-- .../Period.g.cs | 14 +-- .../Wavelength.g.cs | 22 ++--- Semantics.Quantities/Vector0Guards.cs | 26 +++++ .../Generators/QuantitiesGenerator.cs | 21 +++- .../Metadata/dimensions.json | 6 +- .../Models/DimensionsMetadata.cs | 24 +++++ .../Quantities/Vector0InvariantTests.cs | 98 +++++++++++++++++++ 9 files changed, 194 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1634540..1dd806c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ These are now baked into the generator and enforced by tests. **Do not reopen wi 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. 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`. 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. -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). +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. ### Physical constants diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/HalfLife.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/HalfLife.g.cs index 508f00c..9b41aa3 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/HalfLife.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/HalfLife.g.cs @@ -24,49 +24,49 @@ public record HalfLife : PhysicalQuantity, T>, IVector0The value in Second. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromSeconds(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value))); + public static HalfLife FromSeconds(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value))); /// /// Creates a new HalfLife from a value in Millisecond. /// /// The value in Millisecond. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromMilliseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); + public static HalfLife FromMilliseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); /// /// Creates a new HalfLife from a value in Microsecond. /// /// The value in Microsecond. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromMicroseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); + public static HalfLife FromMicroseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); /// /// Creates a new HalfLife from a value in Minute. /// /// The value in Minute. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromMinutes(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value))); + public static HalfLife FromMinutes(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value))); /// /// Creates a new HalfLife from a value in Hour. /// /// The value in Hour. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromHours(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value))); + public static HalfLife FromHours(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value))); /// /// Creates a new HalfLife from a value in Day. /// /// The value in Day. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromDays(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value))); + public static HalfLife FromDays(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value))); /// /// Creates a new HalfLife from a value in Year. /// /// The value in Year. /// A new HalfLife instance. /// Thrown when the resulting magnitude would be negative. - public static HalfLife FromYears(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value))); + public static HalfLife FromYears(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value))); /// Implicit conversion to Duration. public static implicit operator Duration(HalfLife value) => Duration.Create(value.Value); /// Explicit conversion from Duration. diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Period.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Period.g.cs index 601bf0e..bf4b7fd 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Period.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Period.g.cs @@ -24,49 +24,49 @@ public record Period : PhysicalQuantity, T>, IVector0, T> /// The value in Second. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromSeconds(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value))); + public static Period FromSeconds(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value))); /// /// Creates a new Period from a value in Millisecond. /// /// The value in Millisecond. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromMilliseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); + public static Period FromMilliseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); /// /// Creates a new Period from a value in Microsecond. /// /// The value in Microsecond. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromMicroseconds(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); + public static Period FromMicroseconds(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); /// /// Creates a new Period from a value in Minute. /// /// The value in Minute. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromMinutes(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value))); + public static Period FromMinutes(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MinuteToSeconds)), nameof(value))); /// /// Creates a new Period from a value in Hour. /// /// The value in Hour. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromHours(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value))); + public static Period FromHours(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.HourToSeconds)), nameof(value))); /// /// Creates a new Period from a value in Day. /// /// The value in Day. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromDays(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value))); + public static Period FromDays(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.DayToSeconds)), nameof(value))); /// /// Creates a new Period from a value in Year. /// /// The value in Year. /// A new Period instance. /// Thrown when the resulting magnitude would be negative. - public static Period FromYears(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value))); + public static Period FromYears(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YearToSeconds)), nameof(value))); /// Implicit conversion to Duration. public static implicit operator Duration(Period value) => Duration.Create(value.Value); /// Explicit conversion from Duration. diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Wavelength.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Wavelength.g.cs index b2131e4..c321bd2 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Wavelength.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Wavelength.g.cs @@ -24,77 +24,77 @@ public record Wavelength : PhysicalQuantity, T>, IVector0The value in Meter. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromMeters(T value) => Create(Vector0Guards.EnsureNonNegative(value, nameof(value))); + public static Wavelength FromMeters(T value) => Create(Vector0Guards.EnsurePositive(value, nameof(value))); /// /// Creates a new Wavelength from a value in Kilometer. /// /// The value in Kilometer. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromKilometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Kilo)), nameof(value))); + public static Wavelength FromKilometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Kilo)), nameof(value))); /// /// Creates a new Wavelength from a value in Centimeter. /// /// The value in Centimeter. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromCentimeters(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Centi)), nameof(value))); + public static Wavelength FromCentimeters(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Centi)), nameof(value))); /// /// Creates a new Wavelength from a value in Millimeter. /// /// The value in Millimeter. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromMillimeters(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); + public static Wavelength FromMillimeters(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Milli)), nameof(value))); /// /// Creates a new Wavelength from a value in Micrometer. /// /// The value in Micrometer. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromMicrometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); + public static Wavelength FromMicrometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Micro)), nameof(value))); /// /// Creates a new Wavelength from a value in Nanometer. /// /// The value in Nanometer. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromNanometers(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(MetricMagnitudes.Nano)), nameof(value))); + public static Wavelength FromNanometers(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(MetricMagnitudes.Nano)), nameof(value))); /// /// Creates a new Wavelength from a value in Angstrom. /// /// The value in Angstrom. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromAngstroms(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.AngstromToMeters)), nameof(value))); + public static Wavelength FromAngstroms(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.AngstromToMeters)), nameof(value))); /// /// Creates a new Wavelength from a value in Foot. /// /// The value in Foot. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromFeet(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.FeetToMeters)), nameof(value))); + public static Wavelength FromFeet(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.FeetToMeters)), nameof(value))); /// /// Creates a new Wavelength from a value in Inch. /// /// The value in Inch. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromInches(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.InchesToMeters)), nameof(value))); + public static Wavelength FromInches(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.InchesToMeters)), nameof(value))); /// /// Creates a new Wavelength from a value in Yard. /// /// The value in Yard. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromYards(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.YardToMeters)), nameof(value))); + public static Wavelength FromYards(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.YardToMeters)), nameof(value))); /// /// Creates a new Wavelength from a value in Mile. /// /// The value in Mile. /// A new Wavelength instance. /// Thrown when the resulting magnitude would be negative. - public static Wavelength FromMiles(T value) => Create(Vector0Guards.EnsureNonNegative((value * T.CreateChecked(Units.ConversionConstants.MileToMeters)), nameof(value))); + public static Wavelength FromMiles(T value) => Create(Vector0Guards.EnsurePositive((value * T.CreateChecked(Units.ConversionConstants.MileToMeters)), nameof(value))); /// Implicit conversion to Length. public static implicit operator Length(Wavelength value) => Length.Create(value.Value); /// Explicit conversion from Length. diff --git a/Semantics.Quantities/Vector0Guards.cs b/Semantics.Quantities/Vector0Guards.cs index 0f4c0e8..eab9e2b 100644 --- a/Semantics.Quantities/Vector0Guards.cs +++ b/Semantics.Quantities/Vector0Guards.cs @@ -42,4 +42,30 @@ public static T EnsureNonNegative(T value, string paramName) return value; } + + /// + /// Returns unchanged when it is strictly positive; throws + /// otherwise. Used in generated From{Unit} + /// factories on V0 overloads that declare physicalConstraints.minExclusive: "0" + /// in dimensions.json (per #51). Examples: Wavelength, Period, + /// HalfLife — quantities for which zero is unphysical, distinct from the V0 + /// default that allows zero. + /// + /// 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, strictly-positive value. + /// When is zero or negative. + public static T EnsurePositive(T value, string paramName) + where T : struct, INumber + { + if (T.Sign(value) <= 0) + { + throw new ArgumentException( + $"Value must be strictly positive; received {value}.", + paramName); + } + + return value; + } } diff --git a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index cd6b916..59866c4 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -558,6 +558,10 @@ private static Dictionary BuildUnitMap(UnitsMetadata uni /// negative after a unit conversion (e.g. FromCelsius(-300)) — throws /// . This locks in the V0 non-negativity invariant /// from #50 across every per-unit factory introduced for #48. + /// When is also true (V0 overloads with + /// physicalConstraints.minExclusive: "0" per #51) the guard is upgraded to + /// Vector0Guards.EnsurePositive, which rejects zero as well as negative values. + /// is ignored when is false. /// private static void AddUnitFactories( ClassTemplate cls, @@ -566,13 +570,16 @@ private static void AddUnitFactories( string typeName, string fullType, string crefForComment, - bool applyV0Guard) + bool applyV0Guard, + bool strictPositive = false) { if (availableUnits == null || availableUnits.Count == 0) { return; } + string guardMethod = strictPositive ? "EnsurePositive" : "EnsureNonNegative"; + string baseUnit = availableUnits[0]; foreach (string unitName in availableUnits) { @@ -582,7 +589,7 @@ private static void AddUnitFactories( : BuildToBaseExpression(unitName, unitMap); string body = applyV0Guard - ? $" => Create(Vector0Guards.EnsureNonNegative({conversionExpr}, nameof(value)));" + ? $" => Create(Vector0Guards.{guardMethod}({conversionExpr}, nameof(value)));" : $" => Create({conversionExpr});"; // Issue #49: factory names use the plural form. Prefer an explicit FactoryName from @@ -997,7 +1004,12 @@ private void EmitOverloadType( // Factory methods for every available unit (#48); overloads inherit the dimension's // units. V0 overloads enforce the same non-negativity invariant as their V0 base - // type (#50); V1 overloads accept any sign. + // type (#50). V0 overloads that declare physicalConstraints.minExclusive in + // dimensions.json (#51, e.g. Wavelength, Period, HalfLife) get the stricter + // EnsurePositive guard so a zero input is rejected too. V1 overloads accept + // any sign. + bool strictPositive = vectorForm == 0 + && overload.PhysicalConstraints?.MinExclusive == "0"; AddUnitFactories( cls, dim.AvailableUnits, @@ -1005,7 +1017,8 @@ private void EmitOverloadType( typeName, fullType, typeName, - applyV0Guard: vectorForm == 0); + applyV0Guard: vectorForm == 0, + strictPositive: strictPositive); // Implicit widening to base type cls.Members.Add(new MethodTemplate() diff --git a/Semantics.SourceGenerators/Metadata/dimensions.json b/Semantics.SourceGenerators/Metadata/dimensions.json index c1a6bdb..8370346 100644 --- a/Semantics.SourceGenerators/Metadata/dimensions.json +++ b/Semantics.SourceGenerators/Metadata/dimensions.json @@ -46,7 +46,7 @@ }, { "name": "Distance", "description": "Separation between two points in space." }, { "name": "Altitude", "description": "Height above a reference level." }, - { "name": "Wavelength", "description": "Spatial period of a periodic wave." }, + { "name": "Wavelength", "description": "Spatial period of a periodic wave.", "physicalConstraints": { "minExclusive": "0" } }, { "name": "Thickness", "description": "Extent through the thinnest dimension." }, { "name": "Perimeter", "description": "Total boundary length of a 2D shape." } ] @@ -107,8 +107,8 @@ "vector0": { "base": "Duration", "overloads": [ - { "name": "Period", "description": "Time interval for one complete cycle." }, - { "name": "HalfLife", "description": "Time for half of a substance to decay." }, + { "name": "Period", "description": "Time interval for one complete cycle.", "physicalConstraints": { "minExclusive": "0" } }, + { "name": "HalfLife", "description": "Time for half of a substance to decay.", "physicalConstraints": { "minExclusive": "0" } }, { "name": "TimeConstant", "description": "Characteristic response time of a system." }, { "name": "Latency", "description": "Delay before a response begins." }, { "name": "ReverberationTime", "description": "Time for sound to decay by 60 dB in an enclosed space." }, diff --git a/Semantics.SourceGenerators/Models/DimensionsMetadata.cs b/Semantics.SourceGenerators/Models/DimensionsMetadata.cs index 6102f42..d83a9df 100644 --- a/Semantics.SourceGenerators/Models/DimensionsMetadata.cs +++ b/Semantics.SourceGenerators/Models/DimensionsMetadata.cs @@ -170,6 +170,30 @@ public class OverloadDefinition public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public Dictionary Relationships { get; set; } = []; + + /// + /// Optional per-overload physical constraints. Currently only minExclusive is + /// honoured (per #51): when set on a V0 overload, the generated From{Unit} + /// factories use Vector0Guards.EnsurePositive instead of the default + /// EnsureNonNegative, so a strict-positive overload like Wavelength + /// rejects a zero input. The field is a string so it can hold a literal SI-base-unit + /// value (e.g. "0"); the generator emits T.CreateChecked(...) around it. + /// + public PhysicalConstraints? PhysicalConstraints { get; set; } +} + +/// +/// Per-overload physical constraints applied at construction time, on top of the structural +/// V0 non-negativity invariant. Only MinExclusive is implemented today; the other +/// fields are reserved for future per-overload bounds (#51). +/// +public class PhysicalConstraints +{ + /// + /// Strict-positive lower bound. When set (typically to "0"), the value must be + /// strictly greater than this number after conversion to the SI base unit. + /// + public string MinExclusive { get; set; } = string.Empty; } /// diff --git a/Semantics.Test/Quantities/Vector0InvariantTests.cs b/Semantics.Test/Quantities/Vector0InvariantTests.cs index d053a41..430765d 100644 --- a/Semantics.Test/Quantities/Vector0InvariantTests.cs +++ b/Semantics.Test/Quantities/Vector0InvariantTests.cs @@ -185,4 +185,102 @@ public void Vector0Guards_Throws_On_Negative_With_ParamName() () => Vector0Guards.EnsureNonNegative(-1.0, "myParam")); Assert.AreEqual("myParam", ex.ParamName); } + + // =========================================================== #51: Strict-positive overloads + + // Wavelength, Period, HalfLife declare physicalConstraints.minExclusive: "0" in + // dimensions.json; their generated From{Unit} factories use Vector0Guards.EnsurePositive + // instead of EnsureNonNegative, rejecting zero as well as negative values. + + [TestMethod] + public void Wavelength_FromMeters_Zero_Throws() + => _ = Assert.ThrowsExactly(() => Wavelength.FromMeters(0.0)); + + [TestMethod] + public void Wavelength_FromMeters_Negative_Throws() + => _ = Assert.ThrowsExactly(() => Wavelength.FromMeters(-1e-9)); + + [TestMethod] + public void Wavelength_FromMeters_Positive_Succeeds() + { + Wavelength w = Wavelength.FromMeters(550e-9); + Assert.AreEqual(550e-9, w.Value, 1e-15); + } + + [TestMethod] + public void Wavelength_FromNanometers_Zero_Throws() + => _ = Assert.ThrowsExactly(() => Wavelength.FromNanometers(0.0)); + + [TestMethod] + public void Period_FromSeconds_Zero_Throws() + => _ = Assert.ThrowsExactly(() => Period.FromSeconds(0.0)); + + [TestMethod] + public void Period_FromSeconds_Positive_Succeeds() + { + Period p = Period.FromSeconds(0.001); + Assert.AreEqual(0.001, p.Value, Tolerance); + } + + [TestMethod] + public void HalfLife_FromSeconds_Zero_Throws() + => _ = Assert.ThrowsExactly(() => HalfLife.FromSeconds(0.0)); + + // The base type (Length, Duration) has no minExclusive, so zero is still allowed — + // only the constrained overloads reject it. + + [TestMethod] + public void Length_FromMeters_Zero_Allowed() + { + Length l = Length.FromMeters(0.0); + Assert.AreEqual(0.0, l.Value, Tolerance); + } + + [TestMethod] + public void Duration_FromSeconds_Zero_Allowed() + { + Duration d = Duration.FromSeconds(0.0); + Assert.AreEqual(0.0, d.Value, Tolerance); + } + + // Other Length / Duration overloads without the constraint also still allow zero. + + [TestMethod] + public void Distance_FromMeters_Zero_Allowed() + { + Distance d = Distance.FromMeters(0.0); + Assert.AreEqual(0.0, d.Value, Tolerance); + } + + [TestMethod] + public void Latency_FromSeconds_Zero_Allowed() + { + // Latency has no minExclusive — a zero-latency response is meaningful. + Latency l = Latency.FromSeconds(0.0); + Assert.AreEqual(0.0, l.Value, Tolerance); + } + + // Vector0Guards.EnsurePositive directly. + + [TestMethod] + public void Vector0Guards_EnsurePositive_Allows_Positive() + { + Assert.AreEqual(3.5, Vector0Guards.EnsurePositive(3.5, "v")); + } + + [TestMethod] + public void Vector0Guards_EnsurePositive_Throws_On_Zero_With_ParamName() + { + ArgumentException ex = Assert.ThrowsExactly( + () => Vector0Guards.EnsurePositive(0.0, "myParam")); + Assert.AreEqual("myParam", ex.ParamName); + } + + [TestMethod] + public void Vector0Guards_EnsurePositive_Throws_On_Negative_With_ParamName() + { + ArgumentException ex = Assert.ThrowsExactly( + () => Vector0Guards.EnsurePositive(-1.0, "myParam")); + Assert.AreEqual("myParam", ex.ParamName); + } }