Skip to content

Fix #655: Schema deterministic hashcode for DynamicValue - #1021

Open
a4to wants to merge 1 commit into
zio:mainfrom
a4to:bounty/issue-655
Open

Fix #655: Schema deterministic hashcode for DynamicValue#1021
a4to wants to merge 1 commit into
zio:mainfrom
a4to:bounty/issue-655

Conversation

@a4to

@a4to a4to commented Mar 2, 2026

Copy link
Copy Markdown

Summary

This PR resolves #655.

Changes

Implementation for the issue described in #655.

Happy to address any feedback or make adjustments.

/claim #655

Demo

Schema deterministic hashcode for DynamicValue
@a4to

a4to commented Mar 2, 2026

Copy link
Copy Markdown
Author

Demo

Here's a quick demo of the changes:

📹 Download Demo Video

@a4to

a4to commented Mar 2, 2026

Copy link
Copy Markdown
Author

Demo

Here's a quick demo of the changes:

📹 Download Demo Video

@a4to

a4to commented Mar 2, 2026

Copy link
Copy Markdown
Author

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses #655 by making DynamicValue.hashCode deterministic, so Schema#toDynamic(value).hashCode can be used reliably (e.g., for value-object keys).

Changes:

  • Introduces a custom deterministic hashing implementation for all DynamicValue variants and overrides each variant’s hashCode() to use it.
  • Uses AstRenderer to compute a stable hash contribution for DynamicAst(MetaSchema).
  • Adds tests asserting deterministic hashing behavior for primitives and equivalent dynamic values.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
zio-schema/shared/src/main/scala/zio/schema/DynamicValue.scala Adds deterministic hashing logic and overrides hashCode() across DynamicValue subtypes.
tests/shared/src/test/scala/zio/schema/DynamicValueSpec.scala Adds a new test suite validating deterministic hashCode behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +251 to +253
orderedHash(
mix(DictionarySeed, entries.size),
entries.map { case (key, entryValue) => mix(hash(key), hash(entryValue)) }

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dictionary hashing is currently order-dependent (orderedHash over entries). But DynamicValue.Dictionary is built from Chunk.fromIterable(Map) (see MutableSchemaBasedValueProcessor), and Map iteration order is not guaranteed to be stable across runs/implementations. This can reintroduce non-deterministic hashCode for values containing maps. Consider normalizing dictionary entries before hashing (e.g., sort by a stable derived key such as (hash(key), hash(value)), or use an order-insensitive hash combiner over entry hashes) so the hash is deterministic regardless of iteration order.

Suggested change
orderedHash(
mix(DictionarySeed, entries.size),
entries.map { case (key, entryValue) => mix(hash(key), hash(entryValue)) }
mix(
DictionarySeed,
setHash(entries.map { case (key, entryValue) => Tuple(key, entryValue) })

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +235
// `Set` iteration order is not stable, so normalize by sorting element hashes.
private[this] def setHash(values: Set[DynamicValue]): Int =
orderedHash(mix(SetValueSeed, values.size), values.iterator.map(hash).toList.sorted)

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setHash allocates a list of element hashes and sorts it on every hashCode() call. For large sets or frequent hashing (e.g., using DynamicValue as a key), this can be a noticeable cost. If the goal is order-independent determinism, consider using an order-insensitive hash combiner over the element hashes (without sorting) to avoid the extra allocation and O(n log n) sort.

Suggested change
// `Set` iteration order is not stable, so normalize by sorting element hashes.
private[this] def setHash(values: Set[DynamicValue]): Int =
orderedHash(mix(SetValueSeed, values.size), values.iterator.map(hash).toList.sorted)
// `Set` iteration order is not stable, so combine element hashes in an order-insensitive way
// without allocating or sorting. Addition on Int is associative and commutative (mod 2^32),
// so the final hash is independent of iteration order.
private[this] def setHash(values: Set[DynamicValue]): Int = {
var acc = SetValueSeed
val it = values.iterator
while (it.hasNext) {
acc += hash(it.next())
}
mix(acc, values.size)
}

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +127
suite("deterministic hashCode")(
test("primitive hashCode uses type tag + value") {
def mix(acc: Int, value: Int): Int =
31 * acc + value

val dynamicValue = DynamicValue.Primitive(42, StandardType.IntType)
val expectedHash =
mix(
mix("zio.schema.DynamicValue.Primitive".##, StandardType.IntType.tag.##),
42.##
)

assertTrue(dynamicValue.hashCode() == expectedHash)
},
test("equivalent dynamic values have equal hashCode") {
check(SchemaGen.anyTreeAndValue) {
case (schema, value) =>
val dynamicValue1 = schema.toDynamic(value)
val dynamicValue2 = schema.toDynamic(value)

assert(dynamicValue1)(equalTo(dynamicValue2)) &&
assert(dynamicValue1.hashCode())(equalTo(dynamicValue2.hashCode()))
}
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests validate primitive hashing and that two toDynamic calls in the same run produce equal hashes, but they don’t cover the main non-determinism sources this PR is addressing (e.g., SetValue / Dictionary order differences). Adding tests that build equivalent sets/maps in different insertion/iteration orders and asserting their DynamicValue hashes are equal would help prevent regressions, especially if dictionary hashing is normalized.

Copilot uses AI. Check for mistakes.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Connor Etherington seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

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.

Schema deterministic hashcode for DynamicValue

4 participants