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

Documentation Shared models

Salary, picks & player value

The valuation inputs behind contract and asset scoring.

Expected salary

Expected salary consumes the shared player valuation pipeline. Future seasons apply cap growth and age adjustments; confidence decays over the horizon. These projections contain documented defaults, not a player-specific aging forecast. Unavailable valuations stay unavailable rather than becoming fabricated zeros.

expected_salary(y) = predicted_aav × cap_growth(y) × age_adjustment(age, y)

Protected picks and rollovers

Draft Asset Value enumerates terminal outcomes and applies an absolute time discount to each outcome once. A protection that rolls into a later pick is represented by its eventual outcome and year, avoiding double discounting through the chain.

DAV = Σ P(outcome) × slot_value(outcome) × discount^years_ahead

Swap rights

A swap is the incremental value of improving the pick the team would otherwise keep. The positive difference is calculated inside each simulated outcome, then averaged. It does not represent ownership of both picks.

swap_value = E[max(received_value − retained_value, 0)] × discount

Calibration is part of the model

Player Transaction Value has an explicit calibration gate. Candidate parameters and a version number do not establish empirical accuracy. The Asset Management reference explains how uncalibrated exchange evidence affects aggregation.

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.

expected_salary.exElixir

lib/trade_machine/gm_grade_v2/expected_salary.ex

What the market would pay a player, per remaining season, with a number attached to how much that estimate can be trusted.

Three later pillars read this and nothing else for "what is he worth": Contract Health (Phase 10), Player Transaction Value (Phase 12), and contract plausibility (Phase 14).

The current season is the existing valuation layer, not a new model

assess_current/2 loads the valuation subject and passes it directly to TradeMachine.Valuation.ContractValue.assess/3 with no comparables. The active CVS model reads its cached league context and ignores browse comparables — the same two calls TradeMachine.TradeGrade.TradeInput.default_player_valuer/1 makes. It is deliberately the same wiring rather than a grading-specific copy: a forked valuation path would let one player's fair value differ between the trade grade and the GM grade, and that difference is visible to a user holding both screens open. Per D-55 the engines are read-only here — this module composes their public outputs and adds nothing to them.

A number for the confidence

The valuation layer answers :high | :moderate | :low ; AVC's formulas need a scalar. ModelVersion.confidence_mapping/0 is that conversion (0.9 / 0.6 / 0.3) and it is a **recorded v1 default pending calibration** (D-07), not a measurement — the interpretation notes are explicit that it must eventually be fitted from out-of-sample errors. The mapping is a lookup, not a case with a fallback: a categorical value the mapping does not name resolves to {:error, :unavailable} , because inventing a number for an unrecognised confidence is exactly the failure this whole module refuses.

Future seasons are AVC's documented placeholder, and say so

    expected(y) = predicted_aav × cap_growth(y) × age_adjustment(age, y)

cap_growth(y) is the ratio of season y's salary cap to the current season's when both are in the threshold table, and Rules.cap_escalation_rate/0 ^ y when they are not. Which one produced a season is frozen alongside it ( :cap_growth_source ), because that is the one part of the projection a reader cannot re-derive later: the age curve and the decay are versioned constants, but *which seasons had real cap rows at freeze time* is a fact about the moment of capture.

age_adjustment/3 compounds one flat factor per season ahead, chosen by the player's age in that season (D-19). It is not an aging model and is not player-specific — those are deferred — and it is the projection's largest source of error, which is why it and the confidence mapping are the two constants flagged for AVC's review packet.

Confidence decays over the horizon: numeric_confidence × 0.9^years_ahead (D-18).

`:unavailable` is an answer

A player the valuation pipeline cannot price — most notably one with no games or no usable stats — returns {:error, :unavailable} and is *stored* as unavailable. Not zero, not the league average, not 50. A fabricated expected salary does not read as missing data downstream; it reads as a confident claim that a player is worth nothing, and Contract Health would grade a team as though it had signed him to the worst contract in the league.

Nothing here rescues

A snapshot is insert-only and never corrected (see TradeMachine.GMGradeV2.ChallengeSnapshot ). A rescue around the per-player valuation would convert a bug into 450 players frozen as :unavailable in a document that can never be fixed, and the resulting grades would look valid. Failing the build loudly is the cheaper failure: nothing is written, and the next run is correct. TradeInput rescues because a broken player must not take down a trade screen; this module does not, because a broken player must not become permanent evidence.

Scenario salaries never feed back in

Every input to the estimate comes from the frozen stats/contract snapshot the candidate pool is loaded from. A scenario that pays a player $50M does not make him worth $50M — if it did, "overpay" would be unmeasurable, since the yardstick would move with the thing being measured.

Download source ↓

SHA-256 3080143e355531a4b11e506f7559951eb9a434cf5031aada6ab0ece844c6ea45

defmodule TradeMachine.GMGradeV2.ExpectedSalary do
  @moduledoc """
  What the market would pay a player, per remaining season, with a number
  attached to how much that estimate can be trusted.

  Three later pillars read this and nothing else for "what is he worth": Contract
  Health (Phase 10), Player Transaction Value (Phase 12), and contract
  plausibility (Phase 14).

  ## The current season is the existing valuation layer, not a new model

  `assess_current/2` loads the valuation subject and passes it directly to
  `TradeMachine.Valuation.ContractValue.assess/3` with no comparables. The active
  CVS model reads its cached league context and ignores browse comparables — the
  same two calls `TradeMachine.TradeGrade.TradeInput.default_player_valuer/1` makes.
  It is deliberately the same wiring rather than a grading-specific copy: a
  forked valuation path would let one player's fair value differ between the trade
  grade and the GM grade, and that difference is visible to a user holding both
  screens open. Per D-55 the engines are read-only here — this module composes
  their public outputs and adds nothing to them.

  ## A number for the confidence

  The valuation layer answers `:high | :moderate | :low`; AVC's formulas need a
  scalar. `ModelVersion.confidence_mapping/0` is that conversion (0.9 / 0.6 /
  0.3) and it is a **recorded v1 default pending calibration** (D-07), not a
  measurement — the interpretation notes are explicit that it must eventually be
  fitted from out-of-sample errors. The mapping is a lookup, not a `case` with a
  fallback: a categorical value the mapping does not name resolves to
  `{:error, :unavailable}`, because inventing a number for an unrecognised
  confidence is exactly the failure this whole module refuses.

  ## Future seasons are AVC's documented placeholder, and say so

      expected(y) = predicted_aav × cap_growth(y) × age_adjustment(age, y)

  `cap_growth(y)` is the ratio of season y's salary cap to the current season's
  when both are in the threshold table, and `Rules.cap_escalation_rate/0 ^ y`
  when they are not. Which one produced a season is frozen alongside it
  (`:cap_growth_source`), because that is the one part of the projection a reader
  cannot re-derive later: the age curve and the decay are versioned constants,
  but *which seasons had real cap rows at freeze time* is a fact about the
  moment of capture.

  `age_adjustment/3` compounds one flat factor per season ahead, chosen by the
  player's age in that season (D-19). It is not an aging model and is not
  player-specific — those are deferred — and it is the projection's largest
  source of error, which is why it and the confidence mapping are the two
  constants flagged for AVC's review packet.

  Confidence decays over the horizon: `numeric_confidence × 0.9^years_ahead`
  (D-18).

  ## `:unavailable` is an answer

  A player the valuation pipeline cannot price — most notably one with no games
  or no usable stats — returns `{:error, :unavailable}` and is *stored* as
  unavailable. Not zero, not the league average, not 50. A fabricated expected
  salary does not read as missing data downstream; it reads as a confident claim
  that a player is worth nothing, and Contract Health would grade a team as
  though it had signed him to the worst contract in the league.

  ## Nothing here rescues

  A snapshot is insert-only and never corrected (see
  `TradeMachine.GMGradeV2.ChallengeSnapshot`). A `rescue` around the per-player
  valuation would convert a bug into 450 players frozen as `:unavailable` in a
  document that can never be fixed, and the resulting grades would look valid.
  Failing the build loudly is the cheaper failure: nothing is written, and the
  next run is correct. `TradeInput` rescues because a broken player must not take
  down a trade screen; this module does not, because a broken player must not
  become permanent evidence.

  ## Scenario salaries never feed back in

  Every input to the estimate comes from the frozen stats/contract snapshot the
  candidate pool is loaded from. A scenario that pays a player $50M does not make
  him worth $50M — if it did, "overpay" would be unmeasurable, since the
  yardstick would move with the thing being measured.
  """

  alias TradeMachine.CBA.Rules
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.NBA
  alias TradeMachine.Valuation.ContractValue
  alias TradeMachine.Valuation.CVS.LeagueContext
  alias TradeMachine.Valuation.PlayerValueCurve

  @typedoc """
  A current-season market estimate. `age` is the subject's age today (the
  comparable loader's own value) and is `nil` when the player has no birth date;
  `model_version` is this sub-model's version from the registry.
  """
  @type assessment :: %{
          player_id: String.t(),
          predicted_aav: non_neg_integer(),
          categorical_confidence: :high | :moderate | :low,
          numeric_confidence: float(),
          age: number() | nil,
          sample_size: non_neg_integer(),
          player_asset_value: number() | nil,
          diagnostics: map(),
          model_version: String.t()
        }

  @typedoc "One projected season: dollars, the confidence to read them with, and where the cap growth came from."
  @type season_projection :: %{
          years_ahead: non_neg_integer(),
          expected: non_neg_integer(),
          confidence: float(),
          cap_growth: float(),
          cap_growth_source: :official | :escalation_fallback,
          age_adjustment: float()
        }

  # ── Current-season assessment ─────────────────────────────────────────

  @doc """
  Market estimate for `player_id` in the current season.

  Returns `{:ok, t:assessment/0}` or `{:error, :unavailable}` when the
  comparable pipeline cannot produce a defensible number (unknown player, no
  stats, too few comps, no production variance to fit, or a confidence the
  numeric mapping does not name).

  A prediction of exactly `$0` is also unavailable. The model clamps a negative
  extrapolation up to zero, and zero is not a market answer for a player on an
  NBA contract — the league minimum is over a million dollars — so it is the
  clamp showing through, not a valuation.

  The success case is tagged rather than returned bare so a caller cannot pass
  the error tuple on as if it were an assessment map.

  ## Options

    * `:candidates` — retained for callers that use the candidate pool as the
      snapshot player set; CVS does not use it while valuing an individual.
    * `:valuer` — 1-arity fun replacing the real pipeline, returning a
      `ContractValue.assess/3`-shaped map (optionally carrying `:age`). The same
      swap point `TradeInput` exposes, so callers can test without a database.
    * `:override` — lab constant overrides (Phase 16), forwarded to
      `ModelVersion`.
  """
  @spec assess_current(String.t(), keyword()) :: {:ok, assessment()} | {:error, :unavailable}
  def assess_current(player_id, opts \\ []) when is_binary(player_id) do
    override = Keyword.get(opts, :override, %{})

    case valuation(player_id, opts) do
      %{available: true, predicted_aav: aav, confidence: categorical} = assessment
      when is_number(aav) and aav > 0 ->
        case numeric_confidence(categorical, override) do
          {:ok, numeric} ->
            {:ok,
             %{
               player_id: player_id,
               predicted_aav: round(aav),
               categorical_confidence: categorical,
               numeric_confidence: numeric,
               age: Map.get(assessment, :age),
               sample_size: Map.get(assessment, :sample_size, 0),
               player_asset_value: Map.get(assessment, :player_asset_value),
               diagnostics: Map.get(assessment, :diagnostics, %{}),
               model_version: ModelVersion.versions().expected_salary
             }}

          :error ->
            {:error, :unavailable}
        end

      _ ->
        {:error, :unavailable}
    end
  end

  @doc """
  Numeric confidence for a categorical one (D-07).

  `:error` for anything the mapping does not name — including `nil` — so a new
  confidence level added to the valuation layer surfaces as unavailable rather
  than silently taking a neighbour's number.
  """
  @spec numeric_confidence(atom(), map()) :: {:ok, float()} | :error
  def numeric_confidence(categorical, override \\ %{}) do
    case Map.fetch(ModelVersion.confidence_mapping(override), categorical) do
      {:ok, numeric} -> {:ok, numeric / 1}
      :error -> :error
    end
  end

  # ── Multi-season projection ───────────────────────────────────────────

  @doc """
  Projects `assessment` across `seasons`.

  `seasons` is ordered and **begins with the season the assessment was made in**
  — its position in the list is the number of years ahead, which is what both
  the cap-growth ratio and the confidence decay are measured against. The first
  season therefore always projects to the assessment itself (growth 1.0,
  adjustment 1.0, undecayed confidence).

  `player_age` is the player's age in that first season; `nil` leaves the
  projection age-neutral (factor 1.0 throughout) rather than guessing an age,
  and the resulting `:age_adjustment` of 1.0 records that.

  `thresholds_by_season` maps a season label to anything carrying a
  `salary_cap` — a `%SalaryCapThreshold{}`, an atom-keyed map, or the
  string-keyed map a frozen snapshot section holds.
  """
  @spec project_seasons(assessment(), number() | nil, [String.t()], map(), map()) ::
          %{String.t() => season_projection()}
  def project_seasons(assessment, player_age, seasons, thresholds_by_season, override \\ %{})

  def project_seasons(_assessment, _player_age, [], _thresholds_by_season, _override), do: %{}

  def project_seasons(assessment, player_age, [current | _] = seasons, thresholds, override) do
    %{predicted_aav: aav, numeric_confidence: base_confidence} = assessment
    decay = ModelVersion.confidence_horizon_decay(override)

    seasons
    |> Enum.with_index()
    |> Map.new(fn {season, years_ahead} ->
      {growth, source} = cap_growth(current, season, years_ahead, thresholds)
      adjustment = age_adjustment(player_age, years_ahead, override)

      {season,
       %{
         years_ahead: years_ahead,
         expected: round(aav * growth * adjustment),
         confidence: base_confidence * :math.pow(decay, years_ahead),
         cap_growth: growth,
         cap_growth_source: source,
         age_adjustment: adjustment
       }}
    end)
  end

  @doc """
  Compounded age adjustment from the current season to `years_ahead` seasons out.

  The factor for each intervening season is chosen by the age the player will be
  *in* that season, so a 22-year-old crossing into the prime band mid-horizon
  stops compounding the improvement. `years_ahead` of 0 (and an unknown age) is
  always 1.0.
  """
  @spec age_adjustment(number() | nil, non_neg_integer(), map()) :: float()
  def age_adjustment(player_age, years_ahead, override \\ %{})

  def age_adjustment(_player_age, years_ahead, _override) when years_ahead <= 0, do: 1.0
  def age_adjustment(nil, _years_ahead, _override), do: 1.0

  def age_adjustment(player_age, years_ahead, override) when is_number(player_age) do
    curve = ModelVersion.age_curve(override)

    Enum.reduce(1..years_ahead, 1.0, fn year, acc ->
      acc * age_factor(player_age + year, curve)
    end)
  end

  @doc """
  The single-season factor for an age, from the versioned D-19 band table.

  Bands are inclusive-max and resolved in order; the final band's `nil` max
  catches everything above. An unknown age is neutral (1.0), matching
  `age_adjustment/3` — an age we do not have is not an age band we can guess.
  """
  @spec age_factor(number() | nil, [{pos_integer() | nil, number()}] | map()) :: float()
  def age_factor(age, curve_or_override \\ %{})

  def age_factor(nil, _curve_or_override), do: 1.0

  def age_factor(age, override) when is_map(override),
    do: age_factor(age, ModelVersion.age_curve(override))

  def age_factor(age, curve) when is_number(age) and is_list(curve) do
    Enum.find_value(curve, 1.0, fn
      {nil, factor} -> factor / 1
      {max_age, factor} -> if age <= max_age, do: factor / 1
    end)
  end

  # ── AVC's confidence blend ────────────────────────────────────────────

  @doc """
  AVC's confidence-adjusted salary: `actual + confidence × (expected − actual)`.

  A confidence of 0 returns the actual salary untouched (the estimate is not
  trusted at all) and 1 returns the estimate (it is trusted completely); in
  between the estimate pulls the actual toward it in proportion to how much the
  model can be believed. Rounded to whole dollars, the unit every salary in this
  codebase is in.

  A `nil` actual salary — a season the player is not under contract for — has
  nothing to blend against and returns `nil`, not the bare estimate: "what he
  would be paid" and "what he is paid, corrected toward what he is worth" are
  different quantities, and only the second is what a caller asking for an
  adjusted salary means.
  """
  @spec confidence_adjusted(number() | nil, number(), number()) :: integer() | nil
  def confidence_adjusted(nil, _expected, _numeric_confidence), do: nil

  def confidence_adjusted(actual_salary, expected, numeric_confidence)
      when is_number(actual_salary) and is_number(expected) and is_number(numeric_confidence) do
    round(actual_salary + numeric_confidence * (expected - actual_salary))
  end

  # ── Composed entry point ──────────────────────────────────────────────

  @doc """
  Expected, confidence and confidence-adjusted salary for each of a player's
  remaining seasons.

  `remaining_seasons` is an ordered list of `%{season: label, salary: dollars}`
  beginning with the current season (see `project_seasons/5`); `salary` may be
  `nil` for a season the player is not under contract for, which yields an
  `adjusted` of `nil` beside a real `expected`.

  Options are `assess_current/2`'s, plus `:age` (defaults to the assessment's
  own, so the subject is loaded once) and `:thresholds_by_season` (defaults to
  `%{}` — every season then grows by the CBA escalation rate and is marked
  `:escalation_fallback`; the snapshot builder passes the real table).
  """
  @spec for_player(String.t(), [map()], keyword()) ::
          {:ok,
           %{
             String.t() => %{
               expected: non_neg_integer(),
               confidence: float(),
               adjusted: integer() | nil
             }
           }}
          | {:error, :unavailable}
  def for_player(player_id, remaining_seasons, opts \\ []) do
    with {:ok, assessment} <- assess_current(player_id, opts) do
      override = Keyword.get(opts, :override, %{})
      age = Keyword.get(opts, :age, assessment.age)
      thresholds = Keyword.get(opts, :thresholds_by_season, %{})
      labels = Enum.map(remaining_seasons, & &1.season)
      projection = project_seasons(assessment, age, labels, thresholds, override)

      {:ok,
       Map.new(remaining_seasons, fn %{season: season} = entry ->
         %{expected: expected, confidence: confidence} = Map.fetch!(projection, season)

         {season,
          %{
            expected: expected,
            confidence: confidence,
            adjusted: confidence_adjusted(Map.get(entry, :salary), expected, confidence)
          }}
       end)}
    end
  end

  # ── Frozen snapshot section ───────────────────────────────────────────

  @doc """
  Builds the `expected_salaries` section of a challenge snapshot.

  One entry per player, string-keyed and string-valued throughout — the form the
  document is stored and read back in, so the in-memory section and the one a
  grade later reads are the same object (D-42). A player the pipeline cannot
  price is present with `"status" => "unavailable"` and no seasons, never absent
  and never zero: absence and unavailability read identically to a consumer, and
  only one of them means "we looked".

  Every player gets the full projection window regardless of how long his real
  contract runs, because a scenario may re-sign him: grading picks the seasons
  it needs out of a table that already covers them.

  ## Options

    * `:candidates` — preloaded comparable pool (default: every rostered player).
      Also the default player set, which is the v1 scope: ~450 rostered players.
      Teamless free agents are not in it — see DEFERRED.
    * `:player_ids` — explicit player set, overriding the pool's.
    * `:seasons` — projection window, current season first (default: the
      current season plus the four the Contract Health weights cover).
    * `:thresholds_by_season` — cap table (default: every
      `%SalaryCapThreshold{}` row, keyed by season).
    * `:valuer`, `:override` — as `assess_current/2`.
  """
  @spec snapshot_section(keyword()) :: %{String.t() => map()}
  def snapshot_section(opts \\ []) do
    candidates = Keyword.get_lazy(opts, :candidates, &NBA.load_comparable_candidates/0)

    player_ids =
      Keyword.get_lazy(opts, :player_ids, fn -> Enum.map(candidates, & &1.player_id) end)

    seasons = Keyword.get_lazy(opts, :seasons, &projection_window/0)
    thresholds = Keyword.get_lazy(opts, :thresholds_by_season, &load_thresholds/0)
    override = Keyword.get(opts, :override, %{})
    assess_opts = Keyword.put(opts, :candidates, candidates)

    Map.new(player_ids, fn player_id ->
      {player_id, snapshot_entry(player_id, seasons, thresholds, override, assess_opts)}
    end)
  end

  @doc "Builds explicit unavailable entries without invoking the valuation pipeline."
  @spec unavailable_section([String.t()]) :: %{String.t() => map()}
  def unavailable_section(player_ids) when is_list(player_ids) do
    model_version = ModelVersion.versions().expected_salary

    Map.new(player_ids, fn player_id ->
      {player_id,
       %{
         "predicted_aav" => nil,
         "numeric_confidence" => nil,
         "per_season" => %{},
         "status" => "unavailable",
         "model_version" => model_version
       }}
    end)
  end

  @doc """
  The projection window: the current season plus the four beyond it.

  Composed from `Rules.current_season/0` and `Rules.future_seasons/0` rather than
  generated here — the label arithmetic is the CBA engine's (D-55).
  """
  @spec projection_window() :: [String.t()]
  def projection_window, do: [Rules.current_season() | Rules.future_seasons()]

  @doc """
  Reads one player-season out of a frozen `expected_salaries` section.

  The only way grading should reach an expected salary: it consults the frozen
  document and cannot fall through to a live valuation, so two entrants a week
  apart are scored against the same numbers. `{:error, :unavailable}` covers
  every miss — player absent, player stored as unavailable, season outside the
  frozen window — because all three mean the same thing to a consumer: there is
  no number here to use.

  It also validates the entry it finds, rather than trusting the document. The
  snapshot schema constrains only the section's top-level non-emptiness, and the
  row is write-once, so a malformed entry — from a hand-edit, a future builder,
  or a partial migration — is not correctable and must not be readable. A
  `"per_season"` that is not a map, a non-positive or non-integer `expected`, or
  a confidence outside 0..1 are all *unavailable*, not exceptions and not values:
  the same rule as everywhere else here, applied at the read boundary too. In
  particular an `expected` of `0` is rejected on the way out for the reason
  `assess_current/2` rejects it on the way in — it is a clamp, not a valuation.
  """
  @spec season_expectation(map(), String.t(), String.t()) ::
          {:ok, %{expected: pos_integer(), confidence: number()}} | {:error, :unavailable}
  def season_expectation(section, player_id, season) when is_map(section) do
    with %{"status" => "ok", "per_season" => per_season} when is_map(per_season) <-
           Map.get(section, player_id),
         %{"expected" => expected, "confidence" => confidence}
         when is_integer(expected) and expected > 0 and
                is_number(confidence) and confidence >= 0 and confidence <= 1 <-
           Map.get(per_season, season) do
      {:ok, %{expected: expected, confidence: confidence}}
    else
      _ -> {:error, :unavailable}
    end
  end

  # A section that is not a map at all — nil, a list, a partially-migrated value.
  # The clause exists because a reader documented as fail-closed that raises on
  # its own top-level argument is not fail-closed; it just moves the failure.
  def season_expectation(_section, _player_id, _season), do: {:error, :unavailable}

  # ── Internals ─────────────────────────────────────────────────────────

  defp snapshot_entry(player_id, seasons, thresholds, override, opts) do
    model_version = ModelVersion.versions().expected_salary

    case assess_current(player_id, opts) do
      {:ok, assessment} ->
        per_season =
          assessment
          |> project_seasons(assessment.age, seasons, thresholds, override)
          |> Map.new(fn {season, projection} ->
            {season,
             %{
               "expected" => projection.expected,
               "confidence" => projection.confidence,
               "cap_growth_source" => Atom.to_string(projection.cap_growth_source)
             }}
          end)

        %{
          "predicted_aav" => assessment.predicted_aav,
          "numeric_confidence" => assessment.numeric_confidence,
          "contract_value_score" => diagnostic(assessment.diagnostics, :cvs),
          "player_market_value" => diagnostic(assessment.diagnostics, :pmv),
          "player_asset_value" => snapshot_player_asset_value(assessment),
          "contract_value_tier" => diagnostic(assessment.diagnostics, :tier),
          "components" =>
            Map.new(
              ~w(impact scoring defense creation efficiency role_proof age_trajectory)a,
              fn key -> {Atom.to_string(key), diagnostic(assessment.diagnostics, key)} end
            ),
          "per_season" => per_season,
          "status" => "ok",
          "model_version" => model_version
        }

      {:error, :unavailable} ->
        %{
          "predicted_aav" => nil,
          "numeric_confidence" => nil,
          "per_season" => %{},
          "status" => "unavailable",
          "model_version" => model_version
        }
    end
  end

  defp snapshot_player_asset_value(assessment) do
    case Map.get(assessment, :player_asset_value) do
      value when is_number(value) ->
        value

      _ ->
        with pmv when is_number(pmv) <- diagnostic(assessment.diagnostics, :pmv),
             cvs when is_number(cvs) <- diagnostic(assessment.diagnostics, :cvs) do
          PlayerValueCurve.evaluate(pmv, cvs).player_asset_value
        else
          _ -> nil
        end
    end
  end

  defp valuation(player_id, opts) do
    case Keyword.get(opts, :valuer) do
      nil ->
        default_valuation(
          player_id,
          Keyword.get(opts, :candidates),
          Keyword.get(opts, :override, %{}),
          Keyword.get(opts, :require_complete_inputs, false)
        )

      valuer when is_function(valuer, 1) ->
        valuer.(player_id)
    end
  end

  defp diagnostic(diagnostics, key) when is_map(diagnostics) do
    Map.get(diagnostics, key, Map.get(diagnostics, Atom.to_string(key)))
  end

  # The audited CVS pipeline, wired exactly as `TradeInput.value_player/1` wires
  # it, behind one added precondition — see `priceable?/1`.
  defp default_valuation(player_id, _candidates, override, require_complete_inputs?) do
    subject = ContractValue.load_subject(player_id)
    min_games = ModelVersion.min_comparable_games(override)

    complete? =
      not require_complete_inputs? or
        (is_map(subject) and LeagueContext.missing_inputs(subject) == [])

    if (priceable?(subject, min_games) and complete?) or draft_prior_eligible?(subject) do
      subject
      |> ContractValue.assess([], [])
      |> Map.put(:age, subject[:age])
    else
      %{available: false, predicted_aav: nil, age: subject && subject[:age]}
    end
  end

  # A player without a usable statistical profile must not reach the model.
  #
  # This is not defensiveness, it is a real hole: a player with no
  # `player_stats` row cannot be assigned a defensible population percentile.
  # Stored, that reads as "the market says he is worth nothing" — the exact
  # fabricated number the phase's gate forbids — rather than "we could not price
  # him".
  #
  # Retain the established GM Grade eligibility bar across the model swap: a
  # subject needs a scoring line and the versioned minimum games sample, unless
  # the CVS rookie prior can price him from his draft slot.
  defp priceable?(nil, _min_games), do: false

  defp priceable?(subject, min_games) do
    stats = subject[:stats] || %{}
    games = stats[:career_games_played] || stats[:games_played]

    not is_nil(stats[:points]) and is_number(games) and games >= min_games
  end

  defp draft_prior_eligible?(nil), do: false

  defp draft_prior_eligible?(subject) do
    policy = ModelVersion.rookie_contract_policy()

    TradeMachine.Valuation.ContractClassification.active_standard_rookie_scale?(
      subject,
      Rules.current_season()
    ) and
      match?(
        {:ok, _prior},
        TradeMachine.Valuation.RookieContractPolicy.draft_prior(subject, policy)
      )
  end

  # The ratio of season y's cap to the current season's, or the CBA escalation
  # rate compounded when either season is missing a row. A missing row is not an
  # error — the table only runs a few seasons out — but which answer was used is
  # frozen with the number.
  defp cap_growth(current_season, season, years_ahead, thresholds) do
    with {:ok, base} <- salary_cap(thresholds, current_season),
         {:ok, cap} <- salary_cap(thresholds, season) do
      {cap / base, :official}
    else
      :error -> {:math.pow(Rules.cap_escalation_rate(), years_ahead), :escalation_fallback}
    end
  end

  defp salary_cap(thresholds, season) do
    case Map.get(thresholds, season) do
      nil -> :error
      row -> fetch_cap(row)
    end
  end

  defp fetch_cap(%_{} = row), do: row |> Map.from_struct() |> fetch_cap()

  defp fetch_cap(row) when is_map(row) do
    case Map.get(row, :salary_cap) || Map.get(row, "salary_cap") do
      cap when is_number(cap) and cap > 0 -> {:ok, cap}
      _ -> :error
    end
  end

  defp fetch_cap(_row), do: :error

  defp load_thresholds do
    NBA.list_salary_cap_thresholds() |> Map.new(&{&1.season, &1})
  end
