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