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

Documentation The six pillars

Roster construction

Position coverage, skill coverage, functional depth, and roster completeness measure the composition of the roster.

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
Roster completeness 15.0%
Position coverage 25.0%
Skill coverage 35.0%
Functional depth 25.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.

roster_construction.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction.ex

Aggregates AVC's four Roster Construction metrics over the final roster.

Roster Completeness, Position Coverage, Skill Coverage, and Functional Depth retain full precision through the fixed 15/25/35/25 weighted sum.

Download source ↓

SHA-256 74845faa8ffdaa88a439de5d26cb01c01649cc9443b0845e9c686ffaf65bd3f9

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction do
  @moduledoc """
  Aggregates AVC's four Roster Construction metrics over the final roster.

  Roster Completeness, Position Coverage, Skill Coverage, and Functional Depth
  retain full precision through the fixed 15/25/35/25 weighted sum.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.FunctionalDepth
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.RosterCompleteness
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.SkillCoverage
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult

  @metric_weights %{
    roster_completeness: 0.15,
    position_coverage: 0.25,
    skill_coverage: 0.35,
    functional_depth: 0.25
  }

  @metric_modules [
    RosterCompleteness,
    PositionCoverage,
    SkillCoverage,
    FunctionalDepth
  ]

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

  @doc "Returns the full-precision Roster Construction pillar result."
  @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
  def score(
        %{roster: roster} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_list(roster) do
    scoring_state =
      scenario_state
      |> Map.put(:roster, Inputs.hydrate_roster(roster, snapshot))
      |> Map.put(
        :model_override,
        Map.merge(
          Inputs.model_override(snapshot),
          Map.get(scenario_state, :constants_override, %{})
        )
      )

    metric_results = Enum.map(@metric_modules, & &1.score(scoring_state, snapshot))
    {strongest, weakest} = extrema(metric_results)

    %PillarResult{
      key: :roster_construction,
      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
    }
  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, so published metric order
  # provides a stable tie-break without 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
    "Roster Construction combines four full-roster metrics; " <>
      "#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest_key)} is weakest at #{weakest_score}."
  end

  defp label(:roster_completeness), do: "Roster Completeness"
  defp label(:position_coverage), do: "Position Coverage"
  defp label(:skill_coverage), do: "Skill Coverage"
  defp label(:functional_depth), do: "Functional Depth"
end
functional_depth.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction/functional_depth.ex

Scores AVC's Functional Depth metric over the entire final roster.

Position Depth credits a player's listed position at their shared contribution factor and adjacent positions at 35% of that factor. Skill Redundancy counts contribution-factor units only for reliability-adjusted skill indexes of at least 60. Skill Concentration measures the best player's share of each skill's top-three adjusted indexes.

Per D-24, concentration is neutral (50) for a skill with fewer than three measured indexes or a zero top-three sum, and every such use is recorded as a fallback.

Download source ↓

SHA-256 6cb8ac1a33223b0d2261b7b7325fba027dd18da4670ceccb2f5f223fa674d7cd

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.FunctionalDepth do
  @moduledoc """
  Scores AVC's Functional Depth metric over the entire final roster.

  Position Depth credits a player's listed position at their shared
  contribution factor and adjacent positions at 35% of that factor. Skill
  Redundancy counts contribution-factor units only for reliability-adjusted
  skill indexes of at least 60. Skill Concentration measures the best player's
  share of each skill's top-three adjusted indexes.

  Per D-24, concentration is neutral (50) for a skill with fewer than three
  measured indexes or a zero top-three sum, and every such use is recorded as a
  fallback.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.SkillIndexes

  @adjacent %{
    "PG" => ["SG"],
    "SG" => ["PG", "SF"],
    "SF" => ["SG", "PF"],
    "PF" => ["SF", "C"],
    "C" => ["PF"]
  }
  @adjacent_credit 0.35
  @qualified_index 60.0
  @average_weight 0.70
  @weakest_weight 0.30
  @weights %{position_depth: 0.35, skill_redundancy: 0.40, skill_concentration: 0.25}
  @neutral_concentration 50.0

  @doc "Returns a full-precision Functional Depth metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{roster: roster} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_list(roster) do
    tables =
      benchmark
      |> Benchmark.section(:skills)
      |> Map.fetch!("player_percentiles")

    profiles = profiles(roster, tables, Inputs.model_override(scenario_state, snapshot))
    {position_depth, position_inputs, position_fallbacks} = position_depth(profiles)
    {skill_redundancy, redundancy_inputs} = skill_redundancy(profiles)

    {skill_concentration, concentration_inputs, concentration_fallbacks} =
      skill_concentration(profiles)

    supporting = %{
      position_depth: position_depth,
      skill_redundancy: skill_redundancy,
      skill_concentration: skill_concentration
    }

    %MetricResult{
      key: :functional_depth,
      version: benchmark.version,
      score:
        position_depth * @weights.position_depth +
          skill_redundancy * @weights.skill_redundancy +
          skill_concentration * @weights.skill_concentration,
      inputs: %{
        position_depth: position_inputs,
        skill_redundancy: redundancy_inputs,
        skill_concentration: concentration_inputs
      },
      supporting: supporting,
      weights: @weights,
      fallbacks: position_fallbacks ++ concentration_fallbacks,
      explanation:
        "Functional Depth combines position depth (35%), skill redundancy (40%), " <>
          "and skill concentration (25%) across the entire roster."
    }
  end

  @doc "Converts non-negative position-depth units through AVC's piecewise curve."
  @spec position_depth_score(number()) :: float()
  def position_depth_score(units) when is_number(units) and units >= 0 do
    cond do
      units <= 2.0 -> units * 50.0
      units <= 3.0 -> 100.0
      units <= 4.0 -> interpolate(units, 3.0, 100.0, 4.0, 90.0)
      units <= 5.0 -> interpolate(units, 4.0, 90.0, 5.0, 75.0)
      true -> 75.0
    end
  end

  @doc "Converts non-negative qualified-provider units through AVC's redundancy curve."
  @spec redundancy_score(number()) :: float()
  def redundancy_score(units) when is_number(units) and units >= 0 do
    cond do
      units <= 1.0 -> interpolate(units, 0.0, 0.0, 1.0, 45.0)
      units <= 2.0 -> interpolate(units, 1.0, 45.0, 2.0, 75.0)
      units <= 3.0 -> interpolate(units, 2.0, 75.0, 3.0, 90.0)
      units <= 4.0 -> interpolate(units, 3.0, 90.0, 4.0, 100.0)
      true -> 100.0
    end
  end

  @doc "Converts a best-player top-three share through AVC's concentration curve."
  @spec concentration_score(number()) :: float()
  def concentration_score(share) when is_number(share) and share >= 0 do
    cond do
      share <= 0.40 -> 100.0
      share <= 0.45 -> interpolate(share, 0.40, 100.0, 0.45, 85.0)
      share <= 0.50 -> interpolate(share, 0.45, 85.0, 0.50, 70.0)
      share <= 0.55 -> interpolate(share, 0.50, 70.0, 0.55, 50.0)
      share <= 0.60 -> interpolate(share, 0.55, 50.0, 0.60, 30.0)
      share <= 0.65 -> interpolate(share, 0.60, 30.0, 0.65, 10.0)
      true -> 10.0
    end
  end

  defp profiles(roster, tables, override) do
    Enum.map(roster, fn player ->
      %{
        player: player,
        position: PositionCoverage.normalize_position!(fetch_any!(player, :position)),
        contribution_factor: SkillIndexes.contribution_factor(player, override),
        indexes: SkillIndexes.player_indexes(player, tables, %{override: override}).skills
      }
    end)
  end

  defp position_depth(profiles) do
    initial = Map.new(PositionCoverage.positions(), &{&1, 0.0})

    {units, fallbacks} =
      Enum.reduce(profiles, {initial, []}, fn profile, {units, fallbacks} ->
        case profile.contribution_factor do
          factor when is_number(factor) ->
            units =
              units
              |> Map.update!(profile.position, &(&1 + factor))
              |> add_adjacent_units(profile.position, factor)

            {units, fallbacks}

          nil ->
            fallback = %{
              input: player_identifier(profile.player),
              method: :missing_contribution_factor,
              note:
                "#{player_label(profile.player)} has no season-minutes sample, so no " <>
                  "position-depth or qualified-provider credit was invented."
            }

            {units, [fallback | fallbacks]}
        end
      end)

    scores = Map.new(units, fn {position, value} -> {position, position_depth_score(value)} end)
    {score, average, weakest} = weakest_blend(Map.values(scores))

    inputs = %{
      units: units,
      scores: scores,
      average_score: average,
      weakest_score: weakest
    }

    {score, inputs, Enum.reverse(fallbacks)}
  end

  defp skill_redundancy(profiles) do
    units =
      Map.new(SkillIndexes.skills(), fn skill ->
        value =
          Enum.reduce(profiles, 0.0, fn profile, total ->
            index = get_in(profile, [:indexes, skill, :index])

            if is_number(index) and index >= @qualified_index and
                 is_number(profile.contribution_factor) do
              total + profile.contribution_factor
            else
              total
            end
          end)

        {skill, value}
      end)

    scores = Map.new(units, fn {skill, value} -> {skill, redundancy_score(value)} end)
    {score, average, weakest} = weakest_blend(Map.values(scores))

    {score,
     %{
       qualified_index_threshold: @qualified_index,
       units: units,
       scores: scores,
       average_score: average,
       weakest_score: weakest
     }}
  end

  defp skill_concentration(profiles) do
    {shares, scores, fallbacks} =
      Enum.reduce(SkillIndexes.skills(), {%{}, %{}, []}, fn skill, {shares, scores, fallbacks} ->
        top_three =
          profiles
          |> Enum.map(&get_in(&1, [:indexes, skill, :index]))
          |> Enum.filter(&is_number/1)
          |> Enum.sort(:desc)
          |> Enum.take(3)

        total = Enum.sum(top_three)

        if length(top_three) < 3 or total == 0 do
          reason =
            if length(top_three) < 3, do: :fewer_than_three_indexes, else: :zero_top_three_sum

          fallback = %{
            input: Atom.to_string(skill),
            method: :neutral_skill_concentration,
            note:
              "D-24 applies the neutral concentration score of 50 for #{skill}: " <>
                concentration_reason(reason, length(top_three))
          }

          {
            Map.put(shares, skill, nil),
            Map.put(scores, skill, @neutral_concentration),
            [fallback | fallbacks]
          }
        else
          share = hd(top_three) / total

          {
            Map.put(shares, skill, share),
            Map.put(scores, skill, concentration_score(share)),
            fallbacks
          }
        end
      end)

    average = scores |> Map.values() |> then(&(Enum.sum(&1) / length(SkillIndexes.skills())))

    {average, %{top_player_shares: shares, scores: scores}, Enum.reverse(fallbacks)}
  end

  defp add_adjacent_units(units, position, factor) do
    Enum.reduce(Map.fetch!(@adjacent, position), units, fn adjacent, acc ->
      Map.update!(acc, adjacent, &(&1 + factor * @adjacent_credit))
    end)
  end

  defp weakest_blend(scores) do
    average = Enum.sum(scores) / length(scores)
    weakest = Enum.min(scores)
    {average * @average_weight + weakest * @weakest_weight, average, weakest}
  end

  defp interpolate(value, low_x, low_y, high_x, high_y) do
    low_y + (value - low_x) / (high_x - low_x) * (high_y - low_y)
  end

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

  defp fetch_any(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key)))

  defp player_identifier(player) do
    fetch_any(player, :id) || fetch_any(player, :player_id) || player_label(player)
  end

  defp player_label(player) do
    fetch_any(player, :name) ||
      "Player #{inspect(fetch_any(player, :id) || fetch_any(player, :player_id))}"
  end

  defp concentration_reason(:fewer_than_three_indexes, count),
    do: "only #{count} measured player indexes were available."

  defp concentration_reason(:zero_top_three_sum, _count),
    do: "the top-three index sum was zero."
