From 67f2ae130c3953befaddba1362024f27ac092246 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 11:52:25 +0300 Subject: [PATCH] feat(insights): hit-rate breakdown by detector kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "By detector kind" breakdown to the anomaly outcome report + Insights page, answering "which directional detector actually wins?". Only directional kinds (SuspensionFlip, SteamMove) resolve to hit/miss, so the breakdown naturally shows just those — mirrors the existing by-sport / by-severity bucketers and rendering. - AnomalyOutcomeReport.ByKind + EvaluateAnomalyOutcomesUseCase.BuildKindBuckets (keyed OutcomeBucketKeys.KindPrefix + enum name); threaded through the insights VM/service. - Insights page: new section + BucketRenderKind.Kind label case (resolves the enum name to the Anomaly.Kind.* resx); en/ru section heading. - 2 tests: empty-report ByKind + directional-kind grouping. --- .../Reporting/AnomalyOutcomeReport.cs | 5 +++ .../Reporting/OutcomeBucketKeys.cs | 3 ++ .../EvaluateAnomalyOutcomesUseCase.cs | 16 +++++++++ .../Pages/Anomalies/Insights.razor | 28 +++++++++++++++ .../Resources/SharedResource.en.resx | 1 + .../Resources/SharedResource.ru.resx | 1 + .../Services/AnomalyInsightsService.cs | 1 + .../Services/AnomalyInsightsViewModels.cs | 1 + .../EvaluateAnomalyOutcomesUseCaseTests.cs | 36 +++++++++++++++++-- 9 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs b/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs index 9ac26fb..5870e94 100644 --- a/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs +++ b/src/Marathon.Application/Reporting/AnomalyOutcomeReport.cs @@ -18,6 +18,10 @@ namespace Marathon.Application.Reporting; /// Breakdown by Low / Medium / High severity buckets. /// Breakdown by sport code. /// Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00]. +/// +/// Breakdown by detector kind. Only directional kinds (SuspensionFlip, SteamMove) ever +/// resolve to a hit/miss, so non-directional kinds simply don't appear here. +/// /// All resolved anomalies, newest first. Drives the drill-down table. /// All unresolved anomalies, newest first. /// @@ -37,6 +41,7 @@ public sealed record AnomalyOutcomeReport( IReadOnlyList BySeverity, IReadOnlyList BySport, IReadOnlyList ByScoreBin, + IReadOnlyList ByKind, IReadOnlyList Resolved, IReadOnlyList Unresolved, IReadOnlyDictionary EventTitles); diff --git a/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs b/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs index 0ff62f6..2e3453e 100644 --- a/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs +++ b/src/Marathon.Application/Reporting/OutcomeBucketKeys.cs @@ -14,6 +14,9 @@ public static class OutcomeBucketKeys /// Prefix for score-bin buckets, e.g. Bin.0.30-0.40. public const string BinPrefix = "Bin."; + /// Prefix for detector-kind buckets, e.g. Kind.SteamMove (the enum name). + public const string KindPrefix = "Kind."; + /// Prefix for severity buckets, e.g. Severity.High. public const string SeverityPrefix = "Severity."; diff --git a/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs index eac54cd..cc109f4 100644 --- a/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs +++ b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs @@ -124,6 +124,7 @@ public sealed class EvaluateAnomalyOutcomesUseCase BySeverity: BuildSeverityBuckets(resolvedOrdered), BySport: BuildSportBuckets(resolvedOrdered), ByScoreBin: BuildScoreBins(resolvedOrdered), + ByKind: BuildKindBuckets(resolvedOrdered), Resolved: resolvedOrdered, Unresolved: unresolvedOrdered, EventTitles: eventTitles); @@ -171,6 +172,20 @@ public sealed class EvaluateAnomalyOutcomesUseCase .ToList(); } + private static IReadOnlyList BuildKindBuckets( + IReadOnlyCollection resolved) + { + // Only directional kinds resolve to a hit/miss (the evaluator leaves the rest + // Unresolved), so this naturally shows just the directional detectors. + return resolved + .GroupBy(r => r.Kind) + .OrderBy(g => (int)g.Key) + .Select(g => BuildBucket( + key: OutcomeBucketKeys.KindPrefix + g.Key, + items: g)) + .ToList(); + } + private static IReadOnlyList BuildScoreBins( IReadOnlyCollection resolved) { @@ -239,6 +254,7 @@ public sealed class EvaluateAnomalyOutcomesUseCase BySeverity: Array.Empty(), BySport: Array.Empty(), ByScoreBin: Array.Empty(), + ByKind: Array.Empty(), Resolved: Array.Empty(), Unresolved: Array.Empty(), EventTitles: new Dictionary()); diff --git a/src/Marathon.UI/Pages/Anomalies/Insights.razor b/src/Marathon.UI/Pages/Anomalies/Insights.razor index 5870c1a..ee75b97 100644 --- a/src/Marathon.UI/Pages/Anomalies/Insights.razor +++ b/src/Marathon.UI/Pages/Anomalies/Insights.razor @@ -106,6 +106,16 @@
+ @* ---------- By detector kind ---------- *@ +
+
+ @L["Insights.Section.ByKind"] +
+ @RenderBucketTable(vm.ByKind, BucketRenderKind.Kind) +
+ +
+ @* ---------- By sport ---------- *@
@@ -630,6 +640,7 @@ Severity, Sport, Score, + Kind, } private AnomalyInsightsVm? _vm; @@ -826,6 +837,23 @@ } break; } + case BucketRenderKind.Kind: + { + var trimmed = key.StartsWith(OutcomeBucketKeys.KindPrefix, StringComparison.Ordinal) + ? key.Substring(OutcomeBucketKeys.KindPrefix.Length) + : key; + var locKey = trimmed switch + { + nameof(AnomalyKind.SuspensionFlip) => "Anomaly.Kind.SuspensionFlip", + nameof(AnomalyKind.SteamMove) => "Anomaly.Kind.SteamMove", + nameof(AnomalyKind.SuspensionFreeze) => "Anomaly.Kind.SuspensionFreeze", + nameof(AnomalyKind.OverroundCompression) => "Anomaly.Kind.OverroundCompression", + _ => null, + }; + if (locKey is null) builder.AddContent(0, trimmed); + else builder.AddContent(0, L[locKey]); + break; + } case BucketRenderKind.Score: default: { diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index fa7c163..d7fd7ef 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -370,6 +370,7 @@ Total anomalies By severity By sport + By detector kind By confidence score Resolved anomalies Awaiting results diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 2e9be4c..8c98797 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -383,6 +383,7 @@ Всего аномалий По уровню По виду спорта + По типу детектора По уверенности Подтверждённые аномалии Ожидают итога diff --git a/src/Marathon.UI/Services/AnomalyInsightsService.cs b/src/Marathon.UI/Services/AnomalyInsightsService.cs index 933f507..5cd766d 100644 --- a/src/Marathon.UI/Services/AnomalyInsightsService.cs +++ b/src/Marathon.UI/Services/AnomalyInsightsService.cs @@ -40,6 +40,7 @@ public sealed class AnomalyInsightsService : IAnomalyInsightsService BySeverity: report.BySeverity, BySport: report.BySport, ByScoreBin: report.ByScoreBin, + ByKind: report.ByKind, Resolved: resolvedRows, Unresolved: unresolvedRows); } diff --git a/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs b/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs index 004b19b..7237ba3 100644 --- a/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs +++ b/src/Marathon.UI/Services/AnomalyInsightsViewModels.cs @@ -20,6 +20,7 @@ public sealed record AnomalyInsightsVm( IReadOnlyList BySeverity, IReadOnlyList BySport, IReadOnlyList ByScoreBin, + IReadOnlyList ByKind, IReadOnlyList Resolved, IReadOnlyList Unresolved); diff --git a/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs index 08b7a01..e204323 100644 --- a/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs @@ -55,9 +55,9 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests new(_anomalies, _events, _results, NullLogger.Instance); - private static Anomaly MakeAnomaly(EventId eventId, decimal score) => - new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip, - score, FlipEvidence); + private static Anomaly MakeAnomaly( + EventId eventId, decimal score, AnomalyKind kind = AnomalyKind.SuspensionFlip) => + new(Guid.NewGuid(), eventId, BaseTime, kind, score, FlipEvidence); private static Event MakeEvent(EventId id, int sportCode) => new(id, new SportCode(sportCode), "BY", "L1", "Cat", @@ -77,6 +77,7 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests report.BySport.Should().BeEmpty(); report.BySeverity.Should().BeEmpty(); report.ByScoreBin.Should().BeEmpty(); + report.ByKind.Should().BeEmpty(); } [Fact] @@ -202,6 +203,35 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests report.BySport.Single(b => b.Key == "Sport.6").HitRate.Should().Be(0.0m); } + [Fact] + public async Task Should_GroupByKind_ForDirectionalDetectors() + { + var idFlip = new EventId("flip0000"); + var idSteam = new EventId("steam000"); + + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeAnomaly(idFlip, score: 0.55m, kind: AnomalyKind.SuspensionFlip), + MakeAnomaly(idSteam, score: 0.55m, kind: AnomalyKind.SteamMove), + }.ToList().AsReadOnly()); + + foreach (var id in new[] { idFlip, idSteam }) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); + // Post-flip favourite (Side2) wins → both resolve as a Hit. + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.ByKind.Select(b => b.Key) + .Should().BeEquivalentTo(new[] { "Kind.SuspensionFlip", "Kind.SteamMove" }); + report.ByKind.Single(b => b.Key == "Kind.SteamMove").Total.Should().Be(1); + report.ByKind.Single(b => b.Key == "Kind.SuspensionFlip").HitRate.Should().Be(1.0m); + } + [Fact] public async Task Should_BuildSevenScoreBins_With_CanonicalKeys() {