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

Documentation The six pillars

Contract health

Contract value, long-term risk, mobility, and optionality compare contractual obligations with frozen expected-salary evidence.

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
Contract value 35.0%
Long term risk 30.0%
Contract mobility 20.0%
Contract optionality 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.

contract_health.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health.ex

Aggregates AVC's four Contract Health metrics over the final roster.

Contract Value, Long-Term Risk, Contract Mobility, and Contract Optionality retain full precision through the fixed 35/30/20/15 weighted sum.

Download source ↓

SHA-256 34e37af5e2dd63e1862f616ad1b86753d9026d98cacd57da82fbe0f09f2f990f

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth do
  @moduledoc """
  Aggregates AVC's four Contract Health metrics over the final roster.

  Contract Value, Long-Term Risk, Contract Mobility, and Contract Optionality
  retain full precision through the fixed 35/30/20/15 weighted sum.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractMobility
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractOptionality
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractValue
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.LongTermRisk
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult

  @metric_weights %{
    contract_value: 0.35,
    long_term_risk: 0.30,
    contract_mobility: 0.20,
    contract_optionality: 0.15
  }

  @metric_modules [
    ContractValue,
    LongTermRisk,
    ContractMobility,
    ContractOptionality
  ]

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

  @doc "Returns the full-precision Contract Health pillar result."
  @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
  def score(
        scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_map(scenario_state) do
    metric_results = Enum.map(@metric_modules, & &1.score(scenario_state, snapshot))
    {strongest, weakest} = extrema(metric_results)

    %PillarResult{
      key: :contract_health,
      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.sum(
      Enum.map(metric_results, fn result ->
        result.score * Map.fetch!(@metric_weights, result.key)
      end)
    )
  end

  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
    "Contract Health combines four final-roster contract metrics; " <>
      "#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest_key)} is weakest at #{weakest_score}."
  end

  defp label(:contract_value), do: "Contract Value"
  defp label(:long_term_risk), do: "Long-Term Risk"
  defp label(:contract_mobility), do: "Contract Mobility"
  defp label(:contract_optionality), do: "Contract Optionality"
end
contract_mobility.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health/contract_mobility.ex

Scores how easily the final roster's active contracts can be traded.

Player mobility blends Contract Value marketability (60%), salary utility (25%), and guaranteed-term utility (15%), then applies every stored trade restriction multiplicatively. Player options and trade bonuses of at least 5% each apply 0.9 ; a no-trade clause applies 0.5 . Missing stored restriction data never invents a penalty and is reported as "not stored".

Salary Utility implements D-25 as a step function. A boundary belongs to the higher salary-share band: exactly 2%, 8%, 18%, 28%, and 35% score 90, 100, 80, 60, and 40 respectively.

Team mobility is 70% discounted-remaining-salary-weighted average and 30% Low-Mobility Exposure. The latter sorts by {mobility, player_id} , walks the least-mobile contracts until reaching 25% of guaranteed remaining salary, and splits the crossing contract pro-rata. Guaranteed dollars are the stored ContractSeason.guaranteed amounts; a player option uses at least its full salary because the player controls whether that commitment remains. Team options and explicitly non-guaranteed seasons contribute zero. When the team has no guaranteed remaining salary, Low-Mobility Exposure falls back to the weighted average.

Download source ↓

SHA-256 c9ab97e8eb32b1d3dbd761d5dac54ffc113c86682e97c1c1e02ed82632daa04b

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractMobility do
  @moduledoc """
  Scores how easily the final roster's active contracts can be traded.

  Player mobility blends Contract Value marketability (60%), salary utility
  (25%), and guaranteed-term utility (15%), then applies every stored trade
  restriction multiplicatively. Player options and trade bonuses of at least
  5% each apply `0.9`; a no-trade clause applies `0.5`. Missing stored
  restriction data never invents a penalty and is reported as "not stored".

  Salary Utility implements D-25 as a step function. A boundary belongs to the
  higher salary-share band: exactly 2%, 8%, 18%, 28%, and 35% score 90, 100,
  80, 60, and 40 respectively.

  Team mobility is 70% discounted-remaining-salary-weighted average and 30%
  Low-Mobility Exposure. The latter sorts by `{mobility, player_id}`, walks the
  least-mobile contracts until reaching 25% of guaranteed remaining salary,
  and splits the crossing contract pro-rata. Guaranteed dollars are the stored
  `ContractSeason.guaranteed` amounts; a player option uses at least its full
  salary because the player controls whether that commitment remains. Team
  options and explicitly non-guaranteed seasons contribute zero. When the team
  has no guaranteed remaining salary, Low-Mobility Exposure falls back to the
  weighted average.
  """

  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractValue
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @neutral_score 50.0
  @supporting_limit 3

  @doc "Returns a full-precision Contract Mobility metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(scenario_state, %ChallengeSnapshot{} = snapshot) when is_map(scenario_state) do
    override = Map.get(scenario_state, :constants_override, %{})
    expected_salaries = ChallengeSnapshot.section(snapshot, :expected_salaries)
    thresholds = ChallengeSnapshot.section(snapshot, :thresholds)
    contracts = Shared.active_contracts(scenario_state)
    current_season = current_season(scenario_state, contracts)
    salary_cap = salary_cap(thresholds, current_season)

    {players, player_fallbacks} =
      Enum.map_reduce(contracts, [], fn contract, accumulated ->
        stored_contract = stored_contract(scenario_state, contract)
        player_id = fetch_any(contract, :player_id)

        baseline_score =
          ModelVersion.rookie_contract_baseline_for_snapshot(snapshot.data, player_id)

        {player, fallbacks} =
          evaluate_player(
            contract,
            stored_contract,
            expected_salaries,
            current_season,
            salary_cap,
            override,
            baseline_score
          )

        {player, Enum.reverse(fallbacks, accumulated)}
      end)

    team_salary = Enum.sum(Enum.map(players, & &1.discounted_remaining_salary))
    players = Enum.map(players, &with_salary_weight(&1, team_salary))

    {weighted_average, team_fallbacks} =
      weighted_average(players, team_salary, salary_cap, current_season)

    {low_mobility, quantile_fallbacks} =
      case low_mobility_exposure(players) do
        {:ok, exposure} ->
          {exposure, []}

        {:fallback, :zero_guaranteed_salary} ->
          {weighted_average,
           [
             %{
               input: :guaranteed_remaining_salary,
               method: :weighted_average_low_mobility,
               note:
                 "Guaranteed remaining salary is zero; Low-Mobility Exposure uses the weighted average."
             }
           ]}
      end

    team_score = clamp(weighted_average * 0.70 + low_mobility * 0.30)

    %MetricResult{
      key: :contract_mobility,
      version: ModelVersion.model_version(),
      score: team_score,
      inputs: %{
        active_contract_count: length(players),
        current_season: current_season,
        salary_cap: salary_cap,
        discounted_remaining_salary: team_salary,
        guaranteed_remaining_salary:
          Enum.sum(Enum.map(players, & &1.guaranteed_remaining_salary)),
        weighted_mobility_average: weighted_average,
        low_mobility_exposure: low_mobility
      },
      supporting: supporting(players),
      weights: %{
        player: %{marketability: 0.60, salary_utility: 0.25, term_utility: 0.15},
        team: %{weighted_average: 0.70, low_mobility_exposure: 0.30},
        players: :discounted_remaining_salary_share,
        low_mobility_quantile: 0.25
      },
      fallbacks: Enum.reverse(player_fallbacks) ++ team_fallbacks ++ quantile_fallbacks,
      explanation:
        "Contract Mobility blends marketability, salary-matching utility, term, and stored " <>
          "trade restrictions across #{length(players)} active contracts, including the " <>
          "least-mobile 25% of guaranteed remaining salary."
    }
  end

  @doc """
  Scores current salary as a share of the current salary cap.

  Boundaries enter the higher salary-share band (D-25).
  """
  @spec salary_utility(number()) :: float()
  def salary_utility(share) when is_number(share) do
    cond do
      share < 0.02 -> 70.0
      share < 0.08 -> 90.0
      share < 0.18 -> 100.0
      share < 0.28 -> 80.0
      share < 0.35 -> 60.0
      true -> 40.0
    end
  end

  @doc "Scores the count of remaining guaranteed or player-controlled years."
  @spec term_utility(non_neg_integer()) :: float()
  def term_utility(years) when is_integer(years) and years <= 1, do: 100.0
  def term_utility(2), do: 85.0
  def term_utility(3), do: 65.0
  def term_utility(4), do: 50.0
  def term_utility(years) when is_integer(years) and years >= 5, do: 35.0

  @doc """
  Returns the guaranteed-salary-weighted mobility of the bottom 25%.

  The contract crossing the 25% boundary is included only for the dollars
  needed to reach the boundary.
  """
  @spec low_mobility_exposure([map()]) ::
          {:ok, float()} | {:fallback, :zero_guaranteed_salary}
  def low_mobility_exposure(players) when is_list(players) do
    total = Enum.sum(Enum.map(players, &guaranteed_salary/1))

    if total > 0 do
      target = total * 0.25

      {weighted_score, _taken} =
        players
        |> Enum.sort_by(&{fetch_any(&1, :score), fetch_any(&1, :player_id)})
        |> Enum.reduce_while({0.0, 0.0}, fn player, {score, taken} ->
          available = guaranteed_salary(player)
          included = min(available, target - taken)
          next = {score + fetch_any(player, :score) * included, taken + included}

          if elem(next, 1) >= target, do: {:halt, next}, else: {:cont, next}
        end)

      {:ok, weighted_score / target}
    else
      {:fallback, :zero_guaranteed_salary}
    end
  end

  defp evaluate_player(
         contract,
         stored_contract,
         expected_salaries,
         current_season,
         salary_cap,
         override,
         baseline_score
       ) do
    value =
      ContractValue.player_value(
        contract,
        expected_salaries,
        current_season,
        override,
        stored_contract,
        baseline_score
      )

    seasons = Shared.remaining_seasons(contract)
    current = Enum.find(seasons, &(&1.season == current_season)) || List.first(seasons)
    {salary_score, salary_fallbacks} = player_salary_utility(value.player_id, current, salary_cap)
    guaranteed_years = Enum.count(seasons, &guaranteed_year?/1)
    term_score = term_utility(guaranteed_years)

    {modifier, restrictions, restriction_fallbacks} =
      restriction_modifier(stored_contract, seasons)

    base = value.score * 0.60 + salary_score * 0.25 + term_score * 0.15

    player = %{
      player_id: value.player_id,
      name: value.name,
      score: clamp(base * modifier),
      marketability: value.score,
      salary_utility: salary_score,
      term_utility: term_score,
      guaranteed_years_remaining: guaranteed_years,
      restriction_modifier: modifier,
      restrictions: restrictions,
      discounted_remaining_salary: value.discounted_remaining_salary,
      guaranteed_remaining_salary: guaranteed_remaining_salary(seasons),
      salary_weight: 0.0,
      valuation_available?: value.valuation_available?
    }

    fallbacks =
      unavailable_value_fallback(value) ++ salary_fallbacks ++ restriction_fallbacks

    {player, fallbacks}
  end

  defp player_salary_utility(_player_id, %{salary: salary}, cap)
       when is_number(salary) and is_number(cap) and cap > 0 do
    {salary_utility(salary / cap), []}
  end

  defp player_salary_utility(player_id, _current, _cap) do
    {@neutral_score,
     [
       %{
         input: player_id,
         method: :neutral_salary_utility,
         note: "Current salary or salary cap was unavailable; Salary Utility is neutral."
       }
     ]}
  end

  defp unavailable_value_fallback(%{valuation_available?: true}), do: []

  defp unavailable_value_fallback(player) do
    [
      %{
        input: player.player_id,
        method: :neutral_unavailable_expected_salary,
        note: "Expected salary was unavailable; Contract Mobility marketability is neutral at 50."
      }
    ]
  end

  defp restriction_modifier(contract, seasons) do
    {no_trade_clause?, no_trade_fallbacks} = no_trade_clause(contract)
    {trade_bonus?, trade_bonus_fallbacks} = significant_trade_bonus(contract)
    player_option? = Enum.any?(seasons, &player_option?/1)

    restrictions =
      []
      |> maybe_add_restriction(player_option?, :player_option)
      |> maybe_add_restriction(trade_bonus?, :significant_trade_bonus)
      |> maybe_add_restriction(no_trade_clause?, :no_trade_clause)
      |> Enum.reverse()

    modifier =
      Enum.reduce(restrictions, 1.0, fn
        :no_trade_clause, value -> value * 0.5
        _restriction, value -> value * 0.9
      end)

    {modifier, restrictions, no_trade_fallbacks ++ trade_bonus_fallbacks}
  end

  defp no_trade_clause(contract) when is_map(contract) do
    if has_any_key?(contract, :no_trade_clause) do
      {fetch_any(contract, :no_trade_clause) == true, []}
    else
      {false, [not_stored_fallback(:no_trade_clause)]}
    end
  end

  defp no_trade_clause(_contract), do: {false, [not_stored_fallback(:no_trade_clause)]}

  defp significant_trade_bonus(contract) when is_map(contract) do
    rows = fetch_any(contract, :seasons) || fetch_any(contract, :entries) || []
    stored = Enum.map(rows, &fetch_any(&1, :trade_bonus_pct)) |> Enum.reject(&is_nil/1)

    if stored == [] do
      {false, [not_stored_fallback(:trade_bonus_pct)]}
    else
      {Enum.any?(stored, &(decimal_number(&1) >= 5.0)), []}
    end
  end

  defp significant_trade_bonus(_contract),
    do: {false, [not_stored_fallback(:trade_bonus_pct)]}

  defp not_stored_fallback(field) do
    %{
      input: field,
      method: :restriction_not_stored,
      note: "#{field} was not stored; no Contract Mobility restriction was applied."
    }
  end

  defp decimal_number(%Decimal{} = value), do: Decimal.to_float(value)
  defp decimal_number(value) when is_number(value), do: value * 1.0
  defp decimal_number(_value), do: 0.0

  defp guaranteed_year?(season) do
    player_option?(season) or
      (not team_option?(season) and is_number(season.guaranteed) and season.guaranteed > 0)
  end

  defp guaranteed_remaining_salary(seasons) do
    Enum.reduce(seasons, 0.0, fn season, total ->
      guaranteed =
        cond do
          player_option?(season) -> max(season.guaranteed || 0, season.salary)
          team_option?(season) -> 0
          is_number(season.guaranteed) -> max(season.guaranteed, 0)
          true -> 0
        end

      total + guaranteed
    end)
  end

  defp player_option?(season) do
    normalize_option_type(fetch_any(season, :option_type)) in ["player", "player_option"]
  end

  defp team_option?(season) do
    normalize_option_type(fetch_any(season, :option_type)) in ["team", "team_option"]
  end

  defp normalize_option_type(nil), do: nil
  defp normalize_option_type(value) when is_atom(value), do: Atom.to_string(value)
  defp normalize_option_type(value) when is_binary(value), do: String.downcase(value)
  defp normalize_option_type(_value), do: nil

  defp with_salary_weight(player, team_salary) do
    Map.put(
      player,
      :salary_weight,
      Shared.contract_salary_weight(player.discounted_remaining_salary, team_salary)
    )
  end

  defp weighted_average(players, team_salary, salary_cap, current_season) do
    cond do
      team_salary <= 0 ->
        {@neutral_score,
         [
           %{
             input: :active_contracts,
             method: :neutral_contract_mobility,
             note: "No active remaining salary was available; Contract Mobility is neutral."
           }
         ]}

      not (is_number(salary_cap) and salary_cap > 0) ->
        {Enum.sum(Enum.map(players, &(&1.score * &1.salary_weight))),
         [
           %{
             input: current_season || :current_season,
             method: :salary_cap_not_stored,
             note:
               "The current salary cap was not stored; player Salary Utility scores are neutral."
           }
         ]}

      true ->
        {Enum.sum(Enum.map(players, &(&1.score * &1.salary_weight))), []}
    end
  end

  defp supporting(players) do
    %{
      players: players,
      most_movable: ranked(players, :desc),
      least_movable: ranked(players, :asc),
      salary_below_40:
        players
        |> Enum.filter(&(&1.score < 40))
        |> Enum.sum_by(& &1.discounted_remaining_salary),
      useful_mid_sized_contracts: Enum.count(players, &(&1.salary_utility in [90.0, 100.0]))
    }
  end

  defp ranked(players, direction) do
    players
    |> Enum.sort_by(&{&1.score, &1.player_id}, direction)
    |> Enum.take(@supporting_limit)
    |> Enum.map(
      &Map.take(&1, [
        :player_id,
        :name,
        :score,
        :marketability,
        :salary_utility,
        :term_utility,
        :restriction_modifier,
        :salary_weight
      ])
    )
  end

  defp stored_contract(scenario_state, contract) do
    player_id = fetch_any(contract, :player_id)

    scenario_state
    |> fetch_any(:player_rows)
    |> List.wrap()
    |> Enum.find_value(fn row ->
      if row_player_id(row) == player_id, do: fetch_any(row, :contract)
    end)
    |> case do
      nil -> fetch_any(contract, :contract) || contract
      stored -> stored
    end
  end

  defp row_player_id(row) do
    fetch_any(row, :player_id) ||
      row |> fetch_any(:player) |> fetch_any(:id)
  end

  defp current_season(scenario_state, contracts) do
    fetch_any(scenario_state, :season) ||
      contracts
      |> Enum.flat_map(&Shared.remaining_seasons/1)
      |> Enum.map(& &1.season)
      |> Enum.min(fn -> nil end)
  end

  defp salary_cap(thresholds, nil) when is_map(thresholds), do: nil

  defp salary_cap(thresholds, season) when is_map(thresholds) do
    thresholds |> fetch_any(season) |> fetch_any(:salary_cap)
  end

  defp salary_cap(_thresholds, _season), do: nil

  defp guaranteed_salary(player) do
    value = fetch_any(player, :guaranteed_remaining_salary)
    if is_number(value) and value > 0, do: value, else: 0
  end

  defp maybe_add_restriction(restrictions, true, restriction),
    do: [restriction | restrictions]

  defp maybe_add_restriction(restrictions, _present, _restriction), do: restrictions

  defp has_any_key?(map, key) do
    Map.has_key?(map, key) or Map.has_key?(map, Atom.to_string(key))
  end

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

  defp fetch_any(_other, _key), do: nil

  defp clamp(value), do: value |> max(0.0) |> min(100.0)
end
contract_optionality.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health/contract_optionality.ex

Scores how much future flexibility the final roster's contracts preserve.

Contract status comes exclusively from TradeMachine.GMGradeV2.ControlProjection ; this metric only maps that shared evidence to AVC's optionality scores. Each player's future contract seasons are weighted by salary. An expiring contract with no future season is neutral at 50, rather than being rewarded for disappearing or penalized for providing no control.

Team Optionality uses the same discounted remaining-salary player weights as every other Contract Health metric. Current salary therefore determines how much an expiring contract's neutral score matters, while only future seasons determine its player optionality score.

Download source ↓

SHA-256 699d4536b5b897ed8fa3beabd4327d73130a4fb55ad1e045b203b04302dfdecc

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractOptionality do
  @moduledoc """
  Scores how much future flexibility the final roster's contracts preserve.

  Contract status comes exclusively from
  `TradeMachine.GMGradeV2.ControlProjection`; this metric only maps that shared
  evidence to AVC's optionality scores. Each player's future contract seasons
  are weighted by salary. An expiring contract with no future season is neutral
  at 50, rather than being rewarded for disappearing or penalized for providing
  no control.

  Team Optionality uses the same discounted remaining-salary player weights as
  every other Contract Health metric. Current salary therefore determines how
  much an expiring contract's neutral score matters, while only future seasons
  determine its player optionality score.
  """

  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ControlProjection
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @neutral_score 50.0
  @supporting_limit 3

  @contract_statuses [
    :fully_guaranteed,
    :team_option,
    :non_guaranteed,
    :partially_guaranteed,
    :player_option
  ]

  @doc "Returns a full-precision Contract Optionality metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(scenario_state, %ChallengeSnapshot{}) when is_map(scenario_state) do
    override = Map.get(scenario_state, :constants_override, %{})
    contracts = Shared.active_contracts(scenario_state)
    current_season = current_season(scenario_state, contracts)
    modifications = fetch_any(scenario_state, :modifications) || %{}

    {players, player_fallbacks} =
      Enum.map_reduce(contracts, [], fn contract, fallbacks ->
        {player, player_fallbacks} =
          evaluate_player(
            contract,
            Shared.source_player(scenario_state, contract),
            current_season,
            modifications,
            override
          )

        {player, Enum.reverse(player_fallbacks, fallbacks)}
      end)

    team_salary = Enum.sum(Enum.map(players, & &1.discounted_remaining_salary))
    players = Enum.map(players, &with_salary_weight(&1, team_salary))

    {team_score, team_fallbacks} =
      if team_salary > 0 do
        {Enum.sum(Enum.map(players, &(&1.score * &1.salary_weight))), []}
      else
        {@neutral_score,
         [
           %{
             input: :active_contracts,
             method: :neutral_contract_optionality,
             note:
               "No active remaining salary was available to weight; Contract Optionality is neutral."
           }
         ]}
      end

    %MetricResult{
      key: :contract_optionality,
      version: ModelVersion.model_version(),
      score: team_score,
      inputs: %{
        active_contract_count: length(players),
        current_season: current_season,
        discounted_remaining_salary: team_salary
      },
      supporting: supporting(players),
      weights: %{
        player_seasons: :future_salary_share,
        players: :discounted_remaining_salary_share
      },
      fallbacks: Enum.reverse(player_fallbacks) ++ team_fallbacks,
      explanation:
        "Contract Optionality scores future team control across #{length(players)} active " <>
          "contracts, weighted by future salary within each contract and discounted " <>
          "remaining salary across the roster."
    }
  end

  @doc "Maps one `ControlProjection` contract row to AVC's optionality score."
  @spec control_score(map()) :: float()
  def control_score(%{status: :team_option}), do: 100.0
  def control_score(%{status: :non_guaranteed}), do: 95.0

  def control_score(%{status: :partially_guaranteed, guaranteed_pct: pct})
      when is_number(pct) do
    50.0 + 45.0 * (1.0 - clamp(pct, 0.0, 1.0))
  end

  def control_score(%{status: :fully_guaranteed}), do: 50.0
  def control_score(%{status: :player_option}), do: 20.0

  defp evaluate_player(contract, source_player, current_season, modifications, override) do
    player_id = fetch_any!(contract, :player_id)
    seasons = Shared.remaining_seasons(contract)
    future_seasons = Enum.filter(seasons, &(is_nil(current_season) or &1.season > current_season))
    discounted_salary = Shared.discounted_remaining_salary(seasons, override)

    cond do
      future_seasons == [] ->
        {%{
           player_id: player_id,
           name: fetch_any(contract, :name),
           score: @neutral_score,
           discounted_remaining_salary: discounted_salary,
           salary_weight: 0.0,
           expiring?: true,
           seasons: []
         }, []}

      is_nil(source_player) ->
        {neutral_player(contract, future_seasons, discounted_salary),
         [missing_projection_fallback(player_id)]}

      true ->
        labels = Enum.map(future_seasons, & &1.season)

        {control_player, control_modifications} =
          Shared.control_projection_input(source_player, contract, modifications)

        controls =
          ControlProjection.project(control_player, labels, modifications: control_modifications)

        case evaluated_seasons(future_seasons, controls) do
          {:ok, evaluated} ->
            {%{
               player_id: player_id,
               name: fetch_any(contract, :name),
               score: salary_weighted_score(evaluated),
               discounted_remaining_salary: discounted_salary,
               salary_weight: 0.0,
               expiring?: false,
               seasons: evaluated
             }, []}

          :unavailable ->
            {neutral_player(contract, future_seasons, discounted_salary),
             [missing_projection_fallback(player_id)]}
        end
    end
  end

  defp evaluated_seasons(future_seasons, controls) do
    Enum.reduce_while(future_seasons, {:ok, []}, fn season, {:ok, evaluated} ->
      control = Map.get(controls, season.season)

      if is_map(control) and fetch_any(control, :status) in @contract_statuses do
        row = %{
          season: season.season,
          salary: season.salary,
          status: fetch_any(control, :status),
          guaranteed_pct: fetch_any(control, :guaranteed_pct),
          score: control_score(control)
        }

        {:cont, {:ok, [row | evaluated]}}
      else
        {:halt, :unavailable}
      end
    end)
    |> case do
      {:ok, evaluated} -> {:ok, Enum.reverse(evaluated)}
      :unavailable -> :unavailable
    end
  end

  defp salary_weighted_score(seasons) do
    total_salary = Enum.sum(Enum.map(seasons, & &1.salary))
    Enum.sum(Enum.map(seasons, &(&1.score * &1.salary))) / total_salary
  end

  defp neutral_player(contract, future_seasons, discounted_salary) do
    %{
      player_id: fetch_any!(contract, :player_id),
      name: fetch_any(contract, :name),
      score: @neutral_score,
      discounted_remaining_salary: discounted_salary,
      salary_weight: 0.0,
      expiring?: false,
      seasons:
        Enum.map(future_seasons, fn season ->
          %{
            season: season.season,
            salary: season.salary,
            status: :unavailable,
            guaranteed_pct: season.guaranteed_pct,
            score: @neutral_score
          }
        end)
    }
  end

  defp missing_projection_fallback(player_id) do
    %{
      input: player_id,
      method: :neutral_unavailable_control_projection,
      note:
        "Future contract control could not be matched to the shared Control Projection; " <>
          "Contract Optionality is neutral at 50."
    }
  end

  defp with_salary_weight(player, team_salary) do
    Map.put(
      player,
      :salary_weight,
      Shared.contract_salary_weight(player.discounted_remaining_salary, team_salary)
    )
  end

  defp supporting(players) do
    %{
      players: players,
      most_optional: ranked(players, :desc),
      least_optional: ranked(players, :asc),
      expiring_contract_count: Enum.count(players, & &1.expiring?)
    }
  end

  defp ranked(players, direction) do
    players
    |> Enum.sort_by(&{&1.score, &1.player_id}, direction)
    |> Enum.take(@supporting_limit)
    |> Enum.map(&Map.take(&1, [:player_id, :name, :score, :salary_weight, :expiring?]))
  end

  defp current_season(scenario_state, contracts) do
    fetch_any(scenario_state, :season) ||
      contracts
      |> Enum.flat_map(&Shared.remaining_seasons/1)
      |> Enum.map(& &1.season)
      |> Enum.min(fn -> nil end)
  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) when is_map(map) do
    case Map.fetch(map, key) do
      {:ok, value} -> value
      :error -> Map.get(map, to_string(key))
    end
  end

  defp fetch_any(_other, _key), do: nil

  defp clamp(value, low, high), do: value |> max(low) |> min(high)
end
contract_value.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health/contract_value.ex

Scores the value of the active contracts on a scenario's final roster.

Each contract season compares actual salary with the confidence-adjusted expected salary frozen in the challenge snapshot:

    surplus = adjusted_expected - actual
    ratio = surplus / max(adjusted_expected, actual)
    score = 50 + 50 * clamp(ratio, -1, 1)

The formula is intentionally authoritative over the worked examples in AVC's source document (D-05). A player's season scores use the versioned ModelVersion.contract_season_weights/0 ; the team then weights player scores by each contract's share of discounted remaining salary.

A valuation must cover every remaining contract season. If any season is unavailable, the whole player receives 50 and a fallback is recorded. This avoids silently grading only the convenient portion of a contract. Surplus and deficit totals omit neutralized players because no projected dollar difference exists for them.

Active standard rookie-scale seasons with frozen first-round provenance use the snapshot's draft-prior/market expected salary. When that provenance was unavailable at snapshot creation, the versioned structural fallback applies without inventing expected salary, surplus, or deficit. Extension seasons and non-rookie contracts remain on the ordinary expected-salary path.

score/2 reads scenario-adjusted rows from scenario_state.payroll_projection . Tests and other pure callers may instead provide a :contracts list with the same player-row shape. Dead money, cap holds, declined options, and unsigned rights are excluded by Shared.remaining_seasons/1 ; off-roster rows are excluded here.

Download source ↓

SHA-256 773ea39d980f661d1c2d7a5b174efef2adc9979222e4cbab54b94bdea0c5bbff

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.ContractValue do
  @moduledoc """
  Scores the value of the active contracts on a scenario's final roster.

  Each contract season compares actual salary with the confidence-adjusted
  expected salary frozen in the challenge snapshot:

      surplus = adjusted_expected - actual
      ratio = surplus / max(adjusted_expected, actual)
      score = 50 + 50 * clamp(ratio, -1, 1)

  The formula is intentionally authoritative over the worked examples in AVC's
  source document (D-05). A player's season scores use the versioned
  `ModelVersion.contract_season_weights/0`; the team then weights player scores
  by each contract's share of discounted remaining salary.

  A valuation must cover every remaining contract season. If any season is
  unavailable, the whole player receives 50 and a fallback is recorded. This
  avoids silently grading only the convenient portion of a contract. Surplus
  and deficit totals omit neutralized players because no projected dollar
  difference exists for them.

  Active standard rookie-scale seasons with frozen first-round provenance use
  the snapshot's draft-prior/market expected salary. When that provenance was
  unavailable at snapshot creation, the versioned structural fallback applies
  without inventing expected salary, surplus, or deficit. Extension seasons and
  non-rookie contracts remain on the ordinary expected-salary path.

  `score/2` reads scenario-adjusted rows from `scenario_state.payroll_projection`.
  Tests and other pure callers may instead provide a `:contracts` list with the
  same player-row shape. Dead money, cap holds, declined options, and unsigned
  rights are excluded by `Shared.remaining_seasons/1`; off-roster rows are
  excluded here.
  """

  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ExpectedSalary
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.Valuation.ContractClassification

  @neutral_score 50.0
  @supporting_limit 3

  @typedoc "One player's fully evaluated Contract Value contribution."
  @type player_value :: %{
          player_id: String.t(),
          name: String.t() | nil,
          score: float(),
          discounted_remaining_salary: float(),
          salary_weight: float(),
          projected_surplus: number(),
          projected_deficit: number(),
          seasons: [map()],
          valuation_available?: boolean(),
          valuation_method: atom(),
          contract_classifications: [map()]
        }

  @doc "Returns a full-precision Contract Value metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(scenario_state, %ChallengeSnapshot{} = snapshot) when is_map(scenario_state) do
    override = Map.get(scenario_state, :constants_override, %{})
    contracts = Shared.active_contracts(scenario_state)
    expected_salaries = ChallengeSnapshot.section(snapshot, :expected_salaries)
    current_season = current_season(scenario_state, contracts)
    policy_baseline = rookie_policy_baseline(snapshot)

    evaluated =
      Enum.map(contracts, fn contract ->
        classification_subject = Shared.source_player(scenario_state, contract) || contract
        player_id = fetch_any!(contract, :player_id)

        baseline_score =
          ModelVersion.rookie_contract_baseline_for_snapshot(snapshot.data, player_id)

        player_value(
          contract,
          expected_salaries,
          current_season,
          override,
          classification_subject,
          baseline_score
        )
      end)

    team_salary = Enum.sum(Enum.map(evaluated, & &1.discounted_remaining_salary))
    players = Enum.map(evaluated, &with_salary_weight(&1, team_salary))
    fallbacks = player_fallbacks(players)

    {team_score, fallbacks} =
      if team_salary > 0 do
        {Enum.reduce(players, 0.0, &(&2 + &1.score * &1.salary_weight)), fallbacks}
      else
        {@neutral_score,
         fallbacks ++
           [
             %{
               input: :active_contracts,
               method: :neutral_contract_value,
               note:
                 "No active remaining salary was available to weight; Contract Value is neutral."
             }
           ]}
      end

    %MetricResult{
      key: :contract_value,
      version: ModelVersion.model_version(),
      score: team_score,
      inputs: %{
        active_contract_count: length(players),
        discounted_remaining_salary: team_salary
      },
      supporting: supporting(players),
      weights: %{
        contract_seasons: ModelVersion.contract_season_weights(override),
        rookie_scale_fallback_baseline: policy_baseline,
        players: :discounted_remaining_salary_share
      },
      fallbacks: fallbacks,
      explanation:
        "Contract Value compares confidence-adjusted market salary with actual salary " <>
          "across #{length(players)} active contracts, weighted by discounted remaining salary."
    }
  end

  @doc """
  Evaluates one projection/contract row against frozen expected salaries.

  The returned map deliberately remains public: Contract Mobility reuses its
  `:score` as AVC's marketability component instead of reimplementing Contract
  Value.
  """
  @spec player_value(map(), map()) :: player_value()
  def player_value(contract, expected_salaries, override \\ %{})
      when is_map(contract) and is_map(expected_salaries) do
    current_season =
      contract
      |> Shared.remaining_seasons()
      |> List.first()
      |> case do
        %{season: season} -> season
        _other -> nil
      end

    classification_subject = fetch_any(contract, :player) || contract

    player_value(
      contract,
      expected_salaries,
      current_season,
      override,
      classification_subject,
      current_policy_baseline(classification_subject, override)
    )
  end

  @doc false
  @spec player_value(map(), map(), String.t() | nil, map(), map(), number() | nil) ::
          player_value()
  def player_value(
        contract,
        expected_salaries,
        current_season,
        override,
        classification_subject,
        baseline_score
      )
      when is_map(contract) and is_map(expected_salaries) and is_map(classification_subject) do
    player_id = fetch_any!(contract, :player_id)
    seasons = Shared.remaining_seasons(contract)
    discounted_salary = Shared.discounted_remaining_salary(seasons, override)

    case evaluated_seasons(
           seasons,
           expected_salaries,
           player_id,
           classification_subject,
           baseline_score
         ) do
      {:ok, evaluated} ->
        %{
          player_id: player_id,
          name: fetch_any(contract, :name),
          score: weighted_player_score(evaluated, override),
          discounted_remaining_salary: discounted_salary,
          salary_weight: 0.0,
          projected_surplus: sum_positive_surplus(evaluated),
          projected_deficit: sum_deficit(evaluated),
          seasons: evaluated,
          valuation_available?: true,
          valuation_method: valuation_method(evaluated),
          contract_classifications: contract_classifications(evaluated)
        }

      :unavailable ->
        classifications =
          classify_seasons(seasons, classification_subject, current_season)

        %{
          player_id: player_id,
          name: fetch_any(contract, :name),
          score: @neutral_score,
          discounted_remaining_salary: discounted_salary,
          salary_weight: 0.0,
          projected_surplus: 0,
          projected_deficit: 0,
          seasons: neutral_seasons(seasons, classifications),
          valuation_available?: false,
          valuation_method: :neutral_unavailable_expected_salary,
          contract_classifications: classifications
        }
    end
  end

  @doc "Applies AVC's bounded per-season Contract Value formula."
  @spec season_score(number(), number()) :: float()
  def season_score(actual_salary, adjusted_expected)
      when is_number(actual_salary) and actual_salary >= 0 and
             is_number(adjusted_expected) and adjusted_expected >= 0 do
    denominator = max(actual_salary, adjusted_expected)

    ratio =
      if denominator == 0 do
        0.0
      else
        (adjusted_expected - actual_salary) / denominator
      end

    50.0 + 50.0 * clamp(ratio, -1.0, 1.0)
  end

  defp evaluated_seasons(
         seasons,
         expected_salaries,
         player_id,
         classification_subject,
         baseline_score
       ) do
    seasons
    |> Enum.reduce_while({:ok, []}, fn season, {:ok, evaluated} ->
      classification = ContractClassification.classify(classification_subject, season.season)

      if is_number(baseline_score) and classification.active_standard_rookie_scale? do
        value = %{
          season: season.season,
          salary: season.salary,
          expected: nil,
          confidence: nil,
          adjusted_expected: nil,
          surplus: 0,
          score: baseline_score,
          valuation_method: :rookie_scale_baseline,
          contract_classification: classification
        }

        {:cont, {:ok, [value | evaluated]}}
      else
        case ExpectedSalary.season_expectation(expected_salaries, player_id, season.season) do
          {:ok, %{expected: expected, confidence: confidence}} ->
            adjusted =
              Shared.confidence_adjusted_expected(season.salary, expected, confidence)

            value = %{
              season: season.season,
              salary: season.salary,
              expected: expected,
              confidence: confidence,
              adjusted_expected: adjusted,
              surplus: adjusted - season.salary,
              score: season_score(season.salary, adjusted),
              valuation_method: :expected_salary,
              contract_classification: classification
            }

            {:cont, {:ok, [value | evaluated]}}

          {:error, :unavailable} ->
            {:halt, :unavailable}
        end
      end
    end)
    |> case do
      {:ok, evaluated} -> {:ok, Enum.reverse(evaluated)}
      :unavailable -> :unavailable
    end
  end

  defp weighted_player_score([], _override), do: @neutral_score

  defp weighted_player_score(seasons, override) do
    weights = ModelVersion.contract_season_weights(override)
    final_weight = List.last(weights) || 0.0

    {weighted_score, total_weight} =
      seasons
      |> Enum.with_index()
      |> Enum.reduce({0.0, 0.0}, fn {season, index}, {score, total} ->
        weight = Enum.at(weights, index, final_weight)
        {score + season.score * weight, total + weight}
      end)

    if total_weight > 0, do: weighted_score / total_weight, else: @neutral_score
  end

  defp neutral_seasons(seasons, classifications) do
    Enum.map(seasons, fn season ->
      classification = Enum.find(classifications, &(&1.season == season.season))

      %{
        season: season.season,
        salary: season.salary,
        expected: nil,
        confidence: nil,
        adjusted_expected: nil,
        surplus: 0,
        score: @neutral_score,
        valuation_method: :neutral_unavailable_expected_salary,
        contract_classification: classification && classification.result
      }
    end)
  end

  defp with_salary_weight(player, team_salary) do
    Map.put(
      player,
      :salary_weight,
      Shared.contract_salary_weight(player.discounted_remaining_salary, team_salary)
    )
  end

  defp supporting(players) do
    %{
      players: players,
      best_contracts: ranked(players, :desc),
      worst_contracts: ranked(players, :asc),
      total_projected_surplus: Enum.sum(Enum.map(players, & &1.projected_surplus)),
      total_projected_deficit: Enum.sum(Enum.map(players, & &1.projected_deficit))
    }
  end

  defp ranked(players, direction) do
    players
    |> Enum.sort_by(&{&1.score, &1.player_id}, direction)
    |> Enum.take(@supporting_limit)
    |> Enum.map(&Map.take(&1, [:player_id, :name, :score, :salary_weight]))
  end

  defp player_fallbacks(players) do
    Enum.flat_map(players, fn player ->
      baseline_seasons =
        Enum.filter(player.seasons, &(&1.valuation_method == :rookie_scale_baseline))

      rookie =
        if player.valuation_available? and baseline_seasons != [] do
          baseline_seasons
          |> Enum.group_by(& &1.contract_classification.method)
          |> Enum.map(fn {method, classifications} ->
            baseline_score = hd(classifications).score

            %{
              input: player.player_id,
              method: :rookie_scale_baseline,
              classification_method: method,
              classification: :active_standard_rookie_scale,
              baseline_score: baseline_score,
              seasons: Enum.map(classifications, & &1.season),
              note:
                "Active standard rookie-scale seasons use the strong #{baseline_score}-point " <>
                  "structural baseline; missing production does not create projected overpayment."
            }
          end)
        else
          []
        end

      unavailable =
        if player.valuation_available? do
          []
        else
          [
            %{
              input: player.player_id,
              method: :neutral_unavailable_expected_salary,
              note:
                "Expected salary was unavailable for at least one remaining non-rookie season; " <>
                  "the player's Contract Value is neutral at 50."
            }
          ]
        end

      rookie ++ unavailable
    end)
  end

  defp valuation_method(seasons) do
    case seasons |> Enum.map(& &1.valuation_method) |> Enum.uniq() do
      [:rookie_scale_baseline] -> :rookie_scale_baseline
      [:expected_salary] -> :expected_salary
      _mixed -> :expected_salary_with_rookie_scale_baseline
    end
  end

  defp contract_classifications(seasons) do
    Enum.map(seasons, fn season ->
      %{season: season.season, result: season.contract_classification}
    end)
  end

  defp classify_seasons(seasons, subject, current_season) do
    labels =
      case seasons do
        [] when is_binary(current_season) -> [current_season]
        _other -> Enum.map(seasons, & &1.season)
      end

    Enum.map(labels, fn season ->
      %{season: season, result: ContractClassification.classify(subject, season)}
    end)
  end

  defp current_season(scenario_state, contracts) do
    fetch_any(scenario_state, :season) ||
      contracts
      |> Enum.flat_map(&Shared.remaining_seasons/1)
      |> Enum.map(& &1.season)
      |> Enum.min(fn -> nil end)
  end

  defp rookie_policy_baseline(snapshot) do
    policy = ModelVersion.rookie_contract_policy_for_snapshot(snapshot.data)
    fetch_any(policy || %{}, :baseline) || fetch_any(policy || %{}, :fallback_baseline)
  end

  defp current_policy_baseline(subject, override) do
    policy = ModelVersion.rookie_contract_policy(override)

    case TradeMachine.Valuation.RookieContractPolicy.draft_prior(subject, policy) do
      :error -> policy.fallback_baseline
      {:ok, _prior} -> nil
    end
  end

  defp sum_positive_surplus(seasons) do
    seasons
    |> Enum.map(& &1.surplus)
    |> Enum.filter(&(&1 > 0))
    |> Enum.sum()
  end

  defp sum_deficit(seasons) do
    seasons
    |> Enum.map(& &1.surplus)
    |> Enum.filter(&(&1 < 0))
    |> Enum.reduce(0, &(&2 - &1))
  end

  defp clamp(value, minimum, maximum), do: value |> max(minimum) |> min(maximum)

  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) when is_map(map) do
    case Map.fetch(map, key) do
      {:ok, value} -> value
      :error -> Map.get(map, Atom.to_string(key))
    end
  end
end
long_term_risk.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health/long_term_risk.ex

Scores dangerous future overpayments on the final roster's active contracts.

The current season is excluded. The evaluated horizon is the next four seasons in the frozen projection, matching ModelVersion.risk_season_weights/0 ; all projected caps in that horizon stay in the denominator even when the roster has no salary in one of them. This keeps the denominator from changing with contract length.

A future season's exposure is:

    max(0, actual - confidence_adjusted_expected)
    * guarantee_exposure_factor
    * future_season_weight

Player options are fully exposed, team options and explicitly non-guaranteed seasons have no exposure, and partial guarantees use their stored guarantee percentage. Expected-salary projections already include AVC's age curve, so this metric applies no second age penalty.

Active standard rookie-scale seasons instead receive the versioned strong structural baseline and contribute no projected overpayment. The shared classifier runs per season, so extension seasons and every non-rookie deal continue through the expected-salary and guarantee-exposure path.

Salary caps come from the challenge snapshot's frozen thresholds section. For snapshots created before the full horizon was frozen, missing future rows fall back to CapState 's deterministic projection derived from that same frozen current-season row. Rows marked estimated are used as-is, with a visible fallback record.

Download source ↓

SHA-256 bf3b7901c844316826ea2bb49e29e11e70772473b9fe172b10e42dbfd07edb18

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.LongTermRisk do
  @moduledoc """
  Scores dangerous future overpayments on the final roster's active contracts.

  The current season is excluded. The evaluated horizon is the next four
  seasons in the frozen projection, matching
  `ModelVersion.risk_season_weights/0`; all projected caps in that horizon stay
  in the denominator even when the roster has no salary in one of them. This
  keeps the denominator from changing with contract length.

  A future season's exposure is:

      max(0, actual - confidence_adjusted_expected)
      * guarantee_exposure_factor
      * future_season_weight

  Player options are fully exposed, team options and explicitly
  non-guaranteed seasons have no exposure, and partial guarantees use their
  stored guarantee percentage. Expected-salary projections already include
  AVC's age curve, so this metric applies no second age penalty.

  Active standard rookie-scale seasons instead receive the versioned strong
  structural baseline and contribute no projected overpayment. The shared
  classifier runs per season, so extension seasons and every non-rookie deal
  continue through the expected-salary and guarantee-exposure path.

  Salary caps come from the challenge snapshot's frozen `thresholds` section.
  For snapshots created before the full horizon was frozen, missing future rows
  fall back to `CapState`'s deterministic projection derived from that same
  frozen current-season row. Rows marked `estimated` are used as-is, with a
  visible fallback record.
  """

  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ExpectedSalary
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.Valuation.ContractClassification

  @neutral_player_score 50.0
  @no_risk_score 100.0
  @supporting_limit 3
  @curve [
    {0.00, 100.0},
    {0.05, 85.0},
    {0.10, 70.0},
    {0.15, 50.0},
    {0.20, 30.0},
    {0.25, 0.0}
  ]

  @doc "Returns a full-precision Long-Term Risk metric result."
  @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
  def score(scenario_state, %ChallengeSnapshot{} = snapshot) when is_map(scenario_state) do
    override = Map.get(scenario_state, :constants_override, %{})
    expected_salaries = ChallengeSnapshot.section(snapshot, :expected_salaries)
    thresholds = effective_thresholds(scenario_state, snapshot)
    horizon = future_horizon(scenario_state, thresholds, override)
    contracts = Shared.active_contracts(scenario_state)
    policy_baseline = rookie_policy_baseline(snapshot)

    {players, player_fallbacks} =
      contracts
      |> Enum.map(fn contract ->
        classification_subject = Shared.source_player(scenario_state, contract) || contract
        player_id = fetch_any!(contract, :player_id)

        baseline_score =
          ModelVersion.rookie_contract_baseline_for_snapshot(snapshot.data, player_id)

        evaluate_player(
          contract,
          classification_subject,
          horizon,
          expected_salaries,
          baseline_score
        )
      end)
      |> Enum.map_reduce([], fn {player, fallbacks}, accumulated ->
        {player, Enum.reverse(fallbacks, accumulated)}
      end)

    cap_rows = Enum.map(horizon, &cap_row!(&1, thresholds))
    denominator = Enum.sum(Enum.map(cap_rows, & &1.salary_cap))
    at_risk_dollars = Enum.sum(Enum.map(players, & &1.exposure))
    burden = if denominator > 0, do: at_risk_dollars / denominator, else: 0.0
    no_future_seasons? = horizon == []

    fallbacks =
      player_fallbacks
      |> Enum.reverse()
      |> Kernel.++(estimated_cap_fallbacks(cap_rows))
      |> maybe_add_empty_horizon_fallback(no_future_seasons?)

    %MetricResult{
      key: :long_term_risk,
      version: ModelVersion.model_version(),
      score: if(no_future_seasons?, do: @no_risk_score, else: burden_score(burden)),
      inputs: %{
        future_risk_burden: burden,
        projected_cap_denominator: denominator,
        evaluated_seasons: Enum.map(horizon, & &1.season),
        active_contract_count: length(players)
      },
      supporting: supporting(players, horizon),
      weights: %{
        future_seasons: ModelVersion.risk_season_weights(override),
        rookie_scale_fallback_baseline: policy_baseline
      },
      fallbacks: fallbacks,
      explanation: explanation(no_future_seasons?, at_risk_dollars, length(horizon))
    }
  end

  @doc "Maps AVC's Future Risk Burden curve to a 0–100 score."
  @spec burden_score(number()) :: float()
  def burden_score(burden) when is_number(burden) and burden <= 0, do: @no_risk_score
  def burden_score(burden) when is_number(burden) and burden >= 0.25, do: 0.0

  def burden_score(burden) when is_number(burden) do
    {{left_burden, left_score}, {right_burden, right_score}} =
      @curve
      |> Enum.chunk_every(2, 1, :discard)
      |> Enum.find(fn [{left, _}, {right, _}] ->
        burden >= left and burden <= right
      end)
      |> List.to_tuple()

    position = (burden - left_burden) / (right_burden - left_burden)
    left_score + position * (right_score - left_score)
  end

  @doc "Returns AVC's downside-exposure factor for one normalized contract season."
  @spec exposure_factor(map()) :: float() | nil
  def exposure_factor(season) when is_map(season) do
    option_type = season |> fetch_any(:option_type) |> normalize_option_type()
    guaranteed_pct = fetch_any(season, :guaranteed_pct)

    cond do
      option_type in ["player", "player_option"] -> 1.0
      option_type in ["team", "team_option"] -> 0.0
      is_number(guaranteed_pct) -> guaranteed_pct |> max(0.0) |> min(1.0)
      true -> nil
    end
  end

  defp future_horizon(scenario_state, thresholds, override) do
    weights = ModelVersion.risk_season_weights(override)
    current_season = fetch_any(scenario_state, :season)

    scenario_state
    |> projected_seasons(thresholds)
    |> Enum.filter(&(is_nil(current_season) or &1 > current_season))
    |> Enum.take(length(weights))
    |> Enum.with_index()
    |> Enum.map(fn {season, index} ->
      %{season: season, weight: Enum.at(weights, index)}
    end)
  end

  defp effective_thresholds(scenario_state, snapshot) do
    frozen = ChallengeSnapshot.section(snapshot, :thresholds)

    projected =
      case fetch_any(scenario_state, :cap_state) do
        cap_state when is_map(cap_state) ->
          case fetch_any(cap_state, :thresholds) do
            thresholds when is_map(thresholds) -> thresholds
            _ -> %{}
          end

        _ ->
          %{}
      end

    # A stored row is authoritative. The derived horizon fills only labels that
    # older snapshots omitted.
    Map.merge(projected, frozen)
  end

  defp projected_seasons(scenario_state, thresholds) do
    seasons =
      fetch_any(scenario_state, :seasons) ||
        scenario_state |> fetch_any(:payroll_projection) |> fetch_any(:seasons) ||
        Map.keys(thresholds)

    seasons
    |> Enum.filter(&is_binary/1)
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp evaluate_player(
         contract,
         classification_subject,
         horizon,
         expected_salaries,
         baseline_score
       ) do
    player_id = fetch_any!(contract, :player_id)
    seasons_by_label = Shared.remaining_seasons(contract) |> Map.new(&{&1.season, &1})

    {seasons, fallback_reasons} =
      Enum.map_reduce(horizon, [], fn future, fallback_reasons ->
        case Map.get(seasons_by_label, future.season) do
          nil ->
            {nil, fallback_reasons}

          season ->
            classification =
              ContractClassification.classify(classification_subject, season.season)

            case evaluate_season(
                   season,
                   future,
                   expected_salaries,
                   player_id,
                   classification,
                   baseline_score
                 ) do
              {:ok, evaluated} ->
                {evaluated, fallback_reasons}

              {:unavailable, reason} ->
                {neutral_season(season, future), [reason | fallback_reasons]}
            end
        end
      end)

    seasons = Enum.reject(seasons, &is_nil/1)
    unavailable? = fallback_reasons != []
    classifications = contract_classifications(seasons)
    rookie_baseline? = Enum.any?(seasons, &(&1.valuation_method == :rookie_scale_baseline))

    player = %{
      player_id: player_id,
      name: fetch_any(contract, :name),
      score:
        cond do
          unavailable? -> @neutral_player_score
          rookie_baseline? -> baseline_score
          true -> nil
        end,
      exposure: Enum.sum(Enum.map(seasons, & &1.exposure)),
      projected_overpayment: Enum.sum(Enum.map(seasons, & &1.projected_overpayment)),
      valuation_available?: not unavailable?,
      valuation_method: if(rookie_baseline?, do: :rookie_scale_baseline, else: :expected_salary),
      contract_classifications: classifications,
      seasons: seasons
    }

    fallbacks =
      fallback_reasons
      |> Enum.uniq()
      |> Enum.map(fn
        :expected_salary ->
          %{
            input: player_id,
            method: :neutral_unavailable_expected_salary,
            note:
              "Expected salary was unavailable for at least one future contract season; " <>
                "that season contributes no invented downside exposure and the player is recorded neutral at 50."
          }

        :guarantee ->
          %{
            input: player_id,
            method: :neutral_unknown_guarantee,
            note:
              "Guarantee data was missing for at least one future contract season; " <>
                "that season contributes no invented downside exposure and the player is recorded neutral at 50."
          }
      end)
      |> Kernel.++(rookie_baseline_fallbacks(player_id, classifications, baseline_score))

    {player, fallbacks}
  end

  defp evaluate_season(
         season,
         future,
         expected_salaries,
         player_id,
         classification,
         baseline_score
       ) do
    if is_number(baseline_score) and classification.active_standard_rookie_scale? do
      {:ok,
       %{
         season: season.season,
         salary: season.salary,
         adjusted_expected: nil,
         projected_overpayment: 0,
         exposure_factor: exposure_factor(season),
         future_weight: future.weight,
         exposure: 0,
         score: baseline_score,
         valuation_method: :rookie_scale_baseline,
         contract_classification: classification
       }}
    else
      with factor when is_number(factor) <- exposure_factor(season),
           {:ok, %{expected: expected, confidence: confidence}} <-
             ExpectedSalary.season_expectation(expected_salaries, player_id, season.season) do
        adjusted = Shared.confidence_adjusted_expected(season.salary, expected, confidence)
        overpayment = max(0, season.salary - adjusted)

        {:ok,
         %{
           season: season.season,
           salary: season.salary,
           adjusted_expected: adjusted,
           projected_overpayment: overpayment,
           exposure_factor: factor,
           future_weight: future.weight,
           exposure: overpayment * factor * future.weight,
           score: nil,
           valuation_method: :expected_salary,
           contract_classification: classification
         }}
      else
        nil ->
          {:unavailable, :guarantee}

        {:error, :unavailable} ->
          {:unavailable, :expected_salary}
      end
    end
  end

  defp neutral_season(season, future) do
    %{
      season: season.season,
      salary: season.salary,
      adjusted_expected: nil,
      projected_overpayment: 0,
      exposure_factor: exposure_factor(season),
      future_weight: future.weight,
      exposure: 0,
      score: @neutral_player_score,
      valuation_method: :neutral_unavailable_input,
      contract_classification: nil
    }
  end

  defp contract_classifications(seasons) do
    for season <- seasons, not is_nil(season.contract_classification) do
      %{season: season.season, result: season.contract_classification}
    end
  end

  defp rookie_baseline_fallbacks(player_id, classifications, baseline_score) do
    if is_number(baseline_score) do
      classifications
      |> Enum.filter(& &1.result.active_standard_rookie_scale?)
      |> Enum.group_by(& &1.result.method)
      |> Enum.map(fn {method, classified} ->
        %{
          input: player_id,
          method: :rookie_scale_baseline,
          classification_method: method,
          classification: :active_standard_rookie_scale,
          baseline_score: baseline_score,
          seasons: Enum.map(classified, & &1.season),
          note:
            "Active standard rookie-scale seasons use the strong #{baseline_score}-point " <>
              "structural baseline and contribute no projected overpayment."
        }
      end)
    else
      []
    end
  end

  defp rookie_policy_baseline(snapshot) do
    policy = ModelVersion.rookie_contract_policy_for_snapshot(snapshot.data)
    fetch_any(policy || %{}, :baseline) || fetch_any(policy || %{}, :fallback_baseline)
  end

  defp cap_row!(future, thresholds) do
    row = Map.get(thresholds, future.season)

    salary_cap = fetch_any(row, :salary_cap)

    unless is_number(salary_cap) and salary_cap > 0 do
      raise ArgumentError,
            "frozen snapshot has no positive salary cap for future season #{future.season}"
    end

    %{
      season: future.season,
      salary_cap: salary_cap,
      source: fetch_any(row, :source)
    }
  end

  defp estimated_cap_fallbacks(cap_rows) do
    for row <- cap_rows, normalize_source(row.source) == "estimated" do
      %{
        input: row.season,
        method: :snapshot_escalated_salary_cap,
        note:
          "No official threshold row was available; Long-Term Risk used the frozen cap snapshot's recorded escalation fallback."
      }
    end
  end

  defp maybe_add_empty_horizon_fallback(fallbacks, false), do: fallbacks

  defp maybe_add_empty_horizon_fallback(fallbacks, true) do
    fallbacks ++
      [
        %{
          input: :future_seasons,
          method: :no_future_risk,
          note: "The frozen projection contains no future seasons; Long-Term Risk is 100."
        }
      ]
  end

  defp supporting(players, horizon) do
    season_exposure =
      Map.new(horizon, fn future ->
        exposure =
          players
          |> Enum.flat_map(& &1.seasons)
          |> Enum.filter(&(&1.season == future.season))
          |> Enum.map(& &1.exposure)
          |> Enum.sum()

        {future.season, exposure}
      end)

    %{
      at_risk_dollars: Enum.sum(Map.values(season_exposure)),
      riskiest_season: riskiest_season(season_exposure),
      top_exposure_players: top_exposure_players(players),
      seasons: season_exposure,
      players: players
    }
  end

  defp riskiest_season(season_exposure) when map_size(season_exposure) == 0, do: nil

  defp riskiest_season(season_exposure) do
    {season, exposure} =
      season_exposure
      |> Enum.sort_by(fn {season, exposure} -> {-exposure, season} end)
      |> hd()

    %{season: season, exposure: exposure}
  end

  defp top_exposure_players(players) do
    players
    |> Enum.filter(&(&1.exposure > 0))
    |> Enum.sort_by(&{-&1.exposure, &1.player_id})
    |> Enum.take(@supporting_limit)
    |> Enum.map(&Map.take(&1, [:player_id, :name, :exposure, :projected_overpayment]))
  end

  defp explanation(true, _at_risk_dollars, _season_count) do
    "The frozen projection contains no future seasons, so there is no long-term contract risk."
  end

  defp explanation(false, at_risk_dollars, season_count) do
    "Long-Term Risk measures #{at_risk_dollars} weighted dollars of projected " <>
      "overpayment across #{season_count} frozen future salary-cap seasons."
  end

  defp normalize_option_type(value) when is_atom(value), do: Atom.to_string(value)
  defp normalize_option_type(value) when is_binary(value), do: value
  defp normalize_option_type(_value), do: nil

  defp normalize_source(value) when is_atom(value), do: Atom.to_string(value)
  defp normalize_source(value) when is_binary(value), do: value
  defp normalize_source(_value), do: nil

  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) 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
shared.exElixir

lib/trade_machine/gm_grade_v2/pillars/contract_health/shared.ex

Shared contract-season normalization and salary weighting for Contract Health.

Contract Health consumes a frozen final-roster contract snapshot rather than querying contracts or consulting the current league season. The snapshot may carry persisted contract rows under :seasons or the cap engine's scenario-adjusted rows under :entries ; both are normalized to:

    %{
      season: "2026-27",
      salary: 12_000_000,
      guaranteed: 6_000_000,
      guaranteed_pct: 0.5,
      option_type: "team"
    }

Callers must pass only the snapshot's remaining-season window. Projection entries are additionally restricted to active contract statuses, which keeps cap holds, declined options, and empty projection cells out of this pillar.

discounted_remaining_salary/1 uses TradeMachine.GMGradeV2.ModelVersion.contract_season_weights/0 in season order. The final weight repeats if a future CBA permits a contract longer than the versioned five-season window: AVC defines that band as "four or more seasons ahead", not as an unweighted sixth season.

Guarantee percentage is guaranteed / salary from the frozen ContractSeason integer fields. Missing guarantee data stays nil ; it is not silently converted to either fully guaranteed or non-guaranteed. The one projection exception is a scenario signing: the cap engine creates ordinary re-signing and external-signing years as fully guaranteed but omits that value from its payroll projection, so normalization restores salary as guaranteed dollars. Option type remains intact so player/team option rules still take precedence in each metric. Unsigned drafted-rookie rights are explicitly not scenario signings and are excluded from Contract Health. Team options and non-guaranteed seasons remain in the normalized list so each metric can apply its own AVC treatment.

Download source ↓

SHA-256 8f28853677673854b75336e989fdd9c67185cddadfc4e285973b8bab4e1dc91e

defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared do
  @moduledoc """
  Shared contract-season normalization and salary weighting for Contract Health.

  Contract Health consumes a frozen final-roster contract snapshot rather than
  querying contracts or consulting the current league season. The snapshot may
  carry persisted contract rows under `:seasons` or the cap engine's
  scenario-adjusted rows under `:entries`; both are normalized to:

      %{
        season: "2026-27",
        salary: 12_000_000,
        guaranteed: 6_000_000,
        guaranteed_pct: 0.5,
        option_type: "team"
      }

  Callers must pass only the snapshot's remaining-season window. Projection
  entries are additionally restricted to active contract statuses, which keeps
  cap holds, declined options, and empty projection cells out of this pillar.

  `discounted_remaining_salary/1` uses
  `TradeMachine.GMGradeV2.ModelVersion.contract_season_weights/0` in season
  order. The final weight repeats if a future CBA permits a contract longer
  than the versioned five-season window: AVC defines that band as "four or more
  seasons ahead", not as an unweighted sixth season.

  Guarantee percentage is `guaranteed / salary` from the frozen
  `ContractSeason` integer fields. Missing guarantee data stays `nil`; it is not
  silently converted to either fully guaranteed or non-guaranteed. The one
  projection exception is a scenario signing: the cap engine creates ordinary
  re-signing and external-signing years as fully guaranteed but omits that value
  from its payroll projection, so normalization restores salary as guaranteed
  dollars. Option type remains intact so player/team option rules still take
  precedence in each metric. Unsigned drafted-rookie rights are explicitly not
  scenario signings and are excluded from Contract Health. Team options and
  non-guaranteed seasons remain in the normalized list so each metric can apply
  its own AVC treatment.
  """

  alias TradeMachine.GMGradeV2.ExpectedSalary
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.NBA.Contract
  alias TradeMachine.NBA.ContractSeason
  alias TradeMachine.NBA.Player

  @active_projection_statuses [:contracted, :re_signed, :overridden]
  @active_projection_status_strings Enum.map(@active_projection_statuses, &Atom.to_string/1)

  @type normalized_season :: %{
          season: String.t(),
          salary: non_neg_integer(),
          guaranteed: non_neg_integer() | nil,
          guaranteed_pct: float() | nil,
          option_type: String.t() | nil
        }

  @doc """
  Returns the final roster's active contract rows.

  The payroll projection is authoritative for standard contracts and scenario
  changes. Two-way players are absent from that engine seam by design, so they
  are composed from the frozen `player_rows` seam. An explicit `:contracts`
  collection remains a complete override for pure callers and fixtures.
  """
  @spec active_contracts(map()) :: [map()]
  def active_contracts(scenario_state) when is_map(scenario_state) do
    contracts =
      case fetch_any(scenario_state, :contracts) do
        rows when is_list(rows) ->
          rows

        _other ->
          projection_contracts(scenario_state) ++ two_way_contracts(scenario_state)
      end

    Enum.filter(contracts, fn contract ->
      fetch_any(contract, :is_off_roster) != true and
        fetch_any(contract, :is_draft_pick) != true and
        remaining_seasons(contract) != []
    end)
  end

  def active_contracts(_scenario_state), do: []

  @doc """
  Identifies a final-roster scenario signing represented only by payroll output.

  Re-signings are marked directly. External signings are the non-draft
  synthetic rows created from `payroll_added_players`; drafted-rookie rows use
  the same engine container but remain unsigned rights and are not contracts.
  """
  @spec scenario_signing?(map()) :: boolean()
  def scenario_signing?(contract) when is_map(contract) do
    fetch_any(contract, :is_re_signing) == true or
      (fetch_any(contract, :is_synthetic) == true and
         fetch_any(contract, :is_draft_pick) != true)
  end

  def scenario_signing?(_contract), do: false

  @doc """
  Resolves the player record behind a final payroll contract snapshot.

  Scenario signings may have no matching `player_rows` entry, so they receive a
  minimal synthetic player that `ControlProjection` can classify once the
  projection entries are converted into a contract.
  """
  @spec source_player(map(), map()) :: map() | nil
  def source_player(scenario_state, contract) do
    player_id = fetch_any(contract, :player_id)

    scenario_state
    |> fetch_any(:player_rows)
    |> List.wrap()
    |> Enum.find_value(fn row ->
      player = fetch_any(row, :player)
      if fetch_any(player, :id) == player_id, do: player
    end)
    |> case do
      nil -> fetch_any(contract, :player) || synthetic_source_player(contract)
      player -> player
    end
  end

  @doc """
  Converts a scenario-adjusted payroll contract into `ControlProjection` input.

  External signings and re-signings exist authoritatively in payroll projection
  entries, not necessarily in the source player's persisted contract list.
  Rebuilding a synthetic contract here keeps both Contract Optionality and Core
  Continuity on the same shared translation.
  """
  @spec control_projection_input(map(), map(), map()) :: {map(), map()}
  def control_projection_input(source_player, contract, modifications) do
    seasons = remaining_seasons(contract)

    if scenario_signing?(contract) do
      synthetic_contract = %Contract{
        signing_type:
          if(fetch_any(contract, :is_re_signing) == true, do: "re_sign", else: "free_agent"),
        seasons:
          Enum.map(seasons, fn season ->
            %ContractSeason{
              season: season.season,
              salary: season.salary,
              cap_hit: season.salary,
              guaranteed: season.guaranteed,
              option_type: season.option_type
            }
          end)
      }

      player = Map.put(source_player, :contracts, [synthetic_contract])
      {player, drop_player_option_decisions(modifications, fetch_any(contract, :player_id))}
    else
      {source_player, modifications}
    end
  end

  @doc """
  Enumerates a frozen contract's remaining salary seasons in chronological order.

  Persisted contract snapshots expose `seasons`; scenario-adjusted payroll
  snapshots expose `entries`. Both atom- and string-keyed maps are accepted
  because frozen JSON documents return string keys while in-memory engine
  snapshots use atoms.
  """
  @spec remaining_seasons(map() | [map()]) :: [normalized_season()]
  def remaining_seasons(contract_snapshot)

  def remaining_seasons(rows) when is_list(rows) do
    normalize_rows(rows, :contract)
  end

  def remaining_seasons(contract_snapshot) when is_map(contract_snapshot) do
    cond do
      rows = fetch_any(contract_snapshot, :entries) ->
        normalize_rows(rows, :projection, scenario_signing?(contract_snapshot))

      rows = fetch_any(contract_snapshot, :seasons) ->
        normalize_rows(rows, :contract, false)

      true ->
        []
    end
  end

  def remaining_seasons(_contract_snapshot), do: []

  @doc """
  Returns the discounted dollars remaining on a normalized contract.

  Seasons are ordered before weighting so serialized map/list ordering cannot
  change a score. Invalid rows should already have been removed by
  `remaining_seasons/1`; this function nevertheless treats a missing or
  non-numeric salary as zero so a malformed optional row cannot crash an
  otherwise explainable grade.
  """
  @spec discounted_remaining_salary([map()], map()) :: number()
  def discounted_remaining_salary(seasons, override \\ %{})

  def discounted_remaining_salary(seasons, override) when is_list(seasons) do
    weights = ModelVersion.contract_season_weights(override)
    final_weight = List.last(weights) || 0

    seasons
    |> Enum.sort_by(&fetch_any(&1, :season))
    |> Enum.with_index()
    |> Enum.reduce(0.0, fn {season, index}, total ->
      salary = fetch_any(season, :salary)
      weight = Enum.at(weights, index, final_weight)

      total + if(is_number(salary), do: salary * weight, else: 0)
    end)
  end

  def discounted_remaining_salary(_seasons, _override), do: 0.0

  @doc """
  Returns a player's share of the team's discounted remaining salary.

  A team total of zero has no financial weight to allocate and returns `0.0`.
  The metrics that need a neutral empty-roster fallback record it at their own
  result boundary.
  """
  @spec contract_salary_weight(number(), number()) :: float()
  def contract_salary_weight(player_discounted_salary, team_discounted_salary)
      when is_number(player_discounted_salary) and is_number(team_discounted_salary) and
             team_discounted_salary > 0 do
    player_discounted_salary / team_discounted_salary
  end

  def contract_salary_weight(_player_discounted_salary, _team_discounted_salary), do: 0.0

  @doc """
  Delegates AVC's confidence blend to the shared Expected Salary service.
  """
  @spec confidence_adjusted_expected(number() | nil, number(), number()) :: integer() | nil
  def confidence_adjusted_expected(actual_salary, expected_salary, confidence) do
    ExpectedSalary.confidence_adjusted(actual_salary, expected_salary, confidence)
  end

  defp normalize_rows(rows, source), do: normalize_rows(rows, source, false)

  defp normalize_rows(rows, source, scenario_signing?) when is_list(rows) do
    rows
    |> Enum.filter(&included_row?(&1, source))
    |> Enum.map(&normalize_row(&1, source, scenario_signing?))
    |> Enum.sort_by(& &1.season)
  end

  defp normalize_rows(_rows, _source, _scenario_signing?), do: []

  defp included_row?(row, :contract) when is_map(row) do
    valid_season?(row) and positive_salary?(row)
  end

  defp included_row?(row, :projection) when is_map(row) do
    status = fetch_any(row, :status)

    valid_season?(row) and positive_salary?(row) and
      (status in @active_projection_statuses or status in @active_projection_status_strings)
  end

  defp included_row?(_row, _source), do: false

  defp valid_season?(row), do: is_binary(fetch_any(row, :season))

  defp positive_salary?(row) do
    salary = fetch_any(row, :salary)
    is_integer(salary) and salary > 0
  end

  defp normalize_row(row, source, scenario_signing?) do
    salary = fetch_any(row, :salary)
    exercised? = fetch_any(row, :option_decision) == "exercise"
    re_signed? = source == :projection and fetch_any(row, :status) in [:re_signed, "re_signed"]

    guaranteed =
      if exercised? or re_signed? or scenario_signing?,
        do: salary,
        else: fetch_any(row, :guaranteed)

    %{
      season: fetch_any(row, :season),
      salary: salary,
      guaranteed: guaranteed,
      guaranteed_pct: guaranteed_pct(guaranteed, salary),
      option_type: if(exercised?, do: nil, else: fetch_any(row, :option_type))
    }
  end

  defp projection_contracts(scenario_state) do
    scenario_state
    |> fetch_any(:payroll_projection)
    |> fetch_any(:players)
    |> List.wrap()
  end

  defp two_way_contracts(scenario_state) do
    current_season = fetch_any(scenario_state, :season)

    scenario_state
    |> fetch_any(:player_rows)
    |> List.wrap()
    |> Enum.filter(&(fetch_any(&1, :is_two_way) == true and not is_nil(fetch_any(&1, :season))))
    |> Enum.map(fn row ->
      player = fetch_any(row, :player)
      contract = fetch_any(row, :contract)

      entries =
        contract
        |> fetch_any(:seasons)
        |> List.wrap()
        |> Enum.filter(&(is_nil(current_season) or fetch_any(&1, :season) >= current_season))
        |> Enum.map(fn season ->
          %{
            season: fetch_any(season, :season),
            salary: fetch_any(season, :salary),
            guaranteed: fetch_any(season, :guaranteed),
            option_type: fetch_any(season, :option_type),
            option_decision: nil,
            status: :contracted
          }
        end)

      %{
        player_id: fetch_any(player, :id),
        name: player_name(player),
        is_two_way: true,
        is_off_roster: false,
        contract: contract,
        player: player,
        entries: entries
      }
    end)
  end

  defp player_name(player) do
    first = fetch_any(player, :first_name)
    last = fetch_any(player, :last_name)

    [first, last]
    |> Enum.filter(&(is_binary(&1) and &1 != ""))
    |> Enum.join(" ")
  end

  defp synthetic_source_player(contract) do
    if scenario_signing?(contract) do
      %Player{
        id: fetch_any(contract, :player_id),
        first_name: fetch_any(contract, :name) || "Scenario",
        last_name: "",
        position: fetch_any(contract, :position),
        years_of_service: 0,
        contracts: []
      }
    end
  end

  defp drop_player_option_decisions(modifications, player_id) do
    decisions = Map.get(modifications, "option_decisions") || %{}

    cleaned =
      decisions
      |> Map.delete(player_id)
      |> Map.delete(to_string(player_id))

    Map.put(modifications, "option_decisions", cleaned)
  end

  defp guaranteed_pct(guaranteed, salary)
       when is_integer(guaranteed) and is_integer(salary) and salary > 0 do
    guaranteed / salary
  end

  defp guaranteed_pct(_guaranteed, _salary), do: nil

  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

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

Next: Cap & flexibility →

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