Packages
A standalone Prolog-like resolution engine and clause database for Elixir: terms, unification, SLD-resolution, backtracking, a genuine clause-scoped cut, and builtin predicates, built on Ichor's search substrate. No parser -- bring your own front-end (e.g. Aletheia) or build goal terms directly.
Current section
Files
Jump to
Current section
Files
CASE_STUDY.md
# Case study: auditing structured application logs
Every other example in [EXAMPLES.md](EXAMPLES.md) is deliberately
small — one feature, one paragraph, one screenful. This one is
deliberately not: a single, complete, real-world-shaped program that
walks through *why* you'd reach for Episteme, not just *how* to call
one predicate. It assumes you're comfortable with the vocabulary from
[TUTORIAL.md](TUTORIAL.md) and pulls in a feature from nearly every
corner of the library — a DCG grammar, the dynamic database,
aggregation with existential quantification, and `format/2` — to solve
one coherent problem rather than eight disconnected ones.
## 0. Shared setup
```elixir
alias Episteme.{Database, Term}
alias Episteme.Term.Compound
c = fn name, args -> %Compound{name: name, args: args} end
embed = fn goal -> %Compound{name: :{}, args: [goal]} end
```
`c` is the same shorthand [EXAMPLES.md §0](EXAMPLES.md#0-shared-setup)
uses. `embed` is new here: it wraps an ordinary goal as `{Goal}` —
ISO's own `{}`/1 term shape — the way you drop a plain predicate
call into the middle of a DCG body without it being treated as
grammar.
## Intent
A lot of real services — Heroku's own routing layer, most Go and Rust
web frameworks, plenty of hand-rolled ones — emit logs in
[logfmt](https://brandur.org/logfmt): one line per event, space-separated
`key=value` pairs, no fixed schema beyond a handful of conventional
fields. A line looks like this:
```text
ts=2026-08-05T10:15:32Z level=warn component=auth event=login_failed user=alice ip=203.0.113.5
```
Say you want a small audit tool over a stream of these: flag any
**user with repeated failed logins** (a brute-force indicator), and
separately flag any **IP address behind failed logins for more than
one distinct user** (a credential-stuffing indicator — one attacker,
many stolen usernames, one source). Both are real heuristics real
fraud/security tooling actually uses, and both are naturally
*relational* questions — "group these facts by X, then check something
about each group" — which is exactly the shape logic programming is
for.
Three things about Episteme fit this well:
- **No parser of its own, but a DCG when you need one.** Episteme
doesn't ship a `logfmt` reader — nothing does, out of the box — but
[DCG support](REFERENCE.md#dcg-definite-clause-grammars) lets you
write the grammar as a grammar, not a hand-rolled loop of string
splits.
- **A mutable database for a line-at-a-time stream.** Log lines arrive
one at a time, not as a batch you already have in memory; the
[dynamic database](REFERENCE.md#dynamic-database) (`assertz/1`) is
built for exactly that.
- **Aggregation that groups for you.** "Which users have ≥ 3 failed
logins" and "which IPs are shared across users" are both grouping
queries — [`setof/3`](REFERENCE.md#bagof3-and-setof3)'s
free-variable grouping does the work a hand-written accumulator
would otherwise need.
## Solution
### Parsing: a DCG grammar for logfmt lines
Tokenizing on whitespace is just `String.split/1` — Elixir already
does that well, so there's no reason to reinvent it inside Episteme.
What the grammar below owns is everything *after* that: recognizing
the shape of a line (four required fields in order, then a
variable-length tail of arbitrary ones) and pulling each `key=value`
token apart.
`any_field/2` consumes exactly one token and splits it around `=`:
```elixir
af_key = Term.new_var("Key")
af_value = Term.new_var("Value")
af_token = Term.new_var("Token")
af_key_str = Term.new_var("KeyStr")
any_field_head = c.(:any_field, [af_key, af_value])
any_field_body =
c.(:and, [
[af_token],
embed.(
c.(:and, [
c.(:split_string, [af_token, "=", "", [af_key_str, af_value]]),
c.(:atom_string, [af_key, af_key_str])
])
)
])
db = Database.add_clause(Database.new(), Episteme.Dcg.translate_rule(any_field_head, any_field_body))
```
Read the DCG body the way `Episteme.Dcg` reads it: `[af_token]` is a
one-element *terminal* — "consume the next token from the input and
call it `af_token`" (a terminal doesn't have to be a literal; a
variable inside one just captures whatever's there). The `embed.(...)`
half is an ordinary goal, spliced in without touching the input list:
[`split_string/4`](REFERENCE.md#split_string4) breaks `"level=warn"`
into `["level", "warn"]`, and
[`atom_string/2`](REFERENCE.md#atom_string2-and-string_to_atom2) turns
`"level"` into the atom `:level` so field names can be matched and
grouped on later. `Database.add_clause/2` plus
`Episteme.Dcg.translate_rule/2` (rather than `consult_forms/2`'s
`{:dcg, ...}` shorthand) is the direct call the shorthand itself
builds on, and reads consistently with how every other rule in this
file gets stored.
`fields/1` is the variable-length tail: zero or more `any_field/2`
matches, collected into a list.
```elixir
db = Database.add_clause(db, Episteme.Dcg.translate_rule(c.(:fields, [[]]), []))
fc_k = Term.new_var("K")
fc_v = Term.new_var("V")
fc_rest = Term.new_var("Rest")
fields_cons_head = c.(:fields, [[c.(:field, [fc_k, fc_v]) | fc_rest]])
fields_cons_body = c.(:and, [c.(:any_field, [fc_k, fc_v]), c.(:fields, [fc_rest])])
db = Database.add_clause(db, Episteme.Dcg.translate_rule(fields_cons_head, fields_cons_body))
```
Same recursive shape as `sum_list/2` in
[EXAMPLES.md §7](EXAMPLES.md#7-writing-your-own-recursive-list-rule):
an empty-list base case, and a case that peels one `field(K, V)` off
the front and recurses on the rest — except here the recursion is
consuming *tokens*, not walking an already-built list.
`log_line/5` ties it together: four required fields in a fixed order,
each converted to an atom, then whatever's left goes through
`fields/1`:
```elixir
ll_ts = Term.new_var("Ts")
ll_level = Term.new_var("Level")
ll_level_str = Term.new_var("LevelStr")
ll_component = Term.new_var("Component")
ll_component_str = Term.new_var("ComponentStr")
ll_event = Term.new_var("Event")
ll_event_str = Term.new_var("EventStr")
ll_fields = Term.new_var("Fields")
log_line_head = c.(:log_line, [ll_ts, ll_level, ll_component, ll_event, ll_fields])
log_line_body =
c.(:and, [
c.(:any_field, [:ts, ll_ts]),
c.(:and, [
c.(:any_field, [:level, ll_level_str]),
c.(:and, [
embed.(c.(:atom_string, [ll_level, ll_level_str])),
c.(:and, [
c.(:any_field, [:component, ll_component_str]),
c.(:and, [
embed.(c.(:atom_string, [ll_component, ll_component_str])),
c.(:and, [
c.(:any_field, [:event, ll_event_str]),
c.(:and, [
embed.(c.(:atom_string, [ll_event, ll_event_str])),
c.(:fields, [ll_fields])
])
])
])
])
])
])
])
db = Database.add_clause(db, Episteme.Dcg.translate_rule(log_line_head, log_line_body))
```
`any_field(:ts, Ts)` deliberately leaves the timestamp as a string
rather than converting it to an atom like the other three — ISO 8601
timestamps sort correctly as plain byte strings, so there's no need to
parse them any further for what this audit actually asks. Trying the
grammar directly with [`phrase/2`](REFERENCE.md#phrase2-and-phrase3):
```elixir
line = "ts=2026-08-05T10:15:32Z level=warn component=auth event=login_failed user=alice ip=203.0.113.5"
parse_goal =
c.(:phrase, [
c.(:log_line, [
Term.new_var("Ts"),
Term.new_var("Level"),
Term.new_var("Component"),
Term.new_var("Event"),
Term.new_var("Fields")
]),
String.split(line)
])
Episteme.query(parse_goal, db)
#=> {:ok, [%{
# "Ts" => "2026-08-05T10:15:32Z",
# "Level" => :warn,
# "Component" => :auth,
# "Event" => :login_failed,
# "Fields" => [
# %Episteme.Term.Compound{name: :field, args: [:user, "alice"]},
# %Episteme.Term.Compound{name: :field, args: [:ip, "203.0.113.5"]}
# ]
# }]}
```
### Rules over the parsed facts
Every line becomes a `log_entry(Ts, Level, Component, Event, Fields)`
fact once parsed. `field_value/3` is a lookup into that trailing
`Fields` list, built directly on the [`member/2`](CHEATSHEET.md#lists)
builtin:
```elixir
fv_fields = Term.new_var("Fields")
fv_key = Term.new_var("Key")
fv_value = Term.new_var("Value")
db =
Database.add_clause(
db,
{c.(:field_value, [fv_fields, fv_key, fv_value]),
c.(:member, [c.(:field, [fv_key, fv_value]), fv_fields])}
)
```
`failed_login/3` picks out the one event shape the rest of this cares
about:
```elixir
fl_user = Term.new_var("User")
fl_ip = Term.new_var("Ip")
fl_ts = Term.new_var("Ts")
fl_fields = Term.new_var("Fields")
db =
Database.add_clause(
db,
{c.(:failed_login, [fl_user, fl_ip, fl_ts]),
c.(:and, [
c.(:log_entry, [fl_ts, :warn, :auth, :login_failed, fl_fields]),
c.(:and, [
c.(:field_value, [fl_fields, :user, fl_user]),
c.(:field_value, [fl_fields, :ip, fl_ip])
])
])}
)
```
Now the two heuristics, each a [`setof/3`](REFERENCE.md#bagof3-and-setof3)
grouped by exactly one of `failed_login/3`'s three fields, with the
other free variable folded away via `^`
([EXAMPLES.md](REFERENCE.md#bagof3-and-setof3) explains the `Var^Goal`
existential-quantification syntax in full). `brute_force_suspect/1`
groups by `User`, folding `Ip` away, and checks the group is big
enough:
```elixir
bf_user = Term.new_var("User")
bf_ip = Term.new_var()
bf_ts = Term.new_var()
bf_timestamps = Term.new_var("Timestamps")
bf_n = Term.new_var("N")
db =
Database.add_clause(
db,
{c.(:brute_force_suspect, [bf_user]),
c.(:and, [
c.(:setof, [bf_ts, c.(:^, [bf_ip, c.(:failed_login, [bf_user, bf_ip, bf_ts])]), bf_timestamps]),
c.(:and, [c.(:length, [bf_timestamps, bf_n]), c.(:greater_or_equal, [bf_n, 3])])
])}
)
```
`shared_ip_suspect/1` is the mirror image — group by `Ip`, fold `Ts`
away, check more than one distinct user shows up:
```elixir
si_ip = Term.new_var("Ip")
si_user = Term.new_var("User")
si_ts = Term.new_var()
si_users = Term.new_var("Users")
si_n = Term.new_var("N")
db =
Database.add_clause(
db,
{c.(:shared_ip_suspect, [si_ip]),
c.(:and, [
c.(:setof, [si_user, c.(:^, [si_ts, c.(:failed_login, [si_user, si_ip, si_ts])]), si_users]),
c.(:and, [c.(:length, [si_users, si_n]), c.(:>, [si_n, 1])])
])}
)
```
(`greater_or_equal/2` and `>/2` — see
[Arithmetic comparisons](REFERENCE.md#arithmetic-comparisons-numeric_equal2-numeric_not_equal2-2-2-less_or_equal2-greater_or_equal2)
— `setof/3` already deduplicates `si_users`/`bf_timestamps`, so
`length/2` here is counting *distinct* values, not raw match counts.)
### Ingesting a batch of log lines
This is the dynamic-database half: parse each raw line and `assertz`
the resulting fact, the same `assertz/1` from
[EXAMPLES.md §4](EXAMPLES.md#4-a-key-value-store-assertretract), one
line at a time — exactly the shape a real log stream arrives in.
```elixir
lines = [
"ts=2026-08-05T10:15:32Z level=warn component=auth event=login_failed user=alice ip=203.0.113.5",
"ts=2026-08-05T10:15:40Z level=info component=auth event=login_success user=bob ip=198.51.100.7",
"ts=2026-08-05T10:16:02Z level=warn component=auth event=login_failed user=alice ip=203.0.113.5",
"ts=2026-08-05T10:16:10Z level=error component=payments event=charge_failed user=carol amount=49.99",
"ts=2026-08-05T10:16:45Z level=warn component=auth event=login_failed user=alice ip=203.0.113.9",
"ts=2026-08-05T10:16:51Z level=warn component=auth event=login_failed user=dave ip=203.0.113.9",
"ts=2026-08-05T10:17:03Z level=info component=auth event=login_success user=alice ip=203.0.113.9"
]
Enum.each(lines, fn line ->
goal =
c.(:phrase, [
c.(:log_line, [
Term.new_var("Ts"),
Term.new_var("Level"),
Term.new_var("Component"),
Term.new_var("Event"),
Term.new_var("Fields")
]),
String.split(line)
])
{:ok, [solution]} = Episteme.query(goal, db)
entry =
c.(:log_entry, [
solution["Ts"],
solution["Level"],
solution["Component"],
solution["Event"],
solution["Fields"]
])
{:ok, [%{}]} = Episteme.query(c.(:assertz, [entry]), db)
end)
```
Note `carol`'s line: a `payments`/`charge_failed` event with an
`amount` field instead of `ip` at all. `log_entry/5`'s trailing
`Fields` list is exactly why that's not a problem — the grammar never
hard-codes which fields follow the first four, so entries with
completely different shapes live in the same database without any
schema migration.
### Running the audit
```elixir
report_user = Term.new_var()
report_ip = Term.new_var()
report_suspects = Term.new_var("Suspects")
report_ips = Term.new_var("SharedIps")
report_goal =
c.(:and, [
c.(:findall, [report_user, c.(:brute_force_suspect, [report_user]), report_suspects]),
c.(:and, [
c.(:findall, [report_ip, c.(:shared_ip_suspect, [report_ip]), report_ips]),
c.(:and, [
c.(:format, ["Brute-force suspects: ~w~n", [report_suspects]]),
c.(:format, ["Shared-IP suspects: ~w~n", [report_ips]])
])
])
])
Episteme.query(report_goal, db)
```
## Result
Actually running the above (`mix run` against a checkout of this
version of Episteme) prints:
```text
Brute-force suspects: ["alice"]
Shared-IP suspects: ["203.0.113.9"]
```
and answers `{:ok, [%{"Suspects" => ["alice"], "SharedIps" => ["203.0.113.9"]}]}`.
Both are correct, and for different reasons — which is the point of
running two independent heuristics instead of one:
- **`alice`** is a brute-force suspect: three failed logins in the
data, full stop. It doesn't matter that two of them came from one
IP (`203.0.113.5`) and the third from another (`203.0.113.9`) —
`brute_force_suspect/1` explicitly folds `Ip` away with `^` before
counting, because "did this account get hammered" shouldn't depend
on the attacker rotating source addresses.
- **`203.0.113.9`** is a shared-IP suspect: it shows up behind failed
logins for two different users (`alice` and `dave`), which is
exactly the "one attacker, many stolen credentials" shape —
independent of the fact that `dave` alone only has one failed
login and would never trip the brute-force check by himself.
- **`bob`** never appears in either report — his only log line is a
`login_success`, which `failed_login/3` doesn't match at all.
- **`carol`** doesn't appear either, for a different reason: her line
is a real, successfully-parsed `log_entry/5` fact (the DCG doesn't
care that it's a payment event, not a login), it's just that
`failed_login/3` only ever looks at `warn`/`auth`/`login_failed`
entries, so a `charge_failed` payment event was never in scope for
either audit to begin with.
That last distinction — *parsed but not relevant* vs. *never
matched the grammar at all* — is worth sitting with: nothing here
had to special-case `carol`'s line, reject it, or fail. It's a
perfectly ordinary fact in the database; it simply never satisfies a
goal that specifically asks about failed logins. A schema-first
approach (a fixed struct per log line, one JSON schema, etc.) would
have had to decide up front whether payment events belong in the same
table as auth events; here that question just never comes up.
## What this exercises
Every one of these is a full [REFERENCE.md](REFERENCE.md) entry if you
want the complete semantics:
- [DCG grammars](REFERENCE.md#dcg-definite-clause-grammars) —
terminals, embedded goals (`{Goal}`), nonterminal recursion,
`phrase/2`
- [The dynamic database](REFERENCE.md#dynamic-database) — `assertz/1`
building up a fact base one line at a time
- [`setof/3`](REFERENCE.md#bagof3-and-setof3) — grouping by a free
variable, existentially quantifying another away with `^`
- [`split_string/4`](REFERENCE.md#split_string4) and
[`atom_string/2`](REFERENCE.md#atom_string2-and-string_to_atom2) —
the string/atom conversion family, used inside the grammar itself
- [`member/2`](CHEATSHEET.md#lists), `length/2`,
[`greater_or_equal/2`/`>/2`](REFERENCE.md#arithmetic-comparisons-numeric_equal2-numeric_not_equal2-2-2-less_or_equal2-greater_or_equal2),
and [`findall/3`](REFERENCE.md#findall3) — the same everyday
predicates from [EXAMPLES.md](EXAMPLES.md), doing real work here
instead of toy work
- [`format/2`](REFERENCE.md#format1-and-format2) — turning the
query's answer into the actual report text above