Files
step3/api/QuestionsApi/Services/CcsRatingStore.cs
T
hz 34d283e213 Add rating snapshots, percentile, and exam readiness to the dashboard API
Backend half of the dashboard stat-card redesign. Three new data
points the old DashboardResponse had no way to compute:

- Week-over-week rating deltas: UserRating/CcsUserRating only ever
  stored the current Elo (mutated in place, UpdatedUtc just means
  "last touched"), so there was no way to know what a rating was 7
  days ago. Added UserRatingSnapshot (new collection, unique on
  UserId+DateUtc) and UserRatingSnapshotService, a BackgroundService
  following AnswerStatisticsMaintenanceService's exact shape (a
  PeriodicTimer plus a Mongo lease so replicas behind the HPA don't
  race), writing one snapshot per user per day. No historical
  backfill - deltas are simply null for a user's first ~7 days after
  this ships, which the DTO represents honestly via nullable ints
  rather than a fake "+0".
- Percentile rank: DashboardStore now counts how many users have a
  higher OverallElo/Elo than the current user out of the total.
- Exam Readiness + projected pass rate: reuses the existing, tested
  IRT-based ExamSimulationScore.PassProbability (a real normal-CDF
  estimate of scoring >=200, computed in ExamSimulationScoring.Calculate)
  rather than inventing a new prediction model. DashboardStore pulls
  the latest completed simulation's score directly and maps
  PassProbability to a High/Medium/Low label via tunable thresholds.
- Average Score's "+6% vs last 100" needed no new data source at all -
  just one more slice (Skip(100).Take(100)) over the QuestionAttempts
  already loaded for FirstAttemptAccuracy.

Added DashboardStore.Percentile/ReadinessLabel as pure static helpers
(matching the existing Percentage/CalculateCurrentStreak pattern) and
unit tests for both. Full backend suite: 165 passed, 0 failed.
2026-07-26 00:39:11 -05:00

182 lines
8.3 KiB
C#

using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CcsRatingStore
{
private const int BaseElo = 1000;
private const int MinimumElo = 100;
private readonly IMongoCollection<CcsUserRating> _users;
private readonly IMongoCollection<CcsCaseRating> _cases;
private readonly IMongoCollection<CcsRatedCaseResult> _results;
public CcsRatingStore(MongoDatabaseProvider mongo)
{
_users = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
_cases = mongo.Database.GetCollection<CcsCaseRating>("CcsCaseRatings");
_results = mongo.Database.GetCollection<CcsRatedCaseResult>("CcsRatedCaseResults");
_users.Indexes.CreateOne(new CreateIndexModel<CcsUserRating>(
Builders<CcsUserRating>.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true }));
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRating>(
Builders<CcsCaseRating>.IndexKeys.Ascending(value => value.CaseExternalId), new CreateIndexOptions { Unique = true }));
_results.Indexes.CreateOne(new CreateIndexModel<CcsRatedCaseResult>(
Builders<CcsRatedCaseResult>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId),
new CreateIndexOptions { Unique = true }));
}
public async Task<CcsRatingProfileResponse> GetProfileAsync(string userId)
{
var rating = await GetOrCreateUserAsync(userId);
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
}
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
{
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
if (!attempt.RatingEligible || attempt.Mode != CcsAttemptModes.Timed)
return Unrated("not_eligible", actual);
try
{
await _results.InsertOneAsync(new CcsRatedCaseResult
{
UserId = attempt.UserId,
CaseExternalId = attempt.CaseExternalId,
AttemptId = attempt.Id ?? ""
});
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return Unrated("repeat_case", actual);
}
var user = await GetOrCreateUserAsync(attempt.UserId);
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
var expected = ExpectedScore(user.Elo, caseRating.Elo);
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
var caseDelta = (int)Math.Round(CaseK(caseRating.RatedAttemptCount) * (expected - actual), MidpointRounding.AwayFromZero);
var updatedUser = await UpdateUserAsync(user, userDelta);
var updatedCase = await UpdateCaseAsync(caseRating, caseDelta);
return new CcsRatingChange
{
Rated = true,
Reason = "first_timed_attempt",
UserEloBefore = user.Elo,
UserElo = updatedUser.Elo,
UserEloDelta = updatedUser.Elo - user.Elo,
CaseEloBefore = caseRating.Elo,
CaseElo = updatedCase.Elo,
CaseEloDelta = updatedCase.Elo - caseRating.Elo,
ActualScore = actual,
AppliedUtc = DateTime.UtcNow
};
}
public static double ExpectedScore(int userElo, int caseElo) =>
1.0 / (1.0 + Math.Pow(10.0, (caseElo - userElo) / 400.0));
private static int UserK(int count) => count < 20 ? 40 : 24;
private static int CaseK(int count) => count < 20 ? 32 : 24;
private static CcsRatingChange Unrated(string reason, double actual) => new()
{
Rated = false, Reason = reason, ActualScore = actual
};
private async Task<CcsUserRating> GetOrCreateUserAsync(string userId)
{
try
{
await _users.UpdateOneAsync(value => value.UserId == userId,
Builders<CcsUserRating>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Elo, BaseElo)
.SetOnInsert(value => value.RatedCaseCount, 0)
.SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Another API replica created the initial rating first.
}
return await _users.Find(value => value.UserId == userId).FirstAsync();
}
private async Task<CcsCaseRating> GetOrCreateCaseAsync(string externalId)
{
try
{
await _cases.UpdateOneAsync(value => value.CaseExternalId == externalId,
Builders<CcsCaseRating>.Update
.SetOnInsert(value => value.CaseExternalId, externalId)
.SetOnInsert(value => value.Elo, BaseElo)
.SetOnInsert(value => value.RatedAttemptCount, 0)
.SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Another API replica created the initial rating first.
}
return await _cases.Find(value => value.CaseExternalId == externalId).FirstAsync();
}
private async Task<CcsUserRating> UpdateUserAsync(CcsUserRating current, int delta)
{
while (true)
{
var nextElo = Math.Max(MinimumElo, current.Elo + delta);
var updated = await _users.FindOneAndUpdateAsync(
value => value.UserId == current.UserId && value.Elo == current.Elo && value.RatedCaseCount == current.RatedCaseCount,
Builders<CcsUserRating>.Update.Set(value => value.Elo, nextElo)
.Inc(value => value.RatedCaseCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
new FindOneAndUpdateOptions<CcsUserRating> { ReturnDocument = ReturnDocument.After });
if (updated is not null) return updated;
current = await _users.Find(value => value.UserId == current.UserId).FirstAsync();
}
}
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<CcsUserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.Elo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.Elo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
{
while (true)
{
var nextElo = Math.Max(MinimumElo, current.Elo + delta);
var updated = await _cases.FindOneAndUpdateAsync(
value => value.CaseExternalId == current.CaseExternalId && value.Elo == current.Elo && value.RatedAttemptCount == current.RatedAttemptCount,
Builders<CcsCaseRating>.Update.Set(value => value.Elo, nextElo)
.Inc(value => value.RatedAttemptCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
new FindOneAndUpdateOptions<CcsCaseRating> { ReturnDocument = ReturnDocument.After });
if (updated is not null) return updated;
current = await _cases.Find(value => value.CaseExternalId == current.CaseExternalId).FirstAsync();
}
}
}