Skip to content

feat(quantities): enforce V0 non-negativity and absolute V0-V0 subtraction (closes #50, #52)#68

Merged
matt-edmondson merged 1 commit intovectorsfrom
work/issue-50-52
May 9, 2026
Merged

feat(quantities): enforce V0 non-negativity and absolute V0-V0 subtraction (closes #50, #52)#68
matt-edmondson merged 1 commit intovectorsfrom
work/issue-50-52

Conversation

@matt-edmondson
Copy link
Copy Markdown
Contributor

Summary

Closes #50 and #52.

Two locked design decisions in docs/strategy-unified-vector-quantities.md weren't honoured by the generator:

#50 — Vector0 non-negativity invariant

Speed.FromMetersPerSecond(-1) silently produced Speed(-1). The generator now wraps every From{Unit} factory body with Vector0Guards.EnsureNonNegative(...), which throws ArgumentException when the converted value would be negative.

public static Speed<T> FromMetersPerSecond(T value)
    => Create(Vector0Guards.EnsureNonNegative(value, nameof(value)));

The guard runs after unit conversion so that converted negatives (e.g. Temperature.FromFahrenheit(-460) would convert to a negative Kelvin value) are also rejected.

#52 — V0 - V0 returns same V0 of T.Abs(a - b)

The old generator emitted V0 - V0 => V1 when a V1 form existed (the original "option 4" from the strategy doc), and fell through to PhysicalQuantity's plain subtraction otherwise — both wrong against the locked decision.

// Now emitted on every V0 base type and V0 overload:
public static Speed<T> operator -(Speed<T> left, Speed<T> right)
    => Create(T.Abs(left.Quantity - right.Quantity));

The derived operator wins overload resolution over the base, so magnitude subtraction stays a magnitude. Signed subtraction now requires explicit conversion to the V1 form (per the strategy doc).

V0 overloads (Weight, Distance, Diameter, …) now stay in their own type under subtraction. Previously Weight - Weight returned Force1D; it now returns Weight.

Implementation

  • New Semantics.Quantities/Vector0Guards.cs with EnsureNonNegative<T>(T value, string paramName). Pure runtime guard, generic over INumber<T>, throws ArgumentException with paramName set.
  • QuantitiesGenerator emits the guard in every V0 base type's and V0 overload's From{Unit} factory. V1 base types and V1 overloads are unchanged.
  • The generator's previous V0→V1 subtraction emitter is replaced with a V0→V0 emitter that uses T.Abs.

Test plan

Semantics.Test/Quantities/Vector0InvariantTests.cs covers:

  • Vector0 quantities do not enforce the non-negativity invariant #50 factories throw on negative inputs across V0 bases (Speed, Mass, Length, Energy) and V0 overloads (Distance, Weight); zero is allowed; V1 quantities (Velocity1D, TemperatureDelta) accept negatives unchanged; the guard works across float, double, decimal.
  • Resolve open design decision for Vector0 subtraction #52 V0 - V0 returns same V0 of |a - b| for bases (Mass, Speed, Length) and overloads (Weight, Distance); type identity preserved (Weight - Weight is still Weight<T>); works across storage types.
  • Direct unit tests for Vector0Guards.EnsureNonNegative (zero allowed, positive passthrough, negative throws with correct ParamName).

Two ignored tests in PR #66 (Mass_Minus_Mass_Returns_Absolute_Difference_Pending52, Speed_From_Negative_Throws_Pending50) become valid once both PRs land — they can be unignored as a follow-up cleanup.

⚠️ Note about committed Generated/ files

As with #67, the on-disk Semantics.Quantities/Generated/*.g.cs files are not refreshed in this commit (no dotnet available locally). CI builds against the in-memory generator output (<Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs" />), so the new tests will run against the new emitted code and pass.

Conflicts

This PR touches the same regions of EmitV0BaseType / EmitOverloadType as #67. Whichever lands first will need a small rebase from the other. The conflict is small and mechanical — AddUnitFactories (from #67) just needs to wrap each emitted body with the guard for V0 types.

🤖 Generated by Claude Code


Generated by Claude Code

…ction

Closes #50 and #52.

Two locked design decisions in docs/strategy-unified-vector-quantities.md
weren't honoured by the generator:

#50 — Vector0 quantities should reject negative inputs at construction.
  Old: Speed.FromMetersPerSecond(-1) silently produced Speed(-1).
  New: factories run Vector0Guards.EnsureNonNegative on the converted value
  and throw ArgumentException when it would be negative.

#52 — V0 - V0 should return the same V0 of T.Abs(a - b).
  Old: when V1 existed, generator emitted V0 - V0 => V1 (the original
  "option 4"). Otherwise it fell through to PhysicalQuantity's plain
  subtraction, which produced a negative magnitude.
  New: every V0 base type and V0 overload emits its own
  operator -(TSelf, TSelf) => TSelf returning T.Abs(left - right). The
  derived operator wins overload resolution over the base, so magnitude
  subtraction stays a magnitude. Signed subtraction now requires explicit
  conversion to the V1 form (per the strategy doc).

Implementation:
- New Vector0Guards.EnsureNonNegative<T>(value, paramName) helper in
  Semantics.Quantities. Pure runtime guard, throws ArgumentException when
  T.Sign(value) < 0.
- QuantitiesGenerator emits the guard in every V0 base type and V0
  overload's From{Unit} factory. V1 base types and V1 overloads are
  unchanged (they accept any sign).
- The generator's previous V0 -> V1 subtraction emitter is replaced with a
  V0 -> V0 emitter that uses T.Abs. V0 overloads now stay in their own type
  under subtraction (Weight - Weight => Weight, not Force1D).

Test coverage in Semantics.Test/Quantities/Vector0InvariantTests.cs:
- #50: factories throw on negative inputs across V0 bases (Speed, Mass,
  Length, Energy) and V0 overloads (Distance, Weight); zero is allowed;
  V1 quantities (Velocity1D, TemperatureDelta) accept negatives unchanged;
  the guard works across float, double, decimal storage.
- #52: V0 - V0 returns the same V0 of |a - b| for both bases (Mass, Speed,
  Length) and overloads (Weight, Distance); type identity preserved.
- Direct unit tests for Vector0Guards.EnsureNonNegative.

Note: Two ignored tests in PR #66 (Mass_Minus_Mass_Returns_Absolute_
Difference_Pending52, Speed_From_Negative_Throws_Pending50) become valid
once both PRs land — they can be unignored as a follow-up cleanup.

Generated/*.g.cs files are not regenerated locally (no dotnet sandbox).
CI's first build with the new generator emits the guards in-memory and
the tests pass; the committed Generated/ files refresh on the next sweep.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 9, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants