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

254 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class ProgressionStore
{
private readonly IMongoCollection<UserProgression> _progressions;
private readonly IMongoCollection<ProgressionWeeklySnapshot> _snapshots;
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _queue;
private readonly IMongoCollection<StudySession> _sessions;
private readonly QuestionStore _questions;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
public ProgressionStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
{
var db = mongo.Database;
_progressions = db.GetCollection<UserProgression>("UserProgressions");
_snapshots = db.GetCollection<ProgressionWeeklySnapshot>("ProgressionWeeklySnapshots");
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
_sessions = db.GetCollection<StudySession>("StudySessions");
_questions = questions;
_credits = credits;
_clock = clock;
_progressions.Indexes.CreateOne(new CreateIndexModel<UserProgression>(
Builders<UserProgression>.IndexKeys.Ascending(x => x.UserId), new CreateIndexOptions { Unique = true }));
_snapshots.Indexes.CreateOne(new CreateIndexModel<ProgressionWeeklySnapshot>(
Builders<ProgressionWeeklySnapshot>.IndexKeys.Ascending(x => x.UserId).Descending(x => x.WeekStartUtc)));
}
public async Task PrepareAsync(string userId)
{
if (await _progressions.Find(x => x.UserId == userId).AnyAsync())
{
await EnsureWeeklyOpeningAsync(userId);
return;
}
var evidence = await EvidenceAsync(userId);
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
var now = _clock.GetUtcNow().UtcDateTime;
var progression = new UserProgression
{
UserId = userId, HighestRankOrdinal = qualified, BaselineApplied = true,
TrackingStartedUtc = now, UpdatedUtc = now,
EarnedRanksUtc = Enumerable.Range(0, qualified + 1).ToDictionary(x => x.ToString(), _ => now)
};
ApplyHistoricalRecords(progression, evidence);
try { await _progressions.InsertOneAsync(progression); }
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
await EnsureWeeklyOpeningAsync(userId, evidence);
}
public async Task<(ProgressionResponse State, List<CreditAward> Awards)> RefreshAsync(string userId)
{
await PrepareAsync(userId);
var evidence = await EvidenceAsync(userId);
var progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
var oldHighest = progression.HighestRankOrdinal;
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
var now = _clock.GetUtcNow().UtcDateTime;
ApplyHistoricalRecords(progression, evidence);
if (qualified > progression.HighestRankOrdinal)
{
progression.HighestRankOrdinal = qualified;
for (var i = oldHighest + 1; i <= qualified; i++) progression.EarnedRanksUtc.TryAdd(i.ToString(), now);
}
await UpdateWeeklyAsync(progression, evidence, now);
progression.UpdatedUtc = now;
var update = Builders<UserProgression>.Update.Max(x => x.HighestRankOrdinal, qualified)
.Set(x => x.CurrentHardStreak, progression.CurrentHardStreak)
.Set(x => x.BestHardStreak, progression.BestHardStreak)
.Set(x => x.BestHardStreakUtc, progression.BestHardStreakUtc)
.Set(x => x.BestTimedBlock, progression.BestTimedBlock)
.Set(x => x.SystemsAdvancedWeek, progression.SystemsAdvancedWeek)
.Set(x => x.ReviewQueueReduction, progression.ReviewQueueReduction)
.Set(x => x.WeakSystemImprovement, progression.WeakSystemImprovement)
.Set(x => x.UpdatedUtc, now);
for (var i = oldHighest + 1; i <= qualified; i++) update = update.Set($"EarnedRanksUtc.{i}", now);
await _progressions.UpdateOneAsync(x => x.UserId == userId, update);
progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
var awards = new List<CreditAward>();
foreach (var tier in new[] { 3, 6, 9, 12 }.Where(x => oldHighest < x && qualified >= x))
{
var amount = tier switch { 3 => 5, 6 => 10, 9 => 15, _ => 25 };
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"progression:rank:{tier}", amount, CreditReasonCodes.RankPromotion,
$"Advanced to {ProgressionPolicy.Name(tier)}", "progression_rank", tier.ToString(), false));
if (result.Award is not null) awards.Add(result.Award);
}
return (Response(progression, qualified, evidence), awards);
}
public async Task<ProgressionResponse> GetAsync(string userId) => (await RefreshAsync(userId)).State;
private async Task EnsureWeeklyOpeningAsync(string userId, Evidence? supplied = null)
{
var now = _clock.GetUtcNow().UtcDateTime;
var week = WeekStart(now);
var id = $"{userId}:{week:yyyyMMdd}";
if (await _snapshots.Find(x => x.Id == id).AnyAsync()) return;
var evidence = supplied ?? await EvidenceAsync(userId);
var values = Snapshots(evidence.Systems);
var snapshot = new ProgressionWeeklySnapshot
{
Id = id, UserId = userId, WeekStartUtc = week,
OpeningSystems = values, LatestSystems = values,
OpeningPendingReviews = evidence.Pending, LatestPendingReviews = evidence.Pending, UpdatedUtc = now
};
try { await _snapshots.InsertOneAsync(snapshot); }
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
}
private async Task UpdateWeeklyAsync(UserProgression progression, Evidence evidence, DateTime now)
{
await EnsureWeeklyOpeningAsync(progression.UserId, evidence);
var week = WeekStart(now);
var id = $"{progression.UserId}:{week:yyyyMMdd}";
await _snapshots.UpdateOneAsync(x => x.Id == id, Builders<ProgressionWeeklySnapshot>.Update
.Set(x => x.LatestSystems, Snapshots(evidence.Systems)).Set(x => x.LatestPendingReviews, evidence.Pending)
.Set(x => x.UpdatedUtc, now));
var all = await _snapshots.Find(x => x.UserId == progression.UserId).ToListAsync();
foreach (var snapshot in all)
{
var opening = snapshot.OpeningSystems.ToDictionary(x => x.System);
var advanced = snapshot.LatestSystems.Count(x => opening.TryGetValue(x.System, out var before) && x.StateOrdinal > before.StateOrdinal);
SetRecordIfBetter(progression.SystemsAdvancedWeek, advanced, 0, $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
var reduced = Math.Max(0, snapshot.OpeningPendingReviews - snapshot.LatestPendingReviews);
SetRecordIfBetter(progression.ReviewQueueReduction, reduced, snapshot.OpeningPendingReviews,
$"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
foreach (var after in snapshot.LatestSystems)
{
if (!opening.TryGetValue(after.System, out var before) || before.MasteryScore >= 75 || before.DistinctQuestions < 5) continue;
SetRecordIfBetter(progression.WeakSystemImprovement, Math.Max(0, after.MasteryScore - before.MasteryScore),
after.DistinctQuestions, after.System, snapshot.UpdatedUtc);
}
}
}
private async Task<Evidence> EvidenceAsync(string userId)
{
var attempts = await _attempts.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
var reviews = await _reviews.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
var queue = await _queue.Find(x => x.UserId == userId).ToListAsync();
var ids = attempts.Select(x => x.QuestionExternalId).Concat(reviews.Select(x => x.QuestionExternalId)).Concat(queue.Select(x => x.QuestionExternalId));
var questions = await _questions.GetByExternalIdsAsync(ids);
var systems = MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
var delayed = reviews.Where(x => x.IsCorrect).Select(x => new
{
Review = x, Item = queue.FirstOrDefault(q => q.QuestionExternalId == x.QuestionExternalId)
}).Where(x => x.Item is not null && x.Review.CreatedUtc - x.Item.CreatedUtc >= TimeSpan.FromHours(24))
.Select(x => x.Review.QuestionExternalId).Distinct().Count();
var directHard = attempts.Where(x => x.IsCorrect && !x.IsSkip && questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4)
.Select(x => x.QuestionExternalId).Distinct().Count();
var sessions = await _sessions.Find(x => x.UserId == userId && x.State == StudySessionStates.Completed
&& x.Mode == "Timed" && x.GoalType != StudyGoalTypes.SimulationBlock).ToListAsync();
return new Evidence(attempts, reviews, queue, sessions, questions, systems, delayed + directHard / 5,
queue.Count(x => x.State == ReviewQueueStates.Pending));
}
private void ApplyHistoricalRecords(UserProgression progression, Evidence evidence)
{
var streak = 0;
foreach (var attempt in evidence.Attempts.Where(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4))
{
streak = attempt.IsCorrect && !attempt.IsSkip ? streak + 1 : 0;
if (streak > progression.BestHardStreak)
{
progression.BestHardStreak = streak;
progression.BestHardStreakUtc = attempt.CreatedUtc;
}
}
progression.CurrentHardStreak = streak;
var attemptsById = evidence.Attempts.Where(x => x.Id is not null).ToDictionary(x => x.Id!);
foreach (var session in evidence.Sessions)
{
var sessionAttempts = session.AppliedEventIds.Where(attemptsById.ContainsKey).Select(x => attemptsById[x]).ToList();
if (sessionAttempts.Count < 20 || sessionAttempts.Count != session.AppliedEventIds.Count) continue;
var hard = sessionAttempts.Count(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4);
if (hard * 2 < sessionAttempts.Count) continue;
var accuracy = (int)Math.Round(sessionAttempts.Count(x => x.IsCorrect && !x.IsSkip) * 100d / sessionAttempts.Count);
if (accuracy > progression.BestTimedBlock.Value
|| accuracy == progression.BestTimedBlock.Value && sessionAttempts.Count > progression.BestTimedBlock.SampleSize)
progression.BestTimedBlock = new PersonalRecordValue { Value = accuracy, SampleSize = sessionAttempts.Count,
Detail = $"{hard} difficulty 4–5 questions", AchievedUtc = session.CompletedUtc ?? session.UpdatedUtc };
}
}
private static void SetRecordIfBetter(PersonalRecordValue record, int value, int sample, string detail, DateTime date)
{
if (value <= record.Value) return;
record.Value = value; record.SampleSize = sample; record.Detail = detail; record.AchievedUtc = date;
}
private static ProgressionResponse Response(UserProgression p, int qualified, Evidence evidence)
{
var earnedTierNames = Enumerable.Range(0, p.HighestRankOrdinal / 3 + 1).Select(x => ProgressionPolicy.Names[x]).ToList();
var response = new ProgressionResponse
{
RankOrdinal = p.HighestRankOrdinal, QualifiedRankOrdinal = qualified,
RankName = ProgressionPolicy.Name(p.HighestRankOrdinal), Division = ProgressionPolicy.Division(p.HighestRankOrdinal),
TrackingStartedUtc = p.TrackingStartedUtc, EarnedBadges = earnedTierNames, EarnedRanksUtc = p.EarnedRanksUtc,
Records = Records(p), Ladder = Enumerable.Range(0, ProgressionPolicy.Thresholds.Length).Select(i =>
{
var t = ProgressionPolicy.Thresholds[i];
var parts = new List<string>();
if (t.L > 0) parts.Add($"Learning+ {t.L}"); if (t.P > 0) parts.Add($"Practicing+ {t.P}");
if (t.S > 0) parts.Add($"Strong+ {t.S}"); if (t.M > 0) parts.Add($"Mastered {t.M}");
if (t.H > 0) parts.Add($"Hard successes {t.H}"); if (t.R > 0) parts.Add($"Retention {t.R}");
return new ProgressionRankResponse { Ordinal=i, RankName=ProgressionPolicy.Name(i), Division=ProgressionPolicy.Division(i),
Earned=i <= p.HighestRankOrdinal, EarnedUtc=p.EarnedRanksUtc.GetValueOrDefault(i.ToString()),
RequirementSummary=parts.Count == 0 ? "Starting rank" : string.Join(" · ", parts) };
}).ToList()
};
if (p.HighestRankOrdinal >= ProgressionPolicy.Thresholds.Length - 1) return response;
var next = p.HighestRankOrdinal + 1;
response.NextRankName = ProgressionPolicy.Name(next); response.NextDivision = ProgressionPolicy.Division(next);
var threshold = ProgressionPolicy.Thresholds[next];
var values = new Dictionary<string, (string Label, int Current, int Required)>
{
["learning"] = ("Systems at Learning+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 1), threshold.L),
["practicing"] = ("Systems at Practicing+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 2), threshold.P),
["strong"] = ("Systems at Strong+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 3), threshold.S),
["mastered"] = ("Systems Mastered", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 4), threshold.M),
["hard"] = ("Difficulty 4+ successes", evidence.Systems.Sum(x => x.Difficulty4PlusSuccessful), threshold.H),
["retention"] = ("Retention evidence", evidence.Retention, threshold.R)
};
response.NextRequirements = values.Where(x => x.Value.Required > 0).Select(x => new ProgressionRequirementResponse
{ Key = x.Key, Label = x.Value.Label, Current = x.Value.Current, Required = x.Value.Required }).ToList();
return response;
}
private static List<ProgressionRecordResponse> Records(UserProgression p) =>
[
new() { Key="timed_block", Label="Advanced Timed Block", Definition="Best accuracy in a completed 20+ question Timed block with at least 50% difficulty 4–5.", Value=p.BestTimedBlock.Value, SampleSize=p.BestTimedBlock.SampleSize, Detail=p.BestTimedBlock.Detail, AchievedUtc=p.BestTimedBlock.AchievedUtc },
new() { Key="hard_streak", Label="Hard-Question Streak", Definition="Consecutive correct first attempts at difficulty 4–5; easier questions are ignored.", Value=p.BestHardStreak, AchievedUtc=p.BestHardStreakUtc },
new() { Key="systems_week", Label="Systems Advanced in a Week", Definition="Unique systems whose mastery state increased in one UTC Monday–Sunday week.", Value=p.SystemsAdvancedWeek.Value, Detail=p.SystemsAdvancedWeek.Detail, AchievedUtc=p.SystemsAdvancedWeek.AchievedUtc, TrackingFromLaunch=true },
new() { Key="reviews_week", Label="Review Queue Reduction", Definition="Largest weekly decrease in pending reviews.", Value=p.ReviewQueueReduction.Value, SampleSize=p.ReviewQueueReduction.SampleSize, Detail=p.ReviewQueueReduction.Detail, AchievedUtc=p.ReviewQueueReduction.AchievedUtc, TrackingFromLaunch=true },
new() { Key="weak_gain", Label="Weak-System Improvement", Definition="Largest weekly mastery-score gain in a system that began below 75 with at least five questions of evidence.", Value=p.WeakSystemImprovement.Value, SampleSize=p.WeakSystemImprovement.SampleSize, Detail=p.WeakSystemImprovement.Detail, AchievedUtc=p.WeakSystemImprovement.AchievedUtc, TrackingFromLaunch=true }
];
private static List<ProgressionSystemSnapshot> Snapshots(IEnumerable<DashboardSystemPerformance> systems) => systems.Select(x => new ProgressionSystemSnapshot
{ System=x.System, StateOrdinal=ProgressionPolicy.State(x.MasteryState), MasteryScore=x.MasteryScore, DistinctQuestions=x.DistinctQuestions }).ToList();
private static DateTime WeekStart(DateTime value) => value.Date.AddDays(-((7 + (int)value.DayOfWeek - (int)DayOfWeek.Monday) % 7));
private sealed record Evidence(List<QuestionAttempt> Attempts, List<QuestionReviewAttempt> Reviews, List<ReviewQueueItem> Queue, List<StudySession> Sessions,
IReadOnlyDictionary<string, Question> Questions, List<DashboardSystemPerformance> Systems, int Retention, int Pending);
}