Skip to content

Commit ad2f6ff

Browse files
committed
Merge branch 'feature/data-overhaul'
2 parents 602a601 + 4fb7c7b commit ad2f6ff

77 files changed

Lines changed: 7948 additions & 672 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ __pycache__/
44
.venv/
55
*.egg-info/
66
tracer/sessions/
7+
tracer/feedback/
78
tracer/dev_traces/
9+
exports/
810
legacy/tagger/node_modules/
911
legacy/tagger/dist/
1012
.playwright-mcp/

CONTRIBUTING.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Python 3.11+.
1313

1414
```bash
1515
pip install -e ".[dev]"
16-
python -m pytest -q # 225 tests, ~3 seconds
16+
python -m pytest -q # 326 tests, ~4 seconds
1717
```
1818

1919
CI runs the same `pytest -q` on every push and pull request, on 3.11. If it's
@@ -36,8 +36,10 @@ silently. `tracer/tests/test_pace_invariance.py` is the fence.
3636
app in dev mode (`python -m tracer.app 8080 dev`), use *Save last trace*, then
3737
promote the saved trace into `tracer/tests/traces/` with an `"expect"` block.
3838
It joins the corpus, `test_corpus.py` picks it up, and `python -m tracer.sweep`
39-
and `python -m tracer.fit` will tell you what the constants should be. Both
40-
print and neither writes `config.py`, so the final edit is yours.
39+
and `python -m tracer.fit` will tell you what the constants should be. A third
40+
tool, `python -m tracer.calibrate`, is `fit` plus the segment corrections
41+
operators log live in the app (SQLite, `tracer/feedback/`; see TUNING.md). All
42+
three print and none writes `config.py`, so the final edit is yours.
4143

4244
A recognizer PR that changes a constant without a corpus case demonstrating
4345
why won't get merged, not because of process, but because there's no way to

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Match Momentum
1+
# Phase Trace
22

