Rugby match — coupled counting processes turning event rates into a scoreline

Methodology card. This is the primary human- and agent-legible description of the model. The runnable stub beside it (stub.go) is the type-checked generative demonstration; this card carries the structure, assumptions, and validity regime that the Go code does not spell out.

System

A single 80-minute rugby union match between a home and an away side, generated event-by-event as a system of coupled stochastic counting processes running one minute per step. Scoring events (tries, penalties) and discipline events (yellow cards) arrive as Cox processes whose per-minute intensities come from a log-linear (Poisson-GLM) model: rate = baseline · exp(intercept + Σ βⱼ·covariateⱼ). The covariates are substitution indicators — one per position group per side, switching from 0 to 1 at the minute that group’s replacements come on — so “fresh legs” shift a side’s scoring rate. Each new try triggers a Bernoulli conversion attempt, and a derived match-state partition accumulates the scoreline (try = 5, conversion = 2, penalty = 3), tracks active yellow cards (a 10-minute sin-bin), and flags the half. The quantities of interest are the final scoreline and home win probability, and how they respond to substitution timing.

The generative core is eight partitions:

Partition Iteration State Role
baseline_rates general.ParamValuesIteration [6 baseline rates] Constant zero baseline (data-free stand-in for the kernel-smoothed series)
sub_covariates general.FromStorageIteration [8 sub indicators] Per-minute binary substitution covariates replayed from the strategy
score_rates general.ValuesFunctionIteration (ScoreEventRateFunction) [4 rates] Log-linear try / penalty rates (home, away)
card_rates general.ValuesFunctionIteration (CardEventRateFunction) [2 rates] Log-linear yellow-card rates (home, away)
score_events discrete.CoxProcessIteration [home_try, away_try, home_pen, away_pen] Cumulative scoring-event counts
card_events discrete.CoxProcessIteration [home_yellow, away_yellow] Cumulative yellow-card counts
conversion_events ConversionIteration [home_conv, away_conv] Bernoulli conversion per new try
match_state general.ValuesFunctionIteration (MatchStateFunction) [home_score, away_score, diff, home_active_yellow, away_active_yellow, half] Derived scoreline / cards / half

Wiring. The chain is feed-forward within each step: baseline_rates and sub_covariates feed score_rates / card_rates (within-step params_from_upstream); the rate partitions feed the Cox score_events / card_events; score_events feeds conversion_events (which also reads the previous step’s try counts from score_events’ state history to detect new tries) and match_state. match_state reads the card_events history 10 steps back to count still-active yellow cards.

The swept driver. BuildStub exposes one knob, homeSubMinute: the minute the home side empties its bench (all four position groups). Earlier substitution leaves the fresh-legs covariate on for more of the match, and — with the illustrative positive coefficients — lifts the home side’s try and penalty rates. Every other input is a literal Default* constant; the away side’s timing is held fixed so home timing varies in isolation.

Partition wiring

The partition dependency graph, derived statically from the stub’s BuildStub wiring by pkg/graph. Solid arrows are within-step params_from_upstream wiring (which imposes a computation order); dashed arrows leaving a shaded past-copy node are lag reads of a partition’s committed state from an earlier step — drawn as separate source nodes so the graph stays a DAG.

flowchart TB
  n0["baseline_rates"]
  n1["sub_covariates"]
  n2["score_rates"]
  n3["card_rates"]
  n4["score_events"]
  n5["card_events"]
  n6["conversion_events"]
  n7["match_state"]
  n4past["score_events"]
  n5past["card_events"]
  n0 -->|baseline| n2
  n1 -->|covariates| n2
  n0 -->|baseline| n3
  n1 -->|covariates| n3
  n2 -->|rates| n4
  n3 -->|rates| n5
  n4 -->|try_values| n6
  n4past -.->|score_events_partition| n6
  n5 -->|card_values| n7
  n6 -->|conversion_values| n7
  n4 -->|score_values| n7
  n5past -.->|card_partition| n7
  classDef pastcopy fill:#F5F5F5,stroke:#4a7ba6,color:#000;
  class n4past pastcopy;
  class n5past pastcopy;

Ingests (in the stub: nothing)

The stub is data-free — every input is a literal constant in stub.go, with homeSubMinute exposed as the one swept driver. In the downstream application the intercepts and covariate coefficients are a Poisson-GLM fitted by warm-start stochastic gradient descent to real SportDevs match-event streams (tries, penalties, cards, and the substitution timings that generate the covariates), and the baseline rates are adaptive-bandwidth kernel-smoothed per minute from multi-game data (capturing effects like the late-match rise in yellow cards). All of that fitting and smoothing stays downstream; the stub replaces the time-varying baseline with a constant zero baseline (so rates fall back to exp(intercept + covariates)) and ships illustrative coefficients.

Assumptions

Validity regime

Failure modes

Question answered

