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
@@ -55,9 +55,9 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests
new(_anomalies, _events, _results,
NullLogger<EvaluateAnomalyOutcomesUseCase>.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<CancellationToken>())
.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<CancellationToken>()).Returns(MakeEvent(id, 11));
// Post-flip favourite (Side2) wins → both resolve as a Hit.
_results.GetAsync(id, Arg.Any<CancellationToken>())
.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()
{