defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment do @moduledoc """ Aggregates AVC's four mandate-dependent Timeline Alignment metrics. A valid 240-minute rotation is authoritative input. Missing or invalid rotations produce a provisional pillar with no component scores. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.ControlProjection alias TradeMachine.GMGradeV2.CoreEvaluator alias TradeMachine.GMGradeV2.MandateProfile alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreAgeFit alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreContinuity alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.DevelopmentEnvironment alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.PrimeWindowAlignment alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.Result.PillarResult alias TradeMachine.GMGradeV2.RotationValidation alias TradeMachine.GMGradeV2.ScenarioValidation @metric_weights %{ core_age_fit: 0.30, prime_window_alignment: 0.25, core_continuity: 0.25, development_environment: 0.20 } if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do raise "Timeline Alignment metric weights must sum to 1.0" end @doc """ Scores a scenario against the challenge's frozen timeline evidence. The snapshot carries player impacts, birth dates, season start date, and mandate profile; its benchmark carries the league adjusted-EPM population. Control projections are composed from `ScenarioState`'s public player/payroll facts, while tests and other pure callers may provide precomputed projections. """ @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t() def score( scenario_state, %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot ) when is_map(scenario_state) do with {:ok, profile} <- mandate_profile(scenario_state, snapshot), {:ok, entries} <- valid_entries(scenario_state), {:ok, inputs} <- timeline_inputs(scenario_state, snapshot, benchmark, profile) do evaluate(entries, inputs, profile, benchmark.version) else {:error, {:mandate_profile, reason}} -> provisional(benchmark.version, nil, reason) {:error, reason} -> profile = case mandate_profile(scenario_state, snapshot) do {:ok, profile} -> profile _ -> nil end provisional(benchmark.version, profile, reason) end end @doc "Combines the four metric results at AVC's fixed 30/25/25/20 weights." @spec weighted_score([MetricResult.t()]) :: float() def weighted_score(results) do Enum.reduce(results, 0.0, fn result, total -> total + result.score * Map.fetch!(@metric_weights, result.key) end) end defp evaluate(entries, inputs, profile, version) do case CoreEvaluator.evaluate( entries, inputs.players, inputs.players, inputs.season_start, inputs.league_epms ) do {:ok, evaluation} -> metrics = [ CoreAgeFit.score(evaluation.core, profile), PrimeWindowAlignment.score(evaluation.core, profile), CoreContinuity.score( evaluation.core, profile, inputs.control_projection.projections ), DevelopmentEnvironment.score(evaluation.rotation_players, profile) ] |> Enum.map(&attach_evaluation_metadata(&1, evaluation, inputs.control_projection)) {strongest, weakest} = extrema(metrics) %PillarResult{ key: :timeline_alignment, version: version, score: weighted_score(metrics), metric_results: metrics, metric_weights: @metric_weights, mandate_profile: profile_echo(profile), explanation: "Timeline Alignment applies #{profile.label}'s targets; " <> "#{label(strongest.key)} is strongest and #{label(weakest.key)} is weakest.", strongest: strongest.key, weakest: weakest.key } {:error, :degenerate_core} -> provisional(version, profile, "the eligible core has zero total importance.") end end defp timeline_inputs(scenario_state, snapshot, benchmark, profile) do with {:ok, season_start} <- season_start(snapshot), {:ok, league_epms} <- league_adjusted_epms(benchmark), {:ok, control_projection} <- control_projections(scenario_state, profile) do {:ok, %{ players: ChallengeSnapshot.section(snapshot, :players), season_start: season_start, league_epms: league_epms, control_projection: control_projection }} end end defp attach_evaluation_metadata(metric, evaluation, control_projection) do metric = %{ metric | fallbacks: metric.fallbacks ++ evaluation.fallbacks, confidence: Map.merge(metric.confidence, evaluation.confidence), diagnostics: Map.put(metric.diagnostics, :core_evaluation, evaluation.diagnostics) } if metric.key == :core_continuity do %{ metric | fallbacks: metric.fallbacks ++ control_projection.fallbacks, confidence: Map.merge(metric.confidence, control_projection.confidence) } else metric end end defp valid_entries(scenario_state) do case Map.get(scenario_state, :rotation) do nil -> {:error, "the scenario has no 240-minute rotation."} rotation -> entries = MetricInputs.rotation_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} -> {:ok, normalized} {:error, errors} -> {:error, "the scenario rotation is invalid. " <> Enum.map_join(errors, " ", & &1.message)} end end end defp mandate_profile(%{mandate_profile: %MandateProfile{} = profile}, _snapshot), do: {:ok, profile} defp mandate_profile(_scenario_state, snapshot) do stored = ChallengeSnapshot.section(snapshot, :mandate_profile) case MandateProfile.from_frozen(stored) do {:ok, profile} -> {:ok, profile} _ -> {:error, {:mandate_profile, "the frozen mandate profile is incomplete or unknown."}} end end defp season_start(snapshot) do value = snapshot.data |> Map.get("season", %{}) |> Map.get("start_date") case value do %Date{} = value -> {:ok, value} value when is_binary(value) -> case Date.from_iso8601(value) do {:ok, date} -> {:ok, date} {:error, _reason} -> {:error, "the frozen season-start date is invalid."} end _ -> {:error, "the frozen season-start date is missing."} end end defp league_adjusted_epms(benchmark) do with %{} = skills <- Benchmark.section(benchmark, :skills), %{} = tables <- Map.get(skills, "player_percentiles"), values when is_list(values) and values != [] <- Map.get(tables, "adjusted_epm") do {:ok, values} else _ -> {:error, "the frozen league adjusted-EPM population is missing."} end end defp control_projections(%{control_projections: projections}, _profile) when is_map(projections) do {:ok, %{projections: projections, fallbacks: [], confidence: %{control_inputs: :complete}}} end defp control_projections(scenario_state, profile) do with {:ok, seasons} <- horizon_seasons(scenario_state, profile.continuity_horizon), contracts when is_list(contracts) <- Shared.active_contracts(scenario_state), true <- contracts != [] do contract_index = Map.new(contracts, fn contract -> {fetch_value(contract, :player_id), contract} end) {projections, fallbacks} = scenario_state |> Map.get(:roster, []) |> Enum.map_reduce([], fn roster_player, fallbacks -> player_id = Map.get(roster_player, :id) || Map.get(roster_player, "id") contract = Map.get(contract_index, player_id) case contract && Shared.source_player(scenario_state, contract) do nil -> projection = ControlProjection.project(%{id: player_id}, seasons) fallback = %{ input: player_id, method: :missing_control_input, note: "No active scenario contract could be resolved; continuity uses no-control statuses." } {{player_id, projection}, [fallback | fallbacks]} source_player -> {control_player, modifications} = Shared.control_projection_input( source_player, contract, Map.get(scenario_state, :modifications, %{}) ) projection = ControlProjection.project( control_player, seasons, modifications: modifications ) {{player_id, projection}, fallbacks} end end) fallbacks = Enum.reverse(fallbacks) {:ok, %{ projections: Map.new(projections), fallbacks: fallbacks, confidence: %{ control_inputs: if(fallbacks == [], do: :complete, else: :reduced), unresolved_control_player_count: length(fallbacks) } }} else _ -> {:error, "the scenario control-projection inputs are missing."} end end defp horizon_seasons(scenario_state, horizon) do graded_season = Map.get(scenario_state, :season) with %{seasons: seasons} when is_list(seasons) <- Map.get(scenario_state, :payroll_projection), index when is_integer(index) <- Enum.find_index(seasons, &(&1 == graded_season)), selected when length(selected) == horizon <- Enum.slice(seasons, index, horizon) do {:ok, selected} else _ -> {:error, :missing_horizon} end end defp provisional(version, profile, reason) do %PillarResult{ key: :timeline_alignment, version: version, score: 0.0, metric_results: [], metric_weights: @metric_weights, provisional?: true, mandate_profile: profile_echo(profile), explanation: "Timeline Alignment is provisional and was not calculated because #{reason}" } end defp profile_echo(nil), do: nil defp profile_echo(profile) do Map.take(Map.from_struct(profile), [ :key, :version, :ideal_core_age, :age_penalty_below, :age_penalty_above, :prime_offset_range, :continuity_horizon, :young_minute_share_range, :veteran_count_range, :young_role_range ]) end defp extrema(results), do: {Enum.max_by(results, & &1.score), Enum.min_by(results, & &1.score)} defp fetch_value(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key))) defp label(:core_age_fit), do: "Core Age Fit" defp label(:prime_window_alignment), do: "Prime Window Alignment" defp label(:core_continuity), do: "Core Continuity" defp label(:development_environment), do: "Development Environment" end