Keep a nil element in sets and transient sets (jolt-bn2p) (#156)

Two paths dropped a nil set member. phs-seq read members via
phm-to-struct, whose Janet struct can't hold a nil key, so the nil was
lost on seq/print and on into-an-existing-set even though the backing phm
counted it (count/contains? then disagreed). Re-attach the nil from the
phm's has-nil slot, keeping struct-key order for the rest so set printing
stays stable.

The transient set keyed its native table by canon-key and stored the
member as the value; canon-key returns nil for nil and a nil value also
drops the entry, so conj!/disj!/contains?/persistent! lost a nil member
outright. Key by tbl-key (the same nil sentinel the transient map uses)
and box a nil value through tbl-nil-key, unboxing on persistent!.

A nil-containing set used as a map key still collides with the nil-free
one (canon-key drops nil during key canonicalization) — separate latent
bug, filed as jolt-zcm9.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-17 01:43:23 +00:00 committed by GitHub
parent 2668a76837
commit 630eb2b9d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 43 additions and 7 deletions

View file

@ -183,7 +183,7 @@
(case (m :kind) (case (m :kind)
:vector (if (and (number? k) (>= k 0) (< k (length (m :arr)))) (in (m :arr) k) default) :vector (if (and (number? k) (>= k 0) (< k (length (m :arr)))) (in (m :arr) k) default)
:map (let [p (get (m :tbl) (tbl-key k))] (if p (in p 1) default)) :map (let [p (get (m :tbl) (tbl-key k))] (if p (in p 1) default))
:set (if (nil? (get (m :tbl) (canon-key k))) default k)) :set (if (nil? (get (m :tbl) (tbl-key k))) default k))
(if (set? m) (phs-get m k default) (if (set? m) (phs-get m k default)
(if (phm? m) (phm-get m k default) (if (phm? m) (phm-get m k default)
(if (pvec? m) (if (pvec? m)
@ -251,7 +251,7 @@
(if (core-transient? coll) (if (core-transient? coll)
(case (coll :kind) (case (coll :kind)
:vector (and (number? key) (>= key 0) (< key (length (coll :arr)))) :vector (and (number? key) (>= key 0) (< key (length (coll :arr))))
(not (nil? (get (coll :tbl) (if (= :map (coll :kind)) (tbl-key key) (canon-key key)))))) (not (nil? (get (coll :tbl) (tbl-key key)))))
(if (set? coll) (phs-contains? coll key) (if (set? coll) (phs-contains? coll key)
(if (phm? coll) (phm-contains? coll key) (if (phm? coll) (phm-contains? coll key)
(if (pvec? coll) (and (number? key) (>= key 0) (< key (pv-count coll))) (if (pvec? coll) (and (number? key) (>= key 0) (< key (pv-count coll)))

View file

@ -338,7 +338,7 @@
(pvec? coll) (pvec? coll)
@{:jolt/type :jolt/transient :kind :vector :arr (pv->array coll)} @{:jolt/type :jolt/transient :kind :vector :arr (pv->array coll)}
(set? coll) (set? coll)
(let [t @{}] (each e (phs-seq coll) (put t (canon-key e) e)) (let [t @{}] (each e (phs-seq coll) (put t (tbl-key e) (tbl-box e)))
@{:jolt/type :jolt/transient :kind :set :tbl t}) @{:jolt/type :jolt/transient :kind :set :tbl t})
(or (phm? coll) (and (struct? coll) (nil? (get coll :jolt/type)))) (or (phm? coll) (and (struct? coll) (nil? (get coll :jolt/type))))
(let [t @{}] (let [t @{}]
@ -360,7 +360,7 @@
(tr-check-active! t) (tr-check-active! t)
(case (t :kind) (case (t :kind)
:vector (array/push (t :arr) x) :vector (array/push (t :arr) x)
:set (put (t :tbl) (canon-key x) x) :set (put (t :tbl) (tbl-key x) (tbl-box x))
:map (cond :map (cond
# a [k v] pair (map-entry / 2-vector) # a [k v] pair (map-entry / 2-vector)
(and (or (pvec? x) (tuple? x) (array? x)) (and (or (pvec? x) (tuple? x) (array? x))
@ -410,7 +410,7 @@
(defn core-disj! [t & xs] (defn core-disj! [t & xs]
(if (and (core-transient? t) (= :set (t :kind))) (if (and (core-transient? t) (= :set (t :kind)))
(do (tr-check-active! t) (each x xs (put (t :tbl) (canon-key x) nil)) t) (do (tr-check-active! t) (each x xs (put (t :tbl) (tbl-key x) nil)) t)
(error "disj! requires a transient set"))) (error "disj! requires a transient set")))
(defn core-pop! [t] (defn core-pop! [t]
@ -431,7 +431,7 @@
# persistent value ONCE (bottom-up HAMT) instead of folding a phm-assoc # persistent value ONCE (bottom-up HAMT) instead of folding a phm-assoc
# per entry. This is the lever behind every transient-based builder # per entry. This is the lever behind every transient-based builder
# (frequencies/group-by/set/into) — jolt-5vsp collections. # (frequencies/group-by/set/into) — jolt-5vsp collections.
:set (phs-from-seq (values (t :tbl))) :set (phs-from-seq (map tbl-unbox (values (t :tbl))))
:map (phm-from-pairs (values (t :tbl))))) :map (phm-from-pairs (values (t :tbl)))))
# Invalidate: any further bang op (or a second persistent!) now throws. # Invalidate: any further bang op (or a second persistent!) now throws.
(put t :jolt/persistent true) (put t :jolt/persistent true)

View file

@ -104,6 +104,14 @@
(def tbl-nil-key @{}) (def tbl-nil-key @{})
(defn tbl-key [k] (if (nil? k) tbl-nil-key (canon-key k))) (defn tbl-key [k] (if (nil? k) tbl-nil-key (canon-key k)))
# A transient SET stores `(tbl-key x) -> x`, i.e. the member IS the table value.
# A nil member can't be a Janet table value either (put with a nil value drops
# the entry), so box nil as the same sentinel and unbox on read-back. The
# sentinel is a fresh table canon-key never produces, so it can't collide with a
# real member.
(defn tbl-box [x] (if (nil? x) tbl-nil-key x))
(defn tbl-unbox [v] (if (= v tbl-nil-key) nil v))
# All [k v] entries of a map (struct or phm), nil-valued keys included. Use this # All [k v] entries of a map (struct or phm), nil-valued keys included. Use this
# instead of (keys (phm-to-struct m)) — phm-to-struct drops keys whose value is # instead of (keys (phm-to-struct m)) — phm-to-struct drops keys whose value is
# nil, which is exactly what Clojure maps must keep. # nil, which is exactly what Clojure maps must keep.

View file

@ -297,6 +297,8 @@
(defn phm-count [m] (m :cnt)) (defn phm-count [m] (m :cnt))
(defn phm-has-nil? [m] (truthy? (m :has-nil)))
# --- bulk bottom-up build (jolt-5vsp collections) --------------------------- # --- bulk bottom-up build (jolt-5vsp collections) ---------------------------
# Build the HAMT in one pass from a native array of entries, instead of n # Build the HAMT in one pass from a native array of entries, instead of n
# incremental phm-assoc calls (each of which rebuilt the O(log32 n) path with # incremental phm-assoc calls (each of which rebuilt the O(log32 n) path with

View file

@ -48,7 +48,12 @@
(= 0 (s :cnt))) (= 0 (s :cnt)))
(defn phs-seq [s] (defn phs-seq [s]
(tuple ;(keys (phm-to-struct (s :phm))))) # phm-to-struct drops a nil member (a Janet struct can't hold a nil key), so
# re-attach it from the phm's has-nil slot. Keep the struct-key order for the
# non-nil members so set printing stays stable.
(def m (s :phm))
(def ks (keys (phm-to-struct m)))
(if (phm-has-nil? m) (tuple nil ;ks) (tuple ;ks)))
(defn phs-get [s x &opt default] (defn phs-get [s x &opt default]
(default default nil) (default default nil)

View file

@ -33,6 +33,27 @@
["dedup equal maps" "1" "(count (set [{:a 1} (hash-map :a 1)]))"] ["dedup equal maps" "1" "(count (set [{:a 1} (hash-map :a 1)]))"]
["vector elements" "true" "(contains? #{[1 2]} (vec [1 2]))"]) ["vector elements" "true" "(contains? #{[1 2]} (vec [1 2]))"])
(defspec "set / nil element (jolt-bn2p)"
# canon-key returns nil for nil and Janet tables drop a nil key, so a nil
# member used to be silently lost while count/contains? disagreed.
["set keeps nil" "2" "(count (set [nil 1 nil]))"]
["contains? nil true" "true" "(contains? (set [nil 1]) nil)"]
["contains? nil false" "false" "(contains? #{1} nil)"]
["seq includes nil" "true" "(some nil? (seq (set [nil 1])))"]
["disj nil" "#{1}" "(disj (set [nil 1]) nil)"]
["disj nil count" "1" "(count (disj (set [nil 1]) nil))"]
["conj nil count" "2" "(count (conj #{1} nil))"]
["conj nil contains?" "true" "(contains? (conj #{1} nil) nil)"]
["into #{} keeps nil" "2" "(count (into #{} [nil 1]))"]
["into #{} contains? nil" "true" "(contains? (into #{} [nil 1]) nil)"]
["into keeps existing nil" "true" "(contains? (into #{nil} [1]) nil)"]
# transient set path: tr-conj!/persistent!/disj!/contains?
["transient conj! nil" "2" "(count (persistent! (conj! (transient #{}) nil 1)))"]
["transient contains? nil" "true" "(contains? (persistent! (conj! (transient #{}) nil 1)) nil)"]
["transient disj! nil cnt" "1" "(count (persistent! (disj! (conj! (transient #{}) nil 1) nil)))"]
["transient disj! removes" "false" "(contains? (persistent! (disj! (conj! (transient #{}) nil 1) nil)) nil)"]
["transient of set w/ nil" "true" "(contains? (persistent! (transient (set [nil 1]))) nil)"])
(defspec "clojure.set" (defspec "clojure.set"
["union" "#{1 2 3 4}" "(do (require (quote [clojure.set :as s])) (s/union #{1 2} #{3 4}))"] ["union" "#{1 2 3 4}" "(do (require (quote [clojure.set :as s])) (s/union #{1 2} #{3 4}))"]
["intersection" "#{2}" "(do (require (quote [clojure.set :as s])) (s/intersection #{1 2} #{2 3}))"] ["intersection" "#{2}" "(do (require (quote [clojure.set :as s])) (s/intersection #{1 2} #{2 3}))"]