From 4d4da5dc14c0e67f748c023c0e88b3689dbd00af Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 14:05:41 +0200 Subject: [PATCH] feat: score single, multiple and self-check answers with configurable partial credit and penalty --- classes/scoring.go | 174 ++++++++++++++++++++++++++++++++++++++++ classes/scoring_test.go | 132 ++++++++++++++++++++++++++++++ config/config.yaml | 11 ++- 3 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 classes/scoring.go create mode 100644 classes/scoring_test.go diff --git a/classes/scoring.go b/classes/scoring.go new file mode 100644 index 0000000..7fab7c0 --- /dev/null +++ b/classes/scoring.go @@ -0,0 +1,174 @@ +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 +} diff --git a/classes/scoring_test.go b/classes/scoring_test.go new file mode 100644 index 0000000..deb67cd --- /dev/null +++ b/classes/scoring_test.go @@ -0,0 +1,132 @@ +package classes_test + +import ( + "os" + "path/filepath" + "testing" + + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/sm-quizzes-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" +) + +func boolPtr(b bool) *bool { return &b } + +func TestScore(t *testing.T) { + none := classes.ScoringConfig{} + partial := classes.ScoringConfig{MultiplePartial: true} + penalty := classes.ScoringConfig{PenaltyWrong: 0.25} + both := classes.ScoringConfig{MultiplePartial: true, PenaltyWrong: 0.5} + + single := func(chosen ...uint) classes.ScoreInput { + return classes.ScoreInput{Type: models.TypeSingle, Points: 2, CorrectIDs: []uint{10}, ChosenIDs: chosen} + } + multi := func(chosen ...uint) classes.ScoreInput { + return classes.ScoreInput{Type: models.TypeMultiple, Points: 4, CorrectIDs: []uint{1, 2, 3, 4}, ChosenIDs: chosen} + } + self := func(known *bool) classes.ScoreInput { + return classes.ScoreInput{Type: models.TypeSelfCheck, Points: 3, Known: known} + } + + cases := []struct { + name string + in classes.ScoreInput + cfg classes.ScoringConfig + correct bool + points float64 + }{ + {"single right", single(10), none, true, 2}, + {"single wrong", single(11), none, false, 0}, + {"single two chosen", single(10, 11), none, false, 0}, + {"single duplicate id counts once", single(10, 10), none, true, 2}, + {"single empty", single(), none, false, 0}, + {"single wrong with penalty", single(11), penalty, false, -0.5}, + {"single empty never penalised", single(), penalty, false, 0}, + + {"multiple exact", multi(4, 3, 2, 1), none, true, 4}, + {"multiple subset without partial", multi(1, 2), none, false, 0}, + {"multiple subset with partial", multi(1, 2), partial, false, 2}, + {"multiple partial minus wrong", multi(1, 2, 3, 9), partial, false, 2}, + {"multiple partial floored at 0", multi(1, 8, 9), partial, false, 0}, + {"multiple superset with partial", multi(1, 2, 3, 4, 9), partial, false, 3}, + {"multiple wrong with penalty", multi(9), penalty, false, -1}, + {"multiple partial earns, no penalty", multi(1), both, false, 1}, + {"multiple partial nothing, penalty", multi(9), both, false, -2}, + + {"self_check known", self(boolPtr(true)), none, true, 3}, + {"self_check unknown", self(boolPtr(false)), none, false, 0}, + {"self_check unknown never penalised", self(boolPtr(false)), penalty, false, 0}, + {"self_check unanswered", self(nil), none, false, 0}, + + {"unknown type", classes.ScoreInput{Type: "essay", Points: 5}, none, false, 0}, + {"negative points treated as 0", classes.ScoreInput{Type: models.TypeSingle, Points: -1, CorrectIDs: []uint{1}, ChosenIDs: []uint{1}}, none, true, 0}, + {"multiple without correct answers", classes.ScoreInput{Type: models.TypeMultiple, Points: 1, ChosenIDs: []uint{1}}, partial, false, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classes.Score(tc.in, tc.cfg) + if got.IsCorrect != tc.correct || got.Points != tc.points { + t.Errorf("Score = %+v, want correct=%v points=%v", got, tc.correct, tc.points) + } + }) + } +} + +func TestTotals(t *testing.T) { + cases := []struct { + points []float64 + max int + score, pct float64 + }{ + {[]float64{1, 1, 0}, 3, 2, 66.67}, + {[]float64{-1, -0.5}, 2, 0, 0}, + {[]float64{2, -0.5}, 4, 1.5, 37.5}, + {nil, 0, 0, 0}, + {[]float64{1}, 1, 1, 100}, + } + for _, tc := range cases { + score, pct := classes.Totals(tc.points, tc.max) + if score != tc.score || pct != tc.pct { + t.Errorf("Totals(%v, %d) = %v, %v; want %v, %v", tc.points, tc.max, score, pct, tc.score, tc.pct) + } + } +} + +func TestScoringConfigFrom(t *testing.T) { + if got := classes.ScoringConfigFrom(nil); got != (classes.ScoringConfig{}) { + t.Errorf("nil config = %+v, want defaults", got) + } + open := func(yaml string) *compass.Config { + t.Helper() + dir := t.TempDir() + if yaml != "" { + if err := os.WriteFile(filepath.Join(dir, "golem15.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + } + cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + return cfg + } + cfg := open("") + if got := classes.ScoringConfigFrom(cfg); got != (classes.ScoringConfig{}) { + t.Errorf("empty config = %+v, want defaults", got) + } + for _, tc := range []struct { + value any + want float64 + }{{0.25, 0.25}, {1, 1}, {"0.5", 0.5}, {3, 1}, {-2, 0}, {"x", 0}} { + if err := cfg.Set("golem15.quizzes.scoring.penalty_wrong", tc.value); err != nil { + t.Fatal(err) + } + if err := cfg.Set("golem15.quizzes.scoring.multiple_partial", true); err != nil { + t.Fatal(err) + } + got := classes.ScoringConfigFrom(cfg) + if !got.MultiplePartial || got.PenaltyWrong != tc.want { + t.Errorf("penalty_wrong %v: %+v, want partial and %v", tc.value, got, tc.want) + } + } +} diff --git a/config/config.yaml b/config/config.yaml index 0967ef4..340e4bf 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1 +1,10 @@ -{} +# Defaults for golem15.quizzes, merged under the plugin ID: the application +# reads these as golem15.quizzes.scoring.* and can override them in its own +# config. +scoring: + # Give partial credit on multiple-answer questions: + # (correct chosen - wrong chosen) / correct answers, floored at 0. + multiple_partial: false + # Fraction of a question's points subtracted for a wrong answer (0 to 1). + # An attempt's total never goes below 0. + penalty_wrong: 0