Complete CCS results and rating flow

This commit is contained in:
hz
2026-07-15 11:08:13 -05:00
parent 9f379cda5c
commit 52d435fa2e
14 changed files with 402 additions and 47 deletions
@@ -58,6 +58,7 @@ public static class CcsCaseValidator
errors.Add(prefix + "scoring criterion ids must be present and unique");
foreach (var criterion in item.ScoringCriteria)
{
if (string.IsNullOrWhiteSpace(criterion.Title)) errors.Add(prefix + $"criterion {criterion.Id} requires a title");
var targets = criterion.TargetCodes.Count > 0 ? criterion.TargetCodes : [criterion.TargetCode];
if (criterion.Points == 0) errors.Add(prefix + $"criterion {criterion.Id} points cannot be zero");
if (targets.Any(string.IsNullOrWhiteSpace)) errors.Add(prefix + $"criterion {criterion.Id} requires a target code");
+154
View File
@@ -0,0 +1,154 @@
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<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);
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<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();
}
}
}
+23 -3
View File
@@ -9,13 +9,15 @@ public sealed class CcsStore
private readonly IMongoCollection<CcsOrderDefinition> _orders;
private readonly IMongoCollection<CcsAttempt> _attempts;
private readonly ExamSimulationStore _examSimulations;
private readonly CcsRatingStore _ratings;
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations)
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations, CcsRatingStore ratings)
{
_cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
_orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
_attempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
_examSimulations = examSimulations;
_ratings = ratings;
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRevision>(
Builders<CcsCaseRevision>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
new CreateIndexOptions { Unique = true }));
@@ -108,6 +110,7 @@ public sealed class CcsStore
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
RatingEligible = request.Mode == CcsAttemptModes.Timed,
Chart =
[
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
@@ -181,7 +184,7 @@ public sealed class CcsStore
{
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId,
CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId,
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, CreatedUtc = value.CreatedUtc
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc
}).ToList();
}
@@ -227,7 +230,8 @@ public sealed class CcsStore
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Score = completed ? attempt.Score : null, References = completed ? definition.References : []
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
}
@@ -341,6 +345,7 @@ public sealed class CcsStore
attempt.State = CcsAttemptStates.Completed;
attempt.CompletedUtc = DateTime.UtcNow;
attempt.Score = CalculateScore(attempt, definition);
attempt.Rating = await _ratings.ApplyAsync(attempt);
attempt.UpdatedUtc = DateTime.UtcNow;
await SaveAsync(attempt);
}
@@ -367,6 +372,11 @@ public sealed class CcsStore
results.Add(new CcsCriterionResult
{
CriterionId = criterion.Id,
Title = string.IsNullOrWhiteSpace(criterion.Title) ? Humanize(criterion.Id) : criterion.Title,
ActionType = criterion.ActionType,
ExpectedAction = DescribeExpected(criterion, targets),
ObservedAction = action?.Code ?? "Not performed",
ObservedMinute = action?.SimulatedMinute,
Status = met ? criterion.Points < 0 ? "harmful" : "credited" : criterion.Points > 0 ? "missed" : "avoided",
Points = awarded, Rationale = criterion.Rationale
});
@@ -384,6 +394,16 @@ public sealed class CcsStore
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
{
var text = $"{Humanize(criterion.ActionType)}: {string.Join(" or ", targets.Select(Humanize))}";
if (criterion.EarliestMinute is not null && criterion.LatestMinute is not null) text += $" between minute {criterion.EarliestMinute} and {criterion.LatestMinute}";
else if (criterion.EarliestMinute is not null) text += $" at or after minute {criterion.EarliestMinute}";
else if (criterion.LatestMinute is not null) text += $" by minute {criterion.LatestMinute}";
if (!string.IsNullOrWhiteSpace(criterion.AfterTargetCode)) text += $" after {Humanize(criterion.AfterTargetCode)}";
return text;
}
private static CcsCaseSummary ToSummary(CcsCaseRevision value) => new()
{
ExternalId = value.ExternalId, Title = value.Title, System = value.System,