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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user