defmodule TradeMachine.GMGradeV2.MandateProfile do @moduledoc """ The six canonical, versioned GM Mandate Profiles from AVC's model (`avc-grading-system/markdown/01-gm-mandate-logic.md` and `07-pillar-6-timeline-alignment.md`). A Mandate Profile is the *only* thing a mandate controls: the six Final GM Grade pillar weights and Timeline Alignment's mandate-specific internal targets. It does **not** reach into any other pillar's formula — those stay objective and mandate-independent. Per AVC's guardrails, Timeline Alignment is fixed at 20% for every mandate; no pillar weight exceeds 40% or falls below 5%. Profiles are keyed by `{mandate_key, version}`. Challenge authoring selects a mandate and pins its profile version; the version is frozen onto the grade so results stay reproducible when the profile is later retuned. The original prototype used inferred archetypes with a different vocabulary. Those values must not be mapped here by string similarity (see DECISIONS D-02). """ @enforce_keys [ :key, :version, :label, :strategic_question, :pillar_weights, :ideal_core_age, :age_penalty_below, :age_penalty_above, :prime_offset_range, :continuity_horizon, :young_minute_share_range, :veteran_count_range, :young_role_range ] defstruct @enforce_keys @type range :: %{min: number(), max: number()} @type t :: %__MODULE__{ key: atom(), version: String.t(), label: String.t(), strategic_question: String.t(), pillar_weights: %{ competitive_strength: float(), roster_construction: float(), contract_health: float(), cap_flexibility: float(), asset_management: float(), timeline_alignment: float() }, # Ideal core age range (whole years on the season's first day). ideal_core_age: range(), # Asymmetric age-fit penalty rates (points per year outside the range). age_penalty_below: number(), age_penalty_above: number(), # Ideal Prime Timing Offset range (27.5 − age). prime_offset_range: range(), # Core Continuity horizon in seasons (includes the current season). continuity_horizon: pos_integer(), # Ideal young-player minute share of 240, as fractions (0.05 == 5%). young_minute_share_range: range(), # Ideal Established Veteran count. veteran_count_range: range(), # Ideal count of meaningful young rotation roles (≥ 8 assigned minutes). young_role_range: range() } @version "1.0.0" @keys ~w(championship_push run_it_back ascending retool selling rebuilding)a @profiles %{ {:championship_push, @version} => %{ key: :championship_push, version: @version, label: "Championship Push", strategic_question: "Did the GM create the strongest realistic championship roster possible?", pillar_weights: %{ competitive_strength: 0.35, roster_construction: 0.20, contract_health: 0.10, cap_flexibility: 0.10, asset_management: 0.05, timeline_alignment: 0.20 }, ideal_core_age: %{min: 25, max: 31}, age_penalty_below: 8, age_penalty_above: 7, prime_offset_range: %{min: -2.0, max: 1.0}, continuity_horizon: 2, young_minute_share_range: %{min: 0.05, max: 0.20}, veteran_count_range: %{min: 4, max: 8}, young_role_range: %{min: 1, max: 3} }, {:run_it_back, @version} => %{ key: :run_it_back, version: @version, label: "Run It Back", strategic_question: "Did the GM preserve what already works while making the roster more sustainable and complete?", pillar_weights: %{ competitive_strength: 0.25, roster_construction: 0.20, contract_health: 0.15, cap_flexibility: 0.15, asset_management: 0.05, timeline_alignment: 0.20 }, ideal_core_age: %{min: 24, max: 31}, age_penalty_below: 9, age_penalty_above: 10, prime_offset_range: %{min: -1.5, max: 2.0}, continuity_horizon: 3, young_minute_share_range: %{min: 0.10, max: 0.25}, veteran_count_range: %{min: 3, max: 7}, young_role_range: %{min: 1, max: 4} }, {:ascending, @version} => %{ key: :ascending, version: @version, label: "Ascending", strategic_question: "Did the GM make the young team meaningfully better without compromising its long-term trajectory?", pillar_weights: %{ competitive_strength: 0.20, roster_construction: 0.15, contract_health: 0.15, cap_flexibility: 0.15, asset_management: 0.15, timeline_alignment: 0.20 }, ideal_core_age: %{min: 21, max: 27}, age_penalty_below: 7, age_penalty_above: 12, prime_offset_range: %{min: 1.0, max: 5.0}, continuity_horizon: 4, young_minute_share_range: %{min: 0.25, max: 0.45}, veteran_count_range: %{min: 2, max: 5}, young_role_range: %{min: 3, max: 6} }, {:retool, @version} => %{ key: :retool, version: @version, label: "Retool", strategic_question: "Did the GM fix the roster's structural problems without unnecessarily destroying its competitive foundation?", pillar_weights: %{ competitive_strength: 0.15, roster_construction: 0.15, contract_health: 0.20, cap_flexibility: 0.20, asset_management: 0.10, timeline_alignment: 0.20 }, ideal_core_age: %{min: 23, max: 29}, age_penalty_below: 8, age_penalty_above: 12, prime_offset_range: %{min: 0.0, max: 3.5}, continuity_horizon: 3, young_minute_share_range: %{min: 0.20, max: 0.40}, veteran_count_range: %{min: 2, max: 5}, young_role_range: %{min: 2, max: 5} }, {:selling, @version} => %{ key: :selling, version: @version, label: "Selling", strategic_question: "Did the GM successfully convert current roster value into strong future assets?", pillar_weights: %{ competitive_strength: 0.05, roster_construction: 0.05, contract_health: 0.10, cap_flexibility: 0.20, asset_management: 0.40, timeline_alignment: 0.20 }, ideal_core_age: %{min: 19, max: 25}, age_penalty_below: 5, age_penalty_above: 15, prime_offset_range: %{min: 2.0, max: 6.0}, continuity_horizon: 4, young_minute_share_range: %{min: 0.35, max: 0.60}, veteran_count_range: %{min: 1, max: 4}, young_role_range: %{min: 4, max: 8} }, {:rebuilding, @version} => %{ key: :rebuilding, version: @version, label: "Rebuilding", strategic_question: "Did the GM create a credible foundation for the franchise's next competitive era?", pillar_weights: %{ competitive_strength: 0.05, roster_construction: 0.15, contract_health: 0.15, cap_flexibility: 0.15, asset_management: 0.30, timeline_alignment: 0.20 }, ideal_core_age: %{min: 19, max: 25}, age_penalty_below: 4, age_penalty_above: 14, prime_offset_range: %{min: 2.5, max: 7.0}, continuity_horizon: 5, young_minute_share_range: %{min: 0.40, max: 0.65}, veteran_count_range: %{min: 2, max: 4}, young_role_range: %{min: 4, max: 8} } } # Compile-time guardrail assertions: every profile's six weights sum to 1.0, # Timeline Alignment is fixed at 20%, and no weight breaches the 5%/40% guard. for {{key, version}, profile} <- @profiles do sum = profile.pillar_weights |> Map.values() |> Enum.sum() if abs(sum - 1.0) > 1.0e-9 do raise "MandateProfile #{key}/#{version} pillar weights sum to #{sum}, not 1.0" end if profile.pillar_weights.timeline_alignment != 0.20 do raise "MandateProfile #{key}/#{version} Timeline Alignment weight must be 0.20" end for {pillar, w} <- profile.pillar_weights do if w < 0.05 - 1.0e-9 or w > 0.40 + 1.0e-9 do raise "MandateProfile #{key}/#{version} #{pillar} weight #{w} breaches the 5%–40% guardrail" end end end @doc "The current default profile version." @spec current_version() :: String.t() def current_version, do: @version @doc "All canonical mandate keys." @spec keys() :: [atom()] def keys, do: @keys @doc "All profile versions present in the registry." @spec versions() :: [String.t()] def versions, do: @profiles |> Map.keys() |> Enum.map(&elem(&1, 1)) |> Enum.uniq() @doc "Fetches a profile, raising if the `{key, version}` pair is unknown." @spec get!(atom(), String.t()) :: t() def get!(key, version \\ @version) do case Map.fetch(@profiles, {key, version}) do {:ok, data} -> struct!(__MODULE__, data) :error -> raise ArgumentError, "unknown mandate profile #{inspect(key)}/#{inspect(version)}" end end @doc "Fetches a profile, returning `nil` for an unknown `{key, version}` pair." @spec get(atom(), String.t()) :: t() | nil def get(key, version \\ @version) do case Map.get(@profiles, {key, version}) do nil -> nil data -> struct!(__MODULE__, data) end end @doc """ Rehydrates the profile copy stored in a challenge snapshot. Fields omitted by older snapshots retain the named registry version's value; fields present in the frozen document win. This keeps the snapshot copy, not the mutable registry, authoritative at grading time. """ @spec from_frozen(map()) :: {:ok, t()} | {:error, :invalid_profile} def from_frozen(stored) when is_map(stored) do with {:ok, key_value} <- fetch(stored, :key), {:ok, key} <- mandate_key(key_value), {:ok, version} <- fetch(stored, :version), %__MODULE__{} = base <- get(key, version) do values = base |> Map.from_struct() |> Enum.map(fn {field, default} -> value = stored |> Map.get(field, Map.get(stored, Atom.to_string(field))) |> case do nil -> default frozen -> frozen end {field, normalize_frozen_value(field, value)} end) |> Map.new() {:ok, struct!(__MODULE__, values)} else _ -> {:error, :invalid_profile} end rescue _error in [ArgumentError, KeyError] -> {:error, :invalid_profile} end def from_frozen(_stored), do: {:error, :invalid_profile} @doc "All profiles at a given version, in canonical mandate order." @spec all(String.t()) :: [t()] def all(version \\ @version), do: Enum.map(@keys, &get!(&1, version)) @doc "Human label for a mandate key." @spec label(atom()) :: String.t() def label(key), do: get!(key).label defp fetch(map, key) do case Map.fetch(map, key) do {:ok, value} -> {:ok, value} :error -> Map.fetch(map, Atom.to_string(key)) end end defp mandate_key(key) when is_atom(key) do if key in @keys, do: {:ok, key}, else: :error end defp mandate_key(key) when is_binary(key) do case Enum.find(@keys, &(Atom.to_string(&1) == key)) do nil -> :error mandate -> {:ok, mandate} end end defp mandate_key(_key), do: :error defp normalize_frozen_value(field, value) when field in [ :ideal_core_age, :prime_offset_range, :young_minute_share_range, :veteran_count_range, :young_role_range ] and is_map(value) do %{ min: Map.get(value, :min, Map.get(value, "min")), max: Map.get(value, :max, Map.get(value, "max")) } end defp normalize_frozen_value(:key, value), do: normalize_key(value) defp normalize_frozen_value(:pillar_weights, value) when is_map(value) do Map.new(value, fn {key, weight} -> {normalize_key(key), weight} end) end defp normalize_frozen_value(_field, value), do: value defp normalize_key(key) when is_atom(key), do: key defp normalize_key(key) when is_binary(key), do: String.to_existing_atom(key) end