Packages

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

Current section

Files

Jump to
dawg_ex README.md
Raw

README.md

# DawgEx
[![Hex.pm](https://img.shields.io/hexpm/v/dawg_ex.svg)](https://hex.pm/packages/dawg_ex)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/dawg_ex)
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. A dictionary therefore costs one binary on the heap no matter
how many processes read it.
## Installation
Add `dawg_ex` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:dawg_ex, "~> 0.1.0"}
]
end
```
## Usage
```elixir
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:
```elixir
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.
It must be a positive multiple of 8, and it trades encoded size against how
many edges the automaton can hold:
```elixir
DawgEx.from_list(words) # 4 bytes per edge, up to 65_536 edges
DawgEx.from_list(words, 8) # 3 bytes per edge, up to 256 edges
DawgEx.from_list(words, 24) # 5 bytes per edge, up to 16_777_216 edges
```
The width is recorded in the binary, so `member?/2` needs no matching argument
and the default of 16 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 8
8 bits reach at most 256 edges. Rebuild with a wider offset:
DawgEx.from_list(words, 16)
```
## Binary layout
The first byte records the offset width, followed by the root node's offset at
that width:
```
<<offset_width::8, root_offset::size(offset_width)>>
```
The rest of the binary is edges. Each node is a run of consecutive edges:
```
<<char::8, terminal?::1, child_offset::size(offset_width), more?::1, 0::6>>
```
`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. A
node with no outgoing edges still occupies one slot, filled with a placeholder
edge whose `char` is `0xFF` and whose flags are both clear.
Requiring the width to be a multiple of 8 keeps both the header
(`1 + offset_width / 8` bytes) and every edge (`2 + offset_width / 8` bytes) 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](LICENSE).