Skip to content

[patch] Fix storage conversion regressions from exact factors - #231

Merged
matt-edmondson merged 2 commits into
mainfrom
fix-storage-conversion-regressions
Sep 14, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix-storage-conversion-regressions

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Integer storage converts again, and exact factors stay exact

Versions 5.2.0 through 5.2.3 break every quantity stored as an integer. Since #226,
Length<int>.FromKilometer(1) throws TypeInitializationException instead of returning 1000, and so
does every other factory and every In(unit) for int and long. This PR restores the 5.1 behavior
exactly: each factor succeeds or throws OverflowException on its own, at the call that uses it.

It also fixes the other review findings on #226:

  • StorageMath.Sqrt returned a wrong root without complaint for values a double cannot hold. A
    BigInteger of 2^2048 gave about 2^1792. It now gives 2^1024, or throws if Newton's method does not
    settle.
  • Every literal built on π was wrong past its 97th to 104th significant digit, in conversions.json
    and domains.json. They are recomputed from π and correctly rounded to 150 significant digits.
  • SEM009 accepted values that do not compile (100000000000000000000, 1e400).
  • A numeric type whose TryParse throws for NumberStyles.Float broke every factor for that type.
  • The claim that no double constant changed was wrong. PsiToPascals and
    RevolutionPerMinuteToRadianPerSecond each moved to the adjacent double (closer to the true value)
    in 5.2.0, and IUnit.ToBaseFactor of Psi and RevolutionPerMinute moved with them. That is now
    documented rather than denied.

Mechanism

Integer storage. 5.2.0 emitted each factor as internal static readonly T X = StorageLiteral.Parse<T>(literal, double),
where the fallback T.CreateChecked(double) ran inside the Values<T> static initializer. One value
that does not fit (Tera or CurieToBecquerels for int, Yotta for long) failed the initializer, and
every value for that type failed with it. Each value is now:

internal static T Kilo => ParsedKilo ?? T.CreateChecked(MetricMagnitudes.Kilo);
private static readonly T? ParsedKilo = StorageLiteral.Parse<T>("1e3");

StorageLiteral no longer converts the double at all. It answers null for an integer type or one
that cannot parse the literal, so the initializer cannot throw for those types, and the conversion
happens at the read exactly as the 5.1 factories did it. double and decimal still read a cached
parsed value, and QuantityValueTypeTests still measures 0 bytes allocated.

Square roots. When the value does not convert to a normal double, it is scaled by powers of four
into [1, 4), the root is taken there and scaled back by the matching power of two, so the seed is
always within a factor of two. Reaching the step cap now throws ArithmeticException instead of
returning the estimate. The double, float, Half, and integer primitive paths are unchanged.

π literals. π was computed to 220 places with Machin's formula over integers, checked against a
second Machin-like formula and against the first 100 published digits, and each literal written
correctly rounded to 150 significant digits. Changed: DegreeToRadians, GradianToRadians,
RevolutionToRadians, RevolutionPerMinuteToRadianPerSecond, and
FootLambertToCandelaPerSquareMeter in conversions.json, and TwoPi, DegreesPerRadian, and
RadiansPerDegree in domains.json. No double constant changes, because every error was past the
17th digit.

SEM009. Every generated double constant now carries a d suffix, and SEM009 rejects a literal,
operand, or quotient beyond the range of double, and a non-zero value that rounds to zero.

Parse exceptions. StorageLiteral treats NotSupportedException and ArgumentException from
TryParse as a failed parse. Nothing else is caught.

Magnitude and factor. QuantitiesGenerator now multiplies both when a unit declares both, as
UnitsGenerator already did. No unit declares both today, so no generated output changes.

Risk

  • The generated Values<T> members change from fields to properties over private fields. They are
    internal, so no public surface changes. The committed diff is limited to ConversionConstants.g.cs
    (the d suffixes and the holder shape), MetricMagnitudes.g.cs (the holder shape), and
    PhysicalConstants.g.cs (the three π literals and their descriptions).
  • A metadata value that 5.2.0 accepted and SEM009 now rejects would stop generating. None of the
    committed metadata is affected.
  • Integer and fallback storage types pay a T.CreateChecked per conversion again, as in 5.1.

Testing

