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>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AnomalyOutcomeEvaluator"/> covering the join with
|
||||
/// <see cref="EventResult"/> for hit / miss / unresolved verdicts.
|
||||
/// </summary>
|
||||
public sealed class AnomalyOutcomeEvaluatorTests
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly EventId DefaultEventId = new("12345678");
|
||||
|
||||
private const string ThreeWayFlipJson = """
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static Anomaly MakeAnomaly(string evidenceJson, decimal score = 0.5m) =>
|
||||
new(
|
||||
Id: Guid.NewGuid(),
|
||||
EventId: DefaultEventId,
|
||||
DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
Score: score,
|
||||
EvidenceJson: evidenceJson);
|
||||
|
||||
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) =>
|
||||
new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportHit_When_PostFlipFavourite_Wins()
|
||||
{
|
||||
// Post-flip favourite = Side2; result = Side2 wins → Hit.
|
||||
var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m);
|
||||
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
||||
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
||||
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||
verdict.ActualWinner.Should().Be(Side.Side2);
|
||||
verdict.Sport!.Value.Should().Be(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportMiss_When_PostFlipFavourite_Loses()
|
||||
{
|
||||
// Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong).
|
||||
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||
var result = MakeResult(Side.Side1, s1: 2, s2: 0);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
||||
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||
verdict.ActualWinner.Should().Be(Side.Side1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw()
|
||||
{
|
||||
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||
var result = MakeResult(Side.Draw, s1: 1, s2: 1);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
||||
verdict.ActualWinner.Should().Be(Side.Draw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportUnresolved_When_ResultIsNull()
|
||||
{
|
||||
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
||||
verdict.ActualWinner.Should().BeNull();
|
||||
// Pre/post favourites still computed for display.
|
||||
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
||||
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed()
|
||||
{
|
||||
var anomaly = MakeAnomaly("{malformed");
|
||||
var result = MakeResult(Side.Side1);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved,
|
||||
"evidence cannot be parsed so we cannot judge the prediction");
|
||||
verdict.PreFlipFavourite.Should().BeNull(
|
||||
"fabricated favourites would mislead any consumer that reads the unresolved branch");
|
||||
verdict.PostFlipFavourite.Should().BeNull();
|
||||
verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins()
|
||||
{
|
||||
const string twoWayJson = """
|
||||
{
|
||||
"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 anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
||||
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
||||
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner()
|
||||
{
|
||||
// Tennis cannot draw — if the result is Draw the data is inconsistent
|
||||
// with the evidence and we refuse to grade rather than silently miss-classify.
|
||||
const string twoWayJson = """
|
||||
{
|
||||
"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 anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
||||
var result = MakeResult(Side.Draw);
|
||||
|
||||
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
||||
|
||||
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
||||
verdict.ActualWinner.Should().Be(Side.Draw);
|
||||
verdict.PostFlipFavourite.Should().Be(Side.Side2,
|
||||
"favourite is still computed for display, just not graded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Throw_When_AnomalyIsNull()
|
||||
{
|
||||
var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user