defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage do @moduledoc """ Scores AVC's Position Coverage metric over the entire final roster. Each player contributes only at their listed position. The historical player feed's generic `"G"` display value is canonicalized to `"SG"` — the same conservative rule used by calibration, which avoids inventing primary-handler credit. Their contribution is the shared `TradeMachine.GMGradeV2.SkillIndexes.contribution_factor/2`, so the 800-minute experience curve and the two-way availability multiplier remain centralized. A position reaches full coverage at one contribution unit. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.SkillIndexes @positions ~w(PG SG SF PF C) @average_weight 0.70 @weakest_weight 0.30 @doc "Returns the five positions in AVC's stated order." @spec positions() :: [String.t()] def positions, do: @positions @doc "Normalizes an AVC position (including legacy `G`) or raises." @spec normalize_position!(String.t() | atom()) :: String.t() def normalize_position!(position) when position in @positions, do: position def normalize_position!("G"), do: "SG" def normalize_position!(position) when is_atom(position) do position |> Atom.to_string() |> String.upcase() |> normalize_position!() end def normalize_position!(position) do raise ArgumentError, "position must be one of #{Enum.join(@positions, ", ")}, got: #{inspect(position)}" end @doc "Returns a full-precision Position 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 {units, fallbacks} = coverage_units(roster, Inputs.model_override(scenario_state, snapshot)) position_scores = Map.new(units, fn {position, value} -> {position, position_score(value)} end) scores = Map.values(position_scores) average = Enum.sum(scores) / length(@positions) weakest = Enum.min(scores) %MetricResult{ key: :position_coverage, version: benchmark.version, score: average * @average_weight + weakest * @weakest_weight, inputs: %{ coverage_units: units, average_position_score: average, weakest_position_score: weakest }, supporting: position_scores, weights: %{average: @average_weight, weakest: @weakest_weight}, fallbacks: fallbacks, explanation: "Position Coverage gives 70% weight to the five-position average and 30% " <> "to the weakest listed position." } end @doc """ Sums player contribution factors at each listed position. Players with unknown season minutes receive no invented coverage credit and are reported as fallbacks. The legacy generic-guard value `G` uses the documented `SG` canonicalization; other positions outside AVC's PG/SG/SF/PF/C vocabulary are rejected rather than silently omitted. """ @spec coverage_units([map()], map()) :: {%{String.t() => float()}, [MetricResult.fallback()]} def coverage_units(roster, override \\ %{}) when is_list(roster) and is_map(override) do initial = {Map.new(@positions, &{&1, 0.0}), []} {units, fallbacks} = Enum.reduce(roster, initial, fn player, {units, fallbacks} -> position = player |> fetch_any!(:position) |> normalize_position!() case SkillIndexes.contribution_factor(player, override) do factor when is_number(factor) -> {Map.update!(units, position, &(&1 + factor)), fallbacks} nil -> fallback = %{ input: player_identifier(player), method: :missing_contribution_factor, note: "#{player_label(player)} has no season-minutes sample, so no " <> "#{position} coverage credit was invented." } {units, [fallback | fallbacks]} end end) {units, Enum.reverse(fallbacks)} end @doc "Converts coverage units to AVC's capped 0–100 individual-position score." @spec position_score(number()) :: float() def position_score(units) when is_number(units) and units >= 0 do 100.0 * min(1.0, units) end defp fetch_any!(map, key) do case Map.get(map, key, Map.get(map, Atom.to_string(key))) do nil -> raise KeyError, key: key, term: map value -> value end end defp fetch_any(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key))) defp player_identifier(player) do fetch_any(player, :id) || fetch_any(player, :player_id) || player_label(player) end defp player_label(player) do fetch_any(player, :name) || "Player #{inspect(player_identifier_without_label(player))}" end defp player_identifier_without_label(player) do fetch_any(player, :id) || fetch_any(player, :player_id) || "at #{fetch_any(player, :position)}" end end