Issue #591: A HashMap/HashSet key that cannot be hashed passes the checker and fails in rustc (the sibling of D15)

Open and answer this issue on GitHub.

compiler, ready · opened 2026-10-12 · updated 2026-11-03 · 4 comments

What this is, from zero

A HashMap finds a value by hashing its key, so the key type must be hashable and have an equality that is an equivalence (Rust: Hash + Eq). A float is neither (NaN != NaN), and a struct the program declares derives PartialEq, Eq and Copy where its parts allow, but not Hash.

struct P { x: i64 }
let mut a: collections::HashMap[P, i64] = collections::HashMap()
a.insert(P { x: 1 }, 1)        // the checker accepts; rustc refuses

Where it comes from

Found while building NK1247 for #589 (ordered maps: ADR-010 D10), which refuses a BTreeMap/BTreeSet key with no order. The same question for the hash maps was not asked then. Nobody’s program needs it yet; it is a Part III C.1 hole: a rustc error about generated code reaches the user.

Facts (tried on 0.0.850, nikaia run)

HashMap[P, i64] (declared struct), HashMap[f64, i64] and HashMap[Q, i64] (a struct holding an f64) all pass the checker; each fails in rustc with the method insert exists for struct HashMap<..>, but its trait bounds were not satisfied (three errors, one per map). Comparable::asking already has Strength::Totally (Eq), used only to decide derives (compares_totally); nothing asks it for a map key.

Options

Risk

(a): none beyond refusing programs that never compiled. (b): a derived Hash for a type with a custom == (there is none: equality is always derived) is the only way the two could disagree; and with #581 (hasher per class) the derive must work with both Fast and Keyed hashers (it does: Hash is hasher-independent).

Recommendation

(b), by the NK1247 pattern: Strength::Hashes in check/mod.rs, ledger column hashes, describe writes it where the crate derives Hash, the emitter derives Hash where compares_totally holds. Code NK1248 (check that it is free). If the owner wants the smaller step first: (a).

To build (after the decision)

  1. Tests first (crates/nikaia/tests/hashed_keys.rs): keys that hash pass (numbers, String, bool, char, tuples/lists/options of those, declared structs and enums of those under (b)); f64, a struct holding one, a library type without hashes are refused in Nikaia’s words; BTreeMap is not asked.
  2. Checker + ledger column + describe + described.contracts (Symbol hashes).
  3. Emitter derive under (b).
  4. Part III: column row and the code; ADR-010 D11. Version raise.

Related: #589 (ordered keys, built), #586 (checker-accepts-rustc-refuses family), #581, #497 (an unknown type is not refused: this check inherits that).

Comments

keywan-ghadami, 2026-10-12

Note from #592 (ADR-341): NK1248 and NK1249 are taken by that decision, so this issue’s check takes the next free code, not NK1248. Hash as a bound is the follow-up to #592 and shares the ledger-column pattern (orders, equates, and here hashes).


Generated by Claude Code

keywan-ghadami, 2026-10-14

Decision (owner, 2026-10-14): (b), derive

The owner took it as settled that a declared type used as a hash-map key gets a hash derived from its values. No record said so, so it is recorded now.

Where the question stood before. No ADR decided it. Two records left it to this issue:

