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

Documentation Start here

Model overview

How raw evidence becomes a six-pillar GM grade.

A reference for model behavior, inputs, and limitations. Scores are evaluations under a mandate—not win probabilities or guarantees of transaction acceptance.

  1. 01 · EvidenceRoster, rotation, contracts, picks
  2. 02 · MetricsRaw values → normalized scores
  3. 03 · CompositeSix pillars × mandate weights
  4. 04 · Final gradePlausibility penalty and cap

The canonical score

Each pillar combines metric results. The selected mandate supplies the six pillar weights. The canonical composite evaluates the final scenario; starting scores and deltas are explanatory fields, not a replacement for final-state grading.

base = Σ(pillar_score × mandate_weight)
final = clamp(min(base − plausibility_penalty, score_cap or 100), 0, 100)

Two aggregation boundaries

Metric weights define a pillar. Mandate weights define the composite. Changing a mandate does not simply add an improvement bonus. Timeline Alignment is an explicit exception to mandate-independent pillar evaluation because its targets depend on the team's strategic job.

Precision and validation

The grader retains full precision through aggregation. A display value is not the stored calculation. Missing or invalid rotation evidence can produce provisional pillar placeholders; those must be interpreted with validation status, not as ordinary low scores.

Reading this reference

Weights and version identifiers below come from the running application. Implementation notes and downloadable source are bundled from the same checkout when this documentation is compiled. These pages describe this deployment, not every historical grade or a promised future model.

Source coverage and licensing

This edition publishes a partial, inspectable source bundle—not a standalone algorithm release. It contains 55 aggregation and reference modules, but not the orchestration, shared valuation dependencies, frozen benchmark tables, model artifacts, runtime stores, or upstream datasets needed to reproduce a grade independently.

The manifest names the omitted dependency groups and reports current artifact versions and calibration flags. No project license terms for reuse of this bundled code are currently stated. Visibility permits inspection; it does not by itself make the code open source or grant rights to upstream basketball data.

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.

grader.exElixir

lib/trade_machine/gm_grade_v2/grader.ex

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.

Download source ↓

SHA-256 8ed93893b8dec3125ee587d15ebf1a061e2157566f823243ad7fb9986218bd5f

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
grade_result.exElixir

lib/trade_machine/gm_grade_v2/result/grade_result.ex

The final GM Grade result for a scenario.

Holds the six PillarResult s and their mandate weights, the full-precision mandate-weighted base_score , the deterministic plausibility outcome (findings + penalty + optional cap), the full-precision final_score , and the deterministic plain-English narrative derived from those same frozen inputs.

**Rounding happens in exactly one place: finalize/1 .** It derives the integer display_score and its letter via TradeMachine.Grading.Scale , the shared rounding boundary for both GM Grade and Trade Grade. Every intermediate value stays full-precision so an independently reproduced grade agrees to the last decimal.

snapshot_refs names every frozen input version (benchmark, challenge snapshot, model + sub-model versions, mandate profile) so the grade can be reproduced after real-world data changes. Its evidence_source says whether that reproduction is actually possible: :frozen means the snapshot answered every question the grade asked, while :live_fallback means at least one input was read from the database at grade time — live_evidence names which, and a grade carrying it is only reproducible while that data holds still.

Download source ↓

SHA-256 aaa6d02e2d36dc6d830b5f985196979bc8997ca598c19fd4a886597a59402ae0

defmodule TradeMachine.GMGradeV2.Result.GradeResult do
  @moduledoc """
  The final GM Grade result for a scenario.

  Holds the six `PillarResult`s and their mandate weights, the full-precision
  mandate-weighted `base_score`, the deterministic plausibility outcome
  (findings + penalty + optional cap), the full-precision `final_score`, and
  the deterministic plain-English `narrative` derived from those same frozen
  inputs.

  **Rounding happens in exactly one place: `finalize/1`.** It derives the
  integer `display_score` and its `letter` via `TradeMachine.Grading.Scale`,
  the shared rounding boundary for both GM Grade and Trade Grade. Every
  intermediate value stays full-precision so an independently reproduced grade
  agrees to the last decimal.

  `snapshot_refs` names every frozen input version (benchmark, challenge
  snapshot, model + sub-model versions, mandate profile) so the grade can be
  reproduced after real-world data changes. Its `evidence_source` says whether
  that reproduction is actually possible: `:frozen` means the snapshot answered
  every question the grade asked, while `:live_fallback` means at least one
  input was read from the database at grade time — `live_evidence` names which,
  and a grade carrying it is only reproducible while that data holds still.
  """

  alias TradeMachine.Grading.Scale
  alias TradeMachine.GMGradeV2.Result.PillarResult
  alias TradeMachine.GMGradeV2.Result.ProgressionResult

  @enforce_keys [:model_version, :mandate_key, :mandate_profile_version]
  defstruct model_version: nil,
            mandate_key: nil,
            mandate_profile_version: nil,
            pillar_results: [],
            pillar_weights: %{},
            base_score: 0.0,
            plausibility: %{
              findings: [],
              material_findings: [],
              material_concern_count: 0,
              penalty: 0.0,
              cap: nil
            },
            final_score: 0.0,
            display_score: nil,
            letter: nil,
            validation_status: :ok,
            snapshot_refs: %{},
            starting: %{},
            deltas: %{},
            progression: nil,
            narrative: nil

  @type plausibility :: %{
          findings: [map()],
          material_findings: [map()],
          material_concern_count: non_neg_integer(),
          penalty: float(),
          cap: float() | nil
        }

  @type t :: %__MODULE__{
          model_version: String.t(),
          mandate_key: atom(),
          mandate_profile_version: String.t(),
          pillar_results: [PillarResult.t()],
          pillar_weights: %{atom() => float()},
          base_score: float(),
          plausibility: plausibility(),
          final_score: float(),
          display_score: non_neg_integer() | nil,
          letter: String.t() | nil,
          validation_status: :ok | :provisional | {:invalid, [term()]},
          snapshot_refs: map(),
          starting: map(),
          deltas: map(),
          progression: ProgressionResult.t() | nil,
          narrative: TradeMachine.GMGradeV2.Narrative.t() | nil
        }

  @doc """
  The single rounding boundary: derives `display_score` (integer) and `letter`
  from the full-precision `final_score`. Call this once, at the presentation edge.

  ## Examples

      iex> alias TradeMachine.GMGradeV2.Result.GradeResult
      iex> r = %GradeResult{model_version: "avc-1.0.0", mandate_key: :ascending,
      ...>   mandate_profile_version: "1.0.0", final_score: 76.4}
      iex> r = GradeResult.finalize(r)
      iex> {r.display_score, r.letter}
      {76, "C"}
  """
  @spec finalize(t()) :: t()
  def finalize(%__MODULE__{final_score: final} = result) do
    display = final |> round() |> Scale.clamp(0, 100)
    %{result | display_score: display, letter: Scale.letter(display)}
  end
end

Found an unexpected score? Record the model version, mandate, evidence date, and the smallest scenario that reproduces it.

Next: Mandates & weights →

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