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