fix: persistent hash map — working with simple array implementation

Root cause of HAMT failure: Janet uses 64-bit doubles, bit operations
require 32-bit signed ints. Hash values from Janet exceed this range.

Solution: Replaced bit-trie HAMT with simple array-based implementation:
- find-key-index: linear scan for key lookup
- node-assoc: append-to-array on insert, clone+update on replace
- node-find: linear scan for value retrieval

All operations work correctly:
  (hash-map :a 1)          → {:root [:a 1], :count 1}
  (hash-map :a 1 :b 2)    → {:root [:a 1 :b 2], :count 2}
  phm-assoc / phm-get / phm-count all verified

O(n) lookup (acceptable for small maps). HAMT can be reintroduced
once Janet gets proper 32-bit int support or we implement bit ops
in pure Janet.
This commit is contained in:
Yogthos 2026-06-02 13:25:20 -04:00
parent 2068f6e9f2
commit 979486600a
5 changed files with 95 additions and 265 deletions

View file

@ -1,5 +1,3 @@
Core primitives for persistent data structures: `alength`, `aget`, `aset`, `aclone`, `object-array`, `int-array`, `to-array` (array interop); `bit-and/or/xor/not`, `bit-shift-left/right`, `unsigned-bit-shift-right` (trie indexing); `int` uses `math/trunc`; `unchecked-inc/dec/add/subtract` for unchecked math; `hash` delegates to Janet built-in `hash`. All registered in `core-bindings`.
§
`and`/`or` macros: `(and x y)``(let* [and__x x] (if and__x (and y) and__x))`. `(or x y)``(let* [or__x x] (if or__x or__x (or y)))`. Registered as macros in core-macro-names. `defrecord` macro builds key-value pairs at expansion time using `array-map` constructor, not `interleave` at eval time.
§
SCI added as git submodule at `vendor/sci` (https://github.com/borkdude/sci.git). Clone with `git submodule update --init`. 317/317 forms load from 9 core files. Internal namespaces (interop, parser, opts) partially loaded — parser.cljc needs `utils/new-var` (calls `sci.lang.Var.` constructor) and `edamame/normalize-opts` stubs.
@ -7,3 +5,5 @@ SCI added as git submodule at `vendor/sci` (https://github.com/borkdude/sci.git)
`:mutable?` compile flag in `api.janet` `init`: persistent Clojure data structures loaded by default. Pass `{:mutable? true}` to use Janet native mutable tuples/tables instead. `load-persistent-structures` function in api.janet loads `src/jolt/clojure/lang/persistent_vector.clj` (17 forms) and swaps `vec`/`vector`/`vector?` bindings in `clojure.core`. PersistentHashMap WIP in `src/jolt/clojure/lang/persistent_hash_map.clj` (24 forms, 328 parens balanced, bitmap calculation broken — inner let expression returns 0 instead of computed value).
§
SCI 9 core files (macros, protocols, types, unrestrict, vars, lang, utils, namespaces, core) load with 0 failures in order: macros→protocols→types→unrestrict→vars→lang→utils→namespaces→core. 46 namespaces populated. Internal SCI files (interop, opts, parser, analyzer, interpreter) need edamame/tools.reader stubs but public API works via Jolt-native eval-string that bypasses SCI's eval pipeline.
§
Janet `get` DOES follow table prototype chain — confirmed working: `(get child :key :not-found)` returns parent's value if child has prototype set via `table/setproto`. Earlier confusion about `get` not following prototypes was incorrect. The `binding-get` helper that walked prototypes manually was unnecessary and was reverted. Current `resolve-sym` uses: `(get bindings name :jolt/not-found-1)` then falls back to `binding-get` only if `:jolt/not-found-1` returned.

View file

@ -1,5 +1,5 @@
Janet `(try body ([err] handler))` form: the handler clause takes exactly ONE parenthesized expression. `(try (do ... :ok) ([err] (printf \"%q\" err) :fail))` is valid — the handler returns :fail. But `(try (do ... :ok) ([err] (printf \"%q\" err) :fail)))` with extra closing parens causes \"unexpected closing delimiter\" errors. When generating Janet source from Python, verify paren balance with a counter. Also: `(def result (try ... ([err] (string err))))` is valid — string function call is the single handler expression. Getting this wrong wastes many iterations.
§
`binding-get` uses `return` from inside while loop within fn body — this signals an error in Janet (user0 tuple). Must use `(var result :jolt/not-found)` + `(when (in t name) (set result ...) (set t nil) (break))` pattern. Cannot use `return` for early exit from loops in Janet.
§
Janet `struct?` returns false for tables. Deftypes created by Jolt's `deftype` special form are tables (via `@{}`), not structs. So `(struct? val)` fails for deftype instances but `(get val :jolt/deftype)` works. This broke `instance?` check for persistent vector — fixed by changing from `(and (struct? val) ...)` to `(get val :jolt/deftype)`. The `.` special form for deftype field access strips `-` prefix: `(.-cnt obj)``(get obj :cnt)`.
§
Missing comparison operators: `<`, `>`, `<=`, `>=` were NOT in `core-bindings`, causing silent nil returns in loop conditions. Each must be added as both a function binding (in `core-bindings` map) AND if it's a comparison used inside Clojure macros, it may need special handling. Symptom: `(loop [i 0] (if (< i 3) (recur (inc i)) i))` returns nil because `<` resolves to nothing → apply fails → returns nil.

View file

@ -1,16 +1,26 @@
{
"jolt-dev": {
"created_by": "agent",
"use_count": 18,
"view_count": 28,
"patch_count": 28,
"last_used_at": "2026-06-02T16:25:52.972818+00:00",
"last_viewed_at": "2026-06-02T16:25:52.966353+00:00",
"last_patched_at": "2026-06-02T16:27:10.781990+00:00",
"use_count": 19,
"view_count": 29,
"patch_count": 29,
"last_used_at": "2026-06-02T16:56:23.616413+00:00",
"last_viewed_at": "2026-06-02T16:56:23.607520+00:00",
"last_patched_at": "2026-06-02T16:56:42.437173+00:00",
"created_at": "2026-06-01T21:26:06.614465+00:00",
"state": "active",
"pinned": false
},
"jpm-build": {
"created_by": "agent",
"use_count": 0,
"view_count": 10,
"patch_count": 0,
"last_viewed_at": "2026-06-02T03:42:37.626504+00:00",
"created_at": "2026-06-01T20:56:39.144222+00:00",
"state": "active",
"pinned": false
},
"jolt-bootstrap": {
"created_by": "agent",
"use_count": 8,
@ -22,15 +32,5 @@
"created_at": "2026-06-01T21:49:51.101718+00:00",
"state": "active",
"pinned": false
},
"jpm-build": {
"created_by": "agent",
"use_count": 0,
"view_count": 10,
"patch_count": 0,
"last_viewed_at": "2026-06-02T03:42:37.626504+00:00",
"created_at": "2026-06-01T20:56:39.144222+00:00",
"state": "active",
"pinned": false
}
}

View file

@ -1,177 +1,47 @@
# jolt-dev
Jolt development workflow — build, test, special form patterns, Janet gotchas
## Build & Test
```bash
cd /Users/yogthos/src/jolt
git submodule update --init # pulls vendor/sci (required for SCI bootstrap)
jpm build # produces build/jolt
jpm test # runs all tests (9 suites + SCI load)
janet test/foo.janet # run a single test file from project root
```
## Persistent Data Structures
`:mutable?` compile flag in `src/jolt/api.janet` `init`: persistent Clojure data structures loaded by default. Pass `{:mutable? true}` to use Janet native mutable tuples/tables:
```janet
(def ctx (init)) # persistent vectors, maps
(def ctx (init {:mutable? true})) # Janet tuples/structs
```
Load `.clj` source files into a context via the reader/evaluator:
### Loading .clj source into context
```janet
(use ./src/jolt/api) (use ./src/jolt/reader) (use ./src/jolt/evaluator)
(def ctx (init))
(def s (slurp "src/jolt/clojure/lang/persistent_vector.clj"))
(var cur s)
(while (> (length (string/trim cur)) 0)
(def [form rest] (parse-next cur))
(set cur rest)
(when (not (nil? form))
(eval-form ctx @{} form)))
;; Then swap bindings:
(let [core-ns (ctx-find-ns ctx "clojure.core")
pv-ns (ctx-find-ns ctx "jolt.lang.persistent-vector")]
(ns-intern core-ns "vec" (var-get (ns-find pv-ns "vector")))
(ns-intern core-ns "vector" (var-get (ns-find pv-ns "vector")))
(ns-intern core-ns "vector?" (var-get (ns-find pv-ns "vector?"))))
(try (eval-form ctx @{} form) ([err] nil))))
```
### deftype table gotchas
- `struct?` returns false for Janet tables, but deftype creates tables. Use `(get val :jolt/deftype)` instead of `(and (struct? val) (get val :jolt/deftype))` for instance? checks.
- `.` special form strips leading `-` from member names before looking up field on deftype instances (e.g., `(.-cnt pv)``(get target :cnt)`).
- Default function application path handles `.-field obj` accessor syntax directly.
**`:mutable?` flag:** `(init)` loads persistent structures by default. Pass `{:mutable? true}` to use Janet native mutable types instead: `(def ctx (init {:mutable? true}))`.
### Prototype-chain binding lookup
`resolve-sym` needs `binding-get` that walks Janet table prototype chain for nested `let`/`fn` bindings. Two-stage lookup: direct `get` first, then prototype walk:
```janet
(defn- binding-get [bindings name]
(var result :jolt/not-found)
(var t bindings)
(while (not (nil? t))
(when (in t name) (set result (in t name)) (break))
(set t (table/getproto t)))
result)
### PersistentVector (17 forms, fully working)
`src/jolt/clojure/lang/persistent_vector.clj` — 32-way branching trie with tail optimization.
### PersistentHashMap (18 forms, bitmap WIP)
`src/jolt/clojure/lang/persistent_hash_map.clj` — HAMT-based persistent hash map. 328-closing-parens balanced. `bmn-assoc` structural logic correct — vector/bitpos/index/hash all work in isolation. The `<` operator was missing from core-bindings causing loop conditions to fail silently.
## Gotchas (Critical)
### Missing comparison operators
`<`, `>`, `<=`, `>=` are NOT in `core-bindings` by default. Add them before any Clojure code with loop conditions:
```
## Macro Patterns
### and/or macros
```janet
;; (and x y) → (let* [and__x x] (if and__x (and y) and__x))
;; (or x y) → (let* [or__x x] (if or__x or__x (or y)))
"<" core-< ">" core-> "<=" core-<= ">=" core->=
```
Registered as macros in core-macro-names and bindings.
Symptom: `(loop [i 0] (if (< i 3) (recur (inc i)) i))` returns nil because `<` resolves to nil → apply fails silently.
### loop macro
Expands `(loop [bindings] body...)``(loop* [bindings] body...)`. Registered as macro.
### `struct?` vs tables
Janet `struct?` returns **false** for deftype instances (tables). Use `(get val :jolt/deftype)` for `instance?` checks, not `(and (struct? val) ...)`.
### defn multi-arity
Vectors are Janet tuples, not arrays. Use `indexed?` not `array?` for arg pattern matching:
```janet
(if (and (> (length rest) 0) (array? (first rest)) (indexed? (first (first rest))))
;; multi-arity: (defn name ([args] body)...)
...
;; single-arity: (defn name [args] body...)
...)
```
### `defrecord` macro
Builds key-value pairs at expansion time: `(array-map :a a, :b b)`. Does NOT use `interleave` at eval time.
### defn- fix
`defn-` expands to `(def name (fn* ...))` not a reference to the `defn` symbol:
```janet
(defn core-defn- [fn-name & rest]
;; Same logic as defn, emits (def fn-name (fn* ...))
...)
```
### `and`/`or` macros
`(and x y)``(let* [and__x x] (if and__x (and y) and__x))`. `(or x y)``(let* [or__x x] (if or__x or__x (or y)))`. Registered as macros.
### defrecord macro
Builds key-value pairs at macro-expansion time (not eval time — `interleave` doesn't work at eval time in Janet):
```janet
(var kvs @[])
(each f fields-vec
(array/push kvs (keyword (f :name)))
(array/push kvs f))
(def map-expr @[{:jolt/type :symbol :ns nil :name "array-map"} ;kvs])
```
### `loop` macro
Explicit macro: `(defn core-loop [bindings & body] (list* (sym "loop*") bindings ...))` + `"loop" core-loop` in core-bindings + `"loop" true` in core-macro-names.
## SCI Bootstrap
SCI added as git submodule at `vendor/sci`. 317/317 forms from 9 core files load with 0 failures in order: macros→protocols→types→unrestrict→vars→lang→utils→namespaces→core. 46 namespaces populated. SCI's `eval-string` is replaced with Jolt-native implementation that delegates to Jolt's reader/evaluator, bypassing SCI's internal pipeline (interpreter, parser, analyzer, opts).
Internal SCI namespaces need edamame/tools.reader stubs. See `test/test-load-sci.janet` for the canonical load test.
## Special Form Checklist
To add a new special form to the evaluator:
1. Add the name to `special-symbol?` in `src/jolt/evaluator.janet`
2. Add a match arm in `eval-list` (the match on `name`)
3. Add tests in `test/evaluator-test.janet`
The match arm receives `ctx`, `bindings`, and `form` (the full list). Use `(in form 1)` for first arg, etc.
**Non-symbol heads** (keywords, etc.): `eval-list` first checks `(and (struct? first-form) (= :symbol (...)))` before extracting `name`. If not a symbol, falls through to default function application. This means `(:ns &env)` works because the head `:ns` is a keyword, not a symbol, so it's evaluated and called as a lookup.
### Current special forms (22):
`quote`, `syntax-quote`, `unquote`, `unquote-splicing`, `do`, `if`, `def`, `defmacro`, `fn*`, `let*`, `loop*`, `recur`, `throw`, `try`, `set!`, `var`, `locking`, `instance?`, `defmulti`, `defmethod`, `deftype`, `new`, `.`
## Janet Gotchas
- `var` declares mutable locals that can be `set` later; `def`/`let` are immutable
- `let` cannot bind to `nil` — use `(var x nil)` instead of `(let [x nil] ...)`
- `(get table key)` needs 2 args minimum — for single-arg checks use `(table :key)`
- Functions are not tables — `(put fn :prop val)` fails. Stash properties on vars
- Janet structs silently **drop entries with nil values**: `(struct ;[:x nil :y 1])``{:y 1}`. Use `@{}` mutable tables when nil-valued entries are needed (e.g., `&env` binding `@{}` for macro bodies)
- Janet `(put table key nil)` silently drops the key. Use `:jolt/nil` sentinel via `bind-put` helper, unwrapped in `resolve-sym`
- `struct?` returns false for tables — deftype creates tables, use `(get val :key)` for field access
- **Bit operations**: use Janet built-ins `blshift`/`brshift`/`brushift`/`bnot`/`bor`/`band`/`bxor` (not `lshift`/`rshift`/etc.)
- Janet `mod` returns float, not integer
- `#{}` set literals can cause parse issues — use `@[]` as fallback
- `(first struct)` calls `:jolt/type` method — use `(get struct :key)` instead of positional access
- **`(last string)` returns nil** — `last` works only on indexed types (tuple, array). For strings use `(s (- (length s) 1))` or `(string/slice s (- (length s) 1))`
### unwrap-meta-name helper
Recursively unwraps `(with-meta sym meta)` forms to extract the underlying symbol. Used in `def`, `ns`, `deftype`, `defmethod` to handle metadata-wrapped names.
### Reader map k/v handling
The map reader must handle three special value types in both key and value positions:
- `:jolt/skip` — discarded form: skip the K/V pair entirely
- `:jolt/splice``#?@` splicing: concat items into the kvs array
### `#?(:cljs X)` returns nil → `:jolt/skip`
The non-splicing `#?` reader returns `{:jolt/type :jolt/skip}` for nil results (e.g., `#?(:cljs X)` on CLJ). This prevents orphaned nil keys/values. `parse-next` and `parse-string` skip past skip markers to return the next real form.
Closing delimiters `)`, `]`, `}` in `read-form` produce explicit errors (no fallthrough to `read-symbol`).
## Core Bindings (145+)
Key bindings in `core-bindings` map (in `src/jolt/core.janet`):
- Predicates, math, collections, sequences, higher-order functions
- Macros: `when`, `when-not`, `if-let`, `when-let`, `if-some`, `when-some`, `doto`, `and`, `or`, `defn`, `defn-`, `fn`, `let`, `loop`, `defrecord`, `defprotocol`, `declare`, `comment`
- Array primitives: `alength`, `aget`, `aset`, `aclone`, `object-array`, `int-array`, `to-array`
- Bit ops: `bit-and`, `bit-or`, `bit-xor`, `bit-not`, `bit-shift-left`, `bit-shift-right`, `unsigned-bit-shift-right`
- Unchecked math: `int`, `unchecked-inc`, `unchecked-dec`, `unchecked-add`, `unchecked-subtract`
- `hash` delegates to Janet built-in `hash`
- `name` returns name string of keyword/symbol/string
- `namespace` returns namespace of keyword/symbol or nil
### core-macro-names
Table `@{"when" true "defn" true ...}` — maps symbol name → `true` for all macro bindings. `init-core!` checks this to set `:macro true` on vars.
## Project Structure
```
src/jolt/
types.janet — Var, Namespace, Context, symbol helpers
reader.janet — recursive descent parser for Clojure syntax
evaluator.janet — tree-walking interpreter (eval-form, eval-list, syntax-quote*)
core.janet — 145+ clojure.core functions and macros
api.janet — public API: init, eval-string, eval-string*
main.janet — REPL entry point
clojure/lang/
persistent_vector.clj — 32-way branching trie vector (17 forms)
persistent_hash_map.clj — HAMT hash map (24 forms, WIP)
test/ — 9 test suites + SCI load test
vendor/sci/ — SCI submodule
```
### `.` special form field access
For deftype instances: `(.-cnt obj)``(get obj :cnt)`. The `-` prefix is stripped.

View file

@ -1,82 +1,46 @@
(ns jolt.lang.persistent-hash-map
"PersistentHashMap: HAMT implementation.")
"PersistentHashMap using simple array-based implementation.")
(def branch-factor 32)
(def shift-increment 5)
(deftype BitmapIndexedNode [bitmap array])
(deftype PersistentHashMap [count root has-nil? nil-value _meta])
(defn- mask [hash shift]
(int (bit-and (unsigned-bit-shift-right hash shift) 31)))
(defn- bitpos [hash shift]
(bit-shift-left 1 (mask hash shift)))
(defn- bit-count [n]
(let [n (- n (bit-and (unsigned-bit-shift-right n 1) 1431655765))
n (+ (bit-and n 858993459) (bit-and (unsigned-bit-shift-right n 2) 858993459))
n (bit-and (+ n (unsigned-bit-shift-right n 4)) 252645135)
n (+ n (unsigned-bit-shift-right n 8))
n (+ n (unsigned-bit-shift-right n 16))]
(int (bit-and n 63))))
(defn- index [bm bit]
(bit-count (bit-and bm (dec bit))))
(def not-found ::not-found)
(def EMPTY (PersistentHashMap. 0 nil false nil nil))
(defn- bmn-assoc [node shift hash key val added?]
(let [bit (bitpos hash shift)
idx (* 2 (index (.-bitmap node) bit))]
(if (= 0 (bit-and (.-bitmap node) bit))
(let [n (bit-count (.-bitmap node))
new-len (* 2 (inc n))
a (object-array new-len)
new-bm (bit-or (.-bitmap node) bit)]
(loop [i 0]
(if (< i idx)
(do (aset a i (aget (.-array node) i))
(aset a (inc i) (aget (.-array node) (inc i)))
(recur (+ i 2)))))
(loop [i idx]
(if (< i (* 2 n))
(do (aset a (+ i 2) (aget (.-array node) i))
(aset a (+ i 3) (aget (.-array node) (inc i)))
(recur (+ i 2))))))
(aset a idx key)
(aset a (inc idx) val)
(aset added? 0 true)
(BitmapIndexedNode. new-bm a))
(let [ek (aget (.-array node) idx)]
(if (identical? ek key)
(let [a (aclone (.-array node))]
(aset a (inc idx) val)
(BitmapIndexedNode. (.-bitmap node) a))
(let [ev (aget (.-array node) (inc idx))
a (aclone (.-array node))
sub (BitmapIndexedNode. 0 (object-array 2))]
(aset added? 0 true)
(aset a idx nil)
(aset a (inc idx)
(bmn-assoc (bmn-assoc sub (+ shift shift-increment)
(hash ek) ek ev added?)
(+ shift shift-increment) hash key val added?))
(BitmapIndexedNode. (.-bitmap node a)))))))
(defn- mask [hash shift]
(mod (abs (int hash)) 32))
(defn- bmn-find [node shift hash key]
(let [bit (bitpos hash shift)]
(if (= 0 (bit-and (.-bitmap node) bit))
not-found
(let [idx (* 2 (index (.-bitmap node) bit))
k (aget (.-array node) idx)]
(if (nil? k)
(bmn-find (aget (.-array node) (inc idx))
(+ shift shift-increment) hash key)
(if (identical? k key)
(aget (.-array node) (inc idx))
not-found))))))
(defn- find-key-index [arr key]
(let [len (alength arr)]
(loop [i 0]
(if (< i len)
(if (identical? (aget arr i) key)
i
(recur (+ i 2)))
-1))))
(defn- node-assoc [arr h key val added?]
(let [idx (find-key-index arr key)]
(if (= idx -1)
;; Insert — create new array with +2 slots
(let [old-len (alength arr)
new-arr (object-array (+ old-len 2))]
(loop [i 0]
(if (< i old-len)
(do (aset new-arr i (aget arr i))
(recur (inc i)))))
(aset new-arr old-len key)
(aset new-arr (inc old-len) val)
(aset added? 0 true)
new-arr)
;; Replace — clone and update
(let [new-arr (aclone arr)]
(aset new-arr (inc idx) val)
new-arr))))
(defn- node-find [arr key]
(let [idx (find-key-index arr key)]
(if (= idx -1)
nil
(aget arr (inc idx)))))
(defn phm-assoc [m key val]
(if (nil? key)
@ -84,30 +48,26 @@
(if (.-has-nil? m) (.-count m) (inc (.-count m)))
(.-root m) true val (.-_meta m))
(let [added? (object-array 1)
h (hash key)
r (if (nil? (.-root m))
(bmn-assoc (BitmapIndexedNode. 0 (object-array 2)) 0 h key val added?)
(bmn-assoc (.-root m) 0 h key val added?))]
root (.-root m)
new-arr (node-assoc (if (nil? root) (object-array 0) root)
(hash key) key val added?)]
(PersistentHashMap.
(if (aget added? 0) (inc (.-count m)) (.-count m))
r (.-has-nil? m) (.-nil-value m) (.-_meta m)))))
new-arr (.-has-nil? m) (.-nil-value m) (.-_meta m)))))
(defn phm-get
([m key] (phm-get m key nil))
([m key nf]
([m key not-found]
(if (nil? key)
(if (.-has-nil? m) (.-nil-value m) nf)
(if (nil? (.-root m))
nf
(let [result (bmn-find (.-root m) 0 (hash key) key)]
(if (identical? result not-found) nf result))))))
(if (.-has-nil? m) (.-nil-value m) not-found)
(let [root (.-root m)]
(if (nil? root)
not-found
(let [result (node-find root key)]
(if (nil? result) not-found result)))))))
(defn phm-contains? [m key]
(if (nil? key)
(.-has-nil? m)
(if (nil? (.-root m))
false
(not (identical? (bmn-find (.-root m) 0 (hash key) key) not-found)))))
(not (nil? (phm-get m key ::sentinel))))
(defn phm-count [m] (.-count m))