# Shift Scheduling
```elixir
Mix.install(
[
{:guesswork, "~> 0.8"},
{:kino, "~> 0.14"},
{:kino_explorer, "~> 0.1.23"}
], consolidate_protocols: false)
```
## Shift Scheduling
```elixir
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.
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.
```elixir
defmodule Shift do
@derive [Guesswork.Ast.Entity]
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
@derive [Guesswork.Ast.Entity]
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.
```elixir
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.
```elixir
{:bound, schedule} = answer.answer_sets
|> List.first()
|> get_in(["schedule"])
schedule
```
## More Idomatic Queries
Our last query works, but it isn't really ideal.
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 pulls a cached version of the stream that
has been partially evaluated in advance.
You should note that this is not done by default since precomputation on recursive
queries can lead to infinate searches.
```elixir
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) do
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
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, precompute_count: 25)
```
Finally, we can again extract the schedule and see the displayed table.
```elixir
{:bound, schedule} = answer.answer_sets
|> List.first()
|> get_in(["schedule"])
schedule
```