The Hardest Logic Puzzle Ever (In Python)

Fri 24 July 2020 by Moshe Zadka

The Labyrinth is a children’s movie. The main character is 16 years old, and solving a logic puzzle that will literally decide if she lives or dies. In fiction, characters are faced with realistic challenges: ones they can solve, even if they have to make an effort.

So, it makes sense that the designer of the eponymous labyrinth did not consult logicians Richard Smullyan, George Boolos (no relation to the inventor of boolean algebra), and John McCarthy (yes, the same person who invented Lisp and suggested that a “2-month, 10-(person) study” would make significant headway in the study of Artificial Intelligence). Those three would suggest that the designer use The Hardest Logic Puzzle Ever.

There are persistent rumors of the movie getting a reboot, or perhaps a sequel. Like any good sequel, the protagonists should face newer and bigger challenges. In the interests of helping the screen writers for the sequel/reboot, here is my explanation of the “Hardest Logic Puzzle Ever”, together with clear code.

from __future__ import annotations

Do you remember movies from your childhood that just did not age that well? You cringe at some tasteless joke, you say “I can’t believe it was acceptable to show this back then”? I think we can agree we want to future-proof the script for a bit.

Using modern-style annotations will make our code much easier to maintain.

import attr

This movie will come out in the 2020s, and we should use 2020-era code to model it. The attrs library is almost always the right solution to implement classes.

import random

The protagonist will face unknown challenges. Randomness is a state of knowledge. From her perspective, the true state of the world is random.

from zope import interface
from typing import Callable, Mapping, Tuple

This is already a hard logic puzzle, no reason to make it harder on ourselves. Clear interface and type hints will make the logic easier to understand.

This is why zope.interface is appropriate for The Hardest Logic Puzzle Ever. We also need the Callable interface, since our protagonist will be asking the Gods questions in the form of functions, and Mapping, since she will eventually need to answer the question “which God is which”.

The Tuple type will most be used by the careful protagonist in her internal type hints. This is high stakes code, and she wants to make sure she gets it right.

In the HLPE (Hardest Logic Puzzle Ever), the ones who answer the questions are Gods. Just like in the Marvel Cinematic Universe, they might not be literal “Gods”, but clearly powerful aliens. As aliens, they have their own language. The words for “yes” and “no” are “da” and “ja” – but we do not know which is which.

I suggest that this will not be revealed in the script. This is good fodder for endless fan discussions later on Reddit. As such, the best way is to make sure we do not know the answer ourselves: make it a random language!

import enum

@enum.unique
class GodWords(enum.Enum):
    ja = "ja"
    da = "da"

def make_god_language() -> Mapping[bool, GodWords]:
    words = list(GodWords)
    random.shuffle(words)
    ret = {}
    return {True: words.pop(), False: words.pop()}

Shuffling the words for “yes” and “no” means that the .pop() call will get a random one. Now we have the language: it maps an abstract concept (a Python Boolean) to a string.

In the HLPE, there are three Gods, called “A”, “B”, and “C”. They can be asked any question that refers to the Gods.

@enum.unique
class GodNames(enum.Enum):
    A = "A"
    B = "B"
    C = "C"

class IGod(interface.Interface):
    def ask(question: Question) -> GodWords:
        """Ask"""

But what questions is the protagonist allowed to ask? Questions are something the audience-identification protagonist will ask. For this, a Protocol is appropriate. (Also, those are more convenient for describing functions, which we do not want to annotate with explicit implementation declarations.)

from typing_extensions import Protocol

class Question(Protocol):
    def __call__(self, gods: Dict[GodName, IGod]) -> bool:
        pass

Because of how annotations work, at this point we need not know what GodName or IGod are.

The simplest of the Gods is the one who speaks always truly (in the God language).

@interface.implementer(IGod)
@attr.s(auto_attribs=True, frozen=True)
class TrueGod:
    _gods: Mapping[GodNames, IGod]
    _language: Mapping[bool, GodWords]

    def ask(self, question: Question) -> GodWords:
        return self._language[bool(question(self._gods))]

The next God always lies.

@attr.s(auto_attribs=True, frozen=True)
class FalseGod:
    _gods: Mapping[GodNames, IGod]
    _language: Mapping[bool, GodWords]

    def ask(self, question: Question) -> GodWords:
        return self._language[not bool(question(self._gods))]

But how to implement Random? This is a harder question than it seems.

The original statement just said “whether Random speaks truly or falsely is a completely random matter”. What does that mean? If you ask it two questions, can it answer truthfully to one and lie to the other?

Boolos wrote a “clarification”: “Whether Random speaks truly or not should be thought of as depending on the flip of a coin hidden in (their) brain: if the coin comes down heads, (they) speaks truly; if tails, falsely.” This clarification fails to elucidate much: it does not answer, for example, the question above.

