Files
step3/api/QuestionsApi/Services/MasteryCalculator.cs
T

110 lines
6.1 KiB
C#

using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class MasteryCalculator
{
private static readonly IReadOnlyDictionary<int, double> DifficultyWeights = new Dictionary<int, double>
{
[1] = 0.6, [2] = 0.8, [3] = 1.0, [4] = 1.2, [5] = 1.4
};
public static List<DashboardSystemPerformance> Calculate(
IEnumerable<QuestionAttempt> attempts,
IEnumerable<QuestionReviewAttempt> reviews,
IEnumerable<ReviewQueueItem> queueItems,
IReadOnlyDictionary<string, Question> 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<DashboardSystemPerformance>();
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<string>(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<QuestionAttempt> attempts,
IReadOnlyCollection<QuestionReviewAttempt> 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 }
};
}
}