Skip to content

Add canonicalize(Float) -> Float for byte-equivalence use cases#237

Open
thedavidmeister wants to merge 3 commits into
mainfrom
2026-06-13-issue-183
Open

Add canonicalize(Float) -> Float for byte-equivalence use cases#237
thedavidmeister wants to merge 3 commits into
mainfrom
2026-06-13-issue-183

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #183.

What

Adds LibDecimalFloat.canonicalize(Float) -> Float: a public-API library function that returns a unique byte representation per numeric value, so two Floats are numerically equal iff their canonical forms are byte-equal. Intended for consumers that need raw-byte equality (mapping(Float => X) keys, hashing, set membership, content-addressed storage).

How

Floats are non-canonical by design — eq rescales before comparing rather than relying on byte equality. canonicalize picks the representative with the largest |coefficient| that fits int224 subject to the exponent staying >= type(int32).min, reached by the loop-multiply approach from the issue:

function canonicalize(Float float) internal pure returns (Float) {
    (int256 signedCoefficient, int256 exponent) = float.unpack();
    if (signedCoefficient == 0) return FLOAT_ZERO;
    unchecked {
        while (exponent > type(int32).min) {
            int256 trySignedCoefficient = signedCoefficient * 10;
            if (int224(trySignedCoefficient) != trySignedCoefficient) break;
            signedCoefficient = trySignedCoefficient;
            exponent -= 1;
        }
    }
    return packLossless(signedCoefficient, exponent);
}

This avoids the two failure modes of the obvious packLossless(maximizeFull(...)) composition described in the issue (packLossless flagging value-preserving int224 truncation as lossy; packLossy silently zeroing tiny inputs on exponent underflow). The function never reverts for any valid input Float, is idempotent, and is value-preserving (eq to the input).

Bytecode / deploy impact

canonicalize is internal and unused by the concrete DecimalFloat contract, so Solidity dead-code-eliminates it from the deployed bytecode. testDeployAddress and testExpectedCodeHashDecimalFloat both still pass — no redeploy or deploy-constant regeneration required.

Tests

New test/src/lib/LibDecimalFloat.canonicalize.t.sol (matches the draft test list in the issue):

  • testCanonicalizeZero — zero at any exponent canonicalizes to FLOAT_ZERO.
  • testCanonicalizeEqualValuesByteEqual / testCanonicalizeNegativeEqualValuesByteEqual — different representations of the same value canonicalize to identical bytes32 (positive and negative).
  • testCanonicalizeValuePreserving / testCanonicalizeIdempotent — concrete value preservation and idempotence.
  • testCanonicalizeValuePreservingFuzz(int224,int32), testCanonicalizeIdempotentFuzz(int224,int32), testCanonicalizeEqImpliesByteEqualFuzz(int128,uint8) — fuzzed, 5096 runs each.

All 8 pass. Full forge test: 459 passed, 5 failed — the 5 failures are all in LibDecimalFloatDeployProd.t.sol and are pre-existing *_RPC_URL environment failures (live-fork prod-deployment checks); they fail identically on a clean origin/main checkout in this sandbox and are unrelated to this change. forge fmt --check is clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added decimal float canonicalization to normalize numerically equal values into a stable canonical form for reliable byte-level equality.
  • Tests

    • Added extensive tests covering zero, positive/negative values, edge boundaries, idempotence, value preservation, adversarial cases, and fuzz/property tests to ensure correctness.

Floats are non-canonical by design: multiple (coefficient, exponent)
pairs encode the same numeric value and `eq` rescales before comparing
rather than relying on byte equality. Consumers that need raw-byte
equality (mapping(Float => X) keys, hashing, set membership,
content-addressed storage) had no way to obtain a canonical form.

Add `LibDecimalFloat.canonicalize` as a public-API (internal) library
function. It returns the representative with the largest |coefficient|
that fits int224 subject to the exponent staying >= int32.min, reached
by scaling the coefficient up by ten directly within those bounds. This
avoids the two failure modes of the obvious `packLossless(maximizeFull)`
composition (packLossless flagging value-preserving int224 truncation as
lossy, and packLossy silently zeroing tiny inputs on exponent
underflow). The function never reverts for any valid input Float, is
idempotent, and is value-preserving (the result is `eq` to the input).

The function is internal and unused by the concrete `DecimalFloat`
contract, so it is dead-code-eliminated from the deployed bytecode:
`testDeployAddress` and `testExpectedCodeHashDecimalFloat` still pass,
so no redeploy / deploy-constant regeneration is required.

Adds test/src/lib/LibDecimalFloat.canonicalize.t.sol covering zero,
byte-equality across representations (positive and negative),
value preservation, idempotence, and three fuzz tests (value
preservation, idempotence, eq-implies-byte-equal) at 5096 runs each.

Fixes #183

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jun 13, 2026
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@thedavidmeister, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 11 minutes and 48 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9dd3b705-7203-4cfc-a8fa-ff2413e1fddf

📥 Commits

Reviewing files that changed from the base of the PR and between 0dca9cc and 8398200.

📒 Files selected for processing (8)
  • crates/float/abi/DecimalFloat.json
  • crates/float/src/js_api.rs
  • crates/float/src/lib.rs
  • src/concrete/DecimalFloat.sol
  • src/lib/deploy/LibDecimalFloatDeploy.sol
  • test/src/concrete/DecimalFloat.canonicalize.t.sol
  • test/src/lib/LibDecimalFloat.canonicalize.t.sol
  • test_js/float.test.ts

Walkthrough

