Powered by AppSignal & Oban Pro

Drinking with Datalog — Cocktail Advisor

examples/cocktail_advisor.livemd

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, &amp;Database.add(&amp;2, &amp;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, &amp;IO.puts("   ✅ #{&amp;1}"))

What’s Missing for Each Cocktail?

all_drinks = recipes |> Enum.map(&amp;elem(&amp;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(&amp; &amp;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, &amp;(&amp;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, &amp;IO.puts("   🆕 #{&amp;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 meanbegets, 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.