Files
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

123 lines
4.1 KiB
C#

using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Enums;
namespace Marathon.Domain.Tests.AnomalyDetection;
/// <summary>
/// Unit tests for <see cref="AnomalyEvidenceParser"/> covering happy path,
/// two-way (no draw), and malformed JSON tolerance.
/// </summary>
public sealed class AnomalyEvidenceParserTests
{
[Fact]
public void Should_Parse_ThreeWayEvidence_With_DrawOutcome()
{
const string json = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
parsed.Should().BeTrue();
data.SuspensionGapSeconds.Should().Be(90);
data.PreSuspension.P1.Should().Be(0.55m);
data.PreSuspension.PDraw.Should().Be(0.20m);
data.PreSuspension.Favourite.Should().Be(Side.Side1, "Side1 had the highest pre-suspension probability");
data.PostSuspension.Favourite.Should().Be(Side.Side2, "Side2 became favourite after the flip");
}
[Fact]
public void Should_Parse_TwoWayEvidence_With_NullDraw()
{
const string json = """
{
"suspensionGapSeconds": 75,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.70, "p2": 0.30,
"rate1": 1.4, "rate2": 3.3
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:30+03:00",
"p1": 0.30, "p2": 0.70,
"rate1": 3.3, "rate2": 1.4
}
}
""";
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
parsed.Should().BeTrue();
data.PreSuspension.PDraw.Should().BeNull("tennis has no draw outcome");
data.PreSuspension.RateDraw.Should().BeNull();
data.PreSuspension.Favourite.Should().Be(Side.Side1);
data.PostSuspension.Favourite.Should().Be(Side.Side2);
}
[Fact]
public void Should_ReturnFalse_When_JsonIsNullOrEmpty()
{
AnomalyEvidenceParser.TryParse(null, out _).Should().BeFalse();
AnomalyEvidenceParser.TryParse(string.Empty, out _).Should().BeFalse();
AnomalyEvidenceParser.TryParse(" ", out _).Should().BeFalse();
}
[Fact]
public void Should_ReturnFalse_When_JsonIsMalformed()
{
AnomalyEvidenceParser.TryParse("{not json", out _).Should().BeFalse();
}
[Fact]
public void Should_ReturnFalse_When_PreOrPostSuspensionMissing()
{
const string onlyPre = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "p2": 0.25, "rate1": 1.8, "rate2": 4.0
}
}
""";
AnomalyEvidenceParser.TryParse(onlyPre, out _).Should().BeFalse();
}
[Fact]
public void Favourite_Should_Be_Draw_When_DrawIsMostLikely()
{
const string json = """
{
"suspensionGapSeconds": 60,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:00+03:00",
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
}
}
""";
AnomalyEvidenceParser.TryParse(json, out var data).Should().BeTrue();
data.PreSuspension.Favourite.Should().Be(Side.Draw);
}
}