Drinking with Datalog — Cocktail Advisor
Mix.install([
# {:datalog, path: Path.join(__DIR__, "..")},
{:datalog, github: "chgeuer/ex_datalog"},
{:kino, "~> 0.18"}
])
Setup
📚 Other Examples: Azure Service Compatibility
Based on “Drinking with Datalog” by Ian Henry.
Given a bar inventory and a recipe book, a Datalog engine can answer:
- What can I make? — cocktails where you have (or can derive) every ingredient
- What’s missing? — ingredients you’d need to buy for a specific cocktail
- What should I buy next? — the single ingredient that unlocks the most new cocktails
The key insight: ingredient transformations (lime → lime juice, sugar → simple syrup) and substitutions (cointreau ↔ triple sec) are just Datalog rules. The engine chains them automatically — you never write imperative “if/then” logic.
alias Datalog.{Database, Clause, Literal, Variable, Constant, Resolution, Substitution}
Recipes
15 classic cocktails. Each recipe is a set of needs(Drink, Ingredient) facts.
split = fn p -> &String.split(&1, p, trim: true) end
"""
Daiquiri = light rum, lime juice, simple syrup
Margarita = tequila, lime juice, triple sec
Mojito = light rum, lime juice, simple syrup, mint, soda water
Old Fashioned = whiskey, sugar, bitters, orange
Manhattan = whiskey, sweet vermouth, bitters
Negroni = gin, sweet vermouth, campari
Martini = gin, dry vermouth
Cosmopolitan = vodka, triple sec, lime juice, cranberry juice
Whiskey Sour = whiskey, lemon juice, simple syrup
Mai Tai = light rum, lime juice, orange liqueur, orgeat, simple syrup
Gin and Tonic = gin, tonic water, lime
Moscow Mule = vodka, ginger beer, lime juice
Tom Collins = gin, lemon juice, simple syrup, soda water
Aperol Spritz = aperol, prosecco, soda water
Dark and Stormy = dark rum, ginger beer, lime
"""
|> split.("\n").()
|> Enum.map(split.("="))
|> Enum.map(fn [drink, components] -> {String.trim(drink), split.(",").(components) |> Enum.map(&String.trim/1)} end)
recipes = [
{"Daiquiri", ["light rum", "lime juice", "simple syrup"]},
{"Margarita", ["tequila", "lime juice", "triple sec"]},
{"Mojito", ["light rum", "lime juice", "simple syrup", "mint", "soda water"]},
{"Old Fashioned", ["whiskey", "sugar", "bitters", "orange"]},
{"Manhattan", ["whiskey", "sweet vermouth", "bitters"]},
{"Negroni", ["gin", "sweet vermouth", "campari"]},
{"Martini", ["gin", "dry vermouth"]},
{"Cosmopolitan", ["vodka", "triple sec", "lime juice", "cranberry juice"]},
{"Whiskey Sour", ["whiskey", "lemon juice", "simple syrup"]},
{"Mai Tai", ["light rum", "lime juice", "orange liqueur", "orgeat", "simple syrup"]},
{"Gin and Tonic", ["gin", "tonic water", "lime"]},
{"Moscow Mule", ["vodka", "ginger beer", "lime juice"]},
{"Tom Collins", ["gin", "lemon juice", "simple syrup", "soda water"]},
{"Aperol Spritz", ["aperol", "prosecco", "soda water"]},
{"Dark and Stormy", ["dark rum", "ginger beer", "lime"]},
]
needs_facts =
for {drink, ingredients} <- recipes, ingredient <- ingredients do
Clause.fact(Literal.from_terms(:needs, [drink, ingredient]))
end
IO.puts("#{length(needs_facts)} recipe facts for #{length(recipes)} cocktails")
Ingredient Transformations
This is where it gets interesting. Some ingredients can be derived from others:
| Have | Get |
|---|---|
| lime | lime juice |
| lemon | lemon juice |
| sugar | simple syrup |
| cointreau | triple sec, orange liqueur |
| dark rum | light rum |
These are begets_base/2 facts. The Datalog rules will make them composable.
transformations = [
# Citrus → juice
{"lime", "lime juice"},
{"lime", "lime wedge"},
{"lemon", "lemon juice"},
{"lemon", "lemon wedge"},
{"orange", "orange juice"},
{"orange", "orange wedge"},
# Sugar → syrup
{"sugar", "simple syrup"},
# Rum substitutions
{"aged rum", "light rum"},
{"dark rum", "light rum"},
# Tequila substitutions
{"reposado tequila", "tequila"},
{"anejo tequila", "tequila"},
# Gin substitutions
{"london dry gin", "gin"},
# Orange liqueur family
{"cointreau", "triple sec"},
{"cointreau", "orange liqueur"},
{"grand marnier", "orange liqueur"},
]
begets_facts =
for {from, to} <- transformations do
Clause.fact(Literal.from_terms(:begets_base, [from, to]))
end
# Track all known ingredients (from recipes + transformations)
all_ingredients =
(recipes |> Enum.flat_map(fn {_, ings} -> ings end)) ++
(transformations |> Enum.flat_map(fn {from, to} -> [from, to] end))
|> Enum.uniq()
ingredient_facts =
for ingredient <- all_ingredients do
Clause.fact(Literal.from_terms(:is_ingredient, [ingredient]))
end
IO.puts("#{length(transformations)} transformations, #{length(all_ingredients)} unique ingredients")
The Rules
Five Datalog rules that chain together to answer “What can I make?”:
begets(X, X) :- is_ingredient(X). % reflexive: everything "is" itself
begets(X, Y) :- begets_base(X, Y). % direct transformations
has_derived(Out) :- has(In), begets(In, Out). % what you effectively have
missing(Drink, Ing) :- needs(Drink, Ing), % what you need but don't have
not has_derived(Ing). % (negation-as-failure!)
can_make(Drink) :- needs(Drink, _), % a drink with no missing
not missing(Drink, _). % ingredients
The not in missing and can_make is negation-as-failure — the engine tries to
prove has_derived(Ing), and if it can’t, concludes the ingredient is missing.
x = Variable.new(:x)
y = Variable.new(:y)
drink = Variable.new(:drink)
ing = Variable.new(:ing)
out = Variable.new(:out)
inp = Variable.new(:in)
rules = [
# begets(X, X) :- is_ingredient(X).
Clause.new(
Literal.new(:begets, [x, x]),
[Literal.new(:is_ingredient, [x])]
),
# begets(X, Y) :- begets_base(X, Y).
Clause.new(
Literal.new(:begets, [x, y]),
[Literal.new(:begets_base, [x, y])]
),
# has_derived(Out) :- has(In), begets(In, Out).
Clause.new(
Literal.new(:has_derived, [out]),
[Literal.new(:has, [inp]), Literal.new(:begets, [inp, out])]
),
# missing(Drink, Ing) :- needs(Drink, Ing), not has_derived(Ing).
Clause.new(
Literal.new(:missing, [drink, ing]),
[
Literal.new(:needs, [drink, ing]),
Literal.negate(Literal.new(:has_derived, [ing]))
]
),
# can_make(Drink) :- needs(Drink, _), not missing(Drink, _).
Clause.new(
Literal.new(:can_make, [drink]),
[
Literal.new(:needs, [drink, Variable.new(:_any)]),
Literal.negate(Literal.new(:missing, [drink, Variable.new(:_any2)]))
]
),
]
:ok
Assemble the Database
db = Database.new(needs_facts ++ begets_facts ++ ingredient_facts ++ rules)
IO.puts("Database: #{Database.size(db)} clauses")
Stock Your Bar
Change this list to match what’s actually on your shelf!
my_bar = [
"light rum", "lime", "sugar", "mint", "soda water",
"whiskey", "bitters", "orange", "lemon",
]
bar_facts =
for ingredient <- my_bar do
Clause.fact(Literal.from_terms(:has, [ingredient]))
end
db = Enum.reduce(bar_facts, db, &Database.add(&2, &1))
IO.puts("🍸 Your bar: #{Enum.join(my_bar, ", ")}")
What Can I Make?
ctx = %Resolution.Context{database: db, max_depth: 100}
drink_var = Variable.new(:drink)
goal = Literal.new(:can_make, [drink_var])
results = Resolution.query_substitutions(goal, ctx)
mixable =
results
|> Enum.map(fn subst ->
{:ok, d} = Substitution.lookup(subst, drink_var)
d.value
end)
|> Enum.uniq()
|> Enum.sort()
IO.puts("🍹 You can make #{length(mixable)} cocktails:\n")
Enum.each(mixable, &IO.puts(" ✅ #{&1}"))
What’s Missing for Each Cocktail?
all_drinks = recipes |> Enum.map(&elem(&1, 0)) |> Enum.sort()
unmakeable = all_drinks -- mixable
ing_var = Variable.new(:ing)
missing_report =
Enum.map(unmakeable, fn drink_name ->
goal = Literal.new(:missing, [Constant.new(drink_name), ing_var])
results = Resolution.query_substitutions(goal, ctx)
missing =
Enum.map(results, fn subst ->
{:ok, i} = Substitution.lookup(subst, ing_var)
i.value
end)
|> Enum.uniq()
|> Enum.sort()
%{cocktail: drink_name, missing: Enum.join(missing, ", "), count: length(missing)}
end)
missing_report
|> Enum.sort_by(& &1.count)
|> Kino.DataTable.new(name: "Missing ingredients per cocktail")
What Should I Buy Next?
Find the single ingredient that unlocks the most new cocktails. For each unmakeable drink, collect what’s missing, then count how often each ingredient appears.
ingredient_impact =
Enum.reduce(unmakeable, %{}, fn drink_name, acc ->
goal = Literal.new(:missing, [Constant.new(drink_name), ing_var])
results = Resolution.query_substitutions(goal, ctx)
missing =
Enum.map(results, fn subst ->
{:ok, i} = Substitution.lookup(subst, ing_var)
i.value
end)
|> Enum.uniq()
Enum.reduce(missing, acc, fn ingredient, inner_acc ->
Map.update(inner_acc, ingredient, 1, &(&1 + 1))
end)
end)
ranked =
ingredient_impact
|> Enum.sort_by(fn {_, count} -> -count end)
|> Enum.map(fn {ingredient, count} -> %{ingredient: ingredient, unlocks: count} end)
IO.puts("🛒 Shopping advice (most impactful purchases):\n")
ranked
|> Enum.take(5)
|> Enum.each(fn %{ingredient: ing, unlocks: n} ->
IO.puts(" #{ing} — needed for #{n} cocktail#{if n > 1, do: "s", else: ""}")
end)
ranked |> Kino.DataTable.new(name: "Ingredient impact ranking")
Simulate a Purchase
Let’s buy the top-ranked ingredient and see what new cocktails we unlock.
{best_ingredient, _} = hd(Enum.sort_by(ingredient_impact, fn {_, c} -> -c end))
IO.puts("🛒 Buying: #{best_ingredient}\n")
new_fact = Clause.fact(Literal.from_terms(:has, [best_ingredient]))
db_after = Database.add(db, new_fact)
ctx_after = %Resolution.Context{database: db_after, max_depth: 100}
goal = Literal.new(:can_make, [drink_var])
new_results = Resolution.query_substitutions(goal, ctx_after)
new_mixable =
new_results
|> Enum.map(fn subst ->
{:ok, d} = Substitution.lookup(subst, drink_var)
d.value
end)
|> Enum.uniq()
|> Enum.sort()
newly_unlocked = new_mixable -- mixable
IO.puts("🎉 Newly unlocked cocktails:\n")
Enum.each(newly_unlocked, &IO.puts(" 🆕 #{&1}"))
IO.puts("\nTotal: #{length(mixable)} → #{length(new_mixable)} cocktails")
How It All Connects
The power of Datalog is declarative composition. We never wrote:
> “For each cocktail, loop through ingredients, check if we have each one > or can derive it via a transformation chain, and if all are present…”
Instead, we stated what things mean — begets, has_derived, missing, can_make —
and the engine figured out the answers. Adding a new transformation rule (say,
agave nectar → simple syrup) would instantly propagate through every query without
touching any application code.
This is the same pattern used in the Azure Service Compatibility example — swap cocktails for cloud architectures and ingredients for service capabilities.