Impact & normalization
Player reliability, percentile interpolation, ties, and replacement evidence.
Reliability-adjusted impact
The player-impact model scales EPM by sample reliability and provides explicit fallback tiers when EPM is unavailable. The reference implementation below contains those branches and the registry-controlled sample threshold.
season_minutes = games_played × minutes_per_game
reliability = min(1, sqrt(season_minutes / reliability_minutes))
adjusted_epm = epm × reliability
Percentiles are relative measurements
For n benchmark values, order-statistic positions map onto 0–100. Values between distinct adjacent benchmarks interpolate linearly. Ties use the midpoint of tied positions, including at the extrema. Values strictly outside the range clamp to 0 or 100. A singleton gives 0 below, 50 equal, and 100 above; an empty benchmark raises an error.
position_score(i) = 100 × i / (n − 1)
percentile = interpolate between adjacent position scores
A percentile is not a forecast
A score of 80 describes the model's position within its reference distribution. It is not an 80% chance of winning, and moving to a different benchmark population changes the interpretation.
Worked example: a tied benchmark
For the illustrative benchmark [0, 10, 10, 30], these outputs are calculated by Percentile.score/2, not copied from a separate formula. The table rounds to three decimal places.
| Raw value | Percentile score |
|---|---|
| -5 | 0.0 |
| 10 | 50.0 |
| 20 | 83.333 |
| 35 | 100.0 |
Current reliability threshold: 1200
season minutes, read from the model registry.
Implementation
These notes and source files are captured from this deployment's code. Expand a module for its algorithm and branches, or download the Elixir file for local inspection.
player_impact.exElixir
lib/trade_machine/gm_grade_v2/player_impact.ex
Reliability-adjusted player impact — the shared player-level input every AVC
pillar consumes (
avc-grading-system/markdown/02-pillar-1-competitive-strength.md
,
"Shared Player-Impact Calculation").
AVC's calculation is three lines:
Season Minutes = Games Played × Minutes Per Game
Reliability Factor = min(1, sqrt(Season Minutes / 1,200))
Adjusted EPM = Overall EPM × Reliability Factor
with the same factor applied to offensive and defensive EPM, and a three-tier
fallback when overall EPM is unavailable: a season-specific BPM regression,
then a blend of the remaining advanced metrics, then a provisional
replacement-level value. The 1,200-minute threshold lives in
TradeMachine.GMGradeV2.ModelVersion
because AVC calls it tunable.
There is no reliability-adjusted EPM anywhere else in this repo —
player_stats
stores raw
epm
/
epm_off
/
epm_def
and nothing adjusts them. This module is
that adjustment, and it is the only one.
Purity and the `ctx` argument
adjust/2
is pure: it never touches
Repo
, the filesystem, or the clock. Two
of the three fallback tiers need information that cannot be derived from one
player row — the fitted regression coefficients (tier 1) and the season's
population moments (tier 2) — so the caller supplies them in
ctx
. The default
%{}
leaves tiers 1–2 unreachable, so
adjust(stats)
still works and simply
degrades to the provisional tier.
%{
regression: %{"2025-26" => %{"a" => 0.09, "b" => 1.43, "n" => 412, "r2" => 0.71}},
population: %{
"2025-26" => %{
"epm" => %{"mean" => -0.4, "sd" => 2.28},
"bpm" => %{"mean" => -1.1, "sd" => 1.19},
"vorp_per_minute" => %{"mean" => 0.0004, "sd" => 0.0002},
"ws_per_48" => %{"mean" => 0.07, "sd" => 0.06}
}
},
override: %{}
}
The outer keys are atoms (this is an in-memory options map); everything *inside*
the two tables is string-keyed, so the JSON the fitting task writes can be
handed over verbatim with no atom/string conversion step (DECISIONS D-42).
:override
threads lab overrides (Phase 16) through to
ModelVersion
.
load_regression_table/0
is the single I/O function here and is deliberately
*not* called by
adjust/2
— callers load once and pass the table down, rather
than each of ~500 player rows re-reading a file.
Fallback order (AVC "Missing EPM Data")
1.
:epm
— measured overall EPM, shrunk by the reliability factor.
2.
:bpm_regression
—
a + b × bpm
from the season's fitted coefficients,
then shrunk. Skipped when
bpm
is nil, when
ctx
carries no table, or when
the table has no entry for this row's season.
3.
:blend
— the available advanced metrics standardized against the season
population, averaged, and mapped back onto the EPM scale, then shrunk.
4.
:provisional
—
ModelVersion.provisional_epm/1
, *not* shrunk (D-32).
Every result records how it was produced:
source
,
confidence
,
blend_inputs
, and
imputed_splits
.
confidence
describes the *method*, not
the sample — sample size is already reported, in full precision, as
reliability_factor
, and folding it into
confidence
would count it twice.
### Why the blend so often has one input
This repo's
vorp
is
AdvancedMetrics.compute_vorp/2
, which returns nil when
bpm
is nil and is otherwise a linear function of
bpm
— so the blend's three
nominal inputs are really "bpm and two of its shadows" when bpm exists, and
ws_per_48
alone when it does not. That is why
blend_inputs
is reported:
a one-input blend is a materially weaker estimate than a three-input one and
must be visible downstream rather than hidden behind
source: :blend
.
vorp_per_minute
divides by Season Minutes, not the
total_minutes
column —
one minutes definition for the whole module (D-38), and
total_minutes
is not
even selected by
NBA.Stats.list_player_stats_for_season_full/1
.
Missing data is never a silent zero
Per DECISIONS D-31, a nil
games_played
or
minutes_per_game
yields
season_minutes: nil
and
reliability_factor: nil
and routes to the
provisional tier. Coercing the nil to 0 would drive the factor to 0 and turn a
*real* EPM into
0.0
, which reads downstream as league-average rather than as
unknown. Season minutes of genuinely 0 route to provisional too — a value
attached to zero minutes cannot have been measured from them — but their factor
is reported as the computed
0.0
, because unlike the nil case it *is* known.
Structurally impossible inputs (negative games or minutes) raise.
Per D-32 the provisional pair is not shrunk. It is not an estimate from a small
sample; it is AVC's answer for "no meaningful NBA sample exists", and
-1.5 × 0.0
would collapse replacement level onto league average and grade an
unplayed rookie above a known-poor veteran.
Offensive/defensive splits
Measured splits are used as measured, and a measured split is never discarded. EPM decomposes additively, so against a *measured* overall the rules are:
* both splits measured — both used as measured,
imputed_splits: []
* exactly one measured — the other is
overall - measured
, which the identity
hands you exactly; only the derived side is named in
imputed_splits
* neither measured — even halves, both named
Additivity also runs the other way: both splits measured with no stored overall
*is* a measurement of the overall (
epm_off + epm_def
), and is treated as one
rather than falling through to an estimate. Exactly one split with no overall
cannot reconstruct the sum and stays on the estimate path.
D-15's 50/50 rule is scoped to an *estimated* overall, which carries no
information about its components: every estimated tier splits evenly by
construction and names both. The provisional tier uses AVC's authored
-0.75
/
-0.75
pair, which is not an imputation and so is not listed.
SHA-256 9d0e2c341aff998a2e0de80118cf8a5a887d220affbf380e7ba596f9bd74ec44
defmodule TradeMachine.GMGradeV2.PlayerImpact do
@moduledoc """
Reliability-adjusted player impact — the shared player-level input every AVC
pillar consumes (`avc-grading-system/markdown/02-pillar-1-competitive-strength.md`,
"Shared Player-Impact Calculation").
AVC's calculation is three lines:
Season Minutes = Games Played × Minutes Per Game
Reliability Factor = min(1, sqrt(Season Minutes / 1,200))
Adjusted EPM = Overall EPM × Reliability Factor
with the same factor applied to offensive and defensive EPM, and a three-tier
fallback when overall EPM is unavailable: a season-specific BPM regression,
then a blend of the remaining advanced metrics, then a provisional
replacement-level value. The 1,200-minute threshold lives in
`TradeMachine.GMGradeV2.ModelVersion` because AVC calls it tunable.
There is no reliability-adjusted EPM anywhere else in this repo — `player_stats`
stores raw `epm`/`epm_off`/`epm_def` and nothing adjusts them. This module is
that adjustment, and it is the only one.
## Purity and the `ctx` argument
`adjust/2` is pure: it never touches `Repo`, the filesystem, or the clock. Two
of the three fallback tiers need information that cannot be derived from one
player row — the fitted regression coefficients (tier 1) and the season's
population moments (tier 2) — so the caller supplies them in `ctx`. The default
`%{}` leaves tiers 1–2 unreachable, so `adjust(stats)` still works and simply
degrades to the provisional tier.
%{
regression: %{"2025-26" => %{"a" => 0.09, "b" => 1.43, "n" => 412, "r2" => 0.71}},
population: %{
"2025-26" => %{
"epm" => %{"mean" => -0.4, "sd" => 2.28},
"bpm" => %{"mean" => -1.1, "sd" => 1.19},
"vorp_per_minute" => %{"mean" => 0.0004, "sd" => 0.0002},
"ws_per_48" => %{"mean" => 0.07, "sd" => 0.06}
}
},
override: %{}
}
The outer keys are atoms (this is an in-memory options map); everything *inside*
the two tables is string-keyed, so the JSON the fitting task writes can be
handed over verbatim with no atom/string conversion step (DECISIONS D-42).
`:override` threads lab overrides (Phase 16) through to `ModelVersion`.
`load_regression_table/0` is the single I/O function here and is deliberately
*not* called by `adjust/2` — callers load once and pass the table down, rather
than each of ~500 player rows re-reading a file.
## Fallback order (AVC "Missing EPM Data")
1. `:epm` — measured overall EPM, shrunk by the reliability factor.
2. `:bpm_regression` — `a + b × bpm` from the season's fitted coefficients,
then shrunk. Skipped when `bpm` is nil, when `ctx` carries no table, or when
the table has no entry for this row's season.
3. `:blend` — the available advanced metrics standardized against the season
population, averaged, and mapped back onto the EPM scale, then shrunk.
4. `:provisional` — `ModelVersion.provisional_epm/1`, *not* shrunk (D-32).
Every result records how it was produced: `source`, `confidence`,
`blend_inputs`, and `imputed_splits`. `confidence` describes the *method*, not
the sample — sample size is already reported, in full precision, as
`reliability_factor`, and folding it into `confidence` would count it twice.
### Why the blend so often has one input
This repo's `vorp` is `AdvancedMetrics.compute_vorp/2`, which returns nil when
`bpm` is nil and is otherwise a linear function of `bpm` — so the blend's three
nominal inputs are really "bpm and two of its shadows" when bpm exists, and
`ws_per_48` alone when it does not. That is why `blend_inputs` is reported:
a one-input blend is a materially weaker estimate than a three-input one and
must be visible downstream rather than hidden behind `source: :blend`.
`vorp_per_minute` divides by Season Minutes, not the `total_minutes` column —
one minutes definition for the whole module (D-38), and `total_minutes` is not
even selected by `NBA.Stats.list_player_stats_for_season_full/1`.
## Missing data is never a silent zero
Per DECISIONS D-31, a nil `games_played` or `minutes_per_game` yields
`season_minutes: nil` and `reliability_factor: nil` and routes to the
provisional tier. Coercing the nil to 0 would drive the factor to 0 and turn a
*real* EPM into `0.0`, which reads downstream as league-average rather than as
unknown. Season minutes of genuinely 0 route to provisional too — a value
attached to zero minutes cannot have been measured from them — but their factor
is reported as the computed `0.0`, because unlike the nil case it *is* known.
Structurally impossible inputs (negative games or minutes) raise.
Per D-32 the provisional pair is not shrunk. It is not an estimate from a small
sample; it is AVC's answer for "no meaningful NBA sample exists", and
`-1.5 × 0.0` would collapse replacement level onto league average and grade an
unplayed rookie above a known-poor veteran.
## Offensive/defensive splits
Measured splits are used as measured, and a measured split is never discarded.
EPM decomposes additively, so against a *measured* overall the rules are:
* both splits measured — both used as measured, `imputed_splits: []`
* exactly one measured — the other is `overall - measured`, which the identity
hands you exactly; only the derived side is named in `imputed_splits`
* neither measured — even halves, both named
Additivity also runs the other way: both splits measured with no stored overall
*is* a measurement of the overall (`epm_off + epm_def`), and is treated as one
rather than falling through to an estimate. Exactly one split with no overall
cannot reconstruct the sum and stays on the estimate path.
D-15's 50/50 rule is scoped to an *estimated* overall, which carries no
information about its components: every estimated tier splits evenly by
construction and names both. The provisional tier uses AVC's authored
`-0.75`/`-0.75` pair, which is not an imputation and so is not listed.
"""
alias TradeMachine.GMGradeV2.ModelVersion
@fallback_file "priv/gm_grade_v2/epm_fallback.json"
# Blend inputs in canonical order: {reported name, population-table key}. The
# fixed literal order is what makes the averaged z-score reproducible bit for
# bit regardless of how the caller assembled `stats` (D-50).
@blend_metrics [
{:bpm, "bpm"},
{:vorp_per_minute, "vorp_per_minute"},
{:ws_per_48, "ws_per_48"}
]
# A population whose spread is below this is treated as having no spread at
# all: standardizing against it divides by ~0 and manufactures huge z-scores.
# A threshold rather than `== 0.0`, which float accumulation routinely misses.
@sd_epsilon 1.0e-12
@type source :: :epm | :bpm_regression | :blend | :provisional
@type confidence :: :high | :medium | :low
@type t :: %{
adjusted_epm: float(),
adjusted_oepm: float(),
adjusted_depm: float(),
reliability_factor: float() | nil,
season_minutes: float() | nil,
source: source(),
confidence: confidence(),
blend_inputs: [atom()],
imputed_splits: [:offensive | :defensive]
}
@doc """
AVC's Season Minutes: `games_played × minutes_per_game`.
Returns `nil` when either input is nil — an unknown sample, not a zero one
(D-31). Never reads the `total_minutes` column, which genuinely disagrees with
this product (D-38). Raises on negative inputs.
Accepts a `%TradeMachine.NBA.PlayerStats{}` or any map carrying the two keys;
on a stats row an absent key and a nil value both mean "not available", so
they are read the same way.
"""
@spec season_minutes(map()) :: float() | nil
def season_minutes(stats) when is_map(stats) do
minutes(Map.get(stats, :games_played), Map.get(stats, :minutes_per_game))
end
defp minutes(nil, _mpg), do: nil
defp minutes(_gp, nil), do: nil
defp minutes(gp, mpg) when is_number(gp) and is_number(mpg) and gp >= 0 and mpg >= 0,
do: gp * mpg * 1.0
defp minutes(gp, mpg) do
raise ArgumentError,
"games_played and minutes_per_game must be non-negative numbers or nil, " <>
"got #{inspect(gp)} and #{inspect(mpg)}"
end
@doc """
AVC's Reliability Factor: `min(1, sqrt(season_minutes / 1,200))`.
`nil` season minutes → `nil`: the factor is not computable, and a 0.0 here
would be indistinguishable from the genuine factor of a zero-minute player.
Full precision is retained — `reliability_factor(600)` is
`0.7071067811865476`, not AVC's printed `0.71`.
"""
@spec reliability_factor(number() | nil, map()) :: float() | nil
def reliability_factor(season_minutes, override \\ %{})
def reliability_factor(nil, _override), do: nil
def reliability_factor(season_minutes, override)
when is_number(season_minutes) and season_minutes >= 0 do
min(1.0, :math.sqrt(season_minutes / ModelVersion.reliability_minutes(override)))
end
def reliability_factor(season_minutes, _override) do
raise ArgumentError,
"season_minutes must be a non-negative number or nil, got #{inspect(season_minutes)}"
end
@doc """
Reliability-adjusted overall/offensive/defensive impact for one player-season,
with the method and confidence that produced it.
See the module doc for the `ctx` contract and the fallback order. Pure: with
the default `ctx` only the measured and provisional tiers are reachable.
"""
@spec adjust(map(), map()) :: t()
def adjust(stats, ctx \\ %{}) when is_map(stats) and is_map(ctx) do
validate_ctx!(ctx)
override = Map.get(ctx, :override, %{})
season_minutes = season_minutes(stats)
factor = reliability_factor(season_minutes, override)
# D-31: an unknown or empty sample decides the tier before any stored value
# is consulted. An EPM attached to no measured minutes is not a measurement.
if is_nil(season_minutes) or season_minutes == 0 do
provisional(season_minutes, factor, override)
else
measure(stats, ctx, season_minutes, factor, override)
end
end
# An unrecognised `ctx` key makes both estimate tiers unreachable without
# raising anything: every player missing EPM lands on the provisional
# constant, which reads downstream as "this league has no measured impact"
# rather than as a caller error. The likely producer is a consumer rebuilding
# `ctx` from a frozen document, where jsonb hands back string keys (D-42) —
# `%{"regression" => …}` is silently the empty ctx. Cheaper to refuse.
@ctx_keys [:regression, :population, :override]
defp validate_ctx!(ctx) do
case Map.keys(ctx) -- @ctx_keys do
[] ->
:ok
unknown ->
raise ArgumentError,
"unknown ctx key(s): #{inspect(unknown)}; expected any of #{inspect(@ctx_keys)}"
end
end
defp measure(stats, ctx, season_minutes, factor, override) do
case overall(stats) do
{:ok, epm} ->
{oepm, depm, imputed} =
split(Map.get(stats, :epm_off), Map.get(stats, :epm_def), epm)
%{
adjusted_epm: epm * factor,
adjusted_oepm: oepm * factor,
adjusted_depm: depm * factor,
reliability_factor: factor,
season_minutes: season_minutes,
source: :epm,
confidence: :high,
blend_inputs: [],
imputed_splits: imputed
}
:error ->
estimate(stats, ctx, season_minutes, factor, override)
end
end
# The measured tier's entry condition. A stored `epm` is the direct answer, but
# it is not the only measurement: EPM decomposes additively (verified — all 437
# rows carrying all three satisfy `epm == epm_off + epm_def` to within the
# column's own 0.01 precision), so a row with both splits and no overall has
# measured the overall too, just in two pieces.
#
# Reading only `:epm` discarded that. A row with `epm_off 3.0 / epm_def 1.0` and
# no overall fell through to the estimate tiers and published the provisional
# -1.5 / -0.75 / -0.75 — two measured values dropped in favour of authored
# constants, and `imputed_splits: []` affirmatively claiming nothing had been
# imputed. With a fitted table it was worse: a `:medium`-confidence regression
# overrode a measurement.
#
# Exactly one split with no overall stays on the estimate path on purpose. One
# component cannot reconstruct the sum, and pairing a measured split with an
# estimated overall would publish `estimated - measured` as the other half —
# under the provisional tier that is `-1.5 - 3.0 = -4.5`, an authored constant
# and a measurement subtracted from one another. Logged for AVC rather than
# resolved by invention; see `DEFERRED.md` (`04-R2-SPLIT`).
defp overall(stats) do
case {Map.get(stats, :epm), Map.get(stats, :epm_off), Map.get(stats, :epm_def)} do
{epm, _off, _def} when is_number(epm) -> {:ok, epm}
{_epm, off, def} when is_number(off) and is_number(def) -> {:ok, off + def}
_ -> :error
end
end
defp estimate(stats, ctx, season_minutes, factor, override) do
season = Map.get(stats, :season)
case regression(stats, ctx, season) do
{:ok, estimated_epm} ->
estimated(estimated_epm, season_minutes, factor, :bpm_regression, :medium, [])
:error ->
case blend(stats, ctx, season, season_minutes, override) do
{:ok, estimated_epm, inputs} ->
estimated(estimated_epm, season_minutes, factor, :blend, :low, inputs)
:error ->
provisional(season_minutes, factor, override)
end
end
end
# Tier 1 — season-specific OLS fit written by `mix gm_grade_v2.fit_epm_fallback`.
# A nil `season` (the shape `list_player_stats_for_season_full/1` returns does
# not select the column) fails the fetch and falls through, which is the point
# of fetching rather than defaulting.
defp regression(stats, ctx, season) do
with bpm when is_number(bpm) <- Map.get(stats, :bpm),
{:ok, table} when is_map(table) <- Map.fetch(ctx, :regression),
{:ok, fit} when is_map(fit) <- Map.fetch(table, season),
{:ok, a} <- number_fetch(fit, "a"),
{:ok, b} <- number_fetch(fit, "b") do
{:ok, a + b * bpm}
else
_ -> :error
end
end
# Tier 2 — standardize whatever advanced metrics survive against the season
# population, average the z-scores, map back onto the EPM scale.
defp blend(stats, ctx, season, season_minutes, override) do
minimum = ModelVersion.min_blend_population(override)
with {:ok, population} <- population(ctx, season),
{:ok, epm_mean, epm_sd} <- moments(population, "epm", minimum),
[_ | _] = scored <- standardize(stats, population, season_minutes, minimum) do
mean_z = Enum.sum(Enum.map(scored, &elem(&1, 1))) / length(scored)
{:ok, epm_mean + mean_z * epm_sd, Enum.map(scored, &elem(&1, 0))}
else
_ -> :error
end
end
defp standardize(stats, population, season_minutes, minimum) do
Enum.flat_map(@blend_metrics, fn {name, key} ->
with value when is_number(value) <- blend_input(stats, name, season_minutes),
{:ok, mean, sd} <- moments(population, key, minimum) do
[{name, (value - mean) / sd}]
else
_ -> []
end
end)
end
defp blend_input(stats, :bpm, _season_minutes), do: Map.get(stats, :bpm)
defp blend_input(stats, :ws_per_48, _season_minutes), do: Map.get(stats, :ws_per_48)
defp blend_input(stats, :vorp_per_minute, season_minutes) do
case Map.get(stats, :vorp) do
vorp when is_number(vorp) -> vorp / season_minutes
_ -> nil
end
end
defp population(ctx, season) do
with {:ok, table} when is_map(table) <- Map.fetch(ctx, :population),
{:ok, population} when is_map(population) <- Map.fetch(table, season) do
{:ok, population}
else
_ -> :error
end
end
# Fails closed on a population too small to standardize against, mirroring the
# `--min-sample` floor the regression tier already enforces. Without this the
# two guards are backwards: tier 1 correctly refuses a season whose EPM
# resolves only a handful of superstars, and every one of that season's
# players then lands here and is standardized against those same few rows —
# 2024-25 mapped its median player to +6.3 adjusted EPM (top-three-in-the-NBA
# impact) with no player receiving a negative value, and the build reported it
# as ordinary degradation. A population that cannot say how large it is is
# unusable rather than absent (D-52), so a missing `n` refuses too.
defp moments(population, key, minimum) do
with {:ok, entry} when is_map(entry) <- Map.fetch(population, key),
{:ok, mean} <- number_fetch(entry, "mean"),
{:ok, n} when n >= minimum <- number_fetch(entry, "n"),
{:ok, sd} when sd > @sd_epsilon <- number_fetch(entry, "sd") do
{:ok, mean, sd}
else
_ -> :error
end
end
# `Map.fetch/2` rather than `Map.get(a) || Map.get(b)`: absence has to stay
# distinguishable from a stored value here (D-52).
defp number_fetch(map, key) do
case Map.fetch(map, key) do
{:ok, value} when is_number(value) -> {:ok, value}
_ -> :error
end
end
# D-15: an estimated overall value carries no information about the split, so
# the components are even halves and both are declared imputed.
defp estimated(estimated_epm, season_minutes, factor, source, confidence, blend_inputs) do
half = estimated_epm / 2
%{
adjusted_epm: estimated_epm * factor,
adjusted_oepm: half * factor,
adjusted_depm: half * factor,
reliability_factor: factor,
season_minutes: season_minutes,
source: source,
confidence: confidence,
blend_inputs: blend_inputs,
imputed_splits: [:offensive, :defensive]
}
end
# D-32: returned unshrunk, on purpose.
defp provisional(season_minutes, factor, override) do
provisional = ModelVersion.provisional_epm(override)
%{
adjusted_epm: Map.fetch!(provisional, :overall),
adjusted_oepm: Map.fetch!(provisional, :offensive),
adjusted_depm: Map.fetch!(provisional, :defensive),
reliability_factor: factor,
season_minutes: season_minutes,
source: :provisional,
confidence: :low,
blend_inputs: [],
imputed_splits: []
}
end
# Splits for a *measured* overall. D-15's 50/50 rule is scoped to an estimated
# overall ("when overall EPM is estimated … components split 50/50") and
# `estimated/6` owns that case; this one is different, because EPM decomposes
# additively and a measured overall is therefore evidence about the missing
# half rather than silence.
#
# Verified against the data rather than assumed: all 437 `player_stats` rows
# carrying all three values satisfy `epm == epm_off + epm_def` to within 0.01,
# which is the column's own stored precision. So with `epm 4.0` and
# `epm_off 3.0` the defensive component is 1.0, not the 2.0 a blind halving
# produces — halving there discards a value the identity hands you and
# overstates the missing side by 100%. It is still reported as imputed: it is
# derived, not measured.
#
# Both splits missing keeps the even halves. Nothing else is available, and
# the pair at least sums to the measured overall.
#
# Latent today (zero rows have an overall with a missing split — the importer
# writes all three from one source row), which is exactly why it is worth
# fixing now: a frozen benchmark cannot be corrected afterwards, only
# superseded.
defp split(offensive, defensive, overall) do
case {is_number(offensive), is_number(defensive)} do
{true, true} -> {offensive, defensive, []}
{true, false} -> {offensive, overall - offensive, [:defensive]}
{false, true} -> {overall - defensive, defensive, [:offensive]}
{false, false} -> {overall / 2, overall / 2, [:offensive, :defensive]}
end
end
@doc """
Reads the fitted BPM→EPM coefficients written by
`mix gm_grade_v2.fit_epm_fallback` into a `%{season => coefficients}` map for
`ctx[:regression]`.
Returns `%{}` when the file is absent or unreadable, which makes tier 1
unreachable and every nil-EPM player fall through to the blend or provisional
tier — AVC's "use the following fallback order" applied to the fallback data
itself. This is the only function in the module that performs I/O; `adjust/2`
never calls it.
"""
@spec load_regression_table() :: %{optional(String.t()) => map()}
def load_regression_table do
path = Application.app_dir(:trade_machine, @fallback_file)
with {:ok, body} <- File.read(path),
{:ok, table} when is_map(table) <- Jason.decode(body) do
table
else
_ -> %{}
end
end
end
availability.exElixir
lib/trade_machine/gm_grade_v2/availability.ex
Season-availability weighting for one rotation entry.
A rotation describes a single game: 240 minutes, five starters, an assigned
MPG per player. Nothing in it says how many of the 82 games those minutes are
actually played, so a player out until February contributes his 30 MPG at the
same season strength as an ironman.
expected_games
is the user's optional
answer (see
TradeMachine.GMGradeV2.RotationValidation
); this module turns it
into the multiplier the four Competitive Strength metrics apply to that
player's rotation contribution.
The formulation, and why it is not `expected_games / 82`
factor = min(1, expected_games / baseline_games)
where
baseline_games
is the player's own frozen
games_played
clamped into
1..82, and 82 when there is no history to read.
The obvious formulation is
expected_games / 82
, and it breaks the one
constraint this feature has to hold. Competitive Strength is a percentile
against 30 benchmark rotations that
TradeMachine.GMGradeV2.BenchmarkRotation
manufactures from completed seasons, and no benchmark rotation carries
expected games — every benchmark player is implicitly weighted 1.0. The
un-annotated user rotation has to weight 1.0 too, or every grade in existence
moves the day this ships.
/82
cannot do that either way round. Weighting an un-annotated player by his
own
games_played / 82
re-grades every rotation ever saved: a 65-game player
quietly loses 21% of his contribution against a benchmark population that lost
nothing. Weighting him 1.0 while an *annotated* player divides by 82 is worse
in a quieter way — the builder shows 65 as the ghost default, the user types
the number they were already being shown, and their grade drops 21%. A control
whose default value is not what grading actually assumes is a control that
lies.
Dividing by the player's own baseline makes both cases agree: absent is 1.0, and *typing the ghost default* is also 1.0. The box moves the grade only when the user disagrees with the historical record, which is the entire point of having it.
Why the cap
min(1, …)
refuses the upweight. Setting a 50-game player to 82 would
otherwise multiply him by 1.64 — a boost measured against 30 benchmark teams
that never had the box to tick. Availability may subtract season value from a
rotation, because the alternative is counting an absent player at full
strength; it may not add value the comparison population was never allowed to
claim.
No double counting
PlayerImpact
's reliability factor (
min(1, sqrt(season_minutes / 1200))
)
and
SkillIndexes
' experience factor both read games played, and neither is
an availability weight: they shrink a *per-minute rate* toward zero when the
sample behind it is thin, and both sit at 1.0 for any player with a full
season's minutes.
SkillIndexes.availability_multiplier/1
is named for
availability but is the 0.5 two-way-contract discount, which has nothing to do
with games played. Nothing in grading scaled a rotation contribution by season
availability before this module, so nothing here counts it twice.
What this deliberately does not do
Season value only. Minutes are not redistributed: an unavailable player's contribution is discounted, not handed to whoever would really play those minutes, which would mean inventing a second rotation the user never authored. A rotation stays a 240-minute document and the denominators stay 240, so the discount shows up as the metric it should — the team is weaker over a season, because for part of that season one of its rotation slots is empty.
Roster Construction and Timeline Alignment also read the rotation, for *structure* (position coverage, young-player development minutes). Structure is not changed by how many games a player is available for, so they are out of scope.
Out-of-range values are ignored, not clamped
RotationValidation
reports a bad
expected_games
rather than clamping it,
and persists the rotation anyway so work in progress survives a reload. This
is the third layer: the grading read path never trusts the stored number.
Anything that is not a whole number in 0..82 reads as "no opinion" and weights
1.0 — a stored
-5
would otherwise invert every contribution it touched, and
a typo'd
950
is not evidence of anything. Such a rotation cannot back a
submission at all (validation blocks it); ignoring rather than clamping means
the provisional preview a user sees mid-edit shows the same number they saw
before the typo instead of an invented one.
SHA-256 8ed37a8c33b52c3624f5c89057633e3fbc47f165546167108c77198ee5792afa
defmodule TradeMachine.GMGradeV2.Availability do
@moduledoc """
Season-availability weighting for one rotation entry.
A rotation describes a single game: 240 minutes, five starters, an assigned
MPG per player. Nothing in it says how many of the 82 games those minutes are
actually played, so a player out until February contributes his 30 MPG at the
same season strength as an ironman. `expected_games` is the user's optional
answer (see `TradeMachine.GMGradeV2.RotationValidation`); this module turns it
into the multiplier the four Competitive Strength metrics apply to that
player's rotation contribution.
## The formulation, and why it is not `expected_games / 82`
factor = min(1, expected_games / baseline_games)
where `baseline_games` is the player's own frozen `games_played` clamped into
1..82, and 82 when there is no history to read.
The obvious formulation is `expected_games / 82`, and it breaks the one
constraint this feature has to hold. Competitive Strength is a percentile
against 30 benchmark rotations that `TradeMachine.GMGradeV2.BenchmarkRotation`
manufactures from completed seasons, and no benchmark rotation carries
expected games — every benchmark player is implicitly weighted 1.0. The
un-annotated user rotation has to weight 1.0 too, or every grade in existence
moves the day this ships.
`/82` cannot do that either way round. Weighting an un-annotated player by his
own `games_played / 82` re-grades every rotation ever saved: a 65-game player
quietly loses 21% of his contribution against a benchmark population that lost
nothing. Weighting him 1.0 while an *annotated* player divides by 82 is worse
in a quieter way — the builder shows 65 as the ghost default, the user types
the number they were already being shown, and their grade drops 21%. A control
whose default value is not what grading actually assumes is a control that
lies.
Dividing by the player's own baseline makes both cases agree: absent is 1.0,
and *typing the ghost default* is also 1.0. The box moves the grade only when
the user disagrees with the historical record, which is the entire point of
having it.
## Why the cap
`min(1, …)` refuses the upweight. Setting a 50-game player to 82 would
otherwise multiply him by 1.64 — a boost measured against 30 benchmark teams
that never had the box to tick. Availability may subtract season value from a
rotation, because the alternative is counting an absent player at full
strength; it may not add value the comparison population was never allowed to
claim.
## No double counting
`PlayerImpact`'s reliability factor (`min(1, sqrt(season_minutes / 1200))`)
and `SkillIndexes`' experience factor both read games played, and neither is
an availability weight: they shrink a *per-minute rate* toward zero when the
sample behind it is thin, and both sit at 1.0 for any player with a full
season's minutes. `SkillIndexes.availability_multiplier/1` is named for
availability but is the 0.5 two-way-contract discount, which has nothing to do
with games played. Nothing in grading scaled a rotation contribution by season
availability before this module, so nothing here counts it twice.
## What this deliberately does not do
Season value only. Minutes are not redistributed: an unavailable player's
contribution is discounted, not handed to whoever would really play those
minutes, which would mean inventing a second rotation the user never authored.
A rotation stays a 240-minute document and the denominators stay 240, so the
discount shows up as the metric it should — the team is weaker over a season,
because for part of that season one of its rotation slots is empty.
Roster Construction and Timeline Alignment also read the rotation, for
*structure* (position coverage, young-player development minutes). Structure
is not changed by how many games a player is available for, so they are out of
scope.
## Out-of-range values are ignored, not clamped
`RotationValidation` reports a bad `expected_games` rather than clamping it,
and persists the rotation anyway so work in progress survives a reload. This
is the third layer: the grading read path never trusts the stored number.
Anything that is not a whole number in 0..82 reads as "no opinion" and weights
1.0 — a stored `-5` would otherwise invert every contribution it touched, and
a typo'd `950` is not evidence of anything. Such a rotation cannot back a
submission at all (validation blocks it); ignoring rather than clamping means
the provisional preview a user sees mid-edit shows the same number they saw
before the typo instead of an invented one.
"""
alias TradeMachine.GMGradeV2.RotationValidation
@full 1.0
@doc """
The number of games grading already assumes for this player — the ghost
default the rotation builder shows, and the denominator of `factor/2`.
Reads `games_played` off frozen player evidence (string- or atom-keyed) and
clamps it into `1..max_expected_games()`. A player with no usable history — a
rookie, or the provisional replacement evidence `ScenarioAssembly` supplements
for a player the snapshot never saw — has no historical availability to
measure against, so the full season is the honest reference: setting such a
player to 41 games means half a season, not half of a zero.
"""
@spec baseline_games(map() | nil) :: pos_integer()
def baseline_games(evidence) when is_map(evidence) do
case fetch(evidence, :games_played) do
games when is_number(games) and games >= 1 ->
games |> min(RotationValidation.max_expected_games()) |> trunc()
_ ->
RotationValidation.max_expected_games()
end
end
def baseline_games(_evidence), do: RotationValidation.max_expected_games()
@doc """
The usable expected-games value on a rotation entry, or `nil`.
`nil` covers all three "grading has no opinion" cases: the key is absent (the
overwhelmingly common one — an untouched rotation never writes it), the value
is blank, or the value is outside what `RotationValidation` accepts.
"""
@spec expected_games(map()) :: non_neg_integer() | nil
def expected_games(entry) when is_map(entry) do
entry
|> fetch(:expected_games)
|> RotationValidation.to_expected_games()
|> usable()
end
def expected_games(_entry), do: nil
@doc """
The multiplier for `entry`'s rotation contribution, given that player's frozen
evidence.
Exactly `1.0` whenever the user has expressed no usable opinion, which is what
makes an un-annotated rotation grade identically to how it graded before
expected games existed. Float multiplication by `1.0` is the identity, so the
parity is bit-for-bit and not merely close.
"""
@spec factor(map(), map() | nil) :: float()
def factor(entry, evidence) do
case expected_games(entry) do
nil -> @full
games -> min(@full, games / baseline_games(evidence))
end
end
defp usable(games) when is_integer(games) and games >= 0 do
if games <= RotationValidation.max_expected_games(), do: games, else: nil
end
defp usable(_games), do: nil
# Entries and evidence arrive atom-keyed in memory and string-keyed out of
# Postgres, exactly as everywhere else in the grader.
defp fetch(map, key) do
case Map.fetch(map, key) do
{:ok, value} -> value
:error -> Map.get(map, Atom.to_string(key))
end
end
end
percentile.exElixir
lib/trade_machine/gm_grade_v2/percentile.ex
The single percentile helper for the AVC grader. AVC converts many raw metrics (Competitive Strength metrics, team skill raw scores, DAV totals) into a 0–100 score by ranking the scenario's raw value against the 30 real NBA teams (or the league player population). This module is that conversion, used everywhere so scores stay consistent and reproducible against a frozen benchmark.
Method (versioned as part of the benchmark model)
Given a value and an ascending list of
n
benchmark values, the percentile maps
the benchmark's order statistics linearly onto 0–100: a value between two
adjacent benchmarks is linearly interpolated between their order-statistic
positions
i/(n-1) × 100
.
* **Strictly below the minimum → 0.0; strictly above the maximum → 100.0**
(clamped). Clamping applies *beyond* the extrema only.
* **Ties → midpoint**: a value equal to a benchmark takes the average of the
tied block's positions. This governs equality *everywhere*, including at
the extrema — a value equal to a minimum that repeats scores the tied
block's midpoint, not 0.0. A unique minimum still scores 0.0 and a unique
maximum still scores 100.0, because a one-element tied block's midpoint
*is* its order-statistic position.
* **Single benchmark (
n == 1
)**: below → 0, equal → 50, above → 100.
Tied extrema are not hypothetical: over a 30-team benchmark, integer-valued metrics (tradeable firsts, established-veteran counts, trade exceptions) tie at 0 routinely, and a team tying at the league minimum should not be scored as though it were below every other team. This matches DECISIONS D-13, which clamps 0/100 *beyond* min/max and resolves ties to the midpoint.
Full precision is retained; callers round only at the presentation boundary.
SHA-256 0751bbde634e0448e991a3a0d0a7fc1c644ef5efb09077506c89f7b1f4979efc
defmodule TradeMachine.GMGradeV2.Percentile do
@moduledoc """
The single percentile helper for the AVC grader. AVC converts many raw metrics
(Competitive Strength metrics, team skill raw scores, DAV totals) into a 0–100
score by ranking the scenario's raw value against the 30 real NBA teams (or the
league player population). This module is that conversion, used everywhere so
scores stay consistent and reproducible against a frozen benchmark.
## Method (versioned as part of the benchmark model)
Given a value and an ascending list of `n` benchmark values, the percentile maps
the benchmark's order statistics linearly onto 0–100: a value between two
adjacent benchmarks is linearly interpolated between their order-statistic
positions `i/(n-1) × 100`.
* **Strictly below the minimum → 0.0; strictly above the maximum → 100.0**
(clamped). Clamping applies *beyond* the extrema only.
* **Ties → midpoint**: a value equal to a benchmark takes the average of the
tied block's positions. This governs equality *everywhere*, including at
the extrema — a value equal to a minimum that repeats scores the tied
block's midpoint, not 0.0. A unique minimum still scores 0.0 and a unique
maximum still scores 100.0, because a one-element tied block's midpoint
*is* its order-statistic position.
* **Single benchmark (`n == 1`)**: below → 0, equal → 50, above → 100.
Tied extrema are not hypothetical: over a 30-team benchmark, integer-valued
metrics (tradeable firsts, established-veteran counts, trade exceptions) tie at
0 routinely, and a team tying at the league minimum should not be scored as
though it were below every other team. This matches DECISIONS D-13, which
clamps 0/100 *beyond* min/max and resolves ties to the midpoint.
Full precision is retained; callers round only at the presentation boundary.
"""
@doc """
Percentile score (0.0–100.0, full precision) of `value` against ascending
`benchmark` values. Raises on an empty benchmark (a percentile against nothing
is undefined — callers must supply the frozen benchmark).
"""
@spec score(number(), [number()]) :: float()
def score(_value, []),
do: raise(ArgumentError, "cannot compute a percentile against an empty benchmark")
def score(value, benchmark) when is_list(benchmark) do
sorted = Enum.sort(benchmark)
n = length(sorted)
cond do
n == 1 -> single(value, hd(sorted))
# Strict: equality at either extreme is a tie, and `interior/3` resolves
# ties to the tied block's midpoint. A unique extreme still lands on 0.0 /
# 100.0 there, so only the duplicated case changes.
value < hd(sorted) -> 0.0
value > List.last(sorted) -> 100.0
true -> interior(value, List.to_tuple(sorted), n)
end
end
# Competition (standard) rank for display: 1 = strictly the best (highest).
# Ties share the lower rank number, the next distinct value skips accordingly.
@doc "Competition rank (1 = highest) of `value` among `benchmark`."
@spec rank(number(), [number()]) :: pos_integer()
def rank(_value, []), do: raise(ArgumentError, "cannot rank against an empty benchmark")
def rank(value, benchmark) when is_list(benchmark) do
Enum.count(benchmark, &(&1 > value)) + 1
end
defp single(value, b) do
cond do
value < b -> 0.0
value > b -> 100.0
true -> 50.0
end
end
defp interior(value, tuple, n) do
below = count_below(tuple, n, value)
equal = count_equal(tuple, n, value)
if equal > 0 do
# Tied block occupies 0-based indices [below, below + equal - 1]; take the
# midpoint position.
avg_index = below + (equal - 1) / 2
avg_index / (n - 1) * 100.0
else
# Strictly between the largest benchmark < value (index below-1) and the
# smallest benchmark > value (index below). Interpolate linearly.
lower_val = elem(tuple, below - 1)
upper_val = elem(tuple, below)
p_lower = (below - 1) / (n - 1) * 100.0
p_upper = below / (n - 1) * 100.0
frac = (value - lower_val) / (upper_val - lower_val)
p_lower + frac * (p_upper - p_lower)
end
end
defp count_below(tuple, n, value), do: count_where(tuple, n, value, &</2)
defp count_equal(tuple, n, value), do: count_where(tuple, n, value, &==/2)
defp count_where(tuple, n, value, cmp) do
Enum.reduce(0..(n - 1), 0, fn i, acc ->
if cmp.(elem(tuple, i), value), do: acc + 1, else: acc
end)
end
end
benchmark_rotation.exElixir
lib/trade_machine/gm_grade_v2/benchmark_rotation.ex
Deterministic 240-minute rotation for a real NBA team's completed season.
A user's scenario rotation is an explicit input — they choose the starters and distribute the minutes. The 30 benchmark teams have no such author, but AVC's Competitive Strength scores are *percentiles against those 30 teams*, so the benchmark rotations have to be manufactured. This module manufactures them, and AVC's interpretation note 3 sets the bar:
> Every real NBA team needs a reproducible starter designation and 240-minute > allocation calculated under the same role and fatigue rules as a user > scenario.
"Same rules as a user scenario" is taken literally here (DECISIONS D-39): the
240-minute total, the five-starter count and the 48-minute-per-player ceiling
are **read from
TradeMachine.GMGradeV2.RotationValidation
**, never restated
as local constants, and every rotation this module produces is run back
through
RotationValidation.validate/2
before it is returned. A benchmark
rotation that a user would not be allowed to submit is not returned at all —
it comes back as
{:error, …}
. See "Why the output is re-validated" below.
The algorithm (version: `ModelVersion.versions()[:benchmarks]`)
1. **Eligibility.** A row needs a
player_id
and positive Season Minutes.
Every rejected row is recorded in
:excluded
with a reason — see "Nothing
is dropped silently".
2. **Rank** by Season Minutes descending, ties broken by
player_id
ascending. This single ordering is the module's canonical order and is
reused for the pool cutoff, the starter tie-break and the rounding
remainder, so there is one thing to reason about rather than three.
3. **Pool.** Walk the ranking accumulating minutes-per-game until 240 is
covered, including the player who crosses it — a game *is* 240 player
minutes, so this selects the players a real rotation actually consumes
(9–11 per team in 2025-26). Always at least five players, so the
five-starter rule is satisfiable.
4. **Allocate** each pool member minutes in proportion to their minutes per
game, scaled so the total is exactly 240, with the 48-minute ceiling
applied by water-filling: cap whoever exceeds it, redistribute their excess
across the rest, repeat until nobody does.
5. **Round** to whole minutes and correct the residue (see "Rounding").
6. **Starters** — the top five in the pool by games started, ties broken by
Season Minutes then
player_id
, with the rule that actually ran reported
in
:starter_basis
(see "Which starter rule ran").
Season Minutes is *not* the `total_minutes` column (DECISIONS D-38)
The ranking quantity is
games_played × minutes_per_game
via
PlayerImpact.season_minutes/1
— AVC's "Season Minutes" (D-11), the same
quantity that drives the 1,200-minute Reliability Factor and the 800-minute
Experience Factor.
player_stats
*also* carries a literal
total_minutes
column, and the two disagree:
minutes_per_game
is stored rounded to one
decimal, so the product accumulates error across a season (221 of 422 rows in
2025-26 differ by more than a minute, worst case 4.3). Ranking by the stored
column would be marginally more accurate in isolation and is the wrong choice
anyway — a player must not be able to rank above a teammate here while
ranking below him in the reliability adjustment applied to the very same
rotation. Measured, not assumed: across the 30 teams the two keys select an
identical pool, and differ in ordering on two teams.
Rounding
Proportional targets are rounded to the nearest whole minute (whole minutes
because
RotationValidation
rejects fractional assignments), which leaves a
residue of a few minutes either way. The residue is handed out ±1 at a time
down the canonical order — highest Season Minutes first, wrapping if it runs
past the end — skipping anyone who would breach the 48-minute ceiling or go
negative.
This is the task's prescription (D-40) rather than the largest-remainder method that would minimise deviation from exact proportionality. Priced before adopting: summed across the 30 real 2025-26 teams, total departure from the exact proportional split is 89.8 minutes here against 77.8 under largest-remainder, and no single player is off by more than 1.5 minutes. It costs little because rounding to nearest is already unbiased — only the small leftover is directional, not a whole minute per player as a floor-and-top-up scheme would be. It is worth knowing that it *is* a choice: a top-of-rotation tiebreak, not a neutral one.
Which starter rule ran (DECISIONS D-47)
player_stats.games_started
is NULL for every row in every database audited,
so the
games_started
criterion has never actually executed and "top five by
games started" silently degrades into "top five by Season Minutes". The
degraded rule still produces a perfectly legal 240-minute, five-starter
rotation, which is exactly why it went unnoticed — so the rule that ran is
reported in
:starter_basis
rather than left to be inferred, and consumers
freeze it into the benchmark document.
Nothing is dropped silently
The phase constraint is that missing data is recorded rather than imputed. A
team whose roster rows are half empty must not quietly produce a rotation
built from the surviving half and look identical to a complete one, so
:excluded
names every row that did not become an entry and why
(
:missing_player_id
,
:missing_minutes
,
:no_minutes
,
:outside_rotation
,
:not_a_map
).
:outside_rotation
is the ordinary case — a deep bench player
who lost the cutoff — and is listed alongside the others so a caller
reconciling roster size against rotation size gets one complete account.
Structurally impossible values (negative games or minutes) raise, via
PlayerImpact.season_minutes/1
. Duplicate
player_id
s are an error rather
than being merged or de-duplicated: two rows for one player in one season is a
caller bug, and silently picking one of them would produce a rotation nobody
asked for. The check runs over every row handed in, **including the ones
eligibility would drop** — a duplicate whose second row has no minutes is
invisible to this module's own output and still poisons a caller that keys its
own maps off the same rows. See
check_duplicates/1
.
Determinism (DECISIONS D-50)
The same rows in any order produce an identical result, and that has to hold
for the *whole* result, not just for
:entries
. Ineligible rows are therefore
sorted by
{player_id, reason}
before they are returned — reading them off in
arrival order would make
:excluded
a function of however Postgres happened
to return the team's rows, on a value that gets frozen into a benchmark
document. Everything else reduces over the canonically ordered pool.
Why the output is re-validated
Running the finished rotation back through
RotationValidation.validate/2
makes "if this returns
{:ok, _}
, the rotation is legal" true by construction
instead of true by argument. Every invariant — 240 exactly, five starters,
nothing over 48, nothing negative or fractional, no duplicates — is then
enforced by the same code path that polices user submissions, so an arithmetic
regression in the allocator surfaces as a loud
{:error, …}
rather than as a
benchmark that is quietly 239 minutes long and skews every percentile computed
against it.
Purity
No
Repo
access and no ambient state, matching the rest of
GMGradeV2
. The
caller loads one team's rows for one season —
NBA.Stats.list_player_stats_for_season_full/1
grouped by
:team_id
is the intended source — and owns the season filter;
this module never checks which season it was handed.
SHA-256 7d6fc1f758c6e8b03fc11d1112f9a763f12385872adf403f6a36db916c2dddf3
defmodule TradeMachine.GMGradeV2.BenchmarkRotation do
@moduledoc """
Deterministic 240-minute rotation for a real NBA team's completed season.
A user's scenario rotation is an explicit input — they choose the starters and
distribute the minutes. The 30 benchmark teams have no such author, but AVC's
Competitive Strength scores are *percentiles against those 30 teams*, so the
benchmark rotations have to be manufactured. This module manufactures them,
and AVC's interpretation note 3 sets the bar:
> Every real NBA team needs a reproducible starter designation and 240-minute
> allocation calculated under the same role and fatigue rules as a user
> scenario.
"Same rules as a user scenario" is taken literally here (DECISIONS D-39): the
240-minute total, the five-starter count and the 48-minute-per-player ceiling
are **read from `TradeMachine.GMGradeV2.RotationValidation`**, never restated
as local constants, and every rotation this module produces is run back
through `RotationValidation.validate/2` before it is returned. A benchmark
rotation that a user would not be allowed to submit is not returned at all —
it comes back as `{:error, …}`. See "Why the output is re-validated" below.
## The algorithm (version: `ModelVersion.versions()[:benchmarks]`)
1. **Eligibility.** A row needs a `player_id` and positive Season Minutes.
Every rejected row is recorded in `:excluded` with a reason — see "Nothing
is dropped silently".
2. **Rank** by Season Minutes descending, ties broken by `player_id`
ascending. This single ordering is the module's canonical order and is
reused for the pool cutoff, the starter tie-break and the rounding
remainder, so there is one thing to reason about rather than three.
3. **Pool.** Walk the ranking accumulating minutes-per-game until 240 is
covered, including the player who crosses it — a game *is* 240 player
minutes, so this selects the players a real rotation actually consumes
(9–11 per team in 2025-26). Always at least five players, so the
five-starter rule is satisfiable.
4. **Allocate** each pool member minutes in proportion to their minutes per
game, scaled so the total is exactly 240, with the 48-minute ceiling
applied by water-filling: cap whoever exceeds it, redistribute their excess
across the rest, repeat until nobody does.
5. **Round** to whole minutes and correct the residue (see "Rounding").
6. **Starters** — the top five in the pool by games started, ties broken by
Season Minutes then `player_id`, with the rule that actually ran reported
in `:starter_basis` (see "Which starter rule ran").
## Season Minutes is *not* the `total_minutes` column (DECISIONS D-38)
The ranking quantity is `games_played × minutes_per_game` via
`PlayerImpact.season_minutes/1` — AVC's "Season Minutes" (D-11), the same
quantity that drives the 1,200-minute Reliability Factor and the 800-minute
Experience Factor. `player_stats` *also* carries a literal `total_minutes`
column, and the two disagree: `minutes_per_game` is stored rounded to one
decimal, so the product accumulates error across a season (221 of 422 rows in
2025-26 differ by more than a minute, worst case 4.3). Ranking by the stored
column would be marginally more accurate in isolation and is the wrong choice
anyway — a player must not be able to rank above a teammate here while
ranking below him in the reliability adjustment applied to the very same
rotation. Measured, not assumed: across the 30 teams the two keys select an
identical pool, and differ in ordering on two teams.
## Rounding
Proportional targets are rounded to the nearest whole minute (whole minutes
because `RotationValidation` rejects fractional assignments), which leaves a
residue of a few minutes either way. The residue is handed out ±1 at a time
down the canonical order — highest Season Minutes first, wrapping if it runs
past the end — skipping anyone who would breach the 48-minute ceiling or go
negative.
This is the task's prescription (D-40) rather than the largest-remainder
method that would minimise deviation from exact proportionality. Priced
before adopting: summed across the 30 real 2025-26 teams, total departure
from the exact proportional split is 89.8 minutes here against 77.8 under
largest-remainder, and no single player is off by more than 1.5 minutes. It
costs little because rounding to nearest is already unbiased — only the small
leftover is directional, not a whole minute per player as a floor-and-top-up
scheme would be. It is worth knowing that it *is* a choice: a top-of-rotation
tiebreak, not a neutral one.
## Which starter rule ran (DECISIONS D-47)
`player_stats.games_started` is NULL for every row in every database audited,
so the `games_started` criterion has never actually executed and "top five by
games started" silently degrades into "top five by Season Minutes". The
degraded rule still produces a perfectly legal 240-minute, five-starter
rotation, which is exactly why it went unnoticed — so the rule that ran is
reported in `:starter_basis` rather than left to be inferred, and consumers
freeze it into the benchmark document.
## Nothing is dropped silently
The phase constraint is that missing data is recorded rather than imputed. A
team whose roster rows are half empty must not quietly produce a rotation
built from the surviving half and look identical to a complete one, so
`:excluded` names every row that did not become an entry and why
(`:missing_player_id`, `:missing_minutes`, `:no_minutes`, `:outside_rotation`,
`:not_a_map`). `:outside_rotation` is the ordinary case — a deep bench player
who lost the cutoff — and is listed alongside the others so a caller
reconciling roster size against rotation size gets one complete account.
Structurally impossible values (negative games or minutes) raise, via
`PlayerImpact.season_minutes/1`. Duplicate `player_id`s are an error rather
than being merged or de-duplicated: two rows for one player in one season is a
caller bug, and silently picking one of them would produce a rotation nobody
asked for. The check runs over every row handed in, **including the ones
eligibility would drop** — a duplicate whose second row has no minutes is
invisible to this module's own output and still poisons a caller that keys its
own maps off the same rows. See `check_duplicates/1`.
## Determinism (DECISIONS D-50)
The same rows in any order produce an identical result, and that has to hold
for the *whole* result, not just for `:entries`. Ineligible rows are therefore
sorted by `{player_id, reason}` before they are returned — reading them off in
arrival order would make `:excluded` a function of however Postgres happened
to return the team's rows, on a value that gets frozen into a benchmark
document. Everything else reduces over the canonically ordered pool.
## Why the output is re-validated
Running the finished rotation back through `RotationValidation.validate/2`
makes "if this returns `{:ok, _}`, the rotation is legal" true by construction
instead of true by argument. Every invariant — 240 exactly, five starters,
nothing over 48, nothing negative or fractional, no duplicates — is then
enforced by the same code path that polices user submissions, so an arithmetic
regression in the allocator surfaces as a loud `{:error, …}` rather than as a
benchmark that is quietly 239 minutes long and skews every percentile computed
against it.
## Purity
No `Repo` access and no ambient state, matching the rest of `GMGradeV2`. The
caller loads one team's rows for one season — `NBA.Stats.list_player_stats_for_season_full/1`
grouped by `:team_id` is the intended source — and owns the season filter;
this module never checks which season it was handed.
"""
alias TradeMachine.GMGradeV2.ModelVersion
alias TradeMachine.GMGradeV2.PlayerImpact
alias TradeMachine.GMGradeV2.RotationValidation
@typedoc "A rotation slot, in the shape `RotationValidation` normalizes to."
@type entry :: %{player_id: binary(), minutes: integer(), starter: boolean()}
@typedoc "A row that did not become an entry, and why."
@type exclusion :: %{player_id: binary() | nil, reason: atom()}
@typedoc """
Which rule actually designated the starting five, and over how much of the
pool. `:basis` is `:games_started` when at least one candidate carried a
measured value, and `:season_minutes_fallback` when none did — the latter is
the current state of every real team, since the column is unpopulated (D-47).
"""
@type starter_basis :: %{
basis: :games_started | :season_minutes_fallback,
covered: non_neg_integer(),
pool_size: non_neg_integer()
}
@typedoc "A built benchmark rotation plus the diagnostics behind it."
@type t :: %{
entries: [entry()],
excluded: [exclusion()],
pool_size: non_neg_integer(),
scale: float(),
starter_basis: starter_basis(),
version: String.t()
}
@type error :: %{code: atom(), message: String.t()}
@doc """
The benchmark algorithm version, frozen onto every benchmark this rotation
feeds. Bump `:benchmarks` in `ModelVersion.versions/0` when the rules above
change, or old benchmarks stop being reproducible.
"""
@spec version() :: String.t()
def version, do: Map.fetch!(ModelVersion.versions(), :benchmarks)
@doc """
Builds one team's benchmark rotation from its `player_stats` rows for a single
season.
Accepts `%TradeMachine.NBA.PlayerStats{}` structs or plain maps carrying the
same atom keys (`:player_id`, `:games_played`, `:games_started`,
`:minutes_per_game`) — the projection from
`NBA.Stats.list_player_stats_for_season_full/1` works as-is.
Returns `{:ok, t()}` with entries in canonical order (most Season Minutes
first), or `{:error, t:error/0}` when no legal rotation exists for the rows
given.
"""
@spec build(Enumerable.t()) :: {:ok, t()} | {:error, error()}
def build(rows) do
rows = Enum.to_list(rows)
{candidates, ineligible} = classify(rows)
ranked = Enum.sort_by(candidates, &rank_key/1)
with :ok <- check_duplicates(rows),
{:ok, pool} <- select_pool(ranked, ineligible),
{:ok, targets} <- allocate(pool) do
finish(pool, targets, ineligible ++ outside_rotation(ranked, pool))
end
end
# ── Eligibility ─────────────────────────────────────────────────────
defp classify(rows) do
{candidates, ineligible} =
Enum.reduce(rows, {[], []}, fn row, {ok, bad} ->
case candidate(row) do
{:ok, candidate} -> {[candidate | ok], bad}
{:excluded, exclusion} -> {ok, [exclusion | bad]}
end
end)
# Candidates are about to be sorted into canonical order; the exclusions are
# not, so they are sorted here instead of being handed back in whatever order
# the caller's query produced (D-50). `nil` player ids sort first — Erlang's
# term order puts atoms below binaries — which is stable, not meaningful.
{Enum.reverse(candidates), Enum.sort_by(ineligible, &{&1.player_id, &1.reason})}
end
defp candidate(row) when is_map(row) do
player_id = Map.get(row, :player_id)
# Raises on negative games or minutes, and returns nil — never 0 — when
# either input is missing (D-31).
minutes = PlayerImpact.season_minutes(row)
cond do
is_nil(player_id) -> {:excluded, exclusion(nil, :missing_player_id)}
is_nil(minutes) -> {:excluded, exclusion(player_id, :missing_minutes)}
minutes <= 0 -> {:excluded, exclusion(player_id, :no_minutes)}
true -> {:ok, build_candidate(row, player_id, minutes)}
end
end
defp candidate(_row), do: {:excluded, exclusion(nil, :not_a_map)}
defp build_candidate(row, player_id, minutes) do
%{
player_id: player_id,
# Positive Season Minutes guarantees positive minutes per game, so the
# allocator never divides by a zero weight.
minutes_per_game: Map.fetch!(row, :minutes_per_game) / 1,
season_minutes: minutes / 1,
games_started: Map.get(row, :games_started)
}
end
defp exclusion(player_id, reason), do: %{player_id: player_id, reason: reason}
# Over the *raw* rows, not over the eligible candidates. A second row for a
# player that happens to be ineligible — no minutes, say — never reaches
# `classify/1`'s candidate list, so checking the candidates would wave the pair
# through: the rotation builds cleanly from the eligible row and looks
# entirely normal. It is `BenchmarkBuilder` that then pays, because it keys its
# own per-player maps off the same raw rows (`Map.new(rows, …)`) and Postgres
# guarantees no row order — so which of the two rows supplies the player's
# frozen impact becomes a coin flip, and a benchmark that must be identical
# under any row order (D-50) is not. Measured on a five-player fixture: the
# team's `star_power` came out −1.5 or 4.825 depending purely on which
# duplicate arrived first.
defp check_duplicates(rows) do
duplicates =
rows
|> Enum.filter(&is_map/1)
|> Enum.map(&Map.get(&1, :player_id))
|> Enum.reject(&is_nil/1)
|> Enum.frequencies()
|> Enum.filter(fn {_player_id, count} -> count > 1 end)
|> Enum.map(fn {player_id, _count} -> player_id end)
|> Enum.sort()
if duplicates == [] do
:ok
else
{:error,
error(
:duplicate_player,
"Each player may appear once per season; got repeated rows for: " <>
Enum.join(duplicates, ", ")
)}
end
end
# ── Ranking and pool selection ──────────────────────────────────────
# The canonical order: most Season Minutes first, `player_id` breaking ties so
# two players with identical minutes never swap between runs.
defp rank_key(candidate), do: {-candidate.season_minutes, candidate.player_id}
defp select_pool(ranked, ineligible) do
starters = RotationValidation.starter_count()
pool =
case take_until_covered(ranked) do
covered when length(covered) < starters -> Enum.take(ranked, starters)
covered -> covered
end
cond do
pool == [] ->
{:error,
error(
:no_eligible_players,
"No player has a positive minutes total this season (#{summarize(ineligible)})."
)}
length(pool) < starters ->
{:error,
error(
:insufficient_players,
"A rotation needs at least #{starters} players with recorded minutes; got " <>
"#{length(pool)} (#{summarize(ineligible)})."
)}
true ->
{:ok, pool}
end
end
# A build that fails has no `:excluded` list to return, so the reason rows
# were dropped goes into the message instead — otherwise "no eligible
# players" gives an operator nothing to act on.
defp summarize([]), do: "no rows excluded"
defp summarize(ineligible) do
counts =
ineligible
|> Enum.frequencies_by(& &1.reason)
|> Enum.sort()
|> Enum.map_join(", ", fn {reason, count} -> "#{reason}: #{count}" end)
"excluded — " <> counts
end
# Take players down the ranking until their minutes per game cover a full
# game, including whoever crosses the line.
defp take_until_covered(ranked) do
budget = RotationValidation.total_minutes()
{taken, _covered} =
Enum.reduce_while(ranked, {[], 0.0}, fn candidate, {taken, covered} ->
if covered >= budget do
{:halt, {taken, covered}}
else
{:cont, {[candidate | taken], covered + candidate.minutes_per_game}}
end
end)
Enum.reverse(taken)
end
defp outside_rotation(ranked, pool) do
selected = MapSet.new(pool, & &1.player_id)
for candidate <- ranked, not MapSet.member?(selected, candidate.player_id) do
exclusion(candidate.player_id, :outside_rotation)
end
end
# ── Minute allocation ───────────────────────────────────────────────
defp allocate(pool) do
cap = RotationValidation.max_minutes()
budget = RotationValidation.total_minutes()
# Stated rather than assumed: five starters at 48 happens to be exactly 240
# today, so the starter-count floor already guarantees this. Retuning either
# constant would break that coincidence silently.
if length(pool) * cap < budget do
{:error,
error(
:rotation_infeasible,
"#{length(pool)} players capped at #{cap} minutes cannot cover #{budget}."
)}
else
{:ok, water_fill(pool, budget, cap, %{})}
end
end
# Proportional shares under a ceiling: scale the uncapped players to fill the
# remaining budget, and if that pushes anyone past the cap, pin them there and
# re-scale whoever is left. Terminates because each pass either finishes or
# removes at least one player from the uncapped set, and `uncapped` can never
# empty while the feasibility check above holds (`remaining <= n × cap` is
# preserved by each pass, and the whole set cannot exceed the cap at once).
defp water_fill([], _remaining, _cap, allocated), do: allocated
defp water_fill(uncapped, remaining, cap, allocated) do
weight = Enum.reduce(uncapped, 0.0, &(&1.minutes_per_game + &2))
scale = remaining / weight
case Enum.split_with(uncapped, &(&1.minutes_per_game * scale > cap)) do
{[], _} ->
Enum.reduce(uncapped, allocated, fn candidate, acc ->
Map.put(acc, candidate.player_id, candidate.minutes_per_game * scale)
end)
{over, rest} ->
allocated = Enum.reduce(over, allocated, &Map.put(&2, &1.player_id, cap / 1))
water_fill(rest, remaining - length(over) * cap, cap, allocated)
end
end
# ── Rounding to whole minutes ───────────────────────────────────────
defp round_to_budget(pool, targets) do
cap = RotationValidation.max_minutes()
minutes =
Map.new(pool, fn candidate ->
rounded = targets |> Map.fetch!(candidate.player_id) |> round() |> min(cap) |> max(0)
{candidate.player_id, rounded}
end)
# Summed over `pool`, not over the map: these are integers so the total is
# order-independent either way, but reducing over a map's iteration order is
# the habit D-50 exists to break.
assigned = Enum.reduce(pool, 0, &(Map.fetch!(minutes, &1.player_id) + &2))
distribute(minutes, pool, RotationValidation.total_minutes() - assigned, cap)
end
# Hand the residue out ±1 at a time down the canonical order, wrapping if it
# runs past the end, skipping anyone at a boundary. `pool` is already in
# canonical order, so "highest-minute players first" is just its own order.
defp distribute(minutes, _pool, 0, _cap), do: minutes
defp distribute(minutes, pool, residue, cap) do
step = if residue > 0, do: 1, else: -1
eligible = Enum.filter(pool, &adjustable?(Map.fetch!(minutes, &1.player_id), step, cap))
case Enum.take(eligible, abs(residue)) do
# Unreachable while the rotation is feasible. Returning rather than
# looping leaves the shortfall for `RotationValidation` to reject, which
# is the point of re-validating: a bug here cannot escape as a silently
# short rotation.
[] ->
minutes
adjusted ->
minutes =
Enum.reduce(adjusted, minutes, fn candidate, acc ->
Map.update!(acc, candidate.player_id, &(&1 + step))
end)
distribute(minutes, pool, residue - step * length(adjusted), cap)
end
end
# D-40 skips "anyone at the 48-minute ceiling **or at zero**", and that reads on
# the handout, not on the direction. A player whose proportional target rounded
# to zero did not make the rotation on the merits; handing him the residue's
# spare minute promotes a non-participant, and because D-46 populates Star Power
# from entries with `minutes > 0` it also silently enlarges that population in a
# frozen benchmark. The `-1` clause is unchanged: it already refuses to go
# negative, which is the same boundary read from the other side.
defp adjustable?(minutes, 1, cap), do: minutes > 0 and minutes < cap
defp adjustable?(minutes, -1, _cap), do: minutes > 0
# ── Starters ────────────────────────────────────────────────────────
defp designate_starters(pool) do
pool
|> Enum.sort_by(&starter_key/1)
|> Enum.take(RotationValidation.starter_count())
|> MapSet.new(& &1.player_id)
end
# A missing `games_started` is unknown, not zero, so it sorts *below* a
# measured 0 rather than tying with it (D-40). The leading flag carries that
# instead of a sentinel number, so nothing arithmetic depends on the encoding.
defp starter_key(%{games_started: started} = candidate) when is_number(started) do
{0, -started, -candidate.season_minutes, candidate.player_id}
end
defp starter_key(candidate) do
{1, 0, -candidate.season_minutes, candidate.player_id}
end
# ── Assembly ────────────────────────────────────────────────────────
defp finish(pool, targets, excluded) do
minutes = round_to_budget(pool, targets)
starters = designate_starters(pool)
entries =
Enum.map(pool, fn candidate ->
%{
player_id: candidate.player_id,
minutes: Map.fetch!(minutes, candidate.player_id),
starter: MapSet.member?(starters, candidate.player_id)
}
end)
case RotationValidation.validate(entries, Enum.map(pool, & &1.player_id)) do
{:ok, validated} ->
{:ok,
%{
entries: validated,
excluded: excluded,
pool_size: length(pool),
scale: scale(pool),
starter_basis: starter_basis(pool),
version: version()
}}
{:error, errors} ->
{:error,
error(
:invalid_rotation,
"Built a rotation the scenario validator rejects: " <>
Enum.map_join(errors, "; ", & &1.message)
)}
end
end
# Which rule actually decided the starting five, as opposed to which rule is
# written above. `starter_key/1`'s nil clause is a deliberate ordering choice
# (D-40: unknown sorts below a measured 0), but it is silent — and when *no*
# candidate carries a `games_started` value, every key collapses to the
# `{1, 0, -season_minutes, player_id}` form and "top five by games started"
# degrades into "top five by Season Minutes" with nothing to show for it.
#
# That is not hypothetical (D-47): `games_started` is NULL for 100% of
# `player_stats` rows in dev, in the worktree DBs, and in the prod snapshot,
# so the criterion has never once executed. Reporting the basis is this
# module's application of the phase-wide rule that missing data must never
# silently become a number — here the absence degrades an *ordering* rather
# than a value, which is exactly why it went unnoticed. Consumers freeze this
# into the benchmark document so a reader years from now can tell which rule
# produced the five.
defp starter_basis(pool) do
covered = Enum.count(pool, &is_number(&1.games_started))
%{
basis: if(covered == 0, do: :season_minutes_fallback, else: :games_started),
covered: covered,
pool_size: length(pool)
}
end
# Diagnostics only: the *nominal* proportional scale, before the 48-minute
# ceiling is applied — when the ceiling binds, no player is actually scaled by
# this. Above 1.0 means the pool played less than a full game's worth of
# minutes and was stretched to fill 240; real teams sit at 0.89–1.00.
defp scale(pool) do
RotationValidation.total_minutes() / Enum.reduce(pool, 0.0, &(&1.minutes_per_game + &2))
end
defp error(code, message), do: %{code: code, message: message}
end