Skip to content

Add an optional Hash impl for Tree and TreeOp - #446

Open
virtualritz wants to merge 1 commit into
mkeeter:mainfrom
virtualritz:feat/optional-hash-for-tree
Open

Add an optional Hash impl for Tree and TreeOp#446
virtualritz wants to merge 1 commit into
mkeeter:mainfrom
virtualritz:feat/optional-hash-for-tree

Conversation

@virtualritz

Copy link
Copy Markdown
Contributor

Adds Hash for Tree and TreeOp behind a new default-off hash feature on
fidget-core, passed through as fidget/hash on the facade.

Why

A Tree is a natural cache key — it fully describes the shape that will be
built from it, so a consumer that compiles or meshes a tree wants to memoise on
it. Without Hash the options are:

  • hash the Debug string — version-fragile, and it expands shared subtrees, so
    two equal trees can produce different keys; or
  • maintain a parallel structural hash outside the crate, which then has to be
    kept consistent with PartialEq by hand.

We've been carrying this as a fork patch and would rather not.

What

The impl is purely structural, matching Tree's existing PartialEq, so the
Hash/Eq contract holds. Two details worth your eye:

  • Signed zero. +0.0 == -0.0, so they must hash equally. hash_f64
    normalises zero before hashing bits — a naive to_bits() would violate the
    contract for a constant that's trivially easy to produce.
  • Deep trees. The hash walks a worklist Vec rather than recursing,
    matching what Drop and PartialEq already do, so a long chain doesn't
    overflow the stack. There's a test at 100k nodes.

Tests

Four, covering the contract rather than just the happy path:

Test Asserts
equal_trees_hash_equally the Hash/Eq contract
signed_zero_hashes_equally +0.0/-0.0 don't break it
different_trees_hash_differently the hash is actually useful as a key
deep_tree_does_not_overflow_the_stack 100k-node chain

Verified both feature states: cargo check -p fidget-core clean with the
feature off, and cargo test -p fidget-core --features hash passes 4/4.

On the feature gate

Default-off because it commits the crate to the structural-hashing contract. It
pulls in no dependencies and costs nothing when disabled — happy to make it
unconditional instead if you'd prefer that
; just say which you want and I'll
push the change.

Behind a new default-off `hash` feature on `fidget-core`, passed through as
`fidget/hash` on the facade.

A `Tree` is a natural cache key: it fully describes the shape that will be
built from it, so a consumer that compiles or meshes a tree wants to memoise
on it. Without `Hash` the only options are hashing the `Debug` string --
version-fragile, and it expands shared subtrees so two equal trees can produce
different keys -- or maintaining a parallel structural hash outside the crate,
which then has to be kept consistent with `PartialEq` by hand.

The impl is purely structural, matching `Tree`'s existing `PartialEq`, so the
`Hash`/`Eq` contract holds. Two details worth review:

- **Signed zero.** `+0.0 == -0.0`, so they must hash equally; `hash_f64`
  normalises zero before hashing bits. A naive `to_bits()` would violate the
  contract for a constant that is easy to produce.
- **Deep trees.** The hash walks a worklist `Vec` rather than recursing,
  matching what `Drop` and `PartialEq` already do, so a long chain does not
  overflow the stack. There is a test at 100k nodes.

Default-off because it commits the crate to that hashing contract. It pulls in
no dependencies and costs nothing when disabled -- happy to make it
unconditional instead if you would prefer that.

Four tests cover the contract: equal trees hash equally, signed zero, distinct
trees hash distinctly, and the deep-tree stack case.
@mkeeter

mkeeter commented Aug 11, 2026

Copy link
Copy Markdown
Owner

I'm not going to merge this PR as-is, for two reasons:

  1. I don't think this handles NaN values correctly, either in a Tree::Const or the RemapAffine matrix. This is not entirely your fault; I've just now realized that Tree itself should not implement Eq, because it can contain floats and f32::NAN != f32::NAN. I will be opening a PR to remove this shortly! Still, it's going to make your life more difficult: if you stored a Tree::Const(NaN) in a hashmap, it would hash to the same value but compare as false, which seems like a recipe for problems. See this thread for lots of discussion.

  2. I'm iffy about committing LLM-generated code to Fidget. This is a borderline case, because you're a known contributor and the code is relatively short, but it still rubs me the wrong way. I would like for a human (usually me) to have a strong mental model of the code, and feel that it's hard to acquire a strong mental model for code that you didn't write; reading the answers does not produce the same mental effects as working through the problem.

I'm not against making trees hashable, but I don't think we want to switch to bitcasting equality in the general-purpose PartialEq implementation (which would be necessary to work around NaN issues). Perhaps a wrapper TreeKey type which implements bitcasting behavior for equality and hashing?

@mkeeter

mkeeter commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Alternatively, we could borrow ordered_float's semantics, which are well-thought-out. Three slight variations here:

  • Store OrderedFloat values directly in TreeOp, which is a bit awkward for the matrix
  • Cast to them for the purposes of PartialEq and Hash implementations
  • Cast to them for the purposes of PartialEq and Hash implementations in a TreeKey wrapper type, leaving the standard float semantics for Tree (i.e. PartialEq only)

@mkeeter mkeeter mentioned this pull request Aug 11, 2026
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.

2 participants