defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreContinuity do @moduledoc """ Scores control of the six-player core through a mandate-specific horizon. The input projections are normalized facts from `ControlProjection`; this module only applies AVC's shared factors and continuity weights. """ alias TradeMachine.GMGradeV2.ControlFactors alias TradeMachine.GMGradeV2.MandateProfile alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreAgeFit alias TradeMachine.GMGradeV2.Result.MetricResult @doc """ Scores control projections keyed by player id. Each projection may be either an ordered list of season evidence or a map keyed by season. Exactly the first `continuity_horizon` seasons are scored. """ @spec score([map()], MandateProfile.t(), map()) :: MetricResult.t() def score(core, %MandateProfile{} = profile, projections) when is_list(core) and is_map(projections) do factors = ControlFactors.all() players = Enum.map(core, fn player -> seasons = projections |> fetch_projection(player.player_id) |> ordered_seasons() |> Enum.take(profile.continuity_horizon) if length(seasons) != profile.continuity_horizon do raise ArgumentError, "control projection for #{inspect(player.player_id)} must cover " <> "#{profile.continuity_horizon} seasons" end horizon_table = Enum.map(seasons, fn season -> status = normalize_status(fetch!(season, :status)) Map.put(season, :control_factor, Map.fetch!(factors, status)) end) continuity = horizon_table |> Enum.reduce(0.0, &(fetch!(&1, :control_factor) + &2)) |> Kernel./(profile.continuity_horizon) |> Kernel.*(100) age_fit = CoreAgeFit.player_score(player.age, profile) timeline_raw = player.core_weight * (0.40 + 0.60 * age_fit / 100) %{ player_id: player.player_id, core_importance_value: player.core_importance_value, original_core_weight: player.core_weight, age_fit_score: age_fit, timeline_weight_raw: timeline_raw, continuity_score: continuity, horizon: horizon_table } end) timeline_total = Enum.reduce(players, 0.0, &(&1.timeline_weight_raw + &2)) players = Enum.map(players, fn player -> Map.put(player, :timeline_relevant_weight, player.timeline_weight_raw / timeline_total) end) overall = Enum.reduce(players, 0.0, &(&1.continuity_score * &1.timeline_relevant_weight + &2)) top_three = Enum.take(players, 3) top_weight = Enum.reduce(top_three, 0.0, &(&1.original_core_weight + &2)) top_end = Enum.reduce(top_three, 0.0, fn player, total -> total + player.continuity_score * player.original_core_weight / top_weight end) %MetricResult{ key: :core_continuity, version: profile.version, score: overall * 0.70 + top_end * 0.30, inputs: %{mandate: profile.key, continuity_horizon: profile.continuity_horizon}, supporting: %{ players: players, overall_core_control: overall, top_end_control: top_end }, weights: %{overall_core_control: 0.70, top_end_control: 0.30}, fallbacks: estimated_control_fallbacks(players), confidence: %{ estimated_control_seasons: Enum.sum( for player <- players do Enum.count(player.horizon, &fetch(&1, :estimated, false)) end ) }, explanation: "Core Continuity measures franchise control through the #{profile.continuity_horizon}-season mandate window." } end defp fetch_projection(projections, player_id) do Map.get(projections, player_id) || Map.fetch!(projections, to_string(player_id)) end defp ordered_seasons(projection) when is_list(projection), do: projection defp ordered_seasons(projection) when is_map(projection) do projection |> Enum.sort_by(fn {season, _evidence} -> season end) |> Enum.map(&elem(&1, 1)) end defp normalize_status(status) when is_atom(status), do: status defp normalize_status(status) when is_binary(status), do: String.to_existing_atom(status) defp fetch!(map, key) do case Map.fetch(map, key) do {:ok, value} -> value :error -> Map.fetch!(map, Atom.to_string(key)) end end defp estimated_control_fallbacks(players) do for player <- players, season <- player.horizon, fetch(season, :estimated, false) == true do %{ input: player.player_id, method: :estimated_rfa_rights, note: "Control status for #{fetch!(season, :season)} uses the documented RFA estimate." } end end defp fetch(map, key, default) do Map.get(map, key, Map.get(map, Atom.to_string(key), default)) end end