Advent of Code 2015: Some Assembly Required

April 13, 2020

The seventh Advent of Code challenge has a few very different solutions; I chose one that would let me experiment with Ruby lambda features. The solution presented here isn’t too long, but much shorter answers are possible if you’re willing to use the eval method to create assignment statements. The presentation format is going to be a little different than the last posts — we’re going to move through the solution in a top-down fashion instead of from input-to-output.

Part A: What’s on the Wire

This is a wordy problem, so I have eliminated a couple paragraphs to save space; if you want the full description go look at the Advent of Code website. Like always, I suggest you try to work out the requirements before reading below, but this problem has quite a few which are interwoven.

Each wire has an identifier (some lowercase letters) and can carry a 16-bit signal (a number from 0 to 65535). A signal is provided to each wire by a gate, another wire, or some specific value. Each wire can only get a signal from one source, but can provide its signal to multiple destinations. A gate provides no signal until all of its inputs have a signal.

The included instructions booklet describes how to connect the parts together: x AND y -> z means to connect wires x and y to an AND gate, and then connect its output to wire z.

…what signal is ultimately provided to wire a?

Advent of Code, 2015, Day 7

Here are the requirements I see:

By following those requirements and creating an interface to ask for a specific wire’s value, we should be able to solve the problem. What I would like to do, at the top-level of the solution, is pass the problem input and the wire name into a function and get back the wire value.

TEST_INPUT = <<~DATA.freeze
  123 -> x
  456 -> y
  x AND y -> d
  x OR y -> e
  x LSHIFT 2 -> f
  y RSHIFT 2 -> g
  NOT x -> h
  NOT y -> i
DATA

def test_solve
  assert solve(TEST_INPUT, :g), 114
  assert solve(TEST_INPUT, :i), 65_079
end

The test input is pulled directly from the Advent of Code challenge page, and the test describes precisely the no-nonsense interface above. I decided to use symbols (:g) as wire names, though strings would have been completely acceptable, as it just felt a bit cleaner to me. While the solve method feels like a good place to deal with breaking apart the input, it doesn’t feel like the spot for any of the signal value calculation logic.

def solve(input, wire)
  c = Circuit.new
  input.split("\n").each { |l| c << parse_line(l) }
  c.read(wire)
end

I’ve decided to delegate the real work of parsing each input line to a parse_line method and the work of calculating the wire values to a Circuit class. This split seemed reasonable to me, but my idea is that the Circuit class will store the wire values and handle delegation of the logic gate calculations to a Gate class.

Logic Gates

To create these logic gates each line of input will need to be analysed to determine what kind of gate it should be — recall from above that a wire can receive a signal from a literal value, another wire, or a logic gate. I would like to model all of these as a single Gate class that can be parametrized by a lambda.

def parse_line(line)
  wire, parts = components(line)
  [wire.to_sym, build_gate(parts)]
end

Parsing each individual line turns out to be rather easy to write because it delegates to two other methods; remember that I also decided to represent wires as symbols, not strings. Of the two methods components and build_gate only the latter requires tests as it will be the factory method that needs to correctly parametrize the Gate instances.

def components(line)
  gate, wire = line.split(' -> ')
  [wire, gate.split]
end

As you can see both parse_line and components are simple and, at least in my opinion, don’t need tests for this solution. On the other hand, the build_gate method I have chosen to test because it will require a reference to the currently non-existent Circuit class. This kind of relationship should have some tests around it to ensure the communication between classes works correctly.

def test_build_gate
  g = build_gate(['123'])
  assert g.inputs, [123]

  g = build_gate(%w[x AND y])
  assert g.inputs, %i[x y]

  g = build_gate(%w[x OR 5])
  c = Circuit.new
  c << [:x, build_gate(['10'])]
  c.read(:x)
  assert g.signal(c), 15
end

The tests I’ve written exercise the Gate class to retrieve the signal value and ensure the input record is correct. It doesn’t fully exercise the class for all possible inputs, but it’s good enough to give me confidence in the code I will be writing.

DIGIT = /^[0-9]/.freeze

OPERATIONS = {
  'AND' => :&,
  'OR' => :|,
  'LSHIFT' => :<<,
  'RSHIFT' => :>>
}.freeze

def val(v)
  DIGIT.match?(v) ? v.to_i : v.to_sym
end

def build_gate(parts)
  case parts.length
  when 1
    Gate.new(->(x) { x }, val(parts[0]))
  when 2
    Gate.new(->(x) { ~x }, val(parts[1]))
  when 3
    op = OPERATIONS[parts[1]]
    Gate.new(->(l, r) { l.send(op, r) }, val(parts[0]), val(parts[2]))
  end
end

Here I finally get to look at using lambda functions to parametrize Gate operations by encoding the particular logic operation. There is a small helper method val which is used to determine the resultant data type of each gate input which can either be an integer literal or another wire. Since there are no requirements for wires supporting nested signal expressions using parenthesis, I’ve opted to look at the number of components on the left-hand side of the \-> to determine what kind of strategy is necessary for the Gate.