end
valuer.exElixir

lib/trade_machine/gm_grade_v2/dav/valuer.ex

Where a controlled claim becomes a number: Draft Asset Value, discounted to the frozen valuation date.

This is the last of the four DAV modules and the only one that multiplies the other three together. TradeMachine.GMGradeV2.DAV.OutcomeEvaluator says what a claim resolves into and when; TradeMachine.GMGradeV2.DAV.SlotProbability says where each pick lands; TradeMachine.GMGradeV2.DAV.SlotCurve says what a slot is worth. The whole of a protected pick's value is:

    DAV = Σ  probability(outcome) × SlotCurve.value(slot) × 0.92^(outcome year − valuation year)
          over terminal outcomes

One discount per terminal outcome, applied here and nowhere else

AVC requires a time discount "at every stage" of a rollover chain, which is the one instruction in Pillar 5 that is easy to implement twice. The safe convention (interpretation notes, "Recursive Protection Discount Semantics") is the second of the two AVC offers: enumerate terminal outcomes, apply one **absolute** 0.92^years per outcome, never multiply an already-discounted fallback by the current node's discount.

That convention is structural rather than vigilant. OutcomeEvaluator returns a flat list of terminal outcomes, each carrying exactly one :year and no value, so there is no intermediate node holding a number that could be re-multiplied. Double-discounting a chain here would require deliberately discounting twice in one expression.

Swaps are worth the improvement, not the pick

AVC's Important Rule: "A swap should never be valued as though the team owns both first-round picks." swap_dav/2 returns

    E[ max(value of the rank-N pick received − value of the pick otherwise kept, 0) ] × discount

with the max taken **inside** each simulation and averaged after, never max(E[received] − E[kept], 0) . The difference is the option value and it is the entire asset: two picks with identical distributions have equal means, so the outside-the-expectation form values every swap right in the league at exactly zero.

### :dav is the swap right; :inventory_dav is the pick inventory

These are different questions and a swap answers them with different numbers. OutcomeEvaluator **absorbs** a pooled pick out of its owner's range claims — the pool's outcome holders are paid instead — so a team that contributed its own pick to a pool holds only its swap outcome afterwards. Valuing that outcome incrementally is right for "what is this swap right worth" and badly wrong for "what is this team's draft capital": NOP contributes its 2027 first and holds the more-favorable rank, so its incremental gain is a few DAV points while the pick it actually receives is worth 60+.

Every result therefore carries both:

* :dav — AVC's asset value. Incremental for a swap, full for a pick claim. * :inventory_dav — what the team ends the draft holding. Identical to :dav for pick claims; the discounted expected value of the rank-N pick **received** for a swap outcome.

Draft Capital Value (Phase 07 T6, Pillar 5 Metric 1) sums :inventory_dav . Summing :dav across a league understates every pool contributor by roughly the whole value of its own pick; summing :inventory_dav conserves it exactly, because each pooled pick's value flows to whichever rank receives it (in the 2027 UTA/CLE/MIN ladder, three member picks and three ranks: MEM takes the most favorable, UTA the second, PHX the least, and CLE and MIN — members holding no rank — correctly end with nothing).

Determinism

Two picks in a pool are enumerated exactly (900 slot pairs); three or more are sampled, because the joint enumeration grows as 30ⁿ. Monte Carlo is seeded from the pool's sorted member pick ids plus the valuation date, so the same pool values identically on every run and every machine, and the seed, iteration count, variance and standard error are returned for storage alongside the grade (interpretation notes, "Deterministic Monte Carlo").

Deriving the seed from the *pool* rather than the outcome has a property worth keeping: a team holding two ranks of the same pool (HOU holds ranks 1 and 2 of HOU/DAL/PHX 2029) values both against the same sampled draft, so its two rights cannot contradict each other about which pick was most favorable.

Sampling is threaded through :rand 's explicit state rather than the process dictionary, so valuing an asset never perturbs a caller's random state — a grade must not depend on how many swaps were valued before it.

What this module does not decide

* **Correlation between picks.** Members of a pool are sampled independently; two picks from the same draft can land on the same slot. AVC names this limitation. Ranks are still assigned deterministically among ties, so a collision changes no value. * **Unresolved continuations.** An {:asset, _} terminal — a chain whose next pick was not supplied, cycles, or exceeded the hop cap — is not silently worth zero. dav/2 raises unless the caller passes unresolved: :provisional , which returns the resolved mass as an explicit lower bound flagged provisional?: true (AVC: expose a conservative range, never fabricate precision).

Download source ↓

SHA-256 1c3ecb7aaac3a96a5d99bda94bee70bb3f03edc5d9af633a11adb49c71283128

