Armchair GM
2026-27 The league's active season — trades, cap positions, and scenarios are computed against it.

Documentation Start here

Mandates & weights

The strategic weighting layer, read directly from MandateProfile.

Weighting a strategic job

All six pillar weights sum to one for each mandate. The same pillar score can therefore contribute differently under Championship Push and Rebuilding. Timeline Alignment also reads mandate-specific targets, so switching mandates can change its pillar score.

contribution(pillar) = pillar_score × mandate_weight

Versioned profiles

The live table represents current profiles. Historical challenge snapshots retain the mandate profile and version used when their evidence was frozen; a later profile change is not a retroactive update to those snapshots.

Current mandate matrix

Loaded from the same registry used by the grader. Each row totals 100%.

Mandate Competitive strengthRoster constructionContract healthCap & flexibilityAsset managementTimeline alignment
Championship Push 35.0% 20.0% 10.0% 10.0% 5.0% 20.0%
Run It Back 25.0% 20.0% 15.0% 15.0% 5.0% 20.0%
Ascending 20.0% 15.0% 15.0% 15.0% 15.0% 20.0%
Retool 15.0% 15.0% 20.0% 20.0% 10.0% 20.0%
Selling 5.0% 5.0% 10.0% 20.0% 40.0% 20.0%
Rebuilding 5.0% 15.0% 15.0% 15.0% 30.0% 20.0%

Implementation

These notes and source files are captured from this deployment's code. Expand a module for its algorithm and branches, or download the Elixir file for local inspection.

mandate_profile.exElixir

lib/trade_machine/gm_grade_v2/mandate_profile.ex

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).

Download source ↓

SHA-256 2d8b4ec0de79d7629bd13eb78a5004490db2cb40f5806fac1defcf1ca23f9510

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

Teams

More

Current challenges How challenges are graded Explore the six pillars of a good GM
Browse
Team Contracts Player Contracts FA Market
App
Install app

Open trades and cap sheets straight from your home screen in their own app window.

iPhone or iPad

In Safari, open the Share menu, choose Add to Home Screen, then tap Add. Keep Open as Web App enabled if shown.

Android or desktop

Use Install Armchair GM above when available, or open your browser menu and choose Install app or Add to Home screen.

An internet connection is required for live data, trades, and scenario changes.

What's new About
Account
Sign in Tip jar Support Armchair GM