packages feed

automaton-1.8: src/Data/Automaton/Schedule.hs

{-# LANGUAGE OverloadedLists #-}

{- | -
This module defines the 'MonadSchedule' class for running several automata concurrently,
and provides instances for common monad transformers.

The central abstraction is:

@'schedule' :: 'NonEmpty' ('Automaton' m a b) -> 'Automaton' m a b@

This takes a non-empty collection of automata and interleaves their outputs,
yielding one output per tick by cycling through the automata in some
monad-specific order (e.g. round-robin for 'Identity', first-available for 'IO').
For a free simulated-time scheduling monad transformer, see 'ScheduleT' in "Data.Automaton.Schedule.Trans".

== Relationship to @monad-schedule@

This class replaces the @MonadSchedule@ class from the @monad-schedule@ package
(which worked on monadic actions @m a@ directly) with one that works natively on 'Automaton' values.
The free waiting effect 'ScheduleT' from the @monad-schedule@ has been moved to @automaton@.
-}
module Data.Automaton.Schedule where

-- base
import Control.Arrow
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
import Control.Monad (forM_, guard, replicateM_)
import Control.Monad.IO.Class (MonadIO)
import Control.Monad.Identity (Identity (..))
import Data.Bifunctor qualified as Bifunctor
import Data.Foldable1 (Foldable1 (foldrMap1))
import Data.Function ((&))
import Data.Functor ((<&>))
import Data.Functor.Compose (Compose (..))
import Data.Kind (Type)
import Data.List qualified as List
import Data.List.NonEmpty as N
import Data.Maybe (maybeToList)
import Data.Tuple (swap)

-- transformers
import Control.Monad.Trans.Accum (AccumT (..), runAccumT)
import Control.Monad.Trans.Class (MonadTrans (..))
import Control.Monad.Trans.Except (ExceptT (..))
import Control.Monad.Trans.Maybe (MaybeT (..))
import Control.Monad.Trans.Reader (ReaderT (..))
import Control.Monad.Trans.State.Strict (StateT (..), get)
import Control.Monad.Trans.Writer.CPS qualified as CPS
import Control.Monad.Trans.Writer.Lazy qualified as Lazy
import Control.Monad.Trans.Writer.Strict qualified as Strict

-- sop-core
import Data.SOP (HCollapse (hcollapse), HSequence (htraverse'), I (..), K (..), NP (..), SListI, hmap, hzipWith)

-- containers
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.Sequence (Seq, ViewL (..), viewl)
import Data.Sequence qualified as Seq
import Data.Set qualified as S

-- mmorph
import Control.Monad.Morph (MFunctor)

-- witherable
import Witherable ((<&?>))

-- changeset
import Control.Monad.Trans.Changeset (ChangesetT (..))
import Data.Monoid.RightAction (RightAction)

-- time-domain
import Data.TimeDomain (TimeDifference (..))

-- automaton
import Data.Automaton (Automaton (..), arrM, constM, feedback, hoistS, initialised, liftS, reactimate, withAutomaton_)
import Data.Automaton qualified as Automaton
import Data.Automaton.Schedule.Trans (ScheduleT, SkipT, runScheduleS, runSkipS, scheduleS)
import Data.Automaton.Trans.Except (exceptS)
import Data.Automaton.Trans.Maybe (maybeExit, runMaybeS)
import Data.Automaton.Trans.Reader (readerS, runReaderS)
import Data.Automaton.Trans.State (modify, runStateS__)
import Data.Stream (StreamT (..), concatS)
import Data.Stream.Optimized (OptimizedStreamT (Stateful), toStreamT)
import Data.Stream.Result

{- | Class of monads that support running several 'Automaton's concurrently,
interleaving their outputs into a single 'Automaton'.

The semantics of 'schedule' depend on the monad:

* For 'Identity': round-robin (each automaton advances exactly once per cycle).
* For 'IO': all automata run in separate threads; results are delivered as soon
 as they are produced.
* For transformer stacks: defined compositionally by the individual instances.

The first input may be broadcast to an arbitrary number (1 for 'Identity', all for 'IO') of automata,
but subsequent inputs must be delivered to one automaton each.
-}
class MonadSchedule m where
  -- | Run a nonempty list of automata concurrently.
  schedule :: NonEmpty (Automaton m a b) -> Automaton m a b

{- | Start all streams in the background and send their values to a shared 'MVar'.

The first input is broadcast to all automata,
the following inputs are only broadcast to one each.
-}
instance MonadSchedule IO where
  schedule automata = proc a -> do
    (output, input) <- initialised startStreams -< a
    arrM $ uncurry putMVar -< (input, a)
    arrM takeMVar -< output
    where
      startStreams a0 = do
        output <- newEmptyMVar
        input <- newEmptyMVar
        forkIO $ replicateM_ (N.length automata - 1) $ putMVar input a0
        forM_ automata $ \automaton -> forkIO $ reactimate $ constM (takeMVar input) >>> automaton >>> arrM (putMVar output)
        return (output, input)

instance (Monad m, MonadSchedule m) => MonadSchedule (ReaderT r m) where
  schedule =
    fmap runReaderS
      >>> schedule
      >>> readerS

{- | Schedule automata in 'ExceptT'.

When any automaton throws an exception, all others are stopped immediately.
This is intentional: a thrown exception signals that the computation cannot
continue, so it is consistent to stop all peers.

To let all automata run to completion before propagating an exception, lift
them into a monad that does not short-circuit on exceptions (e.g. wrap the
exception type in 'Either' and post-process the results).
-}
instance (Monad m, MonadSchedule m) => MonadSchedule (ExceptT e m) where
  schedule =
    fmap exceptS
      >>> schedule
      >>> withAutomaton_ (fmap sequenceA >>> ExceptT)

{- | Schedule automata in 'MaybeT'.

When any automaton returns 'Nothing', all others are stopped immediately and the
combined automaton also returns 'Nothing'. This is intentional: 'Nothing'
signals termination, so it is consistent to stop all peers.

To let all automata run to completion before stopping, convert the 'MaybeT'
automata to base-monad automata producing @'Maybe' b@ values and post-process
the results.
-}
instance (Monad m, MonadSchedule m) => MonadSchedule (MaybeT m) where
  schedule =
    fmap runMaybeS
      >>> schedule
      >>> withAutomaton_ (fmap sequenceA >>> MaybeT)

{- | A monad transformer for scheduling automata that all need to run to
completion.

Like 'MaybeT', each automaton can signal termination by returning 'Nothing'.
Unlike 'MaybeT', the combined automaton does __not__ stop when one automaton
finishes; it waits until __all__ scheduled automata have finished.

While any automaton is still running, finished automata contribute no outputs.
Once every automaton has finished, the combined automaton itself terminates.
-}
newtype FinalizeT m a = FinalizeT
  { getFinalizeT :: MaybeT m a
  -- ^ Unwrap 'FinalizeT' to the underlying 'MaybeT'.
  }
  deriving newtype (Functor, Applicative, Monad, MonadIO, MonadTrans, MFunctor)

instance (Monad m, MonadSchedule m) => MonadSchedule (FinalizeT m) where
  schedule automata =
    automata
      & N.zip [1 ..]
      & fmap (\(i, automaton) -> runMaybeS (hoistS getFinalizeT automaton) <&> maybe (Left i) Right)
      & schedule
      & (>>> haveAllFinished)
      & liftS
      & (>>> maybeExit)
      & fmap maybeToList
      & Automaton.concatS
      & hoistS FinalizeT
    where
      allN = S.fromAscList [1 .. N.length automata]
      haveAllFinished = Automaton.unfold S.empty $ \input is -> case input of
        Left i -> let is' = S.insert i is in Result is' $ if is' == allN then Nothing else Just Nothing
        Right b -> Result is $ Just $ Just b

instance (Monoid w, Monad m, MonadSchedule m) => MonadSchedule (CPS.WriterT w m) where
  schedule =
    fmap (withAutomaton_ (CPS.runWriterT >>> fmap (\(Result s a, w) -> Result s (a, w))))
      >>> schedule
      >>> withAutomaton_ (fmap (\(Result s (a, w)) -> (Result s a, w)) >>> CPS.writerT)

instance (Monoid w, Monad m, MonadSchedule m) => MonadSchedule (Strict.WriterT w m) where
  schedule =
    fmap (withAutomaton_ (Strict.runWriterT >>> fmap (\(Result s a, w) -> Result s (a, w))))
      >>> schedule
      >>> withAutomaton_ (fmap (\(Result s (a, w)) -> (Result s a, w)) >>> Strict.WriterT)

instance (Monoid w, Monad m, MonadSchedule m) => MonadSchedule (Lazy.WriterT w m) where
  schedule =
    fmap (withAutomaton_ (Lazy.runWriterT >>> fmap (\(Result s a, w) -> Result s (a, w))))
      >>> schedule
      >>> withAutomaton_ (fmap (\(Result s (a, w)) -> (Result s a, w)) >>> Lazy.WriterT)

-- | This will share the accumulated log from the past with all automata
instance (Monoid w, Monad m, MonadSchedule m) => MonadSchedule (AccumT w m) where
  schedule =
    fmap (withAutomaton_ (runAccumT >>> ReaderT >>> CPS.writerT))
      >>> schedule
      >>> withAutomaton_ (CPS.runWriterT >>> runReaderT >>> AccumT)

-- | This will share the accumulated state from the past with all automata
instance (Monoid w, RightAction w s, Monad m, MonadSchedule m) => MonadSchedule (ChangesetT s w m) where
  schedule =
    fmap (withAutomaton_ (getChangesetT >>> ReaderT >>> fmap swap >>> CPS.writerT))
      >>> schedule
      >>> withAutomaton_ (CPS.runWriterT >>> fmap swap >>> runReaderT >>> ChangesetT)

{- | Cycle through all automata in a round-robin fashion.

On each tick of the combined 'Automaton', exactly one of the component
automata is stepped. The automata are advanced in the order they appear in
the input 'NonEmpty' list, cycling indefinitely.
-}
instance MonadSchedule Identity where
  schedule =
    fmap (getAutomaton >>> toStreamT)
      >>> foldrMap1 buildStreams consStreams
      >>> roundRobinStreams
      >>> fmap N.toList
      >>> concatS
      >>> Stateful
      >>> Automaton
    where
      buildStreams :: StreamT m b -> Streams m b
      buildStreams StreamT {state, step} =
        Streams
          { states = I state :* Nil
          , steps = Step (ResultStateT step) :* Nil
          }

      consStreams :: StreamT m b -> Streams m b -> Streams m b
      consStreams StreamT {state, step} Streams {states, steps} =
        Streams
          { states = I state :* states
          , steps = Step (ResultStateT step) :* steps
          }

-- The order of outputs matches the order of inputs: 'foldrMap1' places the
-- first input element at the head of the 'NP', so 'hnonemptycollapse' extracts
-- outputs in the original order.

{- | Step all streams in a 'Streams' bundle simultaneously and collect the
results into a 'NonEmpty' list, preserving the input order.
-}
roundRobinStreams :: (Functor m, Applicative m) => Streams m b -> StreamT m (NonEmpty b)
roundRobinStreams Streams {states, steps} =
  StreamT
    { state = states
    , step = \s ->
        s
          & hzipWith (\Step {getStep} (I s) -> getResultStateT getStep s <&> RunningResult & Compose) steps
          & htraverse' getCompose
          <&> ( \results ->
                  Result
                    (results & hmap (getRunningResult >>> resultState >>> I))
                    (results & hmap (getRunningResult >>> output >>> K) & hnonemptycollapse)
              )
    }

-- | Collapse a non-empty n-ary product of constant functors into a 'NonEmpty' list.
hnonemptycollapse :: (SListI as) => NP (K b) (a ': as) -> NonEmpty b
hnonemptycollapse (K a :* as) = a :| hcollapse as

-- | A nonempty list of 'StreamT's, unzipped into their states and their steps.
data Streams m b
  = forall state (states :: [Type]).
  (SListI states) =>
  Streams
  { states :: NP I (state ': states)
  , steps :: NP (Step m b) (state ': states)
  }

-- | One step of a stream, with the state type argument going last, so it is usable with sop-core.
newtype Step m b state = Step {getStep :: ResultStateT state m b}

-- | The result of a stream, with the type arguments swapped, so it's usable with sop-core
newtype RunningResult b state = RunningResult {getRunningResult :: Result state b}

instance (Monad m, MonadSchedule m) => MonadSchedule (SkipT m) where
  schedule = fmap runSkipS >>> schedule >>> fmap maybeToList >>> Automaton.concatS >>> liftS

-- | Each scheduled automaton must eventually produce an output or a diff greater than 'zero', otherwise this will loop indefinitely.
instance (Show diff, Ord diff, TimeDifference diff, Monad m, MonadSchedule m) => MonadSchedule (ScheduleT diff m) where
  schedule automata = automata & N.zip [1 ..] & fmap instrument & schedule & backpressure & scheduleS & Automaton.concatS
    where
      nAutomata = List.length automata
      instrument :: (Int, Automaton (ScheduleT diff m) a b) -> Automaton m (a, diff) (Int, Either (Maybe diff) b)
      instrument (i, automaton) = flip runStateS__ mempty $ proc (a, globalTime) -> do
        localTime <- constM get -< ()

        if globalTime < localTime
          -- We are ahead of the consensus time, skip this tick instead of emitting
          -- Each automaton ticks once per round-robin cycle over all N automata,
          -- so each input 'a' is delivered to each automaton exactly once per cycle.
          then do
            returnA -< (i, Left Nothing)
          else do
            diffOrOutput <- liftS $ runScheduleS automaton -< a
            case diffOrOutput of
              Left diffNew -> do
                arrM modify -< (`add` diffNew)
              _ -> returnA -< ()
            returnA -< (i, Bifunctor.first Just diffOrOutput)

      -- Each tick of 'schedule' advances exactly one of the N instrumented automata.
      -- 'backpressure' feeds the current consensus time back as the second input
      -- so that automata which are ahead of the consensus will skip their tick.
      backpressure :: Automaton m (a, diff) (Int, Either (Maybe diff) b) -> Automaton m a (Either diff [b])
      backpressure scheduled = feedback (IM.fromAscList $ (,Seq.Empty) <$> [1 .. nAutomata], mempty) $ proc (a, (queues, lastGlobalTime)) -> do
        (i, outputOrDiffMaybe) <- scheduled -< (a, lastGlobalTime)
        let (output, queues') = popOutput $ enqueueOutput i outputOrDiffMaybe queues
        returnA -< (output, (queues', lastGlobalTime & either add (const id) output))

      enqueueOutput :: Int -> Either (Maybe diff) b -> IntMap (Seq (Either diff b)) -> IntMap (Seq (Either diff b))
      enqueueOutput i = \case
        Left Nothing -> id
        Left (Just diff) -> IM.insertWith (<>) i $ Seq.singleton $ Left diff
        Right b -> IM.insertWith (<>) i $ Seq.singleton $ Right b

      analyseQueue :: IntMap (Seq (Either diff b)) -> Maybe (IntMap (Seq (Either diff b)), (IntMap diff, [b]))
      analyseQueue =
        let peekOutput i queuei = do
              case viewl queuei of
                -- Queue empty: we cannot yet determine a consensus output for this
                -- automaton, so we abort and wait for more input from 'schedule'.
                -- This conservatively waits even if some other automata already
                -- have outputs queued; a more aggressive variant could return
                -- those partial results early.
                EmptyL -> lift Nothing
                -- New diff enqueued.
                Left diff :< queuei' -> do
                  modify $ Bifunctor.first $ IM.insert i diff
                  pure queuei'
                -- New output.
                Right b :< queuei' -> do
                  modify $ Bifunctor.second (b :)
                  pure queuei'
         in flip runStateT (IM.empty, []) . IM.traverseWithKey peekOutput

      pushDiffsBack :: IntMap diff -> IntMap (Seq (Either diff b)) -> IntMap (Seq (Either diff b))
      pushDiffsBack diffs = IM.unionWith (<>) $ diffs <&> pure . Left

      popOutput :: IntMap (Seq (Either diff b)) -> (Either diff [b], IntMap (Seq (Either diff b)))
      popOutput queues = case analyseQueue queues of
        Nothing -> (Right [], queues) -- Queues weren't full yet, need to wait for more input
        Just (queues', (diffs, bs)) -> case N.nonEmpty bs of
          Nothing ->
            if IM.null diffs
              then (Right [], queues') -- No diffs or outputs enqueued
              else
                let minDiff = minimum diffs
                    adjustedDiffs = diffs <&?> \diff -> guard (diff /= minDiff) >> Just (diff `difference` minDiff)
                 in (Left minDiff, pushDiffsBack adjustedDiffs queues')
          Just bs -> (Right $ List.reverse $ toList bs, pushDiffsBack diffs queues')