For a rugby match generated from log-linear event rates, in which direction — and roughly how much — do the scoreline and the home win probability move as the home side changes when (and how fully) it uses its bench, and how do the structural rate, conversion, and card drivers move the outcome?

Generative behaviour under test

stub_test.go asserts, beyond “it runs”: 1. Harness — no NaNs, correct state widths, no params mutation, no statefulness residue across a repeated run (simulator.RunWithHarnesses). 2. Structural invariants — every cumulative counting process (score_events, card_events, conversion_events) is non-decreasing; scores are non-negative; the half indicator is binary and reaches the second half by the final step; and conversions never exceed the tries that trigger them. 3. Correct direction of parameter response — an earlier home substitution (minute 20 vs 70) raises the ensemble-mean home try count, averaged over a 24-member ensemble (the headline row of the generated Observed behaviour table below). Away scoring is flat because the away timing is held fixed — the response is attributable to the home lever alone.

The expected-behaviour suite (behaviour_test.go) makes the decision-readiness explicit — each subtest is a named, plain-language response claim, with the observed number for each emitted by the test run into the Observed behaviour table below (never hand-typed):

Observed behaviour

Every row below is one bound object: a plain-language response claim, the test subtest that enforces it, and the number that test produced (ensemble values rounded to 2 dp). Nothing here is hand-written — the claims and their numbers are emitted by TestRugbyExpectedBehaviour (via go run ./cmd/model-graphs), so a claim cannot drift from its test or its result. If the model’s behaviour changes, either the binding test fails (a claim’s assertion broke) or TestCardsUpToDate fails (a number moved) — a broken claim cannot reach the card silently.

Response claim Enforced by Observed
Earlier home substitution raises home tries (headline driver) TestRugbyExpectedBehaviour/earlier_home_substitution_raises_home_tries ensemble-mean home tries — sub min 70 (late) 4.62 · sub min 20 (early) 6.29
Earlier home substitution raises home win probability TestRugbyExpectedBehaviour/earlier_home_substitution_raises_home_win_probability home win probability — sub min 65 (late) 0.48 · sub min 15 (early) 0.70
Substituting more position groups raises home tries TestRugbyExpectedBehaviour/substituting_more_position_groups_raises_home_tries ensemble-mean home tries — 1 group 4.90 · 4 groups 6.43
Earlier away substitution raises away tries TestRugbyExpectedBehaviour/earlier_away_substitution_raises_away_tries ensemble-mean away tries — sub min 70 (late) 3.70 · sub min 20 (early) 5.33
Higher home try intercept raises home tries TestRugbyExpectedBehaviour/higher_try_intercept_raises_home_tries ensemble-mean home tries — intercept -3.0 5.12 · intercept -2.5 7.92
Higher conversion probability raises home score TestRugbyExpectedBehaviour/higher_conversion_probability_raises_home_score ensemble-mean home score — p=0.75 40.46 · p=0.98 42.96
Stronger per-group substitution effect raises home tries TestRugbyExpectedBehaviour/stronger_substitution_effect_raises_home_tries ensemble-mean home tries — β=0.15 6.43 · β=0.40 13.17
Home-advantage intercept makes home outscore away under symmetric subs TestRugbyExpectedBehaviour/home_advantage_intercept_makes_home_outscore_away ensemble-mean tries — away tries 4.42 · home tries 5.20
Higher yellow-card intercept raises cards TestRugbyExpectedBehaviour/higher_yellow_card_intercept_raises_cards ensemble-mean home yellow cards — intercept -4.5 0.70 · intercept -3.3 2.80

Bespoke extensions (staged beside the stub)

ScoreEventRateFunction / CardEventRateFunction and their computeRates core (rate_function.go), ConversionIteration (conversion.go), MatchStateFunction (state_function.go), and the SubstitutionStrategy → covariate machinery (substitution.go) are custom simulator.Iteration / generative code lifted verbatim from the downstream repo’s pkg/match. The event counting and signal-holding partitions reuse the engine’s own discrete.CoxProcessIteration, general.ParamValuesIteration, general.FromStorageIteration and general.ValuesFunctionIteration, so no bespoke generator is needed there.

These lifted pieces live here rather than in engine core because the catalogue is the staging ground for the “should this be promoted into core?” question — a generic log-linear (Poisson-GLM) rate primitive (baseline · exp(intercept + Σβ·x)) recurring across other models would be the signal to promote, but that waits for the recurrence. The data-fitting helpers that accompany these iterations downstream — the SportDevs API client, the adaptive-bandwidth kernel smoothing of baseline rates, and the warm-start SGD training of the coefficients — are inference / ingestion concerns and were left downstream.

Downstream

Data ingestion (the SportDevs rugby API), baseline kernel smoothing, Poisson-GLM training, and the substitution-counterfactual evaluation / win-probability decision layer live in the project repo:

https://github.com/umbralcalc/trywizard