mirror of
https://github.com/open-thought/reasoning-gym.git
synced 2026-04-19 12:58:07 +00:00
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""solver.py
|
|
|
|
Solve the Einstein puzzle using Raymond Hettinger's approach.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from random import Random
|
|
from typing import Generator, Iterable, Type
|
|
|
|
from reasoning_gym.logic.contrib.logic_puzzle.clues import (
|
|
Clue,
|
|
beside,
|
|
comb,
|
|
consecutive,
|
|
found_at,
|
|
left_of,
|
|
one_between,
|
|
right_of,
|
|
same_house,
|
|
two_between,
|
|
)
|
|
from reasoning_gym.logic.contrib.logic_puzzle.literals import (
|
|
Animal,
|
|
Children,
|
|
Cigar,
|
|
Color,
|
|
Drink,
|
|
Flower,
|
|
Food,
|
|
Literal,
|
|
Mother,
|
|
Nationality,
|
|
)
|
|
from reasoning_gym.logic.contrib.logic_puzzle.sat_utils import one_of, solve_all
|
|
|
|
|
|
class Puzzle:
|
|
"""
|
|
A Puzzle is defined as a collection of constraints and clues.
|
|
|
|
Clues are subclassess of Clue. They represent information about the puzzle that can be used by
|
|
a human to solve it, like "the man who drinks tea owns a cat." Clues aren't converted to CNF
|
|
until the `as_cnf` method is called.
|
|
|
|
Constraints are structural properties of the puzzle, given to us in CNF to start. They're
|
|
things like "each house gets exactly one type of flower" and "each flower must be assigned
|
|
to one house." These will be the same for every Puzzle, so we have a default `set_constraints`
|
|
method that takes care of them.
|
|
|
|
We can add clues with `add_clue`. This returns the instance, so they can be chained together.
|
|
|
|
Since in constraint satisfaction land, clues and constraints are the same thing (they're just
|
|
logical clauses), we lump them all together at solve time.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
rng: Random,
|
|
element_types: Iterable[Type[Literal]],
|
|
elements: Iterable[Literal] = None,
|
|
n_houses: int = 5,
|
|
) -> None:
|
|
"""
|
|
Initialize a puzzle with different kinds of elements. The `element_types` is a list of the
|
|
*kinds* of literals we're using, i.e., Smoothie, FavoriteFood, FavoriteColor, etc. The
|
|
`elements` is a list of the literals themselves, since some of the literal types have more
|
|
than `n_houses` elements.
|
|
|
|
If `elements` is not provided, we assume that every member of each of `element_types` is
|
|
part of the puzzle. This is the case in example puzzles, but rarely the case for generated
|
|
ones.
|
|
"""
|
|
|
|
self.rng = rng
|
|
self.element_classes = list(element_types)
|
|
if elements is None:
|
|
self.literals = [el for el_class in self.element_classes for el in el_class]
|
|
else:
|
|
self.literals = list(elements)
|
|
|
|
self.houses = tuple(range(1, n_houses + 1))
|
|
self.clues: set[Clue] = set()
|
|
self.constraints: list[tuple[str]] = []
|
|
self.extra_clues: set[Clue] = set()
|
|
self.solution = None
|
|
|
|
def _add_constraint(self, constraints: list[tuple[str]]) -> Puzzle:
|
|
self.constraints.extend(constraints)
|
|
return self
|
|
|
|
def set_constraints(self) -> Puzzle:
|
|
# each house gets exactly one value from each set of literals
|
|
for house in self.houses:
|
|
for element_type in self.element_classes:
|
|
literals_of_that_type = [l for l in self.literals if isinstance(l, element_type)]
|
|
self._add_constraint(one_of(comb(value, house) for value in literals_of_that_type))
|
|
|
|
# each value gets assigned to exactly one house
|
|
for literal in self.literals:
|
|
self._add_constraint(one_of(comb(literal, house) for house in self.houses))
|
|
|
|
return self
|
|
|
|
def add_clue(self, clue: Clue) -> Puzzle:
|
|
self.clues.add(clue)
|
|
return self
|
|
|
|
def remove_clue(self, clue: Clue) -> Puzzle:
|
|
self.clues.remove(clue)
|
|
return self
|
|
|
|
@contextmanager
|
|
def with_clues(self, clues: Iterable[Clue]) -> Generator[Puzzle]:
|
|
"""Create a context in which this Puzzle temporarily has clues added to it"""
|
|
|
|
clues = list(clues) # so we don't accidentally exhaust the iterable
|
|
empty_clue = len(self.clues) == 0
|
|
for clue in clues:
|
|
self.add_clue(clue)
|
|
|
|
yield self
|
|
if empty_clue:
|
|
for clue in clues:
|
|
self.remove_clue(clue)
|
|
|
|
return self
|
|
|
|
def as_cnf(self) -> list[tuple[str]]:
|
|
"""Express puzzle as solvable CNF"""
|
|
|
|
# this would be a comprehension if we could use iterable unpacking
|
|
cnf = []
|
|
for clue in self.clues:
|
|
cnf.extend(clue.as_cnf())
|
|
|
|
cnf.extend(self.constraints)
|
|
return cnf
|
|
|
|
def __repr__(self) -> str:
|
|
s = f"This is a logic puzzle. "
|
|
s += f"There are {len(self.houses)} houses (numbered {self.houses[0]} on the left, "
|
|
s += f"{self.houses[-1]} on the right), from the perspective of someone standing across "
|
|
s += f"the street from them. Each has a different person in them. "
|
|
s += f"They have different characteristics:\n"
|
|
for element_type in self.element_classes:
|
|
literals = [l for l in self.literals if isinstance(l, element_type)]
|
|
self.rng.shuffle(literals)
|
|
desc = element_type.description()
|
|
idx = desc.index(":") if ":" in desc else None
|
|
desc = desc[:idx]
|
|
s += f" - {desc}: " + ", ".join(e.name.replace("_", " ") for e in literals) + "\n"
|
|
|
|
s += "\n"
|
|
# generate deterministically shuffled order
|
|
clues = sorted(self.clues)
|
|
self.rng.shuffle(clues)
|
|
s += "".join(f"{i + 1}. {clue}\n" for i, clue in enumerate(clues))
|
|
return s
|
|
|
|
|
|
"""
|
|
Original version
|
|
|
|
Taken straight from rhettinger.github.io and the associated talk.
|
|
|
|
Entities:
|
|
* There are five houses in unique colors: Blue, green, red, white and yellow.
|
|
* In each house lives a person of unique nationality: British, Danish, German, Norwegian and Swedish.
|
|
* Each person drinks a unique beverage: Beer, coffee, milk, tea and water.
|
|
* Each person smokes a unique cigar brand: Blue Master, Dunhill, Pall Mall, Prince and blend.
|
|
* Each person keeps a unique pet: Cats, birds, dogs, fish and horses.
|
|
|
|
Constraints:
|
|
* The Brit lives in a red house.
|
|
* The Swede keeps dogs as pets.
|
|
* The Dane drinks tea.
|
|
* The green house is on the left of the white, next to it.
|
|
* The green house owner drinks coffee.
|
|
* The person who smokes Pall Mall rears birds.
|
|
* The owner of the yellow house smokes Dunhill.
|
|
* The man living in the house right in the center drinks milk.
|
|
* The Norwegian lives in the first house.
|
|
* The man who smokes blend lives next to the one who keeps cats.
|
|
* The man who keeps horses lives next to the man who smokes Dunhill.
|
|
* The owner who smokes Blue Master drinks beer.
|
|
* The German smokes Prince.
|
|
* The Norwegian lives next to the blue house.
|
|
* The man who smokes blend has a neighbor who drinks water.
|
|
|
|
For each house, find out what color it is, who lives there, what they drinkk, what
|
|
they smoke, and what pet they own.
|
|
"""
|
|
|
|
if __name__ == "__main__":
|
|
enum_classes: list[Type[Literal]] = [Color, Nationality, Animal, Drink, Cigar]
|
|
literals: list[Literal] = [el for group in enum_classes for el in group]
|
|
|
|
# set up the puzzle with constraints and clues
|
|
puzzle = Puzzle(rng=Random(), element_types=[Color, Nationality, Drink, Cigar, Animal])
|
|
|
|
puzzle = (
|
|
puzzle.set_constraints()
|
|
.add_clue(same_house(Nationality.brit, Color.red))
|
|
.add_clue(same_house(Nationality.swede, Animal.dog))
|
|
.add_clue(same_house(Nationality.dane, Drink.tea))
|
|
.add_clue(consecutive(Color.green, Color.white))
|
|
.add_clue(same_house(Color.green, Drink.coffee))
|
|
.add_clue(same_house(Cigar.pall_mall, Animal.bird))
|
|
.add_clue(same_house(Color.yellow, Cigar.dunhill))
|
|
.add_clue(found_at(Drink.milk, 3))
|
|
.add_clue(found_at(Nationality.norwegian, 1))
|
|
.add_clue(beside(Cigar.blends, Animal.cat))
|
|
.add_clue(beside(Animal.horse, Cigar.dunhill))
|
|
.add_clue(same_house(Cigar.blue_master, Drink.root_beer))
|
|
.add_clue(same_house(Nationality.german, Cigar.prince))
|
|
.add_clue(beside(Nationality.norwegian, Color.blue))
|
|
.add_clue(beside(Cigar.blends, Drink.water))
|
|
)
|
|
|
|
print(puzzle)
|
|
|
|
sols = solve_all(puzzle.as_cnf())
|
|
print(f"{len(sols)} solutions found")
|
|
for sol in sols:
|
|
print(sol)
|
|
|
|
"""
|
|
Quag's version
|
|
|
|
In honor of Mother's Day, a feast is being held to celebrate five Moms: Aniya, Holly,
|
|
Janelle, Kailyn, and Penny. Each Mom will be served by their son or daughter (Bella,
|
|
Fred, Meredith, Samantha, and Timothy), who will also place a bouquet of flowers
|
|
(Carnations, Daffodils, Lilies, Roses, or Tulips) at their Mom's place setting and
|
|
prepare a meal for them (Grilled Cheese, Pizza, Spaghetti, Stew, or Stir Fry).
|
|
|
|
The seats are arranged in a straight line at the head table, with the first being
|
|
the furthest to the left (from our perspective, not the Mom's perspectives).
|
|
|
|
Also, when it says there is "one chair" between two people, it means one person might
|
|
be in the second chair while the other person is in the fourth (i.e. there is one chair
|
|
in between them that neither is sitting in).
|
|
"""
|
|
|
|
if __name__ == "__main__":
|
|
enum_classes: list[Type[Literal]] = [Mother, Children, Flower, Food]
|
|
literals = [el for group in enum_classes for el in group]
|
|
|
|
# set up the puzzle with constraints and clues
|
|
puzzle = Puzzle(rng=Random(), element_types=[Mother, Children, Flower, Food])
|
|
|
|
puzzle = (
|
|
puzzle.set_constraints()
|
|
# 1. There is one chair between the place setting with Lilies and the one eating Grilled Cheese.
|
|
.add_clue(one_between(Flower.lilies, Food.grilled_cheese))
|
|
# 2. There is one chair between Timothy's Mom and the one eating Stew.
|
|
.add_clue(one_between(Children.timothy, Food.stew))
|
|
# 3. There are two chairs between the Bella's Mom and Penny's seat on the right.
|
|
.add_clue(two_between(Children.bella, Mother.penny))
|
|
.add_clue(right_of(Mother.penny, Children.bella))
|
|
# 4. There is one chair between the place setting with Roses and the one eating Spaghetti on the left.
|
|
.add_clue(one_between(Flower.roses, Food.spaghetti))
|
|
.add_clue(left_of(Food.spaghetti, Flower.roses))
|
|
# 5. There are two chairs between the place setting with Carnations and Samantha's Mom.
|
|
.add_clue(two_between(Flower.carnations, Children.samantha))
|
|
# 6. There is one chair between Meredith's Mom and Timothy's Mom on the left.
|
|
.add_clue(one_between(Children.meredith, Children.timothy))
|
|
.add_clue(left_of(Children.timothy, Children.meredith))
|
|
# 7. Aniya's place setting has a lovely Carnation bouquet.
|
|
.add_clue(same_house(Mother.aniya, Flower.carnations))
|
|
# 8. There are two chairs between the one eating Grilled Cheese and the one eating Spaghetti.
|
|
.add_clue(two_between(Food.grilled_cheese, Food.spaghetti))
|
|
# 9. The person in the first chair (left-most) is eating Pizza.
|
|
.add_clue(found_at(Food.pizza, 1))
|
|
# 10. The Tulips were placed at one of the place settings somewhere to the left of Penny's chair.
|
|
.add_clue(left_of(Flower.tulips, Mother.penny))
|
|
# 11. There are two chairs between the one eating Spaghetti and Kailyn's seat.
|
|
.add_clue(two_between(Food.spaghetti, Mother.kailyn))
|
|
# 12. There is one chair between the one eating Pizza and Holly's chair on the right.
|
|
.add_clue(one_between(Food.pizza, Mother.holly))
|
|
.add_clue(right_of(Mother.holly, Food.pizza))
|
|
)
|
|
|
|
print(puzzle)
|
|
all_solutions = solve_all(puzzle.as_cnf())
|
|
print(f"{len(all_solutions)} solutions found")
|
|
print(all_solutions)
|