feat(insights): hit-rate breakdown by detector kind

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.
This commit is contained in:
2026-05-29 11:52:25 +03:00
parent f512a08772
commit 67f2ae130c
9 changed files with 89 additions and 3 deletions
@@ -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<OutcomeBucket> BuildKindBuckets(
IReadOnlyCollection<ResolvedAnomaly> 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<OutcomeBucket> BuildScoreBins(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
@@ -239,6 +254,7 @@ public sealed class EvaluateAnomalyOutcomesUseCase
BySeverity: Array.Empty<OutcomeBucket>(),
BySport: Array.Empty<OutcomeBucket>(),
ByScoreBin: Array.Empty<OutcomeBucket>(),
ByKind: Array.Empty<OutcomeBucket>(),
Resolved: Array.Empty<ResolvedAnomaly>(),
Unresolved: Array.Empty<ResolvedAnomaly>(),
EventTitles: new Dictionary<DomainEventId, string>());