Sponsored by AppSignal
Would you like to see your link here? Contact us
Notesclub

Livebookisms

livebooks/02_livebookisms.livemd

Livebookisms

Mix.install([
  {:kino, "~> 0.12.2"}
])

# IGNORE THE FOLLOWING UNTIL THE TEXT LEADS YOU HERE

# Elixir has a default function that it invokes to handle inspecting data.
# Let's first grab a copy of the function that is normally used:
# f = Inspect.Opts.default_inspect_fun()

# We will now replace the inspect function.  The new function will still 
# invoke the old one so we don't have to replicate all of that behavior,
# but we will force an option into every call to disable charlist guessing:
# Inspect.Opts.default_inspect_fun(fn term, opts ->
#   f.(term, %Inspect.Opts{opts | charlists: :as_lists})
# end)

Our Plan

Let’s begin by working through Day 1 of 2023’s Advent of Code. Click that link and take a few moments to read through the problem statement before we continue. Understanding what we’re being asked to do will help you follow along as we work through the solution.

Each day, two problems are released. The first one introduces some challenge and is typically the easier of the two. The second one usually takes the same exercise in a different and often more complicated direction.

The first problem each year is usually the easiest and I think that holds true for 2023. As we explore solutions, let’s use this as an opportunity to learn how best to use Livebook to solve these and other problems. Several of Livebook’s features will come in handy during these exercises and we will even find ourselves thinking about how to avoid some Livebook pitfalls.

Input

Both problems for any given day rely on a personalized input. There’s a link to download it towards the end of the first problem when you’re logged into the site. The second problem always reuses the same input file.

In addition, there will always be at least one example provided in the problem description each day. The examples are much smaller and less complex than the final input so they make a fantastic starting point for testing out solutions. We can work through the problem using the example input, verify that we are getting the right answers for that, swap the example for our specific input file, cross our fingers, and hope it works on that too. That will serve as our overall strategy.

To follow this plan, it’s helpful to have the example in a file of its own. You can highlight the example and paste it into a new text file.

The example files and even my real inputs have been provided alongside these Livebooks. Remember, my inputs won’t work as a solution when you’re logged in becasue they are personalized for each account. Still, it can be handy to see specific issues that I ran into becasue of my inputs, as we’ll see below.

That brings us to the question of how we should load input files. Here’s one possibility of the simplest thing that could possibly work:

"../inputs/02_livebookisms/example.txt"
|> Path.expand(__DIR__)
|> File.read!()

The code above expands a path relative to the location of this Livebook and then reads the contents from that path. If you Evaluate that code block, you’ll see the first example input as one string with embedded newlines.

We could stuff that in a variable and make it work, but it would be a little tedious to constantly be editting paths to try out later examples or different inputs. Let’s lean on Livebook a bit to see if we can do better.

Building Livebook UI’s

One of the great things about Livebook is that we can install dependencies just as we could in a mix project. That gives us access to the full Elixir ecosystem. Some of those goodies are even tailor made for using with Livebook. For example, Kino.

Kino is a collection of widgets for Livebook that can help you build interactive content. It can be used to create buttons and forms, react to those inputs, build several forms of output, render animations, and more. Luckily for our needs, it includes tools for handling file uploads.

I’ve already included Kino in this Livebook, but if you ever need to add dependencies yourself there’s an easy user interface (UI) for it. Just scroll to the top of any Livebook and click on the first code section right under the title. This section is often hidden under the label Notebook dependencies and setup.

Once it’s exposed, there’s an icon for adding packages in the upper right.

After that dialog appears, just search for what you want and click the Add button. Livebook will insert the proper code to be sure the package is fetched and loaded when the Livebook is first run.

Kino provides functions for rendering the various inputs it can build and other functions for reading the current values from those inputs. Using these two tools makes it possible to create a moderately complex UI in one or more Livebook code blocks and then make use of the data provided by that UI in later code blocks. There are multiple Kino tutorials built into Livebook. To view them from here, first click the Livebook icon in the upper left corner to return to the Home page.

Once there, choose Learn from the menu on the left.

