Skip to content

Commit 6f5fb27

Browse files
Initial commit: persistent HAMT map and radix vector
Pure Standard ML persistent immutable data structures on a 32-ary (5-bits-per-level) trie: - HamtMap: hash array mapped trie (HAMT/CHAMP-style) keyed by a user-supplied hash + equality, with bitmap-indexed packed children and collision nodes for correctness under any hash. - PVec: Clojure-style bit-partitioned persistent vector with an append tail; O(log32 n) sub/update with structural sharing. Dual-compiler (MLton + Poly/ML), 53 deterministic checks including an assoc-list oracle driven by an LCG over mixing/weak/single-bucket hashes, collision-node handling, structural immutability, and vector order/persistence across trie levels. Co-authored-by: Cursor <cursoragent@cursor.com>
0 parents  commit 6f5fb27

18 files changed

Lines changed: 1033 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: CI
2+
on:
3+
push:
4+
branches: [ main ]
5+
pull_request:
6+
branches: [ main ]
7+
jobs:
8+
mlton:
9+
name: MLton (build + test)
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Install MLton
14+
run: sudo apt-get update && sudo apt-get install -y mlton
15+
- name: Run tests
16+
run: make test
17+
polyml:
18+
name: Poly/ML (test)
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Install Poly/ML
23+
run: sudo apt-get update && sudo apt-get install -y polyml libpolyml-dev
24+
- name: Run tests
25+
run: make test-poly

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bin/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 sml-hamt contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# sml-hamt build
2+
#
3+
# make test build + run tests under MLton (default)
4+
# make test-poly build + run tests under Poly/ML
5+
# make all-tests run the suite under both compilers
6+
# make example build + run the demo program
7+
# make clean remove build artifacts
8+
9+
MLTON ?= mlton
10+
BIN := bin
11+
LIBDIR := lib/github.com/sjqtentacles/sml-hamt
12+
TEST_MLB := test/sources.mlb
13+
SRCS := $(wildcard $(LIBDIR)/*.sml $(LIBDIR)/*.sig) $(wildcard test/*.sml) \
14+
$(TEST_MLB) $(LIBDIR)/sources.mlb
15+
16+
.PHONY: all test poly test-poly all-tests example clean
17+
18+
all: $(BIN)/test-mlton
19+
20+
example: $(BIN)/demo
21+
./$(BIN)/demo
22+
23+
$(BIN)/demo: $(SRCS) examples/demo.sml examples/sources.mlb | $(BIN)
24+
$(MLTON) -output $@ examples/sources.mlb
25+
26+
$(BIN)/test-mlton: $(SRCS) | $(BIN)
27+
$(MLTON) -output $@ $(TEST_MLB)
28+
29+
test: $(BIN)/test-mlton
30+
$(BIN)/test-mlton
31+
32+
poly: $(BIN)/test-poly
33+
34+
$(BIN)/test-poly: $(SRCS) tools/polybuild | $(BIN)
35+
sh tools/polybuild -o $@ $(TEST_MLB)
36+
37+
test-poly: $(BIN)/test-poly
38+
$(BIN)/test-poly
39+
40+
all-tests: test test-poly
41+
42+
$(BIN):
43+
mkdir -p $(BIN)
44+
45+
clean:
46+
rm -rf $(BIN)

README.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# sml-hamt
2+
3+
[![CI](https://github.com/sjqtentacles/sml-hamt/actions/workflows/ci.yml/badge.svg)](https://github.com/sjqtentacles/sml-hamt/actions/workflows/ci.yml)
4+
5+
Persistent, immutable data structures for Standard ML built on a 32-ary
6+
(5-bits-per-level) trie:
7+
8+
- **`HamtMap`** -- a hash array mapped trie (HAMT/CHAMP-style) map, keyed by a
9+
user-supplied hash and equality so it works for any key type.
10+
- **`PVec`** -- a persistent bit-partitioned vector (Clojure-style 32-ary
11+
radix tree) with an append tail.
12+
13+
Every operation returns a new value and never mutates its input, so old
14+
versions stay valid and share structure with their descendants. Everything is
15+
pure Standard ML over the Basis library: deterministic, with no FFI, threads,
16+
or clocks. The suite produces identical results on **MLton** and **Poly/ML**.
17+
18+
## Design
19+
20+
`HamtMap` distributes keys by hash, consuming 5 bits per level. An internal
21+
node carries a 32-bit bitmap marking occupied slots plus a *packed* array of
22+
children, so a node holding `k` entries uses an array of length `k` (not 32).
23+
A child is a leaf entry, a hash-collision bucket, or another internal node.
24+
Keys that share a full 32-bit hash are stored together in a collision node, so
25+
**any** hash -- including a deliberately weak one -- yields a correct map; only
26+
performance, never correctness, depends on hash quality.
27+
28+
`PVec` indexes elements by 5 bits per level through a radix tree of 32-wide
29+
nodes, with a 32-element tail buffer for amortized O(1) `push`. `sub` and
30+
`update` are O(log32 n). Because the trie is immutable, `push`/`update` copy
31+
only the path from the root to the affected node and share the rest.
32+
33+
## API
34+
35+
```sml
36+
structure HamtMap : sig
37+
type ('k, 'v) t
38+
val empty : {hash : 'k -> word, eq : 'k * 'k -> bool} -> ('k, 'v) t
39+
val isEmpty : ('k, 'v) t -> bool
40+
val size : ('k, 'v) t -> int
41+
val insert : ('k, 'v) t -> 'k -> 'v -> ('k, 'v) t
42+
val find : ('k, 'v) t -> 'k -> 'v option
43+
val contains : ('k, 'v) t -> 'k -> bool
44+
val remove : ('k, 'v) t -> 'k -> ('k, 'v) t
45+
val foldl : ('k * 'v * 'acc -> 'acc) -> 'acc -> ('k, 'v) t -> 'acc
46+
val toList : ('k, 'v) t -> ('k * 'v) list
47+
end
48+
49+
structure PVec : sig
50+
type 'a t
51+
val empty : 'a t
52+
val isEmpty : 'a t -> bool
53+
val length : 'a t -> int
54+
val push : 'a t -> 'a -> 'a t
55+
val sub : 'a t -> int -> 'a (* raises Subscript if out of range *)
56+
val update : 'a t -> int -> 'a -> 'a t (* raises Subscript if out of range *)
57+
val foldl : ('a * 'acc -> 'acc) -> 'acc -> 'a t -> 'acc
58+
val toList : 'a t -> 'a list
59+
val fromList : 'a list -> 'a t
60+
end
61+
```
62+
63+
### Example
64+
65+
```sml
66+
val m0 = HamtMap.empty {hash = hashString, eq = (op =) : string * string -> bool}
67+
val m1 = HamtMap.insert m0 "apple" 1
68+
val m2 = HamtMap.insert m1 "banana" 2
69+
val m3 = HamtMap.insert m2 "banana" 200 (* new version *)
70+
val _ = HamtMap.find m3 "banana" (* SOME 200 *)
71+
val _ = HamtMap.find m2 "banana" (* SOME 2 -- m2 is untouched *)
72+
73+
val v0 = PVec.fromList [10, 20, 30]
74+
val v1 = PVec.update (PVec.push v0 40) 1 999
75+
val _ = PVec.toList v1 (* [10, 999, 30, 40] *)
76+
val _ = PVec.toList v0 (* [10, 20, 30] -- unchanged *)
77+
```
78+
79+
See [`examples/demo.sml`](examples/demo.sml); run it with `make example`.
80+
81+
## Build & test
82+
83+
Requires [MLton](http://mlton.org/) and/or [Poly/ML](https://polyml.org/).
84+
85+
```sh
86+
make test # build + run the suite under MLton
87+
make test-poly # run the suite under Poly/ML
88+
make all-tests # both
89+
make example # build + run the demo
90+
make clean
91+
```
92+
93+
## Installing with smlpkg
94+
95+
```sh
96+
smlpkg add github.com/sjqtentacles/sml-hamt
97+
smlpkg sync
98+
```
99+
100+
Reference `lib/github.com/sjqtentacles/sml-hamt/hamt.mlb` from your own `.mlb`
101+
(MLton / MLKit), or feed `sources.mlb` to `tools/polybuild` (Poly/ML).
102+
103+
## Layout
104+
105+
```
106+
sml.pkg smlpkg manifest
107+
Makefile MLton + Poly/ML targets
108+
.github/workflows/ci.yml CI: MLton + Poly/ML
109+
lib/github.com/sjqtentacles/sml-hamt/
110+
hamt.sig HAMT_MAP + PVEC signatures
111+
hamt.sml HamtMap (HAMT/CHAMP) + PVec (radix vector)
112+
sources.mlb ordered source list
113+
hamt.mlb public basis
114+
test/
115+
harness.sml shared assertion harness
116+
test.sml oracle, collision, immutability, and vector suites
117+
entry.sml / main.sml
118+
examples/demo.sml map + vector persistence demo
119+
tools/polybuild Poly/ML build wrapper
120+
```
121+
122+
## Tests
123+
124+
53 deterministic checks. The map is driven against an assoc-list oracle using
125+
an LCG-generated sequence of insert/remove operations under three hashes -- a
126+
mixing hash, a deliberately weak 8-bucket hash, and a single-bucket hash --
127+
asserting `find`/`size`/`toList` agree at **every** step. Further suites cover
128+
explicit collision-node handling, structural immutability (older versions are
129+
unaffected by later updates), and `PVec` push/sub/update/order across several
130+
trie levels. Run `make all-tests` to verify identical output under both
131+
compilers.
132+
133+
## License
134+
135+
MIT. See [LICENSE](LICENSE).

examples/demo.sml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
(* demo.sml -- build a HamtMap and a PVec, print lookups, and show that
2+
older versions are untouched by later updates (structural sharing). *)
3+
4+
(* A simple deterministic string hash (FNV-1a, 32-bit). *)
5+
fun hashString s =
6+
let
7+
val prime = 0w16777619
8+
fun step (c, h) = Word.* (Word.xorb (h, Word.fromInt (Char.ord c)), prime)
9+
in
10+
CharVector.foldl step 0wx811c9dc5 s
11+
end
12+
13+
fun showOpt NONE = "<none>"
14+
| showOpt (SOME v) = Int.toString v
15+
16+
val () = print "== HamtMap ==\n"
17+
18+
val m0 = HamtMap.empty {hash = hashString, eq = (op =) : string * string -> bool}
19+
val m1 = HamtMap.insert m0 "apple" 1
20+
val m2 = HamtMap.insert m1 "banana" 2
21+
val m3 = HamtMap.insert m2 "cherry" 3
22+
(* m4 overwrites "banana"; m2 must keep the old value. *)
23+
val m4 = HamtMap.insert m3 "banana" 200
24+
25+
val () = print ("size m4 = " ^ Int.toString (HamtMap.size m4) ^ "\n")
26+
val () = print ("find m4 cherry = " ^ showOpt (HamtMap.find m4 "cherry") ^ "\n")
27+
val () = print ("find m4 banana = " ^ showOpt (HamtMap.find m4 "banana") ^ "\n")
28+
val () = print ("find m3 banana = " ^ showOpt (HamtMap.find m3 "banana")
29+
^ " (older version unchanged)\n")
30+
val () = print ("find m4 durian = " ^ showOpt (HamtMap.find m4 "durian") ^ "\n")
31+
32+
val m5 = HamtMap.remove m4 "apple"
33+
val () = print ("after remove apple: size m5 = " ^ Int.toString (HamtMap.size m5)
34+
^ ", size m4 = " ^ Int.toString (HamtMap.size m4)
35+
^ " (m4 unchanged)\n")
36+
37+
val () = print "\n== PVec ==\n"
38+
39+
val v0 = PVec.fromList [10, 20, 30, 40, 50]
40+
val v1 = PVec.push v0 60
41+
(* v2 updates index 2; v0/v1 keep the original value. *)
42+
val v2 = PVec.update v1 2 999
43+
44+
fun showVec v = "[" ^ String.concatWith "," (List.map Int.toString (PVec.toList v)) ^ "]"
45+
46+
val () = print ("v0 = " ^ showVec v0 ^ "\n")
47+
val () = print ("v1 = push 60= " ^ showVec v1 ^ "\n")
48+
val () = print ("v2 = upd@2 = " ^ showVec v2 ^ "\n")
49+
val () = print ("v1 still = " ^ showVec v1 ^ " (update did not mutate v1)\n")
50+
val () = print ("sub v2 2 = " ^ Int.toString (PVec.sub v2 2) ^ "\n")
51+
val () = print ("length v2 = " ^ Int.toString (PVec.length v2) ^ "\n")

examples/sources.mlb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
$(SML_LIB)/basis/basis.mlb
2+
3+
../lib/github.com/sjqtentacles/sml-hamt/sources.mlb
4+
5+
demo.sml
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(* sml-hamt: public library basis. *)
2+
3+
$(SML_LIB)/basis/basis.mlb
4+
5+
sources.mlb
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
(* hamt.sig
2+
3+
Persistent, immutable data structures for Standard ML built on a
4+
32-ary (5-bits-per-level) trie:
5+
6+
HamtMap a hash array mapped trie (HAMT/CHAMP-style) map, keyed by a
7+
user-supplied hash and equality so it works for any key type.
8+
PVec a persistent bit-partitioned vector (32-ary radix tree).
9+
10+
Every update returns a new value; the input is never mutated, so old
11+
versions remain valid and share structure with their descendants.
12+
Everything is pure Standard ML over the Basis library, deterministic,
13+
with no FFI, threads, or clocks. *)
14+
15+
(* A persistent hash map.
16+
17+
Keys are compared by the {hash, eq} record supplied to `empty`. `hash`
18+
maps a key to a `word` (only the low 32 bits are consulted); `eq` is the
19+
key equality used within a slot. Distinct keys whose hashes collide are
20+
stored together in a collision node, so any `hash` -- including a
21+
deliberately weak one -- yields a correct map. *)
22+
signature HAMT_MAP =
23+
sig
24+
type ('k, 'v) t
25+
26+
(* An empty map using the given hash and equality. *)
27+
val empty : {hash : 'k -> word, eq : 'k * 'k -> bool} -> ('k, 'v) t
28+
29+
(* True if the map has no bindings. *)
30+
val isEmpty : ('k, 'v) t -> bool
31+
32+
(* Number of bindings. *)
33+
val size : ('k, 'v) t -> int
34+
35+
(* `insert m k v` is `m` with `k` bound to `v` (replacing any prior
36+
binding for `k`). `m` is unchanged. *)
37+
val insert : ('k, 'v) t -> 'k -> 'v -> ('k, 'v) t
38+
39+
(* The value bound to `k`, or NONE. *)
40+
val find : ('k, 'v) t -> 'k -> 'v option
41+
42+
(* True if `k` is bound. *)
43+
val contains : ('k, 'v) t -> 'k -> bool
44+
45+
(* `remove m k` is `m` without any binding for `k` (a no-op if `k` is
46+
absent). `m` is unchanged. *)
47+
val remove : ('k, 'v) t -> 'k -> ('k, 'v) t
48+
49+
(* Left fold over all bindings. Iteration order is unspecified. *)
50+
val foldl : ('k * 'v * 'acc -> 'acc) -> 'acc -> ('k, 'v) t -> 'acc
51+
52+
(* All bindings as a list, in unspecified order. *)
53+
val toList : ('k, 'v) t -> ('k * 'v) list
54+
end
55+
56+
(* A persistent indexed sequence (bit-partitioned vector).
57+
58+
Indices run from 0 to `length v - 1`. `sub` and `update` raise `Subscript`
59+
for out-of-range indices. `push` appends at the end. *)
60+
signature PVEC =
61+
sig
62+
type 'a t
63+
64+
(* The empty vector. *)
65+
val empty : 'a t
66+
67+
(* True if the vector has no elements. *)
68+
val isEmpty : 'a t -> bool
69+
70+
(* Number of elements. *)
71+
val length : 'a t -> int
72+
73+
(* `push v x` is `v` with `x` appended; `v` is unchanged. *)
74+
val push : 'a t -> 'a -> 'a t
75+
76+
(* `sub v i` is the element at index `i`; raises `Subscript` if out of
77+
range. *)
78+
val sub : 'a t -> int -> 'a
79+
80+
(* `update v i x` is `v` with index `i` set to `x`; `v` is unchanged.
81+
Raises `Subscript` if `i` is out of range. *)
82+
val update : 'a t -> int -> 'a -> 'a t
83+
84+
(* Left fold over elements in index order. *)
85+
val foldl : ('a * 'acc -> 'acc) -> 'acc -> 'a t -> 'acc
86+
87+
(* All elements as a list, in index order. *)
88+
val toList : 'a t -> 'a list
89+
90+
(* Build a vector from a list, preserving order. *)
91+
val fromList : 'a list -> 'a t
92+
end

0 commit comments

Comments
 (0)