Keep a nil nested in a collection used as a map key (jolt-zcm9) (#157)

canon-key canonicalizes a collection key by re-keying a native Janet
table by the canonical form of each element/entry. canon-key returns nil
for nil, and a Janet struct can't hold a nil key or value, so a nil set
member / nil map key / nil map value was dropped during canonicalization
— #{nil 1} canonicalized like #{1} and collided as a map key. So
(count {#{nil 1} :a, #{1} :b}) was 1 and (get {#{nil 1} :a} #{1}) was :a.

Box a nested nil before it goes into the table. The marker has to be
value-hashable, not the identity-hashed mutable-table sentinel the
transients use: the canonical struct becomes a long-lived phm key, and
its hash must survive the marshal/snapshot/fork that init-cached relies
on — an unmarshalled table gets a fresh address, so its hash isn't
preserved and the map can't find its own key. An interned keyword hashes
by content. Collision risk is only a real value equal to that exact
keyword, the same negligible class as canon-key's existing set/map struct
aliasing.

The transient sentinel stays a mutable table (it's built and consumed
within one op, never crossing a marshal boundary, so identity hashing is
stable there).

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-17 02:11:38 +00:00 committed by GitHub
parent 630eb2b9d3
commit 8192fc9541
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 15 deletions

View file

@ -72,6 +72,20 @@
["frequencies of maps" "2" "(get (frequencies [{:a 1} (hash-map :a 1)]) {:a 1})"]
["group-by collection key" "1" "(count (group-by identity [{:a 1} (hash-map :a 1)]))"])
# A nil element/key/value INSIDE a collection used as a map key must survive key
# canonicalization — it used to be dropped, so the nil-containing collection
# collided with the nil-free one (jolt-zcm9).
(defspec "map / nil inside a collection key (jolt-zcm9)"
["set key w/ nil distinct" "2" "(count {#{nil 1} :a, #{1} :b})"]
["set key w/ nil neg lookup" "nil" "(get {#{nil 1} :a} #{1})"]
["set key w/ nil pos lookup" ":a" "(get {#{nil 1} :a} #{nil 1})"]
["set key just nil distinct" "false" "(= {#{nil} :x} {#{} :x})"]
["map nil-value key distinct" "2" "(count {{:a nil} 1, {} 2})"]
["map nil-value key neg" "nil" "(get {{:a nil} 1} {})"]
["map nil-value key pos" "1" "(get {{:a nil} 1} {:a nil})"]
["map nil-key key distinct" "2" "(count {{nil :a} 1, {} 2})"]
["map nil-key key pos" "1" "(get {{nil :a} 1} {nil :a})"])
# Strictness: assoc bounds-checks vector indices; dissoc requires a map;
# count rejects scalars; numerator/denominator have no ratio type.
(defspec "map / strictness (throws like Clojure)"