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