defmodule TradeMachine.GMGradeV2.DAV.Valuer do
  @moduledoc """
  Where a controlled claim becomes a number: Draft Asset Value, discounted to the
  frozen valuation date.

  This is the last of the four DAV modules and the only one that multiplies the
  other three together. `TradeMachine.GMGradeV2.DAV.OutcomeEvaluator` says what a
  claim resolves into and when; `TradeMachine.GMGradeV2.DAV.SlotProbability` says
  where each pick lands; `TradeMachine.GMGradeV2.DAV.SlotCurve` says what a slot
  is worth. The whole of a protected pick's value is:

      DAV = Σ  probability(outcome) × SlotCurve.value(slot) × 0.92^(outcome year − valuation year)
            over terminal outcomes

  ## One discount per terminal outcome, applied here and nowhere else

  AVC requires a time discount "at every stage" of a rollover chain, which is the
  one instruction in Pillar 5 that is easy to implement twice. The safe
  convention (interpretation notes, "Recursive Protection Discount Semantics") is
  the second of the two AVC offers: enumerate terminal outcomes, apply one
  **absolute** `0.92^years` per outcome, never multiply an already-discounted
  fallback by the current node's discount.

  That convention is structural rather than vigilant. `OutcomeEvaluator` returns
  a flat list of terminal outcomes, each carrying exactly one `:year` and no
  value, so there is no intermediate node holding a number that could be
  re-multiplied. Double-discounting a chain here would require deliberately
  discounting twice in one expression.

  ## Swaps are worth the improvement, not the pick

  AVC's Important Rule: "A swap should never be valued as though the team owns
  both first-round picks." `swap_dav/2` returns

      E[ max(value of the rank-N pick received − value of the pick otherwise kept, 0) ] × discount

  with the `max` taken **inside** each simulation and averaged after, never
  `max(E[received] − E[kept], 0)`. The difference is the option value and it is
  the entire asset: two picks with identical distributions have equal means, so
  the outside-the-expectation form values every swap right in the league at
  exactly zero.

  ### `:dav` is the swap right; `:inventory_dav` is the pick inventory

  These are different questions and a swap answers them with different numbers.
  `OutcomeEvaluator` **absorbs** a pooled pick out of its owner's range claims —
  the pool's outcome holders are paid instead — so a team that contributed its
  own pick to a pool holds only its swap outcome afterwards. Valuing that outcome
  incrementally is right for "what is this swap right worth" and badly wrong for
  "what is this team's draft capital": NOP contributes its 2027 first and holds
  the more-favorable rank, so its incremental gain is a few DAV points while the
  pick it actually receives is worth 60+.

  Every result therefore carries both:

    * `:dav` — AVC's asset value. Incremental for a swap, full for a pick claim.
    * `:inventory_dav` — what the team ends the draft holding. Identical to
      `:dav` for pick claims; the discounted expected value of the rank-N pick
      **received** for a swap outcome.

  Draft Capital Value (Phase 07 T6, Pillar 5 Metric 1) sums `:inventory_dav`.
  Summing `:dav` across a league understates every pool contributor by roughly
  the whole value of its own pick; summing `:inventory_dav` conserves it exactly,
  because each pooled pick's value flows to whichever rank receives it (in the
  2027 UTA/CLE/MIN ladder, three member picks and three ranks: MEM takes the
  most favorable, UTA the second, PHX the least, and CLE and MIN — members
  holding no rank — correctly end with nothing).

  ## Determinism

  Two picks in a pool are enumerated exactly (900 slot pairs); three or more are
  sampled, because the joint enumeration grows as 30ⁿ. Monte Carlo is seeded from
  the pool's sorted member pick ids plus the valuation date, so the same pool
  values identically on every run and every machine, and the seed, iteration
  count, variance and standard error are returned for storage alongside the
  grade (interpretation notes, "Deterministic Monte Carlo").

  Deriving the seed from the *pool* rather than the outcome has a property worth
  keeping: a team holding two ranks of the same pool (HOU holds ranks 1 and 2 of
  HOU/DAL/PHX 2029) values both against the same sampled draft, so its two rights
  cannot contradict each other about which pick was most favorable.

  Sampling is threaded through `:rand`'s explicit state rather than the process
  dictionary, so valuing an asset never perturbs a caller's random state — a
  grade must not depend on how many swaps were valued before it.

  ## What this module does not decide

    * **Correlation between picks.** Members of a pool are sampled
      independently; two picks from the same draft can land on the same slot.
      AVC names this limitation. Ranks are still assigned deterministically among
      ties, so a collision changes no value.
    * **Unresolved continuations.** An `{:asset, _}` terminal — a chain whose
      next pick was not supplied, cycles, or exceeded the hop cap — is not
      silently worth zero. `dav/2` raises unless the caller passes
      `unresolved: :provisional`, which returns the resolved mass as an explicit
      lower bound flagged `provisional?: true` (AVC: expose a conservative range,
      never fabricate precision).
  """

  alias TradeMachine.GMGradeV2.DAV.OutcomeEvaluator
  alias TradeMachine.GMGradeV2.DAV.SlotCurve
  alias TradeMachine.GMGradeV2.DAV.SlotProbability
  alias TradeMachine.GMGradeV2.ModelVersion

  # AVC's recommended simulation count per swap asset.
  @default_iterations 10_000

  # Pools up to this size are enumerated exactly instead of sampled: two 30-slot
  # distributions are 900 joint outcomes, which is cheaper than 10_000 samples
  # and carries no sampling error at all.
  @exact_member_limit 2

  @typedoc """
  Everything a valuation needs that is not on the claim itself.

    * `:valuation_date` — the frozen date the grade is anchored to. Required.
      Both `years_ahead` and the time discount are measured from its **calendar
      year**: the draft falls mid-year, so a valuation date anywhere in year Y
      treats the year-Y draft as the current one. `Date.utc_today()` is never
      called here.
    * `:team_cs_scores` — `%{team_id => 0..100}`, the frozen Competitive Strength
      score per originating team, from the Phase 04 benchmark and never
      recomputed from a scenario roster. Required for any pick whose slot is not
      already known.
    * `:slot_distribution_fn` — supply a distribution function directly instead,
      taking `%{pick_id:, year:, round:, original_team_id:, pick_number:}` and
      returning `[{absolute_slot, probability}]`. Wins over `:team_cs_scores`.
      This is the memoization seam: a 30-team benchmark build otherwise recomputes
      the same team-year distribution once per claim that touches it.
    * `:iterations` — Monte Carlo sample count for pools of 3+ (default 10_000).
    * `:unresolved` — `:raise` (default) or `:provisional`; see the moduledoc.
    * `:override` — Phase 16 lab constant overrides, forwarded to `ModelVersion`.
  """
  @type ctx :: %{
          required(:valuation_date) => Date.t(),
          optional(:team_cs_scores) => %{binary() => number()},
          optional(:slot_distribution_fn) => (map() -> [{pos_integer(), number()}]),
          optional(:iterations) => pos_integer(),
          optional(:unresolved) => :raise | :provisional,
          optional(:override) => map()
        }

  @typedoc """
  A valued claim. `:dav` is the asset's value, `:inventory_dav` is what the team
  ends up holding — see the moduledoc on why a swap answers those differently.
  """
  @type result :: %{dav: float(), inventory_dav: float(), diagnostics: map()}

  @doc """
  DAV of one controlled claim from `OutcomeEvaluator.controlled_claims/5`.

  A `:whole` or `:range_part` claim is valued over its terminal outcomes; a
  `:swap_outcome` claim delegates to `swap_dav/2`. A `:rollover_fallback` claim
  raises — it is a marker for a continuation the evaluator could not resolve, and
  what it is worth is a decision for the caller, not a default.

  Diagnostics for a pick claim:

      %{
        method: :exact,
        version: "1.0.0",
        terminal_outcomes: 30,
        undiscounted_dav: 55.4,          # Σ p × value(slot), before any discount
        conveyance_probability: 0.667,   # mass that resolves to a real slot
        extinguished_probability: 0.333,
        unresolved_probability: 0.0,
        provisional?: false,
        years: [2028, 2029]              # the distinct outcome years discounted
      }
  """
  @spec dav(map(), ctx()) :: result()
  def dav(claim, ctx)

  def dav(%{kind: :swap_outcome} = claim, ctx), do: swap_dav(claim, ctx)

  def dav(%{kind: :rollover_fallback} = claim, _ctx) do
    raise ArgumentError,
          "cannot value a :rollover_fallback claim (reason: #{inspect(claim.reason)}) — it marks " <>
            "a continuation OutcomeEvaluator could not resolve from the supplied graph. Supply " <>
            "the missing pick and rebuild the claims, or decide explicitly what the unresolved " <>
            "continuation is worth. It is not zero by default."
  end

  def dav(%{kind: kind} = claim, ctx) when kind in [:whole, :range_part] do
    ctx = normalize!(ctx)
    outcomes = OutcomeEvaluator.terminal_outcomes(claim, ctx.slot_distribution_fn)

    {contributions, undiscounted, conveyed, extinguished, unresolved, years} =
      Enum.reduce(outcomes, {[], [], [], [], [], MapSet.new()}, fn outcome, acc ->
        accumulate(outcome, ctx, acc)
      end)

    unresolved_probability = canonical_sum(unresolved)
    provisional? = unresolved_probability > 0.0

    if provisional? and ctx.unresolved == :raise do
      raise ArgumentError,
            "claim #{inspect(claim.part_id || claim.pick_id)} carries " <>
              "#{Float.round(unresolved_probability * 100, 2)}% probability of converting into " <>
              "an asset this model cannot value (an unresolved rollover continuation). Treating " <>
              "that mass as worthless understates the claim and treating it as conveying " <>
              "overstates it, so neither is chosen for you. Supply the missing picks, or pass " <>
              "`unresolved: :provisional` to accept the resolved mass as an explicit lower bound."
    end

    value = canonical_sum(contributions)

    %{
      dav: value,
      inventory_dav: value,
      diagnostics: %{
        method: :exact,
        version: version(),
        terminal_outcomes: length(outcomes),
        undiscounted_dav: canonical_sum(undiscounted),
        conveyance_probability: canonical_sum(conveyed),
        extinguished_probability: canonical_sum(extinguished),
        unresolved_probability: unresolved_probability,
        provisional?: provisional?,
        years: years |> MapSet.to_list() |> Enum.sort()
      }
    }
  end

  def dav(other, _ctx) do
    raise ArgumentError,
          "expected a claim from OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}"
  end

  @doc """
  DAV of a swap right: the expected improvement it creates, discounted once.

  `swap_claim` is the `:swap_outcome` claim from
  `OutcomeEvaluator.controlled_claims/5` — the swap group as *this* team holds
  it, carrying the pool's members and the rank the team owns. A bare
  `TradeMachine.NBA.SwapGroup` is deliberately not accepted: a group with three
  ranked outcomes is three different assets held by up to three teams, and
  "value this group" has no single answer.

  Per simulation: draw a slot for every pool member, drop the members whose slot
  falls outside the band they contributed (a protected swap only reaches the pool
  when the protection misses — the retained band is a separate range claim and is
  already counted there), rank the survivors most-favorable-first, and compare
  the pick at this claim's rank against the pick the team would otherwise have
  kept. That baseline is the pool member originating from this team, and it is
  `0` when the team contributed nothing — a team that pooled no pick of its own
  is not swapping, it is receiving, and the rank it holds is worth the whole pick
  it delivers.

  Diagnostics:

      %{
        method: :exact | :monte_carlo,
        version: "1.0.0",
        seed: {a, b, c} | nil,           # nil for exact enumeration
        iterations: 10_000 | nil,
        std_error: 0.0,                  # in :dav units (discounted); 0.0 when exact
        variance: 0.0,
        members: 2,
        baseline_dav: 41.2,              # discounted E[pick otherwise kept]
        received_dav: 48.9,              # discounted E[rank-N pick received] = :inventory_dav
        participation_probability: 1.0   # mass where the rank exists at all
      }
  """
  @spec swap_dav(map(), ctx()) :: result()
  def swap_dav(%{kind: :swap_outcome} = claim, ctx) do
    ranks = [pool_rank!(claim, pool_members!(claim))]
    swap_portfolio_dav(claim, ranks, ctx)
  end

  def swap_dav(other, _ctx) do
    raise ArgumentError,
          "swap_dav/2 takes a :swap_outcome claim from " <>
            "OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}"
  end

  @doc """
  Every rank one team holds in one pool, valued as a single joint holding.

  `swap_dav/2` is the one-rank case of this and calls it, so there is exactly one
  definition of a swap's gain rather than one per arity.

  AVC's rule is `E[max(received − surrendered, 0)]` with the max taken **inside
  each draft**, and that does not decompose across ranks: a team holding ranks 1
  and 2 surrenders one pick and receives two, so the surplus that matters is
  `Σ received − kept` in each simulated draft, floored once. Summing the ranks'
  individual `:dav` values subtracts the baseline once per rank *and* discards
  the drafts each rank's own floor zeroed; taking the floor outside the
  expectation instead — `max(Σ E[received] − E[kept], 0)` — is the same
  substitution this module's moduledoc calls wrong for a single rank, and it
  prices a stochastic holding at 0 whenever the joint surplus changes sign
  between drafts.

  `:dav` is the joint incremental gain. `:inventory_dav` is `E[Σ received]`, what
  the team ends the draft holding across those ranks.
  """
  @spec swap_portfolio_dav(map(), [pos_integer()], ctx()) :: result()
  def swap_portfolio_dav(%{kind: :swap_outcome} = claim, ranks, ctx)
      when is_list(ranks) and ranks != [] do
    ctx = normalize!(ctx)
    members = pool_members!(claim)

    ranks =
      ranks |> Enum.map(&pool_rank!(%{claim | rank: &1}, members)) |> Enum.sort() |> unique!()

    baseline_pick_id = baseline_pick_id(claim, members)

    # Sorted by pick id, so neither the seed, the sampling order, nor the order
    # 900 enumerated products are summed in depends on how the caller happened to
    # order the pool (D-50).
    pool =
      members
      |> Enum.map(fn member ->
        {member, member |> pick_ref() |> ctx.slot_distribution_fn.() |> validate!(member)}
      end)
      |> Enum.sort_by(fn {member, _distribution} -> member.pick_id end)

    {stats, method, seed, iterations} =
      if length(members) <= @exact_member_limit,
        do: enumerate(pool, ranks, baseline_pick_id),
        else: simulate(pool, ranks, baseline_pick_id, ctx)

    discount = discount(claim.year, ctx)

    %{
      dav: stats.gain * discount,
      inventory_dav: stats.received * discount,
      diagnostics: %{
        method: method,
        version: version(),
        seed: seed,
        iterations: iterations,
        ranks: ranks,
        std_error: std_error(stats.gain_variance, iterations) * discount,
        variance: stats.gain_variance * discount * discount,
        # `:inventory_dav` is what all three benchmark metrics sum, and when it
        # is sampled it carries its own uncertainty — one that is *not* the
        # gain's. A rank whose gain is identically zero (it never beats the pick
        # the team pooled) still receives a pick that varies from draft to draft,
        # so reporting the gain's standard error as the claim's would state zero
        # uncertainty on a number that has some, on a write-once row.
        inventory_std_error: std_error(stats.received_variance, iterations) * discount,
        inventory_variance: stats.received_variance * discount * discount,
        members: length(members),
        baseline_dav: stats.baseline * discount,
        received_dav: stats.received * discount,
        participation_probability: stats.participation
      }
    }
  end

  def swap_portfolio_dav(%{kind: :swap_outcome}, ranks, _ctx) do
    raise ArgumentError,
          "swap_portfolio_dav/3 takes a non-empty list of ranks the team holds in one pool, " <>
            "got: #{inspect(ranks)}"
  end

  def swap_portfolio_dav(other, _ranks, _ctx) do
    raise ArgumentError,
          "swap_portfolio_dav/3 takes a :swap_outcome claim from " <>
            "OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}"
  end

  defp unique!(ranks) do
    if length(Enum.uniq(ranks)) == length(ranks) do
      ranks
    else
      raise ArgumentError,
            "a team cannot hold the same swap rank twice, got: #{inspect(ranks)}. Counting one " <>
              "rank twice would pay it twice."
    end
  end

  @doc """
  AVC's time discount for an outcome resolving in `year`: `0.92^(year − valuation year)`.

  Years before the valuation date raise rather than compounding upward into a
  premium — a draft that has already happened is not a more valuable asset.
  """
  @spec discount(integer(), ctx() | map()) :: float()
  def discount(year, ctx) when is_integer(year) do
    ctx = normalize!(ctx)
    years_ahead = year - ctx.valuation_year

    if years_ahead < 0 do
      raise ArgumentError,
            "outcome year #{year} precedes the valuation year #{ctx.valuation_year}; the time " <>
              "discount is only defined forward. A past draft is not an asset."
    end

    :math.pow(ctx.discount_rate, years_ahead)
  end

  @doc "Version of the DAV constants, frozen onto grades that read this model."
  @spec version() :: String.t()
  def version, do: Map.fetch!(ModelVersion.versions(), :dav)

  # ── Pick-claim accumulation ───────────────────────────────────────────

  defp accumulate(outcome, ctx, {values, undiscounted, conveyed, gone, unresolved, years}) do
    case outcome.value_basis do
      {:slot, slot} ->
        base = outcome.probability * SlotCurve.value(slot)

        {[base * discount(outcome.year, ctx) | values], [base | undiscounted],
         [outcome.probability | conveyed], gone, unresolved, MapSet.put(years, outcome.year)}

      :extinguished ->
        {values, undiscounted, conveyed, [outcome.probability | gone], unresolved, years}

      {:asset, _fallback} ->
        {values, undiscounted, conveyed, gone, [outcome.probability | unresolved], years}
    end
  end

  # Float addition is not associative, so a total that depends on term order is
  # not reproducible (D-50). Sorting first makes every sum canonical regardless
  # of the order the walk happened to emit its outcomes in.
  #
  # The `* 1.0` is not decoration: `Enum.sum([])` is the integer `0`, and a
  # diagnostics map that reports `0` for one claim and `0.0` for the next is a
  # different jsonb document for the same state on a write-once table.
  defp canonical_sum(terms), do: terms |> Enum.sort() |> Enum.sum() |> Kernel.*(1.0)

  # ── Swap: exact enumeration ───────────────────────────────────────────

  # Every joint slot assignment, weighted by the product of its marginals. Exact
  # for one or two members; 30ⁿ makes it the wrong tool beyond that.
  #
  # Enumeration is exact, so both variances are 0.0 — the estimate has no
  # sampling error, not merely an unmeasured one.
  defp enumerate(pool, ranks, baseline_pick_id) do
    {gain, received, baseline, participation} =
      pool
      |> joint_assignments()
      |> Enum.reduce({0.0, 0.0, 0.0, 0.0}, fn {assignment, probability}, {g, r, b, part} ->
        {slot_received, slot_baseline, participated?} =
          evaluate(assignment, ranks, baseline_pick_id)

        {g + probability * max(slot_received - slot_baseline, 0.0),
         r + probability * slot_received, b + probability * slot_baseline,
         part + if(participated?, do: probability, else: 0.0)}
      end)

    stats = %{
      gain: gain,
      received: received,
      baseline: baseline,
      participation: participation,
      gain_variance: 0.0,
      received_variance: 0.0
    }

    {stats, :exact, nil, nil}
  end

  defp joint_assignments([]), do: [{[], 1.0}]

  defp joint_assignments([{member, distribution} | rest]) do
    tails = joint_assignments(rest)

    for {slot, probability} <- distribution,
        {tail, tail_probability} <- tails,
        do: {[{member, slot} | tail], probability * tail_probability}
  end

  # ── Swap: seeded Monte Carlo ──────────────────────────────────────────

  defp simulate(pool, ranks, baseline_pick_id, ctx) do
    iterations = ctx.iterations
    seed = seed_for(pool, ctx)

    cumulative =
      Enum.map(pool, fn {member, distribution} -> {member, cumulative(distribution)} end)

    zero = {0.0, 0.0, 0.0, 0.0, 0.0, 0}

    {{gain, gain_squared, received, received_squared, baseline, hits}, _state} =
      Enum.reduce(1..iterations, {zero, :rand.seed_s(:exsss, seed)}, fn _i,
                                                                        {{g, g2, r, r2, b, hits},
                                                                         state} ->
        {assignment, state} = draw(cumulative, state)

        {slot_received, slot_baseline, participated?} =
          evaluate(assignment, ranks, baseline_pick_id)

        sample = max(slot_received - slot_baseline, 0.0)

        {{g + sample, g2 + sample * sample, r + slot_received, r2 + slot_received * slot_received,
          b + slot_baseline, hits + if(participated?, do: 1, else: 0)}, state}
      end)

    n = iterations
    gain_mean = gain / n
    received_mean = received / n

    stats = %{
      gain: gain_mean,
      received: received_mean,
      baseline: baseline / n,
      participation: hits / n,
      gain_variance: sample_variance(gain_squared, gain_mean, n),
      received_variance: sample_variance(received_squared, received_mean, n)
    }

    {stats, :monte_carlo, seed, iterations}
  end

  # The pool's pick ids (already canonically ordered) plus the valuation date —
  # stable across runs, machines and Erlang releases, and identical for every
  # rank drawn from the same pool, so a team holding two ranks sees one
  # consistent draft.
  defp seed_for(pool, ctx) do
    material =
      Enum.map_join(pool, "|", fn {member, _distribution} -> to_string(member.pick_id) end)
      |> Kernel.<>("@" <> Date.to_iso8601(ctx.valuation_date))

    <<a::64, b::64, c::64, _rest::binary>> = :crypto.hash(:sha256, material)
    {a, b, c}
  end

  defp cumulative(distribution) do
    distribution
    |> Enum.reduce({[], 0.0}, fn {slot, probability}, {acc, total} ->
      {[{total + probability, slot} | acc], total + probability}
    end)
    |> elem(0)
    |> Enum.reverse()
  end

  defp draw(cumulative, state) do
    Enum.map_reduce(cumulative, state, fn {member, bands}, state ->
      {u, state} = :rand.uniform_s(state)
      {{member, inverse_cdf(bands, u)}, state}
    end)
  end

  # Inverse-CDF sampling. The final band's cumulative probability can land a
  # hair under 1.0 after renormalization, so an unmatched draw takes the last
  # slot rather than returning nil.
  defp inverse_cdf(bands, u) do
    Enum.find_value(bands, fn {cumulative, slot} -> if u <= cumulative, do: slot end) ||
      bands |> List.last() |> elem(1)
  end

  defp sample_variance(_sum_of_squares, _mean, n) when n < 2, do: 0.0

  defp sample_variance(sum_of_squares, mean, n),
    do: max((sum_of_squares - n * mean * mean) / (n - 1), 0.0)

  defp std_error(_variance, nil), do: 0.0
  defp std_error(variance, iterations), do: :math.sqrt(variance / iterations)

  # ── Swap: one simulated draft ─────────────────────────────────────────

  # A member only reaches the pool when its slot falls inside the band it
  # contributed: a swap over the 6-30 slice of a top-5-protected pick simply does
  # not happen in the worlds where the pick lands 1-5, and that world is already
  # valued as the original team's retained range claim.
  # Sums every rank the team holds in this draft, so the floor above is applied
  # to the joint surplus once rather than to each rank separately.
  # `participated?` means every held rank was filled — for one rank that is the
  # original meaning unchanged.
  defp evaluate(assignment, ranks, baseline_pick_id) do
    participants =
      for {member, slot} <- assignment,
          in_band?(member, slot),
          do: {slot, member.pick_id}

    sorted = Enum.sort(participants)

    {received, filled} =
      Enum.reduce(ranks, {0.0, 0}, fn rank, {sum, filled} ->
        case Enum.at(sorted, rank - 1) do
          nil -> {sum, filled}
          {slot, _pick_id} -> {sum + SlotCurve.value(slot), filled + 1}
        end
      end)

    baseline =
      Enum.find_value(assignment, 0.0, fn {member, slot} ->
        if member.pick_id == baseline_pick_id and in_band?(member, slot),
          do: SlotCurve.value(slot)
      end)

    {received, baseline, filled == length(ranks)}
  end

  defp in_band?(%{range: %Range{} = range}, slot), do: slot in range

  # ── Swap: claim shape ─────────────────────────────────────────────────

  defp pool_members!(%{members: members}) when is_list(members) and members != [], do: members

  defp pool_members!(claim) do
    raise ArgumentError,
          "swap claim #{inspect(claim.swap_group_id)} has no resolvable pool members " <>
            "(#{inspect(claim.members)}). A pool whose picks were not supplied to " <>
            "controlled_claims/5 would silently value at zero; supply them."
  end

  defp pool_rank!(%{rank: rank}, members) when is_integer(rank) and rank >= 1 do
    if rank > length(members) do
      raise ArgumentError,
            "swap outcome holds rank #{rank} of a #{length(members)}-pick pool, so it can never " <>
              "be satisfied. That is a data error, not a worthless asset."
    end

    rank
  end

  defp pool_rank!(claim, _members) do
    raise ArgumentError,
          "swap claim #{inspect(claim.swap_group_id)} has no rank (#{inspect(claim.rank)}); a " <>
            "swap outcome is a rank in a pool."
  end

  # The pick this team would otherwise have kept: the pool member that originates
  # with the outcome holder. `nil` — the holder pooled nothing of its own — means
  # a baseline of zero, so the rank is worth the whole pick it delivers.
  defp baseline_pick_id(claim, members) do
    members
    |> Enum.filter(&(&1.original_team_id == claim.team_id))
    |> Enum.map(& &1.pick_id)
    |> Enum.sort()
    |> List.first()
  end

  # ── Context ───────────────────────────────────────────────────────────

  defp normalize!(%{__dav_ctx__: true} = ctx), do: ctx

  defp normalize!(ctx) when is_map(ctx) do
    valuation_date = valuation_date!(ctx)
    override = Map.get(ctx, :override, %{})

    normalized = %{
      __dav_ctx__: true,
      valuation_date: valuation_date,
      valuation_year: valuation_date.year,
      discount_rate: ModelVersion.dav_time_discount(override),
      team_cs_scores: Map.get(ctx, :team_cs_scores, %{}),
      iterations: iterations!(ctx),
      unresolved: unresolved!(ctx),
      override: override
    }

    Map.put(normalized, :slot_distribution_fn, slot_distribution_fn(ctx, normalized))
  end

  defp normalize!(other) do
    raise ArgumentError,
          "ctx must be a map carrying at least :valuation_date, got: #{inspect(other)}"
  end

  defp valuation_date!(ctx) do
    case Map.fetch(ctx, :valuation_date) do
      {:ok, %Date{} = date} ->
        date

      other ->
        raise ArgumentError,
              ":valuation_date must be the frozen Date the grade is anchored to, got: " <>
                "#{inspect(other)}. Date.utc_today/0 is not a valuation date — a stored grade " <>
                "has to reproduce, and today moves."
    end
  end

  # At least two: the sample variance of one draw is undefined, and reporting it
  # as 0.0 states perfect precision on a single sample written to a row that can
  # never be corrected.
  defp iterations!(ctx) do
    case Map.get(ctx, :iterations, @default_iterations) do
      n when is_integer(n) and n >= 2 ->
        n

      other ->
        raise ArgumentError,
              ":iterations must be an integer of at least 2 — a single draw has no measurable " <>
                "sampling error, and reporting 0.0 for it would freeze false precision. Got: " <>
                "#{inspect(other)}"
    end
  end

  defp unresolved!(ctx) do
    case Map.get(ctx, :unresolved, :raise) do
      policy when policy in [:raise, :provisional] ->
        policy

      other ->
        raise ArgumentError,
              ":unresolved must be :raise or :provisional, got: #{inspect(other)}"
    end
  end

  defp slot_distribution_fn(ctx, normalized) do
    case Map.get(ctx, :slot_distribution_fn) do
      nil ->
        fn pick_ref -> SlotProbability.distribution(spec(pick_ref, normalized)) end

      fun when is_function(fun, 1) ->
        fun

      other ->
        raise ArgumentError,
              ":slot_distribution_fn must be a 1-arity function, got: #{inspect(other)}"
    end
  end

  defp spec(pick_ref, ctx) do
    %{
      round: pick_ref.round,
      years_ahead: pick_ref.year - ctx.valuation_year,
      pick_number: pick_ref.pick_number,
      team_cs_score: cs_score(pick_ref, ctx)
    }
  end

  defp cs_score(%{pick_number: pick_number}, _ctx) when is_integer(pick_number), do: nil

  defp cs_score(pick_ref, ctx) do
    case Map.fetch(ctx.team_cs_scores, pick_ref.original_team_id) do
      {:ok, score} ->
        score

      :error ->
        raise ArgumentError,
              "no frozen Competitive Strength score for team " <>
                "#{inspect(pick_ref.original_team_id)} (pick #{inspect(pick_ref.pick_id)}, " <>
                "#{pick_ref.year} round #{pick_ref.round}). Its slot is unknown, so it can only " <>
                "be projected from team strength — pass :team_cs_scores from the frozen Phase 04 " <>
                "benchmark, or supply :slot_distribution_fn directly."
    end
  end

  defp pick_ref(source),
    do: Map.take(source, [:pick_id, :year, :round, :original_team_id, :pick_number])

  # The swap path does not go through terminal_outcomes/2, so it fails closed on
  # the same distribution errors — a round-2 pool handed round-1 slots would put
  # every member outside its own band and value the whole pool at zero.
  defp validate!(distribution, member),
    do: OutcomeEvaluator.assert_distribution!(distribution, member.round, pick_ref(member))
