feat(exam): start work on automatic exam-occurrence assignment
This commit is contained in:
parent
a7b7bdbea7
commit
282df86bc2
@ -3,9 +3,10 @@ module Handler.Utils.Exam
|
|||||||
, fetchExam, fetchExamId, fetchCourseIdExamId, fetchCourseIdExam
|
, fetchExam, fetchExamId, fetchCourseIdExamId, fetchCourseIdExam
|
||||||
, examBonus, examBonusPossible, examBonusAchieved
|
, examBonus, examBonusPossible, examBonusAchieved
|
||||||
, examResultBonus, examGrade
|
, examResultBonus, examGrade
|
||||||
|
, examAutoOccurrence
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Import.NoFoundation
|
import Import.NoFoundation hiding (distribute)
|
||||||
|
|
||||||
import Database.Persist.Sql (SqlBackendCanRead)
|
import Database.Persist.Sql (SqlBackendCanRead)
|
||||||
import qualified Database.Esqueleto as E
|
import qualified Database.Esqueleto as E
|
||||||
@ -16,8 +17,16 @@ import Database.Esqueleto.Utils.TH
|
|||||||
import qualified Data.Conduit.List as C
|
import qualified Data.Conduit.List as C
|
||||||
|
|
||||||
import qualified Data.Map as Map
|
import qualified Data.Map as Map
|
||||||
|
import qualified Data.Set as Set
|
||||||
|
|
||||||
import Data.Fixed (Fixed(..))
|
import Data.Fixed (Fixed(..))
|
||||||
|
import qualified Data.Foldable as F
|
||||||
|
|
||||||
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
|
||||||
|
import Control.Monad.Trans.Random.Lazy (evalRand)
|
||||||
|
import System.Random (mkStdGen)
|
||||||
|
import Control.Monad.Random.Class (uniform)
|
||||||
|
|
||||||
|
|
||||||
fetchExamAux :: ( SqlBackendCanRead backend
|
fetchExamAux :: ( SqlBackendCanRead backend
|
||||||
@ -161,3 +170,149 @@ examGrade Exam{..} mBonus (otoList -> results)
|
|||||||
lowerBounds :: [(ExamGrade, Points)]
|
lowerBounds :: [(ExamGrade, Points)]
|
||||||
lowerBounds = zip [Grade40, Grade37 ..] examGradingKey'
|
lowerBounds = zip [Grade40, Grade37 ..] examGradingKey'
|
||||||
|
|
||||||
|
|
||||||
|
examAutoOccurrence :: forall seed.
|
||||||
|
Hashable seed
|
||||||
|
=> seed
|
||||||
|
-> ExamOccurrenceRule
|
||||||
|
-> Map ExamOccurrenceId Natural
|
||||||
|
-> Map UserId (User, Maybe ExamOccurrenceId)
|
||||||
|
-> (Maybe (ExamOccurrenceMapping ExamOccurrenceId), Map UserId (Maybe ExamOccurrenceId))
|
||||||
|
examAutoOccurrence (hash -> seed) rule occurrences users
|
||||||
|
| sum occurrences < fromIntegral (Map.size users)
|
||||||
|
|| Map.null users
|
||||||
|
= nullResult
|
||||||
|
| otherwise
|
||||||
|
= case rule of
|
||||||
|
ExamRoomRandom
|
||||||
|
-> ( Nothing
|
||||||
|
, flip Map.mapWithKey users $ \uid (_, mOcc)
|
||||||
|
-> let randomOcc = flip evalRand (mkStdGen $ hashWithSalt seed uid) $
|
||||||
|
uniform $ Map.keysSet occurrences
|
||||||
|
in Just $ fromMaybe randomOcc mOcc
|
||||||
|
)
|
||||||
|
_ | Just (postprocess -> (resMapping, result)) <- bestOption
|
||||||
|
-> ( Just $ ExamOccurrenceMapping rule resMapping
|
||||||
|
, Map.unionWith (<|>) (view _2 <$> users) result
|
||||||
|
)
|
||||||
|
_ -> nullResult
|
||||||
|
where
|
||||||
|
nullResult = (Nothing, view _2 <$> users)
|
||||||
|
|
||||||
|
users' :: Map [CI Char] (Set UserId)
|
||||||
|
-- ^ Finest partition of users
|
||||||
|
users' = case rule of
|
||||||
|
ExamRoomSurname
|
||||||
|
-> Map.fromListWith Set.union
|
||||||
|
[ (map CI.mk $ unpack userSurname, Set.singleton uid)
|
||||||
|
| (uid, (User{..}, Nothing)) <- Map.toList users
|
||||||
|
, not $ null userSurname
|
||||||
|
]
|
||||||
|
ExamRoomMatriculation
|
||||||
|
-> let matrUsers
|
||||||
|
= Map.fromListWith Set.union
|
||||||
|
[ (map CI.mk . reverse $ unpack matriculation, Set.singleton uid)
|
||||||
|
| (uid, (User{..}, Nothing)) <- Map.toList users
|
||||||
|
, let Just matriculation = userMatrikelnummer
|
||||||
|
, not $ null matriculation
|
||||||
|
]
|
||||||
|
in Map.mapKeysWith Set.union (take . F.minimum . Set.map length $ Map.keysSet matrUsers) matrUsers
|
||||||
|
_ -> Map.singleton [] $ Map.keysSet users
|
||||||
|
|
||||||
|
usersGroups :: Natural -- ^ fineness
|
||||||
|
-> Map [CI Char] (Set UserId)
|
||||||
|
-- ^ Partition users into monotonously finer partitions
|
||||||
|
usersGroups (fromIntegral -> c) = Map.mapKeysWith Set.union (take c) users'
|
||||||
|
|
||||||
|
maximumFineness :: Natural
|
||||||
|
-- ^ Fineness at which `usersGroups` becomes constant
|
||||||
|
maximumFineness = fromIntegral . F.maximum . Set.map length $ Map.keysSet users'
|
||||||
|
|
||||||
|
occurrences' :: [(ExamOccurrenceId, Natural)]
|
||||||
|
-- ^ Minimise number of occurrences used
|
||||||
|
--
|
||||||
|
-- Prefer occurrences with higher capacity
|
||||||
|
--
|
||||||
|
-- If a single occurrence can accomodate all participants, pick the one with
|
||||||
|
-- the least capacity
|
||||||
|
occurrences'
|
||||||
|
| Just largeEnoughs <- fromNullable . filter ((>= usersCount) . view _2) $ Map.toList occurrences
|
||||||
|
= pure $ minimumBy (comparing $ view _2) largeEnoughs
|
||||||
|
| otherwise
|
||||||
|
= view _2 . foldr accF (0, []) . sortOn (Down . view _2) $ Map.toList occurrences
|
||||||
|
where
|
||||||
|
usersCount = fromIntegral $ Map.size users
|
||||||
|
|
||||||
|
accF :: (ExamOccurrenceId, Natural)
|
||||||
|
-> (Natural, [(ExamOccurrenceId, Natural)])
|
||||||
|
-> (Natural, [(ExamOccurrenceId, Natural)])
|
||||||
|
accF occ@(_, occSize) acc@(accSize, accOccs)
|
||||||
|
| accSize >= usersCount
|
||||||
|
= acc
|
||||||
|
| otherwise
|
||||||
|
= ( accSize + occSize
|
||||||
|
, accOccs ++ [occ]
|
||||||
|
)
|
||||||
|
|
||||||
|
largestOccurrence :: Num a => a
|
||||||
|
largestOccurrence = fromIntegral . maximum . mapNonNull (view _2) $ impureNonNull occurrences'
|
||||||
|
|
||||||
|
finenessCost :: Natural -> Natural
|
||||||
|
finenessCost x = round (finenessConst * largestOccurrence) * fromIntegral (length occurrences') * x * x
|
||||||
|
where
|
||||||
|
finenessConst :: Rational
|
||||||
|
-- ^ Cost (scaled to proportion of occurrence) of having higher fineness
|
||||||
|
finenessConst = 1 % 20 -- TODO: tweak
|
||||||
|
|
||||||
|
|
||||||
|
distribute :: forall wordId lineId cost.
|
||||||
|
( Num cost
|
||||||
|
, Ord wordId, Ord lineId
|
||||||
|
)
|
||||||
|
=> [(wordId, Natural)]
|
||||||
|
-> [(lineId, Natural)]
|
||||||
|
-> Maybe (cost, Map lineId (Set wordId))
|
||||||
|
-- ^ Distribute the given items (@wordId@s) with associated size in
|
||||||
|
-- contiguous blocks into the given buckets (@lineId@s) such that they are
|
||||||
|
-- filled as evenly as possible (proportionally)
|
||||||
|
--
|
||||||
|
-- Return a cost scaled to item-size squared
|
||||||
|
--
|
||||||
|
-- See <https://xxyxyz.org/line-breaking/> under \"Shortest Path\"
|
||||||
|
distribute wordLengths lineLengths
|
||||||
|
| null wordLengths = Just (0, Map.empty)
|
||||||
|
| null lineLengths = Nothing
|
||||||
|
| otherwise = Just distribute'
|
||||||
|
where
|
||||||
|
_longestLine :: cost
|
||||||
|
-- ^ For scaling costs
|
||||||
|
_longestLine = fromIntegral . maximum . mapNonNull (view _2) $ impureNonNull lineLengths
|
||||||
|
|
||||||
|
distribute' = error "not implemented" -- TODO: implement
|
||||||
|
|
||||||
|
|
||||||
|
options :: [(Natural, (Natural, Map ExamOccurrenceId (Set [CI Char])))]
|
||||||
|
options = do
|
||||||
|
fineness <- [0..maximumFineness]
|
||||||
|
let
|
||||||
|
packets :: [([CI Char], Natural)]
|
||||||
|
packets = Map.toAscList . fmap (fromIntegral . Set.size) $ usersGroups fineness
|
||||||
|
(resultCost, result) <- hoistMaybe $ distribute packets occurrences'
|
||||||
|
return (fineness, (resultCost, result))
|
||||||
|
bestOption :: Maybe (Map ExamOccurrenceId (Set [CI Char]))
|
||||||
|
bestOption = options
|
||||||
|
& takeWhile (\(fineness, (resCost, _)) -> finenessCost fineness < resCost)
|
||||||
|
& map (view $ _2 . _2)
|
||||||
|
& fmap last . fromNullable
|
||||||
|
|
||||||
|
postprocess :: Map ExamOccurrenceId (Set [CI Char])
|
||||||
|
-> ( [(ExamOccurrenceId, [CI Char])]
|
||||||
|
, Map UserId (Maybe ExamOccurrenceId)
|
||||||
|
)
|
||||||
|
postprocess result = (resultAscList, resultUsers)
|
||||||
|
where
|
||||||
|
resultAscList = sortOn (view _2) . map (over _2 Set.findMax) $ Map.toList result
|
||||||
|
resultUsers = Map.fromList $ do
|
||||||
|
(occId, buckets) <- Map.toList result
|
||||||
|
user <- Set.toList $ foldMap (flip (Map.findWithDefault Set.empty) users') buckets
|
||||||
|
return (user, Just occId)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ module Model.Types.Exam
|
|||||||
, _examResult
|
, _examResult
|
||||||
, ExamBonusRule(..)
|
, ExamBonusRule(..)
|
||||||
, ExamOccurrenceRule(..)
|
, ExamOccurrenceRule(..)
|
||||||
|
, ExamOccurrenceMapping(..)
|
||||||
, ExamGrade(..)
|
, ExamGrade(..)
|
||||||
, numberGrade
|
, numberGrade
|
||||||
, ExamGradeDefCenter(..)
|
, ExamGradeDefCenter(..)
|
||||||
@ -152,6 +153,18 @@ deriveJSON defaultOptions
|
|||||||
} ''ExamOccurrenceRule
|
} ''ExamOccurrenceRule
|
||||||
derivePersistFieldJSON ''ExamOccurrenceRule
|
derivePersistFieldJSON ''ExamOccurrenceRule
|
||||||
|
|
||||||
|
data ExamOccurrenceMapping roomId = ExamOccurrenceMapping
|
||||||
|
{ examOccurrenceMappingRule :: ExamOccurrenceRule
|
||||||
|
, examOccurrenceMappingMapping :: [(roomId, [CI Char])]
|
||||||
|
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ fieldLabelModifier = camelToPathPiece' 3
|
||||||
|
, constructorTagModifier = camelToPathPiece' 1
|
||||||
|
, tagSingleConstructors = False
|
||||||
|
} ''ExamOccurrenceMapping
|
||||||
|
derivePersistFieldJSON ''ExamOccurrenceMapping
|
||||||
|
|
||||||
|
|
||||||
data ExamGrade
|
data ExamGrade
|
||||||
= Grade50
|
= Grade50
|
||||||
| Grade40
|
| Grade40
|
||||||
|
|||||||
Reference in New Issue
Block a user