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

320 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7: Anomaly Detection (Suspension + Flip)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./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
- [x] 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
- [x] Add `AnomalyOptions` POCO bound to `Anomaly:*` (in `Marathon.Application/Configuration/`):
```csharp
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;
}
```
- [x] 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
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
`Marathon.Infrastructure/Workers/`:
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
- Calls `DetectAnomaliesUseCase`
- Gated by `Workers:AnomalyDetectionEnabled` (default `true`)
- [x] Add `WorkerOptions.AnomalyDetectionEnabled` (default `true`)
- [x] Register `DetectAnomaliesUseCase` as Scoped in `ApplicationModule`
- [x] Bind `AnomalyOptions` and register `AnomalyDetectionPoller` in `InfrastructureModule`
- [x] Update `appsettings.json` — add `Workers:AnomalyDetectionEnabled: true`
(all 4 `Anomaly:*` keys already existed from Phase 5)
- [x] 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 ✓
- [x] 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
- [x] 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
- [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event)
- [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap.
- [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing).
- [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout.
- [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count.
- [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
- [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity)
- [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag
- [x] 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
- [x] Compiles (Big Bang).
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
- [x] Configurable thresholds via `appsettings.json`.
- [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section).
- [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
- [x] 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
- [x] Detector is deterministic and pure
- [x] Score calculation correct (verified against hand-computed example in test comments)
- [x] No false positives on synthetic "normal" timelines
- [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data).
- [x] 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:
```csharp
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:**
```json
{
"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.