defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.FunctionalDepth do @moduledoc """ Scores AVC's Functional Depth metric over the entire final roster. Position Depth credits a player's listed position at their shared contribution factor and adjacent positions at 35% of that factor. Skill Redundancy counts contribution-factor units only for reliability-adjusted skill indexes of at least 60. Skill Concentration measures the best player's share of each skill's top-three adjusted indexes. Per D-24, concentration is neutral (50) for a skill with fewer than three measured indexes or a zero top-three sum, and every such use is recorded as a fallback. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.SkillIndexes @adjacent %{ "PG" => ["SG"], "SG" => ["PG", "SF"], "SF" => ["SG", "PF"], "PF" => ["SF", "C"], "C" => ["PF"] } @adjacent_credit 0.35 @qualified_index 60.0 @average_weight 0.70 @weakest_weight 0.30 @weights %{position_depth: 0.35, skill_redundancy: 0.40, skill_concentration: 0.25} @neutral_concentration 50.0 @doc "Returns a full-precision Functional Depth 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 tables = benchmark |> Benchmark.section(:skills) |> Map.fetch!("player_percentiles") profiles = profiles(roster, tables, Inputs.model_override(scenario_state, snapshot)) {position_depth, position_inputs, position_fallbacks} = position_depth(profiles) {skill_redundancy, redundancy_inputs} = skill_redundancy(profiles) {skill_concentration, concentration_inputs, concentration_fallbacks} = skill_concentration(profiles) supporting = %{ position_depth: position_depth, skill_redundancy: skill_redundancy, skill_concentration: skill_concentration } %MetricResult{ key: :functional_depth, version: benchmark.version, score: position_depth * @weights.position_depth + skill_redundancy * @weights.skill_redundancy + skill_concentration * @weights.skill_concentration, inputs: %{ position_depth: position_inputs, skill_redundancy: redundancy_inputs, skill_concentration: concentration_inputs }, supporting: supporting, weights: @weights, fallbacks: position_fallbacks ++ concentration_fallbacks, explanation: "Functional Depth combines position depth (35%), skill redundancy (40%), " <> "and skill concentration (25%) across the entire roster." } end @doc "Converts non-negative position-depth units through AVC's piecewise curve." @spec position_depth_score(number()) :: float() def position_depth_score(units) when is_number(units) and units >= 0 do cond do units <= 2.0 -> units * 50.0 units <= 3.0 -> 100.0 units <= 4.0 -> interpolate(units, 3.0, 100.0, 4.0, 90.0) units <= 5.0 -> interpolate(units, 4.0, 90.0, 5.0, 75.0) true -> 75.0 end end @doc "Converts non-negative qualified-provider units through AVC's redundancy curve." @spec redundancy_score(number()) :: float() def redundancy_score(units) when is_number(units) and units >= 0 do cond do units <= 1.0 -> interpolate(units, 0.0, 0.0, 1.0, 45.0) units <= 2.0 -> interpolate(units, 1.0, 45.0, 2.0, 75.0) units <= 3.0 -> interpolate(units, 2.0, 75.0, 3.0, 90.0) units <= 4.0 -> interpolate(units, 3.0, 90.0, 4.0, 100.0) true -> 100.0 end end @doc "Converts a best-player top-three share through AVC's concentration curve." @spec concentration_score(number()) :: float() def concentration_score(share) when is_number(share) and share >= 0 do cond do share <= 0.40 -> 100.0 share <= 0.45 -> interpolate(share, 0.40, 100.0, 0.45, 85.0) share <= 0.50 -> interpolate(share, 0.45, 85.0, 0.50, 70.0) share <= 0.55 -> interpolate(share, 0.50, 70.0, 0.55, 50.0) share <= 0.60 -> interpolate(share, 0.55, 50.0, 0.60, 30.0) share <= 0.65 -> interpolate(share, 0.60, 30.0, 0.65, 10.0) true -> 10.0 end end defp profiles(roster, tables, override) do Enum.map(roster, fn player -> %{ player: player, position: PositionCoverage.normalize_position!(fetch_any!(player, :position)), contribution_factor: SkillIndexes.contribution_factor(player, override), indexes: SkillIndexes.player_indexes(player, tables, %{override: override}).skills } end) end defp position_depth(profiles) do initial = Map.new(PositionCoverage.positions(), &{&1, 0.0}) {units, fallbacks} = Enum.reduce(profiles, {initial, []}, fn profile, {units, fallbacks} -> case profile.contribution_factor do factor when is_number(factor) -> units = units |> Map.update!(profile.position, &(&1 + factor)) |> add_adjacent_units(profile.position, factor) {units, fallbacks} nil -> fallback = %{ input: player_identifier(profile.player), method: :missing_contribution_factor, note: "#{player_label(profile.player)} has no season-minutes sample, so no " <> "position-depth or qualified-provider credit was invented." } {units, [fallback | fallbacks]} end end) scores = Map.new(units, fn {position, value} -> {position, position_depth_score(value)} end) {score, average, weakest} = weakest_blend(Map.values(scores)) inputs = %{ units: units, scores: scores, average_score: average, weakest_score: weakest } {score, inputs, Enum.reverse(fallbacks)} end defp skill_redundancy(profiles) do units = Map.new(SkillIndexes.skills(), fn skill -> value = Enum.reduce(profiles, 0.0, fn profile, total -> index = get_in(profile, [:indexes, skill, :index]) if is_number(index) and index >= @qualified_index and is_number(profile.contribution_factor) do total + profile.contribution_factor else total end end) {skill, value} end) scores = Map.new(units, fn {skill, value} -> {skill, redundancy_score(value)} end) {score, average, weakest} = weakest_blend(Map.values(scores)) {score, %{ qualified_index_threshold: @qualified_index, units: units, scores: scores, average_score: average, weakest_score: weakest }} end defp skill_concentration(profiles) do {shares, scores, fallbacks} = Enum.reduce(SkillIndexes.skills(), {%{}, %{}, []}, fn skill, {shares, scores, fallbacks} -> top_three = profiles |> Enum.map(&get_in(&1, [:indexes, skill, :index])) |> Enum.filter(&is_number/1) |> Enum.sort(:desc) |> Enum.take(3) total = Enum.sum(top_three) if length(top_three) < 3 or total == 0 do reason = if length(top_three) < 3, do: :fewer_than_three_indexes, else: :zero_top_three_sum fallback = %{ input: Atom.to_string(skill), method: :neutral_skill_concentration, note: "D-24 applies the neutral concentration score of 50 for #{skill}: " <> concentration_reason(reason, length(top_three)) } { Map.put(shares, skill, nil), Map.put(scores, skill, @neutral_concentration), [fallback | fallbacks] } else share = hd(top_three) / total { Map.put(shares, skill, share), Map.put(scores, skill, concentration_score(share)), fallbacks } end end) average = scores |> Map.values() |> then(&(Enum.sum(&1) / length(SkillIndexes.skills()))) {average, %{top_player_shares: shares, scores: scores}, Enum.reverse(fallbacks)} end defp add_adjacent_units(units, position, factor) do Enum.reduce(Map.fetch!(@adjacent, position), units, fn adjacent, acc -> Map.update!(acc, adjacent, &(&1 + factor * @adjacent_credit)) end) end defp weakest_blend(scores) do average = Enum.sum(scores) / length(scores) weakest = Enum.min(scores) {average * @average_weight + weakest * @weakest_weight, average, weakest} end defp interpolate(value, low_x, low_y, high_x, high_y) do low_y + (value - low_x) / (high_x - low_x) * (high_y - low_y) end 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), 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(fetch_any(player, :id) || fetch_any(player, :player_id))}" end defp concentration_reason(:fewer_than_three_indexes, count), do: "only #{count} measured player indexes were available." defp concentration_reason(:zero_top_three_sum, _count), do: "the top-three index sum was zero." end