View Source Shift Scheduling
Mix.install(
[
{:guesswork, "~> 0.5"},
{:kino, "~> 0.13"},
{:kino_explorer, "~> 0.1.23"}
],
consolidate_protocols: false
)
Shift Scheduling
import Guesswork.Ast
require Explorer.DataFrame, as: DF
alias Guesswork.Ast.And
alias Guesswork.Ast.Fact
alias Guesswork.Ast.Assign
alias Guesswork.Ast.OneOf
alias Guesswork.Ast.Variable
alias Guesswork.Ast.Is
alias Guesswork.Answer.Result
Shift Scheduling is another interesting use case for logic programming is
scheduling.
It fits well since constraints are easily expressed in logic.
First we'll set up some basic data types to help run tests and display
the result using Kino
.
This will clean up our logical statements by pushing the more standard elixir
statements out of the queries and into these modules.
defmodule Shift do
defstruct [:bartender, :barback, :waiter]
def new(bartender, barback, waiter) do
%__MODULE__{bartender: bartender, barback: barback, waiter: waiter}
end
def valid?(%__MODULE__{bartender: bartender, barback: barback, waiter: waiter}) do
bartender != barback and bartender != waiter and barback != waiter
end
def member?(%__MODULE__{bartender: bartender, barback: barback, waiter: waiter}, value) do
Enum.member?([bartender, barback, waiter], value)
end
def to_kino_row(shift, day) do
Map.merge(Map.from_struct(shift), %{day: day})
end
end
defmodule Schedule do
defstruct [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
def new(monday, tuesday, wednesday, thursday, friday, saturday, sunday) do
%__MODULE__{
monday: monday,
tuesday: tuesday,
wednesday: wednesday,
thursday: thursday,
friday: friday,
saturday: saturday,
sunday: sunday,
}
end
def to_list(%__MODULE__{
monday: monday,
tuesday: tuesday,
wednesday: wednesday,
thursday: thursday,
friday: friday,
saturday: saturday,
sunday: sunday
}) do
[monday, tuesday, wednesday, thursday, friday, saturday, sunday]
end
def count_shifts(schedule, worker) do
to_list(schedule)
|> Enum.count(&Shift.member?(&1, worker))
end
defimpl Kino.Render do
def to_livebook(schedule) do
[:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
|> Enum.zip_with(Schedule.to_list(schedule), &Shift.to_kino_row(&2, Atom.to_string(&1)))
|> Kino.DataTable.new(keys: [:day, :bartender, :barback, :waiter])
|> Kino.Render.to_livebook()
end
end
end
Here is our first attempt at the shift scheduling query.
First we precompute all the valid shifts using a for
comprehension and a filter.
Then we use OneOf
s and is
es to build the query.
There are just two things to note.
First, is the use of the pin operator (^
) to pull in a variable from outside the term
.
Second, is how we are using Enum.shuffle/1
to in the OneOf
s.
If you try running the query without them it is much slower. This is because the shifts need to be different on different days, so starting with different lists of shift means we are more likely to hit shifts that don't conflict with each other.
possible_shifts = for bartender <- ["bob", "paul", "jenny", "chris"],
barback <- ["bob", "milly", "joe", "paul", "jenny"],
waiter <- ["milly", "jenny", "bob"] do
Shift.new(bartender, barback, waiter)
end
|> Enum.filter(&Shift.valid?/1)
answer = term(And.new([
# Assign shifts to days.
OneOf.new(monday, Enum.shuffle(^possible_shifts)),
OneOf.new(tuesday, Enum.shuffle(^possible_shifts)),
OneOf.new(wednesday, Enum.shuffle(^possible_shifts)),
OneOf.new(thursday, Enum.shuffle(^possible_shifts)),
OneOf.new(friday, Enum.shuffle(^possible_shifts)),
OneOf.new(saturday, Enum.shuffle(^possible_shifts)),
OneOf.new(sunday, Enum.shuffle(^possible_shifts)),
# Build the schedule.
is(schedule, fn monday, tuesday, wednesday, thursday, friday, saturday, sunday ->
Schedule.new(monday, tuesday, wednesday, thursday, friday, saturday, sunday)
end),
# The days specific workers aren't available.
is(false, fn monday -> Shift.member?(monday, "bob") end),
is(false, fn monday -> Shift.member?(monday, "chris") end),
is(false, fn tuesday -> Shift.member?(tuesday, "jenny") end),
is(false, fn thursday -> Shift.member?(thursday, "milly") end),
is(false, fn saturday -> Shift.member?(saturday, "bob") end),
# The limits on the number of shifts each worker wants.
is(true, fn schedule -> Schedule.count_shifts(schedule, "bob") >= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "joe") <= 2 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "chris") <= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "milly") <= 3 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "jenny") >= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "paul") <= 4 end),
]))
|> Guesswork.query(1)
Finally, We'll extract the schedule and use the Kino
integration we built
previously to display it.
{:bound, schedule} = answer.answer_sets
|> List.first()
|> get_in(["schedule"])
schedule
More Idomatic Queries
Our last query works, but it isn't really idea.
Instead of building in a script, we should be using a Guesswork.KnowledgeBase
to compile the information we need.
Furthermore, instead of using the for
comprehension we really should be using
Guesswork
's own tools to calculate it, since building a valid shift is really
the same problem as building a valid schedule, just simpler.
Most of the changes below ar in the ShiftKB
.
We establish which workers can do what using deffact
, and then we can build a
rule that says a shift has one of each type of worker, and must statisfy
Shift.valid?/1
.
The final query ends up looking a lot like our first query, only we use
Guesswork.Ast.Fact
s to pull in the information the ShiftKB
holds instead of
using the pin operator.
In fact, the query event acts very similarly to the first query; when Guesswork
sees repeated Guesswork.Ast.Fact
s, it groups, preloads, and shuffles their
answer sets, which is almost exactly what we do with the first query.
defmodule ShiftKB do
use Guesswork.KnowledgeBase.Collection
for worker <- ["bob", "paul", "jenny", "chris"] do
deffact("bartender", [worker])
end
for worker <- ["bob", "milly", "joe", "paul", "jenny"] do
deffact("barback", [worker])
end
for worker <- ["bob", "milly", "jenny"] do
deffact("waiter", [worker])
end
defrule("shift", [shift], And.new([
Fact.new("bartender", [w1]),
Fact.new("barback", [w2]),
Fact.new("waiter", [w3]),
Is.new(shift, [w1, w2, w3], &Shift.new/3),
Is.new(true, [shift], &Shift.valid?/1)
]))
end
answer = Guesswork.query(term(
And.new([
# Assign shifts to days.
Fact.new("shift", [monday]),
Fact.new("shift", [tuesday]),
Fact.new("shift", [wednesday]),
Fact.new("shift", [thursday]),
Fact.new("shift", [friday]),
Fact.new("shift", [saturday]),
Fact.new("shift", [sunday]),
# Build the schedule.
is(schedule, fn monday, tuesday, wednesday, thursday, friday, saturday, sunday ->
Schedule.new(monday, tuesday, wednesday, thursday, friday, saturday, sunday)
end),
# The days specific workerss aren't available.
is(false, fn monday -> Shift.member?(monday, "bob") end),
is(false, fn monday -> Shift.member?(monday, "chris") end),
is(false, fn tuesday -> Shift.member?(tuesday, "jenny") end),
is(false, fn thursday -> Shift.member?(thursday, "milly") end),
is(false, fn saturday -> Shift.member?(saturday, "bob") end),
# The limits on the number of shifts each worker wants.
is(true, fn schedule -> Schedule.count_shifts(schedule, "bob") >= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "joe") <= 2 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "chris") <= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "milly") <= 3 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "jenny") >= 4 end),
is(true, fn schedule -> Schedule.count_shifts(schedule, "paul") <= 4 end),
])
), 1, knowledge_base: ShiftKB)
Finally, we can again extract the schedule and see the displayed table.
{:bound, schedule} = answer.answer_sets
|> List.first()
|> get_in(["schedule"])
schedule