end
inputs.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction/inputs.ex

See the implementation below.

Download source ↓

SHA-256 d4bbc963c45eb0a61584a37487075d60ea3d5381088cc03fba69ca987c68124c

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs do
  @moduledoc false

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot

  @skill_evidence_keys [
    :player_id,
    :season_minutes,
    :games_played,
    :minutes_per_game,
    :assists,
    :turnovers,
    :usg_pct,
    :fg3_pct,
    :fg3a,
    :ts_pct,
    :fga,
    :fta,
    :ft_pct,
    :epm_def,
    :steals,
    :blocks,
    :rebounds
  ]

  # The scenario-owned membership fields — identity, listed position, and
  # contract/two-way status — exactly the shape `ScenarioState` emits (see its
  # `standard_roster/1` and `two_way_roster/1`). Only these may override the
  # frozen player: a scenario legitimately re-positions or re-signs a player, so
  # position and two-way status must win. Everything else — the write-once
  # statistical/skill evidence in `@skill_evidence_keys` — must come from the
  # snapshot and can never be replaced by a roster field.
  @membership_keys [
    :id,
    :player_id,
    :name,
    :position,
    :salary,
    :is_draft_pick,
    :is_two_way
  ]

  # Both spellings, because roster rows arrive atom-keyed in memory and
  # string-keyed out of Postgres / form params — the same dual-spelling rule
  # `SkillIndexes.contribution_factor/2` follows. `Map.take/2` copies whichever
  # spelling the member actually carries.
  @membership_key_spellings Enum.flat_map(@membership_keys, &[&1, Atom.to_string(&1)])

  @doc """
  Joins scenario roster membership to the snapshot's frozen player evidence.

  `ScenarioState` deliberately emits membership only; stats and skill evidence
  live in the write-once challenge snapshot. Only the allowlisted membership
  fields (identity, position, contract/two-way status) merge over the frozen
  player, so scenario-owned position and two-way status win on duplicate keys
  while frozen skill evidence can never be overwritten — a canonical
  `ScenarioState` row carries no stat line, but an enriched or future caller that
  did would otherwise silently replace write-once evidence (e.g. a member
  `season_minutes: 0` zeroing a frozen `800`) and move Position Coverage, Skill
  Coverage, and Functional Depth.
  """
  @spec hydrate_roster([map()], ChallengeSnapshot.t()) :: [map()]
  def hydrate_roster(roster, %ChallengeSnapshot{} = snapshot) when is_list(roster) do
    players = ChallengeSnapshot.section(snapshot, :players)

    Enum.map(roster, fn member ->
      member
      |> player_id!()
      |> frozen_player(players)
      |> canonicalize_skill_evidence()
      |> Map.merge(Map.take(member, @membership_key_spellings))
    end)
  end

  @doc """
  Returns the constants override frozen with the benchmark.

  Benchmarks created before this provenance field existed use the versioned
  defaults. String keys are retained because `ModelVersion` accepts both JSON
  and in-memory spellings without interning document input.
  """
  @spec model_override(ChallengeSnapshot.t()) :: map()
  def model_override(%ChallengeSnapshot{benchmark: %Benchmark{data: data}}) do
    with meta when is_map(meta) <- fetch_any(data, :meta),
         override when is_map(override) <- fetch_any(meta, :model_override) do
      override
    else
      _ -> %{}
    end
  end

  @doc "Returns an override already attached by the pillar, or the benchmark's frozen value."
  @spec model_override(map(), ChallengeSnapshot.t()) :: map()
  def model_override(scenario_state, %ChallengeSnapshot{} = snapshot)
      when is_map(scenario_state) do
    case Map.get(scenario_state, :model_override) do
      override when is_map(override) -> override
      _ -> model_override(snapshot)
    end
  end

  defp frozen_player(player_id, players) do
    case Map.get(players, player_id) || Map.get(players, to_string(player_id)) do
      player when is_map(player) -> player
      _ -> %{}
    end
  end

  defp player_id!(member) do
    case fetch_any(member, :id) || fetch_any(member, :player_id) do
      nil -> raise KeyError, key: :id, term: member
      player_id -> player_id
    end
  end

  # JSON documents return string keys, while `PlayerImpact.season_minutes/1`
  # and the skill formulas consume the schema's atom fields. Only this fixed
  # vocabulary is canonicalized; arbitrary document input is never atomized.
  defp canonicalize_skill_evidence(player) do
    Enum.reduce(@skill_evidence_keys, player, fn key, canonical ->
      case Map.fetch(canonical, key) do
        {:ok, _value} ->
          canonical

        :error ->
          case Map.fetch(canonical, Atom.to_string(key)) do
            {:ok, value} -> Map.put(canonical, key, value)
            :error -> canonical
          end
      end
    end)
  end

  defp fetch_any(map, key) when is_map(map) do
    case Map.fetch(map, key) do
      {:ok, value} -> value
      :error -> Map.get(map, Atom.to_string(key))
    end
  end

  defp fetch_any(_other, _key), do: nil
