Files
maraphon-app/plans/initial-implementation/phase-7-anomaly-detection.md
T
alexei.dolgolyov 12208a4762 feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.

New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
  Phase 5 placeholder with a severity-coded card stream, filter chips
  (severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
  lockup + AnomalyEvidence panel + back link to /events/{eventCode}.

New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
  amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
  pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
  handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
  bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
  signal-red 3px left border on the post column. Handles 2-way (no draw).

State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
  / AnomalyEvidenceSnapshot record. Severity computed in the view-model
  from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
  parses Anomaly.EvidenceJson into typed view-models, applies filters
  client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
  GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
  sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.

Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
  pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
  user clicks 'Mark all read' on the feed toolbar.

Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
  added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
  (default true).

Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.

Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.

Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.

Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).

Phase 7 status:  Done (backend + frontend both complete, awaiting review).

Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
2026-05-05 13:39:39 +03:00

18 KiB
Raw Blame History

Phase 7: Anomaly Detection (Suspension + Flip)

Status: Done Parent plan: PLAN.md Domain: fullstack Implementer: Sonnet (backend portion) + Opus (UI portion, with frontend-design) Depends on: Phase 4 (snapshot pipeline) + Phase 6 (UI patterns)

Objective

Detect the odds-flip anomaly described in customer TZ §3: bookmaker freezes betting on a live event, then re-opens with inverted underdog/favorite odds. Persist anomalies and surface them in a dedicated UI feed page so the user can act on them.

Tasks

Backend (Sonnet) COMPLETE

  • Implement Marathon.Domain/AnomalyDetection/AnomalyDetector.cs:
    • Pure domain logic — takes IReadOnlyList<OddsSnapshot> for an event, returns IReadOnlyList<Anomaly>
    • Detect suspension intervals: gaps between snapshots > SuspensionGapSeconds (configurable)
    • For each suspension, compute pre-suspension and post-suspension implied probability vectors (p1, pDraw, p2) from Win-1/Draw/Win-2 rates
    • Compute flip score: max(|p_post[i] p_pre[i]|) across i ∈ {1, draw, 2}
    • If flip score ≥ OddsFlipThreshold AND the favourite changed (argmax differs), emit an Anomaly(Kind=SuspensionFlip, Score, EvidenceJson) where EvidenceJson contains the snapshots bracketing the suspension
  • Add AnomalyOptions POCO bound to Anomaly:* (in Marathon.Application/Configuration/):
    public sealed class AnomalyOptions {
      public int SuspensionGapSeconds { get; init; } = 60;
      public decimal OddsFlipThreshold { get; init; } = 0.30m;
      public int MinSnapshotCount { get; init; } = 3;
      public int DetectionIntervalSeconds { get; init; } = 60;
    }
    
  • Implement DetectAnomaliesUseCase in Marathon.Application/UseCases/:
    • Iterate over all events and load snapshots from last 24 h
    • Invoke AnomalyDetector per event
    • Persist new anomalies via IAnomalyRepository with dedup logic
  • Implement AnomalyDetectionPoller : BackgroundService in Marathon.Infrastructure/Workers/:
    • Runs every Anomaly:DetectionIntervalSeconds (default 60s)
    • Calls DetectAnomaliesUseCase
    • Gated by Workers:AnomalyDetectionEnabled (default true)
  • Add WorkerOptions.AnomalyDetectionEnabled (default true)
  • Register DetectAnomaliesUseCase as Scoped in ApplicationModule
  • Bind AnomalyOptions and register AnomalyDetectionPoller in InfrastructureModule
  • Update appsettings.json — add Workers:AnomalyDetectionEnabled: true (all 4 Anomaly:* keys already existed from Phase 5)
  • Backend tests in Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs (10 tests):
    • Empty snapshot list → 0 anomalies ✓
    • Below minSnapshotCount → 0 anomalies ✓
    • Pre-match-only snapshots → 0 anomalies ✓
    • No suspension (regular intervals) → 0 anomalies ✓
    • Suspension but odds shift below threshold → 0 anomalies ✓
    • Suspension + favourite flip (2-way) → 1 anomaly ✓
    • Score calculation correct for known inputs ✓
    • Tennis (no draw) → 1 anomaly ✓
    • Multiple suspensions → multiple anomalies ✓
    • EvidenceJson contains pre/post probability vectors and rates ✓
    • Determinism: same input → same output ✓
    • 3-way market flip (draw becomes favourite) → 1 anomaly ✓
    • Mixed pre-match + live snapshots → only live analysed ✓
  • Application tests in Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs (4 tests):
    • Iterates events, calls detector, persists new anomalies ✓
    • Skips already-persisted anomalies (dedup logic) ✓
    • Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓
    • Returns count of new anomalies ✓

