Advent of Code 2023 day 19

    Day 19 was one where Haskell's type system kept me out of all sorts of trouble. And there were a lot of types involved to keep organised! It also showed off the use of lenses and monoids to keep the focus on the logic of the problem, not the plumbing of moving data around.

    Data structures

    Here I followed the task specification and created a type of all the different things I had to keep track of. The Part was simple enough. A RuleElement (like x>10:one or lnx) is either WithTest or WithoutTest; both have a Destination that's one of Accept, Reject, a new Rule, or to Continue to the next rule element. A Test has a Registerbeing tested, a Comparator, and a Threshold. Finally, a RuleBase is a Map from rule names (String) to lists of RuleElement.

    data Part a = Part { _x :: a, _m :: a, _a :: a, _s :: a }
      deriving (Eq, Ord, Show)
    makeLenses ''Part
    
    data Register = X | M | A | S
      deriving (Eq, Ord, Show)
    
    data Comparator = Lt | Gt
      deriving (Eq, Ord, Show)
    
    type RuleBase = M.Map String [RuleElement]
    
    data Destination = Accept | Reject | Rule String | Continue
      deriving (Eq, Ord, Show)
    
    data RuleElement = WithTest Test Destination
                     | WithoutTest Destination
      deriving (Eq, Ord, Show)
    
    data Test = Test { _register :: Register
                     , _comparator :: Comparator 
                     , _threshold :: Int 
                     }
      deriving (Eq, Ord, Show)
    makeLenses ''Test  

    Given all these bits of the input, the parser to read them all is long but a direct translation of the input format into the defined types.

    rulePartP = (,) <$> (rulesP <* endOfLine <* endOfLine) <*> partsP
    
    rulesP = M.fromList <$> ruleP `sepBy` endOfLine
    ruleP = (,) <$> (nameP <* "{") <*> (ruleBodyP <* "}")
    
    nameP = unpack <$> AT.takeWhile (inClass "a-z")
    ruleBodyP = ruleElementP `sepBy` ","
    ruleElementP = withTestP <|> withoutTestP
    
    withTestP = WithTest <$> (testP <* ":") <*> destinationP
    withoutTestP = WithoutTest <$> destinationP
    
    testP = Test <$> registerP <*> comparatorP <*> decimal
    
    registerP = choice [ X <$ "x"
                       , M <$ "m"
                       , A <$ "a"
                       , S <$ "s"
                       ]
    
    destinationP = choice [ Accept <$ "A"
                          , Reject <$ "R"
                          , Rule <$> nameP
                          ]
    
    comparatorP = choice [ Lt <$ "<"
                         , Gt <$ ">"
                         ]
    
    partsP = partP `sepBy` endOfLine
    partP = Part <$> ("{x=" *> decimal) <*> (",m=" *> decimal) <*> (",a=" *> decimal) <*> (",s=" *> decimal <* "}")
    

    Part 1

    Working from the bottom up, applyElement applies a single rule element to a Part. If there's a test and the part fails it, the part will Continue to the next rule element; otherwise, it goes to the destination of the rule element.

    applyElement :: Part Int -> RuleElement -> Destination
    applyElement _ (WithoutTest dest) = dest
    applyElement part (WithTest test dest) 
      | (test ^. comparator == Lt) = 
          if part ^. l < test ^. threshold
            then dest 
            else Continue
      | otherwise = 
          if part ^. l > test ^. threshold
            then dest 
            else Continue
      where l = lensOfR (test ^. register) 
      
    lensOfR :: Register -> Lens' (Part a) a
    lensOfR X = x
    lensOfR M = m
    lensOfR A = a
    lensOfR S = s

    To use a whole rule, I apply the first element to the part; the result is Continue, I apply the rest of the rule, otherwise I return the destination.

    applyRule :: Part Int -> [RuleElement] -> Destination
    applyRule _ [] = Reject
    applyRule part (x:xs) = 
      case applyElement part x of
        Continue -> applyRule part xs
        dest -> dest

    To apply the whole workflow, starting with a given rule name, I apply that rule. If that gives a new rule to apply, I use that; if not, I return the destination.

    applyWorkflow :: String -> RuleBase -> Part Int -> Destination
    applyWorkflow name rules part = 
      case applyRule part (rules ! name) of
        Rule name' -> applyWorkflow name' rules part
        dest -> dest

    The final solution is to apply the workflow to all the parts, and count how many end with Accept.

    part1 rules parts = sum $ fmap sumRegisters acceptedParts
      where acceptedParts = filter ((== Accept) . applyWorkflow "in" rules) parts
    
    sumRegisters :: Part Int -> Int
    sumRegisters part = (part ^. x) + (part ^. m) + (part ^. a) + (part ^. s)

    Part 2

    This needs a whole different approach. The idea is to apply the rules to find the ranges of values that will be accepted. When I'm thinking about these sorts of problems, I tend to think about what I need to know when I'm part-way through the process.

    After processing some rules, I'll end up with some parts-with-ranges that I know are accepted, some that still need further processing by other rules, and some that I know are rejected. As I don't need to know anything about the rejected portions, I don't need to keep hold of them.

    That leads to two new data structures. An Interval is a way of representing a range of values, and that explains the polymorphism of the Part earlier. An Evaluation is the current state of the process, with accepted parts and parts waiting further processing by other rules (represented by a list of WaitingPart).

    data Interval = Interval { _low :: Int, _high :: Int }
      deriving (Eq, Ord, Show)
    makeLenses ''Interval
    
    data WaitingPart = WaitingPart String (Part Interval)
      deriving (Eq, Ord, Show)
    
    data Evaluation = Evaluation 
      { _accepted :: [Part Interval]
      , _waiting :: [WaitingPart]
      } deriving (Eq, Ord, Show)
    makeLenses ''Evaluation

    There will be lots of Evaluations lying around, that I'll want to merge together. I define a Monoid instance to help this, as well as defining the initialPart.

    instance Semigroup Evaluation where
      (Evaluation a1 w1) <> (Evaluation a2 w2) = Evaluation (a1 <> a2) (w1 <> w2)
    
    instance Monoid Evaluation where
      mempty = Evaluation [] []
    
    initialPart :: Part Interval
    initialPart = Part (Interval 1 4000) (Interval 1 4000) 
                       (Interval 1 4000) (Interval 1 4000)

    At the high level, the evaluation as a whole takes an evaluation, runs the first WaitingPart, then combines that with the result of evaluating the rest of the waiting rules. Of course, evaluating a WaitingPart may result in more WaitingParts.

    evaluateRules :: RuleBase -> Evaluation -> [Part Interval]
    evaluateRules rules (Evaluation accepted []) = accepted
    evaluateRules rules (Evaluation accepted ((WaitingPart rulename part):waiting)) = 
      evaluateRules rules ((Evaluation accepted waiting) <> newEvaluation)
      where rulebody = rules ! rulename 
            newEvaluation = applyRuleI part rulebody

    To understand how to apply a rule to a Part Interval, it's easiest to start at the lowest level. Each test in a rule element will split an interval of values into (up to) two parts: the bit that passes the test, and the bit that fails the test. For instance, the test s<1351 will split the interval 1–4000 into the parts 1–1350 and 1351–4000, but the interval 1–1200 into the parts 1–1200 and nothing. splitInterval does this splitting, returning two Maybe (Part Interval)s.

    splitInterval :: Interval -> Comparator -> Int -> (Maybe Interval, Maybe Interval)
    splitInterval interval Lt threshold 
      | (interval ^. high) < threshold = (Just interval, Nothing)
      | (interval ^. low) > threshold = (Nothing, Just interval)
      | otherwise = ( Just (Interval (interval ^. low) (threshold - 1))
                    , Just (Interval threshold (interval ^. high))
                    )
    splitInterval interval Gt threshold 
      | (interval ^. low) > threshold = (Just interval, Nothing)
      | (interval ^. high) < threshold = (Nothing, Just interval)
      | otherwise = ( Just (Interval (threshold + 1) (interval ^. high))
                    , Just (Interval (interval ^. low) threshold)
                    )

    splitPart takes the result of splitInterval and uses it to produce possibly two new Parts. (There's a type issue here that prevents me using the lensOfR result in both the call to splitInterval and the creations of the new Parts. I don't understand the problem, but the use of regValue is the work-around.)

    splitPart :: Part Interval -> Test -> (Maybe (Part Interval), Maybe (Part Interval))
    splitPart part test = (passingPart, failingPart)
      where l = lensOfR (test ^. register) :: Lens' (Part Interval) Interval
            (passingInterval, failingInterval) = 
              splitInterval (regValue part (test ^. register)) (test ^. comparator) (test ^. threshold)
            passingPart = case passingInterval of
                            Nothing -> Nothing
                            Just pi -> Just (part & l .~ pi)
    interval)
            failingPart = case failingInterval of
                            Nothing -> Nothing
                            Just fi -> Just (part & l .~ fi)

    These two Parts are used in applyElementI that applies a ruleElement to a Part Interval. It returns a new Evaluation and a Maybe (Part Interval) that contains the Part that falls through to the next RuleElement in the RuleBody. If the RuleElement has no test, the incoming part is used to create the new Evaluation and the fall-through Part is Nothing. If the RuleElement has a test, it's used by splitPart to produce the two new Parts. The Part that passes the test is sent back to applyElementI to create its Evaluation, that the part that fails is returned.

    applyElementI :: Part Interval -> RuleElement -> (Evaluation, Maybe (Part Interval))
    applyElementI part (WithoutTest Accept) = (mempty & accepted .~ [part], Nothing)
    applyElementI part (WithoutTest Reject) = (mempty, Nothing)
    applyElementI part (WithoutTest (Rule rule)) = (mempty & waiting .~ [WaitingPart rule part], Nothing)
    applyElementI part (WithTest test dest) = (evaluation, failing)
      where (passing, failing) = splitPart part test
            evaluation = case passing of
                          Nothing -> mempty
                          Just p -> fst $ applyElementI p (WithoutTest dest) 

    Finally, applyRuleI keeps applying rule elements until it either runs out of elements or runs out of parts that need processing. It's called by evaluateRules above.

    applyRuleI :: Part Interval -> [RuleElement] -> Evaluation
    applyRuleI _ [] = mempty
    applyRuleI part (e:es) = 
      case inProgress of
        Nothing -> evaluation
        Just p -> evaluation <> (applyRuleI p es)
      where (evaluation, inProgress) = applyElementI part e

    The whole process is kicked off by applying the initialPart to the in rule, and the result is calculated by finding the range of accepted values of all the accepted parts.

    part2 rules = sum $ fmap registerRange accepted
      where accepted = evaluateRules rules 
                                     (Evaluation [] [WaitingPart "in" initialPart])
                                     
    registerRange :: Part Interval -> Int
    registerRange part = ((part ^. x . high) - (part ^. x . low) + 1) * 
                         ((part ^. m . high) - (part ^. m . low) + 1) * 
                         ((part ^. a . high) - (part ^. a . low) + 1) * 
                         ((part ^. s . high) - (part ^. s . low) + 1)                          

    Code

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

    Neil Smith

    Read more posts by this author.