defmodule TradeMachine.GMGradeV2.Percentile do @moduledoc """ The single percentile helper for the AVC grader. AVC converts many raw metrics (Competitive Strength metrics, team skill raw scores, DAV totals) into a 0–100 score by ranking the scenario's raw value against the 30 real NBA teams (or the league player population). This module is that conversion, used everywhere so scores stay consistent and reproducible against a frozen benchmark. ## Method (versioned as part of the benchmark model) Given a value and an ascending list of `n` benchmark values, the percentile maps the benchmark's order statistics linearly onto 0–100: a value between two adjacent benchmarks is linearly interpolated between their order-statistic positions `i/(n-1) × 100`. * **Strictly below the minimum → 0.0; strictly above the maximum → 100.0** (clamped). Clamping applies *beyond* the extrema only. * **Ties → midpoint**: a value equal to a benchmark takes the average of the tied block's positions. This governs equality *everywhere*, including at the extrema — a value equal to a minimum that repeats scores the tied block's midpoint, not 0.0. A unique minimum still scores 0.0 and a unique maximum still scores 100.0, because a one-element tied block's midpoint *is* its order-statistic position. * **Single benchmark (`n == 1`)**: below → 0, equal → 50, above → 100. Tied extrema are not hypothetical: over a 30-team benchmark, integer-valued metrics (tradeable firsts, established-veteran counts, trade exceptions) tie at 0 routinely, and a team tying at the league minimum should not be scored as though it were below every other team. This matches DECISIONS D-13, which clamps 0/100 *beyond* min/max and resolves ties to the midpoint. Full precision is retained; callers round only at the presentation boundary. """ @doc """ Percentile score (0.0–100.0, full precision) of `value` against ascending `benchmark` values. Raises on an empty benchmark (a percentile against nothing is undefined — callers must supply the frozen benchmark). """ @spec score(number(), [number()]) :: float() def score(_value, []), do: raise(ArgumentError, "cannot compute a percentile against an empty benchmark") def score(value, benchmark) when is_list(benchmark) do sorted = Enum.sort(benchmark) n = length(sorted) cond do n == 1 -> single(value, hd(sorted)) # Strict: equality at either extreme is a tie, and `interior/3` resolves # ties to the tied block's midpoint. A unique extreme still lands on 0.0 / # 100.0 there, so only the duplicated case changes. value < hd(sorted) -> 0.0 value > List.last(sorted) -> 100.0 true -> interior(value, List.to_tuple(sorted), n) end end # Competition (standard) rank for display: 1 = strictly the best (highest). # Ties share the lower rank number, the next distinct value skips accordingly. @doc "Competition rank (1 = highest) of `value` among `benchmark`." @spec rank(number(), [number()]) :: pos_integer() def rank(_value, []), do: raise(ArgumentError, "cannot rank against an empty benchmark") def rank(value, benchmark) when is_list(benchmark) do Enum.count(benchmark, &(&1 > value)) + 1 end defp single(value, b) do cond do value < b -> 0.0 value > b -> 100.0 true -> 50.0 end end defp interior(value, tuple, n) do below = count_below(tuple, n, value) equal = count_equal(tuple, n, value) if equal > 0 do # Tied block occupies 0-based indices [below, below + equal - 1]; take the # midpoint position. avg_index = below + (equal - 1) / 2 avg_index / (n - 1) * 100.0 else # Strictly between the largest benchmark < value (index below-1) and the # smallest benchmark > value (index below). Interpolate linearly. lower_val = elem(tuple, below - 1) upper_val = elem(tuple, below) p_lower = (below - 1) / (n - 1) * 100.0 p_upper = below / (n - 1) * 100.0 frac = (value - lower_val) / (upper_val - lower_val) p_lower + frac * (p_upper - p_lower) end end defp count_below(tuple, n, value), do: count_where(tuple, n, value, & if cmp.(elem(tuple, i), value), do: acc + 1, else: acc end) end end