feat(allocations): fingerprints & ordinal ratings

This commit is contained in:
Gregor Kleen 2019-10-04 16:37:11 +02:00
parent 9f61452d0d
commit 60603cb6ec
8 changed files with 100 additions and 23 deletions

View File

@ -136,6 +136,9 @@ user-defaults:
# This encodes the weight of the lecturer ratings on the same scale as the # This encodes the weight of the lecturer ratings on the same scale as the
# centrally supplied priorities. # centrally supplied priorities.
allocation-grade-scale: 25 allocation-grade-scale: 25
# This encodes how many ordinal places lecturer ratings may move students up or
# down when central priorities are supplied as ordered list.
allocation-grade-ordinal-places: 3
instance-id: "_env:INSTANCE_ID:instance" instance-id: "_env:INSTANCE_ID:instance"
ribbon: "_env:RIBBON:" ribbon: "_env:RIBBON:"

View File

@ -21,6 +21,7 @@ Allocation -- attributes with prefix staff- affect lecturers only, but are invis
registerByCourse UTCTime Maybe -- course registration dates are ignored until this day has passed or always prohibited registerByCourse UTCTime Maybe -- course registration dates are ignored until this day has passed or always prohibited
overrideDeregister UTCTime Maybe -- course deregistration enforced to be this date, i.e. students may disenrol from course after or never overrideDeregister UTCTime Maybe -- course deregistration enforced to be this date, i.e. students may disenrol from course after or never
-- overrideVisible not needed, since courses are always visible -- overrideVisible not needed, since courses are always visible
fingerprint AllocationFingerprint Maybe
matchingLog FileId Maybe matchingLog FileId Maybe
TermSchoolAllocationShort term school shorthand -- shorthand must be unique within school and semester TermSchoolAllocationShort term school shorthand -- shorthand must be unique within school and semester
TermSchoolAllocationName term school name -- name must be unique within school and semester TermSchoolAllocationName term school name -- name must be unique within school and semester

View File

