namespace AIStudio.Assistants.ModelComparison; /// /// Folds stored comparisons into ModelComparisonStatistics, one at a time, so that never more than one of them is in /// memory: only the counts and the times stay. /// internal sealed class ModelComparisonStatisticsBuilder { /// /// The identity of each model from its latest comparison, so that a renamed provider entry shows its new name. /// private readonly Dictionary latestIdentities = []; /// /// When each model took part in its latest comparison. /// private readonly Dictionary latestTimes = []; private readonly Dictionary voteStandings = []; private readonly Dictionary judgeStandings = []; private readonly Dictionary pairings = []; private readonly Dictionary judgeAgreements = []; private readonly Dictionary> firstTokenTimes = []; private readonly Dictionary> totalTimes = []; private int comparisonCount; private int runCount; private int seriesCount; private int taskCount; /// /// Counts a stored comparison of the stability mode. /// /// The stored comparison. public void Add(ModelComparisonRecord record) { this.comparisonCount++; this.runCount += record.Runs.Count; this.AddComparison(record.First, record.Second, record.Judge, record.FinishedAtUtc); foreach (var run in record.Runs) { this.AddTimes(record.First.Key, run.First); this.AddTimes(record.Second.Key, run.Second); this.AddDecision(record.First, record.Second, record.Judge, run.Vote, run.JudgeChoice); } } /// /// Counts the blind tasks of a comparison series whose result the user saw. A series before its result counts not /// at all: its models must not show up anywhere with their names. A task decided after the result is not blind and /// stays out, too. The tasks have no times of the answers, so they count toward neither speed. /// /// The series. public void Add(ModelComparisonSeries series) { if (!series.IsResolved) return; var blindTasks = series.Tasks.Where(task => task is { State: ModelComparisonSeriesTaskState.DONE, IsBlind: true }).ToList(); if (blindTasks.Count == 0) return; this.seriesCount++; this.taskCount += blindTasks.Count; this.AddComparison(series.First, series.Second, series.Judge, series.ResolvedAtUtc ?? series.LastActivityUtc); foreach (var task in blindTasks) this.AddDecision(series.First, series.Second, series.Judge, task.Vote, task.JudgeChoice); } /// /// Gets the statistics of everything counted so far. /// /// The statistics. public ModelComparisonStatistics Build() => new() { ComparisonCount = this.comparisonCount, RunCount = this.runCount, SeriesCount = this.seriesCount, TaskCount = this.taskCount, VoteRanking = this.Rank(this.voteStandings), JudgeRanking = this.Rank(this.judgeStandings), Pairings = [ .. this.pairings.Values .Select(pairing => pairing with { First = this.latestIdentities[pairing.First.Key], Second = this.latestIdentities[pairing.Second.Key] }) .OrderByDescending(pairing => pairing.ComparisonCount) .ThenBy(pairing => pairing.First.Label, StringComparer.CurrentCultureIgnoreCase), ], JudgeAgreements = [ .. this.judgeAgreements .Select(agreement => agreement.Value with { Judge = this.latestIdentities[agreement.Key] }) .OrderByDescending(agreement => agreement.ComparedRuns) .ThenBy(agreement => agreement.Judge.Label, StringComparer.CurrentCultureIgnoreCase), ], Speeds = [ .. this.totalTimes.Keys .Select(key => new ModelComparisonModelSpeed { Model = this.latestIdentities[key], AnswerCount = this.totalTimes[key].Count, MedianFirstTokenTime = Median(this.firstTokenTimes[key]), MedianTotalTime = Median(this.totalTimes[key]), }) .OrderBy(speed => speed.MedianTotalTime ?? TimeSpan.MaxValue) .ThenBy(speed => speed.Model.Label, StringComparer.CurrentCultureIgnoreCase), ], }; /// /// Counts a comparison of two models: remembers their names, and counts it for their pair. /// /// The first model of the comparison. /// The second model of the comparison. /// The judge, or null without one. /// When the comparison was finished. private void AddComparison(ModelComparisonModelIdentity first, ModelComparisonModelIdentity second, ModelComparisonModelIdentity? judge, DateTimeOffset finishedAtUtc) { this.Remember(first, finishedAtUtc); this.Remember(second, finishedAtUtc); if (judge is not null) this.Remember(judge, finishedAtUtc); // A comparison of a model with itself tests how consistently it answers, not which model is better: if (first.Key == second.Key) return; var pair = ModelComparisonPairKey.Of(first.Key, second.Key); var pairIsSwapped = pair.First != first.Key; var pairing = this.pairings.GetValueOrDefault(pair) ?? new ModelComparisonPairing { First = pairIsSwapped ? second : first, Second = pairIsSwapped ? first : second, }; this.pairings[pair] = pairing with { ComparisonCount = pairing.ComparisonCount + 1 }; } /// /// Counts the vote of the user and the choice of the judge on one run or task. /// /// The first model of the comparison. /// The second model of the comparison. /// The judge, or null without one. /// The vote of the user, or null without one. /// The choice of the judge, or null without a usable verdict. private void AddDecision(ModelComparisonModelIdentity first, ModelComparisonModelIdentity second, ModelComparisonModelIdentity? judge, ModelComparisonModelChoice? vote, ModelComparisonModelChoice? judgeChoice) { if (first.Key != second.Key) { Count(this.voteStandings, first, second, vote); Count(this.judgeStandings, first, second, judgeChoice); var pair = ModelComparisonPairKey.Of(first.Key, second.Key); var pairIsSwapped = pair.First != first.Key; var pairing = this.pairings[pair]; this.pairings[pair] = pairing with { Votes = pairing.Votes.Plus(Oriented(vote, pairIsSwapped)), JudgeVerdicts = pairing.JudgeVerdicts.Plus(Oriented(judgeChoice, pairIsSwapped)), }; } if (judge is not null && vote is { } decidedVote && judgeChoice is { } decidedJudgeChoice) { var agreement = this.judgeAgreements.GetValueOrDefault(judge.Key) ?? new ModelComparisonJudgeAgreement { Judge = judge }; this.judgeAgreements[judge.Key] = agreement with { Agreements = agreement.Agreements + (decidedVote == decidedJudgeChoice ? 1 : 0), ComparedRuns = agreement.ComparedRuns + 1, }; } } /// /// Remembers the identity of a model when its comparison is the latest one so far. /// /// The identity in the stored comparison. /// When the comparison was finished. private void Remember(ModelComparisonModelIdentity identity, DateTimeOffset finishedAtUtc) { var key = identity.Key; if (this.latestTimes.TryGetValue(key, out var latest) && finishedAtUtc < latest) return; this.latestTimes[key] = finishedAtUtc; this.latestIdentities[key] = identity; } /// /// Keeps the times of a complete answer. /// /// The key of the model. /// How the model did in the run. private void AddTimes(ModelComparisonModelKey model, ModelComparisonRecordAnswer answer) { if (!this.totalTimes.TryGetValue(model, out var totals)) { totals = []; this.totalTimes[model] = totals; this.firstTokenTimes[model] = []; } // Only complete answers count: a failure or a cancellation would make a model look faster than it is. if (!answer.Answered || answer.TotalTime is not { } totalTime) return; totals.Add(totalTime); if (answer.FirstTokenTime is { } firstTokenTime) this.firstTokenTimes[model].Add(firstTokenTime); } /// /// Ranks the models, the one with the highest share of wins first; a model without decided runs comes last. /// /// The standings by model. /// The ranking, with the latest name of each model. private IReadOnlyList Rank(Dictionary standings) => [ .. standings .Select(standing => standing.Value with { Model = this.latestIdentities[standing.Key] }) .OrderByDescending(standing => standing.WinShare.Share ?? -1) .ThenByDescending(standing => standing.WinShare.Decided) .ThenBy(standing => standing.Model.Label, StringComparer.CurrentCultureIgnoreCase), ]; /// /// Counts a choice for and against both models of a comparison. /// /// The standings by model. /// The first model of the comparison. /// The second model of the comparison. /// The choice of the run, or null without one. private static void Count(Dictionary standings, ModelComparisonModelIdentity first, ModelComparisonModelIdentity second, ModelComparisonModelChoice? choice) { switch (choice) { case ModelComparisonModelChoice.FIRST: Change(standings, first, standing => standing with { Wins = standing.Wins + 1 }); Change(standings, second, standing => standing with { Losses = standing.Losses + 1 }); break; case ModelComparisonModelChoice.SECOND: Change(standings, first, standing => standing with { Losses = standing.Losses + 1 }); Change(standings, second, standing => standing with { Wins = standing.Wins + 1 }); break; case ModelComparisonModelChoice.TIE: Change(standings, first, standing => standing with { Ties = standing.Ties + 1 }); Change(standings, second, standing => standing with { Ties = standing.Ties + 1 }); break; } } /// /// Changes the standing of a model. /// /// The standings by model. /// The model. /// The change. private static void Change(Dictionary standings, ModelComparisonModelIdentity model, Func change) { var standing = standings.GetValueOrDefault(model.Key) ?? new ModelComparisonModelStanding { Model = model }; standings[model.Key] = change(standing); } /// /// Turns a choice in the order of a comparison into the order of its pair. /// /// The choice, in the order of the comparison. /// Whether the pair names the models the other way round. private static ModelComparisonModelChoice? Oriented(ModelComparisonModelChoice? choice, bool pairIsSwapped) => !pairIsSwapped ? choice : choice switch { ModelComparisonModelChoice.FIRST => ModelComparisonModelChoice.SECOND, ModelComparisonModelChoice.SECOND => ModelComparisonModelChoice.FIRST, _ => choice, }; /// /// Gets the median of some times. /// /// The times. /// The median, or null without any times. internal static TimeSpan? Median(IReadOnlyCollection times) { if (times.Count == 0) return null; var sorted = times.Order().ToList(); var middle = sorted.Count / 2; return sorted.Count % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } }