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

Documentation The six pillars

Competitive strength

Star power, starting five, rotation quality, and two-way balance. A missing or invalid 240-minute rotation produces a provisional placeholder, not an evaluated zero.

Aggregation

The table below is extracted from the pillar's declared metric weights at build time. Open the metric implementation notes and source to inspect its raw inputs, normalization, branches, and fallback handling.

pillar_score = Σ(metric_score × metric_weight)

Metric weights

Metric Declared weight
Star power 30.0%
Starting five 25.0%
Rotation quality 30.0%
Two way balance 15.0%

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.

competitive_strength.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength.ex

Aggregates AVC's four Competitive Strength metrics.

A valid frozen 240-minute rotation is a prerequisite for every component metric. When the rotation is missing or invalid, the pillar deliberately computes none of them and returns a provisional result instead. The authoritative submission gate remains responsible for blocking submission.

Download source ↓

SHA-256 1e6821548581e555984a90bea7aa48689c4840411e9408022bdf2e32555c5f55

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength do
  @moduledoc """
  Aggregates AVC's four Competitive Strength metrics.

  A valid frozen 240-minute rotation is a prerequisite for every component
  metric. When the rotation is missing or invalid, the pillar deliberately
  computes none of them and returns a provisional result instead. The
  authoritative submission gate remains responsible for blocking submission.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult
  alias TradeMachine.GMGradeV2.RotationValidation
  alias TradeMachine.GMGradeV2.ScenarioValidation
  alias TradeMachine.NBA.ScenarioRotation

  @metric_weights %{
    star_power: 0.30,
    starting_five: 0.25,
    rotation_quality: 0.30,
    two_way_balance: 0.15
  }

  @metric_modules [StarPower, StartingFive, RotationQuality, TwoWayBalance]

  if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do
    raise "Competitive Strength metric weights must sum to 1.0"
  end

  @doc """
  Returns the full-precision Competitive Strength pillar result.

  All four metrics consume the same scenario state and frozen snapshot. A
  missing or currently invalid rotation produces a provisional zero placeholder
  with no metric results; zero is not an evaluated pillar score in that state.
  """
  @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
  def score(
        scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_map(scenario_state) do
    case validate_rotation(scenario_state) do
      {:ok, _entries} ->
        metric_results = Enum.map(@metric_modules, & &1.score(scenario_state, snapshot))
        {strongest, weakest} = extrema(metric_results)

        %PillarResult{
          key: :competitive_strength,
          version: benchmark.version,
          score: weighted_score(metric_results),
          metric_results: metric_results,
          metric_weights: @metric_weights,
          explanation: explanation(strongest, weakest),
          strongest: strongest.key,
          weakest: weakest.key
        }

      {:error, reason} ->
        %PillarResult{
          key: :competitive_strength,
          version: benchmark.version,
          score: 0.0,
          metric_results: [],
          metric_weights: @metric_weights,
          provisional?: true,
          explanation:
            "Competitive Strength is provisional and was not calculated because #{reason}"
        }
    end
  end

  defp validate_rotation(scenario_state) do
    rotation = Map.get(scenario_state, :rotation)

    if is_nil(rotation) do
      {:error, "the scenario has no 240-minute rotation."}
    else
      entries = ScenarioRotation.entries(rotation)

      roster_ids =
        scenario_state
        |> Map.get(:roster, [])
        |> ScenarioValidation.rotation_eligible_player_ids()

      injured_ids =
        scenario_state
        |> Map.get(:roster, [])
        |> ScenarioValidation.out_for_season_player_ids()

      case RotationValidation.validate(entries, roster_ids, injured_ids) do
        {:ok, normalized_entries} ->
          {:ok, normalized_entries}

        {:error, errors} ->
          details = Enum.map_join(errors, " ", & &1.message)
          {:error, "the scenario rotation is invalid. #{details}"}
      end
    end
  end

  defp weighted_score(metric_results) do
    Enum.reduce(metric_results, 0.0, fn result, total ->
      total + result.score * Map.fetch!(@metric_weights, result.key)
    end)
  end

  # Enum.max_by/min_by retain the first item on a tie, giving the published
  # metric order a stable tie-break without inventing a second priority table.
  defp extrema(metric_results) do
    {
      Enum.max_by(metric_results, & &1.score),
      Enum.min_by(metric_results, & &1.score)
    }
  end

  defp explanation(
         %MetricResult{key: strongest_key, score: strongest_score},
         %MetricResult{key: weakest_key, score: weakest_score}
       ) do
    "Competitive Strength combines four frozen-rotation metrics; " <>
      "#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest_key)} is weakest at #{weakest_score}."
  end

  defp label(:star_power), do: "Star Power"
  defp label(:starting_five), do: "Starting Five Quality"
  defp label(:rotation_quality), do: "Rotation Quality"
  defp label(:two_way_balance), do: "Two-Way Balance"
end
metric_inputs.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength/metric_inputs.ex

See the implementation below.

Download source ↓

SHA-256 368ca5b22e8d7c7d230f03e7411e69e7ae6baf93cafc35db396ca1e28a64ca56

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs do
  @moduledoc false

  alias TradeMachine.GMGradeV2.Availability
  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.NBA.ScenarioRotation

  def rotation_entries(entries) when is_list(entries), do: entries
  def rotation_entries(%{entries: entries}) when is_list(entries), do: entries
  def rotation_entries(rotation), do: ScenarioRotation.entries(rotation)

  def player_id(entry), do: fetch_any!(entry, :player_id)
  def minutes(entry), do: fetch_number!(entry, :minutes)
  def starter?(entry), do: fetch_any!(entry, :starter) == true

  @doc """
  The availability multiplier for one entry, plus the diagnostic keys to merge
  into that player's contribution.

  The annotation is `%{}` unless the user actually set a usable expected-games
  value, so a contribution map for an un-annotated rotation is byte-identical to
  the one this metric produced before availability existed — the stored grade
  document has to be, not just the score. When the annotation *is* present it
  records both halves of the arithmetic, because a discounted contribution the
  user later disputes has to be explainable from the grade alone.
  """
  def availability(entry, evidence) do
    case Availability.expected_games(entry) do
      nil ->
        {1.0, %{}}

      games ->
        factor = Availability.factor(entry, evidence)
        {factor, %{expected_games: games, availability: factor}}
    end
  end

  def impact_number!(impacts, player_id, field) do
    impacts
    |> player_impact!(player_id)
    |> fetch_number!(field)
  end

  def player_impact!(impacts, player_id) do
    case Map.fetch(impacts, player_id) do
      {:ok, impact} ->
        impact

      :error ->
        Map.fetch!(impacts, to_string(player_id))
    end
  end

  def roster_names(roster) when is_list(roster) do
    Map.new(roster, fn player ->
      {fetch_any!(player, :id), fetch_any!(player, :name)}
    end)
  end

  def roster_names(_), do: %{}

  def player_name(names, player_id), do: Map.get(names, player_id, to_string(player_id))

  def benchmark_values(%Benchmark{} = benchmark, metric) when is_atom(metric) do
    benchmark
    |> Benchmark.section(:competitive_strength)
    |> Map.fetch!("metrics")
    |> Map.fetch!(Atom.to_string(metric))
  end

  def impact_fallbacks(contributions, impacts, impact_label \\ "Adjusted EPM", opts \\ []) do
    include_imputed_splits? = Keyword.get(opts, :include_imputed_splits?, false)

    contributions
    |> Enum.reject(&Map.get(&1, :replacement?, false))
    |> Enum.flat_map(fn contribution ->
      impact = player_impact!(impacts, contribution.player_id)

      source_fallback(contribution, impact, impact_label) ++
        imputed_split_fallbacks(contribution, impact, include_imputed_splits?)
    end)
  end

  def fetch_any!(map, key) do
    case fetch_any(map, key) do
      nil -> raise KeyError, key: key, term: map
      value -> value
    end
  end

  def fetch_any(map, key) when is_map(map) do
    Map.get(map, key, Map.get(map, Atom.to_string(key)))
  end

  defp source_fallback(contribution, impact, impact_label) do
    case fetch_any(impact, :source) do
      source when source in [nil, :epm, "epm"] ->
        []

      source ->
        [
          %{
            input: contribution.player_id,
            method: source,
            note: "#{contribution.name}'s #{impact_label} uses the frozen #{source} fallback."
          }
        ]
    end
  end

  defp imputed_split_fallbacks(_contribution, _impact, false), do: []

  defp imputed_split_fallbacks(contribution, impact, true) do
    impact
    |> fetch_any(:imputed_splits)
    |> List.wrap()
    |> Enum.map(fn split ->
      split = normalize_split!(split)

      %{
        input: contribution.player_id,
        method: :split_imputation,
        note:
          "#{contribution.name}'s adjusted #{split_label(split)} was derived rather than measured."
      }
    end)
  end

  defp normalize_split!(:offensive), do: :offensive
  defp normalize_split!("offensive"), do: :offensive
  defp normalize_split!(:defensive), do: :defensive
  defp normalize_split!("defensive"), do: :defensive

  defp normalize_split!(split) do
    raise ArgumentError,
          "imputed_splits must contain offensive or defensive, got: #{inspect(split)}"
  end

  defp split_label(:offensive), do: "OEPM"
  defp split_label(:defensive), do: "DEPM"

  defp fetch_number!(map, key) do
    case fetch_any!(map, key) do
      value when is_number(value) -> value
      value -> raise ArgumentError, "#{key} must be numeric, got: #{inspect(value)}"
    end
  end
end
rotation_quality.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength/rotation_quality.ex

Scores AVC's Rotation Quality metric across the full 240-minute rotation.

Every player's Adjusted EPM is multiplied by the shared fatigue-adjusted minutes curve, then by their season-availability factor ( TradeMachine.GMGradeV2.Availability — 1.0 unless the user set expected games). The contributions are summed and divided by 240 before conversion against the frozen rotation_quality benchmark.

The denominator stays 240 when a player is discounted. That is the point: a rotation slot that is empty for half the season really does lower the team's season-long per-minute strength, and renormalizing to the available minutes would divide the discount straight back out.

Download source ↓

SHA-256 6d4478abe06a809e38405cfc764dee19af167b281f50640dddece4202b192567

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality do
  @moduledoc """
  Scores AVC's Rotation Quality metric across the full 240-minute rotation.

  Every player's Adjusted EPM is multiplied by the shared fatigue-adjusted
  minutes curve, then by their season-availability factor
  (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
  games). The contributions are summed and divided by 240 before conversion
  against the frozen `rotation_quality` benchmark.

  The denominator stays 240 when a player is discounted. That is the point: a
  rotation slot that is empty for half the season really does lower the team's
  season-long per-minute strength, and renormalizing to the available minutes
  would divide the discount straight back out.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Fatigue
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.RotationValidation

  @doc "Returns a full-precision Rotation Quality metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{rotation: rotation} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      ) do
    entries = MetricInputs.rotation_entries(rotation)
    impacts = ChallengeSnapshot.section(snapshot, :players)
    names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
    contributions = rotation_contributions(entries, impacts, names)
    raw_value = average(contributions)
    benchmark_values = MetricInputs.benchmark_values(benchmark, :rotation_quality)
    assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes))

    %MetricResult{
      key: :rotation_quality,
      version: benchmark.version,
      score: Percentile.score(raw_value, benchmark_values),
      inputs: %{
        raw: raw_value,
        assigned_minutes: assigned_minutes,
        effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)),
        denominator_minutes: RotationValidation.total_minutes(),
        benchmark: benchmark_values
      },
      supporting: %{contributions: contributions},
      weights: %{
        minutes_1_36: 1.0,
        minutes_37_40: 0.75,
        minutes_41_44: 0.5,
        minutes_45_48: 0.25
      },
      fallbacks: MetricInputs.impact_fallbacks(contributions, impacts),
      explanation:
        "Rotation Quality averages fatigue-adjusted impact across " <>
          "#{length(contributions)} rotation players and #{assigned_minutes} assigned minutes."
    }
  end

  @doc "Calculates Rotation Quality before percentile conversion."
  @spec raw(term(), map()) :: float()
  def raw(rotation, impacts) when is_map(impacts) do
    rotation
    |> MetricInputs.rotation_entries()
    |> rotation_contributions(impacts, %{})
    |> average()
  end

  defp rotation_contributions(entries, impacts, names) do
    Enum.map(entries, fn entry ->
      player_id = MetricInputs.player_id(entry)
      assigned = MetricInputs.minutes(entry)
      evidence = MetricInputs.player_impact!(impacts, player_id)
      {availability, annotation} = MetricInputs.availability(entry, evidence)
      adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
      effective = Fatigue.effective_minutes(assigned)

      Map.merge(
        %{
          player_id: player_id,
          name: MetricInputs.player_name(names, player_id),
          assigned_minutes: assigned,
          effective_minutes: effective,
          adjusted_epm: adjusted_epm,
          contribution: Fatigue.weighted_contribution(adjusted_epm, assigned) * availability,
          replacement?: false
        },
        annotation
      )
    end)
  end

  defp average(contributions) do
    total = Enum.reduce(contributions, 0.0, &(&2 + &1.contribution))
    total / RotationValidation.total_minutes()
  end