The closest decided rules are ADR-204 and ADR-341 D4 (equality derived from the parts) and ADR-010 D10 (ordered keys, NK1247, #589). D10 refused a derived order because field order would become a promise. A hash makes no such promise. No other issue covers this.

One fact changed since this issue was written. ADR-341 D5 now lets a program write impl Eq for its own type. “Equality is always derived” (Risk above) no longer holds. A hash derived from the fields would disagree with a hand-written equality, so such a type does not hash.

Recorded in ADR-010 D11. Part I (map keys), Part III 13.5 (ledger column hashes) and C.3 (NK1260; NK1248 went to ADR-341 and NK1257 to ADR-354). ADR-204 §4 and ADR-341 §6 now point to D11. Version 0.0.942, commit 5488b2d.

In short:

  1. A HashMap/HashSet key is checked by the structural walk == uses (NK1188).
  2. A declared struct or enum hashes where every field hashes. The hash is derived from its fields in the order written, as equality is, so equal values hash alike. No one writes hasher.add(...).
  3. A float, a map, a set, and a declared type with a hand-written impl Eq do not hash. A library type hashes only where its ledger says hashes = true.
  4. A key that does not hash is NK1260 at every position a type is written. The message names the part that does not hash, or the hand-written equality.
  5. A text view hashes its text. The hash function stays ADR-010 D1-D4’s (fast or keyed by provenance, #581); the derived hash works with both.

Not decided: Hash as a bound ([T: Hash]), ADR-341 §6.

To build

  1. Tests first (crates/nikaia/tests/hashed_keys.rs).
    • Keys that hash pass: numbers, String, bool, char, Bytes, tuples/lists/options of those, and declared structs and enums of those, used in insert, get, contains_key and a HashSet.
    • NK1260 in Nikaia’s words for: f64; a struct holding one; a declared type with impl Eq; a library type without hashes.
    • BTreeMap is not asked.
  2. Checker: Strength::Hashes beside compares_totally in the walk, with impl Eq read as does not hash. Report NK1260 at every type position.
  3. Ledger: the hashes column, read for library types. nikaia describe writes it where the crate derives Hash. Add hashes = true in std.contracts / described.contracts where it holds (Symbol among them).
  4. Emitter: #[derive(Hash)] on a declared type where the walk says it hashes.
  5. Run-time test: a HashMap[P, i64] keyed by a two-field struct, two equal keys find one entry, at both hash classes (a Trusted and an Untrusted map).

The commit that builds the last step says Fixes #591.


Generated by Claude Code

keywan-ghadami, 2026-10-14

Addendum (owner’s decision): a type with its own impl Eq hashes through an impl Hash.

The decision above refused every declared type with a hand-written impl Eq as a hash key. The owner narrowed that: such a type is refused unless it also writes impl Hash. Recorded as ADR-010 D12, Part I (map keys, the traits paragraph) and Part III (NK1249, NK1260), 0.0.944 (58e366d).

impl Hash for Name {
    fn hash(ref self, mut into: Hasher) sync {
        into.add(self.text.to_lowercase())
    }
}

To build, in addition to steps 1-5 above:

  1. Checker: register Hash and Hasher as language names. Answer the key check with “hashes” for a type with an impl Hash. Apply NK1249 (foreign type, or not Eq), NK1129 (pausing body) and NK1260 (argument of add).
  2. Emitter: write impl std::hash::Hash for P { fn hash<H: std::hash::Hasher>(&self, into: &mut H) { … } } with into.add(x) as (x).hash(into), and omit the Hash derive for P.
  3. Tests:
    • Name with case-insensitive impl Eq + impl Hash as a HashMap key: "A" and "a" find the same entry;
    • the same type without impl Hash is NK1260, and the message names the impl Hash;
    • impl Hash on a type that is not Eq is NK1249;
    • into.add(1.5) is NK1260.

Generated by Claude Code

keywan-ghadami, 2026-11-03

Still there at 31e2ea7 (language 0.0.1341), and it now blocks a planned piece: the e-graph of ADR-270 D13 keeps a signature table keyed by a declared struct (an operator and its children’s ids). Part I 2.x states the rule this issue builds: a struct or an enum the program declares is [a key] where every field is, hashed from its fields as == compares them.

use std::collections
struct Key { op: u32, a: u32, b: u32 }
fn main() {
    let mut table: collections::HashMap[Key, u32] = collections::HashMap()
    table.insert(Key { op: 1, a: 2, b: 3 }, 7)
    let got = table[Key { op: 1, a: 2, b: 3 }] ?? 99
    println(f"{got}")
}

nikaia check accepts it; nikaia run fails in rustc: The method insert exists for struct HashMap<Key, u32>, but its trait bounds were not satisfied … Key: Hash. Expected: 7. Tuple keys work; per CLAUDE.md the solver does not write around this with tuples, so this probe belongs in step 1’s tests (as a tests/compiler/tests/*.nika with its .stdout, per the rule on new tests, instead of a new Rust test file).


Generated by Claude Code

Back to Open Issues