Context
Float's design is non-canonical: multiple (coef, exp) pairs can represent the same numeric value ((5, 0), (50, -1), (5000, -3) all equal 5), and eq rescales before comparing rather than relying on byte equality. This is documented on the Float type docstring in src/lib/LibDecimalFloat.sol, added in branch 2026-04-20-182 alongside the #182 work.
The design stance is deliberate — canonicalization on every arithmetic op would add gas for a benefit most callers don't need. But consumers that do need byte equality (mapping(Float => X) keys, hash keys, set membership, content-addressed storage of Floats) need a way to canonicalize explicitly at the point of use. Today there is no such function.
Proposed function
/// Canonicalize a Float to a unique byte representation per numeric value.
/// Two Floats are numerically equal iff their canonical forms are byte-equal.
function canonicalize(Float float) internal pure returns (Float);
Semantics: pick the representative with the largest |coefficient| that fits int224, subject to the exponent staying ≥ int32.min. Any deterministic choice works for set-key purposes; "largest coefficient" is natural because maximize/maximizeFull already exist as building blocks.
What we tried
The obvious composition:
function canonicalize(Float f) internal pure returns (Float) {
(int256 c, int256 e) = unpack(f);
(c, e) = LibDecimalFloatImplementation.maximizeFull(c, e);
return packLossless(c, e);
}
This does not work. Two failure modes surfaced under test:
1. packLossless flags any int224 truncation as lossy, even when exact
packLossy (LibDecimalFloat.sol:300-343) sets lossless = int224(signedCoefficient) == signedCoefficient once, at the top, based on the initial int224 fit. If the coefficient exceeds int224, the subsequent truncation loop divides by 10 until it fits — but the lossless flag stays false regardless of whether those divisions actually lost precision.
When maximizeFull has just inflated the coefficient to ≈10^76, every trailing zero it added is a multiple-of-10. packLossy's divisions strip exactly those trailing zeros — value-preserving — but the flag still reports lossless = false, so packLossless reverts with CoefficientOverflow.
Concrete failures from running tests against this composition (all were equality-preserving inputs):
packLossless(5, 0).canonicalize() → reverts CoefficientOverflow(5e76, -76)
packLossless(42, 7).canonicalize() → reverts CoefficientOverflow(4.2e76, -68)
packLossless(1234567890, -3).canonicalize() → reverts CoefficientOverflow(1.234e76, -70)
2. Switching to packLossy (discarding the flag) silently zeros tiny inputs
packLossy at lines 336-343 handles int32 exponent overflow by returning FLOAT_ZERO with lossless = false when the exponent has underflowed. For inputs with exponent near int32.min, maximizeFull pushes the exponent further down (subtracting ≈76), packLossy's truncation loop only partially raises it, and the final exponent still fails the int32 check — silent collapse to zero.
Working alternative: loop-multiply
Scaling up directly within the int224/int32 bounds avoids both failure modes:
function canonicalize(Float float) internal pure returns (Float) {
(int256 signedCoefficient, int256 exponent) = unpack(float);
if (signedCoefficient == 0) return FLOAT_ZERO;
unchecked {
while (exponent > type(int32).min) {
int256 trySignedCoefficient = signedCoefficient * 10;
// int224 overflow = termination condition, not a bug.
// forge-lint: disable-next-line(unsafe-typecast)
if (int224(trySignedCoefficient) != trySignedCoefficient) break;
signedCoefficient = trySignedCoefficient;
exponent -= 1;
}
}
return packLossless(signedCoefficient, exponent);
}
Produces the same canonical form as the composition would in the happy path. Passes all the tests drafted below.
Open design questions
-
Fix packLossless semantics, or accept the asymmetry? The "lossless when divisions are all exact" case is arguably a missing distinction in packLossy — it could track whether any division actually truncated non-zero digits, not just whether the initial coefficient fit. If fixed, the max+pack composition works directly. If not, canonicalize needs a dedicated loop anyway.
-
Location: LibDecimalFloat (public API) seems right since this is a consumer-side function. LibDecimalFloatImplementation is for internal int256-level primitives.
-
Naming: canonicalize vs normalize vs reduce vs shrink. canonicalize matches the docstring framing on the Float type.
-
Reverting vs returning zero for adversarial inputs: with the loop-multiply approach, the function never needs to revert — it just stops scaling when it hits a limit. Non-pathological for any valid input Float.
Draft tests
The following test file was written during investigation and should be committed alongside the implementation:
testCanonicalizeZero — zero at any exponent canonicalizes to FLOAT_ZERO.
testCanonicalizeEqualValuesByteEqual — (5, 0), (50, -1), (5000, -3), (5e10, -10) all canonicalize to the same bytes32.
testCanonicalizeNegativeEqualValuesByteEqual — same for negatives.
testCanonicalizeValuePreserving — canonicalize preserves numeric equality (eq check).
testCanonicalizeIdempotent — canonicalize(canonicalize(f)) == canonicalize(f) byte-equal.
testCanonicalizeValuePreservingFuzz(int224, int32) — fuzzed value preservation.
testCanonicalizeIdempotentFuzz(int224, int32) — fuzzed idempotence.
testCanonicalizeEqImpliesByteEqualFuzz(int128, uint8) — generates same-value shifted pairs, asserts byte-equality after canonicalize.
Both fuzz tests passed at 5096 runs against the loop-multiply version. The concrete tests failed against packLossless(maximizeFull(...)) due to the issues above.
Motivating use case
Came up during triage of #182 while documenting the design decision that Floats are non-canonical. The lazy-canonicalization stance makes sense for the arithmetic hot path, but means consumers that care about raw-byte equality currently have no way to get it without implementing canonicalization themselves.
Context
Float's design is non-canonical: multiple
(coef, exp)pairs can represent the same numeric value ((5, 0),(50, -1),(5000, -3)all equal5), andeqrescales before comparing rather than relying on byte equality. This is documented on theFloattype docstring insrc/lib/LibDecimalFloat.sol, added in branch2026-04-20-182alongside the #182 work.The design stance is deliberate — canonicalization on every arithmetic op would add gas for a benefit most callers don't need. But consumers that do need byte equality (
mapping(Float => X)keys, hash keys, set membership, content-addressed storage of Floats) need a way to canonicalize explicitly at the point of use. Today there is no such function.Proposed function
Semantics: pick the representative with the largest
|coefficient|that fits int224, subject to the exponent staying ≥int32.min. Any deterministic choice works for set-key purposes; "largest coefficient" is natural becausemaximize/maximizeFullalready exist as building blocks.What we tried
The obvious composition:
This does not work. Two failure modes surfaced under test:
1.
packLosslessflags any int224 truncation as lossy, even when exactpackLossy(LibDecimalFloat.sol:300-343) setslossless = int224(signedCoefficient) == signedCoefficientonce, at the top, based on the initial int224 fit. If the coefficient exceeds int224, the subsequent truncation loop divides by 10 until it fits — but thelosslessflag staysfalseregardless of whether those divisions actually lost precision.When
maximizeFullhas just inflated the coefficient to ≈10^76, every trailing zero it added is a multiple-of-10.packLossy's divisions strip exactly those trailing zeros — value-preserving — but the flag still reportslossless = false, sopackLosslessreverts withCoefficientOverflow.Concrete failures from running tests against this composition (all were equality-preserving inputs):
packLossless(5, 0).canonicalize()→ revertsCoefficientOverflow(5e76, -76)packLossless(42, 7).canonicalize()→ revertsCoefficientOverflow(4.2e76, -68)packLossless(1234567890, -3).canonicalize()→ revertsCoefficientOverflow(1.234e76, -70)2. Switching to
packLossy(discarding the flag) silently zeros tiny inputspackLossyat lines 336-343 handles int32 exponent overflow by returningFLOAT_ZEROwithlossless = falsewhen the exponent has underflowed. For inputs with exponent nearint32.min,maximizeFullpushes the exponent further down (subtracting ≈76),packLossy's truncation loop only partially raises it, and the final exponent still fails the int32 check — silent collapse to zero.Working alternative: loop-multiply
Scaling up directly within the int224/int32 bounds avoids both failure modes:
Produces the same canonical form as the composition would in the happy path. Passes all the tests drafted below.
Open design questions
Fix
packLosslesssemantics, or accept the asymmetry? The "lossless when divisions are all exact" case is arguably a missing distinction inpackLossy— it could track whether any division actually truncated non-zero digits, not just whether the initial coefficient fit. If fixed, the max+pack composition works directly. If not,canonicalizeneeds a dedicated loop anyway.Location:
LibDecimalFloat(public API) seems right since this is a consumer-side function.LibDecimalFloatImplementationis for internal int256-level primitives.Naming:
canonicalizevsnormalizevsreducevsshrink.canonicalizematches the docstring framing on the Float type.Reverting vs returning zero for adversarial inputs: with the loop-multiply approach, the function never needs to revert — it just stops scaling when it hits a limit. Non-pathological for any valid input Float.
Draft tests
The following test file was written during investigation and should be committed alongside the implementation:
testCanonicalizeZero— zero at any exponent canonicalizes toFLOAT_ZERO.testCanonicalizeEqualValuesByteEqual—(5, 0),(50, -1),(5000, -3),(5e10, -10)all canonicalize to the same bytes32.testCanonicalizeNegativeEqualValuesByteEqual— same for negatives.testCanonicalizeValuePreserving— canonicalize preserves numeric equality (eqcheck).testCanonicalizeIdempotent—canonicalize(canonicalize(f)) == canonicalize(f)byte-equal.testCanonicalizeValuePreservingFuzz(int224, int32)— fuzzed value preservation.testCanonicalizeIdempotentFuzz(int224, int32)— fuzzed idempotence.testCanonicalizeEqImpliesByteEqualFuzz(int128, uint8)— generates same-value shifted pairs, asserts byte-equality after canonicalize.Both fuzz tests passed at 5096 runs against the loop-multiply version. The concrete tests failed against
packLossless(maximizeFull(...))due to the issues above.Motivating use case
Came up during triage of #182 while documenting the design decision that Floats are non-canonical. The lazy-canonicalization stance makes sense for the arithmetic hot path, but means consumers that care about raw-byte equality currently have no way to get it without implementing canonicalization themselves.