Frontend (Opus + frontend-design) COMPLETE

  • Create Marathon.UI/Pages/Anomalies/AnomalyFeed.razor:
    • List of anomalies sorted by DetectedAt descending
    • Each card shows: severity (color-coded by score), event identity, sport icon, detected timestamp, pre→post odds strip
    • Click card → navigate to /anomalies/{id} detail page
    • Filter: severity threshold (Low/Med/High chips), sport chips, date range
  • Create Marathon.UI/Pages/Anomalies/Detail.razor (per-anomaly page with AnomalyEvidence panel + link back to event)
  • Create Marathon.UI/Components/AnomalyCard.razor — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap.
  • Create Marathon.UI/Components/SeverityBadge.razor — pill: Low (neutral), Medium (amber), High (signal-red, pulsing).
  • Create Marathon.UI/Components/AnomalyEvidence.razor — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout.
  • Add navigation entry to NavBody.razor drawer with pulsing red badge showing unread anomaly count.
  • Create Marathon.UI/Services/IAnomalyBrowsingService.cs + AnomalyBrowsingService.cs + AnomalyBrowsingState.cs + AnomalyViewModels.cs
  • Append Anomaly.* localization keys to SharedResource.ru.resx and SharedResource.en.resx (28 keys, full RU/EN parity)
  • Add Settings UI binding for Workers:AnomalyDetectionEnabled worker flag
  • Frontend tests in Marathon.UI.Tests/Pages/Anomalies/ + Components/:
    • SeverityBadgeTests — score → severity bucket → pill class (9 tests)
    • AnomalyCardTests — severity styling, click callback, 2-way vs 3-way (6 tests)
    • AnomalyEvidenceTests — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests)
    • AnomalyFeedTests — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests)
    • AnomalyDetailTests — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests)

Files to Modify/Create

Backend (done)

  • src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs created
  • src/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs created
  • src/Marathon.Application/Configuration/AnomalyOptions.cs created
  • src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs created
  • src/Marathon.Application/ApplicationModule.cs modified (added DetectAnomaliesUseCase registration)
  • src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs created
  • src/Marathon.Infrastructure/Configuration/WorkerOptions.cs modified (added AnomalyDetectionEnabled)
  • src/Marathon.Infrastructure/InfrastructureModule.cs modified (added AnomalyOptions binding + poller)
  • src/Marathon.Hosts.WpfBlazor/appsettings.json modified (added Workers:AnomalyDetectionEnabled)
  • tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs created (13 tests)
  • tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs created (4 tests)

