packages feed

cpmonad-0.1.0.0: lib/Cpmonad.hs

{-# LANGUAGE NoFieldSelectors #-}
{-# OPTIONS_GHC -Wno-type-defaults #-}
{-# OPTIONS_GHC -Wno-unused-do-bind #-}

{- |
Module     : Cpmonad
Description: Competitive programming problemsetting toolchain
Copyright  : (c) drdilyor, 2025
License    : MIT
Stability  : experimental
Portability: GNU/Linux

Cpmonad (a typo of Comonad) is a set of tools for setting competitive programming
problems. It features easy bidirectional parser/serializer, set of generators,
and tools to automatically run the solutions on all tests. There is no need to
write checkers for input and output formats.

This is very much an experiment. It only supports Batch problems, and doesn't
yet support exporting to polygon or other formats.
-}
module Cpmonad (
  -- * Problem
  Problem (..),
  generateTests,
  runSolutions,

  -- * Solution
  Solution (..),
  hs,
  hsio,
  cpp,

  -- * Tests
  Tests (..),
  UnseededTests,
  testset,
  subtask,
  seedTests,
  allTests,

  -- * Verdict
  Verdict (..),
  VerdictBad (..),
  wa,
  ac,
  mkPts,
  getPoints,
  hasPoints,
  mergeVerdict',

  -- * Other modules
  module Cpmonad.Gen,
  module Cpmonad.Printer,
  module Cpmonad.Misc,
) where

import Control.DeepSeq
import Control.Exception
import Control.Monad
import Data.ByteString.Builder qualified as B
import Data.ByteString.Char8 qualified as B
import Data.Default
import Data.List
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromJust)
import Data.Set qualified as Set
import Data.Vector qualified as V
import Data.Vector.Mutable qualified as VM
import GHC.Generics (Generic)
import System.CPUTime
import System.Directory
import System.Exit (ExitCode (..))
import System.IO
import System.IO.Error (isDoesNotExistError)
import System.IO.Temp (withTempFile)
import System.Process
import System.Timeout (timeout)

import Control.Applicative ((<|>))
import Control.Concurrent
import Cpmonad.Gen
import Cpmonad.Misc
import Cpmonad.Printer
import Data.IORef
import System.Random (StdGen)

{- | Central data specifying every part of the problem/task.

It has three type parameters:

- @i@ is passed to solutions
- @a@ is extra information stored with tests and passed onto grader.
- @o@ is what solutions need to compute

@i@ is written out as .in files, @a@ as .out files

A problem requires 3 printers for each of the type parameters.
@printerA@/@printerO@ accepts @i@ to support parsing outputs that depend on input.

Currently it only supports batch tasks.
-}
data Problem i a o where
  Problem
    :: (NFData i, NFData a, NFData o, Default i, Default a, Default o, Eq i, Eq a, Eq o)
    => { tests :: Tests (i, a)
       , sols :: [Solution i o]
       , check :: i -> a -> o -> Bool
       , printerI :: Printer i
       , printerA :: Printer (i, a)
       , printerO :: Printer (i, o)
       , timeLimit :: Int
        -- ^ time limit in microseconds
       }
    -> Problem i a o

-- | Generate all tests of the problem to a directory @tests@. Cleans the directory in the process
generateTests
  :: Int
  -- ^ number of (green)threads to use. Set it to @corecount - 1@ at most
  -> Problem i a o
  -> IO ()
generateTests threads Problem{..} = do
  wrapAction "cleaning up" $ cleanDirectory "tests"

  _ <- wrapAction "evaluating tests" $ do
    parallelPooled threads progressCounter $ map (evaluate . force) $ concat $ Map.elems tests.testsets
    putStrLn ""

  wrapAction "outputting tests" do
    parallelPooled threads progressCounter $ flip map (allTests tests) \(testName, (i, a)) -> do
      h <- openBinaryFile ("tests/" <> testName <> ".in") WriteMode
      hSetBuffering h (BlockBuffering $ Just 4096)
      B.hPutBuilder h . fromJust $ printerI.toPrinted i
      hClose h

      h <- openBinaryFile ("tests/" <> testName <> ".out") WriteMode
      hSetBuffering h (BlockBuffering $ Just 4096)
      B.hPutBuilder h . fromJust $ printerA.toPrinted (i, a)
      hClose h
    putStrLn ""

  wrapAction "parsing outputs" do
    parallelPooled threads progressCounter $ flip map (allTests tests) \(testName, (i, a)) -> do
      s <- B.readFile ("tests/" <> testName <> ".in")
      i' <- evaluate . force $ printerI.fromPrinted (def, s)
      case i' of
        Nothing -> throwIO $ AssertionFailed $ "tests/" <> testName <> ".in: failed to parse input"
        Just (i', _) | i' /= i -> throwIO $ AssertionFailed $ "tests/" <> testName <> ".in: parsed input is different from original"
        Just (i', _) -> do
          s <- B.readFile ("tests/" <> testName <> ".out")
          a' <- evaluate . force $ printerA.fromPrinted ((i', def), s)
          case a' of
            Nothing -> throwIO $ AssertionFailed $ "tests/" <> testName <> ".in: failed to parse input"
            Just ((_, a'), _) | a' /= a -> throwIO $ AssertionFailed $ "tests/" <> testName <> ".in: parsed input is different from original"
            Just _ -> pure ()
    putStrLn ""

  -- TODO: move this garbage out of here
  let debug = False
  when debug do
    -- only for checking the performance of the printer itself
    wrapAction "transcoding tests" do
      forM_ (allTests tests) \(_, (i, a)) -> do
        let i' = fmap fst . printerI.fromPrinted . (def,) . B.toStrict . B.toLazyByteString . fromJust $ printerI.toPrinted i
        let a' = i' >>= \i -> fmap (snd . fst) . printerA.fromPrinted . ((i, def),) . B.toStrict . B.toLazyByteString . fromJust $ printerA.toPrinted (i, a)
        i' `deepseq` a' `deepseq` pure ()

{- | Run all solutions on all tests.
It is required to call 'generateTests' before this unless the tests are up to date
-}
runSolutions
  :: Int
  -- ^ number of (green)threads to use. Set it to @corecount - 1@ at most
  -> Problem i a o
  -> IO ()
runSolutions threads Problem{..} = do
  sols <- wrapAction "compiling" do
    cleanDirectory "tmp"
    filterM compileSolution sols

  verdicts <- wrapAction "running" do
    forM sols \sol -> do
      let onProgress xs = do
            putStr ("\r" <> "sol " <> sol.name <> ": " <> status)
            hFlush stdout
           where
            status = flip map (V.toList xs) $ \case
              Nothing -> ' '
              Just (Bad e, _) -> case e of TLE -> 'T'; RE _ -> 'R'; PE -> 'P'; Other _ -> 'O'
              Just (pts, _) -> if hasPoints pts then '.' else 'X'

      results <- parallelPooled threads onProgress $ flip map (allTests tests) \(testName, (i, a)) -> do
        o <- runSolutionOnTest testName i sol
        evaluateOutput testName i a o
      putStrLn ""
      pure $ foldl1' mergeVerdict' results

  wrapAction "evaluating" do
    forM_ (zip sols verdicts) \case
      (sol, (Pts x, _)) | x > 0 -> do
        putStrLn $ "sol " <> sol.name <> ": Pts " <> show (round (x * 100) :: Int) <> "%"
      (sol, (verdict, (testName, i, _, o))) -> do
        let readFileHead f = withFile f ReadMode $ flip B.hGetSome 100
        putStrLn $ "sol " <> sol.name <> ": " <> case verdict of { Pts _ -> "WA"; Bad x -> show x } <> ":"

        input <- readFileHead ("tests/" <> testName <> ".in")
        B.putStrLn $ ">>> input:\n" <> input <> "\n"
        judgeOutput <- readFileHead ("tests/" <> testName <> ".out")
        B.putStrLn $ ">>> judge output:\n" <> judgeOutput <> "\n"
        case o of
          Just o -> B.putStrLn $ ">>> output:\n" <> (B.take 100 . B.toStrict . B.toLazyByteString . fromJust $ printerO.toPrinted (i, o)) <> "\n"
          _ -> pure ()
 where
  compileSolution = \case
    SolutionHs{} -> pure True
    SolutionExt{name, compileCmds} -> do
      putStrLn $ "-- " <> name
      handle @IOError (const $ putStrLn "-- compilation failed" >> pure False) do
        forM_ compileCmds (uncurry callProcess)
        pure True

  runSolutionOnTest testName i = \case
    SolutionHs{f} ->
      handle @SomeException (pure . Left . RE . show) $
        maybe (Left TLE) Right <$> timeout timeLimit (evaluate . force =<< f i)
    SolutionExt{runCmd = (cmd, args)} -> do
      withTempFile "tmp/" (testName <> ".out") \outPath hOut -> do
        code <- withFile ("tests/" <> testName <> ".in") ReadMode \hIn -> do
          let timeoutString = show (fromIntegral (round (fromIntegral timeLimit / 1000)) / 1000) <> "s"
          withCreateProcess
            -- GNU coreutils https://www.gnu.org/software/coreutils/timeout
            (proc "timeout" $ ["--signal=KILL", timeoutString, cmd] <> args)
              { delegate_ctlc = False
              , std_in = UseHandle hIn
              , -- hOut is closed here
                std_out = UseHandle hOut
              }
            \_ _ _ p -> waitForProcess p
        case code of
          -- timeout
          ExitFailure 124 -> do
            pure $ Left TLE
          ExitFailure 125 -> do
            pure $ Left $ RE "timeout command returned 125"
          ExitFailure code -> do
            pure $ Left $ RE $ "exit code " <> show code
          ExitSuccess -> do
            output <- B.readFile outPath
            pure $ case printerO.fromPrinted ((i, def), output) of
              Nothing -> Left PE
              Just ((_, o), _) -> Right o

  evaluateOutput testName i a o =
    pure $ case o of
      Left e -> (Bad e, (testName, i, a, Nothing))
      Right o
        | check i a o -> (ac, (testName, i, a, Just o))
        | otherwise -> (wa, (testName, i, a, Just o))

wrapAction :: String -> IO a -> IO a
wrapAction name step = do
  -- TODO: fix the time, use wall time
  putStrLn $ ":: " <> name <> " ..."
  start <- getCPUTime
  res <- step
  end <- getCPUTime
  let diffms = (round :: Double -> Int) $ fromIntegral (end - start) / (10 ^ (9 :: Int))
  putStrLn $ "   took " <> show diffms <> "ms"
  pure res

cleanDirectory :: FilePath -> IO ()
cleanDirectory dir = do
  catchJust
    (\e -> if isDoesNotExistError e then Just e else Nothing)
    (removeDirectoryRecursive dir)
    (const $ pure ())
  createDirectoryIfMissing False dir

-- we use explicit semaphores because otherwise multiple greenthreads will be interleaved on a single core,
-- which will lead to TLEs because `timeout` considers total wall time
parallelPooled :: Int -> (V.Vector (Maybe a) -> IO ()) -> [IO a] -> IO [a]
parallelPooled n onProgress actions = do
  sem <- newQSem n
  results <- VM.replicate (length actions) Nothing
  hasProgress <- newEmptyMVar
  anError <- newIORef Nothing
  channel <- newChan
  onProgress =<< V.freeze results

  let debug = False
  forM_ (zip [0 ..] actions) $ \(i, action) -> forkIO $ do
    result <- try @SomeException $ bracket_ (waitQSem sem) (signalQSem sem) $ do
      when debug do
        putStrLn $ ">> " <> show i
        hFlush stdout
      x <- action >>= evaluate
      when debug do
        putStrLn $ "-- " <> show i
        hFlush stdout
      pure x
    writeChan channel (i, result)

  let progressThread = do
        () <- takeMVar hasProgress
        onProgress =<< V.freeze results
        threadDelay 200_000
        progressThread

  bracket (forkIO progressThread) killThread \_ -> do
    replicateM_ (length actions) do
      (i, val) <- readChan channel
      case val of
        Left e -> modifyIORef' anError (<|> Just e)
        Right x -> do
          VM.write results i $ Just x
          void $ tryPutMVar hasProgress ()

    onProgress =<< V.freeze results
    readIORef anError >>= \case
      Just e -> throwIO e
      _ -> V.toList . V.map fromJust <$> V.freeze results

progressBar :: V.Vector (Maybe a) -> IO ()
progressBar v = do
  putStr $ "\r   " <> map (\case Just _ -> '.'; Nothing -> ' ') (V.toList v)
  hFlush stdout

progressCounter :: V.Vector (Maybe a) -> IO ()
progressCounter v = do
  putStr $ "\r   " <> show doneCount <> "/" <> show (V.length v)
  hFlush stdout
 where
  doneCount = V.sum $ V.map (\case Just _ -> 1; Nothing -> 0) v

{- | A solution can either be a Haskell function or a collection of commands.
The commands can use the @tmp@ directory in the current directory.

/The names must be unique./

'SolutionExt'\'s @runCmd@\'s output will be redirected to a file.
-}
data Solution i o
  = SolutionHs {name :: String, f :: i -> IO o}
  | SolutionExt
      { name :: String
      , compileCmds :: [(FilePath, [String])]
      , runCmd :: (FilePath, [String])
      , cleanupCmds :: [(FilePath, [String])]
      }

-- | Pure Haskell solution
hs :: String -> (i -> o) -> Solution i o
hs name f = SolutionHs{name, f = pure . f}

-- | IO Haskell solution
hsio :: String -> (i -> IO o) -> Solution i o
hsio name f = SolutionHs{..}

-- | C++ solution with the file @\<name\>.cpp@ in the current directory and flags @-O2 and -Wall@
cpp
  :: String
  -- ^ name of the solution
  -> Solution i o
cpp name = cpp' ["-O2", "-Wall"] (name <> ".cpp") name

-- | C++ solution with explicit path to the .cpp file and explicit compile-flags
cpp'
  :: [String]
  -- ^ @g++@ flags
  -> FilePath
  -- ^ path to the @ cpp@ file
  -> String
  -- ^ name of the solution
  -> Solution i o
cpp' flags path name =
  SolutionExt
    { name
    , compileCmds = [("g++", [path, "-o", "tmp/" <> name <> ".exe"] <> flags)]
    , runCmd = ("tmp/" <> name <> ".exe", [])
    , cleanupCmds = []
    }

{- | Collection of 'testset's and 'subtask's.
A testset is a named list of @a@. Testsets can be included in subtasks.

/Tests that are never referenced from subtasks are ignored./

Important: doesn't not check for name collisions. Everything is merged!

Note: the type parameter @a@ has nothing to do with @a@ in 'Problem'!

==== __Examples:__

@
testset "sample" [pure 1, pure 2]
<> testset "small" $ replicate 5 $ genr 1 100
<> testset "big"   $ replicate 5 $ genr 100 (10^9)
<> subtask "brute" ["sample", "small"] []
<> subtask "full" ["sample", "small", "big"] []
@
-}
data Tests a = Tests
  { testsets :: Map String [a]
  , subtaskIncludes :: Map String [String]
  -- ^ mapping from subtask names to testset names
  , subtasks :: [String]
  }
  deriving (Functor, Generic, NFData)

-- | Tests that need to be seeded yet
type UnseededTests a = Tests (Gen a)

instance Show (Tests a) where
  show Tests{..} =
    mconcat
      [ "Tests"
      , " {testsets = " <> show (([] :: [()]) <$ testsets)
      , ", subtaskIncludes = " <> show subtaskIncludes
      , ", subtasks = " <> show subtasks
      , "}"
      ]

instance Semigroup (Tests a) where
  a <> b =
    Tests
      { testsets = Map.unionWith (<>) a.testsets b.testsets
      , subtaskIncludes = Map.unionWith (<>) a.subtaskIncludes b.subtaskIncludes
      , subtasks = a.subtasks <> b.subtasks
      }

instance Monoid (Tests a) where
  mempty = Tests mempty mempty mempty

-- | Make a single testset with the given name and the objects
testset :: String -> [Gen a] -> UnseededTests a
testset name gens = mempty{testsets = Map.singleton name gens}

{- | Make a subtask with the given name, included testsets, and extra tests.
Extra tests are put after the included testcases
-}
subtask :: String -> [String] -> [Gen a] -> UnseededTests a
subtask name includes extra =
  mempty
    { subtaskIncludes = Map.singleton name includes
    , subtasks = singleton name
    }
    <> case extra of
      [] -> mempty
      _ ->
        mempty
          { testsets = Map.singleton ("subtask-" <> name) extra
          , subtaskIncludes = Map.singleton name ["subtask-" <> name]
          }

-- | Threads the StdGen through all the tests
seedTests :: StdGen -> UnseededTests a -> Tests a
seedTests s tests = tests{testsets = fst (runGen everything s)}
 where
  everything = traverse sequenceA tests.testsets

{- | The list of tests with their unique names.

/Tests that are never referenced from subtasks are ignored./

==== __Examples:__

>>> t1 = testset "a" [pure "a1", pure "a2"]
>>> t2 = testset "b" [pure "b1", pure "b2"]
>>> s = subtask "full" ["b", "a"] [pure "x"]
>>> allTests $ seedTests undefined $ t1 <> t2 <> s
[("0-b-0","b1"),("0-b-1","b2"),("1-a-0","a1"),("1-a-1","a2"),("2-subtask-full-0","x")]
-}
allTests :: Tests a -> [(String, a)]
allTests tests = do
  let allTestsets = unique $ concatMap (tests.subtaskIncludes Map.!) tests.subtasks
  (seti, setname) <- zip [0 ..] allTestsets
  (testi, test) <- zip [0 ..] $ tests.testsets Map.! setname
  pure (show seti <> "-" <> setname <> "-" <> show testi, test)
 where
  unique xs = reverse . snd $ foldl' (\(s, r) x -> if Set.member x s then (s, r) else (Set.insert x s, x : r)) (mempty, mempty) xs

-- | Verdict of a solution on a test
data Verdict
  = -- | the solution was graded successfully. Must be between 0 and 1
    Pts Float
  | -- | there was an error
    Bad VerdictBad
  deriving (Show, Eq)

-- | Verdict if there was an error running the solution
data VerdictBad
  = -- | output couldn't be parsed
    PE
  | -- | runtime error with extra information
    RE String
  | -- | time limit exceeded
    TLE
  | -- | any other message to the user
    Other String
  deriving (Show, Eq)

-- | Zero points. @Pts 0@
wa :: Verdict
wa = Pts 0

-- | Full points. @Pts 1@
ac :: Verdict
ac = Pts 1

-- | Constructs a 'Pts' by clamping the points between 0 and 1
mkPts :: Float -> Verdict
mkPts x = Pts (0 `max` x `min` 1)

-- | If 'Pts', gets the points, otherwise 0
getPoints :: Verdict -> Float
getPoints (Pts x) = x
getPoints _ = 0

-- | Whether it is 'Pts' and greater than zero
hasPoints :: Verdict -> Bool
hasPoints x = getPoints x > 0

-- | Return the argument which has smaller points. If an error, returns the left-most one
mergeVerdict' :: (Verdict, b) -> (Verdict, b) -> (Verdict, b)
mergeVerdict' a b = case (a, b) of
  ((Pts x, _), (Pts y, _))
    | x < y -> a
    | otherwise -> b
  ((Pts _, _), _) -> b
  (_, (Pts _, _)) -> a
  (_, _) -> a

-- | Semigroup based on 'mergeVerdict\''
instance Semigroup Verdict where
  a <> b = fst $ mergeVerdict' (a, ()) (b, ())

instance Monoid Verdict where
  mempty = Pts 1