My first is in remit but not in resins
My second is in mingles but not in gingkos
My third is in suave and also in state
My fourth is in caned and also in cored
My fifth is in saucy but not in spacey
My sixth is in carps but not in save
Solution: Teacup
In my last post, I showed now I solved puzzles of the form above. In this post, I'll show how I can generate these puzzles. (You should read the previous post for an explanation of how these riddles work.) The next post shows how to make this generation faster.
Code
Below, I describe my approach to this problem and show some Python code that solves it. You can find the full code on my Git server, or over at Gitlab. Note that I developed this with Jupyter and Jupytext, so only Markdown files are in version control. Jupyter + Jupytext will convert these into Notebook files and Python scripts when they're opened.
Random generation
The basic idea for riddle generation is simple: pick a word, then generate clues for each letter so that the combination of clues uniquely identifies the target word. The problem is with how to do that, in a way that only takes a short time and generates riddles that are "sensible."
In a riddle, each clue (line) has a pair of clue words. Human-crafted riddles tend to have clue words that are either very close in spelling or, more commonly, close in meaning. Each clue tends to suggest a few candidates for each letter in the target word. That gives me two problems to address.
- How to balance allowing several candidates for each letter, against ensuring the whole riddle has just one solution
- How to find suitable pairs of clue words, to give some form of coherence to the puzzle.
Ensuring unique solutions
One question that exercised me a lot was how to build puzzles with unique solutions but a range of letters for each clue. One approach was to ensure that each clue yielded just a single letter. That made the overall puzzle easy, but I wasn't satisfied with that approach. First, it limited the types of clues I could give (it would be hard to find clues of the form "is neither in X nor Y") as well as removing some of the challenge of the riddle itself.
Without a good solution coming to mind, I decided to pass the buck and get the machine to do the work. Rather than coming up with a clever solution, I decided to just generate clues somehow and check that the combination of clues would give a unique solution. If the random clue generation gave multiple solutions, I'd just try a different collection of clues.
If this worked in a reasonable time, that would be good enough. If it took too long, I'd at least have something to attempt to optimise.
Finding clue pairs
First off, looking for words based on meaning is likely to be difficult. I could use something like a pre-trained Word2Vec model to find connected words, but I don't have one of those to hand and I don't think this post is the sort of place to go through the use of one. That leaves finding words with similar letters.
One approach is to pre-compute sample pairs of clue words that will result in the candidate letters we want. However, this is expensive. If we consider just the letter 's', there are about 32,000 words that contain 's' and about 24,000 that don't. That gives over 108 pairs of words to consider when pre-computing the lists. (As the clues are all about sets of letters, it's tempting to reduce the dictionary to distinct sets of letters. But that only roughly halves the size of the dictionary, leaving the pre-computed lists unfeasibly large.)
I could reduce the problem by reducing the size of the dictionary for clue words. But I didn't want to do that.
Instead, I decided that if I was generating riddles randomly, I might as well generate clues randomly too. I could just pick two clue words which, when combined, would include the desired letter as one of the possible letters.
Exploration: random generation
My first attempt at a clue-generator was for the include-exclude type clues ("My first is in remits but not in resins."). I could just pick a random word that contained 't' and a random word that didn't, and that was the clue.
def include_exclude_clue(letter: str) -> (RiddleClue, RiddleClue):
with_letter = [w for w in dictionary if letter in w]
without_letter = [w for w in dictionary if letter not in w]
a = random.choice(with_letter)
b = random.choice(without_letter)
return (RiddleClue(word=a, valence=RiddleValence.Include),
RiddleClue(word=b, valence=RiddleValence.Exclude))
Refining random generation
That worked, in that it generated clue pairs that led to the desired letter. But it didn't work in that the clue pairs used wildly different words, like "is in encountered but not in preyed" as a clue for 't'.
Using edit distance (or Levenshtein distance) is one way to measuring the distance between words. I would pick two random clue words until their edit distance was less than some limit (I picked a distance of 3). An additional edge case that sometimes occurred was when one word's letters were a subset of the other's; this most commonly occurred when the words were the singular and plural of each other.
That meant a couple of additional tests in the function, which I placed in a loop to keep generating clue pairs until I got something that met the conditions.
def include_exclude_clue(letter: str, limit: int = 3) -> (RiddleClue, RiddleClue):
with_letter = [w for w in dictionary if letter in w]
without_letter = [w for w in dictionary if letter not in w]
finished = False
while not finished:
a = random.choice(with_letter)
b = random.choice(without_letter)
finished = ((edit_distance(a, b) <= limit) and
not set(a) <= set(b) and
not set(a) >= set(b))
return (RiddleClue(word=a, valence=RiddleValence.Include),
RiddleClue(word=b, valence=RiddleValence.Exclude))
Alongside this, I cribbed some code for edit distance from somewhere. It's often cited as an example where dynamic programming is useful, but I decided to memoise it with Python's built-in caching function decorator.
@functools.lru_cache
def edit_distance(s: str, t: str) -> int:
if s == "":
return len(t)
if t == "":
return len(s)
if s[0] == t[0]:
cost = 0
else:
cost = 1
res = min(
[ edit_distance(s[1:], t) + 1
, edit_distance(s, t[1:]) + 1
, edit_distance(s[1:], t[1:]) + cost
])
return res
Different clues
With one clue type working, the others were essentially the same.
def include_include_clue(letter: str, limit: int = 3) -> (RiddleClue, RiddleClue):
with_letter = [w for w in dictionary if letter in w]
finished = False
while not finished:
a = random.choice(with_letter)
b = random.choice(with_letter)
finished = ((a != b) and
(edit_distance(a, b) <= limit) and
not set(a) <= set(b) and
not set(a) >= set(b))
return (RiddleClue(word=a, valence=RiddleValence.Include),
RiddleClue(word=b, valence=RiddleValence.Include))
def exclude_exclude_clue(letter: str, limit: int = 3) -> (RiddleClue, RiddleClue):
without_letter = [w for w in dictionary if letter not in w]
finished = False
while not finished:
a = random.choice(without_letter)
b = random.choice(without_letter)
finished = ((a != b) and
(edit_distance(a, b) <= limit) and
not set(a) <= set(b) and
not set(a) >= set(b))
return (RiddleClue(word=a, valence=RiddleValence.Exclude),
RiddleClue(word=b, valence=RiddleValence.Exclude))
Generating riddles
I can put these together in a function that generates a random clue (using random.choices to make a weighted random selection between the type) and from there make a random riddle (remembering to adjust for the one-based numbering of clues).
def random_clue( letter: str
, ie_limit: int = 3
, ii_limit: int = 2
, ee_limit: int = 2) -> (RiddleClue, RiddleClue):
clue_type = random.choices(['include_exclude', 'include_include', 'exclude_exclude'],
weights=[7, 2, 1],
k=1)[0]
if clue_type == 'include_exclude':
return include_exclude_clue(letter, limit=ie_limit)
elif clue_type =='include_include':
return include_include_clue(letter, limit=ii_limit)
else:
return exclude_exclude_clue(letter, limit=ee_limit)
def random_riddle( word: str
, ie_limit: int = 3
, ii_limit: int = 2
, ee_limit: int = 2
) -> Riddle:
return {i+1 :
random_clue(l,
ie_limit=ie_limit, ii_limit=ii_limit, ee_limit=ee_limit)
for i, l in enumerate(word)}
Unique-solution riddles
The approach above generates riddles that have the desired word as a solution. But the generated riddles can have more than one solution. Consider this riddle:
My first is neither in muses nor in mashes
My second is in gores but not in grist
My third is in angers but not in ledgers
My fourth is in ricing but not in dinning
My fifth is neither in blares nor in bakes
My last is in proofs but not in loafs
This is another riddle for "teacups", but "concur" is also a solution. That's easily solved with another cycle of generate-and-test, where I generate riddles then solve them, to ensure that the generated riddle has only one solution.
def valid_random_riddle(word: str) -> Riddle:
finished = False
while not finished:
riddle = random_riddle(word)
solns = solve_riddle(collapse_riddle_clues(riddle))
finished = (len(solns) == 1)
return riddle
Writing riddles
Now I can generate riddles in a sensible time, the final step is to write them in a human-readable form. This is fairly easy, as each clue type has a rigid structure for the text.
That means that each clue type has its own function for writing lines of that type.
def write_include_exclude_line(clue_a: RiddleClue, clue_b: RiddleClue) -> str:
line = f"is in {clue_a.word} but not in {clue_b.word}"
return line
def write_include_include_line(clue_a: RiddleClue, clue_b: RiddleClue) -> str:
if random.randrange(2) == 0:
line = f"is in {clue_a.word} and also in {clue_b.word}"
else:
line = f"is in both {clue_a.word} and {clue_b.word}"
return line
def write_exclude_exclude_line(clue_a: RiddleClue, clue_b: RiddleClue) -> str:
line = f"is neither in {clue_a.word} nor in {clue_b.word}"
return line
Those get called by a function that selects the correct line function, based on the line type of the riddle.
def write_line(a: RiddleClue, b: RiddleClue) -> str:
if a.valence == RiddleValence.Include and b.valence == RiddleValence.Include:
return write_include_include_line(a, b)
elif a.valence == RiddleValence.Include and b.valence == RiddleValence.Exclude:
return write_include_exclude_line(a, b)
elif a.valence == RiddleValence.Exclude and b.valence == RiddleValence.Exclude:
return write_exclude_exclude_line(a, b)
else:
return "illegal line"
And finally, a function that writes a whole riddle, with a bit of faffing to choose between having the last line be written as either "My seventh..." or "My last...".
def write_riddle(riddle: Riddle) -> List[str]:
output = []
for i, (clue_a, clue_b) in sorted(riddle.items()):
pos = reverse_ordinals[i]
if i == len(riddle) and random.random() <= 0.3:
pos = reverse_ordinals[-1]
line = write_line(clue_a, clue_b)
full_line = f"My {pos} {line}"
output.append(full_line)
return output
Summary
And that's it! I can now generate as many riddles as I want and write them in a form that a person can read.
The problem is that the riddle generation is slow, taking about two seconds to generate a riddle. That's an issue I'll address in the next post.