defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.SkillCoverage do @moduledoc """ Scores AVC's Skill Coverage metric over the entire final roster. Each player's five reliability-adjusted indexes come from `TradeMachine.GMGradeV2.SkillIndexes`. The best providers are combined with AVC's skill-specific ranked weights, then each team raw is converted to a percentile against the frozen 30-team benchmark. Per D-23, a ranked slot without a measured player index is filled with the reliability-shrunk league median of 50. The slot remains in the formula and its weight is not renormalized. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Percentile alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.SkillIndexes @team_skill_weights [ playmaking: [0.65, 0.35], shooting: [0.30, 0.25, 0.20, 0.15, 0.10], defense: [0.35, 0.30, 0.20, 0.15], rim_protection: [0.70, 0.30], rebounding: [0.50, 0.30, 0.20] ] @empty_slot_index 50.0 @average_weight 0.70 @weakest_weight 0.30 if Keyword.keys(@team_skill_weights) != SkillIndexes.skills() do raise "team skill formulas must cover exactly SkillIndexes.skills/0, in order" end if Enum.any?(@team_skill_weights, fn {_skill, weights} -> abs(Enum.sum(weights) - 1.0) > 1.0e-9 end) do raise "each team skill formula's weights must sum to 1.0" end @doc "The five AVC team skill raw formulas, in scoring order." @spec team_skill_weights() :: keyword([float()]) def team_skill_weights, do: @team_skill_weights @doc "Returns a full-precision Skill Coverage metric result." @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t() def score( %{roster: roster} = scenario_state, %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot ) when is_list(roster) do skills = Benchmark.section(benchmark, :skills) tables = Map.fetch!(skills, "player_percentiles") benchmark_arrays = Map.fetch!(skills, "metrics") override = Inputs.model_override(scenario_state, snapshot) {raw_scores, ranked_indexes, fallbacks} = team_raw_details(roster, tables, %{override: override}) skill_scores = Map.new(raw_scores, fn {skill, raw} -> array = Map.fetch!(benchmark_arrays, Atom.to_string(skill)) {skill, Percentile.score(raw, array)} end) scores = Map.values(skill_scores) average = Enum.sum(scores) / length(@team_skill_weights) weakest = Enum.min(scores) %MetricResult{ key: :skill_coverage, version: benchmark.version, score: average * @average_weight + weakest * @weakest_weight, inputs: %{ team_raw_scores: raw_scores, ranked_player_indexes: ranked_indexes, average_skill_score: average, weakest_skill_score: weakest }, supporting: skill_scores, weights: %{average: @average_weight, weakest: @weakest_weight}, fallbacks: fallbacks, explanation: "Skill Coverage gives 70% weight to the five-skill average and 30% " <> "to the weakest roster-wide capability." } end @doc """ Calculates the five pre-percentile team skill raws. This is also the benchmark builder's scoring seam, ensuring real-team arrays and challenge rosters use the same ranking, weights, and D-23 median fill. """ @spec team_raw_scores([map()], map(), map()) :: %{atom() => float()} def team_raw_scores(roster, percentile_tables, opts \\ %{}) when is_list(roster) and is_map(percentile_tables) and is_map(opts) do {raw_scores, _ranked_indexes, _fallbacks} = team_raw_details(roster, percentile_tables, opts) raw_scores end @doc false @spec team_raw_details([map()], map(), map()) :: {%{atom() => float()}, %{atom() => [float()]}, [MetricResult.fallback()]} def team_raw_details(roster, percentile_tables, opts \\ %{}) when is_list(roster) and is_map(percentile_tables) and is_map(opts) do override = Map.get(opts, :override, %{}) indexes = Enum.map(roster, &SkillIndexes.player_indexes(&1, percentile_tables, %{override: override})) {raw_scores, ranked_indexes, fallbacks} = Enum.reduce( @team_skill_weights, {%{}, %{}, []}, fn {skill, weights}, {raw_scores, ranked_indexes, fallbacks} -> measured = indexes |> Enum.map(&get_in(&1, [:skills, skill, :index])) |> Enum.filter(&is_number/1) |> Enum.sort(:desc) |> Enum.take(length(weights)) missing_count = max(0, length(weights) - length(measured)) ranked = measured ++ List.duplicate(@empty_slot_index, missing_count) raw = ranked |> Enum.zip(weights) |> Enum.reduce(0.0, fn {index, weight}, total -> total + index * weight end) skill_fallbacks = if missing_count == 0 do [] else [ %{ input: Atom.to_string(skill), method: :league_median_ranked_slot, note: "#{missing_count} unfilled #{skill} ranked #{slot_label(missing_count)} " <> "used the D-23 reliability-shrunk league median of 50." } ] end { Map.put(raw_scores, skill, raw), Map.put(ranked_indexes, skill, ranked), fallbacks ++ skill_fallbacks } end ) {raw_scores, ranked_indexes, fallbacks} end defp slot_label(1), do: "slot" defp slot_label(_count), do: "slots" end