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
@@ -8,15 +8,21 @@ public sealed class DynamicQuestionService
private readonly DynamicQuestionStore _store;
private readonly DynamicQuestionGenerator _generator;
private readonly DynamicQuestionTokenService _tokens;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
public DynamicQuestionService(
DynamicQuestionStore store,
DynamicQuestionGenerator generator,
DynamicQuestionTokenService tokens)
DynamicQuestionTokenService tokens,
RatingStore ratings,
ServiceMetrics metrics)
{
_store = store;
_generator = generator;
_tokens = tokens;
_ratings = ratings;
_metrics = metrics;
}
public Task<DynamicQuestionStatusResponse> StatusAsync(string userId) => _store.StatusAsync(userId);
@@ -53,6 +59,30 @@ public sealed class DynamicQuestionService
var blueprint = selectedObjective
.OrderByDescending(value => value.Version)
.First();
var ratingProfile = await _ratings.GetProfileAsync(userId);
mastery.TryGetValue(blueprint.ObjectiveId, out var objectiveMastery);
var category = RatingCategory.Normalize(
blueprint.PerformanceCategory, blueprint.System, blueprint.Discipline);
var categoryElo = ratingProfile.CategoryElos.GetValueOrDefault(category, ratingProfile.OverallElo);
var effectiveAbility = DynamicDifficultyPolicy.EffectiveAbility(
ratingProfile.OverallElo, categoryElo, objectiveMastery);
var targetElo = DynamicDifficultyPolicy.TargetFamilyElo(effectiveAbility);
var familyRatings = await _store.FamilyRatingsAsync(blueprint);
var blueprintFamilyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(blueprintFamilyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count / 4))
.ToHashSet(StringComparer.Ordinal);
var selectedFamily = DynamicDifficultyPolicy.SelectFamily(
blueprint.CaseFamilies,
familyRatings,
targetElo,
recentForObjective,
RandomNumberGenerator.GetInt32(int.MaxValue));
var selectedFamilyElo = familyRatings.TryGetValue(selectedFamily.Id, out var selectedRating)
? selectedRating.Elo
: selectedFamily.NominalElo;
_metrics.DynamicSelected(selectedFamily.DifficultyTier, selectedFamilyElo - targetElo);
var claims = new DynamicGenerationClaims
{
@@ -63,20 +93,14 @@ public sealed class DynamicQuestionService
Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
Nonce = Guid.NewGuid().ToString("N"),
GeneratorVersion = DynamicQuestionGenerator.CurrentVersion,
CaseFamilyId = selectedFamily.Id,
DifficultyTier = selectedFamily.DifficultyTier,
EffectiveAbilityElo = effectiveAbility,
FamilyElo = selectedFamilyElo,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion);
var familyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(familyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count - 1))
.ToHashSet(StringComparer.Ordinal);
for (var retry = 0; retry < 20 && recentForObjective.Contains(generated.CaseFamilyId); retry++)
{
claims.Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
}
claims.CaseFamilyId = generated.CaseFamilyId;
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.RecentCaseFamilyIds.Add(generated.CaseFamilyId);
@@ -84,7 +108,7 @@ public sealed class DynamicQuestionService
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
return new DynamicQuestionEnvelope
{
Question = QuestionResponse.FromModel(generated.Question),
Question = QuestionResponse.FromModel(generated.Question, questionElo: selectedFamilyElo),
ObjectiveId = blueprint.ObjectiveId,
ObjectiveTitle = blueprint.Title,
GenerationToken = _tokens.SignGeneration(claims),
@@ -109,7 +133,8 @@ public sealed class DynamicQuestionService
throw new InvalidOperationException("This generated question version is no longer supported.");
var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion)
?? throw new KeyNotFoundException("The question blueprint is no longer available.");
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion);
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)
&& claims.CaseFamilyId != generated.CaseFamilyId)
throw new InvalidOperationException("Generated case family does not match its signed descriptor.");