3-
[![tests](https://github.com/Jfrusher/match-momentum/actions/workflows/tests.yml/badge.svg)](https://github.com/Jfrusher/match-momentum/actions/workflows/tests.yml)
3+
[![tests](https://github.com/Jfrusher/phase-trace/actions/workflows/tests.yml/badge.svg)](https://github.com/Jfrusher/phase-trace/actions/workflows/tests.yml)
44

55
**Trace a rugby match with your mouse, get a broadcast-style momentum chart out of it.**
66

@@ -28,6 +28,8 @@ python -m tracer.app 8123 # optional port, default 8080
2828
# chart an event file
2929
python momentum.py examples/tracer-sample.json out.png --sport rugby
3030
python momentum.py examples/events_arg_egy.json out.png --sport football
31+
32+
# browse an exported match: open report/index.html and pick the export folder
3133
```
3234

3335
`examples/tracer-sample.json` is an actual tracer export that has been run through the rugby translator, so it doubles as proof that what the tracer writes is what `momentum.py` can read.
@@ -37,12 +39,31 @@ python momentum.py examples/events_arg_egy.json out.png --sport football
3739
The mechanic, the full hotkey table and the tuning workflow are in [tracer/README.md](tracer/README.md). The short version:
3840

3941
- Hold the mouse button when a possession starts and follow the ball. Pass, run, pass, tackle are all one line.
40-
- Tap `A` or `Space` when the play dies. That, not letting go of the button, is what ends the chain.
42+
- Tap `A` or `Space` when the play dies. That, not letting go of the button, is what ends the chain. Drawing the ball out of play ends it for you, since in law it is over.
4143
- The line redraws colour-coded by inferred action while you go, so you can see what the recognizer thinks.
4244

4345
The recognizer only looks at the shape of the line, never at how fast you drew it. That was deliberate: I wanted to trace off paused or scrubbed video and get the same answer as tracing live. [`tracer/tests/test_pace_invariance.py`](tracer/tests/test_pace_invariance.py) exists to stop that quietly breaking.
4446

45-
Possessions also record how they started (scrum, lineout, penalty, restart, turnover, interception), because in rugby that's a fair chunk of what a possession is worth. Most of it comes off the trace: a kick that ends at the touchline is a lineout, and the kick-to-touch-on-the-full law says where that lineout gets taken. Scrums and penalties are the two a line can't show you, so those are single taps. Whatever gets inferred turns up as a chip on the pitch, and the chip is also how you correct it. With only two teams to pick from, a wrong guess is one click from right.
47+
Possessions also record how they started (scrum, lineout, penalty, restart, turnover, interception, 22 drop-out), because in rugby that's a fair chunk of what a possession is worth. Most of it comes off the trace: a line crossing the touchline is a lineout, and the kick-to-touch-on-the-full law says where that lineout gets taken. What a line can't show you is a scrum, a penalty, and whether the ball was grounded in the in-goal — the first two are single taps, and the third is a chooser on the chip with the likely answer already picked. Whatever gets inferred turns up as a chip on the pitch, and the chip is also how you correct it. With only two teams to pick from, a wrong guess is one click from right.
48+
49+
## After the match
50+
51+
Two exports, answering different questions.
52+
53+
**Validate + export** writes the momentum JSON `momentum.py` reads — and refuses to write until
54+
[`tracer/validate.py`](tracer/validate.py) has dry-run the real pipeline, so nothing can fail
55+
downstream that didn't already fail there.
56+
57+
**Export data (CSV)** writes an analysis bundle — `match.json` plus `actions.csv`, `players.csv`,
58+
`team.csv`, `positions.csv` — one row per carry, pass and kick, with pitch coordinates in metres.
59+
Every field is optional; a column is blank where nothing was tagged, which is the point.
60+
61+
Open [`report/index.html`](report/) and pick that folder for a pitch map, a Gaussian-KDE heatmap
62+
(including where a team *conceded* penalties), team and player tables, and a momentum curve. No
63+
build step, no server, nothing leaves the machine. That curve is reconstructed and approximate —
64+
[`report/README.md`](report/README.md) says how it differs. Second-half positions fold back into
65+
the first-half frame, so a heatmap aggregates across the whole match instead of splitting to both
66+
ends.
4667

4768
## The momentum model
4869

@@ -72,20 +93,23 @@ DataSource.parse() -> Sport.translate() -> MomentumEngine.compute() -> cha
7293
| [`translators/`](translators/) | One `BaseSport` per sport: event weighting plus match structure (duration, half-time marker, decay half-life, axis labels). Static weight tables sit alongside as JSON. |
7394
| [`sources/`](sources/) | One `BaseDataSource` per data provider, parsing raw match data into a common shape. Deliberately independent of `translators/`, so any sport works with any source instead of needing a class per (sport, provider) pair. |
7495
| [`tracer/`](tracer/) | The Live Trace app: capture, recognition, review, export. Writes the same JSON the sources read. |
96+
| [`report/`](report/) | Standalone no-build viewer for an export folder. Client-side only; ports the engine's math to JS for an approximate curve. |
7597

7698
To add a sport, implement `BaseSport` in `translators/`, register it in the `SPORTS` dict in `translators/__init__.py`, and run with `--sport yourname`. [`translators/rugby.py`](translators/rugby.py) is the worked example; its module docstring covers territory-based threat and why cards are markers rather than something fed into the decay sum.
7799

78100
To add a data provider (Opta, StatsBomb, whatever else), implement `BaseDataSource` in `sources/` and map its raw fields into the shape your chosen `Sport.translate()` expects. Nothing downstream changes.
79101

102+
Diagrams for each layer, with the reasoning and the trade-offs behind them, are in [`docs/`](docs/) — six pages covering composition, the recognizer, the possession lifecycle, rugby-law inference, export topology and calibration.
103+
80104
## Tests
81105

82-
225 tests, run on every push and pull request:
106+
326 tests, run on every push and pull request:
83107

84108
```bash
85109
python -m pytest -q
86110
```
87111

88-
The recognizer is gated by a corpus of 39 synthetic trace scenarios in [`tracer/fixtures.py`](tracer/fixtures.py), replayed at baseline config, plus the pace-invariance fence. Every threshold and weight it depends on is a flat constant in [`tracer/config.py`](tracer/config.py). [tracer/TUNING.md](tracer/TUNING.md) covers the loop for moving them (save a trace, promote it with its expected truth, sweep or fit) and where the calibration currently stands.
112+
The recognizer is gated by a corpus of 39 synthetic trace scenarios in [`tracer/fixtures.py`](tracer/fixtures.py), replayed at baseline config, plus the pace-invariance fence. Every threshold and weight it depends on is a flat constant in [`tracer/config.py`](tracer/config.py). [tracer/TUNING.md](tracer/TUNING.md) covers the loop for moving them (save a trace, promote it with its expected truth, sweep or fit) and where the calibration currently stands. Misreads you correct live — clicking a segment to re-cycle it — are logged to a local database and folded into the weight proposal by `python -m tracer.calibrate`, run before the next game. None of the three tools ever writes `config.py`: they print a proposal, you decide.
89113

90114
## Background
91115

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Working document. Delete this file in the final commit before opening the PR.**
44

5-
Target: one PR into `origin/master` (Jfrusher/match-momentum), read by a public
5+
Target: one PR into `origin/master` (Jfrusher/phase-trace), read by a public
66
audience as a portfolio piece. Branch `feature/modular-input-framework` is
77
currently 10 commits ahead of `master`, clean fast-forward, 154 tests green.
88

core/chart.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,15 @@
1111
GOAL_LINE = "#e8e8e8"
1212

1313

14-
def render(t, y_home, y_away, events, home, away, chart_profile,
15-
home_color, away_color, title, footer, out_path):
16-
fig, ax = plt.subplots(figsize=(12, 5.2), dpi=200)
17-
fig.patch.set_facecolor(BG_FIG)
18-
ax.set_facecolor(BG_PANEL)
19-
20-
# net momentum: one side at a time, FIFA-broadcast style
21-
ax.fill_between(t, 0, y_home, color=home_color, alpha=0.95, lw=0)
22-
ax.fill_between(t, 0, -y_away, color=away_color, alpha=0.95, lw=0)
23-
ax.axhline(0, color="#d9d9d9", lw=2)
24-
25-
for m in chart_profile.interval_markers:
26-
ax.axvline(m, color="#c8c8c8", lw=1.2, alpha=0.7)
27-
28-
# score markers (stagger labels that fall within 12 minutes of each other)
14+
def _draw_score_markers(ax, events, home):
15+
"""Goal-line markers, staggering labels that fall within 12 min of each other."""
2916
last_x = {1: -100, -1: -100}
3017
high = {1: False, -1: False}
3118
for ev in events:
3219
if ev.marker != "score":
3320
continue
3421
sign = 1 if ev.team == home else -1
35-
if ev.t - last_x[sign] < 12:
36-
high[sign] = not high[sign]
37-
else:
38-
high[sign] = False
22+
high[sign] = not high[sign] if ev.t - last_x[sign] < 12 else False
3923
last_x[sign] = ev.t
4024
tip = 1.12 + (0.14 if high[sign] else 0)
4125
ax.plot([ev.t, ev.t], [0, sign * tip], color=GOAL_LINE, lw=1.4)
@@ -44,7 +28,9 @@ def render(t, y_home, y_away, events, home, away, chart_profile,
4428
ha="center", va="center", fontsize=8.5,
4529
color="#f0f0f0", fontweight="bold")
4630

47-
# secondary annotation markers (e.g. missed penalty, sin bin)
31+
32+
def _draw_note_markers(ax, events, home):
33+
"""Secondary annotation markers (e.g. missed penalty, sin bin)."""
4834
for ev in events:
4935
if ev.marker != "note":
5036
continue
@@ -55,6 +41,9 @@ def render(t, y_home, y_away, events, home, away, chart_profile,
5541
ha="center", va="center", fontsize=8.5,
5642
color="#f5c518", fontweight="bold")
5743

44+
45+
def _style_axes(fig, ax, chart_profile, home, away, home_color, away_color,
46+
title, footer):
5847
ax.set_xlim(0, chart_profile.max_t)
5948
ax.set_ylim(-1.55, 1.55)
6049
ax.set_xticks(chart_profile.tick_positions)
@@ -71,5 +60,25 @@ def render(t, y_home, y_away, events, home, away, chart_profile,
7160
fontsize=13, fontweight="bold", va="bottom")
7261
fig.text(0.5, 0.015, footer or "", ha="center", color="#9a9a9a", fontsize=8)
7362

63+
64+
def render(t, y_home, y_away, events, home, away, chart_profile,
65+
home_color, away_color, title, footer, out_path):
66+
fig, ax = plt.subplots(figsize=(12, 5.2), dpi=200)
67+
fig.patch.set_facecolor(BG_FIG)
68+
ax.set_facecolor(BG_PANEL)
69+
70+
# net momentum: one side at a time, FIFA-broadcast style
71+
ax.fill_between(t, 0, y_home, color=home_color, alpha=0.95, lw=0)
72+
ax.fill_between(t, 0, -y_away, color=away_color, alpha=0.95, lw=0)
73+
ax.axhline(0, color="#d9d9d9", lw=2)
74+
75+
for m in chart_profile.interval_markers:
76+
ax.axvline(m, color="#c8c8c8", lw=1.2, alpha=0.7)
77+
78+
_draw_score_markers(ax, events, home)
79+
_draw_note_markers(ax, events, home)
80+
_style_axes(fig, ax, chart_profile, home, away, home_color, away_color,
81+
title, footer)
82+
7483
plt.tight_layout(rect=[0, 0.04, 1, 1])
7584
fig.savefig(out_path, facecolor=BG_FIG, bbox_inches="tight")

docs/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Architecture docs
2+
3+
Six areas of this repo that are hard to reconstruct from the source alone, each with a diagram and
4+
the reasoning behind it. Read them in order if you're new; jump straight to the one you need if
5+
you're not.
6+
7+
| # | Page | What it answers |
8+
|---|------|-----------------|
9+
| 1 | [System composition](architecture/01-system-composition.md) | What the four packages are, and why the momentum maths knows nothing about rugby |
10+
| 2 | [The recognizer pipeline](architecture/02-recognizer-pipeline.md) | How one mouse drag becomes a list of carries, passes and kicks — and why drawing speed can't change the answer |
11+
| 3 | [Possession lifecycle](architecture/03-possession-lifecycle.md) | The chain state machine, the two undo snapshots, and the rewind-and-replay correction model |
12+
| 4 | [Rugby-law inference](architecture/04-law-inference.md) | How the tool decides a lineout from a scrum from a 22 drop-out, and which facts it refuses to guess |
13+
| 5 | [Export topology](architecture/05-export-topology.md) | Two parallel event logs, two exits, and the record shapes that land in each |
14+
| 6 | [Calibration loop](architecture/06-calibration-loop.md) | Where every threshold comes from, and why no tool in this repo may write `config.py` |
15+
16+
## Viewing the diagrams
17+
18+
Diagrams are native `.drawio` files in [diagrams/](diagrams/) — editable, diff-visible XML rather
19+
than baked images.
20+
21+
- **VS Code** — install the *Draw.io Integration* extension (`hediet.vscode-drawio`) and open the
22+
file. Multi-page diagrams get a page tab bar at the bottom.
23+
- **Browser**[app.diagrams.net](https://app.diagrams.net)*File ▸ Open from ▸ Device*. Nothing
24+
is uploaded; the editor runs locally in the tab.
25+
- **Desktop** — draw.io Desktop opens them directly. It also ships a CLI, which is the only way to
26+
export PNG/SVG/PDF from these files:
27+
```bash
28+
drawio -x -f svg -e -b 10 -o diagram.drawio.svg diagram.drawio
29+
```
30+
The `-e` flag embeds the diagram XML in the export, so the exported file stays editable.
31+
32+
No PNG or SVG exports are committed. They would be a second copy of the same information with
33+
nothing keeping them in step, and the one thing worse than no diagram is a diagram that quietly
34+
stopped being true.
35+
36+
## Conventions used across all six diagrams
37+
38+
| | |
39+
|---|---|
40+
| **Blue** `#dae8fc` | Code — a module, a function, a step in a pipeline |
41+
| **Yellow** `#fff2cc` | Data — an in-memory log, a file on disk, a record shape |
42+
| **Green** `#d5e8d4` | An output, or something the sport-agnostic core owns |
43+
| **Orange** `#ffe6cc` | A human is in the loop — an operator tap, a chooser, a correction |
44+
| **Red** `#f8cecc` | A hard gate: the laws of the game overriding geometry, a validation blocker, a decision no tool is allowed to make for you |
45+
| **Purple** `#e1d5e7` | A decision the code evaluates |
46+
| **Dashed grey box** | Context or a zone label, not a component |
47+
| **Dashed edge** | Optional, asynchronous, or a loop back to an earlier step |
48+
49+
## What isn't documented here
50+
51+
- `legacy/tagger/` — the archived React keyboard logger this project replaced. Kept for the record;
52+
see its own [README](../legacy/tagger/README.md) for why it wasn't enough.
53+
- Per-view internals of `report/` — the viewer has its own [README](../report/README.md), and its
54+
self-check page (`report/selfcheck.html`) is the executable version of that document.
55+
- Tuning procedure — [tracer/TUNING.md](../tracer/TUNING.md) covers the symptom→parameter table.
56+
Page 6 here covers the *shape* of that loop, not the recipe.
57+
58+
[docs/tracer-dataflow.drawio](tracer-dataflow.drawio) predates this set and covers similar ground
59+
for `tracer/` in three pages. It is left as-is; where the two disagree, these pages are newer.

0 commit comments

Comments
 (0)