Adapt dynamic question difficulty to learner Elo
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class DynamicDifficultyPolicy
|
||||
{
|
||||
public const int MinimumElo = 600;
|
||||
public const int MaximumElo = 1800;
|
||||
public const int TargetOffset = 70;
|
||||
public static readonly IReadOnlyDictionary<string, int> TierElos =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["foundation"] = 850,
|
||||
["standard"] = 1050,
|
||||
["advanced"] = 1250,
|
||||
["expert"] = 1450
|
||||
};
|
||||
|
||||
public static int EffectiveAbility(
|
||||
int overallElo,
|
||||
int categoryElo,
|
||||
DynamicObjectiveMastery? objective)
|
||||
{
|
||||
var bankAbility = (int)Math.Round((overallElo + categoryElo) / 2.0);
|
||||
if (objective is null || objective.Attempts <= 0)
|
||||
return Clamp(bankAbility);
|
||||
var objectiveWeight = Math.Min(0.8, objective.Attempts * 0.08);
|
||||
return Clamp((int)Math.Round(bankAbility * (1.0 - objectiveWeight) + objective.Elo * objectiveWeight));
|
||||
}
|
||||
|
||||
public static int TargetFamilyElo(int effectiveAbility) => Clamp(effectiveAbility - TargetOffset);
|
||||
|
||||
public static double ExpectedScore(int playerElo, int familyElo) =>
|
||||
1.0 / (1.0 + Math.Pow(10, (familyElo - playerElo) / 400.0));
|
||||
|
||||
public static int LearnerDelta(int playerElo, int familyElo, bool correct, int attempts) =>
|
||||
(int)Math.Round((attempts < 5 ? 40 : 24)
|
||||
* ((correct ? 1.0 : 0.0) - ExpectedScore(playerElo, familyElo)));
|
||||
|
||||
public static int FamilyDelta(int playerElo, int familyElo, bool correct, int attempts) =>
|
||||
(int)Math.Round((attempts < 20 ? 32 : 24)
|
||||
* (ExpectedScore(playerElo, familyElo) - (correct ? 1.0 : 0.0)));
|
||||
|
||||
public static DynamicCaseFamily SelectFamily(
|
||||
IReadOnlyList<DynamicCaseFamily> families,
|
||||
IReadOnlyDictionary<string, DynamicFamilyRating> ratings,
|
||||
int targetElo,
|
||||
ISet<string> recentFamilyIds,
|
||||
int tieBreaker)
|
||||
{
|
||||
if (families.Count == 0) throw new InvalidOperationException("No case families are available.");
|
||||
var eligible = families.Where(value => !recentFamilyIds.Contains(value.Id)).ToList();
|
||||
if (eligible.Count == 0) eligible = families.ToList();
|
||||
var ranked = eligible
|
||||
.Select(value => new
|
||||
{
|
||||
Family = value,
|
||||
Elo = ratings.TryGetValue(value.Id, out var rating) ? rating.Elo : value.NominalElo
|
||||
})
|
||||
.OrderBy(value => Math.Abs(value.Elo - targetElo))
|
||||
.ThenBy(value => value.Family.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var bestDistance = Math.Abs(ranked[0].Elo - targetElo);
|
||||
var tied = ranked.Where(value => Math.Abs(value.Elo - targetElo) == bestDistance).ToList();
|
||||
return tied[Math.Abs(tieBreaker) % tied.Count].Family;
|
||||
}
|
||||
|
||||
public static int Clamp(int elo) => Math.Clamp(elo, MinimumElo, MaximumElo);
|
||||
}
|
||||
Reference in New Issue
Block a user