defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive do @moduledoc """ Scores AVC's Starting Five Quality metric from the designated starters. Each starter contributes Adjusted EPM multiplied by `min(1, assigned_minutes / 28)` and by their season-availability factor (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected games). The five contributions are summed equally before conversion against the frozen `starting_five` benchmark. A starter the user expects to miss most of the season stays the designated starter and keeps their role factor: the discount is on the season value of the five names chosen, not a re-designation of who starts. Nobody is promoted into their place, because the rotation the user authored is the rotation being graded. """ 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 alias TradeMachine.GMGradeV2.RotationValidation @role_minutes 28 @doc "Returns a full-precision Starting Five Quality metric result." @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 = starter_contributions(entries, impacts, names, replacement) raw_value = sum_contributions(contributions) benchmark_values = MetricInputs.benchmark_values(benchmark, :starting_five) %MetricResult{ key: :starting_five, version: benchmark.version, score: Percentile.score(raw_value, benchmark_values), inputs: %{ raw: raw_value, designated_starter_count: Enum.count(entries, &MetricInputs.starter?/1), role_factor_minutes: @role_minutes, replacement_value: replacement, benchmark: benchmark_values }, supporting: %{contributions: contributions}, weights: %{each_starter: 1.0}, fallbacks: MetricInputs.impact_fallbacks(contributions, impacts) ++ replacement_fallbacks(contributions), explanation: explanation(contributions) } end @doc """ Calculates Starting Five Quality before percentile conversion. This is also the benchmark builder's shared formula. A designated zero-minute starter remains in the sum with a zero role factor. """ @spec raw(term(), map()) :: float() def raw(rotation, impacts) when is_map(impacts) do replacement = ModelVersion.provisional_epm().overall rotation |> MetricInputs.rotation_entries() |> starter_contributions(impacts, %{}, replacement) |> sum_contributions() end defp starter_contributions(entries, impacts, names, replacement) do starters = entries |> Enum.filter(&MetricInputs.starter?/1) |> Enum.take(RotationValidation.starter_count()) |> Enum.map(&player_contribution(&1, impacts, names)) starters ++ replacement_contributions( RotationValidation.starter_count() - length(starters), 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 starter #{slot}", assigned_minutes: @role_minutes, adjusted_epm: replacement, role_factor: 1.0, contribution: replacement, replacement?: true } end end defp sum_contributions(contributions) do Enum.reduce(contributions, 0.0, &(&2 + &1.contribution)) end defp replacement_fallbacks(contributions) do for contribution <- contributions, contribution.replacement? do %{ input: :starting_five_slot, method: :provisional_replacement, note: "#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor." } end end defp explanation(contributions) do names = Enum.map_join(contributions, ", ", & &1.name) "Starting Five Quality combines equal contributions from #{names}." end end