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