Then scroll down until you see the Open button to the right of “Exploring Built-In Kinos.” This is an excellent starting point to practice with Kino inputs.

If your needs are simple though, the Kino.Shorts module provides shortcut functions that render and then immediately read a specific input. Here’s what that looks like for uploading files:

input =
  case Kino.Shorts.read_file("Select Your Input") do
    %{file_ref: ref} ->
      ref
      |> Kino.Input.file_path()
      |> File.stream!()
      |> Stream.map(&String.trim/1)

    nil ->
      nil
  end

Most of the magic in the code block above is provided by Kino in the single function call on line 2. That call renders the file input you see below the code block and then immediately reads what that file input is set to. That’s why we see a nil when we first evaluate it. We haven’t had a chance to select a file, so the result is nil.

Try this: click the file input, navigate to ../inputs/02_livebookisms/example.txt, click Open, and reevaluate the code block. The earlier nil should be replaced with a data structure created by the first clause of the case statement.

That data structure is an Elixir Stream. A Stream is another kind of Enumerable, so you’ll see several functions similar to what you would find in Enum. However, if Enum is eager (they both start with E’s), a Stream is lazy (like a lazy river). It will delay processing your request until absolutely needed.

That means that we have asked to receive each line of the file entered into Kino’s input and we’ve asked that the whitespace be trimmed off of each of those lines, but nothing has actually happened yet. Instead, the data structure you see here describes what will happen when needed.

This laziness can have several advantages. For example, if we were processing a huge file, we wouldn’t read all of the lines at once and then go back over the huge list of lines to pull the whitespace off of them. Lines will be read as needed, trimmed, and then passed on to whatever code makes use of them. Here’s one way we could force it to evaluate:

Enum.to_list(input)

With our input prepared in a variable that we can read from as needed, we’re ready to start solving this problem.

Test Data

An essential step in solving any problem is to come up with some good test data for checking the various aspects your code will need to be concerned with. The output above gives us some starting points. Notice how the first line has digits at the beginning and end while the next three have them in the middle. The last line has another fun gotcha: with only one digit, it will be both the first and last.

Good test data covers both the normal expected usage for code and as many edge cases as we can dream up. We can already see some of those edge cases above, but I can think of at least one more. What about two adjacent digits, like abc42def13? The end result of that case should be 43 ignoring both the 2 and 1.

With some ideas about what we should test, let’s turn our attention to extracting the digits from the letters:

Regex.scan(~r{\d}, "a1b2c3")

The code above uses a regular expression to match all of the digits present in the passed string. Regular expressions are a mini-language supported inside most modern programming languages that provide a succinct way of describing data. They are useful for finding or changing parts of data.

The expression above is contained inside of the ~r{…} sigil. In a regular expression, \d will match any one digit 0 to 9. The Regex.scan/2 function is equivelant to the Find All feature in many word processors.

As you can see, each match is returned in its own inner list. In more complicated expressions there could be multiple parts to a single match. That doesn’t apply in our simple case, so we can remove the nesting:

Regex.scan(~r{\d}, "a1b2c3")
|> List.flatten()

That looks like what we’re after. Let’s wrap it in a function so we can reuse it:

extract_digits = fn text ->
  Regex.scan(~r{\d}, text)
  |> List.flatten()
end

Let’s see how this works on our test data. We’ll start with digits on the ends and in the middle:

extract_digits.("1ends9")
extract_digits.("m1dd7e")

How about just one digit?

extract_digits.("just1digit")

That output seems correct, but it does push the problem of using it as both the first and last down the road a bit. We’ll keep that in mind and carry on for now.

Let’s check adjacent digits:

extract_digits.("abc42def13")

Looks good. Let’s see how it performs on the example input:

Enum.map(input, extract_digits)

That seems to cover grabbing digits. Onward to value calculation!

What’s in a Calibration Value?

Since we’re successfully extracting the digits, it’s time to calculate the calibration values described in the problem. Remember that we need to handle the issue of there possibly being just one digit. I can think of a couple of ways we might try that. First, we could use indexing:

solo = ["7"]
Enum.at(solo, 0)

