defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.PrimeWindowAlignment do @moduledoc "Scores mandate window timing and weighted core synchronization." alias TradeMachine.GMGradeV2.MandateProfile alias TradeMachine.GMGradeV2.Result.MetricResult @prime_midpoint 27.5 @curve [{1.5, 100.0}, {2.5, 85.0}, {3.5, 65.0}, {4.5, 40.0}, {6.0, 10.0}] @doc "Returns AVC's 65/35 Prime Window Alignment metric." @spec score([map()], MandateProfile.t()) :: MetricResult.t() def score(core, %MandateProfile{} = profile) when is_list(core) do players = Enum.map(core, fn player -> offset = @prime_midpoint - player.age %{ player_id: player.player_id, age: player.age, core_weight: player.core_weight, prime_timing_offset: offset, window_timing_score: timing_score(offset, profile.prime_offset_range) } end) timing = Enum.reduce(players, 0.0, &(&1.window_timing_score * &1.core_weight + &2)) spread = weighted_population_sd(players) synchronization = synchronization_score(spread) final = combine(timing, synchronization) %MetricResult{ key: :prime_window_alignment, version: profile.version, score: final, inputs: %{mandate: profile.key, prime_midpoint: @prime_midpoint}, supporting: %{ players: players, prime_offset_range: profile.prime_offset_range, window_timing: timing, prime_timing_spread: spread, core_synchronization: synchronization }, weights: %{window_timing: 0.65, core_synchronization: 0.35}, explanation: "Prime Window Alignment combines mandate timing and core synchronization." } end @doc "Combines Window Timing and Core Synchronization at AVC's 65/35 weights." def combine(window_timing, core_synchronization) do window_timing * 0.65 + core_synchronization * 0.35 end @doc "Scores a prime offset against the selected mandate range." def timing_score(offset, %{min: minimum, max: maximum}) when is_number(offset) do distance = if offset < minimum, do: minimum - offset, else: Kernel.max(offset - maximum, 0) max(0.0, 100.0 - 15 * distance) end @doc "Maps weighted population spread onto AVC's piecewise-linear curve." def synchronization_score(spread) when spread <= 1.5, do: 100.0 def synchronization_score(spread) when spread >= 6.0, do: 10.0 def synchronization_score(spread) do [{x1, y1}, {x2, y2}] = @curve |> Enum.chunk_every(2, 1, :discard) |> Enum.find(fn [{x1, _}, {x2, _}] -> spread >= x1 and spread <= x2 end) y1 + (spread - x1) / (x2 - x1) * (y2 - y1) end defp weighted_population_sd(players) do mean = Enum.reduce(players, 0.0, &(&1.prime_timing_offset * &1.core_weight + &2)) variance = Enum.reduce(players, 0.0, fn player, total -> delta = player.prime_timing_offset - mean total + player.core_weight * delta * delta end) :math.sqrt(variance) end end