end
slot_curve.exElixir

lib/trade_machine/gm_grade_v2/dav/slot_curve.ex

Base Draft-Slot Value: what a single draft slot is worth on AVC's ~0–100 Draft Asset Value scale.

This is the one place in the DAV model where a draft slot becomes a number. Everything else in Phase 07 — slot probability distributions, protection recursion, swap enumeration, the 0.92-per-year time discount — multiplies or averages the values this module returns. Keeping the curve here (rather than inlined at each call site) is what lets a stored grade name the exact curve that produced it: the constants are versioned as :dav in TradeMachine.GMGradeV2.ModelVersion.versions/0 and read back by version/0 .

The two formulas (AVC Pillar 5, "Base Draft-Slot Value")

    first_round(s)  = 20 + 80 × ((31 − s) / 30) ^ 1.35     for s in 1..30
    second_round(s) = 3  + 17 × ((61 − s) / 30) ^ 1.15     for s in 31..60

Each round is a floor plus a decaying premium. The first round's floor of 20 is its limit at the hypothetical slot 31, and the second-round formula evaluates at slot 31 to exactly 3 + 17 = 20 — so the rounds meet by construction, not by coincidence. The visible step from slot 30 (20.81) to slot 31 (20.00) is one slot's worth of ordinary decline, not a discontinuity; a curve that jumped at the round boundary would make the 31st pick a different kind of asset from the 30th, which is not how the market treats them.

The exponents differ because the rounds are shaped differently: 1.35 makes the first round steep at the top (the first three picks are worth far more than picks 8–10), while 1.15 keeps the second round nearly linear, because one second-rounder is not dramatically better than another.

The curve is convex — never value a mean slot

value/1 accepts an **integer** slot and raises on anything else, including a float. That is a deliberate guard, not an oversight. Because the curve is convex, the value of the average slot is *not* the average value of the slot distribution — and the gap is one-directional, always understating the asset:

    value(15.5)                     = 52.8   ← wrong
    Σ p(s) × value(s) over 1..30    = 55.4   ← right (uniform 1–30, same mean)

AVC's notes make this the central modelling instruction for distant picks: uncertainty widens the further out a draft is, and because the curve is nonlinear that extra spread makes an unprotected future pick worth *more* than its expected slot suggests. A caller that reaches for value(expected_slot) has silently thrown that away. Making the fractional slot raise turns the mistake into a crash instead of a plausible-looking number.

Integrate over the distribution from TradeMachine.GMGradeV2.DAV.SlotProbability instead.

DAV is not `predicted_aav`

TradeMachine.Valuation.Models.PickCurve also maps a slot to a number, and the two must never be conflated (AVC, "Existing Pick Valuation Is Related but Not DAV"). That model returns **dollars** — the expected annual production value of the player the pick becomes — off a VORP-lineage curve, with its own PV discount, flat protection haircuts and fixed swap factors. This one returns a **unitless franchise-asset score** on AVC's 0–100 scale, and its downstream machinery values probability distributions, recursive protections and incremental swap gains instead. What Phase 07 reuses from that model is its *architecture* — pure functions, versioned constants, diagnostics — never its numbers. Any bridge between the two scales would have to be calibrated; there is no identity between them.

These are initial parameters

AVC states the curves are starting points to be calibrated against historical trade prices, rookie-contract surplus value, and realised outcomes by slot. When they are retuned, bump :dav in ModelVersion.versions/0 so grades already stored stay reproducible against the curve that produced them.

Download source ↓

SHA-256 74574bc1d768ae335f512dbd392f7f0dde855a17ad17e90dd4e7bff6ce726b90

defmodule TradeMachine.GMGradeV2.DAV.SlotCurve do
  @moduledoc """
  Base Draft-Slot Value: what a single draft slot is worth on AVC's ~0–100 Draft
  Asset Value scale.

  This is the one place in the DAV model where a draft slot becomes a number.
  Everything else in Phase 07 — slot probability distributions, protection
  recursion, swap enumeration, the 0.92-per-year time discount — multiplies or
  averages the values this module returns. Keeping the curve here (rather than
  inlined at each call site) is what lets a stored grade name the exact curve
  that produced it: the constants are versioned as `:dav` in
  `TradeMachine.GMGradeV2.ModelVersion.versions/0` and read back by `version/0`.

  ## The two formulas (AVC Pillar 5, "Base Draft-Slot Value")

      first_round(s)  = 20 + 80 × ((31 − s) / 30) ^ 1.35     for s in 1..30
      second_round(s) = 3  + 17 × ((61 − s) / 30) ^ 1.15     for s in 31..60

  Each round is a floor plus a decaying premium. The first round's floor of 20 is
  its limit at the hypothetical slot 31, and the second-round formula evaluates
  at slot 31 to exactly `3 + 17 = 20` — so the rounds meet by construction, not
  by coincidence. The visible step from slot 30 (20.81) to slot 31 (20.00) is one
  slot's worth of ordinary decline, not a discontinuity; a curve that jumped at
  the round boundary would make the 31st pick a different kind of asset from the
  30th, which is not how the market treats them.

  The exponents differ because the rounds are shaped differently: 1.35 makes the
  first round steep at the top (the first three picks are worth far more than
  picks 8–10), while 1.15 keeps the second round nearly linear, because one
  second-rounder is not dramatically better than another.

  ## The curve is convex — never value a mean slot

  `value/1` accepts an **integer** slot and raises on anything else, including a
  float. That is a deliberate guard, not an oversight. Because the curve is
  convex, the value of the average slot is *not* the average value of the slot
  distribution — and the gap is one-directional, always understating the asset:

      value(15.5)                     = 52.8   ← wrong
      Σ p(s) × value(s) over 1..30    = 55.4   ← right (uniform 1–30, same mean)

  AVC's notes make this the central modelling instruction for distant picks:
  uncertainty widens the further out a draft is, and because the curve is
  nonlinear that extra spread makes an unprotected future pick worth *more* than
  its expected slot suggests. A caller that reaches for `value(expected_slot)`
  has silently thrown that away. Making the fractional slot raise turns the
  mistake into a crash instead of a plausible-looking number.

  Integrate over the distribution from `TradeMachine.GMGradeV2.DAV.SlotProbability`
  instead.

  ## DAV is not `predicted_aav`

  `TradeMachine.Valuation.Models.PickCurve` also maps a slot to a number, and the
  two must never be conflated (AVC, "Existing Pick Valuation Is Related but Not
  DAV"). That model returns **dollars** — the expected annual production value of
  the player the pick becomes — off a VORP-lineage curve, with its own PV
  discount, flat protection haircuts and fixed swap factors. This one returns a
  **unitless franchise-asset score** on AVC's 0–100 scale, and its downstream
  machinery values probability distributions, recursive protections and
  incremental swap gains instead. What Phase 07 reuses from that model is its
  *architecture* — pure functions, versioned constants, diagnostics — never its
  numbers. Any bridge between the two scales would have to be calibrated; there
  is no identity between them.

  ## These are initial parameters

  AVC states the curves are starting points to be calibrated against historical
  trade prices, rookie-contract surplus value, and realised outcomes by slot.
  When they are retuned, bump `:dav` in `ModelVersion.versions/0` so grades
  already stored stay reproducible against the curve that produced them.
  """

  alias TradeMachine.GMGradeV2.ModelVersion

  # First round (slots 1–30): 20 + 80 × ((31 − s)/30)^1.35. The floor is the
  # formula's value at the hypothetical slot 31, which is what makes it meet the
  # second-round curve.
  @first_floor 20
  @first_premium 80
  @first_exponent 1.35

  # Second round (slots 31–60): 3 + 17 × ((61 − s)/30)^1.15.
  @second_floor 3
  @second_premium 17
  @second_exponent 1.15

  @doc """
  Base DAV of a draft slot, at full precision.

  `slot` must be an integer in 1..60. First round (1–30) and second round (31–60)
  use different formulas; see the moduledoc.

  Approximate values, matching AVC's published tables:

  | Slot | DAV  | Slot | DAV  |
  |-----:|-----:|-----:|-----:|
  |    1 | 100.0|   31 | 20.0 |
  |    2 |  96.4|   35 | 17.4 |
  |    3 |  92.9|   40 | 14.3 |
  |    5 |  85.9|   45 | 11.3 |
  |   10 |  69.4|   50 |  8.4 |
  |   15 |  54.2|   55 |  5.7 |
  |   20 |  40.6|   60 |  3.3 |
  |   25 |  29.1|      |      |
  |   30 |  20.8|      |      |

  Raises `ArgumentError` on a non-integer or out-of-range slot — a fractional
  "expected slot" must be integrated over, not valued directly (see the moduledoc
  on convexity).
  """
  @spec value(integer()) :: float()
  def value(slot) when is_integer(slot) and slot >= 1 and slot <= 30 do
    @first_floor + @first_premium * :math.pow((31 - slot) / 30, @first_exponent)
  end

  def value(slot) when is_integer(slot) and slot >= 31 and slot <= 60 do
    @second_floor + @second_premium * :math.pow((61 - slot) / 30, @second_exponent)
  end

  def value(slot) do
    raise ArgumentError,
          "draft slot must be an integer in 1..60, got: #{inspect(slot)}. " <>
            "The DAV curve is convex, so an expected/fractional slot must never be " <>
            "valued directly — sum value(s) weighted by a slot distribution instead."
  end

  @doc "Version of the DAV constants, frozen onto grades that read this curve."
  @spec version() :: String.t()
  def version, do: Map.fetch!(ModelVersion.versions(), :dav)
end
slot_probability.exElixir

lib/trade_machine/gm_grade_v2/dav/slot_probability.ex

Where a draft pick is likely to land: a probability distribution over draft slots, never a single expected slot.

TradeMachine.GMGradeV2.DAV.SlotCurve turns a slot into DAV; this module says which slots are in play and how likely each one is. The two together are the whole of an unprotected pick's value, and the split is deliberate: because the curve is convex, curve(mean_slot) is strictly less than Σ p(s) × curve(s) for any spread at all, so a model that resolves a future pick to one slot understates it — the further out the draft, the more it understates. AVC states this directly ("the model must integrate the full distribution rather than evaluate only the mean slot"), and SlotCurve.value/1 enforces it by refusing a fractional slot outright. This module is the other half of that contract: it is what a caller reaches for *instead of* a point estimate.

Three cases, one rule

| Case | What we know | Distribution | |---|---|---| | Order finalized | draft_pick.pick_number is set | point mass at that slot | | Current draft, pre-lottery | nothing yet | strength-based (below) | | Future draft | nothing, and won't for years | strength-based, wider each year |

The first two collapse into one rule — **a known pick_number is a point mass, anything else is strength-based** — because v1 does not model official lottery odds. A pre-lottery lottery team is treated exactly like a future team one year out with σ = 4: honest about the uncertainty, wrong about its shape (real lottery odds are flat-topped across the bottom four seeds and cliff-edged at pick 5, not Gaussian). Wiring in the published odds table is a recorded deferral, not an oversight — see "Deferred proposals" in the phase DECISIONS.md , "official lottery-odds distributions".

