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;
}
}