Advent of Code 2023 day 20

Day 20 was the first occurrence of a key feature of the Haskell way of doing things, the reader-writer-state monad stack, the RWS. In this case, the reader is the topology of the network, the writer is a log of the broadcast messages, and the state is the combination of the states of the modules and the current queue of messages.

Data structures and parsing

As usual, I define new structures for holding the data. A Pulse has a source, a Level, and a destination; pulses are held in a Queue, where new pulses are fed in from the right and extracted from the left (this is the same order as they're presented in the input rules).

type Name = String

data Level = Low | High deriving (Show, Eq, Ord)
data Pulse = Pulse { _source :: Name, _level :: Level , _destination :: Name }
  deriving (Show, Eq, Ord)
makeLenses ''Pulse  

type Queue = Q.Seq Pulse

A Module is one of the four types. FlipFlops hold a boolean state, Conjuctions have a Memory that connects source to last-seen state.

type Memory = M.Map Name Level

data Module = 
  Broadcast 
  | FlipFlop Bool 
  | Conjunction Memory
  | Untyped
  deriving (Show, Eq, Ord)

The Network is the connections from modules to modules. The Modules are held in a Map, indexed by name. The NetworkState holds both the current modules and the message queue. Finally, a NetworkHandler combines the Network reader, the list of Pulses writer, and the NetworkState.

type Network = M.Map Name [Name]
type Modules = M.Map Name Module

data NetworkState = NetworkState { _modules :: Modules
                                 , _queue :: Queue
                                 }
  deriving (Show, Eq, Ord)
makeLenses ''NetworkState  

type NetworkHandler = RWS Network [Pulse] NetworkState

Parsing the data follows the rules as they're given in the input file.

configLinesP :: Parser [((Module, Name), [Name])]
configLineP :: Parser ((Module, Name), [Name])
moduleP, broadcastP, flipFlopP, conjunctionP :: Parser (Module, Name)
nameP :: Parser Name

configLinesP = configLineP `sepBy` endOfLine
configLineP = (,) <$> (moduleP <* " -> ") <*> (nameP `sepBy` ", ")

moduleP = broadcastP <|> flipFlopP <|> conjunctionP

broadcastP = (Broadcast, "broadcaster") <$ "broadcaster"
flipFlopP = (FlipFlop False, ) <$> ("%" *> nameP)
conjunctionP = (Conjunction M.empty, ) <$> ("&" *> nameP)

Each line of the input turns into a pair, comprising the pair of module type and module name, and the list of names of modules it feeds into. Unfortunately, this isn't at all like the structures I need for the simulation of the network.

I build the Network and Modules in several passes (hardly efficient, but easy to write and quick enough). They are

  1. creating the network (and initial stab at the modules)
  2. creating the modules
  3. adding the memory to all the Conjunction modules
assembleNetwork :: [((Module, Name), [Name])] -> (Network, Modules)
assembleNetwork config = (network, modules)
  where (network, modules0) = mkNetwork config
        modules1 = M.union (mkModules config) modules0
        modules = addConjunctionMemory network modules1

Making the network walks over the config lines, creating a connection from each source module name to the list of destinations. It also creates an Untyped module for each destination. Making the modules builds a Map from the module type and name given in each config line. These two collections of Modules are combined in assembleNetwork above, so that unspecified destinations are present as Untyped modules.

mkNetwork :: [((Module, Name), [Name])] -> (Network, Modules)
mkNetwork config = (net, mods) 
  where net = M.fromList $ fmap (\((_, n), ds) -> (n, ds)) config
        mods = M.fromList $ concatMap (\(_, ds) -> fmap (\d -> (d, Untyped)) ds) config

mkModules :: [((Module, Name), [Name])] -> Modules
mkModules = M.fromList . fmap (\((m, n), _) -> (n, m))

Finally, I add the full memories to all the Conjuctions. This is a pair of nested folds: for each entry in the Network, and for each destination in the entry, if the destination is a Conjunction, add that source to the destination's memory.

addConjunctionMemory :: Network -> Modules -> Modules
addConjunctionMemory network modules = 
  M.foldlWithKey addMemory modules network

addMemory :: Modules -> Name -> [Name] -> Modules
addMemory modules source connections = 
  foldl' (addOneMemory source) modules connections

addOneMemory :: Name -> Modules -> Name -> Modules
addOneMemory source modules destination =
  case modules ! destination of
    Conjunction memory -> 
      M.insert destination 
               (Conjunction $ M.insert source Low memory) 
               modules
    _ -> modules

Simulating the network

Now I have everything assembled, I can do some simulation!

The rules of the simulation are implemented in processPulse. This takes a Pulse and a Module and returns the updated Module and the optional Level of any new Pulse. Almost all the logic is handled by pattern matching.

processPulse :: Pulse -> Module -> (Module, Maybe Level)
processPulse (Pulse _ l _) Broadcast = (Broadcast, Just l)
processPulse (Pulse _ Low _) (FlipFlop False) = (FlipFlop True, Just High)
processPulse (Pulse _ Low _) (FlipFlop True) = (FlipFlop False, Just Low)
processPulse (Pulse _ High _) (FlipFlop s) = (FlipFlop s, Nothing)
processPulse (Pulse s l _) (Conjunction memory) = 
  (Conjunction memory', Just outLevel)
  where memory' = M.insert s l memory
        outLevel = if all (== High) $ M.elems memory' then Low else High
processPulse _ Untyped = (Untyped, Nothing)

handlePulse takes one Pulse and uses the result of processPulse to update the state according to the network structure. It uses lenses to get into the innards of the nested structures, but is mostly just moving data around from place to place.

handlePulse :: Pulse -> NetworkHandler ()
handlePulse p@(Pulse _ _ destination) = 
  do  mdl <- gets ((! destination) . _modules)
      outGoings <- asks (! destination)
      let (mdl', maybeLevel) = processPulse p mdl
      modify (\s -> s & modules . ix destination .~ mdl')
      tell [p]
      case maybeLevel of
        Nothing -> return ()
        Just level' -> 
          do let newPulses = fmap (Pulse destination level') outGoings
             modify (\s -> s & queue %~ (>< (Q.fromList newPulses)))

handlePulses keeps calling handlePulse until the queue is empty. buttonPress sets the queue to a button press, then simulates.

handlePulses :: NetworkHandler ()
handlePulses = 
  do  pulses <- gets _queue
      case pulses of
        Q.Empty -> return ()
        (p :<| ps) -> 
          do modify (\s -> s & queue .~ ps)
             handlePulse p
             handlePulses

buttonPress :: Network -> NetworkState -> (NetworkState, [Pulse])
buttonPress network state = 
  execRWS handlePulses network (state & queue .~ pulse0)
  where pulse0 = Q.singleton $ Pulse "button" Low "broadcaster"    

Solving the puzzle

Now I can finally solve the puzzles!

Both parts of the puzzle require me to look at the log of button presses and extract some information, so I have a function that does that. pressAndEvaluate takes an interim result, presses the button, and combines the log with the interim result to generate a new result. For part 1, the combination is to count the number of high and low pulses.

part1, part2 :: Network -> Modules -> Int
part1 network modules = highs * lows 
  where (_, (highs, lows)) = 
         (!! 1000) $ iterate (pressAndEvaluate network part1Extractor) (state0, (0, 0))
        state0 = NetworkState modules Q.empty

pressAndEvaluate :: Network -> (a -> [Pulse] -> a) -> (NetworkState, a) -> (NetworkState, a)
pressAndEvaluate network resultExtractor (state0, result0) = (state1, result1)
  where (state1, pulses) = buttonPress network state0
        result1 = resultExtractor result0 pulses

part1Extractor :: (Int, Int) -> [Pulse] -> (Int, Int)
part1Extractor (highs, lows) pulses = (highs + length hs, lows + length ls)
  where (hs, ls) = partition ((== High) . _level) pulses

Part 2 needs a Low pulse sent to the the rx node. Inspection of the network graph shows that rx is supplied only by the Conjuction module lx, and this has four inputs. That means lx will only send a Low pulse when each of these inputs sends it a High pulse. Each of those inputs comes from a separate chunk of the network, and these chunks are only connected by the broadcaster and lx modules.

My input structure

Running the network for 10,000 steps shows that each of these chunks sends a High pulse every few thousand-ish button presses, and there's no burn-in time for any of them. This is the same as the loops for day 8, and I take the same approach here.

For part 2, the extractor pulls out High pulses going to lx. Once I have those, I do some list-processing shennanigans to find the button press of the first high pulse sent by each, and find the lowest common multiple of the four values.

part2 network modules = foldl' lcm 1 cycleLengths
  where (_, lxPulses) = 
         (!! 10000) $ iterate (pressAndEvaluate network part2Extractor) (state0, [(0, [])])
        state0 = NetworkState modules Q.empty
        lxHighs = filter (not . null . snd) lxPulses
        cycleLengths = fmap ((fst . head) . sort) $ 
                          groupBy ((==) `on` (_source . snd)) $ 
                          sortBy (compare `on` (\(_, p) -> p ^. source)) $ 
                          fmap (\(n, ps) -> (n, head ps)) lxHighs 

part2Extractor :: [(Int, [Pulse])] -> [Pulse] -> [(Int, [Pulse])]
part2Extractor allRs@((i, _):rs) pulses = (i + 1, lxPulses) : allRs
  where lxPulses = filter catchLx pulses
        catchLx (Pulse _ High "lx") = True
        catchLx _ = False

Code

You can get the code from my locally-hosted Git repo, or from Gitlab.