Day 24 is in certainly in my top three of "least favourite AoC puzzles", if not the top spot. It's the sort of fiddly, detailed, laborious reverse-engineering puzzle that I really don't like. But that's my preference.
Data structures
The representation of the device follows the structure of the puzzle. The wires are a map from name to value. The gates are records containing the gate type, the input wires, and the output wire.
type Wires = M.Map String Int
data GateType = And | Or | Xor
deriving (Show, Eq, Ord)
data Gate = Gate { gType :: GateType, inputs :: [String], output :: String }
deriving (Show, Eq, Ord)
type Device = [Gate]
Reading the input file is a typical parsing exercise, using gateify
to build the Gate
records.
wiresDeviceP = (,) <$> wiresP <* endOfLine <* endOfLine <*> deviceP
wiresP = M.fromList <$> wireP `sepBy` endOfLine
wireP = (,) <$> nameP <* string ": " <*> decimal
nameP = many1 (letter <|> digit)
deviceP = gateP `sepBy` endOfLine
gateP = gateify <$> nameP <* space <*> gateTypeP <* space <*> nameP <* string " -> " <*> nameP
where gateify i1 g i2 o = Gate g (sort [i1, i2]) o
Part 1: simulation
Simulating the device uses the set of wires to drive the process. I canActivate
a gate if I have values for both inputs. Given a gate can be activated, I can simulate a gate by looking up the type of gate, doing the correct operation, and pushing the result into the set of wires
.
canActivateGate :: Wires -> Gate -> Bool
canActivateGate wires gate = all (`M.member` wires) gate.inputs
simulateGate :: Wires -> Gate -> Wires
simulateGate wires gate = M.insert gate.output result wires
where [i1, i2] = gate.inputs
result = case gate.gType of
And -> (wires M.! i1) .&. (wires M.! i2)
Or -> (wires M.! i1) .|. (wires M.! i2)
Xor -> (wires M.! i1) `xor` (wires M.! i2)
I do the simulation by keeping two sets of gates: those I can activate, and those I can't yet. At each step, I simulate all the gates I can, and return the updated wires
and the gates left to simulate. Collecting the gate outputs is done with the fold
. Simulating the whole device is repeated simulation steps, until all gates have values.
simulate :: Wires -> Device -> Wires
simulate wires device = wires'
where (wires', []) = until (null . snd) simulateOnce (wires, device)
simulateOnce :: (Wires, Device) -> (Wires, Device)
simulateOnce (wires, device) = (wires', remaining)
where
(run, remaining) = partition (canActivateGate wires) device
wires' = foldl simulateGate wires run
The final stage is to find the output on the z
wires.
part1 :: Wires -> Device -> Int
part1 wires device = wiresOutput $ simulate wires device
wiresOutput :: Wires -> Int
wiresOutput wires = M.foldlWithKey' go 0 outWires
where outWires = M.filterWithKey (\k _ -> isOutputWire k) wires
go acc w v = acc .|. (v .<<. codeOfName w)
isOutputWire :: String -> Bool
isOutputWire (x:_) = x == 'z'
codeOfName :: String -> Int
codeOfName s
| null ds = 1000
| otherwise = read ds
where ds = filter isDigit s
Part 2
This is where it all goes wrong.
The first step is to get an understanding of what all these gates are doing and how they're structured. The puzzle description says that the network is a directed acyclic graph, so I can tree of the device going from a particular output wire to the inputs. I use the standard Data.Tree
library for this. unfoldFromWire
will take
unfoldFromWire :: Device -> String -> DeviceTree
unfoldFromWire device wire =
unfoldTree unfoldDevice (device, fromJust $ gateForWire device wire)
unfoldDevice :: (Device, Gate) -> (Gate, [(Device, Gate)])
unfoldDevice (device, gate) =
( gate
, fmap (\g -> (device, g)) feedGates
)
where feedGates = sort $ catMaybes $ fmap (gateForWire device) gate.inputs
That allowed me to see what was happening for the first few inputs.
ghci> putStrLn $ drawTree $ fmap show $ unfoldFromWire device "z00"
Gate {gType = Xor, inputs = ["x00","y00"], output = "z00"}
ghci> putStrLn $ drawTree $ fmap show $ unfoldFromWire device "z01"
Gate {gType = Xor, inputs = ["qtf","wqc"], output = "z01"}
|
+- Gate {gType = And, inputs = ["x00","y00"], output = "qtf"}
|
`- Gate {gType = Xor, inputs = ["x01","y01"], output = "wqc"}
ghci> putStrLn $ drawTree $ fmap show $ unfoldFromWire device "z02"
Gate {gType = Xor, inputs = ["cpb","wmr"], output = "z02"}
|
+- Gate {gType = Or, inputs = ["djn","tnr"], output = "cpb"}
| |
| +- Gate {gType = And, inputs = ["qtf","wqc"], output = "djn"}
| | |
| | +- Gate {gType = And, inputs = ["x00","y00"], output = "qtf"}
| | |
| | `- Gate {gType = Xor, inputs = ["x01","y01"], output = "wqc"}
| |
| `- Gate {gType = And, inputs = ["x01","y01"], output = "tnr"}
|
`- Gate {gType = Xor, inputs = ["x02","y02"], output = "wmr"}
Output zero is the xor of input zeros. Output one is the xor of input ones, combined with the carry from input zeros. Output two is the same structure, but using the carry from the previous position.
This makes sense, using the recursive structure of natural numbers to define a recursive adder circuit.
I tested that the first few bits of the adder circuit worked correctly, by testing all combinations of inputs. That all succeeded, and gave me confidence that the structures I was seeing for the first few bits were correct.
setInputs :: Int -> Int -> Wires
setInputs x y = M.union (bitsOf "x" x) (bitsOf "y" y)
ghci> filter not [ (x + y) == part1 (setInputs x y) device | x <- [0..127] , y <- [0..127] ]
Then I had to find the errors. To do that, I had to build the structure I was expecting to see, so I could compare it against the structure I actually saw. makeAdder
makes an adder of the appropriate number of levels.
makeAdder :: Int -> String -> Int -> DeviceTree
makeAdder 0 o _ = Node {rootLabel = Gate {gType = Xor, inputs = ["x00","y00"], output = o}, subForest = []}
makeAdder depth o n = Node {rootLabel = Gate {gType = Xor, inputs = [n0, n1], output = o}, subForest = [gAdd, gCarry]}
where gAdd = Node {rootLabel = Gate {gType = Xor, inputs = ["x" ++ show2d depth,"y" ++ show2d depth], output = n0}, subForest = []}
gCarry = makeCarry (depth-1) n1 (n + 2)
n0 = nonceStr n
n1 = nonceStr (n+1)
makeCarry :: Int -> String -> Int -> DeviceTree
makeCarry 0 o _ = Node {rootLabel = Gate {gType = And, inputs = ["x00","y00"], output = o}, subForest = []}
makeCarry depth o n = Node {rootLabel = Gate {gType = Or, inputs = [n0, n1], output = o}, subForest = [c0, c1]}
where c0 = Node {rootLabel = Gate {gType = And, inputs = [n2, n3], output = n0}, subForest = [c2, c3]}
c1 = Node {rootLabel = Gate {gType = And, inputs = ["x" ++ show2d depth,"y" ++ show2d depth], output = n1}, subForest = []}
c2 = makeCarry (depth-1) n2 (n + 4)
c3 = Node {rootLabel = Gate {gType = Xor, inputs = ["x" ++ show2d depth,"y" ++ show2d depth], output = n3}, subForest = []}
n0 = nonceStr n
n1 = nonceStr (n+1)
n2 = nonceStr (n+2)
n3 = nonceStr (n+3)
nonceStr :: Int -> String
nonceStr n = "n" ++ show n
That gave outputs like this:
ghci> putStrLn $ drawTree $ fmap show $ makeAdder 2 "o2" 0
Gate {gType = Xor, inputs = ["n0","n1"], output = "o2"}
|
+- Gate {gType = Xor, inputs = ["x02","y02"], output = "n0"}
|
`- Gate {gType = Or, inputs = ["n2","n3"], output = "n1"}
|
+- Gate {gType = And, inputs = ["n4","n5"], output = "n2"}
| |
| +- Gate {gType = And, inputs = ["x00","y00"], output = "n4"}
| |
| `- Gate {gType = Xor, inputs = ["x01","y01"], output = "n5"}
|
`- Gate {gType = And, inputs = ["x01","y01"], output = "n3"}
Up to renaming of intermediate wires, and reordering of inputs, this is isomorphic to the actual adder fragments I found above.
I built a function to test that equivalence.
equivalentTree :: DeviceTree -> DeviceTree -> Bool
equivalentTree (Node (Gate g1 i1 _) []) (Node (Gate g2 i2 _) []) =
g1 == g2 && i1 == i2
equivalentTree (Node (Gate g1 _ _) sub1@(_:_))
(Node (Gate g2 _ _) sub2@(_:_)) =
g1 == g2 && ( (all (uncurry equivalentTree) $ zip sub1 sub2)
|| (all (uncurry equivalentTree) $ zip sub1 (reverse sub2))
)
equivalentTree _ _ = False
All that was left was to look for where the trees didn't match, and make the corrections! In theory, simple enough. In practice, the obfuscated names of the wires made this a job I had no particular patience for. Luckily, I found a great post on Reddit by u/an-absolute-potato, who described a way of renaming the wires to more meaningful names. That make the process not easier, but at least tractable.
To rename a wire from one name to another, I change the name in every input and output for that wire.
renameWire :: String -> String -> Device -> Device
renameWire from to = fmap rename
where rename (Gate g i o) = Gate g (fmap renameName i) (renameName o)
renameName n | n == from = to
| otherwise = n
renameAll
renames the output wire of a gate that has the given inputs. renameAllN
does the same for all gates that match a given prefix. For instance, renameAll Xor "x21" "y21" "xor21" device
will find all the gates with inputs x21
and y21
, and rename their outputs to be xor21
. renameAllN Xor "x" "y" "xor" 21 device
will do the same, but building x21
from the prefix and number.
existingOutput :: GateType -> String -> String -> Device -> Maybe String
existingOutput gType i1 i2 device
| null gates = Nothing
| otherwise = Just $ output $ head gates
where
f (Gate g i _o) = g == gType && i1 `elem` i && i2 `elem` i
gates = filter f device
renameAll :: GateType -> String -> String -> String -> Device -> Device
renameAll gType i1 i2 newO device =
case mo of
Nothing -> device
Just o -> renameWire o newO device
where mo = existingOutput gType i1 i2 device
renameAllN :: GateType -> String -> String -> String -> Int -> Device -> Device
renameAllN gType p1 p2 po n device = renameAll gType i1 i2 pn device
where i1 = p1 ++ show2d n
i2 = p2 ++ show2d n
pn = po ++ show2d n
show2d :: Int -> String
show2d n | length (show n) == 1 = "0" ++ (show n)
| otherwise = show n
That allows me to rename all the gates with two "input" wires, e.g. x02
and y02
with a more sensible label.
renamings :: Device -> Device
renamings device = d3
where
d1 = foldr (renameAllN Xor "x" "y" "xor") device [0..44]
d2 = foldr (renameAllN And "x" "y" "and") d1 [0..44]
d3 = foldr (renameAllN Or "x" "y" "or") d2 [0..44]
The next stage was to rename the two additional gates involved in the carry part of the adder. The "intermediate carry" gate took the output of the previous "carry" gate and the xor
of the two input bits. The "carry" gate took the intermediate carry output and the and
of the two inputs.
renameCarryI :: Int -> Device -> Device
renameCarryI n device = renameAll And ("xor" ++ show2d n) ("carry" ++ show2d (n-1)) ("int_carry" ++ show2d n) device
renameCarry :: Device -> Int -> Device
renameCarry device n = d2
where d1 = renameCarryI n device
d2 = renameAll Or ("int_carry" ++ show2d n) ("and" ++ show2d n) ("carry" ++ show2d n) d1
That allowed me to rename all the correctly-wired parts of the adder. (Note the foldl
at the last stage. That took me a few minute to track down the bug.)
renamings :: Device -> Device
renamings device = d5
where
d1 = foldr (renameAllN Xor "x" "y" "xor") device [0..44]
d2 = foldr (renameAllN And "x" "y" "and") d1 [0..44]
d3 = foldr (renameAllN Or "x" "y" "or") d2 [0..44]
d4 = renameWire "and00" "carry00" d3
d5 = foldl renameCarry d4 [1..44]
Applying renamings
to the device will produce human-sensible names for all the wires that are part of the correctly-wired adder. For output 2, you get this:
ghci> putStrLn $ drawTree $ fmap show $ unfoldFromWire device "z02"
Gate {gType = Xor, inputs = ["carry01","xor02"], output = "z02"}
|
+- Gate {gType = Or, inputs = ["int_carry01","and01"], output = "carry01"}
| |
| +- Gate {gType = And, inputs = ["carry00","xor01"], output = "int_carry01"}
| | |
| | +- Gate {gType = And, inputs = ["x00","y00"], output = "carry00"}
| | |
| | `- Gate {gType = Xor, inputs = ["x01","y01"], output = "xor01"}
| |
| `- Gate {gType = And, inputs = ["x01","y01"], output = "and01"}
|
`- Gate {gType = Xor, inputs = ["x02","y02"], output = "xor02"}
If you take those renamed gates, sort by input, and look at the result, you find some correctly named wires and some not. The boundary between the two marks where things first go wrong.
ghci> putStrLn $ unlines $ fmap show $ sortOn (codeOfName . head . inputs) $ device
Then it's a case of showing the tree for the next output along, showing the tree it should be, and comparing them to see what goes wrong. Sometimes, where an output has been rewired, you have to look at the next-but-one output. Often, you have to look at the original input file, not the renamed devices, to find all the wire names.
For me, the first error was that wires "vss" and "z14" were swapped. I could put them back with swapWires
, that swaps the outputs of two gates.
swapWires :: String -> String -> Device -> Device
swapWires w1 w2 = renameOutput "swap" w2 . renameOutput w2 w1 . renameOutput w1 "swap"
where renameOutput from to = fmap rename
where rename (Gate g i o) = Gate g i (renameName o)
renameName n | n == from = to
| otherwise = n
Over time, I found the errors, corrected them, gave human-sensible names to the rest, and tried again. Eventually, I got these renamings.
rdn :: IO Device
rdn = do
text <- TIO.readFile "../data/advent24.txt"
let (_, device) = successfulParse text
let r1 = swapWires "vss" "z14" device
let r2 = swapWires "kdh" "hjf" r1
let r3 = swapWires "z31" "kpp" r2
let r4 = swapWires "z35" "sgj" r3
return $ renamings r4
I never want to have to do something like that again.
Code
You can get the code from my locally-hosted Git repo, or from Codeberg.