defmodule TradeMachine.GMGradeV2.Grader do @moduledoc """ Pure end-to-end AVC GM grader. Pillars and plausibility retain full precision. Display rounding occurs only when `TradeMachine.GMGradeV2.Result.GradeResult.finalize/1` is called. """ alias TradeMachine.GMGradeV2.CapState alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.ComparableState alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.GMGradeV2.MoveSummary alias TradeMachine.GMGradeV2.Narrative alias TradeMachine.GMGradeV2.Plausibility alias TradeMachine.GMGradeV2.Progression alias TradeMachine.GMGradeV2.Pillars.AssetManagement alias TradeMachine.GMGradeV2.Pillars.CapFlexibility alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength alias TradeMachine.GMGradeV2.Pillars.ContractHealth alias TradeMachine.GMGradeV2.Pillars.RosterConstruction alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment alias TradeMachine.GMGradeV2.Result.GradeResult alias TradeMachine.GMGradeV2.Result.PillarResult @weight_tolerance 1.0e-9 @pillar_order [ :competitive_strength, :roster_construction, :contract_health, :cap_flexibility, :asset_management, :timeline_alignment ] @doc """ Runs all six pillar aggregators and returns an unrounded grade. The assembled state must include `:asset_metric_results`; callers that begin with a persisted scenario should use `ScenarioAssembly.grade/2`. """ @spec grade(map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t() def grade(scenario_state, %ChallengeSnapshot{} = snapshot, mandate_profile) do grade(scenario_state, snapshot, mandate_profile, []) end @doc "Grades with optional lab-only `:constants_override` values." @spec grade(map(), ChallengeSnapshot.t(), struct(), keyword()) :: GradeResult.t() def grade(scenario_state, %ChallengeSnapshot{} = snapshot, mandate_profile, opts) when is_list(opts) do scenario_state = Map.put( scenario_state, :constants_override, Keyword.get(opts, :constants_override, %{}) ) pillar_results = score_pillars(scenario_state, snapshot, mandate_profile) aggregate(pillar_results, scenario_state, snapshot, mandate_profile) end @doc """ Aggregates already-computed pillar results. This is the narrow unit-test seam for the final arithmetic; production uses `grade/3`, which always computes the six pillars. """ @spec aggregate([PillarResult.t()], map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t() def aggregate(pillar_results, scenario_state, snapshot, mandate_profile) when is_list(pillar_results) do weights = mandate_profile.pillar_weights base_score = composite_score(pillar_results, weights) moves = build_moves([scenario_state]) plausibility = scenario_state |> Plausibility.assess(snapshot, pillar_results) |> Plausibility.integrate_moves(moves) final_score = apply_plausibility(base_score, plausibility) result = %GradeResult{ model_version: ModelVersion.model_version(), mandate_key: mandate_profile.key, mandate_profile_version: mandate_profile.version, pillar_results: pillar_results, pillar_weights: weights, base_score: base_score, plausibility: plausibility, final_score: final_score, validation_status: validation_status(pillar_results, scenario_state), snapshot_refs: snapshot_refs(snapshot, mandate_profile, scenario_state) } %{result | narrative: Narrative.build(result, mandate_profile, moves)} end @doc "Applies penalty, then the minimum cap, and clamps once at full precision." @spec apply_plausibility(number(), %{penalty: number(), cap: number() | nil}) :: float() def apply_plausibility(base_score, plausibility) when is_number(base_score) do base_score |> Kernel.-(Map.fetch!(plausibility, :penalty)) |> min(Map.get(plausibility, :cap) || 100.0) |> max(0.0) |> min(100.0) |> Kernel.*(1.0) end @doc """ Computes the canonical mandate-weighted composite for six pillar results. This is also the reusable arithmetic boundary for projections that retain AVC's pillar weights while deliberately omitting the plausibility layer. """ @spec composite_score([PillarResult.t()], %{atom() => number()}) :: float() def composite_score(pillar_results, weights) when is_list(pillar_results) and is_map(weights) do assert_weights!(weights) assert_pillars!(pillar_results, weights) pillar_results |> Enum.sort_by(&pillar_order!/1) |> Enum.reduce(0.0, fn pillar, total -> total + pillar.score * Map.fetch!(weights, pillar.key) end) |> Kernel.*(1.0) end @doc """ Grades starting and final states against identical frozen evidence. The returned result is the final grade. Starting scores and pillar/metric deltas are explanatory fields and never enter the composite. """ @spec grade_pair(map(), map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t() def grade_pair(starting_state, final_state, snapshot, mandate_profile) do grade_pair(starting_state, final_state, snapshot, mandate_profile, []) end @doc """ Grades a state pair with optional lab-only overrides. The separately versioned Progression result is opt-in shadow data. Pass `progression?: true` from an explicitly authorized orchestration boundary; the canonical grade is identical either way. """ @spec grade_pair(map(), map(), ChallengeSnapshot.t(), struct(), keyword()) :: GradeResult.t() def grade_pair(starting_state, final_state, snapshot, mandate_profile, opts) when is_list(opts) do starting = grade(starting_state, snapshot, mandate_profile, opts) final = grade(final_state, snapshot, mandate_profile, opts) starting_by_key = Map.new(starting.pillar_results, &{&1.key, &1}) pillar_results = Enum.map(final.pillar_results, fn result -> starting_result = Map.fetch!(starting_by_key, result.key) %{ result | starting_score: starting_result.score, delta: result.score - starting_result.score } end) starting_scores = Map.new(starting.pillar_results, &{&1.key, &1.score}) deltas = Map.new(pillar_results, fn result -> starting_result = Map.fetch!(starting_by_key, result.key) {result.key, %{ score: result.delta, metrics: metric_deltas(starting_result, result) }} end) progression = if Keyword.get(opts, :progression?, false) do progression(starting, final, mandate_profile, opts) end result = %{ final | pillar_results: pillar_results, starting: starting_scores, deltas: deltas, progression: progression } %{ result | narrative: build_narrative(result, mandate_profile, [starting_state, final_state]) } end defp build_narrative(result, mandate_profile, states) do Narrative.build(result, mandate_profile, build_moves(states)) end defp build_moves(states) do final_state = List.last(states) || %{} final_state |> Map.get(:modifications, %{}) |> MoveSummary.build(players_by_id(states)) end defp players_by_id(states) do states |> Enum.flat_map(&Map.get(&1, :roster, [])) |> Enum.reduce(%{}, fn player, players -> case Map.get(player, :id) || Map.get(player, "id") do nil -> players id -> Map.put(players, id, player) end end) end defp progression(starting, final, mandate_profile, opts) do progression_opts = [model_override: Keyword.get(opts, :progression_override, %{})] progression_opts = case Keyword.get(opts, :progression_policy) do nil -> progression_opts policy -> Keyword.put(progression_opts, :comparison_policy, policy) end case ComparableState.project_pair(starting, final) do {:ok, comparable} -> Progression.evaluate( mandate_profile.key, comparable.baseline.score, comparable.final.score, final.final_score, progression_opts ++ [ pillar_deltas: comparable.pillar_deltas, metric_deltas: comparable.metric_deltas ] ) {:error, reason} -> Progression.unavailable( mandate_profile.key, final.final_score, reason, progression_opts ) end end defp score_pillars(state, snapshot, _mandate_profile) do unless Map.has_key?(state, :asset_metric_results) do raise ArgumentError, "Grader.grade/3 requires an assembled state with :asset_metric_results; " <> "use ScenarioAssembly.grade/2 for persisted scenarios" end cap_state = Map.get_lazy(state, :cap_state, fn -> CapState.build(state, season_start!(snapshot)) end) asset_results = Map.fetch!(state, :asset_metric_results) [ CompetitiveStrength.score(state, snapshot), RosterConstruction.score(state, snapshot), ContractHealth.score(state, snapshot), CapFlexibility.score(cap_state), AssetManagement.score(asset_results), TimelineAlignment.score(state, snapshot) ] end defp season_start!(snapshot) do snapshot |> ChallengeSnapshot.section(:season) |> Map.fetch!("start_date") |> Date.from_iso8601!() end defp assert_weights!(weights) do sum = weights |> Map.values() |> Enum.sum() if abs(sum - 1.0) > @weight_tolerance do raise ArgumentError, "mandate pillar weights sum to #{sum}, not 1.0" end end defp assert_pillars!(results, weights) do result_keys = results |> Enum.map(& &1.key) |> MapSet.new() weight_keys = weights |> Map.keys() |> MapSet.new() if length(results) != map_size(weights) or result_keys != weight_keys do raise ArgumentError, "pillar results must contain exactly #{inspect(Map.keys(weights))}; got " <> inspect(Enum.map(results, & &1.key)) end end defp validation_status(results, state) do if Enum.any?(results, & &1.provisional?) or Map.get(state, :provisional_player_ids, []) != [] do :provisional else :ok end end # `evidence_source` is `nil` for a hand-assembled state, which is the honest # answer: nothing accounted for where its evidence came from. Only # `ScenarioAssembly` can say, and it always does. defp snapshot_refs(snapshot, profile, state) do benchmark = snapshot.benchmark %{ benchmark_id: benchmark.id, benchmark_version: benchmark.version, snapshot_id: snapshot.id, model_version: ModelVersion.model_version(), model_versions: ChallengeSnapshot.section(snapshot, :model_versions), mandate_key: profile.key, mandate_profile_version: profile.version, provisional_player_ids: Map.get(state, :provisional_player_ids, []), starting_rotation_fallback: Map.get(state, :starting_rotation_fallback), evidence_source: Map.get(state, :evidence_source), live_evidence: Map.get(state, :live_evidence, []) } end defp metric_deltas(starting, final) do starting_metrics = Map.new(starting.metric_results, &{&1.key, &1.score}) Map.new(final.metric_results, fn metric -> delta = case Map.fetch(starting_metrics, metric.key) do {:ok, starting_score} -> metric.score - starting_score :error -> nil end {metric.key, delta} end) end defp pillar_order!(%PillarResult{key: key}) do case Enum.find_index(@pillar_order, &(&1 == key)) do nil -> raise ArgumentError, "unknown pillar key #{inspect(key)}" index -> index end end end