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