defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower do @moduledoc """ Scores AVC's Star Power metric from a frozen scenario rotation. Every player assigned more than zero minutes receives a role factor of `min(1, assigned_minutes / 30)` and their season-availability factor (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected games). The three highest resulting Adjusted EPM contributions are weighted 55/30/15. When fewer than three players are eligible, each empty slot receives the provisional replacement value at a full role factor (D-20). Ranking reads the availability-weighted contribution, so a star the user expects to miss most of the season can fall out of the top three in favour of a teammate who will actually play — which is the honest answer to "who carries this roster", and the one thing a per-game rotation could not say before. `score/2` reads both player impacts and the benchmark distribution from the challenge snapshot. `raw/2` is the shared pre-percentile calculator used by the scenario scorer and, from Phase 08 T6, the benchmark builder. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.GMGradeV2.Percentile alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs alias TradeMachine.GMGradeV2.Result.MetricResult @role_minutes 30 @weights [0.55, 0.30, 0.15] @type entry :: %{required(:player_id) => term(), required(:minutes) => number()} @type impact :: %{required(:adjusted_epm) => number()} @doc """ Returns a full-precision Star Power metric result. `scenario_state` must carry the persisted rotation and its roster. Player impact values come only from `snapshot.data["players"]`; the score is percentiled only against the snapshot's frozen `star_power` array. """ @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t() def score( %{rotation: rotation} = scenario_state, %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot ) do entries = MetricInputs.rotation_entries(rotation) impacts = ChallengeSnapshot.section(snapshot, :players) names = MetricInputs.roster_names(Map.get(scenario_state, :roster, [])) replacement = scenario_state |> Map.get(:constants_override, %{}) |> ModelVersion.provisional_epm() |> Map.fetch!(:overall) contributions = ranked_contributions(entries, impacts, names, replacement) raw_value = weighted_raw(contributions) benchmark_values = MetricInputs.benchmark_values(benchmark, :star_power) %MetricResult{ key: :star_power, version: benchmark.version, score: Percentile.score(raw_value, benchmark_values), inputs: %{ raw: raw_value, eligible_player_count: Enum.count(entries, &(entry_minutes(&1) > 0)), role_factor_minutes: @role_minutes, replacement_value: replacement, benchmark: benchmark_values }, supporting: %{contributions: contributions}, weights: %{first: 0.55, second: 0.30, third: 0.15}, fallbacks: fallbacks(contributions, impacts), explanation: explanation(contributions) } end @doc """ Calculates Star Power before percentile conversion. Accepts either a rotation value understood by `ScenarioRotation.entries/1`, a benchmark rotation carrying an `:entries` list, or the entry list itself. `impacts` is keyed by player id and may contain canonical string-keyed frozen values or in-memory atom-keyed `PlayerImpact.adjust/1` outputs. """ @spec raw(term(), %{required(term()) => impact() | map()}) :: float() def raw(rotation, impacts) when is_map(impacts) do replacement = ModelVersion.provisional_epm().overall rotation |> MetricInputs.rotation_entries() |> ranked_contributions(impacts, %{}, replacement) |> weighted_raw() end defp ranked_contributions(entries, impacts, names, replacement) do players = entries |> Enum.filter(&(entry_minutes(&1) > 0)) |> Enum.map(&player_contribution(&1, impacts, names)) |> Enum.sort_by(&{-&1.contribution, to_string(&1.player_id)}) |> Enum.take(length(@weights)) players ++ replacement_contributions(length(@weights) - length(players), replacement) end defp player_contribution(entry, impacts, names) do player_id = MetricInputs.player_id(entry) assigned = MetricInputs.minutes(entry) evidence = MetricInputs.player_impact!(impacts, player_id) {availability, annotation} = MetricInputs.availability(entry, evidence) adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm) role_factor = min(1.0, assigned / @role_minutes) Map.merge( %{ player_id: player_id, name: MetricInputs.player_name(names, player_id), assigned_minutes: assigned, adjusted_epm: adjusted_epm, role_factor: role_factor, contribution: adjusted_epm * role_factor * availability, replacement?: false }, annotation ) end defp replacement_contributions(count, replacement) do for slot <- 1..count//1 do %{ player_id: nil, name: "Replacement-level slot #{slot}", assigned_minutes: 30, adjusted_epm: replacement, role_factor: 1.0, contribution: replacement * 1.0, replacement?: true } end end defp weighted_raw(contributions) do contributions |> Enum.zip(@weights) |> Enum.reduce(0.0, fn {contribution, weight}, total -> total + contribution.contribution * weight end) end defp fallbacks(contributions, impacts) do impact_fallbacks = MetricInputs.impact_fallbacks(contributions, impacts) replacement_fallbacks = for contribution <- contributions, contribution.replacement? do %{ input: :star_power_slot, method: :provisional_replacement, note: "#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor." } end impact_fallbacks ++ replacement_fallbacks end defp explanation(contributions) do names = Enum.map_join(contributions, ", ", & &1.name) "The top three Star Power contributions come from #{names}." end defp entry_minutes(entry), do: MetricInputs.minutes(entry) end