end
star_power.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength/star_power.ex

Scores AVC's Star Power metric from a frozen scenario rotation.

Every player assigned more than zero minutes receives a role factor of min(1, assigned_minutes / 30) and their season-availability factor ( TradeMachine.GMGradeV2.Availability — 1.0 unless the user set expected games). The three highest resulting Adjusted EPM contributions are weighted 55/30/15. When fewer than three players are eligible, each empty slot receives the provisional replacement value at a full role factor (D-20).

Ranking reads the availability-weighted contribution, so a star the user expects to miss most of the season can fall out of the top three in favour of a teammate who will actually play — which is the honest answer to "who carries this roster", and the one thing a per-game rotation could not say before.

score/2 reads both player impacts and the benchmark distribution from the challenge snapshot. raw/2 is the shared pre-percentile calculator used by the scenario scorer and, from Phase 08 T6, the benchmark builder.

Download source ↓

SHA-256 172fde8e53d28c9da488223690bc913de391b077af44192654d0495e8c1412ba

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower do
  @moduledoc """
  Scores AVC's Star Power metric from a frozen scenario rotation.

  Every player assigned more than zero minutes receives a role factor of
  `min(1, assigned_minutes / 30)` and their season-availability factor
  (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
  games). The three highest resulting Adjusted EPM contributions are weighted
  55/30/15. When fewer than three players are eligible, each empty slot receives
  the provisional replacement value at a full role factor (D-20).

  Ranking reads the availability-weighted contribution, so a star the user
  expects to miss most of the season can fall out of the top three in favour of
  a teammate who will actually play — which is the honest answer to "who carries
  this roster", and the one thing a per-game rotation could not say before.

  `score/2` reads both player impacts and the benchmark distribution from the
  challenge snapshot. `raw/2` is the shared pre-percentile calculator used by
  the scenario scorer and, from Phase 08 T6, the benchmark builder.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @role_minutes 30
  @weights [0.55, 0.30, 0.15]

  @type entry :: %{required(:player_id) => term(), required(:minutes) => number()}
  @type impact :: %{required(:adjusted_epm) => number()}

  @doc """
  Returns a full-precision Star Power metric result.

  `scenario_state` must carry the persisted rotation and its roster. Player
  impact values come only from `snapshot.data["players"]`; the score is
  percentiled only against the snapshot's frozen `star_power` array.
  """
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{rotation: rotation} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      ) do
    entries = MetricInputs.rotation_entries(rotation)
    impacts = ChallengeSnapshot.section(snapshot, :players)
    names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))

    replacement =
      scenario_state
      |> Map.get(:constants_override, %{})
      |> ModelVersion.provisional_epm()
      |> Map.fetch!(:overall)

    contributions = ranked_contributions(entries, impacts, names, replacement)
    raw_value = weighted_raw(contributions)
    benchmark_values = MetricInputs.benchmark_values(benchmark, :star_power)

    %MetricResult{
      key: :star_power,
      version: benchmark.version,
      score: Percentile.score(raw_value, benchmark_values),
      inputs: %{
        raw: raw_value,
        eligible_player_count: Enum.count(entries, &(entry_minutes(&1) > 0)),
        role_factor_minutes: @role_minutes,
        replacement_value: replacement,
        benchmark: benchmark_values
      },
      supporting: %{contributions: contributions},
      weights: %{first: 0.55, second: 0.30, third: 0.15},
      fallbacks: fallbacks(contributions, impacts),
      explanation: explanation(contributions)
    }
  end

  @doc """
  Calculates Star Power before percentile conversion.

  Accepts either a rotation value understood by `ScenarioRotation.entries/1`,
  a benchmark rotation carrying an `:entries` list, or the entry list itself.
  `impacts` is keyed by player id and may contain canonical string-keyed frozen
  values or in-memory atom-keyed `PlayerImpact.adjust/1` outputs.
  """
  @spec raw(term(), %{required(term()) => impact() | map()}) :: float()
  def raw(rotation, impacts) when is_map(impacts) do
    replacement = ModelVersion.provisional_epm().overall

    rotation
    |> MetricInputs.rotation_entries()
    |> ranked_contributions(impacts, %{}, replacement)
    |> weighted_raw()
  end

  defp ranked_contributions(entries, impacts, names, replacement) do
    players =
      entries
      |> Enum.filter(&(entry_minutes(&1) > 0))
      |> Enum.map(&player_contribution(&1, impacts, names))
      |> Enum.sort_by(&{-&1.contribution, to_string(&1.player_id)})
      |> Enum.take(length(@weights))

    players ++ replacement_contributions(length(@weights) - length(players), replacement)
  end

  defp player_contribution(entry, impacts, names) do
    player_id = MetricInputs.player_id(entry)
    assigned = MetricInputs.minutes(entry)
    evidence = MetricInputs.player_impact!(impacts, player_id)
    {availability, annotation} = MetricInputs.availability(entry, evidence)
    adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
    role_factor = min(1.0, assigned / @role_minutes)

    Map.merge(
      %{
        player_id: player_id,
        name: MetricInputs.player_name(names, player_id),
        assigned_minutes: assigned,
        adjusted_epm: adjusted_epm,
        role_factor: role_factor,
        contribution: adjusted_epm * role_factor * availability,
        replacement?: false
      },
      annotation
    )
  end

  defp replacement_contributions(count, replacement) do
    for slot <- 1..count//1 do
      %{
        player_id: nil,
        name: "Replacement-level slot #{slot}",
        assigned_minutes: 30,
        adjusted_epm: replacement,
        role_factor: 1.0,
        contribution: replacement * 1.0,
        replacement?: true
      }
    end
  end

  defp weighted_raw(contributions) do
    contributions
    |> Enum.zip(@weights)
    |> Enum.reduce(0.0, fn {contribution, weight}, total ->
      total + contribution.contribution * weight
    end)
  end

  defp fallbacks(contributions, impacts) do
    impact_fallbacks = MetricInputs.impact_fallbacks(contributions, impacts)

    replacement_fallbacks =
      for contribution <- contributions, contribution.replacement? do
        %{
          input: :star_power_slot,
          method: :provisional_replacement,
          note:
            "#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor."
        }
      end

    impact_fallbacks ++ replacement_fallbacks
  end

  defp explanation(contributions) do
    names = Enum.map_join(contributions, ", ", & &1.name)
    "The top three Star Power contributions come from #{names}."
  end

  defp entry_minutes(entry), do: MetricInputs.minutes(entry)
end
starting_five.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength/starting_five.ex

Scores AVC's Starting Five Quality metric from the designated starters.

Each starter contributes Adjusted EPM multiplied by min(1, assigned_minutes / 28) and by their season-availability factor ( TradeMachine.GMGradeV2.Availability — 1.0 unless the user set expected games). The five contributions are summed equally before conversion against the frozen starting_five benchmark.

A starter the user expects to miss most of the season stays the designated starter and keeps their role factor: the discount is on the season value of the five names chosen, not a re-designation of who starts. Nobody is promoted into their place, because the rotation the user authored is the rotation being graded.

Download source ↓

SHA-256 fe83867bd22ab02c1fc2916ad3979b95502db0e39d86611dc047441dbce57b19

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive do
  @moduledoc """
  Scores AVC's Starting Five Quality metric from the designated starters.

  Each starter contributes Adjusted EPM multiplied by
  `min(1, assigned_minutes / 28)` and by their season-availability factor
  (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
  games). The five contributions are summed equally before conversion against
  the frozen `starting_five` benchmark.

  A starter the user expects to miss most of the season stays the designated
  starter and keeps their role factor: the discount is on the season value of
  the five names chosen, not a re-designation of who starts. Nobody is promoted
  into their place, because the rotation the user authored is the rotation being
  graded.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.RotationValidation

  @role_minutes 28

  @doc "Returns a full-precision Starting Five Quality metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{rotation: rotation} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      ) do
    entries = MetricInputs.rotation_entries(rotation)
    impacts = ChallengeSnapshot.section(snapshot, :players)
    names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))

    replacement =
      scenario_state
      |> Map.get(:constants_override, %{})
      |> ModelVersion.provisional_epm()
      |> Map.fetch!(:overall)

    contributions = starter_contributions(entries, impacts, names, replacement)
    raw_value = sum_contributions(contributions)
    benchmark_values = MetricInputs.benchmark_values(benchmark, :starting_five)

    %MetricResult{
      key: :starting_five,
      version: benchmark.version,
      score: Percentile.score(raw_value, benchmark_values),
      inputs: %{
        raw: raw_value,
        designated_starter_count: Enum.count(entries, &MetricInputs.starter?/1),
        role_factor_minutes: @role_minutes,
        replacement_value: replacement,
        benchmark: benchmark_values
      },
      supporting: %{contributions: contributions},
      weights: %{each_starter: 1.0},
      fallbacks:
        MetricInputs.impact_fallbacks(contributions, impacts) ++
          replacement_fallbacks(contributions),
      explanation: explanation(contributions)
    }
  end

  @doc """
  Calculates Starting Five Quality before percentile conversion.

  This is also the benchmark builder's shared formula. A designated
  zero-minute starter remains in the sum with a zero role factor.
  """
  @spec raw(term(), map()) :: float()
  def raw(rotation, impacts) when is_map(impacts) do
    replacement = ModelVersion.provisional_epm().overall

    rotation
    |> MetricInputs.rotation_entries()
    |> starter_contributions(impacts, %{}, replacement)
    |> sum_contributions()
  end

  defp starter_contributions(entries, impacts, names, replacement) do
    starters =
      entries
      |> Enum.filter(&MetricInputs.starter?/1)
      |> Enum.take(RotationValidation.starter_count())
      |> Enum.map(&player_contribution(&1, impacts, names))

    starters ++
      replacement_contributions(
        RotationValidation.starter_count() - length(starters),
        replacement
      )
  end

  defp player_contribution(entry, impacts, names) do
    player_id = MetricInputs.player_id(entry)
    assigned = MetricInputs.minutes(entry)
    evidence = MetricInputs.player_impact!(impacts, player_id)
    {availability, annotation} = MetricInputs.availability(entry, evidence)
    adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
    role_factor = min(1.0, assigned / @role_minutes)

    Map.merge(
      %{
        player_id: player_id,
        name: MetricInputs.player_name(names, player_id),
        assigned_minutes: assigned,
        adjusted_epm: adjusted_epm,
        role_factor: role_factor,
        contribution: adjusted_epm * role_factor * availability,
        replacement?: false
      },
      annotation
    )
  end

  defp replacement_contributions(count, replacement) do
    for slot <- 1..count//1 do
      %{
        player_id: nil,
        name: "Replacement-level starter #{slot}",
        assigned_minutes: @role_minutes,
        adjusted_epm: replacement,
        role_factor: 1.0,
        contribution: replacement,
        replacement?: true
      }
    end
  end

  defp sum_contributions(contributions) do
    Enum.reduce(contributions, 0.0, &(&2 + &1.contribution))
  end

  defp replacement_fallbacks(contributions) do
    for contribution <- contributions, contribution.replacement? do
      %{
        input: :starting_five_slot,
        method: :provisional_replacement,
        note:
          "#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor."
      }
    end
  end

  defp explanation(contributions) do
    names = Enum.map_join(contributions, ", ", & &1.name)
    "Starting Five Quality combines equal contributions from #{names}."
  end
