Files
maraphon-app/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs
T
alexei.dolgolyov 67f2ae130c 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.
2026-05-29 11:52:25 +03:00

262 lines
11 KiB
C#

using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Reporting;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
/// <see cref="Anomaly"/> with the originating event and its
/// <see cref="EventResult"/>, then running the pure
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
/// </summary>
/// <remarks>
/// <para>
/// This is the answer to "does the SuspensionFlip detector actually predict the
/// right side?" The report is the validator for the entire anomaly-detection
/// premise of the product — without it, the algorithm's confidence score is
/// just a number with no calibration.
/// </para>
/// <para>
/// The use case loads all three collections in one pass each and performs the
/// join in memory. Anomaly volumes are small (one per suspension interval per
/// event) so this is well within budget. If volumes grow significantly the
/// repository layer can later add a SQL-side join — the public shape of the
/// report does not change.
/// </para>
/// </remarks>
public sealed class EvaluateAnomalyOutcomesUseCase
{
/// <summary>
/// Lowest score bin shown in the histogram. Score values below this never
/// appear because the detector enforces a configurable threshold (default
/// 0.30) — but the constant is repeated here so the bucketer is independent
/// of any specific configuration value.
/// </summary>
public const decimal MinScore = 0.30m;
/// <summary>
/// Bin width for the score histogram. Yields 7 buckets:
/// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80),
/// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right.
/// </summary>
public const decimal BinWidth = 0.10m;
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
public EvaluateAnomalyOutcomesUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<EvaluateAnomalyOutcomesUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AnomalyOutcomeReport> ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started");
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: no anomalies — empty report");
return EmptyReport();
}
// Batched lookups — a single query each, replacing the prior per-event
// GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
foreach (var (id, ev) in eventLookup)
eventTitles[id] = ev.Title;
// Evaluate every anomaly through the pure domain function.
var resolved = new List<ResolvedAnomaly>();
var unresolved = new List<ResolvedAnomaly>();
foreach (var anomaly in anomalies)
{
ct.ThrowIfCancellationRequested();
eventLookup.TryGetValue(anomaly.EventId, out var ev);
resultLookup.TryGetValue(anomaly.EventId, out var result);
var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result);
if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved)
unresolved.Add(evaluated);
else
resolved.Add(evaluated);
}
var resolvedOrdered = resolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var unresolvedOrdered = unresolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
var missCount = resolvedOrdered.Count - hitCount;
var report = new AnomalyOutcomeReport(
TotalAnomalies: anomalies.Count,
ResolvedCount: resolvedOrdered.Count,
UnresolvedCount: unresolvedOrdered.Count,
HitCount: hitCount,
MissCount: missCount,
HitRate: ComputeRate(hitCount, resolvedOrdered.Count),
BySeverity: BuildSeverityBuckets(resolvedOrdered),
BySport: BuildSportBuckets(resolvedOrdered),
ByScoreBin: BuildScoreBins(resolvedOrdered),
ByKind: BuildKindBuckets(resolvedOrdered),
Resolved: resolvedOrdered,
Unresolved: unresolvedOrdered,
EventTitles: eventTitles);
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}",
report.TotalAnomalies, report.ResolvedCount, report.HitCount);
return report;
}
// ── Bucketers ────────────────────────────────────────────────────────────
private static IReadOnlyList<OutcomeBucket> BuildSeverityBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Thresholds sourced from the Domain so the UI's severity badge and
// this report cannot drift out of sync — single source of truth.
return new[]
{
BuildBucket(OutcomeBucketKeys.SeverityLow,
resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)),
BuildBucket(OutcomeBucketKeys.SeverityMedium,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium
&& r.Score < AnomalySeverityThresholds.High)),
BuildBucket(OutcomeBucketKeys.SeverityHigh,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)),
};
}
private static IReadOnlyList<OutcomeBucket> BuildSportBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
return resolved
.Where(r => r.Sport is not null)
.GroupBy(r => r.Sport!.Value)
.OrderBy(g => g.Key)
.Select(g => BuildBucket(
key: string.Format(
CultureInfo.InvariantCulture,
"{0}{1}",
OutcomeBucketKeys.SportPrefix,
g.Key),
items: g))
.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)
{
// Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins.
// If the operator has lowered the detector's flip threshold and we have
// resolved anomalies below 0.30, prepend additional bins so every row in
// the report shows up in exactly one bucket — the histogram total must
// equal ResolvedCount no matter how the detector is tuned.
var floor = MinScore;
if (resolved.Count > 0)
{
var lowest = resolved.Min(r => r.Score);
if (lowest < MinScore)
{
var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth);
floor = MinScore - binsBelow * BinWidth;
if (floor < 0m) floor = 0m;
}
}
var bins = new List<OutcomeBucket>();
for (var start = floor; start < 1.0m; start += BinWidth)
{
var binStart = start;
var binEnd = start + BinWidth;
var isLast = binEnd >= 1.0m;
// Last bin is closed on the right so 1.00 lands in [0.90, 1.00].
var inBin = resolved.Where(r =>
r.Score >= binStart &&
(isLast ? r.Score <= 1.0m : r.Score < binEnd));
var key = string.Format(
CultureInfo.InvariantCulture,
"{0}{1:0.00}-{2:0.00}",
OutcomeBucketKeys.BinPrefix,
binStart,
Math.Min(binEnd, 1.0m));
bins.Add(BuildBucket(key, inBin));
}
return bins;
}
private static OutcomeBucket BuildBucket(string key, IEnumerable<ResolvedAnomaly> items)
{
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? items.ToList();
var total = list.Count;
var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
return new OutcomeBucket(key, total, hits, ComputeRate(hits, total));
}
private static decimal? ComputeRate(int numerator, int denominator) =>
denominator == 0
? null
: Math.Round(numerator / (decimal)denominator, 4);
private static AnomalyOutcomeReport EmptyReport() =>
new(
TotalAnomalies: 0,
ResolvedCount: 0,
UnresolvedCount: 0,
HitCount: 0,
MissCount: 0,
HitRate: null,
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>());
}