The strength-based model (AVC Pillar 5, "Expected Draft Position")

    strength slot   = 1 + 29 × (team Competitive Strength / 100)
    expected slot(y)= 15.5 + (strength slot − 15.5) × 0.70^y
    σ(y)            = min(10, 4 + 1.25 × y)

A stronger team picks later, so Competitive Strength maps *upward* onto the slot: CS 0 → slot 1, CS 50 → 15.5, CS 100 → slot 30. Then two things happen as the draft recedes. The mean regresses toward the middle of the round, because nobody can forecast a 2032 standings table — at four years out a 60-win team's pick already projects near 18. And the spread widens to a 10-slot cap, which under a convex curve is what makes a *distant unprotected* first worth more than its shrinking mean suggests. Both effects, and the tension between them, are the point of using a distribution at all:

| years ahead | σ | mean slot for a 60-win team (CS 90) | |---:|---:|---:| | 0 | 4.00 | 27.10 | | 1 | 5.25 | 23.62 | | 2 | 6.50 | 21.18 | | 3 | 7.75 | 19.48 | | 4 | 9.00 | 18.29 | | 5 | 10.00 | 17.45 |

### Competitive Strength comes from the FROZEN benchmark

:team_cs_score is the originating team's Pillar 1 score **as of the frozen Phase 04 benchmark**, never a score recomputed from the scenario's edited roster. This is the frozen-valuation rule and it is a correctness constraint, not hygiene: a user grading a trade could otherwise gut the originating team inside the scenario and watch the pick he is sending out grow more valuable while he does it. The caller resolves the score and passes it in — this module cannot reach for the wrong one because it cannot reach at all.

### Discretization: truncate and renormalize

Slot s takes the normal mass over the band [s − 0.5, s + 0.5] , and the 30 bands of a round are renormalized to sum to 1.0. Mass outside the round is **discarded**, per AVC ("create a truncated probability distribution between picks 1 and 30 and normalize the probabilities to total 100%") — not piled onto the end slots, which is the other common convention and would say something different and wrong: that a team projected near the top has a *lump* of probability on pick 1 specifically, rather than "the model is confident it picks early and cannot resolve where".

Every slot in the round is returned, including ones whose probability underflows to 0.0 . A caller can rely on the list's length and order; a distribution that dropped its tail would silently change shape with σ.

Second-round picks

Slots 31–60 are the first round's model shifted by 30: position within the round is computed identically, regressed toward the round's midpoint (45.5), and offset. This mirrors the real order — both rounds are seeded by record — but it is one simplification worth naming: the second round is *not* lottery-affected, so a bad team's second-rounder is knowable with much more certainty than its first, and σ(0) = 4 overstates that uncertainty. It errs toward spread, which under a convex curve is the direction that slightly overvalues bad teams' seconds; the effect is small on a curve whose whole second round spans 17 DAV points.

What is not modelled here (all recorded, none accidental)

* **Official lottery odds** — deferred, above. * **Correlation between picks from the same team.** Two picks from the same team in the same draft are drawn independently here. AVC names this limitation and suggests sampling one team-season and reusing it; that belongs with the Monte Carlo work in TradeMachine.GMGradeV2.DAV.Valuer , not in a per-pick distribution. * **Franchise trajectory.** A team whose strength rests on one 34-year-old star has a fat downside tail that regression-toward-15.5 does not produce. Deferred with "franchise-trajectory states for distant picks". * **Draft-class strength.** The curve is class-neutral by decision; nothing here scales a class up or down.

Download source ↓

SHA-256 29535727533f3f3ea54f976731cd7be7e3e48a400c287a780e79c54c4c412204

defmodule TradeMachine.GMGradeV2.DAV.SlotProbability do
  @moduledoc """
  Where a draft pick is likely to land: a probability distribution over draft
  slots, never a single expected slot.

  `TradeMachine.GMGradeV2.DAV.SlotCurve` turns a slot into DAV; this module says
  which slots are in play and how likely each one is. The two together are the
  whole of an unprotected pick's value, and the split is deliberate: because the
  curve is convex, `curve(mean_slot)` is strictly less than `Σ p(s) × curve(s)`
  for any spread at all, so a model that resolves a future pick to one slot
  understates it — the further out the draft, the more it understates. AVC states
  this directly ("the model must integrate the full distribution rather than
  evaluate only the mean slot"), and `SlotCurve.value/1` enforces it by refusing
  a fractional slot outright. This module is the other half of that contract: it
  is what a caller reaches for *instead of* a point estimate.

  ## Three cases, one rule

  | Case | What we know | Distribution |
  |---|---|---|
  | Order finalized | `draft_pick.pick_number` is set | point mass at that slot |
  | Current draft, pre-lottery | nothing yet | strength-based (below) |
  | Future draft | nothing, and won't for years | strength-based, wider each year |

  The first two collapse into one rule — **a known `pick_number` is a point
  mass, anything else is strength-based** — because v1 does not model official
  lottery odds. A pre-lottery lottery team is treated exactly like a future
  team one year out with σ = 4: honest about the uncertainty, wrong about its
  shape (real lottery odds are flat-topped across the bottom four seeds and
  cliff-edged at pick 5, not Gaussian). Wiring in the published odds table is a
  recorded deferral, not an oversight — see "Deferred proposals" in the phase
  `DECISIONS.md`, "official lottery-odds distributions".

  ## The strength-based model (AVC Pillar 5, "Expected Draft Position")

      strength slot   = 1 + 29 × (team Competitive Strength / 100)
      expected slot(y)= 15.5 + (strength slot − 15.5) × 0.70^y
      σ(y)            = min(10, 4 + 1.25 × y)

  A stronger team picks later, so Competitive Strength maps *upward* onto the
  slot: CS 0 → slot 1, CS 50 → 15.5, CS 100 → slot 30. Then two things happen as
  the draft recedes. The mean regresses toward the middle of the round, because
  nobody can forecast a 2032 standings table — at four years out a 60-win team's
  pick already projects near 18. And the spread widens to a 10-slot cap, which
  under a convex curve is what makes a *distant unprotected* first worth more
  than its shrinking mean suggests. Both effects, and the tension between them,
  are the point of using a distribution at all:

  | years ahead | σ | mean slot for a 60-win team (CS 90) |
  |---:|---:|---:|
  | 0 | 4.00 | 27.10 |
  | 1 | 5.25 | 23.62 |
  | 2 | 6.50 | 21.18 |
  | 3 | 7.75 | 19.48 |
  | 4 | 9.00 | 18.29 |
  | 5 | 10.00 | 17.45 |

  ### Competitive Strength comes from the FROZEN benchmark

  `:team_cs_score` is the originating team's Pillar 1 score **as of the frozen
  Phase 04 benchmark**, never a score recomputed from the scenario's edited
  roster. This is the frozen-valuation rule and it is a correctness constraint,
  not hygiene: a user grading a trade could otherwise gut the originating team
  inside the scenario and watch the pick he is sending out grow more valuable
  while he does it. The caller resolves the score and passes it in — this module
  cannot reach for the wrong one because it cannot reach at all.

  ### Discretization: truncate and renormalize

  Slot `s` takes the normal mass over the band `[s − 0.5, s + 0.5]`, and the 30
  bands of a round are renormalized to sum to 1.0. Mass outside the round is
  **discarded**, per AVC ("create a truncated probability distribution between
  picks 1 and 30 and normalize the probabilities to total 100%") — not piled onto
  the end slots, which is the other common convention and would say something
  different and wrong: that a team projected near the top has a *lump* of
  probability on pick 1 specifically, rather than "the model is confident it
  picks early and cannot resolve where".

  Every slot in the round is returned, including ones whose probability
  underflows to `0.0`. A caller can rely on the list's length and order; a
  distribution that dropped its tail would silently change shape with σ.

  ## Second-round picks

  Slots 31–60 are the first round's model shifted by 30: position within the
  round is computed identically, regressed toward the round's midpoint (45.5),
  and offset. This mirrors the real order — both rounds are seeded by record —
  but it is one simplification worth naming: the second round is *not*
  lottery-affected, so a bad team's second-rounder is knowable with much more
  certainty than its first, and σ(0) = 4 overstates that uncertainty. It errs
  toward spread, which under a convex curve is the direction that slightly
  overvalues bad teams' seconds; the effect is small on a curve whose whole
  second round spans 17 DAV points.

  ## What is not modelled here (all recorded, none accidental)

    * **Official lottery odds** — deferred, above.
    * **Correlation between picks from the same team.** Two picks from the same
      team in the same draft are drawn independently here. AVC names this
      limitation and suggests sampling one team-season and reusing it; that
      belongs with the Monte Carlo work in `TradeMachine.GMGradeV2.DAV.Valuer`,
      not in a per-pick distribution.
    * **Franchise trajectory.** A team whose strength rests on one 34-year-old
      star has a fat downside tail that regression-toward-15.5 does not produce.
      Deferred with "franchise-trajectory states for distant picks".
    * **Draft-class strength.** The curve is class-neutral by decision; nothing
      here scales a class up or down.
  """

  # AVC's strength→slot map: CS 0 lands on slot 1, CS 100 on slot 30, linearly.
  @slot_floor 1
  @slot_span 29

  # Slots per round, and the round's midpoint — both the regression target and
  # the value CS 50 maps to, which is why a league-average team's pick is
  # unaffected by how far away the draft is.
  @round_slots 30
  @round_midpoint 15.5

  # Future regression: how much of a team's distance from the midpoint survives
  # one more year of not knowing.
  @regression_rate 0.70

  # Draft-slot uncertainty: σ = min(10, 4 + 1.25 × years). The cap binds from
  # year 5 on, where 4 + 1.25 × 5 = 10.25.
  @sd_base 4.0
  @sd_per_year 1.25
  @sd_cap 10.0

  alias TradeMachine.GMGradeV2.ModelVersion

  @typedoc """
  What must be known about a pick to project where it lands.

    * `:round` — 1 or 2. Required, and deliberately not defaulted: a second
      rounder valued as a first is a ~60-point DAV error.
    * `:years_ahead` — whole years from the frozen valuation date to the draft.
      Non-negative; a past draft is not a valuable asset and raises.
    * `:pick_number` — the **absolute** slot (1–60, the spelling
      `draft_picks.pick_number` uses), or `nil`/absent when the order is not yet
      known.
    * `:team_cs_score` — the originating team's frozen Competitive Strength
      score, 0–100. Required whenever `:pick_number` is absent.
  """
  @type context :: %{
          required(:round) => 1 | 2,
          required(:years_ahead) => non_neg_integer(),
          optional(:pick_number) => pos_integer() | nil,
          optional(:team_cs_score) => number() | nil
        }

  @doc """
  Slot distribution for one pick: `[{slot, probability}]`, ascending by slot,
  summing to 1.0.

  A known `pick_number` yields a single-entry point mass; otherwise the full
  round is returned — 30 entries — from the strength-based model.

  Raises `ArgumentError` on a context that cannot be projected rather than
  guessing: a missing or non-1/2 round, a negative or non-integer
  `:years_ahead`, a `:pick_number` outside its round, a Competitive Strength
  score that is missing or outside 0–100, and — the one that catches real data
  bugs — a `:pick_number` on a draft that has not happened yet, which claims
  certainty that cannot exist and would freeze a false point mass into a grade.
  """
  @spec distribution(context()) :: [{pos_integer(), float()}]
  def distribution(context) when is_map(context) do
    round = fetch_round(context)
    years_ahead = fetch_years_ahead(context)

    case Map.get(context, :pick_number) do
      nil -> strength_based(round, years_ahead, fetch_cs_score(context))
      pick_number -> point_mass(pick_number, round, years_ahead)
    end
  end

  @doc """
  AVC's strength-based expected slot, before any regression: `1 + 29 × CS/100`,
  offset into `round`.

  | CS | slot (round 1) |
  |---:|---:|
  | 0 | 1.00 |
  | 25 | 8.25 |
  | 50 | 15.50 |
  | 75 | 22.75 |
  | 100 | 30.00 |

  Fractional by design — it is the mean of a distribution, not a slot. Feeding it
  to `SlotCurve.value/1` raises, which is the intended outcome.
  """
  @spec strength_slot(number(), 1 | 2) :: float()
  def strength_slot(cs_score, round \\ 1)
      when is_number(cs_score) and cs_score >= 0 and cs_score <= 100 and round in [1, 2] do
    offset(round) + @slot_floor + @slot_span * (cs_score / 100)
  end

  @doc """
  The expected slot `years_ahead` years out: the strength slot pulled toward the
  round's midpoint by `0.70^years_ahead`.

  Stays inside the round by construction — it is a convex combination of a slot
  in 1..30 and the midpoint — which is what guarantees the truncated
  distribution below always has mass to normalize.
  """
  @spec expected_slot(number(), non_neg_integer(), 1 | 2) :: float()
  def expected_slot(cs_score, years_ahead, round \\ 1)
      when is_integer(years_ahead) and years_ahead >= 0 do
    midpoint = offset(round) + @round_midpoint

    midpoint +
      (strength_slot(cs_score, round) - midpoint) * :math.pow(@regression_rate, years_ahead)
  end

  @doc """
  Draft-slot standard deviation `years_ahead` out: `min(10, 4 + 1.25 × years)`.

  The cap matters more than the slope. Without it, a pick eight years out would
  carry σ = 14 — wider than the round it is drawn from — and the truncated
  distribution would flatten toward uniform, at which point every distant first
  from every team is worth the same thing. Capping at 10 keeps a distant pick
  uncertain without making it anonymous.
  """
  @spec slot_sd(non_neg_integer()) :: float()
  def slot_sd(years_ahead) when is_integer(years_ahead) and years_ahead >= 0 do
    min(@sd_cap, @sd_base + @sd_per_year * years_ahead)
  end

  @doc "Version of the DAV constants, frozen onto grades that read this model."
  @spec version() :: String.t()
  def version, do: Map.fetch!(ModelVersion.versions(), :dav)

  # ── Point mass ────────────────────────────────────────────────────────

  defp point_mass(pick_number, round, years_ahead) do
    slots = slots(round)

    cond do
      not (is_integer(pick_number) and pick_number in slots) ->
        raise ArgumentError,
              ":pick_number #{inspect(pick_number)} is not a round-#{round} slot " <>
                "(#{inspect(slots)}). Pick numbers are absolute — a second-rounder is " <>
                "31..60, not 1..30."

      years_ahead > 0 ->
        raise ArgumentError,
              "pick #{pick_number} is #{years_ahead} year(s) from the valuation date, so its " <>
                "slot cannot be known. A `:pick_number` on a future draft is contradictory " <>
                "data: honouring it freezes a false certainty into the grade, and ignoring " <>
                "it hides the bad row. Fix the pick, or drop `:pick_number` from the context."

      true ->
        [{pick_number, 1.0}]
    end
  end

  # ── Strength-based distribution ───────────────────────────────────────

  # Truncated discretized normal over the round, renormalized to 1.0. The total
  # is never zero: `expected_slot/3` lands inside the round and σ ≥ 4, so the
  # band holding the mean always carries real mass.
  defp strength_based(round, years_ahead, cs_score) do
    mean = expected_slot(cs_score, years_ahead, round)
    sd = slot_sd(years_ahead)

    masses = Enum.map(slots(round), fn slot -> {slot, band_mass(slot, mean, sd)} end)
    total = Enum.reduce(masses, 0.0, fn {_slot, mass}, acc -> acc + mass end)

    Enum.map(masses, fn {slot, mass} -> {slot, mass / total} end)
  end

  # Normal mass over the unit band centred on the slot. Bands tile the round
  # exactly ([0.5, 30.5] for the first round), so no probability is counted twice
  # or lost between slots before renormalization.
  defp band_mass(slot, mean, sd) do
    normal_cdf((slot + 0.5 - mean) / sd) - normal_cdf((slot - 0.5 - mean) / sd)
  end

  # Standard normal CDF via the error function.
  defp normal_cdf(z), do: 0.5 * (1.0 + :math.erf(z / :math.sqrt(2)))

  # ── Context validation ────────────────────────────────────────────────

  defp fetch_round(context) do
    case Map.fetch(context, :round) do
      {:ok, round} when round in [1, 2] ->
        round

      other ->
        raise ArgumentError,
              ":round must be 1 or 2, got: #{describe(other)}. It is required rather than " <>
                "defaulted because valuing a second-rounder on the first-round curve is a " <>
                "~60-point DAV error that looks like a plausible number."
    end
  end

  defp fetch_years_ahead(context) do
    case Map.fetch(context, :years_ahead) do
      {:ok, years} when is_integer(years) and years >= 0 ->
        years

      other ->
        raise ArgumentError,
              ":years_ahead must be a non-negative integer, got: #{describe(other)}. " <>
                "It is measured from the frozen valuation date, never from today."
    end
  end

  defp fetch_cs_score(context) do
    case Map.fetch(context, :team_cs_score) do
      {:ok, cs_score} when is_number(cs_score) and cs_score >= 0 and cs_score <= 100 ->
        cs_score

      other ->
        raise ArgumentError,
              ":team_cs_score must be the originating team's frozen Competitive Strength " <>
                "score (0–100), got: #{describe(other)}. A pick with no known slot cannot be " <>
                "projected without one, and defaulting to league-average would quietly " <>
                "value every unmatched team's picks identically."
    end
  end

  defp describe(:error), do: "absent"
  defp describe({:ok, value}), do: inspect(value)

  defp slots(round), do: (offset(round) + 1)..(offset(round) + @round_slots)

  defp offset(round), do: (round - 1) * @round_slots
end
outcome_evaluator.exElixir

lib/trade_machine/gm_grade_v2/dav/outcome_evaluator.ex

The canonical rights outcome evaluator: what a team actually controls, and what each of those claims resolves into.

Two functions, answering the two questions every draft-asset number depends on:

* controlled_claims/5 — *which assets are mine?* Returns the *mutually exclusive* claims a team may be paid for. This is the duplicate-claim firewall: a whole pick, the conveyed slice of that pick, and a swap outcome over that slice are three descriptions of overlapping rights, and summing all three would inflate a team's inventory (AVC, "Asset Identity and Duplicate Claims"). * terminal_outcomes/2 — *what does one claim turn into?* Walks the protection band, the rollover chain, and any second-round conversion until the asset **conveys**, **converts** into a different asset, or **extinguishes** — AVC's three terminal states, which are exactly the three shapes of :value_basis .

Everything here is pure. Picks, parts, and swap groups are passed in; nothing queries. That is what keeps the frozen-valuation rule structural rather than aspirational — this module cannot reach for a live scenario roster because it cannot reach at all — and it is why TradeMachine.GMGradeV2.DAV.Valuer , the swap Monte Carlo, and the Phase 04 benchmark builder can all share one interpretation of pick language instead of growing three.

