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

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

Metadata

Metadata

Assignees

No one assigned

    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