When we go to grab the last item, we can use Elixir’s support for reading negative indexes backwards from the end to find it:

Enum.at(solo, -1)

Another option is to forgo indexes in favor of two helper functions from the List module:

{List.first(solo), List.last(solo)}

Let’s choose one of those, convert to integers, and wrap that up in a function:

calculate = fn digits ->
  String.to_integer(List.first(digits) <> List.last(digits))
end

input
|> Enum.at(0)
|> extract_digits.()
|> calculate.()

If we tie all of that together in one more function, we can trivially test the entire input:

extract_and_calculate = fn text ->
  text
  |> extract_digits.()
  |> calculate.()
end

Enum.map(input, extract_and_calculate)

That appears to be doing the right thing. However, seeing that list of numbers reminds me that we narrowly dodged a bullet there. Had the numbers been slightly different we would have seen some odd output. Have a look:

input
|> Enum.map(extract_and_calculate)
|> List.delete(15)

What happened there? We removed one number from the list and now Elixir is speaking gibberish?

Languages in Languages

It has to do with which numbers are in the list. It just so happens that 15 is different from the other numbers in that list in one key aspect: it is not the ASCII value of a printable character. Observe:

List.ascii_printable?([12, 38, 15, 77])
List.ascii_printable?([12, 38, 77])

We now know what triggers the change, but it still probably doesn’t make a lot of sense. Why is Elixir doing this?

You probably know that Elixir is built on top of another language called Erlang. In Elixir code, we can call into Erlang at any point. There are some key differences between the two languages that can matter when we make calls across the boundary. The relevant one to this discussion is that Erlang and Elixir represent strings using different data structures.

Under the hood, everything in a computer is ultimately represented by numbers. Even the letters in this sentence. Given that, to represent strings in a programming language there must be some data structure filled with numbers. In Erlang, this is primarily handled by filling ordinary lists with numbers. Erlangers call such lists “charlists.” One way to create a charlist in Elixir is using the sigil syntax: ~c"...". Elixir will use that representation to show charlists by default:

String.to_charlist("Even the letters in this sentence.")

This creates a challenge for showing data the correct way. If that charlist is just a list of numbers under the hood, how did Elixir know to show the letters and not the numbers? In short, it makes an educated guess. If all of the characters in the list are in the range of printable ASCII character codes, it assumes you are dealing with character data and shows it as such. That system is great for when you call into an Erlang function that is using charlists to represent strings:

:erlang.pid_to_list(self())

Don’t worry too much about what the function calls above are doing. The key is that Erlang was trying to show us character data and Elixir correctly sussed this out.

It’s important to remember that this is only a visual change in how Elixir shows us the output. The underlying data structure is unchanged. It’s always just a list of numbers in all of these examples. The only thing that changes is how we see it.

Of course, this guessing strategy doesn’t always pay off. For example, imagine that you enjoy working strange programming problems off of the internet created by nerds who prefer to represent most data as arbitrary lists of numbers. This might make Elixir’s guessing less helpful.

The good news is that Elixir gives us control over this behavior. When we see the results of an expression in Livebook or iex, Elixir is just calling IO.inspect/1 for us:

IO.inspect([12, 38, 77])

We now see the output twice, because I printed it myself and Livebook always shows the result of the last line of code. However, we do see what makes the conversion. Let’s try passing an option to control how the conversion happens:

IO.inspect([12, 38, 77], charlists: :as_lists)

Ta da! Notice in the first conversion, the one I asked for, we see the list of numbers. Then Livebook took over as normal and reverted to Elixir’s magical guessing.

We are one small trick away from solving this problem for good. If we were so inclined to build entire Livebooks around these strange numerical programming problems that you enjoy so much, it might make sense to just change the default behavior of how charlists are handled. Remember that semi-hidden block of code at the beginning of the Livebook under Notebook dependencies and setup? That’s an ideal place to stash some code to handle, well, setup.

I’ve placed some code in there to globally disable Elixir’s charlist guessing, but it’s currently commented out. Scroll up there, find the code, turn it on, then click Reconnect and setup. After you do, you can come back down here and rerun all the code blocks you want to banish those ~c"..." demons.