Absolute slots, everywhere

draft_pick_parts.range_low / range_high are stored **within the round** (1–30 for both rounds); draft_picks.pick_number and TradeMachine.GMGradeV2.DAV.SlotCurve are **absolute** (1–60). Everything this module emits — :range on a claim, :retained_range on a chain hop, {:slot, s} on an outcome — is absolute, converted once at the boundary. A second-rounder's 1–30 band becomes 31–60 here. Mixing the two conventions is the single most expensive mistake available in this file: a round-2 band read as round-1 slots misses every slot of its own distribution and silently values the asset at zero. terminal_outcomes/2 raises rather than allow it.

What counts as controlled

Claim eligibility mirrors TradeMachine.NBA.Trades.list_tradeable_pick_parts_for_team/1 , which is the audited answer the trade builders already use — deliberately, so the grader and the builder cannot disagree about who owns what:

* Range parts the team owns that are not conveyed, on picks that are not conveyed and are not legacy is_swap_rights companion rows. * Minus parts absorbed into a swap pool. Membership is part-scoped, so a protected split's retained band stays visible even when its conveyable band sits in a pool (#379); a membership recorded without a part absorbs the pick's non-retained parts. * Plus the swap-group **outcomes** the team holds — the right to the rank-N most favorable pick in a pool, which is what a swap actually is.

Frozen picks ( is_frozen ) are still controlled assets with real DAV — they are merely untradeable — so they are claimed here and flagged :frozen for Phase 07's capacity analysis to exclude. Picks outside window_years are not claimed at all (AVC: "Picks outside the evaluation window").

The chain means two different things, so every claim says which

A protected pick that fails to convey moves the obligation forward via draft_pick_parts.rolls_to_pick_id , which lives on the **retained** part — the original team's band. Both sides of that split share the same chain and read it in opposite directions:

* chain_role: :fallback — the *receiver's* band ( convey_type: "as_pick" ). When the protection hits, this claim's value continues into the next year's pick. terminal_outcomes/2 walks it. * chain_role: :obligation — the *original team's* retained band. The chain is what this team **owes**, not what it gains: outside its protected band the pick conveys away and the claim is worth nothing. terminal_outcomes/2 deliberately does **not** walk it. Following it here would value an obligation as an asset — exactly backwards — and would also double-count against the receiver, who is already paid for that same chain.

The walk mirrors TradeMachine.NBA.Trades.rollover_chain/1 : same fallback semantics, same cycle guard, same eight-hop cap, but resolving hops from the collections passed in rather than querying, so a 30-team benchmark build costs no round trips and the tests need no database. One deliberate tightening — where the engine takes an unordered limit: 1 among a pick's protected parts, this walk sorts first, because a benchmark frozen forever must not depend on row order.

Known limitation: rank conditions are not modelled

A pool member's *band* is structural — draft_pick_parts.range_low/high say exactly which slots reach the pool, and the walk honours them. A condition on an **outcome** is not: TradeMachine.NBA.SwapGroupOutcome carries a rank and an owner and nothing else, so a contract like *"ATL less favorable, protected 1-4 — if 1-4 the obligation is extinguished and NOP keeps both"* (2027 R1) or *"POR keeps less favorable but conveys it to HOU only at 56-60"* (2027 R2) has nowhere to live but the group's free-text note . Nine of the 43 seeded groups carry such a condition, including four where a rank is pledged onward to a team that is neither a member nor an outcome holder.

This module reads ranks as unconditional, which is the only reading the schema supports — and would misassign a pick in the worlds where such a condition fires. Expressing them properly needs new fields on SwapGroupOutcome , i.e. a [[D-55]] engine change, so the mitigation is to make the gap **visible rather than invisible**: every swap claim carries :swap_note , the pool's contract text verbatim, which TradeMachine.GMGradeV2.DAV.Inventory stores beside the number on the frozen document. A later reader can see what the model was not told. Recorded as 07-R1-SWAP-CONDITIONS in the phase DEFERRED.md .

Known limitation: cross-team correlation

Claims are per-team and the graph is not conditioned on other years' outcomes. Where a rollover chain retries against a pick the original team still owns in full — DEN's 2028/2029 firsts, split 1-5/6-30 with **both** bands DEN-owned because OKC only gains the 6-30 band in the world where the earlier year failed ( 043_seed_rollover_chains.ex ) — DEN is claimed for the whole 2028 pick while OKC's 2027 claim is separately paid for its 2028 fallback branch. Both are right about their own team; the league total is overstated. Fixing it needs joint conditioning across teams, not a change here. :incoming_rollover_from names the conditioning picks on every claim it applies to, so the effect is visible rather than silent.

Download source ↓

SHA-256 5e7f36a55f5ead5b0fadd6c3fabf03627cd1bf280ac8ccfdb08662658c8b4fb2

