Skip to main content
Glama
Snedz

Ask the match

by Snedz
README.md
# Mission Winning — explainable match intelligence (synthetic data)

**The reason is computed, not written.** Every number that reaches the screen is
re-derived from the match state before it is shown, and when a claim fails, the
rejection and the recovery are on screen rather than in a log.

Entry for the Microsoft × Premier League *Inside the Game* hackathon, built by
**Mission Winning**. Every club, player and event in the football pipeline is
**synthetic** — win chance here is match intelligence, not a price or a wager.

**Live demo:** <https://snedz.github.io/match-intelligence/> — the static build,
no server, no key, no account. It carries the [1:18 demo video](https://snedz.github.io/match-intelligence/inside-the-game-demo.mp4)
and the [14-page deck](https://snedz.github.io/match-intelligence/inside-the-game-deck.pdf)
with it, and it is the same build the video shows.

## Run it (no keys, no cloud)
```bash
pip install -r requirements.txt
python -m itg.web
```
Requires **Python 3.9 or newer** (verified on 3.9.6, 3.12.14 and 3.14.8). Open http://127.0.0.1:8000 . The page replays a seeded synthetic match on a pitch at 30× (play, pause, scrub). Overlay cards and the win-chance ribbon read the pipeline JSON. Fan/Analyst, EN/ES/FR, and the favourite-player list re-render the story with the offline templates.

Run the tests with the same install:
```bash
python -m pytest -q     # green, offline, no keys
```
Ten of the tests are the Microsoft Agent Framework graph and are skipped
unless that package is installed; the extra install below turns them on. The
exact count is the badge on the repository — it moves every time a test is
added, and a number in a document that nobody keeps in step is a number
nobody should quote.

The same suite runs on every push, on Python 3.9, 3.12 and 3.14 —
`.github/workflows/tests.yml`. That matrix is exactly the set of interpreters
it has been verified on by hand (the same result on each), so a green
run is a fact rather than a hope; a version is added only after it has been
run locally. `tests/test_requirements.py` pins the install contract: every
third-party import in this repository has to be declared in `requirements.txt`,
so the two commands above cannot silently drift apart.

The Microsoft Agent Framework graph orchestrator is a separate install, because it pulls in ~70 packages and the default install should stay fast:
```bash
pip install -r requirements-agent-framework.txt
```
Everything runs either way. Without the package, `itg/workflow.py` reports itself unavailable rather than pretending, the dependency-free pipeline is the orchestrator, and the trace records which one ran.

QA recovery demo: http://127.0.0.1:8000/?inject=1 or the "Inject a fabricated claim" switch. The same flag is `GET /api/trace?seed=7&inject=1`. QA rejects the claim, Story falls back to the offline template, and QA accepts that recap. The trace lists Data → Analysis → WinProb → Story → QA with timings.

```bash
python -m itg.pipeline --seed 7 --persona fan --language en
python -m itg.pipeline --seed 7 --persona analyst --language es --favorite Okafor
python -m itg.pipeline fan          # same as persona fan, seed 7, English
python -m pytest -q
```

`--language` accepts `en`, `es`, or `fr` (also English / Spanish / French). `--persona` is `fan` or `analyst`. `--favorite` highlights one player on the team sheet. With no model configured, Story uses offline templates. That path is what the tests run.

The JSON includes `overlays`. Cards with `"type": "win_prob"` are the swings, each with a short reason and the claims QA checked against match state.

## Ask the match — the MCP server (`itg/mcp.py`)

The same match state, published to any MCP client. This is the integration
surface: an agent can ask the match questions and check its own claims.

```bash
python -m itg.mcp            # speak MCP on stdio (JSON-RPC 2.0)
python -m itg.mcp --demo     # one scripted exchange, printed
python -m itg.mcp --list-tools
```

| Tool | What it answers |
|---|---|
| `get_match_state` | the shared match state: teams, score, events ingested, squad |
| `get_key_moments` | the key moments, each with its reason and its recomputable claims |
| `get_win_probability` | the win chance at a minute, and the factual cause of the swing |
| `verify_claim` | **the guardrail, exposed** — is this sentence about the match true? |
| `verify_overlay_claims` | recompute every number on every on-screen card from match state |
| `get_agent_trace` | which agent ran, in which order, who handed off, what was rejected |
| `get_player_profile` | one player: team, position, distance, top speed, carries, xT |

`verify_claim` is the one to try first. Given a fabricated sentence it returns
`accepted: false` and the reasons:

```json
{
  "accepted": false,
  "issues": [
    "high-xG count 14 exceeds match state (4)",
    "pressure count 223 exceeds match state (216)"
  ],
  "how": "the QA agent recomputed the claim from match state, it did not read it and judge it"
}
```

It is hand-rolled JSON-RPC rather than the `mcp` SDK on purpose: the install
path a judge takes is `pip install -r requirements.txt`, and a new dependency
there is a new way for the entry to fail before it is seen. It adds nothing to
the requirements, and `tests/test_mcp.py` asserts that it does not.

## In a container

```bash
docker build -t mission-winning .
docker run --rm -p 8000:8000 mission-winning          # the web app
docker run --rm -i mission-winning python -m itg.mcp  # the MCP server
```

`docs/AZURE.md` has the Azure path for the same image — Container Apps for the
app and the MCP server, Foundry with managed identity — with the exact `az`
commands. **It is not deployed on Azure:** there is no subscription behind this
entry, and that document says so in its first paragraph. The Dockerfile has
not been built in this environment either, because Docker is not installed
here; `tests/test_deployment.py` checks what it can (every path it copies
exists, it copies no `.env`, it runs as a non-root user, its entrypoint is a
real module).

## Architecture

```mermaid
flowchart LR
  G["Generator<br/>seeded possession-chain<br/>live event stream"] --> D["Data agent<br/>events → shared MatchState"]
  D --> A["Analysis agent<br/>xG · pass difficulty · pressure surges<br/>momentum · synthetic xT"]
  A --> W["WinProb agent<br/>Poisson win chance<br/>+ a factual 'why it moved'"]
  W --> S["Story agent<br/>persona × language × favourite player"]
  S --> Q["QA agent<br/>fact-check score, reasons,<br/>distances vs MatchState"]
  Q -- "reject → grounded template" --> S
  Q --> O["Overlay JSON (timestamped)<br/>+ web UI"]
  D --> X["MCP server<br/>the same state,<br/>for any agent or client"]
  Q --> X
  M["Model chain<br/>Foundry (not wired — awaits Azure)<br/>→ OpenAI-compatible (Ollama)<br/>→ offline templates (default)"] -.-> S
```

Every agent reads and writes the **same `MatchState`**, so the QA agent checks a claim against the state the Story agent wrote from. One shared state, not five private copies.

The **model chain always terminates at the offline templates**, which is why the demo runs with no key, no account and no network. A model call that fails falls back to the grounded template; it never invents a value to cover the failure.

## The pipeline
| Stage | Agent | File | What it adds |
|---|---|---|---|
| Ingest | Data agent | `itg/agents/data_agent.py` | Streaming events into shared match state |
| Interpret + Explain | Analysis agent | `itg/agents/analysis_agent.py` | xG, pressure windows, momentum, pass difficulty |
| Interpret | Win-chance agent | `itg/winprob.py` | Live home/draw/away chance and why it moved |
| Render + Personalize | Story agent | `itg/agents/story_agent.py` | Fan vs analyst, EN/ES/FR, favourite player |
| Guardrail | QA agent | `itg/agents/qa_agent.py` | Score, reasons, distances, and wagering language |

`itg/pipeline.py` is the orchestrator. If QA rejects a recap, Story is called again on the offline template only. `python -m itg.web` serves that JSON to the replay page (`itg/web/`).

`itg/generator.py` is a seeded possession-chain simulator. Shots get a toy xG from distance to goal. Carries, a synthetic expected-threat value, per-player speed and distance, and match metadata are added without changing that shot model, so the calibration below still applies. On-ball events also carry a SPADL-shaped descriptor; see `NOTICE`. Clubs play in the fictional Harbor Invitational.

## Model backends (`itg/llm.py`)
1. **Microsoft Foundry** via Microsoft Agent Framework — set `FOUNDRY_PROJECT_ENDPOINT`, run `az login`. Not wired to a project yet; that waits on an Azure account.
2. Any OpenAI-compatible endpoint (for example local Ollama) — `LLM_BASE_URL`.
3. **Offline** templates — the default. Always works with no keys. Tests use this mode.

If a model call fails, the agent falls back to the grounded template. Copy `.env.example` → `.env`. `.env` is git-ignored. Never commit keys.

License: MIT.

## Win-chance agent (`itg/winprob.py`)
Live home/draw/away chances that explain their own swings ("Harbor City FC win chance 39%→8%, tilted to Northgate United by a goal by Vidal; 1 high-xG chance under sustained press (10 pressures)"). Poisson model on remaining minutes; rate = blend of pre-match strength and observed xG. Fitted on 300 synthetic matches, tested on **300 held-out** synthetic matches — each scored at seven time points, so **2,100 scored predictions** (`python3 -m itg.winprob backtest`):

| Model | Brier (3-way) | Log-loss |
|---|---|---|
| Win-chance agent | **0.350** | **0.599** |
| Score-only baseline | 0.390 | 0.673 |

Reliability table in `predictions/winprob-backtest.json` (predicted vs actual within 7 points per bin; the largest gap is 6.8 points, in the 0.7–0.8 bin. The per-bin counts are scored predictions, not matches.) A 40-match smoke check in the tests must stay under a Brier of 0.40.

The full method, the reliability table bin by bin, and what this measurement
does and does not establish are in **[docs/CALIBRATION.md](docs/CALIBRATION.md)**.
`tests/test_deployment.py` asserts that the numbers quoted there still match
`predictions/winprob-backtest.json`, so the document cannot drift away from
the artifact it was measured from.

## What is where

| | |
|---|---|
| `itg/pipeline.py` | the orchestrator: ingest → interpret → explain → render → personalize, with the QA retry |
| `itg/mcp.py` | the MCP server over the same match state |
| `itg/agents/` | Data, Analysis, Story and QA agents |
| `itg/winprob.py` | the win-chance model and its backtest |
| `itg/web/` | the replay page and the JSON API |
| `docs/CALIBRATION.md` | how the win-chance number was measured |
| `docs/AZURE.md` | the Azure path, and what is not deployed |
| `SUBMISSION.md`, `DECK.md` | the pitch and the deck |
| `tools/md2pdf.py` | the deck generator, so the PDF is reproducible rather than a hand-written file |
| `.github/copilot-instructions.md` | how to work in this repository — for a human contributor, and for an agentic editor |
| `inside-the-game-deck.manifest.json` | the hash of the deck the PDF was built from; a test fails if the PDF goes stale |
| `inside-the-game-demo.mp4` | the 1:14 demo video (silent, 1600×900) |