When there is only one item, like “Y -> x”, then it represents a simple pass-through of a literal integer or wire value. Two items is only represented by “NOT Y -> x”, making that an easy logic gate to detect. Finally, three items can be any other logic gate, all of which take two parameters, and I send the operation by looking up the correct method via the string representation. Each of these Gate.new statements passes in the pre-requisite inputs for the lambda operation.

The last piece of the input-to-gate translation is to create the Gate class itself.

class Gate
  attr_reader :inputs

  def initialize(op, *inputs)
    @op = op
    @inputs = inputs
  end

  def signal(circuit)
    args = inputs.map { |i| circuit.signal(i) }
    @op.call(*args) & 0xffff
  end
end

Remember, as shown above, that the Gate class requires inputs and signal as the interface, and that we want to pass a lambda as an operation and the inputs. The Gate itself doesn’t keep track of any values, so inside signal it asks the given Circuit for the value of every relied upon input. Importantly, the Gate class also applies the 16-bit restriction by dropping any extraneous bits via & 0xffff. The remaining portion of code to write is the Circuit which records all the signal values.

The Circuit

This class is the most complicated part of the solution because it only calculates necessary wire values for the requested read operation. I’ve decided on a simple interface to Circuit which only has two methods: << as a short-hand for “add-a-wire-and-gate”, read to ask for a wire value, and signal to fetch the value of a particular input for Gate.

def test_circuit
  c = Circuit.new
  c << [:z, build_gate(%w[x AND y])]
  c << [:x, build_gate(['123'])]
  c << [:w, build_gate(%w[NOT z])]
  c << [:y, build_gate(['456'])]

  assert c.read(:x), 123
  assert c.read(:z), 72
  assert c.read(:w), 65_463
end

This test also doubles as a check that Circuit will work with the output of the parse_line method that we wrote above; if the class works with our test data then it should work with parse_line, unless that method has a bug.

class Circuit
  def initialize
    @wires = {}
    @signals = {}
  end

  def <<(signal)
    @wires[signal[0]] = signal[1]
  end

  def signal(s)
    s.is_a?(Symbol) ? @signals[s] : s
  end
...

The first chunk of the Circuit class is fairly easy to understand — initialization of the wire and signal state in initialize, poking around the signals state values for Gate in signal, and storing the associations of wire names to signal representations using <<. The read method is the bulk of the class and implements a depth-first approach to calculating only the necessary wire values. Before looking at read I want to look at two helper methods because they will make understanding the depth-first approach a little easier.

def missing_signals(inputs)
  inputs.filter { |x| x.is_a?(Symbol) && [email protected]?(x) }
end

def calculate_signal(wire, gate)
  @signals[wire] = gate.signal(self)
end

The calculate_signal method stores the result of asking the Gate for it’s signal to re-use it if the wire is referenced multiple times. More important to the algorithm is the missing_signals method — it determines which inputs still need to be calculated so that we only deal with each wire once. With these two methods, we can now look at the read implementation.

def read(wire)
  queue = [wire]
  until queue.empty?
    w = queue[-1]
    unless @signals.key?(w)
      gate = @wires[w]
      missing = missing_signals(gate.inputs)
      unless missing.empty?
        queue.concat(missing)
        next
      end
      calculate_signal(w, gate)
    end
    queue.pop
  end
  @signals[wire].to_i
end

This is, at its heart, a depth-first search — we have a work queue that dictates how long we stay in the method for and we take some actions until it is empty. Each time around we look at the last wire in the work queue and if it’s already in the @signals cache then we can skip all the work and remove the wire from the queue. When we do need to calculate the wire signal, we first retrieve the Gate and find any missing signal dependencies; if there are missing signal dependencies we can’t calculate the gate’s value yet, we need to calculate those dependencies first, so we add them to the back of the queue and jump to the top of the loop. Otherwise, we have all the dependencies for the current wire and we can calculate its signal.

That’s the entire implementation, and we can run the code to figure out what wire :a has as a value.

$ run -y 2015 -q 7 -a
956

Part B: Override the Signal

This implementation worked out well because the second part of this challenge requires the same thing, but with slightly different inputs.

Now, take the signal you got on wire a, override wire b to that signal, and reset the other wires (including wire a). What new signal is ultimately provided to wire a?

Advent of Code, 2015, Day 7

To solve this, I didn’t write any code — I duplicated the input file, manually replaced the value for wire :b with 956, and loaded that file for part_b.

$ run -y 2015 -q 7 -b
40149

Assembly Complete

Top-down design in this solution worked out for me, but I can’t say I particularly enjoyed writing the code this way — I didn’t bother showing the refactoring steps I took to improve the code, but you can see them in this commit. While the lambda support in Ruby is nice, I’m not particularly happy with the cyclical dependency represented by Circuit and Gate. It feels to me like object-orientation led me toward this design, but the solution works and is relatively quick. Sometimes that’s enough.