A Wild Solution Appears

We finally have all of the pieces we need to solve this first problem. We need to run through the input, extract digits and convert them to calibration values, and sum up that list of numbers. Let’s get it done:

input
|> Stream.map(extract_and_calculate)
|> Enum.sum()

If you run the code above, you should see the output 142, which is the correct answer for the test data. Now you can scroll back to our file input and switch it over to use my input file: jeg2s_input.txt. Then return here and rerun the code. You should see 55017 which is the answer that the Advent of Code site accepted for me.

Feel free to go get your own personalized input from the Advent of Code site and upload it above, replacing my input with yours. Did you get a star?

Part Two

When I work the Advent of Code problems each year, I give myself little challenges to help me get the most out of my efforts. In 2023, my challenge to myself was to try to reuse as much as I could from the part one solution in part two. For example, it would be great if I didn’t need to reparse the input to solve the second challenge.

No plan survives first contact with the enemy.

This first problem in 2023 ruined my streak before it ever got started. Part two introduced a new example input to better cater to the expanded problem. One of the lines of that new input file contains zero digits. Because of that, when I changed the input to the new example file, a bunch of my existing code broke. It couldn’t find digits.

We’ll solve that problem in a crude but effective way… we’ll read the new input separately:

tricky_example =
  case Kino.Shorts.read_file("Select the Second Example") do
    %{file_ref: ref} ->
      ref
      |> Kino.Input.file_path()
      |> File.stream!()
      |> Stream.map(&amp;String.trim/1)

    nil ->
      nil
  end

Evaluate the code block above, then click on the file input and set it to ../inputs/02_livebookisms/example_2.txt. We’ll use the new tricky_example stream below.

Let’s see where we get just by extracting digits on our new example, since it was the value calculation code that didn’t work:

Enum.map(tricky_example, extract_digits)

We can see the problematic line of this new input. The second line doesn’t include any literal digits because they are spelled out. It’s time to upgrade our parser, but that will introduce another Livebook quirk.

Resistance

Working with Livebook is smooth in so many ways, but I have found that there are a couple of things that Livebook really resists. The first is long code blocks. Once a code block is longer than your screen, you end up in a scrollbars inside of scrollbars situation that makes for uncomfortable editing. I find this bites me most when I am entering modules. Getting around that leads to another challenge.

Livebook does not allow you to change modules in later code blocks. This is a purposeful decision from the design team. There is a huge emphasis on making Livebook code as repeatable as possible. Ideally, when you run this Livebook on your machine, you would have the same experience I had when running it on mine. Modules that can change have the potential to hinder this by introducing bugs into already evaluated blocks of code.

These features of Livebook do a lot of good for us and generally aren’t tough to work around. You may have already noticed that I’m leaning more on anonymous functions than modules. They help keep the code blocks smaller, allow me to build up solutions over multiple code blocks, and even support redefining functions as needed.

Only one problem remains: recursion. If we don’t use modules, simple recursion is off the table. I tried faking it in early versions of this Livebook with anonymous functions that accept themself as an arguement. While it worked, it upped the complexity of the examples and made it harder to keep the focus on the problem we’re actually discussing. Eventually, I found a better way.

It’s time to change how our digit extraction code works. The first version used a trivial regular expression to pluck all of the digits out of a sea of characters. That worked fine, but it would be ugly to expand it to cover the old digits and the new words.

We need to start working through each piece of text dealing with every digit or word as we encounter it. This is a much more flexible approach. We’ll start this change by rewriting digit extraction into a simple function that moves found digits into an accummulator or tosses non-digit characters one at a time:

extract_digit = fn
  <>, digits
  when first in ~w[1 2 3 4 5 6 7 8 9] ->
    {rest, [first | digits]}

  <<_first::binary-size(1), rest::binary>>, digits ->
    {rest, digits}
end

extract_digit.("12c", [])

