# Result Integrity — Feature & Data Specification

**Version:** 1.0 (draft) · **Status:** Internal spec for dev review · **Owner:** Checkrace Co., Ltd.
**Scope:** Anti-impersonation / anti-bib-swap layer across Registration, Timing, Photo, Judging.

Result Integrity is a **cross-system flagging layer** that detects when the person who
*ran* a bib is probably not the person who *registered* it (สวมสิทธิ์ / ใช้บิบของผู้อื่น).
It does **not** auto-disqualify anyone. It produces **flags + evidence + a Fraud Score**
and hands the final decision to a human judge, with an appeal path.

> **Design promises baked into this spec**
> - **Flag, don't judge.** The system surfaces risk; a referee decides. Every flag is reversible.
> - **Evidence-first.** No flag exists without a traceable artifact (split, photo, no-show log).
> - **Focus the scarce attention.** Only screen prize-bearing results (overall / age-group / top 100), not the whole field.
> - **PDPA by design.** Reference faces & national IDs are consented, hashed where possible, access-controlled.
> - **Human appeal always.** A flag is a question, never a verdict.

This spec covers three shippable modules and the score that fuses them:

1. **Performance Anomaly Check** — does the finish time fit this runner's profile & in-race splits?
2. **Award No-Show Check** — did a podium finisher fail to report for verification?
3. **Central Blacklist** — confirmed offenders, enforced at registration & ranking (policy in [`blacklist-policy.md`](blacklist-policy.md)).

Plus **Fraud Score** (§5) — the weighted fusion, and the judge dashboard ([`/result-integrity-dashboard.html`](../result-integrity-dashboard.html)).

---

## 1. Concepts

| Term | Meaning |
|---|---|
| **Runner Profile** | A runner's performance fingerprint: PB & typical pace per distance, age-grade, history depth, built from Checkrace results + Thailand Runner Rank. |
| **Reference face** | Consented selfie/portrait captured at registration or race-pack pickup, used to verify identity in photos & at the award desk. |
| **Screened set** | The only results we auto-check: overall winners, each age-group podium, and top 100 overall. |
| **Signal** | One normalized risk indicator (0–100), e.g. "finish beats PB by 12%". |
| **Fraud Score** | Weighted sum of signals for one result (0–100), bucketed into Clear / Review / Investigate. |
| **Flag** | A surfaced result with score ≥ Review threshold, shown to a judge with its evidence. |
| **Verdict** | A judge's human decision on a flag: `cleared` / `warned` / `disqualified`. |

---

## 2. Module 1 — Performance Anomaly Check

**Goal:** for every result in the *screened set*, decide whether the time is *plausible*
for the person who registered the bib.

### 2.1 Inputs
- Finish time + per-checkpoint splits (from the timing system / RaceResult export).
- Runner Profile for the bib's Runner ID (may be empty for newcomers).
- Distance, category, gender, age (for age-grading).

### 2.2 Signals produced
| Signal | Logic | Works without history? |
|---|---|---|
| `pb_jump` | Finish faster than the runner's verified PB at this distance by > **8%** (configurable). Scales to 100 by ~20%. | No (needs profile) |
| `agegrade_impossible` | Age-grade % above a per-distance/age ceiling (e.g. > 105% = world-class for that bracket). | Partly (needs DOB) |
| `negative_split_extreme` | 2nd-half pace faster than 1st-half by > **X%** beyond a plausible band. | **Yes** |
| `segment_impossible` | Any inter-CP segment pace faster than an absolute human ceiling for the distance. | **Yes** |
| `missing_cp` | One or more mandatory checkpoints have no read (often paired with course-cutting + swap). | **Yes** |
| `novel_profile` | Bib's Runner ID has zero prior history but landed in the screened set. **Low weight** — contextual, never decisive. | n/a |

> **Why split-anomaly is the workhorse:** it needs no prior history, so it catches the
> hardest case (a ringer with a fresh profile). Profile-compare is the *amplifier* for
> runners who *do* have history.

### 2.3 Output
A `PerformanceCheck` record per screened result (schema §4.2). It never blocks a result —
it raises the relevant signals, which the Fraud Score consumes.

### 2.4 False-positive guardrails
- `pb_jump` and `novel_profile` **alone** can never reach the Investigate band — they require
  corroboration from an *identity* signal (photo mismatch / no-show) to escalate.
