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