defmodule TradeMachine.GMGradeV2.DAV.OutcomeEvaluator do
  @moduledoc """
  The canonical rights outcome evaluator: what a team actually controls, and
  what each of those claims resolves into.

  Two functions, answering the two questions every draft-asset number depends on:

    * `controlled_claims/5` — *which assets are mine?* Returns the *mutually
      exclusive* claims a team may be paid for. This is the duplicate-claim
      firewall: a whole pick, the conveyed slice of that pick, and a swap
      outcome over that slice are three descriptions of overlapping rights, and
      summing all three would inflate a team's inventory (AVC, "Asset Identity
      and Duplicate Claims").
    * `terminal_outcomes/2` — *what does one claim turn into?* Walks the
      protection band, the rollover chain, and any second-round conversion until
      the asset **conveys**, **converts** into a different asset, or
      **extinguishes** — AVC's three terminal states, which are exactly the
      three shapes of `:value_basis`.

  Everything here is pure. Picks, parts, and swap groups are passed in; nothing
  queries. That is what keeps the frozen-valuation rule structural rather than
  aspirational — this module cannot reach for a live scenario roster because it
  cannot reach at all — and it is why `TradeMachine.GMGradeV2.DAV.Valuer`, the
  swap Monte Carlo, and the Phase 04 benchmark builder can all share one
  interpretation of pick language instead of growing three.

  ## Absolute slots, everywhere

  `draft_pick_parts.range_low`/`range_high` are stored **within the round**
  (1–30 for both rounds); `draft_picks.pick_number` and
  `TradeMachine.GMGradeV2.DAV.SlotCurve` are **absolute** (1–60). Everything
  this module emits — `:range` on a claim, `:retained_range` on a chain hop,
  `{:slot, s}` on an outcome — is absolute, converted once at the boundary. A
  second-rounder's 1–30 band becomes 31–60 here. Mixing the two conventions is
  the single most expensive mistake available in this file: a round-2 band read
  as round-1 slots misses every slot of its own distribution and silently values
  the asset at zero. `terminal_outcomes/2` raises rather than allow it.

  ## What counts as controlled

  Claim eligibility mirrors `TradeMachine.NBA.Trades.list_tradeable_pick_parts_for_team/1`,
  which is the audited answer the trade builders already use — deliberately, so
  the grader and the builder cannot disagree about who owns what:

    * Range parts the team owns that are not conveyed, on picks that are not
      conveyed and are not legacy `is_swap_rights` companion rows.
    * Minus parts absorbed into a swap pool. Membership is part-scoped, so a
      protected split's retained band stays visible even when its conveyable
      band sits in a pool (#379); a membership recorded without a part absorbs
      the pick's non-retained parts.
    * Plus the swap-group **outcomes** the team holds — the right to the rank-N
      most favorable pick in a pool, which is what a swap actually is.

  Frozen picks (`is_frozen`) are still controlled assets with real DAV — they
  are merely untradeable — so they are claimed here and flagged `:frozen` for
  Phase 07's capacity analysis to exclude. Picks outside `window_years` are not
  claimed at all (AVC: "Picks outside the evaluation window").

  ## The chain means two different things, so every claim says which

  A protected pick that fails to convey moves the obligation forward via
  `draft_pick_parts.rolls_to_pick_id`, which lives on the **retained** part —
  the original team's band. Both sides of that split share the same chain and
  read it in opposite directions:

    * `chain_role: :fallback` — the *receiver's* band (`convey_type: "as_pick"`).
      When the protection hits, this claim's value continues into the next year's
      pick. `terminal_outcomes/2` walks it.
    * `chain_role: :obligation` — the *original team's* retained band. The chain
      is what this team **owes**, not what it gains: outside its protected band
      the pick conveys away and the claim is worth nothing. `terminal_outcomes/2`
      deliberately does **not** walk it. Following it here would value an
      obligation as an asset — exactly backwards — and would also double-count
      against the receiver, who is already paid for that same chain.

  The walk mirrors `TradeMachine.NBA.Trades.rollover_chain/1`: same fallback
  semantics, same cycle guard, same eight-hop cap, but resolving hops from the
  collections passed in rather than querying, so a 30-team benchmark build costs
  no round trips and the tests need no database. One deliberate tightening —
  where the engine takes an unordered `limit: 1` among a pick's protected parts,
  this walk sorts first, because a benchmark frozen forever must not depend on
  row order.

  ## Known limitation: rank conditions are not modelled

  A pool member's *band* is structural — `draft_pick_parts.range_low/high` say
  exactly which slots reach the pool, and the walk honours them. A condition on
  an **outcome** is not: `TradeMachine.NBA.SwapGroupOutcome` carries a rank and
  an owner and nothing else, so a contract like *"ATL less favorable, protected
  1-4 — if 1-4 the obligation is extinguished and NOP keeps both"* (2027 R1) or
  *"POR keeps less favorable but conveys it to HOU only at 56-60"* (2027 R2)
  has nowhere to live but the group's free-text `note`. Nine of the 43 seeded
  groups carry such a condition, including four where a rank is pledged onward
  to a team that is neither a member nor an outcome holder.

  This module reads ranks as unconditional, which is the only reading the schema
  supports — and would misassign a pick in the worlds where such a condition
  fires. Expressing them properly needs new fields on `SwapGroupOutcome`, i.e. a
  [[D-55]] engine change, so the mitigation is to make the gap **visible rather
  than invisible**: every swap claim carries `:swap_note`, the pool's contract
  text verbatim, which `TradeMachine.GMGradeV2.DAV.Inventory` stores beside the
  number on the frozen document. A later reader can see what the model was not
  told. Recorded as `07-R1-SWAP-CONDITIONS` in the phase `DEFERRED.md`.

  ## Known limitation: cross-team correlation

  Claims are per-team and the graph is not conditioned on other years' outcomes.
  Where a rollover chain retries against a pick the original team still owns in
  full — DEN's 2028/2029 firsts, split 1-5/6-30 with **both** bands DEN-owned
  because OKC only gains the 6-30 band in the world where the earlier year failed
  (`043_seed_rollover_chains.ex`) — DEN is claimed for the whole 2028 pick while
  OKC's 2027 claim is separately paid for its 2028 fallback branch. Both are
  right about their own team; the league total is overstated. Fixing it needs
  joint conditioning across teams, not a change here. `:incoming_rollover_from`
  names the conditioning picks on every claim it applies to, so the effect is
  visible rather than silent.
  """

  alias TradeMachine.NBA.DraftPick
  alias TradeMachine.NBA.DraftPickPart
  alias TradeMachine.NBA.SwapGroup

  # Slots per round. Absolute slot = (round - 1) * 30 + position-in-round.
  @round_slots 30

  # Mirrors `Trades.rollover_chain/1`'s cap. Real chains are 2–3 hops (DEN's
  # 2027→2028→2029 is the longest seeded); the cap is a backstop against data
  # that cycles or fans out further than anyone intends.
  @max_hops 8

  # Parts whose band the original team keeps when the protection hits. Both
  # spellings carry the retained semantics; only `rolls_to` also points forward.
  @retained_convey_types ~w(retained rolls_to)

  # A distribution that sums to 1.0 only to this tolerance is already suspect;
  # anything looser lets a renormalization bug through as a rounding artifact.
  @probability_tolerance 1.0e-6

  @typedoc """
  One rollover hop: the pick an unconveyed obligation retries against.

  `:retained_range` is the protected band **on that pick** (absolute slots) — the
  obligation conveys *outside* it. `nil` means the hop carries no protection at
  all: an unconditional obligation that conveys at every slot and ends the chain.

  `resolved?: false` marks a hop the walk could not follow — the pick was not
  supplied, the chain cycles, or the hop cap was reached. It carries `:reason`,
  and it is what produces an `{:asset, claim}` terminal instead of a silent
  extinguish.
  """
  @type hop :: %{
          pick_id: binary() | nil,
          part_id: binary() | nil,
          year: integer() | nil,
          round: 1 | 2 | nil,
          original_team_id: binary() | nil,
          pick_number: integer() | nil,
          retained_range: Range.t() | nil,
          resolved?: boolean(),
          reason: :cycle | :not_supplied | :hop_limit | nil
        }

  @typedoc """
  A mutually exclusive right a team may be paid for.

    * `:kind` —
      `:whole` (unconditional control of an entire pick),
      `:range_part` (one side of a protected split),
      `:swap_outcome` (a rank in a swap pool),
      `:rollover_fallback` (a continuation this module could not resolve; see
      `terminal_outcomes/2` — never returned by `controlled_claims/5`).
    * `:range` — the absolute slots this claim realizes at; `nil` for a swap
      outcome, which is a rank rather than a band.
    * `:chain_role` — `:fallback` (walked for value) or `:obligation` (reported,
      never walked). See the moduledoc.
    * `:incoming_rollover_from` — picks whose failed conveyance would encumber
      this claim's pick. Non-empty means the claim is conditional on another
      year's outcome, which this module does not model (see the moduledoc).
    * `:swap_note` — the pool's free-text contract note, carried verbatim on a
      swap outcome. See "Rank conditions are not modelled" in the moduledoc: it
      is the only record of a condition the schema cannot express.
  """
  @type claim :: %{
          kind: :whole | :range_part | :swap_outcome | :rollover_fallback,
          team_id: binary() | nil,
          pick_id: binary() | nil,
          part_id: binary() | nil,
          range: Range.t() | nil,
          chain: [hop()],
          chain_role: :fallback | :obligation,
          year: integer() | nil,
          round: 1 | 2 | nil,
          original_team_id: binary() | nil,
          pick_number: integer() | nil,
          convey_type: String.t() | nil,
          frozen: boolean(),
          incoming_rollover_from: [binary()],
          swap_group_id: binary() | nil,
          rank: pos_integer() | nil,
          members: [map()] | nil,
          swap_note: String.t() | nil,
          resolved?: boolean(),
          reason: atom() | nil
        }

  @typedoc """
  One terminal state of a claim, with the probability of reaching it.

  `:year` is the draft year the outcome resolves in — the *only* input a
  discount belongs on. This module never discounts; the caller applies exactly
  one absolute `0.92^(year − valuation_year)` per outcome, which is the
  convention that makes chained double-discounting structurally impossible
  (AVC, "Recursive Protection Discount Semantics").

  `:value_basis` is AVC's three terminal states:

    * `{:slot, s}` — **conveys** at absolute slot `s`.
    * `{:asset, claim}` — **converts** into a different asset this module will
      not value. Must not be silently treated as zero; see `terminal_outcomes/2`.
    * `:extinguished` — the right lapses and is worth nothing.
  """
  @type outcome :: %{
          probability: float(),
          year: integer() | nil,
          pick_id: binary() | nil,
          value_basis: {:slot, pos_integer()} | {:asset, claim()} | :extinguished
        }

  @doc """
  The mutually exclusive claims `team_id` controls, ordered by year, round, and
  slot.

  `picks`, `parts`, and `swap_groups` are the graph to evaluate — `DraftPick`,
  `DraftPickPart`, and `SwapGroup` structs (groups with `:members` and
  `:outcomes` loaded). They need not be scoped to the team: eligibility is
  decided here, and picks the team does not own still matter as rollover targets.

  `window_years` is the evaluation window — a `Range` of draft years
  (`2026..2032`) or a list of them. Only claims whose own pick falls inside it
  are returned; a rollover chain then follows its obligation wherever it leads,
  since a fallback landing in year 8 is still part of what this claim is worth.

  The result is pairwise disjoint under `footprint/1`; `disjoint?/1` checks it.
  """
  @spec controlled_claims(binary(), [DraftPick.t()], [DraftPickPart.t()], [SwapGroup.t()], term()) ::
          [claim()]
  def controlled_claims(team_id, picks, parts, swap_groups, window_years)
      when is_list(picks) and is_list(parts) and is_list(swap_groups) do
    years = normalize_window(window_years)
    picks_by_id = Map.new(picks, &{&1.id, &1})
    parts_by_pick = Enum.group_by(parts, & &1.draft_pick_id)
    {member_part_ids, partless_pick_ids} = swap_member_exclusions(swap_groups)
    rollover_sources = rollover_sources(parts)

    range_claims =
      parts
      |> Enum.filter(fn part ->
        owned_range_part?(part, team_id) and
          not absorbed_by_pool?(part, member_part_ids, partless_pick_ids) and
          picks_by_id |> Map.get(part.draft_pick_id) |> claimable_pick?(years)
      end)
      |> Enum.map(fn part ->
        pick = Map.fetch!(picks_by_id, part.draft_pick_id)
        range_claim(team_id, pick, part, picks_by_id, parts_by_pick, rollover_sources)
      end)

    swap_claims =
      swap_claims(team_id, swap_groups, years, picks_by_id, parts_by_pick, rollover_sources)

    Enum.sort_by(range_claims ++ swap_claims, &sort_key/1)
  end

  @doc """
  Every terminal state `claim` can reach, with probabilities summing to 1.0.

  `slot_distribution_fn` is called once per pick the walk visits, with a pick
  reference —

      %{pick_id: id, year: 2028, round: 1, original_team_id: id, pick_number: nil}

  — and must return `[{absolute_slot, probability}]` covering that pick's round
  and summing to 1.0: `TradeMachine.GMGradeV2.DAV.SlotProbability.distribution/1`
  with the valuation date and the frozen Competitive Strength score applied by
  the caller. A distribution that misses its round, sums to anything else,
  repeats a slot, or carries a negative probability raises here rather than
  quietly producing a plausible number.

  The walk: the claim conveys at any slot inside its band. Outside it, a
  `:fallback` claim continues into the rollover chain — repeating the same test
  against each hop's own distribution — while an `:obligation` claim
  extinguishes. When the chain runs out, the residual mass extinguishes; when a
  hop could not be resolved, the residual mass becomes `{:asset, claim}`.

  Zero-probability slots are returned rather than filtered, so the outcome list's
  shape does not change with σ.

  Raises for `:swap_outcome` claims (a swap is a joint distribution over the
  pool's picks, worth only its incremental gain — see
  `TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2`) and for `:rollover_fallback`
  claims, which are terminal markers rather than walkable assets.
  """
  @spec terminal_outcomes(claim(), (map() -> [{pos_integer(), number()}])) :: [outcome()]
  def terminal_outcomes(claim, slot_distribution_fn)

  def terminal_outcomes(%{kind: :swap_outcome}, _slot_distribution_fn) do
    raise ArgumentError,
          "a swap outcome has no protection chain to walk — it is a joint distribution over " <>
            "the pool's picks, and AVC's rule is that it is worth only the incremental gain " <>
            "over the pick the team would otherwise keep, never a whole pick. Use " <>
            "TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2."
  end

  def terminal_outcomes(%{kind: :rollover_fallback} = claim, _slot_distribution_fn) do
    raise ArgumentError,
          "a :rollover_fallback claim (reason: #{inspect(claim.reason)}) is a terminal marker, " <>
            "not a walkable asset — the evaluator stopped because the continuation could not " <>
            "be resolved from the supplied graph. Supply the missing pick and rebuild the " <>
            "claims, or decide explicitly what the unresolved continuation is worth. Do not " <>
            "treat it as zero by default."
  end

  def terminal_outcomes(%{kind: kind} = claim, slot_distribution_fn)
      when kind in [:whole, :range_part] and is_function(slot_distribution_fn, 1) do
    nodes =
      case claim.chain_role do
        :fallback -> [claim_node(claim) | Enum.map(claim.chain, &hop_node/1)]
        :obligation -> [claim_node(claim)]
      end

    walk(nodes, 1.0, slot_distribution_fn, [])
  end

  @doc """
  The cells a claim asserts a primary right over — the duplicate-claim check.

  A pick band contributes `{:pick, pick_id, absolute_slot}` per slot. A swap
  outcome contributes a single `{:swap, swap_group_id, rank}`, deliberately
  *not* its pool's slots: a team holding ranks 1 and 2 of a three-pick pool
  receives two distinct picks and double-counts nothing, while the pooled bands
  themselves are already excluded from that team's range claims.
  """
  @spec footprint(claim()) :: MapSet.t()
  def footprint(%{kind: :swap_outcome} = claim),
    do: MapSet.new([{:swap, claim.swap_group_id, claim.rank}])

  def footprint(%{range: %Range{} = range, pick_id: pick_id}),
    do: MapSet.new(range, &{:pick, pick_id, &1})

  def footprint(_claim), do: MapSet.new()

  @doc """
  Whether every claim in `claims` covers rights no other claim covers — the
  property `controlled_claims/5` exists to guarantee.

  False means some asset is being counted twice.
  """
  @spec disjoint?([claim()]) :: boolean()
  def disjoint?(claims) when is_list(claims) do
    Enum.reduce_while(claims, MapSet.new(), fn claim, seen ->
      cells = footprint(claim)

      if MapSet.disjoint?(seen, cells),
        do: {:cont, MapSet.union(seen, cells)},
        else: {:halt, :overlap}
    end) != :overlap
  end

  # ── Claim construction ────────────────────────────────────────────────

  defp range_claim(team_id, pick, part, picks_by_id, parts_by_pick, rollover_sources) do
    role = if part.convey_type == "as_pick", do: :fallback, else: :obligation

    anchor =
      case role do
        :obligation -> part
        :fallback -> sibling_protected_part(pick, parts_by_pick)
      end

    %{
      kind: claim_kind(part),
      team_id: team_id,
      pick_id: pick.id,
      part_id: part.id,
      range: absolute_range(pick.round, part.range_low, part.range_high),
      chain: build_chain(anchor, picks_by_id, parts_by_pick),
      chain_role: role,
      year: pick.year,
      round: pick.round,
      original_team_id: pick.original_team_id,
      pick_number: pick.pick_number,
      convey_type: part.convey_type,
      frozen: pick.is_frozen == true,
      incoming_rollover_from: Map.get(rollover_sources, pick.id, []),
      swap_group_id: nil,
      rank: nil,
      members: nil,
      swap_note: nil,
      resolved?: true,
      reason: nil
    }
  end

  # `:whole` means unconditional control of an entire pick, so it turns on the
  # conveyance terms and not merely on the band's width: a full-round `rolls_to`
  # part covers 1-30 and is still a fully-protected obligation, not a whole pick.
  defp claim_kind(%DraftPickPart{convey_type: "as_pick"} = part) do
    if part.range_low == 1 and part.range_high == @round_slots, do: :whole, else: :range_part
  end

  defp claim_kind(_part), do: :range_part

  # The retained band that anchors a conveyed band's fallback chain: what rolls
  # forward is the obligation, and the obligation is recorded on the sibling.
  # The claim's own part cannot be selected here — it is `as_pick` by the only
  # branch that calls this, and `protected_part/1` takes only retained bands.
  defp sibling_protected_part(pick, parts_by_pick),
    do: pick |> parts_of(parts_by_pick) |> protected_part()

  defp swap_claims(team_id, swap_groups, years, picks_by_id, parts_by_pick, rollover_sources) do
    for group <- swap_groups,
        group.year in years,
        outcome <- outcomes_of(group),
        outcome.owner_team_id == team_id do
      swap_claim(team_id, group, outcome, picks_by_id, parts_by_pick, rollover_sources)
    end
  end

  defp swap_claim(team_id, group, outcome, picks_by_id, parts_by_pick, rollover_sources) do
    members = swap_members(group, picks_by_id, parts_by_pick, rollover_sources)

    %{
      kind: :swap_outcome,
      team_id: team_id,
      pick_id: nil,
      part_id: nil,
      range: nil,
      chain: [],
      chain_role: :fallback,
      year: group.year,
      round: group.round,
      original_team_id: nil,
      pick_number: nil,
      convey_type: nil,
      frozen: false,
      # A pool is only as certain as the picks in it: if a member's pick is the
      # target of an earlier year's protection, that pick can convey away before
      # this draft and the pool is one pick smaller. Read up from the members so
      # a swap claim answers this question the same way a pick claim does,
      # rather than hardcoding "unconditional" and leaving every reader of the
      # field — `PickCapacity.coverage?/1`, and the frozen document — to believe
      # it.
      incoming_rollover_from:
        members |> Enum.flat_map(& &1.incoming_rollover_from) |> Enum.uniq() |> Enum.sort(),
      swap_group_id: group.id,
      rank: outcome.rank,
      members: members,
      swap_note: Map.get(group, :note),
      resolved?: true,
      reason: nil
    }
  end

  @doc """
  Whether a swap outcome's rank is certain to receive a pick.

  A member only reaches the pool when its slot falls inside the band it
  contributed, so a pool holding a protected slice has fewer picks in it in the
  drafts where that protection hits, and the bottom ranks go unfilled — the
  behaviour `TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2` already values
  correctly (an unsatisfied rank is worth nothing in those worlds).

  A rank is guaranteed exactly when at least that many members are *certain* to
  reach the pool. Six of the 108 seeded outcomes fail this, and for two of them —
  TOR's 2027 rank 4 and PHX's 2028 rank 5 — the conditional rank is that team's
  *only* first-round asset in the year, which is why
  `TradeMachine.GMGradeV2.PickCapacity` must not read it as Stepien coverage.
  On the live graph this predicate moves exactly one team's capacity (LAC, 5 → 4)
  and raises none, which is the direction it must move in.
  """
  @spec guaranteed_rank?(claim()) :: boolean()
  def guaranteed_rank?(%{kind: :swap_outcome, rank: rank, members: members})
      when is_integer(rank) and is_list(members),
      do: rank <= Enum.count(members, &certain_member?/1)

  def guaranteed_rank?(_claim), do: false

  # Certain means nothing can keep this pick out of the pool. Two ways that
  # fails, and both must be checked, because this predicate is the *only* thing
  # standing between a conditional asset and a Stepien package built on it:
  #
  #   * the band is a slice of the round, so a slot outside it never arrives;
  #   * the pick is itself the target of an earlier year's protection, so a
  #     failed conveyance can take it away before this draft happens.
  #
  # A known final slot would be a third way for a narrow band to still be
  # certain, and it is deliberately absent: every member of a pool this module
  # claims sits in the capacity window, which starts at the first *future* draft,
  # so a member cannot have a settled slot. `member_pick!/3` refuses that row
  # outright rather than reading it as certainty — the reading
  # `SlotProbability.distribution/1` already refuses for the same reason.
  defp certain_member?(%{incoming_rollover_from: [_ | _]}), do: false

  defp certain_member?(%{range: %Range{} = range, round: round}) when round in [1, 2],
    do: range == round_slots(round)

  defp certain_member?(_member), do: false

  # The picks a pool draws from, each narrowed to the band it contributes. A
  # member without an explicit part contributes exactly the band the pool
  # absorbed from its owner; anything ambiguous raises (see
  # `pool_member_part/2`).
  defp swap_members(group, picks_by_id, parts_by_pick, rollover_sources) do
    group
    |> members_of()
    |> Enum.map(fn member ->
      pick = member_pick!(group, member, picks_by_id)
      part = pool_member_part(member, parts_by_pick)

      # Always a resolved positional part now — `pool_member_part/2` raises
      # rather than inferring a whole-round band from absent input, so a pool can
      # no longer draw over slots its owner still claims.
      range = absolute_range(pick.round, part.range_low, part.range_high)

      %{
        pick_id: pick.id,
        part_id: part.id,
        year: pick.year,
        round: pick.round,
        original_team_id: pick.original_team_id,
        pick_number: pick.pick_number,
        range: range,
        incoming_rollover_from: Map.get(rollover_sources, pick.id, [])
      }
    end)
    |> Enum.sort_by(&{&1.year, &1.round, &1.range.first, &1.pick_id})
  end

  # A pool is valued as a whole, so a membership that quietly vanishes does not
  # make the pool smaller — it makes it a *different* pool, and a plausible one.
  # Drop one member of three and the group becomes a two-pick pool that
  # `Valuer.pool_rank!/2` still accepts at ranks 1 and 2, valued by exact
  # enumeration rather than sampling, and frozen with a method and a seed that
  # look deliberate. A member drawn from another draft is the same failure one
  # level down: its slots would come from its own year while the discount and
  # the round split come from the group's.
  defp member_pick!(group, member, picks_by_id) do
    pick =
      Map.get(picks_by_id, member.draft_pick_id) ||
        raise ArgumentError,
              "swap group #{inspect(group.id)} (#{group.year} round #{group.round}) has a " <>
                "member on pick #{inspect(member.draft_pick_id)}, which was not supplied in " <>
                "`picks`. Skipping it would reprice the pool as a smaller one every rank still " <>
                "fits into. Supply the pick."

    unless pick.year == group.year and pick.round == group.round do
      raise ArgumentError,
            "swap group #{inspect(group.id)} is #{group.year} round #{group.round}, but its " <>
              "member #{inspect(pick.id)} is #{pick.year} round #{pick.round}. The pool would " <>
              "draw slots from one draft and be discounted and classified into another."
    end

    # Every pool this module claims sits in a window starting at the first
    # *future* draft, so no member can have a settled slot. A stored one is a
    # stale or prematurely populated row, and the two readers would disagree
    # about it: `certain_member?/1` would take it as guaranteed Stepien coverage
    # while `SlotProbability.distribution/1` refuses to value the same row at
    # all. Refusing here is the reading that keeps them agreeing.
    if pick.pick_number != nil do
      raise ArgumentError,
            "swap group member #{inspect(pick.id)} (#{pick.year} round #{pick.round}) carries " <>
              "pick_number #{pick.pick_number}, but #{pick.year} has not been drafted — every " <>
              "pool member sits in the future window. A settled slot here would be read as " <>
              "certain coverage while the valuation refuses the same row. Clear it or exclude " <>
              "the draft from the window."
    end

    pick
  end

  # A partless membership on a *split* pick is resolvable, and resolving it is
  # what stops the retained band being paid twice. `absorbed_by_pool?/3` already
  # decides what such a membership absorbs — the pick's non-retained parts, the
  # #379 rule — so the band it contributes must be exactly those. Naming the
  # whole round instead (the reading `SwapGroups.member_part/1` leaves to its UI
  # caller) lets the pool draw over slots the original team still holds as a
  # range claim, so that band is counted once through the rank holder and once
  # directly.
  #
  # A pick with *no* parts supplied is not "obviously the whole round" — it is
  # indistinguishable from a caller that omitted the pick's real protected
  # parts, and every pick in this database has at least one part (measured: 0 of
  # 480 have none). Inferring a full 1-30 band from missing input is the one
  # error this module cannot detect downstream, because a fabricated whole-round
  # member draws over slots the owner may still claim directly. So it raises.
  #
  # Every other partless membership resolves through `absorbed_part!/2`,
  # including a pick with exactly one part: a lone *retained* band is absorbed by
  # nothing, so pooling it would be the double-count in its purest form.
  defp pool_member_part(member, parts_by_pick) do
    parts = Map.get(parts_by_pick, member.draft_pick_id, [])

    case {member.draft_pick_part_id, parts} do
      {nil, []} ->
        raise ArgumentError,
              "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <>
                "no parts were supplied for that pick. Reading it as the whole round cannot be " <>
                "distinguished from a caller that omitted the pick's protected bands, and would " <>
                "pool slots its owner may still claim. Supply the pick's parts."

      {nil, parts} ->
        member |> absorbed_part!(parts) |> positional_part!(member)

      {part_id, _parts} ->
        parts
        |> Enum.find(&(&1.id == part_id))
        |> case do
          nil ->
            raise ArgumentError,
                  "swap group member on pick #{inspect(member.draft_pick_id)} names part " <>
                    "#{inspect(part_id)}, which was not supplied in `parts`. The band it " <>
                    "contributes is unknown, and defaulting to the whole round would pool slots " <>
                    "its owner may still retain. Supply the pick's parts."

          part ->
            positional_part!(part, member)
        end
    end
  end

  # `range_low`/`range_high` only mean draft slots on a `"range"` part. A legacy
  # `"swap"` part carries the same two columns at their defaults, so reading one
  # as a band silently values a non-positional row as a real 1-30 slice —
  # `owned_range_part?/2` already refuses them on the claim side for exactly this
  # reason. All 497 live parts are `"range"`, so this is a guard against drift,
  # not a live condition.
  defp positional_part!(%DraftPickPart{part_type: "range"} = part, _member), do: part

  defp positional_part!(%DraftPickPart{} = part, member) do
    raise ArgumentError,
          "swap group member on pick #{inspect(member.draft_pick_id)} resolves to part " <>
            "#{inspect(part.id)}, whose part_type is #{inspect(part.part_type)} rather than " <>
            "\"range\". Only a positional part has slot bounds; reading this one's range_low/" <>
            "range_high as a band would pool a slice that does not exist."
  end

  # Exactly the band `absorbed_by_pool?/3` removed from the owner's claims, by
  # exactly the same predicate. The two must name one band or the slots between
  # them are paid twice or not at all, and nothing downstream can detect it:
  # `footprint/1` gives a swap outcome one opaque cell, so `disjoint?/1` sees a
  # pool and a range claim as different namespaces however much they overlap.
  #
  # `is_conveyed` is deliberately not consulted on either side. A part flagged
  # conveyed is precisely the part whose value now flows through the pool
  # (`033_qa_wave1_pick_corrections.ex`, `026_*`), and refusing it here would
  # fail the build on data the owner-side rule already handles correctly.
  defp absorbed_part!(member, parts) do
    parts
    |> Enum.filter(&(&1.convey_type not in @retained_convey_types))
    |> case do
      [only_conveyable] ->
        only_conveyable

      [] ->
        raise ArgumentError,
              "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <>
                "every part of that pick is a band its owner retains. Nothing was absorbed into " <>
                "the pool, so treating the membership as the whole round would draw over slots " <>
                "the owner still claims directly, paying them twice. Point the membership at a " <>
                "part."

      other ->
        raise ArgumentError,
              "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <>
                "the pick has #{length(other)} conveyable bands, so which one reaches the pool " <>
                "is ambiguous. Treating it as the whole round would pool slots the original " <>
                "team still retains, paying that band twice. Point the membership at a part."
    end
  end

  # ── Eligibility ───────────────────────────────────────────────────────

  defp owned_range_part?(part, team_id),
    do: part.owner_team_id == team_id and part.part_type == "range" and part.is_conveyed != true

  # A conveyed pick is spent, and a legacy `is_swap_rights` companion row is a
  # display artifact whose real value lives in the pool's outcomes — claiming
  # either would be pure duplication. A forfeited pick was voided by the league:
  # no team will ever select with it, so it is worth nothing to anyone.
  defp claimable_pick?(nil, _years), do: false

  defp claimable_pick?(pick, years),
    do:
      pick.is_conveyed != true and pick.is_forfeited != true and pick.is_swap_rights != true and
        pick.round in [1, 2] and pick.year in years

  # Parts absorbed into a swap pool are no longer independently claimable — the
  # pool's outcome holders are paid instead. Part-scoped membership absorbs
  # exactly its part; a partless membership absorbs the pick's non-retained
  # parts, so a protected split's retained band never vanishes just because the
  # membership predates part scoping (#379).
  defp absorbed_by_pool?(part, member_part_ids, partless_pick_ids) do
    MapSet.member?(member_part_ids, part.id) or
      (MapSet.member?(partless_pick_ids, part.draft_pick_id) and
         part.convey_type not in @retained_convey_types)
  end

  defp swap_member_exclusions(swap_groups) do
    Enum.each(swap_groups, &validate_group!/1)

    members = Enum.flat_map(swap_groups, &members_of/1)

    part_ids =
      for m <- members, m.draft_pick_part_id != nil, into: MapSet.new(), do: m.draft_pick_part_id

    partless =
      for m <- members, m.draft_pick_part_id == nil, into: MapSet.new(), do: m.draft_pick_id

    {part_ids, partless}
  end

  # Exclusion happens here, once, for every group — but claims are built later
  # and only for the outcomes a team owns, so a group can take assets out of the
  # league's inventory and put nothing back. That failure is silent and total:
  # `members_of/1` returns `[]` for an unloaded association, so a group whose
  # members were never preloaded excludes nothing and values nothing, while a
  # group with members and no outcomes excludes its picks and produces no claim
  # to carry them. Either way the picks simply vanish from all 30 teams and from
  # a frozen league total, with no unresolved marker on anything.
  #
  # So each group is validated as a unit before its exclusions are trusted. The
  # database permits every shape refused here (`create_swap_group/1` enforces
  # none of it); all 43 live groups pass.
  defp validate_group!(group) do
    members = members_of(group)
    outcomes = outcomes_of(group)
    ranks = outcomes |> Enum.map(& &1.rank) |> Enum.sort()
    pick_ids = Enum.map(members, & &1.draft_pick_id)

    cond do
      members == [] ->
        raise ArgumentError,
              "swap group #{inspect(Map.get(group, :id))} has no members (association not " <>
                "loaded, or an empty pool). It would exclude nothing and value nothing; a pool " <>
                "with no picks in it is a data error, not an empty asset."

      outcomes == [] ->
        raise ArgumentError,
              "swap group #{inspect(Map.get(group, :id))} has #{length(members)} member(s) and " <>
                "no outcomes. Its members' bands are absorbed out of their owners' claims and " <>
                "no rank exists to receive them, so those picks would leave the league's " <>
                "inventory entirely."

      length(Enum.uniq(pick_ids)) != length(pick_ids) ->
        raise ArgumentError,
              "swap group #{inspect(Map.get(group, :id))} names the same pick more than once " <>
                "(#{inspect(pick_ids)}). One pick cannot be two members of one pool; the ranks " <>
                "would draw it twice."

      ranks != Enum.to_list(1..length(members)//1) ->
        raise ArgumentError,
              "swap group #{inspect(Map.get(group, :id))} has #{length(members)} member(s) but " <>
                "ranks #{inspect(ranks)}, which is not 1..#{length(members)}. A ladder that " <>
                "does not assign every pooled pick leaves one unaccounted for, and a repeated " <>
                "or out-of-range rank has no meaning."

      true ->
        :ok
    end
  end

  # pick_id => the picks whose failed conveyance would encumber it.
  defp rollover_sources(parts) do
    parts
    |> Enum.filter(&(&1.rolls_to_pick_id != nil))
    |> Enum.group_by(& &1.rolls_to_pick_id, & &1.draft_pick_id)
    |> Map.new(fn {target, sources} -> {target, sources |> Enum.uniq() |> Enum.sort()} end)
  end

  # ── Rollover chain ────────────────────────────────────────────────────

  defp build_chain(nil, _picks_by_id, _parts_by_pick), do: []

  defp build_chain(%DraftPickPart{} = anchor, picks_by_id, parts_by_pick) do
    walk_chain(
      anchor,
      MapSet.new([anchor.draft_pick_id]),
      @max_hops,
      picks_by_id,
      parts_by_pick,
      []
    )
  end

  defp walk_chain(%DraftPickPart{rolls_to_pick_id: nil}, _visited, _hops, _picks, _parts, acc),
    do: Enum.reverse(acc)

  defp walk_chain(part, visited, hops, picks_by_id, parts_by_pick, acc) do
    next_id = part.rolls_to_pick_id
    next_pick = Map.get(picks_by_id, next_id)

    cond do
      next_pick == nil ->
        stop(acc, unresolved_hop(next_id, :not_supplied, nil))

      MapSet.member?(visited, next_id) ->
        stop(acc, unresolved_hop(next_id, :cycle, next_pick))

      hops == 0 ->
        stop(acc, unresolved_hop(next_id, :hop_limit, next_pick))

      true ->
        next_part = next_pick |> parts_of(parts_by_pick) |> protected_part()
        hop = resolved_hop(next_pick, next_part)

        case next_part do
          nil ->
            stop(acc, hop)

          %DraftPickPart{} = p ->
            walk_chain(
              p,
              MapSet.put(visited, next_id),
              hops - 1,
              picks_by_id,
              parts_by_pick,
              [hop | acc]
            )
        end
    end
  end

  defp stop(acc, hop), do: Enum.reverse([hop | acc])

  defp parts_of(pick, parts_by_pick), do: Map.get(parts_by_pick, pick.id, [])

  # The band the original team keeps. Sorted before taking one: the engine's
  # equivalent query is an unordered `limit: 1`, and a benchmark frozen forever
  # must not depend on row order.
  #
  # Two retained bands raise rather than resolving to the first. The team keeps
  # both, so the pick only conveys outside their *union* — bands 1-3 and 4-8
  # convey over 9-30, while taking 1-3 alone conveys over 4-30 and pays the
  # receiver for five slots the original team never gave up. Distinct
  # `rolls_to_pick_id`s on the two would not be resolvable at all. No live pick
  # has the shape; a deterministic wrong answer is worse than a refusal here,
  # because it freezes with no unresolved marker on it.
  defp protected_part(parts) do
    parts
    |> Enum.filter(&(&1.convey_type in @retained_convey_types and &1.is_conveyed != true))
    |> Enum.sort_by(&{&1.range_low, &1.range_high, &1.id})
    |> case do
      [] ->
        nil

      [one] ->
        one

      many ->
        raise ArgumentError,
              "pick #{inspect(List.first(many).draft_pick_id)} has #{length(many)} retained " <>
                "bands (#{Enum.map_join(many, ", ", &"#{&1.range_low}-#{&1.range_high}")}). The " <>
                "team keeps all of them, so the pick conveys only outside their union and no " <>
                "single band anchors the rollover chain. Model the retained range as one part."
    end
  end

  defp resolved_hop(pick, part) do
    %{
      pick_id: pick.id,
      part_id: part && part.id,
      year: pick.year,
      round: pick.round,
      original_team_id: pick.original_team_id,
      pick_number: pick.pick_number,
      retained_range: part && absolute_range(pick.round, part.range_low, part.range_high),
      resolved?: true,
      reason: nil
    }
  end

  defp unresolved_hop(pick_id, reason, pick) do
    %{
      pick_id: pick_id,
      part_id: nil,
      year: pick && pick.year,
      round: pick && pick.round,
      original_team_id: pick && pick.original_team_id,
      pick_number: pick && pick.pick_number,
      retained_range: nil,
      resolved?: false,
      reason: reason
    }
  end

  # ── Outcome walk ──────────────────────────────────────────────────────

  # The claim's own node realizes *inside* its band.
  defp claim_node(claim) do
    %{
      pick_ref: pick_ref(claim),
      convey_slots: MapSet.new(claim.range),
      year: claim.year,
      round: claim.round,
      pick_id: claim.pick_id,
      resolved?: true,
      hop: nil
    }
  end

  # A hop realizes *outside* the band the original team retained — that is what
  # "the obligation conveys" means. A hop with no protected part is
  # unconditional and conveys at every slot.
  defp hop_node(%{resolved?: false} = hop) do
    %{
      pick_ref: nil,
      convey_slots: nil,
      year: hop.year,
      round: hop.round,
      pick_id: hop.pick_id,
      resolved?: false,
      hop: hop
    }
  end

  defp hop_node(hop) do
    all = MapSet.new(round_slots(hop.round))

    convey =
      case hop.retained_range do
        nil -> all
        %Range{} = retained -> MapSet.difference(all, MapSet.new(retained))
      end

    %{
      pick_ref: pick_ref(hop),
      convey_slots: convey,
      year: hop.year,
      round: hop.round,
      pick_id: hop.pick_id,
      resolved?: true,
      hop: hop
    }
  end

  defp pick_ref(source),
    do: Map.take(source, [:pick_id, :year, :round, :original_team_id, :pick_number])

  defp walk([%{resolved?: false} = node | _rest], carried, _dist_fn, acc) do
    acc ++
      [
        %{
          probability: carried,
          year: node.year,
          pick_id: node.pick_id,
          value_basis: {:asset, fallback_claim(node.hop)}
        }
      ]
  end

  defp walk([node | rest], carried, dist_fn, acc) do
    distribution =
      node.pick_ref |> dist_fn.() |> assert_distribution!(node.round, node.pick_ref)

    {conveyed, missed} =
      Enum.reduce(distribution, {[], 0.0}, fn {slot, probability}, {hits, miss} ->
        if MapSet.member?(node.convey_slots, slot) do
          hit = %{
            probability: carried * probability,
            year: node.year,
            pick_id: node.pick_id,
            value_basis: {:slot, slot}
          }

          {[hit | hits], miss}
        else
          {hits, miss + probability}
        end
      end)

    acc = acc ++ Enum.reverse(conveyed)

    if missed <= 0.0,
      do: acc,
      else: continue(rest, carried * missed, dist_fn, acc, node)
  end

  defp continue([], carried, _dist_fn, acc, node) do
    acc ++
      [
        %{
          probability: carried,
          year: node.year,
          pick_id: node.pick_id,
          value_basis: :extinguished
        }
      ]
  end

  defp continue(rest, carried, dist_fn, acc, _node), do: walk(rest, carried, dist_fn, acc)

  defp fallback_claim(hop) do
    %{
      kind: :rollover_fallback,
      team_id: nil,
      pick_id: hop.pick_id,
      part_id: nil,
      range: nil,
      chain: [],
      chain_role: :fallback,
      year: hop.year,
      round: hop.round,
      original_team_id: hop.original_team_id,
      pick_number: hop.pick_number,
      convey_type: nil,
      frozen: false,
      incoming_rollover_from: [],
      swap_group_id: nil,
      rank: nil,
      members: nil,
      swap_note: nil,
      resolved?: false,
      reason: hop.reason
    }
  end

  @doc """
  Validates a caller-supplied slot distribution against `round`, returning it
  sorted by slot; raises otherwise.

  Public because `TradeMachine.GMGradeV2.DAV.Valuer`'s swap path samples pool
  members directly rather than walking a protection chain, and one wrong
  distribution must not mean two different answers about what "valid" is.

  Fail closed, because every way a distribution can be wrong produces a number
  rather than an error: the wrong round misses the whole band (zero), a sub-unit
  total silently shrinks the asset, and a repeated slot pays twice. The sort is
  not cosmetic — float addition is not associative, so the residual mass would
  otherwise depend on the caller's ordering (D-50).
  """
  @spec assert_distribution!([{pos_integer(), number()}], 1 | 2, map()) ::
          [{pos_integer(), number()}]
  def assert_distribution!(distribution, round, pick_ref) when is_list(distribution) do
    slots = round_slots(round)
    total = distribution |> Enum.map(&elem(&1, 1)) |> Enum.sort() |> Enum.sum()

    cond do
      distribution == [] ->
        raise ArgumentError,
              "slot_distribution_fn returned an empty distribution for #{inspect(pick_ref)}"

      not Enum.all?(distribution, &valid_entry?/1) ->
        raise ArgumentError,
              "slot_distribution_fn must return [{integer_slot, non_negative_probability}] " <>
                "for #{inspect(pick_ref)}, got: #{inspect(distribution)}"

      Enum.any?(distribution, fn {slot, _p} -> slot not in slots end) ->
        raise ArgumentError,
              "slot_distribution_fn returned slots outside round #{round} " <>
                "(#{inspect(slots)}) for #{inspect(pick_ref)}. Slots are ABSOLUTE here: a " <>
                "second-round pick occupies 31..60. A round mismatch makes every slot miss the " <>
                "claim's band and values the asset at zero."

      duplicate_slots?(distribution) ->
        raise ArgumentError,
              "slot_distribution_fn repeated a slot for #{inspect(pick_ref)}; each slot " <>
                "must appear once, or the claim is paid twice for the same outcome."

      abs(total - 1.0) > @probability_tolerance ->
        raise ArgumentError,
              "slot_distribution_fn returned a distribution summing to #{total} for " <>
                "#{inspect(pick_ref)}; it must sum to 1.0."

      true ->
        Enum.sort_by(distribution, &elem(&1, 0))
    end
  end

  def assert_distribution!(other, _round, pick_ref) do
    raise ArgumentError,
          "slot_distribution_fn must return [{slot, probability}] for #{inspect(pick_ref)}, " <>
            "got: #{inspect(other)}"
  end

  defp valid_entry?({slot, probability}),
    do: is_integer(slot) and is_number(probability) and probability >= 0

  defp valid_entry?(_other), do: false

  defp duplicate_slots?(distribution),
    do: distribution |> Enum.uniq_by(&elem(&1, 0)) |> length() != length(distribution)

  # ── Shared helpers ────────────────────────────────────────────────────

  # `draft_pick_parts` stores bands within the round (1-30 for both rounds);
  # everything downstream speaks absolute slots.
  defp absolute_range(round, low, high) do
    offset = (round - 1) * @round_slots
    (offset + low)..(offset + high)//1
  end

  defp round_slots(round), do: absolute_range(round, 1, @round_slots)

  defp members_of(%{members: members}) when is_list(members), do: members
  defp members_of(_group), do: []

  defp outcomes_of(%{outcomes: outcomes}) when is_list(outcomes), do: outcomes
  defp outcomes_of(_group), do: []

  defp sort_key(claim),
    do:
      {claim.year, claim.round, (claim.range && claim.range.first) || 0, claim.rank || 0,
       claim.part_id || claim.swap_group_id || ""}

  defp normalize_window(%Range{} = years), do: years |> MapSet.new() |> ensure_non_empty(years)

  defp normalize_window(years) when is_list(years) do
    if Enum.all?(years, &is_integer/1) do
      years |> MapSet.new() |> ensure_non_empty(years)
    else
      raise ArgumentError,
            "window_years must be draft years — a Range like 2026..2032 or a list of " <>
              "integers, got: #{inspect(years)}"
    end
  end

  defp normalize_window(other) do
    raise ArgumentError,
          "window_years must be a Range of draft years (2026..2032) or a list of integer " <>
            "years, got: #{inspect(other)}"
  end

  defp ensure_non_empty(set, original) do
    if MapSet.size(set) == 0 do
      raise ArgumentError,
            "window_years is empty (#{inspect(original)}), so no claim could ever be " <>
              "returned. An empty inventory and an empty window read identically downstream; " <>
              "pass the real evaluation window."
    end

    set
  end
end
ptv.exElixir

lib/trade_machine/gm_grade_v2/ptv.ex

Entry point and calibration gate for Player Transaction Value.

The checked-in nonlinear curve is intentionally provisional. Candidate parameters may be fitted against labeled transaction cases, but only a reviewed human decision may set "calibrated": true .

Download source ↓

SHA-256 ab3463c52b83d5fb9b607104f99bc567c1a6e7f2f4a2aab1de494acb5493a22e

defmodule TradeMachine.GMGradeV2.PTV do
  @moduledoc """
  Entry point and calibration gate for Player Transaction Value.

  The checked-in nonlinear curve is intentionally provisional. Candidate
  parameters may be fitted against labeled transaction cases, but only a
  reviewed human decision may set `"calibrated": true`.
  """

  alias TradeMachine.GMGradeV2.PTV.NonlinearV1

  @doc "Values a player with the current PTV implementation."
  @spec value(map(), map()) :: %{ptv: float(), confidence: atom(), diagnostics: map()}
  def value(player_snapshot, context \\ %{}), do: NonlinearV1.value(player_snapshot, context)

  @doc "Returns whether the checked-in coefficient set passed human calibration."
  @spec calibrated?() :: boolean()
  def calibrated? do
    coefficients_document() |> Map.fetch!("calibrated")
  end

  @doc "Loads the versioned coefficient document."
  @spec coefficients_document() :: map()
  def coefficients_document do
    expected = Map.fetch!(TradeMachine.GMGradeV2.ModelVersion.versions(), :ptv)

    document =
      :ptv |> TradeMachine.Valuation.ModelArtifact.load_legacy!(expected) |> legacy_projection()

    actual = Map.fetch!(document, "version")

    if actual != expected do
      raise ArgumentError,
            "PTV coefficient version #{inspect(actual)} does not match model registry #{inspect(expected)}"
    end

    document
  end

  defp legacy_projection(artifact) do
    artifact
    |> Map.fetch!("parameters")
    |> Map.put("version", Map.fetch!(artifact, "version"))
    |> Map.put("calibrated", get_in(artifact, ["approval", "calibrated"]))
  end
end
nonlinear_v1.exElixir

lib/trade_machine/gm_grade_v2/ptv/nonlinear_v1.ex

Interpretable nonlinear Player Transaction Value model.

AVC's Player Market Value score establishes the basketball-value base. Contract Value Score then acts as a bounded multiplier, allowing an elite contract to amplify a scarce star without turning a low-impact bargain into a centerpiece.

Download source ↓

SHA-256 ded191f21930d813071396e4fc32be0946f7919e0083197742ce20ace0f9939b

defmodule TradeMachine.GMGradeV2.PTV.NonlinearV1 do
  @moduledoc """
  Interpretable nonlinear Player Transaction Value model.

  AVC's Player Market Value score establishes the basketball-value base.
  Contract Value Score then acts as a bounded multiplier, allowing an elite
  contract to amplify a scarce star without turning a low-impact bargain into
  a centerpiece.
  """

  @behaviour TradeMachine.GMGradeV2.PTVModel

  alias TradeMachine.GMGradeV2.PTV
  alias TradeMachine.Valuation.PlayerValueCurve

  @impl true
  def value(player_snapshot, context) when is_map(player_snapshot) and is_map(context) do
    document =
      case Map.fetch(context, :coefficients_document) do
        {:ok, frozen} -> frozen
        :error -> PTV.coefficients_document()
      end

    parameters = Map.fetch!(document, "curve_parameters")
    quality = score!(player_snapshot, :player_market_value)
    contract = score!(player_snapshot, :contract_value_score)
    curve = PlayerValueCurve.evaluate(quality, contract, parameters)

    %{
      ptv: curve.player_asset_value,
      confidence: if(Map.fetch!(document, "calibrated"), do: :model, else: :provisional),
      diagnostics: %{
        model: __MODULE__,
        version: Map.fetch!(document, "version"),
        calibrated: Map.fetch!(document, "calibrated"),
        valuation_foundation: %{
          player_market_value: quality,
          contract_value_score: contract,
          contract_value_tier: field(player_snapshot, :contract_value_tier),
          expected_salary: field(player_snapshot, :expected_salary),
          components: field(player_snapshot, :cvs_components)
        },
        transforms: %{
          quality_value: curve.quality_value,
          contract_multiplier: curve.contract_multiplier
        },
        curve_parameters: parameters
      }
    }
  end

  @impl true
  def info do
    document = PTV.coefficients_document()

    %{
      name: :nonlinear_v1,
      version: Map.fetch!(document, "version"),
      calibrated: Map.fetch!(document, "calibrated"),
      scale: :dav_equivalent,
      features: [:player_market_value, :contract_value_score]
    }
  end

  defp score!(snapshot, key) do
    case field(snapshot, key) do
      value when is_number(value) and value >= 0 and value <= 100 -> value * 1.0
      value -> raise ArgumentError, "#{key} must be between 0 and 100, got: #{inspect(value)}"
    end
  end

  defp field(snapshot, key), do: Map.get(snapshot, key, Map.get(snapshot, Atom.to_string(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