Dive C4.05.1
Prefix tries
Store a key by its pieces, and keys that begin the same way share the road in.
Before the hashing, the shape. A trie — a prefix tree — stores keys piece by
piece. Each node holds the value (if any) that ends exactly here, plus a map from the next piece to a child
node. To find a key, walk it one piece at a time, following the child for each piece, and read the value at the
end. Two keys that share a prefix — say "JO" and "JOB" — share
every node down to where they diverge. Nothing is hashed yet; the HAMT's move, in the next dive, is to hash the
key first and walk slices of that hash instead of raw pieces. This dive builds a small trie in F# and verifies
it in dotnet fsi.
A node is a value and a map of children
The model is one recursive record. A node carries an optional value — present only at a node
where a key ends — and a map from the next character to a child node. The map of children is
an F# Map; the recursion is the trie. add walks the key
character by character, reusing the child that already exists for each character or starting an empty one, and
sets the value at the end; find walks the same path and reads the value there.
type Trie<'v> = { Value: 'v option Children: Map<char, Trie<'v>> } module Trie = let empty : Trie<'v> = { Value = None; Children = Map.empty } let rec add (key: string) (i: int) (v: 'v) (node: Trie<'v>) : Trie<'v> = if i = key.Length then { node with Value = Some v } else let c = key.[i] let child = node.Children |> Map.tryFind c |> Option.defaultValue empty { node with Children = node.Children.Add(c, add key (i + 1) v child) } let rec find (key: string) (i: int) (node: Trie<'v>) : 'v option = if i = key.Length then node.Value else match Map.tryFind key.[i] node.Children with | Some child -> find key (i + 1) child | None -> None
- node
- a
Value: 'v option(a value only where a key ends) plusChildren: Map<char, Trie<'v>>. - add
- walks the key, descends or starts a child per character, and sets the value at the final node.
- find
- walks the same path; returns the node's value, or
Noneif any character has no child. - shared prefix
- keys with a common start share the nodes down to the point they differ — the trie's defining property.
Two keys, one shared root
Add "JO" then "JOB". Both descend through the
'J' child at the root and the 'O' child below it;
"JO" ends there with its value, and "JOB" carries on to a
'B' child. Looking up a key that was never added returns
None the moment a character has no child.
let t0 = Trie.empty let t1 = Trie.add "JO" 0 1 t0 let t2 = Trie.add "JOB" 0 2 t1 Trie.find "JO" 0 t2 // val it : int option = Some 1 Trie.find "JOB" 0 t2 // val it : int option = Some 2 Trie.find "JX" 0 t2 // val it : int option = None t2.Children.ContainsKey 'J' // val it : bool = true (both keys share it)
Elixir — a trie node as a map
# Elixir — a trie node: value + children map defp add(node, "", v), do: %{node | value: v} defp add(node, <<c::utf8, rest::binary>>, v) do child = Map.get(node.children, c, %{value: nil, children: %{}}) %{node | children: Map.put(node.children, c, add(child, rest, v))} end
F# — the same node, statically typed (verified in FSI)
// F# — Value: 'v option ; Children: Map<char, Trie<'v>> let rec add (key: string) (i: int) (v: 'v) (node: Trie<'v>) = if i = key.Length then { node with Value = Some v } else let c = key.[i] let child = node.Children |> Map.tryFind c |> Option.defaultValue Trie.empty { node with Children = node.Children.Add(c, add key (i + 1) v child) }
Walk the trie, key by key
Pick a key to look up in a fixed trie holding "JO", "JOB",
and "JAR". The path lights up character by character as
find descends; the readout shows the character being matched, the child taken, and
the value at the end — or where the walk stopped at a missing child. Every step is computed by the same
recursive descent the code uses, over the fixed trie.
find key : descend one character per level
A trie reads a key by its pieces, and a shared start is a shared path. Switch the key from raw characters to slices of the key's hash, and a node from a child-per-character map to a bitmap-plus-array, and the trie becomes a HAMT — the next dive.
Why keying by digits is the HAMT's seed
A branded id like JOB0NcQgyPQEbI is fourteen characters: a three-letter namespace
then an eleven-character Base62 snowflake. Keying a registry by the id's characters makes a trie whose
top levels are the namespace (J, O,
B), then the snowflake. Ids minted close in time share the high bytes of the
timestamp, so they share a long prefix — and therefore most of the path.
That works, but a character trie has an awkward shape: branching depends on the alphabet, and a long key means
a deep walk. The HAMT keeps the trie idea and fixes both: it hashes the key first, then reads
the hash in fixed 5-bit slices, so every node branches exactly 32 ways and the depth is
~log₃₂ n regardless of key length. The next dive makes that switch.
This dive · a prefix trie
Walk the key's raw pieces; a node is a value plus a child-per-piece map. Shared prefixes share the path.
Next dive · the HAMT
Hash the key, walk 5-bit slices; a node is a bitmap plus a packed array. Branch 32 ways, depth ~log₃₂ n.
EchoMQ in F#
Status — echo_fs is an in-memory model. The EchoMQ protocol — the Lua transitions executing on a Valkey wire — is not implemented (there is no Lua and no Valkey client in the code); that is the coming-soon transport (EchoMQ.Connector). This block shows how the model uses the structure, not a running bus.
EchoMQ keys its job registry by the branded id directly: EchoMQ.Store.Rows is a
Map<string, Job> over those ids. The trie view of this dive says: if that map
were a prefix trie keyed by the id's digits, ids minted in the same window would share the path through their
common prefix. The id itself is built to make that prefix meaningful — its namespace and timestamp lead.
EchoMQ in F#
Keying the registry by the branded id's digits
The registry's key is a branded id, and the id is gated to a strict shape before it is used as a key. Quoted
verbatim from echo_fs/EchoMQ/BrandedId.fs:
/// The full-format check: 3 uppercase letters then 11 Base62 chars. A bare /// `"JOB"` has a valid namespace but is **not** a valid id (the gate the key /// builder enforces). let isValid (id: string) : bool = not (isNull id) && id.Length = 14 && Seq.forall isUpper (id.Substring(0, 3)) && Seq.forall isBase62 (id.Substring(3))
Reading those fourteen digits as a trie path: the first three (JOB)
are the namespace, the next eleven the Base62 snowflake. Ids from the same mint window share the leading
digits, so a trie keyed this way shares their path (the descent below was verified in FSI over a fixed trie):
let reg = Trie.empty |> Trie.add "JOB0NcQgyPQEbI" 0 "ping" |> Trie.add "JOB0NcQh2mK1tW" 0 "scan" reg.Children.ContainsKey 'J' // val it : bool = true (one shared J node) Trie.find "JOB0NcQgyPQEbI" 0 reg // val it : string option = Some "ping"
The F# EchoMQ keys its registry by the branded id today — EchoMQ.Store.Rows, a
Map<string, Job> in the in-memory core
(echo_fs/EchoMQ/Store.fs, BrandedId.fs — built and tested).
The map is a balanced tree, not a trie; the trie above is the shape this dive teaches. The HAMT/CHAMP backing
the Elixir bus uses is the next dive's subject, and the Valkey transport EchoMQ.Connector lands next.
The shape
A trie stores keys by their pieces; a shared prefix is a shared path. A branded id leads with namespace and timestamp.
The next dive
The HAMT hashes the key and walks 5-bit slices, so a node branches 32 ways and depth stays near-constant.
Where this leads
The trie is the shape. The next dive turns it into a HAMT: a node becomes a bitmap plus one packed array, the
key becomes its hash, and the descent reads 5-bit slices of that hash — so a node branches 32 ways and the
depth drops to ~log₃₂ n. The third reads how an update shares structure.
References
Sources
- Microsoft — F# documentation — records, the
Mapmodule,Option, and recursion. - FSharp.Core API reference — the
Mapmodule (tryFind,Add) andOption. - The F# Software Foundation — F#'s home on the .NET runtime.
Related in this course
- Elixir — Hash array mapped tries (E4.05) — the trie and the HAMT on the BEAM.
- C4.05 — Hash Array Mapped Tries — the module hub.
- C4.05.2 — The HAMT — the next dive: the trie indexed by hash slices.
- C1.07 — Collections — the
MapandOptionthis dive builds on.