- Genuine breakout performances exist. The output phrase is **"recommend verify"**, not "cheater".
- Thresholds are per-event configurable; elite/championship events tighten them, fun runs relax.

---

## 3. Module 2 — Award No-Show Check

**Goal:** a real runner who placed almost never skips the prize. A podium finisher who
vanishes is the classic signature of a substitute who won't show their face.

### 3.1 Process
1. **Winner Verify zone** at the award area. Each overall/age-group placer scans QR / e-bib.
2. Staff (or an auto face-compare station) checks the live face against the **reference face**.
3. The system records a `report_status`: `reported` / `no_show` / `refused`.
4. A configurable deadline (e.g. *report within N minutes of provisional announcement*),
   announced in the event T&C in advance.
5. `no_show` / `refused` → prize held, result stays **provisional**, and the no-show signal feeds the Fraud Score.

### 3.2 Signal produced
| Signal | Logic |
|---|---|
| `award_no_show` | Placer did not report by deadline → high weight. `refused` (declined the face check) → near-max. |

### 3.3 Guardrails
- **Legitimate reasons exist** (injury, taken to medical). Provide a post-event verification /
  appeal channel; never auto-blacklist on no-show alone.
- No-show is most powerful *in combination*: `award_no_show` + `pb_jump` + `face_mismatch`
  ≈ near-certain, but each alone is only a question.

---

## 4. Data model

All records key on `result_id` (one runner, one event, one distance) and `runner_id`.

### 4.1 Runner Profile
```json
{
  "runner_id": "RID-7Q3KD8ZP",
  "reference_face_id": "face_a91f...",        // pointer to PDPA-scoped face store, not the image
  "history_depth": 14,                          // number of verified results on record
  "pbs": {
    "5k":  { "seconds": 1080, "event_id": "evt_221", "verified": true },
    "10k": { "seconds": 2280, "event_id": "evt_310", "verified": true },
    "21k": { "seconds": 5040, "event_id": "evt_404", "verified": true }
  },
  "typical_pace": { "10k": { "mean_sec_per_km": 240, "stdev": 9 } },
  "age_grade_best": 78.4,
  "first_seen": "2023-02-11",
  "last_updated": "2026-06-01"
}
```

### 4.2 PerformanceCheck (Module 1 output)
```json
{
  "result_id": "res_88210",
  "runner_id": "RID-7Q3KD8ZP",
  "event_id": "evt_512",
  "distance": "21k",
  "screened_reason": "agegroup_podium",         // overall | agegroup_podium | top100
  "finish_seconds": 4980,
  "signals": [
    { "code": "pb_jump", "value": 62, "detail": "finish 4980s vs PB 5040s? +1.2% — below threshold", "raised": false },
    { "code": "negative_split_extreme", "value": 81, "detail": "2nd-half 18% faster than 1st", "raised": true },
    { "code": "missing_cp", "value": 100, "detail": "no read at CP3 (15km)", "raised": true }
  ],
  "computed_at": "2026-06-22T09:41:00+07:00"
}
```

### 4.3 AwardReport (Module 2 output)
```json
{
  "result_id": "res_88210",
  "placement": { "type": "agegroup", "category": "M40-44", "rank": 1 },
  "report_status": "no_show",                    // reported | no_show | refused
  "deadline": "2026-06-22T10:30:00+07:00",
  "verified_by": null,
  "face_match_at_desk": null,                     // 0..1 when reported, else null
  "note": ""
}
```

### 4.4 PhotoIdentity (from existing Photo system — consumed, not built here)
```json
{
  "result_id": "res_88210",
  "bib_ocr": "A1423",
  "face_match_to_reference": 0.31,               // 0..1; low = mismatch
  "face_continuity": [
    { "cp": "CP1", "face_match_to_finish": 0.27 },
    { "cp": "finish", "face_match_to_finish": 1.0 }
  ]
}
```

### 4.5 IntegrityCase (the fused, judge-facing record)
```json
{
  "case_id": "case_5567",
  "result_id": "res_88210",
  "runner_id": "RID-7Q3KD8ZP",
  "event_id": "evt_512",
  "fraud_score": 78,
  "band": "investigate",                          // clear | review | investigate
  "contributing_signals": ["missing_cp", "negative_split_extreme", "award_no_show", "face_mismatch"],
  "status": "under_review",                       // under_review | cleared | warned | disqualified | appealed
  "verdict": null,
  "decided_by": null,
  "decided_at": null,
  "evidence_refs": ["res_88210/splits", "res_88210/photos", "res_88210/award_report"],
  "appeal": null
}
```