@ -1,8 +1,12 @@
module Handler.Utils.Allocation module Handler.Utils.Allocation
( sinkAllocationPriorities ( allocationDone
, ordinalPriorities
, sinkAllocationPriorities
, computeAllocation , computeAllocation
, storeAllocationFingerprint
, doAllocation , doAllocation
, ppMatchingLog, storeMatchingLog , ppMatchingLog, storeMatchingLog
, storeAllocationResult
) where ) where
import Import import Import
@ -12,12 +16,15 @@ import qualified Data.Map.Strict as Map
import qualified Database.Esqueleto as E import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E import qualified Database.Esqueleto.Utils as E
import Control.Monad.Trans.State.Strict (evalStateT)
import qualified Control.Monad.State.Class as State (get, modify')
import Data.List (genericLength, elemIndex) import Data.List (genericLength, elemIndex)
import qualified Data.Vector as Vector import qualified Data.Vector as Vector
import Data.Vector.Lens (vector) import Data.Vector.Lens (vector)
import qualified Data.Set as Set import qualified Data.Set as Set
import System.Random (newStdGen) import System.Random (mkStdGen)
import Utils.Allocation import Utils.Allocation
@ -25,6 +32,20 @@ import qualified Data.Conduit.List as C
import Data.Generics.Product.Param import Data.Generics.Product.Param
import qualified Crypto.Hash as Crypto
import qualified Data.Binary as Binary
import qualified Data.ByteArray as BA (convert)
allocationDone :: AllocationId -> DB (Maybe UTCTime)
allocationDone allocId = fmap (E.unValue <=< listToMaybe) . E.select . E.from $ \participant -> do
E.where_ $ participant E.^. CourseParticipantAllocated E.==. E.just (E.val allocId)
return . E.max_ $ participant E.^. CourseParticipantRegistration
ordinalPriorities :: Monad m => ConduitT UserMatriculation (Map UserMatriculation AllocationPriority) m ()
ordinalPriorities = transPipe (flip evalStateT 0) . C.mapM $ \matr -> singletonMap matr <$> (AllocationPriorityOrdinal <$> State.get <* State.modify' succ)
sinkAllocationPriorities :: AllocationId sinkAllocationPriorities :: AllocationId
-> ConduitT (Map UserMatriculation AllocationPriority) Void DB () -> ConduitT (Map UserMatriculation AllocationPriority) Void DB ()
@ -38,10 +59,19 @@ sinkAllocationPriorities allocId = C.mapM_ . imapM_ $ \matr prio ->
computeAllocation :: AllocationId computeAllocation :: AllocationId
-> DB (Set (UserId, CourseId), Seq (MatchingLog UserId CourseId Natural)) -> DB (AllocationFingerprint, Set (UserId, CourseId), Seq (MatchingLog UserId CourseId Natural))
computeAllocation allocId = do computeAllocation allocId = do
users' <- selectList [ AllocationUserAllocation ==. allocId ] [] users' <- selectList [ AllocationUserAllocation ==. allocId ] []
let cloneCounts = Map.filter (> 0) . Map.fromList $ (allocationUserUser . entityVal &&& allocationUserTotalCourses . entityVal) <$> users' let users'' = users'
& mapMaybe ( runMaybeT $ (,) <$> lift (allocationUserUser . entityVal)
<*> ( (,) <$> lift (allocationUserTotalCourses . entityVal)
<*> MaybeT (allocationUserPriority . entityVal)
)
)
& Map.fromList
& Map.filter ((> 0) . view _1)
cloneCounts = Map.map (view _1) users''
allocationPrio = view _2 . (Map.!) users''
courses' <- E.select . E.from $ \(allocationCourse `E.InnerJoin` course) -> do courses' <- E.select . E.from $ \(allocationCourse `E.InnerJoin` course) -> do
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
@ -67,27 +97,35 @@ computeAllocation allocId = do
return ((courseApplicationUser, courseApplicationCourse), (courseApplicationAllocationPriority, courseApplicationRatingPoints)) return ((courseApplicationUser, courseApplicationCourse), (courseApplicationAllocationPriority, courseApplicationRatingPoints))
gradeScale <- getsYesod $ view _appAllocationGradeScale gradeScale <- getsYesod $ view _appAllocationGradeScale
let centralNudge user (fromIntegral -> cloneIndex) grade gradeOrdinalPlaces <- getsYesod $ view _appAllocationGradeOrdinalPlaces
| Just AllocationPriorityNumeric{..} <- allocationPrio let centralNudge user cloneIndex grade = case allocationPrio user of
= let allocationPriorities' = under vector (sortOn Down) allocationPriorities AllocationPriorityNumeric{..}
minPrio | Vector.null allocationPriorities' = 0 -> let allocationPriorities' = under vector (sortOn Down) allocationPriorities
| otherwise = Vector.last allocationPriorities' minPrio | Vector.null allocationPriorities' = 0
in withNumericGrade . fromMaybe minPrio $ allocationPriorities Vector.!? cloneIndex | otherwise = Vector.last allocationPriorities'
| otherwise in AllocationPriorityComparisonNumeric . withNumericGrade . fromInteger . fromMaybe minPrio $ allocationPriorities Vector.!? fromIntegral cloneIndex
= withNumericGrade 0 AllocationPriorityOrdinal{..}
| gradeOrdinalPlaces > 0
-> let allocationOrdinal' = gradeScale / fromIntegral gradeOrdinalPlaces * fromIntegral allocationOrdinal
in AllocationPriorityComparisonOrdinal (Down cloneIndex) $ withNumericGrade allocationOrdinal'
AllocationPriorityOrdinal{..}
-> AllocationPriorityComparisonOrdinal (Down cloneIndex) $ fromIntegral allocationOrdinal
where where
allocationPrio = allocationUserPriority . entityVal =<< listToMaybe (filter ((== user) . allocationUserUser . entityVal) users') withNumericGrade :: Rational -> Rational
withNumericGrade withNumericGrade
| Just grade' <- grade | Just grade' <- grade
= let numberGrade' = fromMaybe (error "non-passing grade") (fromIntegral <$> elemIndex grade' passingGrades) / pred (genericLength passingGrades) = let numberGrade' = fromMaybe (error "non-passing grade") (fromIntegral <$> elemIndex grade' passingGrades) / pred (genericLength passingGrades)
passingGrades = sort $ filter (view $ passingGrade . _Wrapped) universeF passingGrades = sort $ filter (view $ passingGrade . _Wrapped) universeF
numericGrade = -gradeScale + numberGrade' * 2 * gradeScale numericGrade = -gradeScale + numberGrade' * 2 * gradeScale
in (+) numericGrade . fromInteger in (+) numericGrade
| otherwise | otherwise
= fromInteger = id
g <- liftIO newStdGen let
fingerprint :: AllocationFingerprint
fingerprint = Crypto.hash . toStrict $ Binary.encode (users'', capacities, preferences, gradeScale, gradeOrdinalPlaces)
g = mkStdGen $ hash (BA.convert fingerprint :: ByteString)
let let
doAllocationWithout cs = runWriter $ computeMatchingLog g cloneCounts capacities' preferences' centralNudge doAllocationWithout cs = runWriter $ computeMatchingLog g cloneCounts capacities' preferences' centralNudge
@ -103,7 +141,13 @@ computeAllocation allocId = do
belowMin = catMaybes . flip map courses' $ \(Entity _ AllocationCourse{..}, _, E.Value minCap) -> belowMin = catMaybes . flip map courses' $ \(Entity _ AllocationCourse{..}, _, E.Value minCap) ->
guardOn (Set.size (Set.filter (\(_, c) -> c == allocationCourseCourse) allocs) < minCap) allocationCourseCourse guardOn (Set.size (Set.filter (\(_, c) -> c == allocationCourseCourse) allocs) < minCap) allocationCourseCourse
return $!! allocationLoop Set.empty return . (\(ms, mLog) -> (fingerprint, ms, mLog)) $!! allocationLoop Set.empty
storeAllocationFingerprint :: AllocationId
-> AllocationFingerprint
-> DB ()
storeAllocationFingerprint allocId fp = update allocId [ AllocationFingerprint =. Just fp ]
doAllocation :: AllocationId doAllocation :: AllocationId
-> Set (UserId, CourseId) -> Set (UserId, CourseId)
@ -131,3 +175,12 @@ storeMatchingLog allocationId (ppMatchingLog -> matchingLog) = do
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
fId <- insert $ File "matchings.log" (Just $ encodeUtf8 matchingLog) now fId <- insert $ File "matchings.log" (Just $ encodeUtf8 matchingLog) now
update allocationId [ AllocationMatchingLog =. Just fId ] update allocationId [ AllocationMatchingLog =. Just fId ]
storeAllocationResult :: AllocationId
-> (AllocationFingerprint, Set (UserId, CourseId), Seq (MatchingLog UserId CourseId Natural))
-> DB ()
storeAllocationResult allocId (allocFp, allocMatchings, allocLog) = do
storeAllocationFingerprint allocId allocFp
doAllocation allocId allocMatchings
storeMatchingLog allocId allocLog

View File

@ -15,6 +15,7 @@ import Data.Time.Zones
import Data.Time.Clock.POSIX import Data.Time.Clock.POSIX
import Handler.Utils.DateTime import Handler.Utils.DateTime
import Handler.Utils.Allocation (allocationDone)
import Control.Monad.Trans.Writer (WriterT, execWriterT) import Control.Monad.Trans.Writer (WriterT, execWriterT)
import Control.Monad.Writer.Class (MonadWriter(..)) import Control.Monad.Writer.Class (MonadWriter(..))
@ -336,14 +337,12 @@ determineCrontab = execWriterT $ do
} }
_other _other
-> return () -> return ()
lastResult <- fmap (E.unValue <=< listToMaybe) . lift . E.select . E.from $ \participant -> do doneSince <- lift $ allocationDone nAllocation
E.where_ $ participant E.^. CourseParticipantAllocated E.==. E.just (E.val nAllocation) whenIsJust doneSince $ \doneSince' ->
return . E.max_ $ participant E.^. CourseParticipantRegistration
whenIsJust lastResult $ \lastResult' ->
tell $ HashMap.singleton tell $ HashMap.singleton
(JobCtlQueue $ JobQueueNotification NotificationAllocationResults{..}) (JobCtlQueue $ JobQueueNotification NotificationAllocationResults{..})
Cron Cron
{ cronInitial = CronTimestamp . utcToLocalTimeTZ appTZ $ addUTCTime appNotificationCollateDelay lastResult' { cronInitial = CronTimestamp . utcToLocalTimeTZ appTZ $ addUTCTime appNotificationCollateDelay doneSince'
, cronRepeat = CronRepeatNever , cronRepeat = CronRepeatNever
, cronRateLimit = appNotificationRateLimit , cronRateLimit = appNotificationRateLimit
, cronNotAfter = Left appNotificationExpiration , cronNotAfter = Left appNotificationExpiration

View File

@ -1,5 +1,7 @@
module Model.Types.Allocation module Model.Types.Allocation
( AllocationPriority(..) ( AllocationPriority(..)
, AllocationPriorityComparison(..)
, AllocationFingerprint
, module Utils.Allocation , module Utils.Allocation
) where ) where
@ -12,11 +14,14 @@ import qualified Data.Vector as Vector
import qualified Data.Map.Strict as Map import qualified Data.Map.Strict as Map
import Crypto.Hash (Digest, SHAKE128)
{-# ANN module ("HLint: ignore Use newtype instead of data"::String) #-} {-# ANN module ("HLint: ignore Use newtype instead of data"::String) #-}
data AllocationPriority data AllocationPriority
= AllocationPriorityNumeric { allocationPriorities :: Vector Integer } = AllocationPriorityNumeric { allocationPriorities :: Vector Integer }
| AllocationPriorityOrdinal { allocationOrdinal :: Natural }
deriving (Eq, Ord, Read, Show, Generic, Typeable) deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 1 { fieldLabelModifier = camelToPathPiece' 1
@ -28,9 +33,20 @@ deriveJSON defaultOptions
} ''AllocationPriority } ''AllocationPriority
derivePersistFieldJSON ''AllocationPriority derivePersistFieldJSON ''AllocationPriority
instance Binary AllocationPriority
instance Csv.FromRecord (Map UserMatriculation AllocationPriority) where instance Csv.FromRecord (Map UserMatriculation AllocationPriority) where
parseRecord v = parseNumeric parseRecord v = parseNumeric
where where
parseNumeric parseNumeric
| Vector.length v >= 1 = Map.singleton <$> v Csv..! 0 <*> (AllocationPriorityNumeric <$> mapM Csv.parseField (Vector.tail v)) | Vector.length v >= 1 = Map.singleton <$> v Csv..! 0 <*> (AllocationPriorityNumeric <$> mapM Csv.parseField (Vector.tail v))
| otherwise = mzero | otherwise = mzero
data AllocationPriorityComparison
= AllocationPriorityComparisonNumeric { allocationGradeScale :: Rational }
| AllocationPriorityComparisonOrdinal { allocationCloneIndex :: Down Natural, allocationOrdinalScale :: Rational }
deriving (Eq, Ord, Read, Show, Generic, Typeable)
type AllocationFingerprint = Digest (SHAKE128 128)

View File

@ -218,6 +218,8 @@ instance PersistField ExamGrade where
instance PersistFieldSql ExamGrade where instance PersistFieldSql ExamGrade where
sqlType _ = SqlNumeric 2 1 sqlType _ = SqlNumeric 2 1
instance Binary ExamGrade
newtype ExamGradeDefCenter = ExamGradeDefCenter { examGradeDefCenter :: Maybe ExamGrade } newtype ExamGradeDefCenter = ExamGradeDefCenter { examGradeDefCenter :: Maybe ExamGrade }
deriving (Eq, Read, Show, Generic, Typeable) deriving (Eq, Read, Show, Generic, Typeable)

View File

@ -131,6 +131,7 @@ data AppSettings = AppSettings
, appTransactionLogIPRetentionTime :: NominalDiffTime , appTransactionLogIPRetentionTime :: NominalDiffTime
, appAllocationGradeScale :: Rational , appAllocationGradeScale :: Rational
, appAllocationGradeOrdinalPlaces :: Natural
, appReloadTemplates :: Bool , appReloadTemplates :: Bool
-- ^ Use the reload version of templates -- ^ Use the reload version of templates
@ -429,6 +430,7 @@ instance FromJSON AppSettings where
appTransactionLogIPRetentionTime <- o .: "ip-retention-time" appTransactionLogIPRetentionTime <- o .: "ip-retention-time"
appAllocationGradeScale <- o .: "allocation-grade-scale" <|> fmap toRational (o .: "allocation-grade-scale" :: Aeson.Parser Scientific) appAllocationGradeScale <- o .: "allocation-grade-scale" <|> fmap toRational (o .: "allocation-grade-scale" :: Aeson.Parser Scientific)
appAllocationGradeOrdinalPlaces <- o .: "allocation-grade-ordinal-places"
appUserDefaults <- o .: "user-defaults" appUserDefaults <- o .: "user-defaults"
appAuthPWHash <- o .: "auth-pw-hash" appAuthPWHash <- o .: "auth-pw-hash"

View File

@ -785,6 +785,7 @@ fillDb = do
, allocationRegisterByStaffTo = Nothing , allocationRegisterByStaffTo = Nothing
, allocationRegisterByCourse = Nothing , allocationRegisterByCourse = Nothing
, allocationOverrideDeregister = Just now , allocationOverrideDeregister = Just now
, allocationFingerprint = Nothing
, allocationMatchingLog = Nothing , allocationMatchingLog = Nothing
} }
insert_ $ AllocationCourse funAlloc pmo 100 insert_ $ AllocationCourse funAlloc pmo 100