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