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