end
two_way_balance.exElixir

lib/trade_machine/gm_grade_v2/pillars/competitive_strength/two_way_balance.ex

Scores AVC's Two-Way Balance metric across the full 240-minute rotation.

Rotation OEPM and DEPM use the same fatigue-adjusted minutes and the same season-availability factor as Rotation Quality ( TradeMachine.GMGradeV2.Availability ), applied to both sides of a player's split so availability cannot tilt the balance it is discounting. Each raw side is converted independently against its frozen benchmark array before the weaker side receives 70% of the final score and the stronger side receives 30%.

Offensive and Defensive Strength remain supporting scores. There is no combined Two-Way Balance raw benchmark because the combination happens after percentile conversion (D-43).

Download source ↓

SHA-256 772da3cc7de6fd598db86d7ef06ff591e8846633da70e10224b8f47655fa9be6

defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance do
  @moduledoc """
  Scores AVC's Two-Way Balance metric across the full 240-minute rotation.

  Rotation OEPM and DEPM use the same fatigue-adjusted minutes and the same
  season-availability factor as Rotation Quality
  (`TradeMachine.GMGradeV2.Availability`), applied to both sides of a player's
  split so availability cannot tilt the balance it is discounting. Each raw side
  is converted independently against its frozen benchmark array before the
  weaker side receives 70% of the final score and the stronger side receives
  30%.

  Offensive and Defensive Strength remain supporting scores. There is no
  combined Two-Way Balance raw benchmark because the combination happens after
  percentile conversion (D-43).
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Fatigue
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.RotationValidation

  @lower_weight 0.70
  @higher_weight 0.30

  @doc "Returns a full-precision Two-Way Balance metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{rotation: rotation} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      ) do
    entries = MetricInputs.rotation_entries(rotation)
    impacts = ChallengeSnapshot.section(snapshot, :players)
    names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
    contributions = rotation_contributions(entries, impacts, names)
    raw_values = averages(contributions)
    offense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_oepm)
    defense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_depm)
    offensive_strength = Percentile.score(raw_values.rotation_oepm, offense_benchmark)
    defensive_strength = Percentile.score(raw_values.rotation_depm, defense_benchmark)
    {lower, higher} = min_max(offensive_strength, defensive_strength)
    assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes))

    %MetricResult{
      key: :two_way_balance,
      version: benchmark.version,
      score: lower * @lower_weight + higher * @higher_weight,
      inputs: %{
        rotation_oepm: raw_values.rotation_oepm,
        rotation_depm: raw_values.rotation_depm,
        assigned_minutes: assigned_minutes,
        effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)),
        denominator_minutes: RotationValidation.total_minutes(),
        offense_benchmark: offense_benchmark,
        defense_benchmark: defense_benchmark
      },
      supporting: %{
        offensive_strength: offensive_strength,
        defensive_strength: defensive_strength,
        contributions: contributions
      },
      weights: %{lower_side: @lower_weight, higher_side: @higher_weight},
      fallbacks:
        MetricInputs.impact_fallbacks(
          contributions,
          impacts,
          "Adjusted OEPM and DEPM",
          include_imputed_splits?: true
        ),
      explanation: explanation(offensive_strength, defensive_strength, assigned_minutes)
    }
  end

  @doc """
  Calculates fatigue-adjusted Rotation OEPM and DEPM before percentile conversion.

  The two raw values are kept separate because Two-Way Balance combines their
  percentile scores, not their EPM-scale values.
  """
  @spec raw(term(), map()) :: %{rotation_oepm: float(), rotation_depm: float()}
  def raw(rotation, impacts) when is_map(impacts) do
    rotation
    |> MetricInputs.rotation_entries()
    |> rotation_contributions(impacts, %{})
    |> averages()
  end

  defp rotation_contributions(entries, impacts, names) do
    Enum.map(entries, fn entry ->
      player_id = MetricInputs.player_id(entry)
      assigned = MetricInputs.minutes(entry)
      evidence = MetricInputs.player_impact!(impacts, player_id)
      {availability, annotation} = MetricInputs.availability(entry, evidence)
      adjusted_oepm = MetricInputs.impact_number!(impacts, player_id, :adjusted_oepm)
      adjusted_depm = MetricInputs.impact_number!(impacts, player_id, :adjusted_depm)
      effective = Fatigue.effective_minutes(assigned)

      Map.merge(
        %{
          player_id: player_id,
          name: MetricInputs.player_name(names, player_id),
          assigned_minutes: assigned,
          effective_minutes: effective,
          adjusted_oepm: adjusted_oepm,
          adjusted_depm: adjusted_depm,
          offensive_contribution: adjusted_oepm * effective * availability,
          defensive_contribution: adjusted_depm * effective * availability,
          replacement?: false
        },
        annotation
      )
    end)
  end

  defp averages(contributions) do
    {offense, defense} =
      Enum.reduce(contributions, {0.0, 0.0}, fn contribution, {offense, defense} ->
        {
          offense + contribution.offensive_contribution,
          defense + contribution.defensive_contribution
        }
      end)

    denominator = RotationValidation.total_minutes()
    %{rotation_oepm: offense / denominator, rotation_depm: defense / denominator}
  end

  defp min_max(first, second) when first <= second, do: {first, second}
  defp min_max(first, second), do: {second, first}

  defp explanation(offensive_strength, defensive_strength, assigned_minutes) do
    weaker_side =
      if offensive_strength <= defensive_strength, do: "offense", else: "defense"

    "Two-Way Balance weights the weaker #{weaker_side} side at 70% across " <>
      "#{assigned_minutes} assigned rotation minutes."
  end
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