Frontend (UI agent owns)

  • src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor
  • src/Marathon.UI/Components/AnomalyCard.razor
  • src/Marathon.UI/Services/IAnomalyBrowsingService.cs
  • src/Marathon.UI/Services/AnomalyBrowsingService.cs
  • src/Marathon.UI/Services/AnomalyBrowsingState.cs
  • src/Marathon.UI/Services/AnomalyViewModels.cs
  • src/Marathon.UI/Resources/SharedResource.ru.resx (append new keys)
  • src/Marathon.UI/Resources/SharedResource.en.resx (append new keys)
  • src/Marathon.UI/MainLayout.razor or NavBody.razor (anomaly nav entry)
  • tests/Marathon.UI.Tests/Pages/Anomalies/**

Acceptance Criteria

  • Compiles (Big Bang).
  • AnomalyDetector is a pure function — no I/O, no DI dependencies.
  • Configurable thresholds via appsettings.json.
  • Visible in Settings page (Workers:AnomalyDetectionEnabled toggle in WORKERS section).
  • UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
  • Evidence timeline shows the actual snapshots that triggered the detection (parsed EvidenceJson rendered in the two-column AnomalyEvidence panel on the detail page).

Notes

  • This is the product's actual differentiator — quality of detection logic and evidence presentation matters. Spend time getting the score formula right.
  • Implied probability formula: p = 1 / odds (then normalize so they sum to 1).
  • Big Bang: compile-only smoke check.

Review Checklist

  • Detector is deterministic and pure
  • Score calculation correct (verified against hand-computed example in test comments)
  • No false positives on synthetic "normal" timelines
  • UI evidence timeline matches stored EvidenceJson (AnomalyBrowsingService parses the JSON via System.Text.Json and AnomalyEvidence renders both bracket snapshots verbatim — no synthesised data).
  • All strings localized (RU + EN parity for the 28 new Anomaly.* + 2 new Settings.Workers.AnomalyDetectionEnabled* keys).

Handoff to Next Phase

Handoff to Phase 7 Frontend (UI) Agent

Read this section first. The backend is fully implemented. You own all Marathon.UI files listed above. Do NOT touch any backend files.


What the backend provides

DetectAnomaliesUseCase.ExecuteAsync(CancellationToken)

  • Returns Task<int> (count of new anomalies persisted this cycle).
  • Called automatically by AnomalyDetectionPoller every 60 s (default).
  • You do NOT call this from the UI — it is worker-driven.
  • The UI only reads from IAnomalyRepository.

AnomalyDetector — detection formula (for rendering evidence)

  • Implied probability: p_i = (1 / rate_i) for each win side.
  • Normalisation: divide each p_i by the sum of all raw p_i values → they sum to 1.
  • Flip score: max(|p_post[i] p_pre[i]|) over i ∈ {p1, pDraw?, p2}.
  • Favourite-changed test: argmax(p_pre) != argmax(p_post).
  • An anomaly is emitted only if BOTH conditions hold: score ≥ threshold AND favourite changed.

IAnomalyRepository — the UI service should call:

  • ListAsync(CancellationToken) — all anomalies for the feed page (paginate client-side).
  • GetAsync(Guid id, CancellationToken) — single anomaly for a detail view.
  • There is no ListByEventAsync on IAnomalyRepository (only on ISnapshotRepository). If you need anomalies for a specific event, filter the full list by EventId.

Anomaly entity — fields available to the UI:

Guid           Id          // GUID primary key
EventId        EventId     // bookmaker event code (e.g. "26456117")
DateTimeOffset DetectedAt  // Moscow TZ (UTC+3)
AnomalyKind    Kind        // currently always SuspensionFlip
decimal        Score       // normalised [0, 1] — the largest implied-prob delta
string         EvidenceJson // see shape below

Anomaly.EvidenceJson shape:

{
  "suspensionGapSeconds": 90,
  "preSuspension": {
    "capturedAt": "2026-05-10T18:00:00+03:00",
    "p1": 0.755,
    "pDraw": null,
    "p2": 0.245,
    "rate1": 1.3,
    "rateDraw": null,
    "rate2": 4.0
  },
  "postSuspension": {
    "capturedAt": "2026-05-10T18:01:30+03:00",
    "p1": 0.245,
    "pDraw": null,
    "p2": 0.755,
    "rate1": 4.0,
    "rateDraw": null,
    "rate2": 1.3
  }
}
  • pDraw / rateDraw are null for 2-way markets (tennis, etc.).
  • Use System.Text.Json.JsonDocument.Parse(anomaly.EvidenceJson) to deserialise in the UI. Or define a EvidenceDto record in AnomalyViewModels.cs and use JsonSerializer.Deserialize<EvidenceDto>.

Recommended severity buckets (for color-coding):

Severity Score range MudBlazor color suggestion
Low 0.300.45 Color.Warning
Medium 0.450.60 Color.Error
High 0.60+ deep red / Color.Error + pulsing badge

Settings page addition (UI agent must wire)

Workers:AnomalyDetectionEnabled (bool, default true) was added to WorkerOptions and appsettings.json. The Phase 5 Settings page needs a toggle for it. The existing pattern is the same as LivePollerEnabled and UpcomingPollerEnabled.


Localization keys to add

Append these to both SharedResource.ru.resx and SharedResource.en.resx:

Key EN value RU value
Anomaly.Title Anomaly Feed Лента аномалий
Anomaly.Severity.Low Low Низкая
Anomaly.Severity.Medium Medium Средняя
Anomaly.Severity.High High Высокая
Anomaly.Card.DetectedAt Detected at Обнаружено
Anomaly.Card.Score Score Оценка
Anomaly.Card.Kind.SuspensionFlip Suspension Flip Переворот после паузы
Anomaly.Card.GapSeconds Suspension gap Длительность паузы
Anomaly.Evidence.PreSuspension Before suspension До паузы
Anomaly.Evidence.PostSuspension After suspension После паузы
Anomaly.Evidence.Probability Implied prob. Вероятность
Anomaly.Evidence.Rate Rate Коэффициент
Anomaly.Filter.Severity Min severity Минимальная важность
Anomaly.Filter.Sport Sport Вид спорта
Anomaly.Filter.DateRange Date range Диапазон дат
Anomaly.Empty No anomalies detected yet. Аномалии пока не обнаружены.
Settings.AnomalyDetection Anomaly detection Обнаружение аномалий
Settings.AnomalyDetectionEnabled Enable anomaly detection Включить обнаружение аномалий

Integration pattern for the UI service

Follow the same split as EventBrowsingService (Scoped) + EventBrowsingState (Singleton) documented in CONTEXT.md Phase 6 notes. Specifically:

  • AnomalyBrowsingState (Singleton): holds current filter settings + fires OnChange.
  • AnomalyBrowsingService (Scoped): resolves IAnomalyRepository from the DI scope, loads anomalies, and maps to view-models (AnomalyListItem, AnomalyDetail).
  • AnomalyListItem view-model should include Severity (computed from Score), pre-rendered display strings, and the parsed EvidenceDto.

🟡 Known gaps / deferred items

  • No "last detection run" tracking. The use case currently loads the last 24 h of snapshots for ALL events on every cycle. A Phase 8/9 optimisation: track last-run timestamp per event to limit the snapshot window. Flag this in the UI as "best-effort coverage window: last 24 h".
  • Settings.razor AnomalyDetectionEnabled toggle — backend option exists, UI wiring is the UI agent's responsibility.
  • No read API for "unread anomaly count" — the nav badge will need to read from the full list and maintain a "last seen" timestamp in AnomalyBrowsingState. Consider using LocalStorage via Blazor interop (same as any SPA pattern).

Handoff to Phase 8

Reusable patterns from Phase 7 frontend

Pattern File How Phase 8 (results loader UI) reuses it
State + Service split AnomalyBrowsingState (Singleton) + AnomalyBrowsingService (Scoped) Mirror for results: ResultsBrowsingState + ResultsBrowsingService. Pages never inject IResultRepository directly.
View-model factory AnomalyViewModels.cs (AnomalyListItem, AnomalyDetailVm, AnomalyEvidenceSnapshot) Phase 8 should expose ResultListItem / ResultDetail records — keep the UI shielded from EF graphs.
Severity-style chips AnomalyFeed.razor toolbar (m-chip w/ aria-pressed) Match the chip cadence for results filters (sport, status: pending/complete).
Evidence panel AnomalyEvidence.razor two-column layout If results show "predicted vs final" deltas, reuse the same paired-card structure.
Severity-coded card AnomalyCard.razor left-border colour driven by severity Pattern transfers to "result outcome" badging if needed (winner/loser/draw).
Nav badge NavBody.razor m-nav__badge (signal-red, pulsing) Phase 8 may want a similar "new results" badge. CSS class is already factored.

New CSS surfaces introduced

  • .m-severity / .m-severity--{low,medium,high} — small pill, severity-coded.
  • .m-anomaly-card / .m-anomaly-card--{low,medium,high} — feed card with severity-coded left border.
  • .m-evidence / .m-evidence__col / .m-evidence__bar — two-column evidence panel.
  • .m-anomaly-feed__stats — at-a-glance count strip (Total / High / Medium / Low).
  • .m-nav__badge — signal-red pulsing pill on the drawer link.

Routing changes

  • /anomalies — replaced placeholder with Pages/Anomalies/AnomalyFeed.razor.
  • /anomalies/{id:guid} — new detail page Pages/Anomalies/Detail.razor.
  • The Pages/Anomalies.razor placeholder file was deleted (Option A from the brief).

Test infrastructure

  • tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs — in-memory fake with MakeItem(...) and MakeSnapshot(...) factory helpers.
  • MarathonTestContext now also registers AnomalyBrowsingState (singleton) + the fake. Phase 8 tests can follow the same factory pattern for IResultBrowsingService.

Localization keys added

28 Anomaly.* keys (RU+EN full parity) plus Settings.Workers.AnomalyDetectionEnabled and its .Hint. All under the <Surface>.<Element> convention from Phase 5/6.