This PR adds an internal canonicalize(Float) that normalizes non-canonical Float representations by scaling the coefficient up (decrementing exponent) within int224/int32 bounds and repacking via packLossless. It also adds unit and adversarial tests validating zero handling, numeric preservation, idempotence, and that numerically equal Floats canonicalize to identical bytes.

Changes

Float Canonicalization

Layer / File(s) Summary
Canonicalize function
src/lib/LibDecimalFloat.sol
canonicalize(Float) unpacks, returns FLOAT_ZERO for zero, then multiplies the coefficient by 10 while decrementing exponent until int224 would overflow or exponent == int32.min, then repacks with packLossless to produce a deterministic, value-preserving representative.
Basic canonicalize tests
test/src/lib/LibDecimalFloat.canonicalize.t.sol
Unit and fuzz tests asserting zero canonicalization, byte-equality for numerically-equal positive and negative values, value preservation, and idempotence.
Adversarial and boundary tests
test/src/lib/LibDecimalFloat.canonicalizeAdversarial.t.sol
Adversarial concrete and fuzz tests covering no-collision at int32.min and int224 caps, cross-representation floor cases, boundary value-preservation and idempotence, and an iff property equating eq() with canonical byte-equality.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a canonicalize function for byte-equivalence use cases, which is the core purpose of this PR.
Linked Issues check ✅ Passed The implementation fully addresses all coding objectives from issue #183: canonicalize function with loop-multiply approach, comprehensive tests covering zero/equivalence/preservation/idempotence/fuzzing, and adversarial boundary cases.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing and testing canonicalize(Float) per issue #183; no out-of-scope refactoring or unrelated modifications are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-06-13-issue-183

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

thedavidmeister and others added 2 commits June 13, 2026 09:39
The existing canonicalize suite stays inside the int128/uint8 "easy
middle": every coefficient fits int224 with room to spare, every
exponent is far from int32.min, and the safety property (numerically
distinct Floats must canonicalize to byte-UNEQUAL results) is never
asserted at all.

Add test/src/lib/LibDecimalFloat.canonicalizeAdversarial.t.sol covering:

- NO-COLLISION (the documented "eq iff byte-equal" guarantee in its
  contrapositive): fuzzed over the full int224 coefficient x int32
  exponent box and over a narrow band pinned to the int32.min floor,
  asserting !eq(a,b) implies the canonical bytes differ; plus concrete
  floor-pinned and int224-cap cases. Kills a mutant that forces the
  packed exponent to int32.min (which collapses distinct values).
- eq-iff-byte-equal in BOTH directions over raw int224/int32 inputs.
- Cross-representation uniqueness at the int32.min floor: the same value
  reached via a floor-pinned rep and a one-step-higher rep must produce
  identical bytes. Kills an off-by-one mutant on the `exponent >
  type(int32).min` floor condition that the existing tests miss.
- Value preservation + idempotence pinned to the int32.min floor band
  (coefficient NOT maximised) and to the int224.min/int224.max
  coefficient edges. Kills mutants that drop the int224 overflow guard
  or change the x10 scaling step.

All tests pass on the unmutated implementation at 5096 fuzz runs and
were mutation-validated against four production mutations (drop the
int224 guard, off-by-one floor, x100 scaling step, force-floor
exponent); each mutation is killed by at least one new test. No
production code change.

The scaling/floor structure was additionally proved injective and
collision-free by exhaustive enumeration over scaled-down int224/int32
analogues, so no bug was found; these tests pin that down at the real
boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ncrete

Rework per maintainer review.

Requirement 1 — tests organized per source, not a separate "adversarial"
file. Moved every test from
test/src/lib/LibDecimalFloat.canonicalizeAdversarial.t.sol into the
existing source-organized test/src/lib/LibDecimalFloat.canonicalize.t.sol
(boundary/no-collision/floor/int224-extreme tests) and deleted the
adversarial file. The int224/int32 boundary constants moved with them
(dropping the unused INT32_MAX). 19 tests, all passing.

Requirement 2 — expose canonicalize in the concrete for deployment + wasm.
LibDecimalFloat.canonicalize was internal, so it was dead-code-eliminated
from the deployed DecimalFloat contract and unreachable from Rust/JS. Added
canonicalize(Float) returns (Float) as an external pure method on
src/concrete/DecimalFloat.sol (matching abs/minus). Wired it through the
Rust layer: Float::canonicalize in crates/float/src/lib.rs (revm-delegating)
and the canonicalize wasm export in crates/float/src/js_api.rs. Added a
concrete fuzz test asserting the deployed method matches the lib result, a
Rust doctest, and a JS binding test. Regenerated the committed ABI
(crates/float/abi/DecimalFloat.json) via CopyArtifacts.

Deploy consequence — exposing the method changes DecimalFloat's bytecode,
hence its Zoltu address + codehash. Updated the pinned constants in
src/lib/deploy/LibDecimalFloatDeploy.sol to the new values:
  ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = 0x6b7C246F02E67299b5801f8215d7f40abD82056d
  DECIMAL_FLOAT_CONTRACT_HASH = 0x61bbc303586dc1b233644acdebe38dcc757907d7b39edcf6b1152c2081cf3197
so testDeployAddress / testExpectedCodeHashDecimalFloat pass against the new
bytecode. An on-chain redeploy
(manual-sol-artifacts.yaml -f suite=decimal-float) is still required to land
the new bytecode at this address; that is a separate human-triggered step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

Add canonicalize(Float) -> Float for byte-equivalence use cases

1 participant