Files
maraphon-app/src/Marathon.UI/Services/AnomalyViewModels.cs
T
alexei.dolgolyov 292223174c feat(insights): anomaly outcome validator — hit-rate calibration page
Adds a calibration dashboard that joins persisted SuspensionFlip anomalies
with EventResult rows and reports whether the post-flip favourite actually
won — the single metric that says whether the detector is doing its job.

Domain:
- AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by
  AnomalyDetector without re-implementing the schema.
- AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved.
  Tennis-style two-way markets with a Draw winner are downgraded to
  Unresolved rather than silently counted as Miss.
- AnomalySeverityThresholds: shared Low/Medium/High constants so the UI
  badge and the report buckets cannot drift.

Application:
- EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation.
- AnomalyOutcomeReport carries totals, hit rate, three breakdowns
  (severity / sport / score bins) and a per-event title lookup so the UI
  needs no second pass over IEventRepository.
- Score bins extend below 0.30 automatically when the operator lowers the
  detector threshold so the histogram total always equals ResolvedCount.

UI:
- Insights page at /anomalies/insights — hero header, 4-card KPI strip
  (hit rate tinted by tone), three breakdown grids with bar visualisation,
  drill-down tables for resolved and unresolved anomalies. Honors
  prefers-reduced-motion. RU + EN localisation.
- Nav entry under Analysis section + chip button on the Anomaly Feed.

Tests: +42 across Domain + Application (evaluator boundary cases including
tennis two-way and Draw guard, score-bin edges, dynamic floor when
threshold is lowered, event-title pass-through). All 324 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:31 +03:00

109 lines
3.2 KiB
C#

using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Severity bucket derived from <see cref="AnomalyListItem.Score"/>.
/// Phase 7 mapping (see backend handoff):
/// Low = [0.30, 0.45), Medium = [0.45, 0.60), High = [0.60, 1.00].
/// </summary>
public enum AnomalySeverity
{
Low,
Medium,
High,
}
/// <summary>
/// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>.
/// All fields optional — empty filter returns the full feed.
/// </summary>
public sealed record AnomalyFilter(
AnomalySeverity? MinSeverity = null,
IReadOnlyCollection<int>? SportCodes = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null);
/// <summary>
/// Compact anomaly row used by the feed page. Designed to render without any
/// further repository calls — pre-shaped strings + parsed evidence summary.
/// </summary>
public sealed record AnomalyListItem(
Guid Id,
EventId EventId,
string EventTitle,
SportCode Sport,
string CountryCode,
string LeagueId,
DateTimeOffset DetectedAt,
decimal Score,
AnomalySeverity Severity,
AnomalyKind Kind,
int SuspensionGapSeconds,
decimal? PreWin1Rate,
decimal? PreDrawRate,
decimal? PreWin2Rate,
decimal? PostWin1Rate,
decimal? PostDrawRate,
decimal? PostWin2Rate,
AnomalyFavourite PreFavourite,
AnomalyFavourite PostFavourite,
bool IsTwoWay);
/// <summary>
/// Full anomaly aggregate for the detail page. Carries the parsed evidence
/// snapshots plus the originating event metadata for the link-back affordance.
/// </summary>
public sealed record AnomalyDetailVm(
AnomalyListItem Item,
AnomalyEvidenceSnapshot Pre,
AnomalyEvidenceSnapshot Post);
/// <summary>
/// Snapshot bracket of the suspension window — pre or post — with raw rates,
/// implied probabilities, and the side that was the favourite at that moment.
/// </summary>
public sealed record AnomalyEvidenceSnapshot(
DateTimeOffset CapturedAt,
decimal? Rate1,
decimal? RateDraw,
decimal? Rate2,
decimal? P1,
decimal? PDraw,
decimal? P2,
AnomalyFavourite Favourite);
/// <summary>Side that holds the lowest implied probability in a snapshot.</summary>
public enum AnomalyFavourite
{
Side1,
Draw,
Side2,
None,
}
/// <summary>Helpers for severity bucketing. Thresholds come from
/// <see cref="AnomalySeverityThresholds"/> so the UI badges and the
/// Application-layer outcome report agree by construction.</summary>
public static class AnomalySeverityRules
{
public const decimal LowThreshold = AnomalySeverityThresholds.Low;
public const decimal MediumThreshold = AnomalySeverityThresholds.Medium;
public const decimal HighThreshold = AnomalySeverityThresholds.High;
public static AnomalySeverity FromScore(decimal score) => score switch
{
< MediumThreshold => AnomalySeverity.Low,
< HighThreshold => AnomalySeverity.Medium,
_ => AnomalySeverity.High,
};
public static bool MeetsThreshold(AnomalySeverity actual, AnomalySeverity? minimum)
{
if (minimum is null) return true;
return (int)actual >= (int)minimum.Value;
}
}