defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength do @moduledoc """ Aggregates AVC's four Competitive Strength metrics. A valid frozen 240-minute rotation is a prerequisite for every component metric. When the rotation is missing or invalid, the pillar deliberately computes none of them and returns a provisional result instead. The authoritative submission gate remains responsible for blocking submission. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.Result.PillarResult alias TradeMachine.GMGradeV2.RotationValidation alias TradeMachine.GMGradeV2.ScenarioValidation alias TradeMachine.NBA.ScenarioRotation @metric_weights %{ star_power: 0.30, starting_five: 0.25, rotation_quality: 0.30, two_way_balance: 0.15 } @metric_modules [StarPower, StartingFive, RotationQuality, TwoWayBalance] if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do raise "Competitive Strength metric weights must sum to 1.0" end @doc """ Returns the full-precision Competitive Strength pillar result. All four metrics consume the same scenario state and frozen snapshot. A missing or currently invalid rotation produces a provisional zero placeholder with no metric results; zero is not an evaluated pillar score in that state. """ @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t() def score( scenario_state, %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot ) when is_map(scenario_state) do case validate_rotation(scenario_state) do {:ok, _entries} -> metric_results = Enum.map(@metric_modules, & &1.score(scenario_state, snapshot)) {strongest, weakest} = extrema(metric_results) %PillarResult{ key: :competitive_strength, version: benchmark.version, score: weighted_score(metric_results), metric_results: metric_results, metric_weights: @metric_weights, explanation: explanation(strongest, weakest), strongest: strongest.key, weakest: weakest.key } {:error, reason} -> %PillarResult{ key: :competitive_strength, version: benchmark.version, score: 0.0, metric_results: [], metric_weights: @metric_weights, provisional?: true, explanation: "Competitive Strength is provisional and was not calculated because #{reason}" } end end defp validate_rotation(scenario_state) do rotation = Map.get(scenario_state, :rotation) if is_nil(rotation) do {:error, "the scenario has no 240-minute rotation."} else entries = ScenarioRotation.entries(rotation) roster_ids = scenario_state |> Map.get(:roster, []) |> ScenarioValidation.rotation_eligible_player_ids() injured_ids = scenario_state |> Map.get(:roster, []) |> ScenarioValidation.out_for_season_player_ids() case RotationValidation.validate(entries, roster_ids, injured_ids) do {:ok, normalized_entries} -> {:ok, normalized_entries} {:error, errors} -> details = Enum.map_join(errors, " ", & &1.message) {:error, "the scenario rotation is invalid. #{details}"} end end end defp weighted_score(metric_results) do Enum.reduce(metric_results, 0.0, fn result, total -> total + result.score * Map.fetch!(@metric_weights, result.key) end) end # Enum.max_by/min_by retain the first item on a tie, giving the published # metric order a stable tie-break without inventing a second priority table. defp extrema(metric_results) do { Enum.max_by(metric_results, & &1.score), Enum.min_by(metric_results, & &1.score) } end defp explanation( %MetricResult{key: strongest_key, score: strongest_score}, %MetricResult{key: weakest_key, score: weakest_score} ) do "Competitive Strength combines four frozen-rotation metrics; " <> "#{label(strongest_key)} is strongest at #{strongest_score}, while " <> "#{label(weakest_key)} is weakest at #{weakest_score}." end defp label(:star_power), do: "Star Power" defp label(:starting_five), do: "Starting Five Quality" defp label(:rotation_quality), do: "Rotation Quality" defp label(:two_way_balance), do: "Two-Way Balance" end