Advent of Code 2024 day 24 revisited

    Now I've shaken off the virus I had over Christmas, it's time to have another look at the day 24 puzzle, this time attempting to solve it!

    My earlier attempt used a lot of manual renaming of wires to guide a manual inspection of what the swaps should be.

    Building on my earlier work, I could create a stage in the adder, showing what it should look like. I could also find the gates that should represent an adder stage, working back from a particular output.

    The challenge was to identify where things went wrong.

    A diagram of each stage of the adder should help.

    My idea was to build a stage of the adder, using the given gates, and to find when the gates provided didn't match the gate that should be in a particular position. This is possible because each gate in the adder stage is unique: starting at a particular carry input, there should be only one choice for each gate when building the adder I expect.

    I anchor this process by relying on the first few adder stages. My earlier tests confirmed that the first seven (at least) stages of the adder were correct.

    Fixing the adder is an inductive process. Given the carry from position n-1 (and all gates and wires preceding it) is correct, I can build up the adder until output n, by using the gates provided in the puzzle input. repairAdder does this, keeping a list of swapped wires as it goes. Note that the generated adder is an Either type: it's a Right DeviceTree if this adder stage is correct, or Left (String, String) if the two named wires need to be swapped. If this stage is correct, I can move on to the next stage; if not, I make the swap and try the current stage again.

    repairAdder :: Device -> Int -> [(String, String)] -> [(String, String)]
    repairAdder _device 44 acc = acc
    repairAdder device n acc = 
      case adder of
            Left (o1, o2) -> repairAdder (swapWires o1 o2 device) n ((o1, o2) : acc)
            Right _ -> repairAdder device (n + 1) acc
      where d1 = replaceCarry (n - 1) device
            adder = growFromCarry n d1

    The first step is to anchor that building process for a particular adder stage. I added another constructor to the GateType type, and wrote a function that replaced the carry sub-tree with a Carry gate (it finds the Or gate before the gate that produces the specified output).

    data GateType = And | Or | Xor | Carry 
      deriving (Show, Eq, Ord)
      
    replaceCarry :: Int -> Device -> Device
    replaceCarry n device = (Gate Carry [] carryGate.output) : filter (/= carryGate) device
      where outWire = "z" ++ show2d n
            outGate = head $ filter ((== outWire) . output) device
            carryGate = head $ filter (\g -> (g.output `elem` outGate.inputs) && g.gType == Or) device

    From that, I build up the adder in stages, as shown in the diagram below. growFromCarry does that building.

    An image of an adder stage, showing the separation into three pairs of gates proceeding from the previous carry to the output.

    There's a bit of preamble to name all the inputs and outputs that should be seen in the adder stage. The spineGates show the structure of the adder stage to be built. Each highlighted pair in the diagram above is one stage in the building: the And gate on the spine and the Xor for the inputs is first, then the Or and And, finally the Xor and Xor. The work is done by growSpine, folding that list into the adder stage.

    growFromCarry :: Int -> Device -> Either (String, String) DeviceTree
    growFromCarry n device =
      do let c = Node {rootLabel = fromJust $ find ((== Carry) . gType) device, subForest = []}
         let x0 = "x" ++ show2d (n - 1)
         let y0 = "y" ++ show2d (n - 1)
         let x1 = "x" ++ show2d n
         let y1 = "y" ++ show2d n
         let z1 = "z" ++ show2d n
         let spineGates = [ (And, (Gate Xor [x0, y0] ""))
                          , (Or, (Gate And [x0, y0] ""))
                          , (Xor, (Gate Xor [x1, y1] ""))
                          ]
         grown <- foldM (growSpine device) c spineGates
         let grownOut = grown.rootLabel.output
         if grownOut == z1 then Right grown else Left (grownOut, z1)

    This uses the fact that Either is a monad, behaving much like Maybe. So long as the monadic values are Right, computation proceeds; but the first Left (wrong) value stops and the whole computation returns that first Left value.

    growSpine has the logic of finding the gates to build up the adder. It finds the gate that comes next in the spine, the new leaf gate, and the gate that comes next after the leaf. If these two "next" gates are the same, the wiring is correct. If growSpine can't find one or the other "next" gate, that indicates a wrong wire. In that case, it returns that the swap should occur between the output of the found node (the spine or the leaf) and the "other" wire in the found "next" node.

    growSpine :: Device -> DeviceTree -> (GateType, Gate) -> Either (String, String) DeviceTree
    growSpine device 
              spine 
              ( spineType  -- next spine template
              , (Gate leafType leafInput _) -- next leaf template
              )
      | null spineParents = Left (spineOut, otherParentInput)
      | null nextLeafParents = Left (nextLeaf.output, otherParentInput)
      | not $ null commonSpineCandidates = Right (Node {rootLabel = head commonSpineCandidates, subForest = [nextLeafTree, spine]})
      | otherwise = Left ("", "")
      where 
        spineParents = filter (\g -> g.gType == spineType && spineOut `elem` g.inputs) device
        nextLeaf = head $ filter (\g -> g.gType == leafType && leafInput == g.inputs) device
        nextLeafParents = filter (\g -> g.gType == spineType && nextLeaf.output `elem` g.inputs) device
        nextLeafTree = Node {rootLabel = nextLeaf, subForest = []}
        commonSpineCandidates = spineParents `intersect` nextLeafParents
        spineOut = spine.rootLabel.output
        otherParentInput = if null spineParents 
                            then head $ delete nextLeaf.output (inputs $ head nextLeafParents)
                            else head $ delete spineOut (inputs $ head spineParents) 

    Example

    Looking at the adder stage that feeds into z22 reveals this structure.

    Gate {gType = Xor, inputs = ["gjn","kdh"], output = "z22"}
    |
    +- Gate {gType = And, inputs = ["x22","y22"], output = "kdh"}
    |
    `- Gate {gType = Or, inputs = ["hbc","wdr"], output = "gjn"}
       |
       +- Gate {gType = And, inputs = ["frt","mhw"], output = "wdr"}
       |  |
       |  +- Gate {gType = Xor, inputs = ["x21","y21"], output = "mhw"}
       |  |
       |  `- Gate {gType = Carry, inputs = [], output = "frt"}
       |
       `- Gate {gType = And, inputs = ["x21","y21"], output = "hbc"}
    

    Comparison with the diagram above reveals that the and gate with output kdh is wrong: it should be an xor gate with the same inputs.

    Building the adder stage from the Carry with the first two gates in the spine gives this structure.

    Gate {gType = Or, inputs = ["hbc","wdr"], output = "gjn"}
    |
    +- Gate {gType = And, inputs = ["x21","y21"], output = "hbc"}
    |
    `- Gate {gType = And, inputs = ["frt","mhw"], output = "wdr"}
       |
       +- Gate {gType = Xor, inputs = ["x21","y21"], output = "mhw"}
       |
       `- Gate {gType = Carry, inputs = [], output = "frt"}
    

    Now we have a problem adding the next gate in the spine. Wire gjn feeds into the correct xor gate, providing the output z22. But the other input to that gate, kdh, is an and gate when it should be the xor between x22 and y22. The xor gate in question has output wire hjf and fees into the adder stage for z23. That means that hjf and kdh should be swapped to make the z22 adder stage correct.

    Building up the adder stages like this gives the correct swaps, at least for my input.

    part2 :: Device -> String
    part2 device = intercalate "," $ sort $ concatMap (\(a, b) -> [a, b]) swaps
      where swaps = repairAdder device 3 []

    Finally! The puzzle is solved by a program.

    Code

    You can get the code from my locally-hosted Git repo, or from Codeberg. MainOriginal.hs is the original solution where I manually solved the problem. MainWithInvestigations.hs is a version of the code above that includes additional code I wrote to investigate the adder and develop the solution. Main.hs is the final version that has only what's needed for this solution.

    Neil Smith

    Read more posts by this author.