defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance do @moduledoc """ Scores AVC's Two-Way Balance metric across the full 240-minute rotation. Rotation OEPM and DEPM use the same fatigue-adjusted minutes and the same season-availability factor as Rotation Quality (`TradeMachine.GMGradeV2.Availability`), applied to both sides of a player's split so availability cannot tilt the balance it is discounting. Each raw side is converted independently against its frozen benchmark array before the weaker side receives 70% of the final score and the stronger side receives 30%. Offensive and Defensive Strength remain supporting scores. There is no combined Two-Way Balance raw benchmark because the combination happens after percentile conversion (D-43). """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Fatigue alias TradeMachine.GMGradeV2.Percentile alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.RotationValidation @lower_weight 0.70 @higher_weight 0.30 @doc "Returns a full-precision Two-Way Balance 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, [])) contributions = rotation_contributions(entries, impacts, names) raw_values = averages(contributions) offense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_oepm) defense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_depm) offensive_strength = Percentile.score(raw_values.rotation_oepm, offense_benchmark) defensive_strength = Percentile.score(raw_values.rotation_depm, defense_benchmark) {lower, higher} = min_max(offensive_strength, defensive_strength) assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes)) %MetricResult{ key: :two_way_balance, version: benchmark.version, score: lower * @lower_weight + higher * @higher_weight, inputs: %{ rotation_oepm: raw_values.rotation_oepm, rotation_depm: raw_values.rotation_depm, assigned_minutes: assigned_minutes, effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)), denominator_minutes: RotationValidation.total_minutes(), offense_benchmark: offense_benchmark, defense_benchmark: defense_benchmark }, supporting: %{ offensive_strength: offensive_strength, defensive_strength: defensive_strength, contributions: contributions }, weights: %{lower_side: @lower_weight, higher_side: @higher_weight}, fallbacks: MetricInputs.impact_fallbacks( contributions, impacts, "Adjusted OEPM and DEPM", include_imputed_splits?: true ), explanation: explanation(offensive_strength, defensive_strength, assigned_minutes) } end @doc """ Calculates fatigue-adjusted Rotation OEPM and DEPM before percentile conversion. The two raw values are kept separate because Two-Way Balance combines their percentile scores, not their EPM-scale values. """ @spec raw(term(), map()) :: %{rotation_oepm: float(), rotation_depm: float()} def raw(rotation, impacts) when is_map(impacts) do rotation |> MetricInputs.rotation_entries() |> rotation_contributions(impacts, %{}) |> averages() end defp rotation_contributions(entries, impacts, names) do Enum.map(entries, fn entry -> player_id = MetricInputs.player_id(entry) assigned = MetricInputs.minutes(entry) evidence = MetricInputs.player_impact!(impacts, player_id) {availability, annotation} = MetricInputs.availability(entry, evidence) adjusted_oepm = MetricInputs.impact_number!(impacts, player_id, :adjusted_oepm) adjusted_depm = MetricInputs.impact_number!(impacts, player_id, :adjusted_depm) effective = Fatigue.effective_minutes(assigned) Map.merge( %{ player_id: player_id, name: MetricInputs.player_name(names, player_id), assigned_minutes: assigned, effective_minutes: effective, adjusted_oepm: adjusted_oepm, adjusted_depm: adjusted_depm, offensive_contribution: adjusted_oepm * effective * availability, defensive_contribution: adjusted_depm * effective * availability, replacement?: false }, annotation ) end) end defp averages(contributions) do {offense, defense} = Enum.reduce(contributions, {0.0, 0.0}, fn contribution, {offense, defense} -> { offense + contribution.offensive_contribution, defense + contribution.defensive_contribution } end) denominator = RotationValidation.total_minutes() %{rotation_oepm: offense / denominator, rotation_depm: defense / denominator} end defp min_max(first, second) when first <= second, do: {first, second} defp min_max(first, second), do: {second, first} defp explanation(offensive_strength, defensive_strength, assigned_minutes) do weaker_side = if offensive_strength <= defensive_strength, do: "offense", else: "defense" "Two-Way Balance weights the weaker #{weaker_side} side at 70% across " <> "#{assigned_minutes} assigned rotation minutes." end end