end
position_coverage.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction/position_coverage.ex

Scores AVC's Position Coverage metric over the entire final roster.

Each player contributes only at their listed position. The historical player feed's generic "G" display value is canonicalized to "SG" — the same conservative rule used by calibration, which avoids inventing primary-handler credit. Their contribution is the shared TradeMachine.GMGradeV2.SkillIndexes.contribution_factor/2 , so the 800-minute experience curve and the two-way availability multiplier remain centralized. A position reaches full coverage at one contribution unit.

Download source ↓

SHA-256 a2c15501449fbdd179226f17e3aabfbfd084d4d3ff36caa17c47370861314ff4

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage do
  @moduledoc """
  Scores AVC's Position Coverage metric over the entire final roster.

  Each player contributes only at their listed position. The historical player
  feed's generic `"G"` display value is canonicalized to `"SG"` — the same
  conservative rule used by calibration, which avoids inventing primary-handler
  credit. Their contribution is the shared
  `TradeMachine.GMGradeV2.SkillIndexes.contribution_factor/2`, so the 800-minute
  experience curve and the two-way availability multiplier remain centralized.
  A position reaches full coverage at one contribution unit.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.SkillIndexes

  @positions ~w(PG SG SF PF C)
  @average_weight 0.70
  @weakest_weight 0.30

  @doc "Returns the five positions in AVC's stated order."
  @spec positions() :: [String.t()]
  def positions, do: @positions

  @doc "Normalizes an AVC position (including legacy `G`) or raises."
  @spec normalize_position!(String.t() | atom()) :: String.t()
  def normalize_position!(position) when position in @positions, do: position
  def normalize_position!("G"), do: "SG"

  def normalize_position!(position) when is_atom(position) do
    position
    |> Atom.to_string()
    |> String.upcase()
    |> normalize_position!()
  end

  def normalize_position!(position) do
    raise ArgumentError,
          "position must be one of #{Enum.join(@positions, ", ")}, got: #{inspect(position)}"
  end

  @doc "Returns a full-precision Position Coverage metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{roster: roster} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_list(roster) do
    {units, fallbacks} = coverage_units(roster, Inputs.model_override(scenario_state, snapshot))

    position_scores =
      Map.new(units, fn {position, value} -> {position, position_score(value)} end)

    scores = Map.values(position_scores)
    average = Enum.sum(scores) / length(@positions)
    weakest = Enum.min(scores)

    %MetricResult{
      key: :position_coverage,
      version: benchmark.version,
      score: average * @average_weight + weakest * @weakest_weight,
      inputs: %{
        coverage_units: units,
        average_position_score: average,
        weakest_position_score: weakest
      },
      supporting: position_scores,
      weights: %{average: @average_weight, weakest: @weakest_weight},
      fallbacks: fallbacks,
      explanation:
        "Position Coverage gives 70% weight to the five-position average and 30% " <>
          "to the weakest listed position."
    }
  end

  @doc """
  Sums player contribution factors at each listed position.

  Players with unknown season minutes receive no invented coverage credit and
  are reported as fallbacks. The legacy generic-guard value `G` uses the
  documented `SG` canonicalization; other positions outside AVC's
  PG/SG/SF/PF/C vocabulary are rejected rather than silently omitted.
  """
  @spec coverage_units([map()], map()) ::
          {%{String.t() => float()}, [MetricResult.fallback()]}
  def coverage_units(roster, override \\ %{}) when is_list(roster) and is_map(override) do
    initial = {Map.new(@positions, &{&1, 0.0}), []}

    {units, fallbacks} =
      Enum.reduce(roster, initial, fn player, {units, fallbacks} ->
        position = player |> fetch_any!(:position) |> normalize_position!()

        case SkillIndexes.contribution_factor(player, override) do
          factor when is_number(factor) ->
            {Map.update!(units, position, &(&1 + factor)), fallbacks}

          nil ->
            fallback = %{
              input: player_identifier(player),
              method: :missing_contribution_factor,
              note:
                "#{player_label(player)} has no season-minutes sample, so no " <>
                  "#{position} coverage credit was invented."
            }

            {units, [fallback | fallbacks]}
        end
      end)

    {units, Enum.reverse(fallbacks)}
  end

  @doc "Converts coverage units to AVC's capped 0–100 individual-position score."
  @spec position_score(number()) :: float()
  def position_score(units) when is_number(units) and units >= 0 do
    100.0 * min(1.0, units)
  end

  defp fetch_any!(map, key) do
    case Map.get(map, key, Map.get(map, Atom.to_string(key))) do
      nil -> raise KeyError, key: key, term: map
      value -> value
    end
  end

  defp fetch_any(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key)))

  defp player_identifier(player) do
    fetch_any(player, :id) || fetch_any(player, :player_id) || player_label(player)
  end

  defp player_label(player) do
    fetch_any(player, :name) || "Player #{inspect(player_identifier_without_label(player))}"
  end

  defp player_identifier_without_label(player) do
    fetch_any(player, :id) || fetch_any(player, :player_id) ||
      "at #{fetch_any(player, :position)}"
  end
end
roster_completeness.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction/roster_completeness.ex

Scores AVC's Roster Completeness metric over the final scenario roster.

Standard and two-way roster limits come from TradeMachine.CBA.Rules ; this module does not duplicate CBA constants. Standard contracts receive AVC's piecewise base score, and occupied two-way slots add at most five points.

Download source ↓

SHA-256 77b66dd36c129b2b8e64b79b81f61a6c1a650289db99f99225bb4c482031cc6f

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.RosterCompleteness do
  @moduledoc """
  Scores AVC's Roster Completeness metric over the final scenario roster.

  Standard and two-way roster limits come from `TradeMachine.CBA.Rules`; this
  module does not duplicate CBA constants. Standard contracts receive AVC's
  piecewise base score, and occupied two-way slots add at most five points.
  """

  alias TradeMachine.CBA.Rules
  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @two_way_bonus_cap 5.0

  @doc "Returns a full-precision Roster Completeness metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{roster: roster},
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark}
      )
      when is_list(roster) do
    {two_way_players, standard_players} = Enum.split_with(roster, &two_way?/1)
    standard_count = length(standard_players)
    two_way_count = length(two_way_players)
    minimum = Rules.min_standard_roster()
    maximum = Rules.max_standard_roster()
    maximum_two_way = Rules.max_two_way_contracts()
    standard_score = standard_roster_score(standard_count, minimum, maximum)
    bonus = two_way_bonus(two_way_count, maximum_two_way)

    %MetricResult{
      key: :roster_completeness,
      version: benchmark.version,
      score: min(100.0, standard_score + bonus),
      inputs: %{
        standard_count: standard_count,
        two_way_count: two_way_count,
        minimum_standard_roster: minimum,
        maximum_standard_roster: maximum,
        maximum_two_way_contracts: maximum_two_way
      },
      supporting: %{
        standard_roster_score: standard_score,
        two_way_bonus: bonus
      },
      explanation:
        "Roster Completeness scores #{standard_count} standard contracts and " <>
          "#{two_way_count} of #{maximum_two_way} available two-way slots."
    }
  end

  @doc """
  Applies AVC's season-aware piecewise curve to a standard-roster count.

  When the legal range spans more than two players, values between the legal
  minimum (80) and one below the maximum (90) are linearly interpolated.
  """
  @spec standard_roster_score(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: float()
  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  def standard_roster_score(count, minimum, maximum)
      when is_integer(count) and count >= 0 and is_integer(minimum) and minimum >= 0 and
             is_integer(maximum) and maximum >= minimum do
    cond do
      count >= maximum ->
        100.0

      count == maximum - 1 ->
        90.0

      count >= minimum and maximum - minimum > 2 ->
        80.0 + 10.0 * (count - minimum) / (maximum - minimum - 1)

      count >= minimum ->
        80.0

      count == minimum - 1 ->
        60.0

      count == minimum - 2 ->
        40.0

      count == minimum - 3 ->
        20.0

      true ->
        0.0
    end
  end

  @doc "Calculates the capped five-point two-way roster bonus."
  @spec two_way_bonus(non_neg_integer(), non_neg_integer()) :: float()
  def two_way_bonus(_count, 0), do: 0.0

  def two_way_bonus(count, maximum)
      when is_integer(count) and count >= 0 and is_integer(maximum) and maximum > 0 do
    min(@two_way_bonus_cap, @two_way_bonus_cap * count / maximum)
  end

  defp two_way?(row) when is_map(row) do
    Map.get(row, :is_two_way, Map.get(row, "is_two_way")) == true
  end
end
skill_coverage.exElixir

lib/trade_machine/gm_grade_v2/pillars/roster_construction/skill_coverage.ex

Scores AVC's Skill Coverage metric over the entire final roster.

Each player's five reliability-adjusted indexes come from TradeMachine.GMGradeV2.SkillIndexes . The best providers are combined with AVC's skill-specific ranked weights, then each team raw is converted to a percentile against the frozen 30-team benchmark.

Per D-23, a ranked slot without a measured player index is filled with the reliability-shrunk league median of 50. The slot remains in the formula and its weight is not renormalized.

Download source ↓

SHA-256 b686ab0410f582820b79644dfcabc34a5c438044771998a567846dfc78f1af94

defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.SkillCoverage do
  @moduledoc """
  Scores AVC's Skill Coverage metric over the entire final roster.

  Each player's five reliability-adjusted indexes come from
  `TradeMachine.GMGradeV2.SkillIndexes`. The best providers are combined with
  AVC's skill-specific ranked weights, then each team raw is converted to a
  percentile against the frozen 30-team benchmark.

  Per D-23, a ranked slot without a measured player index is filled with the
  reliability-shrunk league median of 50. The slot remains in the formula and
  its weight is not renormalized.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.SkillIndexes

  @team_skill_weights [
    playmaking: [0.65, 0.35],
    shooting: [0.30, 0.25, 0.20, 0.15, 0.10],
    defense: [0.35, 0.30, 0.20, 0.15],
    rim_protection: [0.70, 0.30],
    rebounding: [0.50, 0.30, 0.20]
  ]
  @empty_slot_index 50.0
  @average_weight 0.70
  @weakest_weight 0.30

  if Keyword.keys(@team_skill_weights) != SkillIndexes.skills() do
    raise "team skill formulas must cover exactly SkillIndexes.skills/0, in order"
  end

  if Enum.any?(@team_skill_weights, fn {_skill, weights} ->
       abs(Enum.sum(weights) - 1.0) > 1.0e-9
     end) do
    raise "each team skill formula's weights must sum to 1.0"
  end

  @doc "The five AVC team skill raw formulas, in scoring order."
  @spec team_skill_weights() :: keyword([float()])
  def team_skill_weights, do: @team_skill_weights

  @doc "Returns a full-precision Skill Coverage metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(
        %{roster: roster} = scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_list(roster) do
    skills = Benchmark.section(benchmark, :skills)
    tables = Map.fetch!(skills, "player_percentiles")
    benchmark_arrays = Map.fetch!(skills, "metrics")
    override = Inputs.model_override(scenario_state, snapshot)

    {raw_scores, ranked_indexes, fallbacks} =
      team_raw_details(roster, tables, %{override: override})

    skill_scores =
      Map.new(raw_scores, fn {skill, raw} ->
        array = Map.fetch!(benchmark_arrays, Atom.to_string(skill))
        {skill, Percentile.score(raw, array)}
      end)

    scores = Map.values(skill_scores)
    average = Enum.sum(scores) / length(@team_skill_weights)
    weakest = Enum.min(scores)

    %MetricResult{
      key: :skill_coverage,
      version: benchmark.version,
      score: average * @average_weight + weakest * @weakest_weight,
      inputs: %{
        team_raw_scores: raw_scores,
        ranked_player_indexes: ranked_indexes,
        average_skill_score: average,
        weakest_skill_score: weakest
      },
      supporting: skill_scores,
      weights: %{average: @average_weight, weakest: @weakest_weight},
      fallbacks: fallbacks,
      explanation:
        "Skill Coverage gives 70% weight to the five-skill average and 30% " <>
          "to the weakest roster-wide capability."
    }
  end

  @doc """
  Calculates the five pre-percentile team skill raws.

  This is also the benchmark builder's scoring seam, ensuring real-team arrays
  and challenge rosters use the same ranking, weights, and D-23 median fill.
  """
  @spec team_raw_scores([map()], map(), map()) :: %{atom() => float()}
  def team_raw_scores(roster, percentile_tables, opts \\ %{})
      when is_list(roster) and is_map(percentile_tables) and is_map(opts) do
    {raw_scores, _ranked_indexes, _fallbacks} =
      team_raw_details(roster, percentile_tables, opts)

    raw_scores
  end

  @doc false
  @spec team_raw_details([map()], map(), map()) ::
          {%{atom() => float()}, %{atom() => [float()]}, [MetricResult.fallback()]}
  def team_raw_details(roster, percentile_tables, opts \\ %{})
      when is_list(roster) and is_map(percentile_tables) and is_map(opts) do
    override = Map.get(opts, :override, %{})

    indexes =
      Enum.map(roster, &SkillIndexes.player_indexes(&1, percentile_tables, %{override: override}))

    {raw_scores, ranked_indexes, fallbacks} =
      Enum.reduce(
        @team_skill_weights,
        {%{}, %{}, []},
        fn {skill, weights}, {raw_scores, ranked_indexes, fallbacks} ->
          measured =
            indexes
            |> Enum.map(&get_in(&1, [:skills, skill, :index]))
            |> Enum.filter(&is_number/1)
            |> Enum.sort(:desc)
            |> Enum.take(length(weights))

          missing_count = max(0, length(weights) - length(measured))
          ranked = measured ++ List.duplicate(@empty_slot_index, missing_count)

          raw =
            ranked
            |> Enum.zip(weights)
            |> Enum.reduce(0.0, fn {index, weight}, total -> total + index * weight end)

          skill_fallbacks =
            if missing_count == 0 do
              []
            else
              [
                %{
                  input: Atom.to_string(skill),
                  method: :league_median_ranked_slot,
                  note:
                    "#{missing_count} unfilled #{skill} ranked #{slot_label(missing_count)} " <>
                      "used the D-23 reliability-shrunk league median of 50."
                }
              ]
            end

          {
            Map.put(raw_scores, skill, raw),
            Map.put(ranked_indexes, skill, ranked),
            fallbacks ++ skill_fallbacks
          }
        end
      )

    {raw_scores, ranked_indexes, fallbacks}
  end

  defp slot_label(1), do: "slot"
  defp slot_label(_count), do: "slots"
end

Found an unexpected score? Record the model version, mandate, evidence date, and the smallest scenario that reproduces it.

Next: Contract health →

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