Each fix has a test that failed before the change:

  • IntegerStorageConversionTests compares int and long factories (FromKilometer, FromCentimeter,
    FromFoot, FromMile, FromHour, FromCelsius, FromFahrenheit, FromCurie) and In(unit)
    against the 5.1 generated expressions with the 5.1 constants, including the OverflowException from
    FromCurie on int and the DivideByZeroException from In(Units.Fahrenheit).
  • StorageMathTests covers BigInteger 2^2048 and 10^400 (exact roots), the smallest and largest
    decimal, and zero.
  • PiLiteralTests checks all eight π literals to their last digit against π computed in the test.
  • AMetricMagnitudeCombinedWithALongFactorIsExact replaces AMetricMagnitudeIsExact, and fails
    when decimal is temporarily sent through the double route.
  • GeneratorDiagnosticTests covers SEM009 range rejection, compiles each accepted constant with
    Roslyn, and checks a unit with both a magnitude and a factor.
  • StorageLiteralTests runs factories over a custom INumber<T> whose parse throws
    NotSupportedException or ArgumentException.

The solution builds with no warnings. Semantics.Test passes all 1237 tests. Semantics.Cpp.Test
passes 29 of 33, and the 4 skipped tests are the C++ compiler tests that need g++ or clang++.
Regenerating the committed generator output and the alias props leaves no diff beyond what this PR
commits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5Bk9UjGdGUtC5C6qK5ZxD

Restore integer storage behavior broken in 5.2.0. 5.2.0 converted every
conversion factor and metric magnitude for a storage type in one static
initializer, so a single value too large for the type (Tera or
CurieToBecquerels for int, Yotta for long) made every factory and In(unit)
for that type throw TypeInitializationException. Length<int>.FromKilometer(1)
threw instead of returning 1000. Each holder value is now a property over a
nullable parsed field, and a type that does not parse the literal converts
the double at each read, so each value succeeds or throws OverflowException
on its own, exactly as the 5.1 factories did.

Make StorageMath.Sqrt return the converged root or throw. A value a double
cannot hold is scaled by powers of four into [1, 4) for the seed and the root
scaled back, and a root that does not settle throws ArithmeticException. A
BigInteger of 2^2048 now gives 2^1024 instead of about 2^1792.

Recompute every literal built on pi in conversions.json and domains.json from
pi itself, correctly rounded to 150 significant digits. The old literals were
wrong from the 97th to 104th significant digit. Add PiLiteralTests, which
checks each literal to its last digit against pi computed by Machin's formula.

Replace AMetricMagnitudeIsExact, which passed through the old double route
too, with a test that combines a magnitude with a 17-digit factor and fails
through that route.

Emit a d suffix on every double constant, so a literal such as
100000000000000000000 compiles, and make SEM009 reject a literal, operand, or
quotient beyond the range of double, or a non-zero value that rounds to zero.

Treat a TryParse that throws NotSupportedException or ArgumentException for
NumberStyles.Float as a type that cannot parse the literal, so it falls back
to the double instead of failing.

Correct the claim that no double constant changed. PsiToPascals and
RevolutionPerMinuteToRadianPerSecond moved to the adjacent double in 5.2.0,
and with them IUnit.ToBaseFactor of Psi and RevolutionPerMinute. Document it
in CLAUDE.md and docs/physics-generator.md.

Multiply both magnitude and conversion factor in QuantitiesGenerator when a
unit declares both, as UnitsGenerator already does.

Claude-Session: https://claude.ai/code/session_01K5Bk9UjGdGUtC5C6qK5ZxD
Comment thread Semantics.SourceGenerators/Generators/ConversionValue.cs Fixed
Comment thread Semantics.Test/Quantities/ParseThrowingNumber.cs Fixed
Both CodeQL findings are exact-zero comparisons, and both are kept exact
rather than turned into a tolerance:

- ConversionValue.IsHeldByDouble tested `value != 0d` to catch a non-zero
  literal that underflowed. It now asks whether the magnitude is above
  zero, which is the same test once NaN is already excluded. The
  framework predicate CodeQL suggests, `double.IsZero`, is a static
  abstract on INumberBase<double> and does not exist on netstandard2.0,
  which this generator targets.
- ParseThrowingNumber.IsZero now routes through INumberBase<T>.IsZero via
  a constrained type parameter, which is the only way to reach a static
  abstract interface member.

Also clears the Sonar findings in the code this branch adds, none of
which change any generated output:

- PiLiteralTests.ArctanOfReciprocal advanced `n` in the incrementer while
  testing `power` in the condition (S1994). It is a while loop now.
- ConversionValue.IsDecimalLiteral was over the cognitive complexity
  limit (S3776), split into SkipSign, TryPassFraction and TryPassExponent.
- The fourth `"internal"` literal in ConversionsGenerator tripped S1192.
  Added Emit.Internal and used it across the three generators that spell
  the modifier.

Solution builds with no warnings, all 1237 tests in Semantics.Test pass,
and regenerating the committed generator output leaves no diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLjFwwiUGZ6i5yZ7rj1WAU
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 58c36b5 into main Sep 14, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the fix-storage-conversion-regressions branch September 14, 2026 14:36
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