The function above uses binary pattern matching to decide which clause to execute. The pattern on line 2 separates the first character of the text from the rest. When that first character is found to be a digit (line 3), the code on line 4 is executed to add it to the list of digits. Line 6 is almost identical except that it ignores the first character since we now know that it is not a digit.

Notice how the example above and those that follow show how each call of this function extracts the first character. If that character is a digit, it’s added to the collection. Otherwise, it is simply discarded.

extract_digit.("2c", ["1"])
extract_digit.("c", ["2", "1"])

Now that we have a function that processes each character, it’s a small step to repeatedly apply it to the shrinking text. One way to do that, without recursion, is to make use of another interesting property of Stream iteration.

Streams can be potentially infinite. That may sound odd, but it’s quite handy. If we use Enum to walk over a list of ten items, the function we pass will get called ten times or less in most cases. But what if we don’t know how many calls we need? Right now we are working character by character so we only need as many as there are characters in the text. However, we’re making this change so we can introduce code that handles words, too. When we do that, we’ll process several characters at once.

Stream.iterate/2 to the rescue. It accepts a starting argument and a function to pass that argument into. Whatever the function returns will become the argument for the next call. This process will continue as long as code requests new values from this lazy Stream. This is what that looks like in action:

extract_all = fn text, extractor ->
  {text, []}
  |> Stream.iterate(fn {text, digits} -> extractor.(text, digits) end)
  |> Enum.find(fn {text, _digits} -> text == "" end)
  |> elem(1)
  |> Enum.reverse()
end

extract_all.("a1b2c3", extract_digit)

You can see the call to Stream.iterate/2 on lines 2 and 3. We use a tuple to carry forward the two things we need to track with each call: the remaining text and any digits extracted.

Line 4 is what decides when the Stream should stop iterating. We simply find the first result where the remaining text is empty. That will mean that we’ve found all of the digits we are going to find. We can then extract them from the tuple and put them back in proper order.

The second argument to extract_all, extractor, will be another function. It’s what will be used to find the items we want to pull off the front of the text with each iteration. You can see it used on line 3. We could have just hardcoded it, but since we’ve already had to define both an extract_digit and extract_word, this seems like a chunk of code that could benefit from being able to inject new matchers.

It was vital to restructure how digit extraction works so we can add on the ability to recognize spelled out digits and actually solve the second part of our challenge.

Reading Words

Our new extractor processes one character at a time. We are not limited to looking at just the first character though. We could instead look at the first three characters, for example, to see if they are "one" or "two". Let’s put that together with our existing extractor to see where it gets us:

extract_word = fn
  "one" <> rest, digits -> {rest, ["1" | digits]}
  "two" <> rest, digits -> {rest, ["2" | digits]}
  "three" <> rest, digits -> {rest, ["3" | digits]}
  "four" <> rest, digits -> {rest, ["4" | digits]}
  "five" <> rest, digits -> {rest, ["5" | digits]}
  "six" <> rest, digits -> {rest, ["6" | digits]}
  "seven" <> rest, digits -> {rest, ["7" | digits]}
  "eight" <> rest, digits -> {rest, ["8" | digits]}
  "nine" <> rest, digits -> {rest, ["9" | digits]}
  text, digits -> extract_digit.(text, digits)
end

Enum.map(tricky_example, fn text -> extract_all.(text, extract_word) end)

Hooray! We are now finding digits in all of the new lines of input.

Most of that function shouldn’t hold many surprises. Each new clause checks for another digit word and adds the non-word form into the list when found. We find those words using a second form of binary pattern matching. Combining a string literal with a variable name after the concatenation operator (<>) will match the literal string and put the rest of the string into the variable.

The trickiest line is number 11. If we haven’t found a word, we make a handoff to extract_digit to hunt for a digit. Since Stream.iterate/2 is going to repeat this process over and over, we will pull off each word or digit as it is encountered:

extract_all.("one2threexfour", extract_word)

extract_word is called first and it decodes the "one". When called a second time, it doesn’t find another word and hands off to extract_digit which pulls out the "2". Stream then invokes extract_word again which finds the "three". The next call to extract_word passes to extract_digit to toss out the "x" which allows one more call of extract_word to see the final "four".

