< Summary

Information
Class: Orchestrator.Commands.Observability.PrepareRepeatedMatchSlice.PrepareRepeatedMatchSliceCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/PrepareRepeatedMatchSlice/PrepareRepeatedMatchSliceCommand.cs
Line coverage
87%
Covered lines: 173
Uncovered lines: 24
Coverable lines: 197
Total lines: 338
Line coverage: 87.8%
Branch coverage
68%
Covered branches: 44
Total branches: 64
Branch coverage: 68.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExecuteAsync()64.29%141494.19%
LoadSourceItemsAsync()77.78%1818100%
ExpandRepeatedItems(...)100%44100%
GetStartsAt(...)50%8883.33%
SelectRandomItems(...)75%4477.78%
ParseMatchdays(...)83.33%6685.71%
BuildDefaultSourcePoolKey(...)50%6450%
BuildDatasetMetadata(...)50%4481.82%
ResolveOutputDirectory(...)50%4222.22%
WriteJsonFileAsync<T>(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/PrepareRepeatedMatchSlice/PrepareRepeatedMatchSliceCommand.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using EHonda.KicktippAi.Core;
 4using Microsoft.Extensions.Logging;
 5using NodaTime;
 6using Orchestrator.Commands.Observability.Experiments;
 7using Orchestrator.Commands.Observability.ExportExperimentDataset;
 8using Orchestrator.Infrastructure.Factories;
 9using Spectre.Console;
 10using Spectre.Console.Cli;
 11
 12namespace Orchestrator.Commands.Observability.PrepareRepeatedMatchSlice;
 13
 14public sealed class PrepareRepeatedMatchSliceCommand : AsyncCommand<PrepareRepeatedMatchSliceSettings>
 15{
 16    private readonly IAnsiConsole _console;
 17    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 18    private readonly ILogger<PrepareRepeatedMatchSliceCommand> _logger;
 19
 120    public PrepareRepeatedMatchSliceCommand(
 121        IAnsiConsole console,
 122        IFirebaseServiceFactory firebaseServiceFactory,
 123        ILogger<PrepareRepeatedMatchSliceCommand> logger)
 24    {
 125        _console = console;
 126        _firebaseServiceFactory = firebaseServiceFactory;
 127        _logger = logger;
 128    }
 29
 30    protected override async Task<int> ExecuteAsync(
 31        CommandContext context,
 32        PrepareRepeatedMatchSliceSettings settings,
 33        CancellationToken cancellationToken)
 34    {
 35        try
 36        {
 137            var matchOutcomeRepository = _firebaseServiceFactory.CreateMatchOutcomeRepository();
 138            var matchdays = ParseMatchdays(settings.Matchdays);
 139            var startsAfter = EvaluationTimeParser.ParseOrNull(settings.StartsAfter);
 140            var normalizedStartsAfter = EvaluationTimeParser.NormalizeOrNull(settings.StartsAfter);
 141            var availableItems = await LoadSourceItemsAsync(
 142                matchOutcomeRepository,
 143                settings.CommunityContext,
 144                matchdays,
 145                startsAfter,
 146                cancellationToken);
 47
 148            if (availableItems.Count == 0)
 49            {
 050                throw new InvalidOperationException("No completed historical matches were found for the requested repeat
 51            }
 52
 153            var sampleSeed = settings.SampleSeed ?? int.Parse(
 154                DateTimeOffset.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
 155                CultureInfo.InvariantCulture);
 156            var sliceKey = string.IsNullOrWhiteSpace(settings.SliceKey)
 157                ? $"random-{settings.MatchCount}x{settings.Repetitions}-seed-{sampleSeed}"
 158                : settings.SliceKey.Trim();
 159            var sourcePoolKey = string.IsNullOrWhiteSpace(settings.SourcePoolKey)
 160                ? BuildDefaultSourcePoolKey(matchdays, startsAfter)
 161                : settings.SourcePoolKey.Trim();
 162            var selectedItems = SelectRandomItems(availableItems, settings.MatchCount, sampleSeed)
 163                .OrderBy(item => item.SourceDatasetItemId, StringComparer.Ordinal)
 164                .ToList();
 165            var repeatedItems = ExpandRepeatedItems(selectedItems, sliceKey, settings.Repetitions);
 66
 167            var sourceDatasetName = ExperimentArtifactSupport.BuildSourceDatasetName(settings.CommunityContext);
 168            var sliceDatasetName = settings.DatasetName
 169                ?? $"{sourceDatasetName}/repeated-match-slices/{sourcePoolKey}/{sliceKey}";
 170            var datasetDescription = string.IsNullOrWhiteSpace(settings.DatasetDescription)
 171                ? null
 172                : settings.DatasetDescription.Trim();
 173            var outputDirectory = ResolveOutputDirectory(settings.OutputDirectory, settings.CommunityContext, sourcePool
 174            var sliceArtifactPath = Path.Combine(outputDirectory, "slice-dataset.json");
 175            var sliceManifestPath = Path.Combine(outputDirectory, "slice-manifest.json");
 76
 177            Directory.CreateDirectory(outputDirectory);
 78
 179            var bundle = PreparedExperimentBundleBuilder.Build(
 180                repeatedItems,
 181                settings.CommunityContext,
 182                sourceDatasetName,
 183                sliceDatasetName,
 184                sliceKey,
 185                "repeated-match-slice",
 186                "repeated-match-slice",
 187                sourcePoolKey,
 188                sampleSeed,
 189                datasetDescription,
 190                BuildDatasetMetadata(settings.MatchCount, settings.Repetitions, normalizedStartsAfter, datasetDescriptio
 191                settings.MatchCount,
 192                settings.Repetitions);
 193            var manifest = bundle.Manifest with
 194            {
 195                TaskType = "repeated-match-slice",
 196                StartsAfter = normalizedStartsAfter
 197            };
 98
 199            await WriteJsonFileAsync(sliceArtifactPath, bundle.Artifact, cancellationToken);
 1100            await WriteJsonFileAsync(sliceManifestPath, manifest, cancellationToken);
 101
 1102            var summary = new
 1103            {
 1104                mode = "repeated-match-slice",
 1105                sourceDatasetName,
 1106                datasetName = manifest.SliceDatasetName,
 1107                manifest.CommunityContext,
 1108                manifest.SourcePoolKey,
 1109                manifest.SliceKey,
 1110                manifest.SliceKind,
 1111                manifest.SampleMethod,
 1112                settings.MatchCount,
 1113                settings.Repetitions,
 1114                manifest.SampleSize,
 1115                manifest.SampleSeed,
 1116                matchdays,
 1117                manifest.StartsAfter,
 1118                datasetDescription = bundle.Artifact.DatasetDescription,
 1119                datasetMetadata = bundle.Artifact.DatasetMetadata,
 1120                manifest.SelectedItemIds,
 1121                manifest.SelectedItemIdsHash,
 1122                outputDirectory,
 1123                sliceArtifactPath,
 1124                sliceManifestPath
 1125            };
 126
 1127            _console.WriteLine(JsonSerializer.Serialize(summary, PreparedExperimentCommandSupport.JsonOptions));
 1128            return 0;
 129        }
 0130        catch (Exception ex)
 131        {
 0132            _logger.LogError(ex, "Error preparing repeated-match-slice experiment artifact");
 0133            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 0134            return 1;
 135        }
 1136    }
 137
 138    private static async Task<IReadOnlyList<PreparedExperimentSourceItem>> LoadSourceItemsAsync(
 139        IMatchOutcomeRepository matchOutcomeRepository,
 140        string communityContext,
 141        IReadOnlyList<int> matchdays,
 142        DateTimeOffset? startsAfter,
 143        CancellationToken cancellationToken)
 144    {
 1145        var sourceItems = new List<PreparedExperimentSourceItem>();
 1146        var startsAfterInstant = startsAfter is null
 1147            ? (Instant?)null
 1148            : Instant.FromDateTimeOffset(startsAfter.Value);
 149
 1150        foreach (var matchday in matchdays)
 151        {
 1152            var outcomes = await matchOutcomeRepository.GetMatchdayOutcomesAsync(matchday, communityContext, cancellatio
 1153            foreach (var outcome in outcomes)
 154            {
 1155                if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 156                {
 157                    continue;
 158                }
 159
 1160                if (startsAfterInstant is not null && outcome.StartsAt.ToInstant() <= startsAfterInstant.Value)
 161                {
 162                    continue;
 163                }
 164
 1165                var datasetItem = ExperimentArtifactSupport.BuildHostedDatasetItem(outcome);
 1166                sourceItems.Add(new PreparedExperimentSourceItem(
 1167                    datasetItem.Id,
 1168                    datasetItem.Id,
 1169                    datasetItem.Id,
 1170                    datasetItem.Metadata.Competition,
 1171                    datasetItem.Metadata.Season,
 1172                    datasetItem.Metadata.CommunityContext,
 1173                    datasetItem.Metadata.Matchday,
 1174                    datasetItem.Metadata.MatchdayLabel,
 1175                    datasetItem.Metadata.HomeTeam,
 1176                    datasetItem.Metadata.AwayTeam,
 1177                    GetStartsAt(datasetItem),
 1178                    datasetItem.Metadata.TippSpielId,
 1179                    datasetItem.ExpectedOutput.HomeGoals,
 1180                    datasetItem.ExpectedOutput.AwayGoals));
 181            }
 182        }
 183
 1184        return sourceItems
 1185            .OrderBy(item => item.SourceDatasetItemId, StringComparer.Ordinal)
 1186            .ToList();
 1187    }
 188
 189    private static IReadOnlyList<PreparedExperimentSourceItem> ExpandRepeatedItems(
 190        IReadOnlyList<PreparedExperimentSourceItem> selectedItems,
 191        string sliceKey,
 192        int repetitions)
 193    {
 1194        var repeatedItems = new List<PreparedExperimentSourceItem>(selectedItems.Count * repetitions);
 1195        for (var fixtureIndex = 1; fixtureIndex <= selectedItems.Count; fixtureIndex += 1)
 196        {
 1197            var selectedItem = selectedItems[fixtureIndex - 1];
 1198            for (var repetitionIndex = 1; repetitionIndex <= repetitions; repetitionIndex += 1)
 199            {
 1200                var sliceDatasetItemId = ExperimentArtifactSupport.BuildRepeatedMatchSliceDatasetItemId(
 1201                    selectedItem.SourceDatasetItemId,
 1202                    sliceKey,
 1203                    fixtureIndex,
 1204                    selectedItems.Count,
 1205                    repetitionIndex,
 1206                    repetitions);
 1207                repeatedItems.Add(selectedItem with
 1208                {
 1209                    SliceDatasetItemId = sliceDatasetItemId,
 1210                    FixtureIndex = fixtureIndex,
 1211                    RepetitionIndex = repetitionIndex
 1212                });
 213            }
 214        }
 215
 1216        return repeatedItems;
 217    }
 218
 219    private static string GetStartsAt(HostedMatchExperimentDatasetItem item)
 220    {
 1221        if (item.Input.ValueKind != JsonValueKind.Object
 1222            || !item.Input.TryGetProperty("startsAt", out var startsAt)
 1223            || startsAt.ValueKind != JsonValueKind.String
 1224            || string.IsNullOrWhiteSpace(startsAt.GetString()))
 225        {
 0226            throw new InvalidOperationException($"Dataset item '{item.Id}' is missing input.startsAt.");
 227        }
 228
 1229        return startsAt.GetString()!;
 230    }
 231
 232    private static IReadOnlyList<PreparedExperimentSourceItem> SelectRandomItems(
 233        IReadOnlyList<PreparedExperimentSourceItem> items,
 234        int count,
 235        int seed)
 236    {
 1237        if (items.Count < count)
 238        {
 0239            throw new InvalidOperationException(
 0240                $"Requested match count {count} exceeds available dataset item count {items.Count}.");
 241        }
 242
 1243        var buffer = items.ToList();
 1244        var random = new Random(seed);
 1245        for (var index = buffer.Count - 1; index > 0; index -= 1)
 246        {
 1247            var swapIndex = random.Next(index + 1);
 1248            (buffer[index], buffer[swapIndex]) = (buffer[swapIndex], buffer[index]);
 249        }
 250
 1251        return buffer.Take(count).ToList();
 252    }
 253
 254    private static IReadOnlyList<int> ParseMatchdays(string? matchdays)
 255    {
 1256        if (string.IsNullOrWhiteSpace(matchdays))
 257        {
 0258            return Enumerable.Range(1, 34).ToList().AsReadOnly();
 259        }
 260
 1261        return matchdays
 1262            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 1263            .Select(segment => int.Parse(segment, CultureInfo.InvariantCulture))
 1264            .Distinct()
 0265            .OrderBy(matchday => matchday)
 1266            .ToList()
 1267            .AsReadOnly();
 268    }
 269
 270    private static string BuildDefaultSourcePoolKey(IReadOnlyList<int> matchdays, DateTimeOffset? startsAfter)
 271    {
 1272        var baseKey = matchdays.SequenceEqual(Enumerable.Range(1, 34))
 1273            ? "all-matchdays"
 1274            : $"matchdays-{string.Join('-', matchdays)}";
 275
 1276        if (startsAfter is null)
 277        {
 1278            return baseKey;
 279        }
 280
 0281        var utcToken = startsAfter.Value
 0282            .ToUniversalTime()
 0283            .ToString("yyyyMMdd't'HHmmss'z'", CultureInfo.InvariantCulture)
 0284            .ToLowerInvariant();
 0285        return $"{baseKey}-after-{utcToken}";
 286    }
 287
 288    private static IReadOnlyDictionary<string, object?> BuildDatasetMetadata(
 289        int matchCount,
 290        int repetitions,
 291        string? startsAfter,
 292        string? datasetDescription)
 293    {
 1294        var metadata = new Dictionary<string, object?>
 1295        {
 1296            ["matchCount"] = matchCount,
 1297            ["repetitions"] = repetitions,
 1298            ["predictionCount"] = matchCount * repetitions
 1299        };
 300
 1301        if (!string.IsNullOrWhiteSpace(startsAfter))
 302        {
 0303            metadata["startsAfter"] = startsAfter;
 304        }
 305
 1306        if (!string.IsNullOrWhiteSpace(datasetDescription))
 307        {
 0308            metadata["datasetDescription"] = datasetDescription;
 309        }
 310
 1311        return metadata;
 312    }
 313
 314    private static string ResolveOutputDirectory(
 315        string? outputDirectoryOverride,
 316        string communityContext,
 317        string sourcePoolKey,
 318        string sliceKey)
 319    {
 1320        if (!string.IsNullOrWhiteSpace(outputDirectoryOverride))
 321        {
 1322            return Path.GetFullPath(outputDirectoryOverride);
 323        }
 324
 0325        return Path.GetFullPath(Path.Combine(
 0326            "artifacts",
 0327            "langfuse-experiments",
 0328            "repeated-match-slices",
 0329            ExperimentArtifactSupport.Slugify(communityContext),
 0330            sourcePoolKey,
 0331            sliceKey));
 332    }
 333
 334    private static Task WriteJsonFileAsync<T>(string path, T value, CancellationToken cancellationToken)
 335    {
 1336        return File.WriteAllTextAsync(path, JsonSerializer.Serialize(value, PreparedExperimentCommandSupport.JsonOptions
 337    }
 338}