---

## 5. Fraud Score (signal fusion)

The Fraud Score fuses every signal into one 0–100 number per result, so judges screen by
risk instead of reading the whole field.

### 5.1 Formula
```
FraudScore = clamp( Σ ( wᵢ · sᵢ ) , 0, 100 )

  sᵢ  = signal value, normalized 0–100
  wᵢ  = signal weight (Σ wᵢ may exceed 1 so that any TWO strong identity
        signals can independently reach the Investigate band)
```

### 5.2 Default weights
| Signal | Weight | Category | Notes |
|---|---|---|---|
| `face_mismatch` | **0.45** | Identity | Strongest — face in photo ≠ reference face |
| `face_discontinuity` | 0.40 | Identity | Different face across checkpoints = swap mid-race |
| `award_no_show` | 0.40 | Identity | `refused` pushes value to ~100 |
| `missing_cp` | 0.35 | Course | Skipped mandatory checkpoint |
| `segment_impossible` | 0.35 | Course | Inter-CP pace beyond human ceiling |
| `negative_split_extreme` | 0.25 | Performance | Unnatural pacing |
| `agegrade_impossible` | 0.20 | Performance | Beyond bracket ceiling |
| `pb_jump` | 0.15 | Performance | Corroborating only — cannot escalate alone |
| `id_duplicate` | 0.20 | Identity | Same nid/phone/face on multiple bibs |
| `novel_profile` | 0.05 | Context | Newcomer; nudge only, never decisive |
| `blacklist_proximity` | 0.30 | History | nid_hash previously confirmed (see policy doc) |

### 5.3 Bands
| Band | Range | Meaning | Action |
|---|---|---|---|
| 🟢 **Clear** | 0–39 | Nothing notable | Auto-confirm result |
| 🟠 **Review** | 40–69 | Worth a human glance | Surfaced in dashboard, prize may proceed with note |
| 🔴 **Investigate** | 70–100 | Strong multi-signal risk | Prize held provisional until a judge rules |

### 5.4 Escalation rule (anti-false-positive)
A result can only enter **Investigate** if **at least one *identity* or *course* signal**
(`face_*`, `award_no_show`, `missing_cp`, `segment_impossible`, `id_duplicate`,
`blacklist_proximity`) is raised. Performance signals alone (`pb_jump`,
`agegrade_impossible`, `negative_split_extreme`, `novel_profile`) **cap at the top of Review** —
a fast time with a clean identity trail is a breakout, not a fraud.

### 5.5 Worked example
`res_88210`: `missing_cp` (100·0.35=35) + `negative_split_extreme` (81·0.25=20.25)
+ `award_no_show` (100·0.40=40) + `face_mismatch` (69·0.45=31)
→ clamp(126.25) → **100 → Investigate**, with an identity signal present. Prize held.

---

## 6. Pipeline & sequencing

```
Timing export ──► screened set (overall / age-group / top100)
                      │
        ┌─────────────┼──────────────┬───────────────┐
   PerformanceCheck   PhotoIdentity   AwardReport     (history)
        (Mod 1)       (existing)      (Mod 2)         blacklist_proximity
        └─────────────┴──────────────┴───────────────┘
                      ▼
                Fraud Score (§5)  →  IntegrityCase
                      ▼
            Judge dashboard (human verdict + appeal)
                      ▼ disqualified & confirmed
            Central Blacklist  →  Runner Rank suspension + registration block
```

### Recommended build order (low effort → high)
1. **Module 2 (No-Show)** — process + a status button. Ships in days, immediate value.
2. **Module 1, split-anomaly only** — uses timing data already on hand; no profile needed.
3. **Module 1, profile-compare** — once Runner Profile / Rank coverage is deep enough.
4. **Fraud Score + judge dashboard** — fuse signals (photo system already provides face data).
5. **Central Blacklist** — last, because it needs the policy + due-process + PDPA scaffolding in [`blacklist-policy.md`](blacklist-policy.md).

---

## 7. Privacy & access (summary; full policy in blacklist doc)
- Reference faces and national IDs are **consented at registration** (PDPA lawful basis: contract + legitimate interest in fair competition, disclosed in T&C).
- National ID stored only as a salted hash for matching; raw value not retained for integrity checks.
- Integrity cases and the blacklist are **internal to organizers** — never published to the public web.
- Face data has a defined retention window and is purged per the photo system's policy.