sequenceDiagram
Stream.iterate/2-->>extract_word/2: 
extract_word/2->>Stream.iterate/2: "one" 
Stream.iterate/2-->>extract_word/2: 
extract_word/2-->>extract_digit/2:  
extract_digit/2->>Stream.iterate/2: "2" 
Stream.iterate/2-->>extract_word/2: 
extract_word/2->>Stream.iterate/2: "three"
Stream.iterate/2-->>extract_word/2: 
extract_word/2-->>extract_digit/2:  
extract_digit/2->>Stream.iterate/2: "x"
Stream.iterate/2-->>extract_word/2: 
extract_word/2->>Stream.iterate/2: "four"

The real question on everyone’s mind though is, have we solved the problem?

Get Used to Disappointment

Let’s wrap the extraction process in one more function that tacks on a call to calculate/1 and sum up the calibration values we get for each line to make it to easy to check our answers:

solve = fn input, extractor ->
  input
  |> Stream.map(fn text ->
    text
    |> extract_all.(extractor)
    |> calculate.()
  end)
  |> Enum.sum()
end

I’ve got good news and terrible news. Let’s start with the good:

solve.(tricky_example, extract_word)

That is the correct answer for the example data, but…

solve.(input, extract_word)

When I entered that number into the Advent of Code website, it told me that it was too high. My hopes and dreams have been dashed on the rocks below and now the real fun begins.

“Do The Annoying Thing”

The best part about having a ton of programming experience to draw on is that I often have some technique to fall back on when things stop working and the going gets tough. I’ve picked up a lot of problem solving strategies over the years: read the problem well and often, divide and conquer, guess and check, look for patterns, make a table, work backwards, and so on. Sadly, in this current situation, I have to fall back to my last resort which Julia Evans taught me to refer to as “Do the annoying thing.”

Advent of Code problems generally end up turning a bunch of scenarios into a stream of numbers that get combined into a final score that the site can check. This makes perfect sense. It’s an easy way to create universal problems shared over the internet that we can all solve with our preferred languages and tools. However, when you run into edge cases like the examples are passing but your input isn’t, you don’t have much to go on to find where the problem actually lies. This can be frustrating. (I want to be crystal clear: I love the Advent of Code and I think it does many things very well.)

So with nothing to go on, we’re just going to need to come up with an idea about things that could have possibly gone wrong. When I did re-read the problem, I found a tiny clue:

> …their calibration document (your puzzle input) has been amended by a very young Elf who was apparently just excited to show off her art skills.

Maybe that’s a hint that something is goofy with the input data? Let’s take a closer peek at the example data first, even though it’s passing, because I would rather read it than my giant input file:

tricky_example
|> Enum.join("\n")
|> IO.puts()

If you read those lines super closely, you may begin to notice some oddities. Some digit words are run together. For example, check out line 2. It begins with eightwo. Our current solution would have parsed out the eight and ignored the wo, but was it suppossed to be an eight and a two? Is that what the artistic elf hinted at? If we did parse it as an 8 and a 2, that doesn’t change the result of that line in the example because the added 2 would have been a middle digit and thus ignored. Could we find a line in the input where it would have changed the answer?

Enum.filter(input, fn line -> String.match?(line, ~r{eightwo\D*\z}) end)
extract_all.("47qxgjthreeeightwohp", extract_word)

Bingo! The code above searches through the input for any lines containing the eightwo combo. However, we only match those lines if there are no digits after that combination. That means the two, that our existing code ignored, would have replaced the eight as the final digit and that would have changed the answer.

We do this fancy match using another regular expression. The eightwo matches those characetrs exactly. You may remember \d from the beginning of this discussion which matched a single digit. \D is similar but matches anything that is not a digit. The * right after it repeats it zero or more times. That means, together, they match any number of non-digit characters. They do that up to the \z which means the end of the input.

Finding these lines seems to be enough evidence to support trying to decode these combined word digits to see if it fixes the problem.

That leaves me wondering which digits can be combined? Let’s make a table!

