Skip to content

Logarithmic scales round-trip through double, so a precise storage type silently loses its precision #235

Description

@matt-edmondson

What happens

Every logarithmic scale is generic over its storage type, so Decibels<PreciseNumber> and PH<decimal> compile and read as precise. The conversions to and from their linear counterparts are not: they convert to double, call Math.Log* / Math.Pow, and convert back.

Semantics.SourceGenerators/Generators/LogarithmicScalesGenerator.cs:151:

cb.WriteLine("double linearValue = double.CreateChecked(linear.Value);");

and :174:

cb.WriteLine("double scaleValue = double.CreateChecked(Value);");

Which lands in committed generated source — Semantics.Quantities/Generated/…/LogarithmicScalesGenerator/Decibels.g.cs:39-40:

public readonly partial record struct Decibels<T>(T Value) : IComparable<Decibels<T>>
    where T : struct, INumber<T>
...
    double linearValue = double.CreateChecked(linear.Value);
    return new(T.CreateChecked(20.0 * Math.Log10(linearValue)));

and :49-50 for the way back:

    double scaleValue = double.CreateChecked(Value);
    return Gain<T>.Create(T.CreateChecked(Math.Pow(10.0, scaleValue / 20.0)));

All eight generated scales are affected: Cents, Decibels, DirectionalityIndex, PH, Semitones, SoundIntensityLevel, SoundPowerLevel, SoundPressureLevel.

Why it matters now

This is a documented decision, not an oversight — CLAUDE.md says the logarithmic scales and the hand-written audio types still compute through double. It is worth revisiting because the premise changed twice since:

  1. [minor] Convert units exactly for every storage type #226 made unit factors and metric magnitudes exact per storage type, and vector Length()/Distance() compute roots in the storage type's own arithmetic.
  2. [minor] Add a PreciseNumber storage-type alias package #232 shipped ktsu.Semantics.Quantities.Precise, which binds every quantity in a project to ktsu.PreciseNumber.PreciseNumber through a global-using alias.

So a consumer of the Precise alias package gets exact foot-to-inch conversion and a 50-digit vector length, and — with no diagnostic and no difference in how the type is spelled — roughly 16 significant digits for anything that passes through a decibel or a pH. The alias package is precisely the context in which someone has declared they care about the error budget.

The same applies to decimal, which StorageConversionTests<decimal> otherwise holds to 1e-25.

Scope

Math.Log and Math.Pow over an arbitrary INumber<T> are a real piece of work, not a mechanical change, which is presumably why the original decision went the way it did. Options, roughly in increasing order of cost:

  1. Document it at the call site. Cheapest, and stops it being a surprise: an XML <remarks> on each generated scale saying the conversion is computed in double whatever T is, so it shows in IntelliSense rather than only in CLAUDE.md.
  2. Route through the widest available path. Use decimal where T is decimal, keeping double otherwise. Narrow benefit, small change.
  3. Compute the log and the exponential in T's own arithmetic, the way StorageMath.Sqrt already does for roots — seed from the double result and refine. StorageMath is the obvious home, and Sqrt is the precedent for the shape.

Option 1 is worth doing regardless of whether 3 ever happens.

Verification

Whatever the fix, StorageConversionTests<T> is the natural place to pin it: it already runs the same conversions over double and decimal and takes one derived line per storage type, and a PreciseNumber derived class was added in #232.

Activity

  1. matt-edmondson commented on Sep 16, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Category: Improvement
    Priority: Medium
    Area: Semantics.SourceGenerators — LogarithmicScalesGenerator, eight generated scales

    Medium: a documented decision rather than an oversight, but the premise it rested on has changed twice. #226 made unit factors and metric magnitudes exact per storage type, and #232 shipped ktsu.Semantics.Quantities.Precise, which binds every quantity in a project to PreciseNumber through a global-using alias. So a consumer of the Precise package now gets exact conversions and a 50-digit vector length, and roughly 16 significant digits for anything touching a decibel or a pH — with no diagnostic and no difference in how the type is spelled. The alias package is by definition the context where someone has declared they care about the error budget, which is what makes this worth revisiting rather than re-affirming.

    Medium rather than High because it is silent precision loss in a bounded set of eight scales, not incorrect results, and decimal users are in the same position today.

    Suggested assignment: none specific.

    Related: ktsu-dev/PreciseNumber#81 is the same defect one layer down — Pow's non-integer path computes through Math.Log/Math.Exp on double and returns ~15 digits from a 50-digit type. Worth noting that option 3 here (compute log and exp in T's own arithmetic) substantially overlaps that work: if PreciseNumber grows real Exp/Log, the expensive part of option 3 for the most demanding storage type is already paid for. Whoever considers option 3 should check #81's status first — sequencing them the other way means writing it twice.

    Open PR covering this: none.

    Suggested next step: land option 1 now, independently of whether option 3 ever happens — an XML <remarks> on each generated scale saying the conversion computes in double whatever T is puts it in IntelliSense instead of only in CLAUDE.md, which is where a Precise consumer would actually encounter it. It is a generator change of a few lines and it converts a silent surprise into a stated contract.

    Option 2 (decimal where T is decimal) is worth pricing but looks like the weakest of the three: narrow benefit, and it leaves PreciseNumber — the case that motivated the issue — exactly where it is. StorageConversionTests<T> is the right place to pin whatever lands, as the issue says, since it already runs these conversions across storage types and gained a PreciseNumber derived class in #232.


    Generated by Claude Code

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

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions