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

Documentation Shared models

Plausibility & progression

Canonical penalties and caps, separated from shadow improvement experiments.

Canonical plausibility

Plausibility findings are assessed against frozen evidence and associated with scenario moves. The canonical grade subtracts the combined penalty and then applies the score cap. These adjustments operate after the six-pillar composite.

final = max(0, min(100, min(base − penalty, cap or 100)))

Progression is a separate experiment

The pair grader can attach a versioned Progression result when requested. It does not replace the canonical final score. Comparable State Score neutralizes transaction-path exchange evidence for starting/final comparison; it must not be confused with the canonical Asset Management calculation.

Evaluate before promotion

Treat shadow results as research. Compare directional examples, inspect outliers and data quality, and validate against held-out human judgments before changing the model users are scored by.

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.

plausibility.exElixir

lib/trade_machine/gm_grade_v2/plausibility.ex

Deterministic contract and trade plausibility for the final scenario.

Every constant in this module is an AVC v1 calibration default (D-28). Contract realism compares first-season salary with the frozen expected-salary evidence. The comparison uses:

    adjusted_expected = actual + confidence * (expected - actual)
    ratio = actual / adjusted_expected
    effective_threshold = published_threshold * confidence

Multiplying thresholds by confidence widens the acceptable range when the market estimate is uncertain. Findings originating in Asset Exchange Value are retained for explanation but are already capped there and never contribute another grade-level penalty or cap.

Download source ↓

SHA-256 ce9df3f6e8b31f01216a4a688cf87e84b4453bc18beb0d81febf4efc0c345db9

defmodule TradeMachine.GMGradeV2.Plausibility do
  @moduledoc """
  Deterministic contract and trade plausibility for the final scenario.

  Every constant in this module is an AVC v1 calibration default (D-28).
  Contract realism compares first-season salary with the frozen expected-salary
  evidence. The comparison uses:

      adjusted_expected = actual + confidence * (expected - actual)
      ratio = actual / adjusted_expected
      effective_threshold = published_threshold * confidence

  Multiplying thresholds by confidence widens the acceptable range when the
  market estimate is uncertain. Findings originating in Asset Exchange Value
  are retained for explanation but are already capped there and never contribute
  another grade-level penalty or cap.
  """

  alias TradeMachine.CBA.Exceptions
  alias TradeMachine.CBA.Rules
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ExpectedSalary
  alias TradeMachine.GMGradeV2.MaterialFinding
  alias TradeMachine.GMGradeV2.Result.PillarResult

  @unlikely_penalty 5.0
  @maximum_penalty 10.0
  @unlikely_cap 65.0
  @unrealistic_cap 40.0
  @material_severities [:aggressive, :unlikely, :unrealistic]

  @type finding :: %{
          origin: :aev_transaction | :contract | :trade,
          move_ref: String.t() | nil,
          transaction_type: atom(),
          display_label: String.t(),
          teams: [String.t()],
          assets: [map()],
          severity: atom(),
          reason_category: atom(),
          subject: term(),
          evidence: map(),
          message: String.t(),
          explanation: String.t()
        }

  @doc "Normalizes a current or historical persisted plausibility finding."
  @spec normalize_finding(map()) :: MaterialFinding.t()
  def normalize_finding(finding), do: MaterialFinding.normalize(finding)

  @doc "Matches findings to normalized moves and freezes the user-visible material subset."
  @spec integrate_moves(map(), [map()]) :: map()
  def integrate_moves(plausibility, moves) when is_map(plausibility) and is_list(moves) do
    findings =
      plausibility
      |> field(:findings, [])
      |> list()
      |> Enum.map(&match_move(&1, moves))

    material_findings = Enum.filter(findings, &material_finding?/1)

    plausibility
    |> Map.put(:findings, findings)
    |> Map.put(:material_findings, material_findings)
    |> Map.put(:material_concern_count, length(material_findings))
  end

  @doc "Returns the same material finding list used for the aggregate concern count."
  @spec material_findings(map()) :: [MaterialFinding.t()]
  def material_findings(plausibility) when is_map(plausibility) do
    case field(plausibility, :material_findings, :missing) do
      findings when is_list(findings) ->
        Enum.map(findings, &MaterialFinding.normalize/1)

      :missing ->
        plausibility
        |> field(:findings, [])
        |> list()
        |> Enum.map(&MaterialFinding.normalize/1)
        |> Enum.filter(&material_finding?/1)

      _other ->
        []
    end
  end

  def material_findings(_plausibility), do: []

  @doc "Counts exactly the material findings returned by `material_findings/1`."
  @spec material_concern_count(map()) :: non_neg_integer()
  def material_concern_count(plausibility), do: plausibility |> material_findings() |> length()

  @doc """
  Assesses the final scenario against frozen evidence.

  `pillar_results` is optional so callers assessing contract realism alone do
  not need to manufacture Asset Management output.
  """
  @spec assess(map(), ChallengeSnapshot.t(), [PillarResult.t()]) :: %{
          findings: [finding()],
          penalty: float(),
          cap: float() | nil
        }
  def assess(scenario_state, snapshot, pillar_results \\ [])

  def assess(scenario_state, %ChallengeSnapshot{} = snapshot, pillar_results)
      when is_map(scenario_state) and is_list(pillar_results) do
    findings =
      contract_findings(scenario_state, snapshot) ++ aev_findings(pillar_results)

    grade_findings = Enum.filter(findings, &(&1.origin in [:contract, :trade]))

    penalty =
      grade_findings
      |> Enum.count(&(&1.severity == :unlikely))
      |> Kernel.*(@unlikely_penalty)
      |> min(@maximum_penalty)

    cap =
      grade_findings
      |> Enum.flat_map(fn
        %{severity: :unrealistic} -> [@unrealistic_cap]
        %{severity: :unlikely} -> [@unlikely_cap]
        _ -> []
      end)
      |> minimum_cap()

    %{findings: findings, penalty: penalty, cap: cap}
  end

  defp contract_findings(scenario_state, snapshot) do
    modifications = Map.get(scenario_state, :modifications, %{}) || %{}
    season = Map.fetch!(scenario_state, :season)
    expected = ChallengeSnapshot.section(snapshot, :expected_salaries)

    signing_entries(modifications, season)
    |> Enum.map(fn signing ->
      contract_finding(
        signing,
        expected,
        veteran_minimum(scenario_state, snapshot, signing.season)
      )
    end)
  end

  defp contract_finding(signing, expected_section, veteran_minimum) do
    with true <- is_number(signing.salary) and signing.salary >= 0,
         true <- is_number(veteran_minimum) and veteran_minimum > 0,
         {:ok, expectation} <-
           ExpectedSalary.season_expectation(expected_section, signing.player_id, signing.season) do
      cond do
        expectation.confidence == 0 ->
          finding(
            :contract,
            :insufficient_evidence,
            :insufficient_evidence,
            signing,
            %{
              player_id: signing.player_id,
              season: signing.season,
              confidence: expectation.confidence
            },
            "Frozen expected-salary evidence has zero confidence for #{signing.subject}; no plausibility penalty applies."
          )

        expectation.expected <= 2 * veteran_minimum ->
          finding(
            :contract,
            :plausible,
            :minimum_salary_market,
            signing,
            %{
              exemption: :minimum_salary_market,
              actual_salary: signing.salary,
              expected_salary: expectation.expected,
              veteran_minimum: veteran_minimum
            },
            "#{signing.subject} is exempt because the expected salary is within twice the veteran minimum."
          )

        true ->
          adjusted =
            ExpectedSalary.confidence_adjusted(
              signing.salary,
              expectation.expected,
              expectation.confidence
            )

          ratio = if adjusted > 0, do: signing.salary / adjusted, else: 0.0
          severity = severity(ratio, expectation.confidence)

          finding(
            :contract,
            severity,
            :salary_below_expected,
            signing,
            %{
              player_id: signing.player_id,
              season: signing.season,
              actual_salary: signing.salary,
              expected_salary: expectation.expected,
              confidence: expectation.confidence,
              confidence_adjusted_expected: adjusted,
              ratio: ratio,
              effective_thresholds: effective_thresholds(expectation.confidence)
            },
            "#{signing.subject}'s first-season salary is #{ratio_text(ratio)} of the confidence-adjusted frozen expectation."
          )
      end
    else
      false ->
        finding(
          :contract,
          :insufficient_evidence,
          :insufficient_evidence,
          signing,
          %{player_id: signing.player_id, season: signing.season, salary: signing.salary},
          "Salary or veteran-minimum evidence is unavailable for #{signing.subject}; no plausibility penalty applies."
        )

      {:error, :unavailable} ->
        finding(
          :contract,
          :insufficient_evidence,
          :insufficient_evidence,
          signing,
          %{player_id: signing.player_id, season: signing.season},
          "Frozen expected-salary evidence is unavailable for #{signing.subject}; no plausibility penalty applies."
        )
    end
  end

  defp severity(ratio, confidence) do
    thresholds = effective_thresholds(confidence)

    cond do
      ratio >= thresholds.plausible -> :plausible
      ratio >= thresholds.aggressive -> :aggressive
      ratio >= thresholds.unlikely -> :unlikely
      true -> :unrealistic
    end
  end

  defp effective_thresholds(confidence) do
    %{
      plausible: 0.80 * confidence,
      aggressive: 0.60 * confidence,
      unlikely: 0.40 * confidence
    }
  end

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp signing_entries(modifications, season) do
    re_signings =
      modifications
      |> Map.get("re_signings", %{})
      |> Enum.map(fn {player_id, entry} ->
        signing(
          :re_signing,
          player_id,
          entry["name"] || player_id || "Unknown re-signing",
          entry["signing_season"] || List.first(Rules.future_seasons()) || season,
          entry["salary"]
        )
      end)

    one_year =
      modifications
      |> Map.get("added_players", [])
      |> Enum.map(fn entry ->
        signing(
          :signing,
          entry["id"],
          entry["name"] || entry["id"] || "Unknown signing",
          season,
          entry["salary"]
        )
      end)

    multi_year =
      modifications
      |> Map.get("payroll_added_players", [])
      |> Enum.reject(&(&1["is_draft_pick"] == true))
      |> Enum.map(fn entry ->
        signing_season = entry["signing_season"] || List.first(Rules.future_seasons()) || season
        salaries = entry["salaries"] || %{}

        signing(
          :signing,
          entry["id"],
          entry["name"] || entry["id"] || "Unknown signing",
          signing_season,
          Map.get(salaries, signing_season) || earliest_salary(salaries)
        )
      end)

    (re_signings ++ one_year ++ multi_year)
    |> Enum.sort_by(&{&1.subject, &1.player_id, &1.season})
  end

  defp signing(transaction_type, player_id, subject, season, salary) do
    %{
      transaction_type: transaction_type,
      move_ref: MaterialFinding.move_ref(transaction_type, player_id),
      player_id: player_id,
      subject: subject,
      season: season,
      salary: salary
    }
  end

  defp veteran_minimum(scenario_state, snapshot, season) do
    frozen =
      snapshot
      |> ChallengeSnapshot.section(:thresholds)
      |> threshold_minimum(season)

    if is_number(frozen) and frozen > 0 do
      frozen
    else
      scenario_state
      |> Map.get(:thresholds_by_season, %{})
      |> Map.get(season)
      |> case do
        nil -> nil
        thresholds -> Exceptions.minimum_salary(thresholds, 10)
      end
    end
  end

  defp earliest_salary(salaries) when is_map(salaries) do
    salaries
    |> Enum.filter(fn {season, salary} -> is_binary(season) and is_number(salary) end)
    |> Enum.min_by(fn {season, _salary} -> season end, fn -> {nil, nil} end)
    |> elem(1)
  end

  defp threshold_minimum(thresholds, season) do
    with season_thresholds when is_map(season_thresholds) <- Map.get(thresholds, season),
         minimums when is_map(minimums) <- Map.get(season_thresholds, "minimum_salaries"),
         values = Enum.filter(Map.values(minimums), &is_number/1),
         true <- values != [] do
      Map.get(minimums, "10") || Enum.max(values)
    else
      _ -> nil
    end
  end

  defp aev_findings(pillar_results) do
    with %PillarResult{} = pillar <-
           Enum.find(pillar_results, &(&1.key == :asset_management)),
         metric when not is_nil(metric) <-
           Enum.find(pillar.metric_results, &(&1.key == :asset_exchange_value)) do
      metric.diagnostics
      |> Map.get(:plausibility_findings, [])
      |> Enum.map(fn source ->
        transaction_id = Map.get(source, :transaction_id) || Map.get(source, "transaction_id")
        severity = Map.get(source, :severity) || Map.get(source, "severity") || :unlikely

        source
        |> Map.merge(%{
          origin: :aev_transaction,
          move_ref: MaterialFinding.move_ref(:trade, transaction_id),
          transaction_type: :trade,
          display_label: "Trade #{transaction_id}",
          severity: severity,
          reason_category: :asset_value_outlier,
          subject: transaction_id,
          evidence: source
        })
        |> MaterialFinding.normalize()
      end)
    else
      _ -> []
    end
  end

  defp match_move(finding, moves) do
    normalized = MaterialFinding.normalize(finding)

    case matching_move(normalized, moves) do
      nil ->
        normalized

      move ->
        normalized
        |> Map.put(:move_ref, field(move, :move_ref, normalized.move_ref))
        |> Map.put(:transaction_type, field(move, :transaction_type, normalized.transaction_type))
        |> Map.put(:display_label, field(move, :display_label, normalized.display_label))
        |> Map.put(:teams, preferred_context(field(move, :teams, []), normalized.teams))
        |> Map.put(:assets, preferred_context(field(move, :assets, []), normalized.assets))
        |> Map.put(:move_summary, move)
    end
  end

  defp matching_move(finding, moves) do
    exact_matches =
      case field(finding, :move_ref) do
        move_ref when is_binary(move_ref) and move_ref != "" ->
          Enum.filter(moves, &(field(&1, :move_ref) == move_ref))

        _other ->
          []
      end

    case exact_matches do
      [move] -> move
      [] -> legacy_matching_move(finding, moves)
      _ambiguous -> nil
    end
  end

  # Historical findings may predate stable move references. Match them only
  # when their recorded type plus label/subject identifies exactly one move.
  defp legacy_matching_move(finding, moves) do
    type = field(finding, :transaction_type)
    labels = [field(finding, :display_label), field(finding, :subject)]

    candidates =
      Enum.filter(moves, fn move ->
        type_matches?(type, field(move, :transaction_type)) and
          Enum.any?(labels, &same_label?(&1, field(move, :display_label)))
      end)

    case candidates do
      [move] -> move
      _other -> nil
    end
  end

  defp type_matches?(type, _move_type) when type in [nil, :unknown, "unknown"], do: true
  defp type_matches?(type, move_type), do: to_string(type) == to_string(move_type)

  defp same_label?(label, move_label) when is_binary(label) and is_binary(move_label),
    do: label != "" and String.downcase(label) == String.downcase(move_label)

  defp same_label?(_label, _move_label), do: false

  defp preferred_context(value, _fallback) when is_list(value) and value != [], do: value
  defp preferred_context(_value, fallback), do: fallback

  defp material_finding?(finding) do
    severity = field(finding, :severity)
    severity in @material_severities or severity in Enum.map(@material_severities, &to_string/1)
  end

  defp field(map, key, default \\ nil),
    do: Map.get(map, key, Map.get(map, Atom.to_string(key), default))

  defp list(value) when is_list(value), do: value
  defp list(_value), do: []

  defp finding(origin, severity, reason_category, signing, evidence, message) do
    MaterialFinding.new(%{
      origin: origin,
      move_ref: signing.move_ref,
      transaction_type: signing.transaction_type,
      display_label: signing.subject,
      teams: [],
      assets: [
        %{type: :player, id: signing.player_id, label: signing.subject}
      ],
      severity: severity,
      reason_category: reason_category,
      subject: signing.subject,
      evidence: evidence,
      message: message,
      explanation: message
    })
  end

  defp minimum_cap([]), do: nil
  defp minimum_cap(caps), do: Enum.min(caps)
  defp ratio_text(ratio), do: "#{Float.round(ratio * 100, 1)}%"
