Armchair GM
2026-27 The league's active season — trades, cap positions, and scenarios are computed against it.

Documentation Shared models

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.

Download source ↓

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.

Download source ↓

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.

Download source ↓

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.

Download source ↓

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

Teams

More

Current challenges How challenges are graded Explore the six pillars of a good GM
Browse
Team Contracts Player Contracts FA Market
App
Install app

Open trades and cap sheets straight from your home screen in their own app window.

iPhone or iPad

In Safari, open the Share menu, choose Add to Home Screen, then tap Add. Keep Open as Web App enabled if shown.

Android or desktop

Use Install Armchair GM above when available, or open your browser menu and choose Install app or Add to Home screen.

An internet connection is required for live data, trades, and scenario changes.

What's new About
Account
Sign in Tip jar Support Armchair GM