Skip to content

Hash the sanitized form, so Equals and GetHashCode agree - #103

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/hash-sanitized-form
Sep 24, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/hash-sanitized-form

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #101.

The bug

Equals compares PreciseNumber values numerically, via Compare — it aligns exponents and compares significands, so 2.5 and an un-sanitized 2.50 (Significand=250, Exponent=-2) are equal. GetHashCode hashed the raw (Exponent, Significand) fields, so those same two values produced different hashes. That violates .NET's Equals/GetHashCode contract.

Un-sanitized instances are reachable through CreateFromComponents(exponent, significand, sanitize: false) and through MakeCommonized / MakeCommonizedWithExponent — all internal, all documented as kept for the ktsu.SignificantNumber derived library, and all of which hand back un-normalized values directly rather than only as transient internal state.

The practical failure, reproduced on main:

lookup = { CreateFromComponents(-1, 25): "two and a half" }   // 2.5
lookup.TryGetValue(CreateFromComponents(-2, 250, sanitize: false))   ->  false

Two Equals-equal values, two different buckets, a lookup that silently misses.

Public Parse and the arithmetic paths always sanitize first, so ordinary usage is unaffected — the exposure is to code consuming those internal escape hatches.

The fix

Strip the trailing zeros before combining, so the hash is taken over the sanitized form however the instance was built:

  • A zero significand hashes as the sanitized zero, whatever exponent it happens to be stored at (an un-sanitized zero keeps its exponent; a sanitized one is forced to 0).
  • Otherwise CountTrailingZeros — the same helper the sanitizing constructor uses — gives the shift, and the significand is divided down with the exponent moved up to match.
  • The common case, an already-sanitized value, hits trailingZeros == 0 and combines the stored fields exactly as before, so no existing hash value changes.
  • The exponent adjustment is unchecked, because GetHashCode must not throw. The constructor does the same addition checked, so an exponent that could overflow here cannot survive ordinary arithmetic anyway, and a wrapped value still sends Equals-equal pairs to the same bucket.

Tests

Three tests added to PreciseNumberTests, all verified to fail against the previous implementation (3 failed before the change, 383 passed after):

  • TestGetHashCodeAgreesWithEqualsForUnsanitizedValues — seven (exponent, significand) pairs built both ways, asserting Equals and hash equality against the sanitized form: 2.50, 2.500, 100 spelled two ways, a negative -25.0, and zero at both a zero and a non-zero exponent.
  • TestGetHashCodeAgreesWithEqualsAfterCommonizing — covers the other route to an un-normalized instance, MakeCommonized, rather than only the explicit sanitize: false flag.
  • TestUnsanitizedValuesShareADictionarySlotWithTheirSanitizedForm — the contract violation as a caller actually meets it: a Dictionary lookup that used to miss.

Full suite on the branch: 383 passed, 0 failed (380 pre-existing, unchanged — including the existing TestGetHashCode).

Scope

One method and three tests. No public API change. Independent of #102 (also against main); the two touch different methods and do not conflict.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FLcTd3PKEVEnz3UcufaB4o


Generated by Claude Code

Equals compares numerically, so an un-sanitized 2.50 - significand 250 at
exponent -2 - equals a sanitized 2.5. GetHashCode hashed the raw fields, so
the two produced different hashes, violating the Equals/GetHashCode contract:
they land in different buckets of a Dictionary or HashSet and lookups miss.

Such instances are reachable through CreateFromComponents(sanitize: false)
and MakeCommonized/MakeCommonizedWithExponent, all internal and all kept for
ktsu.SignificantNumber, and all of which return un-normalized values directly
rather than only as transient internal state.

Strip the trailing zeros before combining. Zero hashes as the sanitized zero
whatever exponent it was stored at, and the exponent adjustment is unchecked
because a hash must not throw.

Fixes #101

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

Copy link
Copy Markdown

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.

GetHashCode hashes raw Exponent/Significand, breaking the Equals contract for un-sanitized values

2 participants