defmodule TradeMachine.GMGradeV2.ModelVersion do @moduledoc """ The single registry of scoring constants and sub-model versions for the AVC six-pillar GM grader (`TradeMachine.GMGradeV2`). Every scoring module in later phases reads its versioned constants from here rather than hardcoding them, so a submitted grade can name the exact model version — and every constant that produced it — for reproducibility. When a constant is retuned, bump the relevant entry in `versions/0` (and, for a behavioural change, `model_version/0`) so old grades stay reproducible against their frozen version. All numeric defaults below are AVC's authored values or explicitly-recorded v1 defaults (see `.maestro/playbooks/2026-07-25-GM-Challenge-Grading/DECISIONS.md`). They are lab-tunable via an optional `override` map (Phase 16) threaded through each getter; production call sites pass no override and get the frozen default. """ @model_version "avc-1.5.0" alias TradeMachine.Valuation.ContractClassification alias TradeMachine.Valuation.Coefficients # `:benchmarks` versions the frozen 30-team benchmark set as a whole — the # `TradeMachine.GMGradeV2.BenchmarkRotation` algorithm that manufactures each # real team's 240-minute rotation (rank by Season Minutes, cover 240 minutes # per game, proportional minutes under a 48-minute ceiling, top five by games # started) together with the raw metrics and percentile tables built from it. # Bump it whenever any of those rules change: a stored grade names this # version, and a benchmark built under different rules is not comparable to # one built under these. @versions %{ player_impact: "1.0.0", skill_indexes: "1.0.0", expected_salary: "2.0.0", dav: "1.0.0", plausibility: "2.0.0", benchmarks: "1.0.0", ptv: "2.0.0", progression: "0.1.0", rookie_contract: "2.0.0" } # Proposed Progression v0 lab calibration. Progression is a separately # versioned post-composite model: these values never alter the canonical model # version or the six AVC pillar weights. # # Sensitivities and point caps are positive magnitudes. The normalized curve # signal is solely responsible for direction. @progression_config %{ version: "0.1.0", mandates: %{ championship_push: %{ curve_key: :championship_push_v0, target: %{ type: :headroom, intercept: 1.0, reference_score: 80.0, rate: 0.15, min: 0.0, max: 4.0 }, softness: 6.0, upside_sensitivity: 0.03, downside_sensitivity: 0.04, upside_point_cap: 3.0, downside_point_cap: 4.0, default_policy: :longitudinal }, run_it_back: %{ curve_key: :run_it_back_v0, target: %{ type: :headroom, intercept: 0.0, reference_score: 75.0, rate: 0.20, min: -2.0, max: 3.0 }, softness: 6.0, upside_sensitivity: 0.02, downside_sensitivity: 0.02, upside_point_cap: 2.0, downside_point_cap: 2.0, default_policy: :maintenance }, ascending: %{ curve_key: :ascending_v0, target: %{ type: :headroom, intercept: 2.0, reference_score: 75.0, rate: 0.10, min: 2.0, max: 5.0 }, softness: 7.0, upside_sensitivity: 0.05, downside_sensitivity: 0.06, upside_point_cap: 4.0, downside_point_cap: 5.0, default_policy: :longitudinal }, rebuilding: %{ curve_key: :rebuilding_v0, target: %{ type: :headroom, intercept: 0.0, reference_score: 75.0, rate: 0.15, min: 3.0, max: 7.0 }, softness: 6.0, upside_sensitivity: 0.06, downside_sensitivity: 0.08, upside_point_cap: 5.0, downside_point_cap: 6.0, default_policy: :longitudinal }, retool: %{ curve_key: :retool_v0, target: %{type: :fixed, value: 2.0}, softness: 8.0, upside_sensitivity: 0.02, downside_sensitivity: 0.02, upside_point_cap: 2.0, downside_point_cap: 2.0, default_policy: :episodic }, selling: %{ curve_key: nil, target: nil, softness: nil, upside_sensitivity: 0.0, downside_sensitivity: 0.0, upside_point_cap: 0.0, downside_point_cap: 0.0, default_policy: :episodic } } } # Competitive Strength reliability factor denominator (season minutes at which # the factor reaches 1.0). AVC Pillar 1. @reliability_minutes 1200 # Roster Construction experience factor denominator. AVC Pillar 2. @experience_minutes 800 # Provisional replacement-level impact when no meaningful NBA sample exists. # AVC Pillar 1 "Missing EPM Data". @provisional_epm %{overall: -1.5, offensive: -0.75, defensive: -0.75} # Minimum values per input before the blend tier will standardize against a # season's population. Mirrors the regression tier's `--min-sample` default, # and deliberately equals it: both tiers estimate an impact from a season # population, so both need the same floor under it. Without it the two guards # are backwards — the seasons the regression correctly refuses as biased are # exactly the ones whose players then fall through to the blend. @min_blend_population 30 # Numeric mapping of the existing categorical valuation confidence. v1 default # pending calibration from out-of-sample errors (DECISIONS D-07). @confidence_mapping %{high: 0.9, moderate: 0.6, low: 0.3} # Minimum games played before a player's season is a usable comparable — and, # since it is the same reliability question, before he is a priceable subject. # Equals `PlayerSimilarity`'s documented default; declared here and passed to # it explicitly so the grader's two uses of the number cannot drift from the # engine's, without the grader restating a rule the engine owns. @min_comparable_games 20 # Confidence decays with the projection horizon: a numeric confidence is # multiplied by this per season ahead (DECISIONS D-18). The interpretation # notes flag Confidence(i) vs Confidence(i,y) — this is the v1 answer to the # second: the same model, trusted less the further out it is read. @confidence_horizon_decay 0.9 # AVC's documented temporary age-curve adjustment for future-season expected # salary: one flat multiplier per season ahead, chosen by the player's age *in # that season* and compounded across the horizon (DECISIONS D-19). Ordered # `{inclusive_max_age, factor}` bands, `nil` meaning "and above" — ordered # because band resolution takes the first match, and a map would resolve in # hash order. A player-specific aging curve is deferred; this is the v1 # fallback shape AVC documented, not a model. @age_curve [{23, 1.03}, {28, 1.00}, {31, 0.97}, {nil, 0.92}] # Contract Health multi-year discount weights (current season + four ahead). # AVC Pillar 3. @contract_season_weights [1.0, 0.9, 0.8, 0.7, 0.6] # Long-Term Risk future-season weights (next season + three ahead). AVC Pillar 3. @risk_season_weights [1.0, 0.9, 0.8, 0.7] # Strong structural value floor for active standard rookie-scale seasons. # AVC Pillar 3. The policy identifier names the exact classification # interpretation paired with the baseline; both are exposed together by # `rookie_contract_policy/1`, while `@versions.rookie_contract` is frozen into # challenge snapshots. @rookie_contract_classification_id "active_standard_rookie_scale_v1" @rookie_market_blend %{minimum_games: 20, full_games: 82} # Draft Asset Value present-value discount per year. AVC Pillar 5. @dav_time_discount 0.92 # Universal prime midpoint age (v1 fallback until player-specific curves are # calibrated). AVC Pillar 6. @prime_midpoint_age 27.5 @doc "The overall grader model version frozen onto every grade." @spec model_version() :: String.t() def model_version, do: @model_version @doc "Map of every sub-model version, frozen onto a grade's snapshot refs." @spec versions() :: %{atom() => String.t()} def versions, do: @versions @doc "The separately versioned proposed Progression model configuration." @spec progression_config(map()) :: map() def progression_config(override \\ %{}), do: get(override, :progression, @progression_config) @doc "Reliability-factor denominator (season minutes → factor 1.0). AVC Pillar 1." @spec reliability_minutes(map()) :: number() def reliability_minutes(override \\ %{}), do: get(override, :reliability_minutes, @reliability_minutes) @doc "Experience-factor denominator. AVC Pillar 2." @spec experience_minutes(map()) :: number() def experience_minutes(override \\ %{}), do: get(override, :experience_minutes, @experience_minutes) @doc "Provisional replacement-level EPM (overall/offensive/defensive)." @spec provisional_epm(map()) :: %{overall: number(), offensive: number(), defensive: number()} def provisional_epm(override \\ %{}), do: get(override, :provisional_epm, @provisional_epm) @doc "Minimum per-input population the blend tier will standardize against." @spec min_blend_population(map()) :: number() def min_blend_population(override \\ %{}), do: get(override, :min_blend_population, @min_blend_population) @doc "Categorical→numeric confidence mapping (DECISIONS D-07)." @spec confidence_mapping(map()) :: %{high: number(), moderate: number(), low: number()} def confidence_mapping(override \\ %{}), do: get(override, :confidence_mapping, @confidence_mapping) @doc "Minimum games played for a season to be a usable comparable *or* a priceable subject." @spec min_comparable_games(map()) :: number() def min_comparable_games(override \\ %{}), do: get(override, :min_comparable_games, @min_comparable_games) @doc "Per-season-ahead confidence decay for projected salaries (DECISIONS D-18)." @spec confidence_horizon_decay(map()) :: number() def confidence_horizon_decay(override \\ %{}), do: get(override, :confidence_horizon_decay, @confidence_horizon_decay) @doc """ Age-curve bands for the temporary multi-year projection (DECISIONS D-19). An ordered list of `{inclusive_max_age, per-season factor}`; the last band's `nil` max means "and above". Read by `TradeMachine.GMGradeV2.ExpectedSalary.age_factor/2`. """ @spec age_curve(map()) :: [{pos_integer() | nil, number()}] def age_curve(override \\ %{}), do: get(override, :age_curve, @age_curve) @doc "Contract Value multi-year discount weights." @spec contract_season_weights(map()) :: [number()] def contract_season_weights(override \\ %{}), do: get(override, :contract_season_weights, @contract_season_weights) @doc "Long-Term Risk future-season weights." @spec risk_season_weights(map()) :: [number()] def risk_season_weights(override \\ %{}), do: get(override, :risk_season_weights, @risk_season_weights) @doc "Structural Contract Health baseline for active standard rookie-scale seasons." @spec rookie_contract_baseline(map()) :: number() def rookie_contract_baseline(override \\ %{}), do: get( override, :rookie_contract_baseline, ContractClassification.rookie_baseline_score() * 1.0 ) @doc "Stable identifier for the rookie-contract classification policy." @spec rookie_contract_classification_id() :: String.t() def rookie_contract_classification_id, do: @rookie_contract_classification_id @doc """ Returns whether a frozen snapshot opted into the current rookie policy version. Full policy inputs are frozen separately in the snapshot document. """ @spec rookie_contract_policy_active?(map()) :: boolean() def rookie_contract_policy_active?(model_versions) when is_map(model_versions) do version = Map.get(model_versions, :rookie_contract) || Map.get(model_versions, "rookie_contract") version == Map.fetch!(@versions, :rookie_contract) end def rookie_contract_policy_active?(_model_versions), do: false @doc """ Frozen metadata for the rookie-contract classification and scoring policy. The complete returned payload is captured in every new challenge snapshot. This freezes the draft curve and blend boundaries, rather than relying on the live coefficient registry when an old challenge is graded later. """ @spec rookie_contract_policy(map()) :: %{ version: String.t(), classification_id: String.t(), fallback_baseline: number(), minimum_market_games: pos_integer(), full_market_games: pos_integer(), pick_curve: map() } def rookie_contract_policy(override \\ %{}) do %{ version: Map.fetch!(@versions, :rookie_contract), classification_id: @rookie_contract_classification_id, fallback_baseline: rookie_contract_baseline(override), minimum_market_games: get(override, :rookie_minimum_market_games, @rookie_market_blend.minimum_games), full_market_games: get(override, :rookie_full_market_games, @rookie_market_blend.full_games), pick_curve: get(override, :rookie_pick_curve, Coefficients.for(:pick_curve)) } end @doc "Returns the exact rookie policy frozen into a snapshot document." @spec rookie_contract_policy_for_snapshot(map()) :: map() | nil def rookie_contract_policy_for_snapshot(snapshot_data) when is_map(snapshot_data) do case Map.get(snapshot_data, "rookie_contract_policy") || Map.get(snapshot_data, :rookie_contract_policy) do policy when is_map(policy) -> policy _missing -> versions = Map.get(snapshot_data, "model_versions", %{}) case Map.get(versions, "rookie_contract") || Map.get(versions, :rookie_contract) do "1.0.0" -> %{ "version" => "1.0.0", "classification_id" => "active_standard_rookie_scale_v1", "baseline" => 75.0 } _unknown -> nil end end end def rookie_contract_policy_for_snapshot(_snapshot_data), do: nil @doc "Returns the structural baseline applicable to one player in a frozen snapshot." @spec rookie_contract_baseline_for_snapshot(map(), String.t()) :: number() | nil def rookie_contract_baseline_for_snapshot(snapshot_data, player_id) when is_map(snapshot_data) and is_binary(player_id) do policy = rookie_contract_policy_for_snapshot(snapshot_data) players = Map.get(snapshot_data, "players") || Map.get(snapshot_data, :players) || %{} subject = Map.get(players, player_id) || %{} cond do is_number(policy_value(policy, :baseline)) -> policy_value(policy, :baseline) is_number(policy_value(policy, :fallback_baseline)) and TradeMachine.Valuation.RookieContractPolicy.draft_prior(subject, policy) == :error -> policy_value(policy, :fallback_baseline) true -> nil end end def rookie_contract_baseline_for_snapshot(_snapshot_data, _player_id), do: nil @doc "DAV present-value discount per year (AVC 0.92)." @spec dav_time_discount(map()) :: number() def dav_time_discount(override \\ %{}), do: get(override, :dav_time_discount, @dav_time_discount) @doc "Universal prime midpoint age (v1 fallback, AVC 27.5)." @spec prime_midpoint_age(map()) :: number() def prime_midpoint_age(override \\ %{}), do: get(override, :prime_midpoint_age, @prime_midpoint_age) # Lab override lookup: an override map (Phase 16) can replace any constant by # key; production passes an empty map and gets the frozen default. defp get(override, key, default) when is_map(override) do case Map.fetch(override, key) do {:ok, value} -> value :error -> Map.get(override, Atom.to_string(key), default) end end defp policy_value(policy, key) when is_map(policy) do Map.get(policy, key) || Map.get(policy, Atom.to_string(key)) end defp policy_value(_policy, _key), do: nil end