DawgEx

Hex.pmDocs

A compact, binary-encoded DAWG (directed acyclic word graph) for fast set-membership queries over large word lists.

DawgEx.from_list/2 builds a minimal automaton and flattens it into a single binary; DawgEx.member?/2 queries that binary directly, without decoding it back into terms — compact storage, cheap lookups.

Installation

Add dawg_ex to your dependencies in mix.exs:

def deps do
[
{:dawg_ex, "~> 0.1.0"}
]
end

Usage

dawg = DawgEx.from_list(["cat", "cats", "dog"])
DawgEx.member?(dawg, "cat") #=> true
DawgEx.member?(dawg, "cats") #=> true
DawgEx.member?(dawg, "ca") #=> false

The binary is a plain term, so it can be built once at compile time and embedded in a module attribute:

defmodule Dictionary do
@dawg "priv/words.txt" |> File.read!() |> String.split("\n", trim: true) |> DawgEx.from_list()
def word?(word), do: DawgEx.member?(@dawg, word)
end

Choosing an offset width

from_list/2 takes the number of bits each edge spends addressing its child. Together with the two flag bits every edge carries, it must fill whole bytes — offset_width + 2 must be a multiple of 8 — and it trades encoded size against how many edges the automaton can hold:

DawgEx.from_list(words) # 3 bytes per edge, up to 16_384 edges
DawgEx.from_list(words, 6) # 2 bytes per edge, up to 64 edges
DawgEx.from_list(words, 22) # 4 bytes per edge, up to 4_194_304 edges

The width is recorded in the binary, so member?/2 needs no matching argument and the default of 14 suits most word lists. Asking for a width too narrow to address the minimized automaton raises rather than encoding offsets that would wrap, and the message names the width to rebuild at:

** (ArgumentError) cannot address 2244 edges with an offset_width of 6
6 bits reach at most 64 edges. Rebuild with a wider offset:
DawgEx.from_list(words, 14)

Binary layout

The first byte records the offset width, followed by the root node's offset, stored two bits wider than the width so the header fills whole bytes:

<<offset_width::8, root_offset::size(offset_width + 2)>>

The rest of the binary is edges. Each node is a run of consecutive edges, and an edge packs its two flag bits into the same bytes as its offset:

<<char::8, child_offset::size(offset_width), terminal?::1, more?::1>>

terminal? marks the end of a word, child_offset is the edge index of the child node, and more? is set on every edge except the last of its node. Offset 0 is a sentinel: a single placeholder edge (char0xFF, both flags clear) sits right after the header, and every node with no outgoing edges points at it instead of occupying a slot of its own.

Requiring offset_width + 2 to be a multiple of 8 keeps both the header and every edge — 1 + (offset_width + 2) / 8 bytes each — a whole number of bytes. Offsets are edge indices rather than byte positions, which is why the width caps the edge count rather than the byte size.

Development

mix deps.get
mix check # format, compile --warnings-as-errors, credo --strict, test, dialyzer

Individual steps are available as mix credo --strict, mix dialyzer, and mix test. The first Dialyzer run builds a PLT under priv/plts/ and takes a few minutes; later runs are incremental.

License

MIT — see LICENSE.