using QuestionsApi.Models; namespace QuestionsApi.Services; public static class MasteryCalculator { private static readonly IReadOnlyDictionary DifficultyWeights = new Dictionary { [1] = 0.6, [2] = 0.8, [3] = 1.0, [4] = 1.2, [5] = 1.4 }; public static List Calculate( IEnumerable attempts, IEnumerable reviews, IEnumerable queueItems, IReadOnlyDictionary questions, DateTime nowUtc) { var mappedAttempts = attempts.Where(attempt => questions.ContainsKey(attempt.QuestionExternalId)).ToList(); var answered = mappedAttempts.Where(attempt => !attempt.IsSkip).ToList(); var maxCycle = mappedAttempts.Count == 0 ? 0 : mappedAttempts.Max(attempt => attempt.BankCycle); var reviewList = reviews.OrderBy(review => review.CreatedUtc).ToList(); var queueList = queueItems.ToList(); var results = new List(); foreach (var system in Step3SystemCatalog.All) { var systemAttempts = answered.Where(attempt => Step3SystemCatalog.Normalize(questions[attempt.QuestionExternalId]) == system).ToList(); var ids = systemAttempts.Select(attempt => attempt.QuestionExternalId).Distinct(StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal); var systemReviews = reviewList.Where(review => ids.Contains(review.QuestionExternalId)).ToList(); var pending = queueList.Count(item => item.State == ReviewQueueStates.Pending && questions.TryGetValue(item.QuestionExternalId, out var question) && Step3SystemCatalog.Normalize(question) == system); double earned = 0; double possible = 0; var advancedSuccesses = new HashSet(StringComparer.Ordinal); foreach (var attempt in systemAttempts) { var question = questions[attempt.QuestionExternalId]; var weight = DifficultyWeights.GetValueOrDefault(Math.Clamp(question.Difficulty, 1, 5), 1.0) * Math.Pow(0.5, Math.Max(0, maxCycle - attempt.BankCycle)); var outcome = attempt.IsCorrect ? 1.0 : RecoveryOutcome(attempt, systemAttempts, systemReviews); earned += weight * outcome; possible += weight; if (question.Difficulty >= 4 && outcome >= 0.75) advancedSuccesses.Add(question.ExternalId); } var score = possible == 0 ? 0 : (int)Math.Round(earned / possible * 100, MidpointRounding.AwayFromZero); var state = State(ids.Count, score, advancedSuccesses.Count, pending); var lastEvidence = systemAttempts.Select(value => (DateTime?)value.CreatedUtc) .Concat(systemReviews.Select(value => (DateTime?)value.CreatedUtc)).Max(); var refreshDue = state is MasteryStates.Strong or MasteryStates.Mastered && lastEvidence.HasValue && lastEvidence.Value < nowUtc.AddDays(-30); results.Add(new DashboardSystemPerformance { System = system, Correct = systemAttempts.Count(value => value.IsCorrect), Total = systemAttempts.Count, Accuracy = DashboardStore.Percentage(systemAttempts.Count(value => value.IsCorrect), systemAttempts.Count), MasteryScore = score, MasteryState = state, DistinctQuestions = ids.Count, Difficulty4PlusSuccessful = advancedSuccesses.Count, PendingReviews = pending, LastEvidenceUtc = lastEvidence, RefreshDue = refreshDue, RecommendedAction = Action(state, pending, refreshDue) }); } return results; } private static double RecoveryOutcome( QuestionAttempt attempt, IReadOnlyCollection attempts, IReadOnlyCollection reviews) { var nextAttempt = attempts.Where(value => value.QuestionExternalId == attempt.QuestionExternalId && value.CreatedUtc > attempt.CreatedUtc) .OrderBy(value => value.CreatedUtc).FirstOrDefault(); var recovery = reviews.Where(value => value.QuestionExternalId == attempt.QuestionExternalId && value.IsCorrect && value.CreatedUtc > attempt.CreatedUtc && (nextAttempt is null || value.CreatedUtc < nextAttempt.CreatedUtc)) .OrderBy(value => value.CreatedUtc).FirstOrDefault(); if (recovery is null) return 0; return recovery.CreatedUtc - attempt.CreatedUtc >= TimeSpan.FromHours(24) ? 0.75 : 0.5; } private static string State(int distinct, int score, int advanced, int pending) { if (distinct == 0) return MasteryStates.Unseen; if (distinct < 5) return MasteryStates.Learning; if (distinct >= 25 && score >= 85 && advanced >= 5 && pending == 0) return MasteryStates.Mastered; if (distinct >= 15 && score >= 75) return MasteryStates.Strong; return MasteryStates.Practicing; } private static DashboardMasteryAction Action(string state, int pending, bool refreshDue) { if (pending > 0) return new() { Type = "review", Label = "Review missed concepts", QuestionCount = pending }; return state switch { MasteryStates.Unseen or MasteryStates.Learning => new() { Type = "practice", Label = "Start learning", QuestionCount = 10 }, MasteryStates.Practicing => new() { Type = "practice", Label = "Build mastery", QuestionCount = 10, DifficultyMin = 3, DifficultyMax = 4 }, MasteryStates.Strong => new() { Type = "practice", Label = "Challenge this system", QuestionCount = 10, DifficultyMin = 4, DifficultyMax = 5 }, MasteryStates.Mastered when refreshDue => new() { Type = "practice", Label = "Refresh mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 }, _ => new() { Type = "practice", Label = "Maintain mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 } }; } }