|
| 1 | +# sml-hamt |
| 2 | + |
| 3 | +[](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). |
0 commit comments