end
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
progression.exElixir

lib/trade_machine/gm_grade_v2/progression.ex

Pure evaluator for the separately versioned scenario Progression layer.

The inherited and final inputs are Comparable State Scores: canonical pillar results projected with AEV neutralized and plausibility excluded. The canonical final GM Grade is a separate input used only as the base for the bounded adjustment.

Download source ↓

SHA-256 86e37ce0adc18d5cf488740e2da1b8b904c6c3c1a63dd5013d575ac3155e2569

defmodule TradeMachine.GMGradeV2.Progression do
  @moduledoc """
  Pure evaluator for the separately versioned scenario Progression layer.

  The inherited and final inputs are Comparable State Scores: canonical pillar
  results projected with AEV neutralized and plausibility excluded. The
  canonical final GM Grade is a separate input used only as the base for the
  bounded adjustment.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.ProgressionResult

  @policies [:longitudinal, :maintenance, :episodic]

  @doc """
  Evaluates a mandate curve over two Comparable State Scores.

  Product-v0 Retool and Selling are neutral by default. A Lab may explicitly
  model Retool with `comparison_policy: :longitudinal`; Selling is always
  `:not_applicable` with an exact `1.0` multiplier.
  """
  @spec evaluate(
          atom(),
          number() | nil,
          number() | nil,
          number(),
          keyword() | map()
        ) :: ProgressionResult.t()
  def evaluate(
        mandate_key,
        baseline_comparable_score,
        final_comparable_score,
        canonical_final_grade,
        opts \\ []
      ) do
    config = ModelVersion.progression_config(option(opts, :model_override, %{}))
    policy = option(opts, :comparison_policy, default_policy(config, mandate_key))

    with :ok <- validate_root_config(config),
         :ok <- validate_mandate(config, mandate_key),
         :ok <- validate_score(canonical_final_grade, :invalid_canonical_final_grade),
         :ok <- validate_policy(policy) do
      evaluate_validated(
        config,
        mandate_key,
        baseline_comparable_score,
        final_comparable_score,
        canonical_final_grade * 1.0,
        policy,
        opts
      )
    else
      {:error, reason} ->
        invalid_result(config, mandate_key, canonical_final_grade, policy, reason)
    end
  end

  @doc "Builds an exact-neutral unavailable result for a malformed grading pair."
  @spec unavailable(atom(), number(), atom(), keyword() | map()) :: ProgressionResult.t()
  def unavailable(mandate_key, canonical_final_grade, reason, opts \\ []) do
    config = ModelVersion.progression_config(option(opts, :model_override, %{}))
    policy = option(opts, :comparison_policy, default_policy(config, mandate_key))

    neutral_result(
      config,
      mandate_key,
      canonical_final_grade * 1.0,
      policy,
      :unavailable,
      reason,
      invalid_reason(reason)
    )
  end

  defp evaluate_validated(
         config,
         :selling,
         _baseline_comparable,
         _final_comparable,
         canonical_final,
         policy,
         _opts
       ) do
    neutral_result(
      config,
      :selling,
      canonical_final,
      policy,
      :not_applicable,
      :selling_has_zero_sensitivity,
      "Selling is not applicable to product-v0 scenario progression; its multiplier is exactly 1.0."
    )
  end

  defp evaluate_validated(
         config,
         :retool,
         _baseline_comparable,
         _final_comparable,
         canonical_final,
         policy,
         _opts
       )
       when policy != :longitudinal do
    neutral_result(
      config,
      :retool,
      canonical_final,
      policy,
      :not_applicable,
      :retool_requires_longitudinal_policy,
      "Retool is not applicable in product v0; only a Lab-only longitudinal experiment may apply its curve."
    )
  end

  defp evaluate_validated(
         config,
         mandate,
         nil,
         _final_comparable,
         canonical_final,
         policy,
         _opts
       ) do
    neutral_result(
      config,
      mandate,
      canonical_final,
      policy,
      :unavailable,
      :baseline_comparable_score_missing,
      "An inherited Comparable State Score is required for this mandate."
    )
  end

  defp evaluate_validated(
         config,
         mandate,
         _baseline_comparable,
         nil,
         canonical_final,
         policy,
         _opts
       ) do
    neutral_result(
      config,
      mandate,
      canonical_final,
      policy,
      :unavailable,
      :final_comparable_score_missing,
      "A final Comparable State Score is required for this mandate."
    )
  end

  defp evaluate_validated(
         config,
         mandate,
         baseline_comparable,
         final_comparable,
         canonical_final,
         policy,
         opts
       ) do
    with :ok <-
           validate_score(baseline_comparable, :invalid_baseline_comparable_score),
         :ok <- validate_score(final_comparable, :invalid_final_comparable_score) do
      apply_progression(
        config,
        mandate,
        baseline_comparable * 1.0,
        final_comparable * 1.0,
        canonical_final,
        policy,
        opts
      )
    else
      {:error, reason} ->
        invalid_result(config, mandate, canonical_final, policy, reason)
    end
  end

  defp apply_progression(
         config,
         mandate,
         baseline_comparable,
         final_comparable,
         canonical_final,
         policy,
         opts
       ) do
    mandate_config = get_in(config, [:mandates, mandate])

    if valid_mandate_config?(mandate_config) do
      comparable_delta = final_comparable - baseline_comparable
      target_change = target_change(mandate_config.target, baseline_comparable)
      signal = :math.tanh((comparable_delta - target_change) / mandate_config.softness)

      sensitivity =
        if signal >= 0.0,
          do: mandate_config.upside_sensitivity,
          else: mandate_config.downside_sensitivity

      multiplier_bounds = %{
        min: 1.0 - mandate_config.downside_sensitivity,
        max: 1.0 + mandate_config.upside_sensitivity
      }

      raw_multiplier =
        (1.0 + sensitivity * signal)
        |> clamp(multiplier_bounds.min, multiplier_bounds.max)

      raw_adjustment = canonical_final * (raw_multiplier - 1.0)

      bounded_adjustment =
        clamp(
          raw_adjustment,
          -mandate_config.downside_point_cap,
          mandate_config.upside_point_cap
        )

      multiplier =
        if canonical_final == 0.0,
          do: 1.0,
          else: 1.0 + bounded_adjustment / canonical_final

      proposed_grade = clamp(canonical_final + bounded_adjustment, 0.0, 100.0)

      %ProgressionResult{
        status: :applied,
        reason: :progression_applied,
        reasons: [applied_reason(mandate, comparable_delta, target_change)],
        model_version: config.version,
        mandate_key: mandate,
        comparison_policy: policy,
        curve_key: mandate_config.curve_key,
        curve_version: config.version,
        baseline_comparable_score: baseline_comparable,
        final_comparable_score: final_comparable,
        canonical_final_grade: canonical_final,
        comparable_delta: comparable_delta,
        target_change: target_change,
        curve_softness: mandate_config.softness,
        progress_signal: signal,
        applied_sensitivity: sensitivity,
        multiplier_bounds: multiplier_bounds,
        raw_multiplier: raw_multiplier,
        multiplier: multiplier,
        raw_adjustment: raw_adjustment,
        point_caps: %{
          upside: mandate_config.upside_point_cap,
          downside: mandate_config.downside_point_cap
        },
        bounded_adjustment: bounded_adjustment,
        proposed_grade: proposed_grade,
        pillar_deltas: option(opts, :pillar_deltas, %{}),
        metric_deltas: option(opts, :metric_deltas, %{})
      }
    else
      invalid_result(config, mandate, canonical_final, policy, :invalid_progression_config)
    end
  end

  defp neutral_result(config, mandate, canonical_final, policy, status, reason, explanation) do
    %ProgressionResult{
      status: status,
      reason: reason,
      reasons: [explanation],
      model_version: config_version(config),
      mandate_key: mandate,
      comparison_policy: policy,
      canonical_final_grade: canonical_final,
      multiplier: 1.0,
      bounded_adjustment: 0.0,
      proposed_grade: canonical_final
    }
  end

  defp invalid_result(config, mandate, canonical_final, policy, reason) do
    canonical_final =
      if valid_score?(canonical_final), do: canonical_final * 1.0, else: nil

    %ProgressionResult{
      status: :invalid,
      reason: reason,
      reasons: [invalid_reason(reason)],
      model_version: config_version(config),
      mandate_key: mandate,
      comparison_policy: policy,
      canonical_final_grade: canonical_final,
      multiplier: 1.0,
      bounded_adjustment: 0.0,
      proposed_grade: canonical_final
    }
  end

  defp target_change(%{type: :fixed, value: value}, _baseline), do: value * 1.0

  defp target_change(target, baseline) do
    (target.intercept + target.rate * (target.reference_score - baseline))
    |> clamp(target.min, target.max)
  end

  defp default_policy(config, mandate) do
    case mandate_config(config, mandate) do
      %{default_policy: policy} -> policy
      _config -> :episodic
    end
  end

  defp validate_root_config(%{version: version, mandates: mandates})
       when is_binary(version) and is_map(mandates),
       do: :ok

  defp validate_root_config(_config), do: {:error, :invalid_progression_config}

  defp validate_mandate(config, mandate) do
    if is_map(mandate_config(config, mandate)),
      do: :ok,
      else: {:error, :invalid_mandate}
  end

  defp validate_policy(policy) do
    if policy in @policies, do: :ok, else: {:error, :invalid_comparison_policy}
  end

  defp validate_score(score, reason) do
    if valid_score?(score), do: :ok, else: {:error, reason}
  end

  defp valid_score?(score), do: is_number(score) and score >= 0 and score <= 100

  defp valid_mandate_config?(config) do
    is_map(config) and is_atom(config.curve_key) and
      is_number(config.softness) and config.softness > 0 and
      non_negative?(config.upside_sensitivity) and
      non_negative?(config.downside_sensitivity) and
      non_negative?(config.upside_point_cap) and
      non_negative?(config.downside_point_cap) and valid_target?(config.target)
  end

  defp valid_target?(%{type: :fixed, value: value}), do: is_number(value)

  defp valid_target?(%{
         type: :headroom,
         intercept: intercept,
         reference_score: reference,
         rate: rate,
         min: low,
         max: high
       }) do
    Enum.all?([intercept, reference, rate, low, high], &is_number/1) and low <= high
  end

  defp valid_target?(_target), do: false
  defp non_negative?(value), do: is_number(value) and value >= 0

  defp applied_reason(:championship_push, comparable_delta, _target)
       when comparable_delta < 0 do
    "Championship Push regression always produces a negative scenario Progression signal."
  end

  defp applied_reason(mandate, comparable_delta, target) do
    comparison =
      cond do
        comparable_delta > target -> "exceeded"
        comparable_delta < target -> "fell below"
        true -> "met"
      end

    "#{mandate_label(mandate)} scenario progress #{comparison} its #{format_number(target)}-point target."
  end

  defp invalid_reason(:invalid_mandate), do: "The mandate is not configured for Progression."

  defp invalid_reason(:invalid_canonical_final_grade),
    do: "The canonical Final GM Grade must be from 0 to 100."

  defp invalid_reason(:invalid_baseline_comparable_score),
    do: "The inherited Comparable State Score must be from 0 to 100."

  defp invalid_reason(:invalid_final_comparable_score),
    do: "The final Comparable State Score must be from 0 to 100."

  defp invalid_reason(:invalid_comparison_policy),
    do: "Comparison policy must be longitudinal, maintenance, or episodic."

  defp invalid_reason(:invalid_progression_config),
    do: "The selected Progression configuration is invalid."

  defp invalid_reason(:incompatible_grade_pair),
    do: "The inherited and final grades do not share one frozen comparison boundary."

  defp invalid_reason(:invalid_pillar_weights),
    do: "The Comparable State Score requires one valid set of canonical mandate weights."

  defp invalid_reason(:missing_comparable_pillar),
    do: "A required comparable pillar result is missing or malformed."

  defp invalid_reason(:missing_comparable_metric),
    do: "A required comparable Asset Management metric is missing or malformed."

  defp mandate_label(mandate) do
    mandate
    |> Atom.to_string()
    |> String.replace("_", " ")
    |> String.split()
    |> Enum.map_join(" ", &String.capitalize/1)
  end

  defp format_number(number), do: :erlang.float_to_binary(number * 1.0, decimals: 2)

  defp config_version(%{version: version}) when is_binary(version), do: version
  defp config_version(_config), do: Map.fetch!(ModelVersion.versions(), :progression)

  defp mandate_config(%{mandates: mandates}, mandate) when is_map(mandates),
    do: Map.get(mandates, mandate)

  defp mandate_config(_config, _mandate), do: nil

  defp option(opts, key, default) when is_list(opts), do: Keyword.get(opts, key, default)

  defp option(opts, key, default) when is_map(opts),
    do: Map.get(opts, key, Map.get(opts, Atom.to_string(key), default))

  defp option(_opts, _key, default), do: default
  defp clamp(value, low, high), do: value |> max(low) |> min(high)
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