Day 7: Some Assembly Required
Mix.install([
{:kino, "~> 0.12.3"}
])
Modules
defmodule Circuit do
@moduledoc """
Functions for assembling and inspecting circuits.
"""
@u16_size 65536
@typedoc "A circuit"
@type t :: %{atom() => connection()}
@typedoc "An instruction for building the circuit"
@type instruction :: {atom(), connection()}
@typedoc "The connections possible for each instruction"
@type connection ::
{:AND, connection(), connection()}
| {:OR, connection(), connection()}
| {:LSHIFT, connection(), {:SIGNAL, non_neg_integer()}}
| {:RSHIFT, connection(), {:SIGNAL, non_neg_integer()}}
| {:NOT, connection()}
| {:WIRE, atom()}
| {:SIGNAL, non_neg_integer()}
@doc """
Builds a circuit from a list of instructions.
## Example
```elixir
iex> [{:fy, {:NOT, {:WIRE, :fx}}}, {:fx, {:SIGNAL, 10}}]
iex> |> Circuit.build()
%{
fy: {:NOT, {:WIRE, :fx}},
fx: {:SIGNAL, 10},
}
```
"""
@spec build(list(instruction())) :: t()
def build(instructions) do
instructions
|> Enum.reduce(%{}, fn
instruction, circuit ->
{wire, connection} = instruction
Map.put(circuit, wire, connection)
end)
end
@doc """
Calculates the signal on a given wire.
## Example
```elixir
iex> %{a: {:LSHIFT, {:WIRE, :b}, {:SIGNAL, 1}}, b: {:SIGNAL, 8}}
iex> |> Circuit.get_signal(:a)
16
```
"""
def get_signal(circuit, wire) do
resolve(circuit, circuit[wire], %{})
|> elem(0)
end
@doc """
Overrides a signal for a specific wire identifier.
## Example
```elixir
iex> %{b: {:SIGNAL, 10}}
iex> |> Circuit.put_signal(:b, 50)
%{b: {:SIGNAL, 50}}
```
"""
def put_signal(circuit, wire, value) do
Map.put(circuit, wire, {:SIGNAL, value})
end
# recursively resolves a connection, with the aid of a cache
defp resolve(circuit, connection, cache)
# when value is in the cache
defp resolve(_circuit, {:WIRE, wire}, cache) when is_map_key(cache, wire),
do: {cache[wire], cache}
# when value is not in the cache
defp resolve(circuit, {:WIRE, wire}, cache) do
{result, cache} = resolve(circuit, circuit[wire], cache)
{result, Map.put(cache, wire, result)}
end
defp resolve(_circuit, {:SIGNAL, value}, cache), do: {value, cache}
defp resolve(circuit, {:NOT, a}, cache),
do: unary(circuit, a, &Bitwise.bnot/1, cache)
defp resolve(circuit, {:AND, a, b}, cache),
do: binary(circuit, a, b, &Bitwise.&&&/2, cache)
defp resolve(circuit, {:OR, a, b}, cache),
do: binary(circuit, a, b, &Bitwise.|||/2, cache)
defp resolve(circuit, {:LSHIFT, a, value}, cache),
do: binary(circuit, a, value, &Bitwise.<<>>/2, cache)
# used to resolve a single operand and apply a unary operation
# to the results
# the final result is reduced to fit in 16 bits
defp unary(circuit, right, function, cache) do
with {right, cache} <- resolve(circuit, right, cache) do
{function.(right) |> Integer.mod(@u16_size), cache}
end
end
# used to resolve a pair of operands and apply a binary operation
# to the results
# the final result is reduced to fit in 16 bits
defp binary(circuit, left, right, function, cache) do
with {left, cache} <- resolve(circuit, left, cache),
{right, cache} <- resolve(circuit, right, cache) do
{function.(left, right) |> Integer.mod(@u16_size), cache}
end
end
end
defmodule Parser do
@moduledoc """
For parsing a string into circuit instructions.
"""
@doc ~S"""
Parses a string into a list of instructions.
"""
def parse(str) do
str
|> String.split("\n")
|> Enum.map(&parse_line/1)
end
# parses single line into instruction form
defp parse_line(str) do
[expr | [dest]] = String.split(str, " -> ")
wire = String.to_atom(dest)
op = Regex.run(~r/AND|OR|LSHIFT|RSHIFT|NOT/, expr) |> parse_op()
operands = Regex.scan(~r/\d+|[a-z]+/, expr) |> parse_operands()
{wire, parse_connection(op, operands)}
end
# parses the connection
defp parse_connection(op, operands)
# for :SIGNAL and :WIRE connections
defp parse_connection(nil, [operand]), do: operand
# for unary connections :NOT
defp parse_connection(op, [a]), do: {op, a}
# for binary connections :AND, :OR, :LSHIFT and :RSHIFT
defp parse_connection(op, [a, b]), do: {op, a, b}
# creates atom of operation match results
defp parse_op(op_matches)
defp parse_op(nil), do: nil
defp parse_op([op]), do: String.to_atom(op)
# ensures we have a flat list of integers or identifiers as operands
defp parse_operands(operand_matches) do
operand_matches
|> List.flatten()
|> Enum.map(&parse_operand/1)
end
# parses single operand
defp parse_operand(operand) do
Integer.parse(operand)
|> case do
:error -> {:WIRE, String.to_atom(operand)}
{integer, _} -> {:SIGNAL, integer}
end
end
end
Input
input = Kino.Input.textarea("Please paste your puzzle input:")
Part 1
input
|> Kino.Input.read()
|> Parser.parse()
|> Circuit.build()
|> Circuit.get_signal(:a)
Part 2
circuit =
Kino.Input.read(input)
|> Parser.parse()
|> Circuit.build()
a = Circuit.get_signal(circuit, :a)
circuit
|> Circuit.put_signal(:b, a)
|> Circuit.get_signal(:a)