Adapt dynamic question difficulty to learner Elo

This commit is contained in:
hz
2026-07-23 16:18:44 -05:00
parent 5b5741de37
commit fae38d2ded
13 changed files with 519 additions and 33 deletions
@@ -80,6 +80,71 @@ public sealed class DynamicQuestionTests
Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id));
}
[Fact]
public void DifficultyPolicy_BlendsThenSpecializes_AndTargetsSixtyPercent()
{
Assert.Equal(1200, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, null));
var early = new DynamicObjectiveMastery { Elo = 1500, Attempts = 1 };
var established = new DynamicObjectiveMastery { Elo = 1500, Attempts = 10 };
Assert.Equal(1224, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, early));
Assert.Equal(1440, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, established));
Assert.Equal(1370, DynamicDifficultyPolicy.TargetFamilyElo(1440));
}
[Fact]
public void DifficultyPolicy_SelectsClosestNonrecentFamily()
{
var families = new List<DynamicCaseFamily>
{
new() { Id = "foundation", DifficultyTier = "foundation", NominalElo = 850 },
new() { Id = "standard", DifficultyTier = "standard", NominalElo = 1050 },
new() { Id = "advanced-a", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "advanced-b", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "expert", DifficultyTier = "expert", NominalElo = 1450 }
};
var selected = DynamicDifficultyPolicy.SelectFamily(
families, new Dictionary<string, DynamicFamilyRating>(), 1260,
new HashSet<string> { "advanced-a" }, 0);
Assert.Equal("advanced-b", selected.Id);
}
[Fact]
public void DifficultyPolicy_RatingsMoveInOppositeDirections()
{
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, true, 0) > 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, true, 0) < 0);
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, false, 0) < 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, false, 0) > 0);
Assert.Equal(600, DynamicDifficultyPolicy.Clamp(-100));
Assert.Equal(1800, DynamicDifficultyPolicy.Clamp(5000));
}
[Fact]
public void AdaptiveContent_ResolvesToFourCompleteTiers()
{
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true };
var root = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
var source = Directory.EnumerateFiles(root, "*.json")
.SelectMany(file => System.Text.Json.JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
File.ReadAllText(file), options) ?? [])
.ToList();
var adaptive = DynamicBlueprintResolver.ResolveAll(source)
.Where(value => value.QualityStandardVersion >= 3).ToList();
Assert.Equal(5, adaptive.Count);
Assert.All(adaptive, blueprint =>
{
Assert.Equal(8, blueprint.CaseFamilies.Count);
Assert.All(DynamicDifficultyPolicy.TierElos.Keys, tier =>
Assert.Equal(2, blueprint.CaseFamilies.Count(value => value.DifficultyTier == tier)));
Assert.All(blueprint.CaseFamilies, family =>
Assert.False(family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal)));
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
});
}
private static DynamicQuestionBlueprint Blueprint() => new()
{
ExternalId = "test",