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:
@@ -18,6 +18,10 @@ namespace Marathon.Application.Reporting;
|
||||
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
|
||||
/// <param name="BySport">Breakdown by sport code.</param>
|
||||
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
|
||||
/// <param name="ByKind">
|
||||
/// Breakdown by detector kind. Only directional kinds (SuspensionFlip, SteamMove) ever
|
||||
/// resolve to a hit/miss, so non-directional kinds simply don't appear here.
|
||||
/// </param>
|
||||
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
|
||||
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
|
||||
/// <param name="EventTitles">
|
||||
@@ -37,6 +41,7 @@ public sealed record AnomalyOutcomeReport(
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<OutcomeBucket> ByKind,
|
||||
IReadOnlyList<ResolvedAnomaly> Resolved,
|
||||
IReadOnlyList<ResolvedAnomaly> Unresolved,
|
||||
IReadOnlyDictionary<DomainEventId, string> EventTitles);
|
||||
|
||||
@@ -14,6 +14,9 @@ public static class OutcomeBucketKeys
|
||||
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
|
||||
public const string BinPrefix = "Bin.";
|
||||
|
||||
/// <summary>Prefix for detector-kind buckets, e.g. <c>Kind.SteamMove</c> (the enum name).</summary>
|
||||
public const string KindPrefix = "Kind.";
|
||||
|
||||
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
|
||||
public const string SeverityPrefix = "Severity.";
|
||||
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -106,6 +106,16 @@
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By detector kind ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-kind">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.ByKind"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.ByKind, BucketRenderKind.Kind)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By sport ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
|
||||
<header class="m-insights__section-head">
|
||||
@@ -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:
|
||||
{
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
|
||||
<data name="Insights.Section.BySport"><value>By sport</value></data>
|
||||
<data name="Insights.Section.ByKind"><value>By detector kind</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
|
||||
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
|
||||
<data name="Insights.Section.ByKind"><value>По типу детектора</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record AnomalyInsightsVm(
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<OutcomeBucket> ByKind,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Resolved,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Unresolved);
|
||||
|
||||
|
||||
@@ -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