Finally, based on the suggested solution, and assuming that the obvious simpler solutions do not work, Raben and Raben suggested suggested the clear guideline: “Whether Random says ja or da should be thought of as depending on the flip of a coin hidden in his brain: if the coin comes down heads, he says ja; if tails, he says da.”

This is the guideline I have chosen to implement here.

@attr.s(auto_attribs=True, frozen=True)
class RandomGod:
    _gods: Mapping[GodNames, IGod]
    _language: Mapping[bool, GodWords]

    def ask(self, question: Question) -> GodWords:
        return self._language[random.choice([True, False])]

So much back and forth discussion, over two millenia, for such simple code.

I’m sure the God-like aliens would have used code to describe the puzzle from the beginning, avoiding the messiness of natural language.

Accessing the God classes themselves would be the height of hubris, but the goal of the protagonist is to answer “which God is which”. We will build a special enumeration of the Gods’ identities, to be used in the solution.

GodIdentities = enum.Enum('God Identities',
                          {klass.__name__: klass.__name__
                           for klass in [TrueGod, FalseGod, RandomGod]})

With the Gods’ personalities implemented, we can write the code that creates a random world that complies with the terms of the puzzle. Three Gods, known as “A”, “B”, and “C”, assigned to the names randomly, and speaking in a randomly generated language.

def make_gods() -> Mapping[GodNames, IGod]:
    language = make_god_language()
    gods = {}
    god_list = [klass(gods=gods, language=language)
                for klass in [TrueGod, FalseGod, RandomGod]]
    random.shuffle(god_list)
    for name in GodNames:
        gods[name] = god_list.pop()
    return gods

The code so far corresponds to the first part of the scene: where the protagonist comes to the place of the Gods, learns the local rules, and realizes that she must either solve the puzzle correctly, or fail.

This time she is granted the chance to ask three questions. However, there are many more unknowns: there are 6 possible assignments for the Gods, and two possible meanings for the language. Frankly, I doubt that I would be able to solve the puzzle on my feet, when I am afraid for my life.

But luckily, I have Wikipedia and time, and so I have written up the code to find a solution here.

Part of the solution is to ask a God a question about themselves: “if I asked you SOME QUESTION, would you say ja”. While no question asked of Random is useful (the answer is random) for either True or False, this question would result in “ja” the right answer is “yes”, and “da* if the right answer is”no". This means that wrapping questions like this means we have to care neither the identity of the God, nor about the details of the God language.

This makes it a useful abstraction!

def if_asked_a_question_would_you_say_ja_is_ja(
        god_name: GodNames,
        gods: Mapping[GodNames, IGod],
        question: Callable[[IGod, Mapping[GodNames, IGod]], bool],
    ) -> bool:
    you = gods[god_name]
    def add_you(gods):
        return question(you, gods)
    return you.ask(lambda gods: you.ask(add_you) == GodWords.ja) == GodWords.ja

This would be a wonderful chance for a flash-“forward” into a hypothetical scene: the movie goes into black-and-white, and the protagonist voice-overs: if “A” is “True”, what would happen if I ask them “is 1 equal 1”? What would happen if I ask them “if I asked you, ‘is 1 equal 1?’, would you answer ’ja? What if”A" is “False”?

def hypothetical():
    language = make_god_language()
    gods = {}
    hypothetical_true_god = TrueGod(gods=gods, language=language)
    hypothetical_false_god = FalseGod(gods=gods, language=language)
    gods[GodNames.A] = hypothetical_true_god
    gods[GodNames.B] = hypothetical_false_god
    for (name, identity) in [(GodNames.A, GodIdentities.TrueGod), (GodNames.B, GodIdentities.FalseGod)]:
        for question in [lambda x,y: 1==1, lambda x, y: 1==0]:
            objective_value = question(None, None)
            print(
                f"{identity}, asked {objective_value} question:",
                if_asked_a_question_would_you_say_ja_is_ja(name, gods, question)
            )
hypothetical()
del hypothetical
God Identities.TrueGod, asked True question: True
God Identities.TrueGod, asked False question: False
God Identities.FalseGod, asked True question: True
God Identities.FalseGod, asked False question: False

Movie goes back to color. A smile spreads on the protagonist’s face. She knows how to solve this.

The next challenge is to find a God that is not Random. If you ask God B about whether A is Random, then if the answer is “ja”, then it means either the correct answer is that A is Random or it means B is Random. Either way, C is not Random.

For similar reasons, if B answers “da”, then “A” is not Random.

Either way, we have found a non-Random God. Now we know that we can find the truth from them by using the wrapper!

So we ask them whether they’re True. Now we ask them about whether “B” is Random.

So we know:

  • One God who is not Random (who we will call “the interlocutor”, since we’ll spend the rest of the conversation with them)
  • Whether the interlocutor is True
  • Whether B is Random
