You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
**Capture a rugby match by tracing the ball with your mouse, and get a broadcast-style momentum chart out of it.**
5
+
**Trace a rugby match with your mouse, get a broadcast-style momentum chart out of it.**
6
6
7
-
One continuous drag per possession. The software reads the geometry of that line — its turns, its distances, the shape of each leg — and segments it into carries, passes and kicks. Keyboard taps layer on player numbers, linebreaks and scores without interrupting the drag. Traced distance and field position feed the momentum model directly, so the chart is built from where the ball actually went rather than from typed estimates.
7
+
You hold the button down and follow the ball for a whole possession, one unbroken line. The app reads the shape of that line, its turns and its distances, and works out where the carries, passes and kicks were. Tapping keys while you drag adds player numbers, linebreaks and scores without breaking the drag. Since the trace already knows the distances and where on the pitch things happened, the momentum weights come from that rather than from a number you typed in afterwards.
8
8
9
9

10
10
11
-
*Amber carry, blue pass, red kick. White dots mark where the recognizer cut the line.*
11
+
*Amber carry, blue pass, red kick. White dots are where the recognizer cut the line.*
12
12
13
-
Behind the tracer sits a sport-agnostic momentum engine: exponential decay over weighted threat events, rendered as the mirrored area chart familiar from football broadcasts. Football and rugby ship as translators; the engine itself knows about neither.
13
+
Underneath the tracer is a momentum engine that doesn't know what sport it's drawing. Threat events decay exponentially, and what comes out is the mirrored area chart you see on football broadcasts. Football and rugby both work, as translators sitting on top of that engine.
python momentum.py examples/events_arg_egy.json out.png --sport football
31
31
```
32
32
33
-
`examples/tracer-sample.json` is a real tracer export, validated end to end through the rugby translator — it is the round-trip proof that what the tracer writes is what `momentum.py`reads.
33
+
`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.
34
34
35
35
## Live Trace
36
36
37
-
Full mechanic, hotkey table and tuning workflow: **[tracer/README.md](tracer/README.md)**.
37
+
The mechanic, the full hotkey table and the tuning workflow are in [tracer/README.md](tracer/README.md). The short version:
38
38
39
-
The essentials:
39
+
- 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.
41
+
- The line redraws colour-coded by inferred action while you go, so you can see what the recognizer thinks.
40
42
41
-
-**Hold** the mouse button when a possession starts and follow the ball. Pass, run, pass, tackle are one unbroken line.
42
-
-**Tap `A` or `Space`** when the play dies. That, not releasing the button, is the authoritative end of the chain.
43
-
- The line redraws colour-coded by inferred action as you go, which is the recognizer's feedback channel.
43
+
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.
44
44
45
-
Two design decisions shape everything else:
46
-
47
-
**The line is read from its shape, never its speed.** The same trace classifies identically whether it was drawn in real time or sketched afterwards, which is what makes tracing from paused or scrubbed video possible. [`tracer/tests/test_pace_invariance.py`](tracer/tests/test_pace_invariance.py) pins that property.
48
-
49
-
**Every inferred attribution renders as a chip, and the chip is the correction UI.** Each possession records how it began — scrum, lineout, penalty, restart, turnover, interception — because in rugby that is a large part of what the possession is worth. Most of it is read off the trace: a kick ending at a touchline is a lineout, and the kick-to-touch-on-the-full law decides where that lineout is taken. The two things a line cannot show, a scrum and a penalty, are single taps. There are only ever two teams, so a wrong guess is one click from right.
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.
50
46
51
47
## The momentum model
52
48
53
-
Each threat event injects momentum energy for its team, which decays exponentially (half-life ≈ 3 min for football, set per sport by the translator):
49
+
Every threat event adds momentum energy for its team, and that energy decays exponentially (half-life around 3 minutes for football, set per sport by the translator):
54
50
55
51
```
56
52
momentum_team(t) = Σ over events e: w_e · exp(-λ · (t - t_e)) for t ≥ t_e
57
53
```
58
54
59
-
The chart shows **net** momentum — home energy minus away energy, smoothed with a Gaussian kernel — so at any moment exactly one team is on top. That matches the visual grammar of the broadcast graphics the model was built to reproduce: when one side has momentum, the other by definition doesn't.
55
+
The chart plots net momentum, home minus away, smoothed with a Gaussian kernel, so only one team is above the line at a time. That's how the broadcast graphics do it, and the model was built to reproduce them.
60
56
61
-
Weighting is where the sports differ. Football keys off discrete threat events (shot, chance, goal, sustained pressure) via a flat lookup table. Rugby has no equivalent discrete moment for phase play, so [`translators/rugby.py`](translators/rugby.py)derives weight in code from metres gained, field position and linebreaks instead.
57
+
Weighting is where the two sports part company. Football keys off discrete threat events (shot, chance, goal, sustained pressure) through a flat lookup table. Rugby phase play has no equivalent single moment to key off, so [`translators/rugby.py`](translators/rugby.py)works the weight out in code from metres gained, field position and linebreaks.
|[`core/`](core/)|`schema.py`—`StandardEvent`, the only shape the math ever sees. `engine.py`— decay and smoothing. `chart.py`— the area-chart renderer, driven by a `ChartProfile`. |
76
-
|[`translators/`](translators/)| One `BaseSport` per sport: event weighting plus match structure (duration, half-time marker, decay half-life, axis labels). Static weight tables live alongside as JSON. |
77
-
|[`sources/`](sources/)| One `BaseDataSource` per data provider, parsing raw match data into a common shape. Kept independent of `translators/` so any sport works with any source, rather than one class per (sport, provider) pair. |
78
-
|[`tracer/`](tracer/)| The Live Trace app: capture, recognition, review and export. Writes the same JSON the sources read. |
71
+
|[`core/`](core/)|`schema.py`holds`StandardEvent`, the only shape the math ever sees. `engine.py`is the decay and smoothing. `chart.py`renders the areachart from a `ChartProfile`. |
72
+
|[`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. |
73
+
|[`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. |
74
+
|[`tracer/`](tracer/)| The Live Trace app: capture, recognition, review, export. Writes the same JSON the sources read. |
79
75
80
-
**To add a sport:** implement `BaseSport` in `translators/`, register it in `translators/__init__.py`'s `SPORTS` dict, 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 marker-only rather than fed into the decay sum.
76
+
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.
81
77
82
-
**To add a data provider** (Opta, StatsBomb, …): implement `BaseDataSource` in `sources/`, mapping its raw fields into the shape your chosen `Sport.translate()` expects. The decay and rendering pipeline is unchanged either way.
78
+
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.
The recognizer is gated by a corpus of 39 synthetic trace scenarios ([`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); the tuning loop that moves them — save a trace, promote it with its expected truth, sweep or fit — is documented in [tracer/TUNING.md](tracer/TUNING.md), alongside the current calibration status.
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.
93
89
94
90
## Background
95
91
96
-
This is a fork of [JakeBonnici22/match-momentum](https://github.com/JakeBonnici22/match-momentum), a reconstruction of FIFA's World Cup 2026 broadcast momentum graphic: an exponential-decay model over a hand-built football event stream, validated against published Flashscore graphics. That original write-up — the ARG–EGY match narrative, the validation against reference charts, and the honest limits of both — is worth reading in the [upstream README](https://github.com/JakeBonnici22/match-momentum#readme).
92
+
This started as a fork of [JakeBonnici22/match-momentum](https://github.com/JakeBonnici22/match-momentum), which rebuilt FIFA's World Cup 2026 broadcast momentum graphic: an exponential-decay model over a hand-built football event stream, checked against published Flashscore graphics. The ARG–EGY match narrative, the validation work and an honest account of the limits of both are all in the [upstream README](https://github.com/JakeBonnici22/match-momentum#readme), and I'd rather link to it than paraphrase it.
97
93
98
-
Two things pulled this repo away from there. First, the model is not really about football, so the football-specific parts were factored out into the translator and source split above. Second, the model needs an event stream, and typing one out during a match is slow and imprecise — which is what the tracer exists to solve. An earlier attempt at that, a keyboard-only React event logger, is archived unchanged in [`legacy/tagger/`](legacy/tagger/): a keyboard vocabulary can record *that* a carry happened but not *where*, and territory is most of rugby's momentum signal.
94
+
Two things took this repo somewhere else. The model isn't really about football, so I pulled the football-specific parts out into the translator and source split above. And the model needs an event stream from somewhere, which is the actual problem: typing one out during a match is slow and you get it wrong. That's what the tracer is for. My first go at it was a keyboard-only React event logger, archived as-is in [`legacy/tagger/`](legacy/tagger/). A keyboard vocabulary can tell you a carry happened but not where it happened, and in rugby where is most of the signal.
99
95
100
96
## Acknowledgements
101
97
102
-
Thanks to [Jake Bonnici](https://github.com/JakeBonnici22) for [match-momentum](https://github.com/JakeBonnici22/match-momentum), the project this one is forked from. The decay-and-smoothing engine in `core/engine.py` and the broadcast-style chart it feeds are his work, and they remain the heart of everything here — the rugby translator and the tracer are input layers wrapped around a model that already worked.
98
+
Thanks to [Jake Bonnici](https://github.com/JakeBonnici22) for [match-momentum](https://github.com/JakeBonnici22/match-momentum), which this is forked from. The decayandsmoothing engine in `core/engine.py`, and the chart it feeds, are his. The rugby translator and the tracer are input layers wrapped around a model that already worked.
103
99
104
100
## Contributing
105
101
106
-
Issues and pull requests welcome. Run `python -m pytest -q` before opening one; anything touching the recognizer should come with a fixture scenario in `tracer/fixtures.py` covering it.
0 commit comments