defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility.FutureFlexibility do @moduledoc """ Scores AVC's three-season Future Flexibility metric from `CapState`. Effective committed salary, dead money, and projected thresholds are consumed exactly as the cap snapshot reports them. Each future season combines the commitment and dead-money curves at 80/20, then the three seasons combine at 50/30/20. """ alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.GMGradeV2.Result.MetricResult @commitment_curve [ {0.50, 100.0}, {0.60, 90.0}, {0.70, 80.0}, {0.80, 70.0}, {0.90, 60.0}, {1.00, 50.0}, {1.10, 35.0}, {1.20, 20.0}, {1.30, 0.0} ] @dead_money_curve [ {0.00, 100.0}, {0.01, 90.0}, {0.03, 75.0}, {0.05, 55.0}, {0.08, 30.0}, {0.10, 0.0} ] @component_weights %{commitment: 0.80, dead_money: 0.20} @season_weights [0.50, 0.30, 0.20] if abs(Enum.sum(Map.values(@component_weights)) - 1.0) > 1.0e-9 or abs(Enum.sum(@season_weights) - 1.0) > 1.0e-9 do raise "Future Flexibility weights must sum to 1.0" end @doc "Returns the full-precision Future Flexibility metric result." @spec score(map()) :: MetricResult.t() def score(%{future_seasons: future_seasons}) do seasons = case future_seasons do [first, second, third] -> [first, second, third] other -> raise ArgumentError, "Future Flexibility requires exactly 3 seasons, got: #{length(other)}" end supporting = Enum.map(seasons, &evaluate_season/1) season_scores = Enum.map(supporting, & &1.score) %MetricResult{ key: :future_flexibility, version: ModelVersion.model_version(), score: combine_seasons(season_scores), inputs: %{future_season_count: length(seasons)}, supporting: %{seasons: supporting}, fallbacks: estimated_cap_fallbacks(supporting), weights: %{ commitment: @component_weights.commitment, dead_money: @component_weights.dead_money, future_seasons: @season_weights }, explanation: "Future Flexibility scores unavoidable commitments and dead money over the " <> "next three cap-state seasons, weighted 50/30/20." } end @doc "Applies AVC's piecewise-linear commitment-ratio curve." @spec commitment_score(number()) :: float() def commitment_score(ratio) when is_number(ratio) and ratio >= 0 do interpolate(ratio, @commitment_curve) end @doc "Applies AVC's piecewise-linear dead-money-ratio curve." @spec dead_money_score(number()) :: float() def dead_money_score(ratio) when is_number(ratio) and ratio >= 0 do interpolate(ratio, @dead_money_curve) end @doc "Combines one future season's commitment and dead-money scores." @spec season_score(number(), number()) :: float() def season_score(commitment, dead_money) when is_number(commitment) and is_number(dead_money) do commitment * @component_weights.commitment + dead_money * @component_weights.dead_money end @doc "Combines the next three season scores at AVC's 50/30/20 weights." @spec combine_seasons([number()]) :: float() def combine_seasons([_first, _second, _third] = scores) do @season_weights |> Enum.zip(scores) |> Enum.reduce(0.0, fn {weight, score}, total -> total + weight * score end) end defp evaluate_season(%{ season: season, thresholds: thresholds, effective_committed_salary: committed, dead_money: dead_money }) do salary_cap = Map.fetch!(thresholds, :salary_cap) commitment_ratio = committed / salary_cap dead_money_ratio = dead_money / salary_cap commitment = commitment_score(commitment_ratio) dead_money_component = dead_money_score(dead_money_ratio) %{ season: season, salary_cap: salary_cap, threshold_source: Map.get(thresholds, :source), effective_committed_salary: committed, commitment_ratio: commitment_ratio, commitment_score: commitment, dead_money: dead_money, dead_money_ratio: dead_money_ratio, dead_money_score: dead_money_component, score: season_score(commitment, dead_money_component) } end defp estimated_cap_fallbacks(seasons) do for season <- seasons, season.threshold_source == :estimated do %{ input: season.season, method: :snapshot_escalated_salary_cap, note: "No official threshold row was available; Future Flexibility used the frozen cap snapshot's recorded escalation fallback." } end end defp interpolate(value, [{minimum, score} | _rest]) when value <= minimum, do: score defp interpolate(value, points) do case Enum.chunk_every(points, 2, 1, :discard) |> Enum.find(fn [{lower, _}, {upper, _}] -> value <= upper and value >= lower end) do [{lower, lower_score}, {upper, upper_score}] -> lower_score + (upper_score - lower_score) * (value - lower) / (upper - lower) nil -> points |> List.last() |> elem(1) end end end