def ask_questions(gods: Mapping[GodNames, IGod]) -> Tuple[GodNames, bool, bool]:
    is_a_random_according_to_b = if_asked_a_question_would_you_say_ja_is_ja(
        GodNames.B,
        gods,
        lambda you, gods: isinstance(gods[GodNames.A], RandomGod)
    )
    interlocutor = GodNames.C if is_a_random_according_to_b else GodNames.A
    is_interlocutor_true = if_asked_a_question_would_you_say_ja_is_ja(
        interlocutor,
        gods,
        lambda you, gods: isinstance(you, TrueGod)
    )
    is_b_random = if_asked_a_question_would_you_say_ja_is_ja(
        interlocutor,
        gods,
        lambda you, gods: isinstance(gods[GodNames.B], RandomGod)
    )
    return interlocutor, is_interlocutor_true, is_b_random

Once again, the movie goes into a black and white flash-forward as the protagonist plans her move.

def hypothetical():
    for experiment in range(1, 5):
        print("Experiment", experiment)
        gods = make_gods()
        interlocutor, is_interlocutor_true, is_b_random = ask_questions(gods)
        print(f"I think {interlocutor} is {is_interlocutor_true}God. They're {type(gods[interlocutor]).__name__}.")
        print(f"B is {'' if is_b_random else 'not '}RandomGod. They're {type(gods[GodNames.B]).__name__}.")
hypothetical()
del hypothetical
Experiment 1
I think GodNames.C is FalseGod. They're FalseGod.
B is not RandomGod. They're TrueGod.
Experiment 2
I think GodNames.A is FalseGod. They're FalseGod.
B is not RandomGod. They're TrueGod.
Experiment 3
I think GodNames.A is TrueGod. They're TrueGod.
B is RandomGod. They're RandomGod.
Experiment 4
I think GodNames.A is FalseGod. They're FalseGod.
B is not RandomGod. They're TrueGod.

Now that we know the answers, it is time to put them all together. We first record whether the interlocutor is True or False. If B is Random, we mark them as such. If not, we know that neither the interlocutor or B is Random, so the other God must be Random.

Now that we know two Gods’ identities, the last name that remains belongs to the last possible identity.

def analyze_answers(interlocutor: GodNames, is_interlocutor_true: bool, is_b_random: bool):
    solution = {}
    solution[interlocutor] = (GodIdentities.TrueGod if is_interlocutor_true
                              else GodIdentities.FalseGod)
    [other_god] = set(GodNames) - set([interlocutor, GodNames.B])
    random_god = GodNames.B if is_b_random else other_god
    solution[random_god] = GodIdentities.RandomGod
    [last_god] = set(GodNames) - set(solution.keys())
    [last_god_value] = set(GodIdentities) - set(solution.values())
    solution[last_god] = last_god_value
    return solution

Putting the questioning and the analysis together is straightforward.

def find_solution(gods: Mapping[GodNames, IGod]) -> Mapping[GodNames, GodIdentities]:
    interlocutor, is_interlocutor_true, is_b_random = ask_questions(gods)
    return analyze_answers(interlocutor, is_interlocutor_true, is_b_random)

Our little checker returns both a description of the situation, as well as whether the solution was correct.

def check_solution() -> Tuple[Mapping[str, str], Mapping[str, str], bool]:
    gods = make_gods()
    solution = find_solution(gods)
    reality = sorted([(name.value, type(god).__name__) for name, god in gods.items()])
    deduced = sorted([(name.value, identity.value) for name, identity in solution.items()])
    return reality, deduced, reality == deduced

The last step is to test our solution multiple times. Again, remember that there are 6 * 2 = 12 possible situations. However, if “B” is Random, we can get two different answers. This means the total number of options for a path is more than 12, but less than 24.

If we run the solution for a 1000 times, the probability that a given path will not be taken is less than (23/24)**1000 * 24. How much is it?

(23/24)**1000 * 24
7.885069901070409e-18

Good enough!

Now we can see if the script works. We lack Hollywood’s professional script doctors, but we do have a powerful Python interpreter.

for i in range(1000):
    reality, deduced, correct = check_solution()
    if not correct:
        raise ValueError("Solution is incorrect", reality, deduced)
    if i % 200 == 0:
        print("Solved correctly for", reality)
Solved correctly for [('A', 'RandomGod'), ('B', 'TrueGod'), ('C', 'FalseGod')]
Solved correctly for [('A', 'RandomGod'), ('B', 'FalseGod'), ('C', 'TrueGod')]
Solved correctly for [('A', 'RandomGod'), ('B', 'TrueGod'), ('C', 'FalseGod')]
Solved correctly for [('A', 'TrueGod'), ('B', 'FalseGod'), ('C', 'RandomGod')]
Solved correctly for [('A', 'TrueGod'), ('B', 'RandomGod'), ('C', 'FalseGod')]

We printed out every 200th situation, to have some nice output!

Python confirms it. Hollywood should buy our script.

Thanks to Glyph Lefkowitz for his feedback on the Labyrinth post, some of which inspired changes in this post. Thanks to Mark Williams for his feedback on an early draft. Any mistakes or issues that remain are my responsibility.