Files
sm-quizzes-plugin/classes/scoring.go

175 lines
4.7 KiB
Go

package classes
import (
"math"
"strconv"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
// ScoringConfig holds the golem15.quizzes.scoring.* settings.
type ScoringConfig struct {
// MultiplePartial gives partial credit on a multiple question: (correct
// chosen - wrong chosen) / correct answers, floored at 0.
MultiplePartial bool
// PenaltyWrong is the fraction of a question's points subtracted for a
// wrong answer, from 0 (no penalty) to 1.
PenaltyWrong float64
}
// ScoringConfigFrom reads golem15.quizzes.scoring.multiple_partial and
// golem15.quizzes.scoring.penalty_wrong. A missing config gives the defaults
// (no partial credit, no penalty); the penalty is clamped to [0, 1].
func ScoringConfigFrom(cfg *compass.Config) ScoringConfig {
if cfg == nil {
return ScoringConfig{}
}
out := ScoringConfig{MultiplePartial: cfg.Bool("golem15.quizzes.scoring.multiple_partial")}
if v, ok := cfg.Lookup("golem15.quizzes.scoring.penalty_wrong"); ok {
out.PenaltyWrong = toFloat(v)
}
out.PenaltyWrong = math.Min(math.Max(out.PenaltyWrong, 0), 1)
return out
}
func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case uint64:
return float64(n)
case string:
f, err := strconv.ParseFloat(n, 64)
if err == nil {
return f
}
}
return 0
}
// ScoreInput is one answered question, as the scorer sees it.
type ScoreInput struct {
// Type is models.TypeSingle, models.TypeMultiple or models.TypeSelfCheck.
Type string
// Points is the question's full value.
Points int
// CorrectIDs are the IDs of the question's correct answers.
CorrectIDs []uint
// ChosenIDs are the answer IDs the user chose (single and multiple).
ChosenIDs []uint
// Known is the user's own mark on a self_check question.
Known *bool
}
// ScoreResult is the outcome of one question.
type ScoreResult struct {
// IsCorrect is true only for a fully correct answer.
IsCorrect bool
// Points earned; negative when a penalty applies. Totals are floored at
// 0 by Totals, never per question.
Points float64
}
// Score grades one answer. It is a pure function:
//
// - single: correct when exactly one answer is chosen and it is a correct
// one.
// - multiple: correct when the chosen set equals the correct set. With
// MultiplePartial a partly right answer earns (correct chosen - wrong
// chosen) / correct answers of the points, floored at 0.
// - self_check: correct when the user marked it as known.
//
// An answer that earns nothing and is not empty is wrong: with PenaltyWrong
// it loses that fraction of the question's points. An empty answer and a
// self_check marked as unknown are never penalised.
func Score(in ScoreInput, cfg ScoringConfig) ScoreResult {
full := float64(max(in.Points, 0))
switch in.Type {
case models.TypeSelfCheck:
if in.Known != nil && *in.Known {
return ScoreResult{IsCorrect: true, Points: full}
}
return ScoreResult{}
case models.TypeSingle, models.TypeMultiple:
default:
return ScoreResult{}
}
chosen := uniqueIDs(in.ChosenIDs)
if len(chosen) == 0 {
return ScoreResult{}
}
correct := make(map[uint]bool, len(in.CorrectIDs))
for _, id := range in.CorrectIDs {
correct[id] = true
}
right, wrong := 0, 0
for _, id := range chosen {
if correct[id] {
right++
} else {
wrong++
}
}
var res ScoreResult
switch in.Type {
case models.TypeSingle:
if len(chosen) == 1 && right == 1 {
res = ScoreResult{IsCorrect: true, Points: full}
}
case models.TypeMultiple:
switch {
case len(correct) > 0 && wrong == 0 && right == len(correct):
res = ScoreResult{IsCorrect: true, Points: full}
case cfg.MultiplePartial && len(correct) > 0:
res.Points = round(full * math.Max(float64(right-wrong), 0) / float64(len(correct)))
}
}
if !res.IsCorrect && res.Points == 0 && cfg.PenaltyWrong > 0 {
res.Points = round(-full * cfg.PenaltyWrong)
}
return res
}
// Totals sums the earned points of an attempt, floored at 0, and the
// percentage of maxPoints it is. With no points to earn the percentage is 0.
func Totals(points []float64, maxPoints int) (score, percent float64) {
for _, p := range points {
score += p
}
score = round(math.Max(score, 0))
if maxPoints <= 0 {
return score, 0
}
return score, round(score / float64(maxPoints) * 100)
}
// round keeps two decimals.
func round(f float64) float64 {
r := math.Round(f*100) / 100
if r == 0 {
return 0 // no negative zero in JSON
}
return r
}
func uniqueIDs(ids []uint) []uint {
seen := make(map[uint]bool, len(ids))
out := make([]uint, 0, len(ids))
for _, id := range ids {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}