As you see above, the words only ever overlap by their final letter. We don’t even have to worry about some words, like six. Given that there’s not that many cases one solution is to handle them the same way that we handled the initial words:

extract_combined = fn
  "oneight" <> rest, digits -> {rest, ["8", "1" | digits]}
  "twone" <> rest, digits -> {rest, ["1", "2" | digits]}
  "threeight" <> rest, digits -> {rest, ["8", "3" | digits]}
  "fiveight" <> rest, digits -> {rest, ["8", "5" | digits]}
  "sevenine" <> rest, digits -> {rest, ["9", "7" | digits]}
  "eightwo" <> rest, digits -> {rest, ["2", "8" | digits]}
  "eighthree" <> rest, digits -> {rest, ["3", "8" | digits]}
  "nineight" <> rest, digits -> {rest, ["8", "9" | digits]}
  value, digits -> extract_word.(value, digits)
end

extract_all.("47qxgjthreeeightwohp", extract_combined)

This is nearly identical to how we handled parsing the individual words earlier. The one tricky change is that we add the two digits to our accumulator in reverse order to match how the other two functions work. That means that when we match oneight on line 2, we add them to digits as "8" then "1".

The other change is that we delegate to extract_word at the end. It will check for single words and further delegate to extract_digit as needed.

Is this enough to fix our solution?

solve.(input, extract_combined)

The Advent of Code site did accept that result as the correct answer. We could call it there. But I’m a completionist that has to solve all of the side quests in a video game.

Extra Credit

Apparently, my input only ever combines two words at once or at least it only does that at the end, where it would matter. Devious puzzle creators could choose to go further. Technically, it’s possible to combine any number of words. For example, oneightwoneightwone… can keep repeating. We don’t currently handle that case. Could we?

Remember when we enumerated all of the possible combinations? We noticed that it’s only possible for them to overlap by one letter. We should be able to use that to handle any combination of words. When we’re pulling a word out, we can choose to leave behind the last letter. If that letter creates a new digit word, it will be caught by the next iteration of the code. If it turns out to be a junk letter instead, it will be removed by the same process that has always removed uneeded letters.

This approach removes the need to handle specific combinations, so let’s update our extract_word. Yes, we can do that with anonymous functions. We just need to reassign the variable:

extract_word = fn
  "one" <> rest, digits -> {"e" <> rest, ["1" | digits]}
  "two" <> rest, digits -> {"o" <> rest, ["2" | digits]}
  "three" <> rest, digits -> {"e" <> rest, ["3" | digits]}
  "four" <> rest, digits -> {rest, ["4" | digits]}
  "five" <> rest, digits -> {"e" <> rest, ["5" | digits]}
  "six" <> rest, digits -> {rest, ["6" | digits]}
  "seven" <> rest, digits -> {"n" <> rest, ["7" | digits]}
  "eight" <> rest, digits -> {"t" <> rest, ["8" | digits]}
  "nine" <> rest, digits -> {"e" <> rest, ["9" | digits]}
  value, digits -> extract_digit.(value, digits)
end

extract_all.("47qxgjthreeeightwohp", extract_word)

That’s a very small change from our initial extract_word. The only difference is that we manually add the final letter of the matched word onto the front of rest in the needed clauses.

Let’s make sure that still gets the right answer on my input:

solve.(input, extract_word)

Job’s done!

Looking Back

Despite this being the easiest problem in 2023, or perhaps because of it, we covered a lot of topics beyond just how to solve it:

  • Adding external packages to Livebook
  • Using Kino to handle file uploads
  • Delaying iteration with Stream
  • Building good test data
  • Controlling how Elixir handles charlist guessing
  • Better strategies for working with Livebook code blocks
  • Infinite iteration with Stream
  • Problem solving at its best and worst
  • Multiple approaches for handling special cases

Livebook is an incredible tool for sharing code with other programmers. It’s a little like a reproducable iex session with added commentary. This is great for educational materials, but also potentially useful for sharing things like automation scripts used by multiple workers in a company. You could even use it to explore proof of concept ideas with non-tech folks and management. I hope this Livebook gave you some new ideas about how you could make use of it for work or play.