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()
{