defmodule TradeMachine.GMGradeV2.Availability do @moduledoc """ Season-availability weighting for one rotation entry. A rotation describes a single game: 240 minutes, five starters, an assigned MPG per player. Nothing in it says how many of the 82 games those minutes are actually played, so a player out until February contributes his 30 MPG at the same season strength as an ironman. `expected_games` is the user's optional answer (see `TradeMachine.GMGradeV2.RotationValidation`); this module turns it into the multiplier the four Competitive Strength metrics apply to that player's rotation contribution. ## The formulation, and why it is not `expected_games / 82` factor = min(1, expected_games / baseline_games) where `baseline_games` is the player's own frozen `games_played` clamped into 1..82, and 82 when there is no history to read. The obvious formulation is `expected_games / 82`, and it breaks the one constraint this feature has to hold. Competitive Strength is a percentile against 30 benchmark rotations that `TradeMachine.GMGradeV2.BenchmarkRotation` manufactures from completed seasons, and no benchmark rotation carries expected games — every benchmark player is implicitly weighted 1.0. The un-annotated user rotation has to weight 1.0 too, or every grade in existence moves the day this ships. `/82` cannot do that either way round. Weighting an un-annotated player by his own `games_played / 82` re-grades every rotation ever saved: a 65-game player quietly loses 21% of his contribution against a benchmark population that lost nothing. Weighting him 1.0 while an *annotated* player divides by 82 is worse in a quieter way — the builder shows 65 as the ghost default, the user types the number they were already being shown, and their grade drops 21%. A control whose default value is not what grading actually assumes is a control that lies. Dividing by the player's own baseline makes both cases agree: absent is 1.0, and *typing the ghost default* is also 1.0. The box moves the grade only when the user disagrees with the historical record, which is the entire point of having it. ## Why the cap `min(1, …)` refuses the upweight. Setting a 50-game player to 82 would otherwise multiply him by 1.64 — a boost measured against 30 benchmark teams that never had the box to tick. Availability may subtract season value from a rotation, because the alternative is counting an absent player at full strength; it may not add value the comparison population was never allowed to claim. ## No double counting `PlayerImpact`'s reliability factor (`min(1, sqrt(season_minutes / 1200))`) and `SkillIndexes`' experience factor both read games played, and neither is an availability weight: they shrink a *per-minute rate* toward zero when the sample behind it is thin, and both sit at 1.0 for any player with a full season's minutes. `SkillIndexes.availability_multiplier/1` is named for availability but is the 0.5 two-way-contract discount, which has nothing to do with games played. Nothing in grading scaled a rotation contribution by season availability before this module, so nothing here counts it twice. ## What this deliberately does not do Season value only. Minutes are not redistributed: an unavailable player's contribution is discounted, not handed to whoever would really play those minutes, which would mean inventing a second rotation the user never authored. A rotation stays a 240-minute document and the denominators stay 240, so the discount shows up as the metric it should — the team is weaker over a season, because for part of that season one of its rotation slots is empty. Roster Construction and Timeline Alignment also read the rotation, for *structure* (position coverage, young-player development minutes). Structure is not changed by how many games a player is available for, so they are out of scope. ## Out-of-range values are ignored, not clamped `RotationValidation` reports a bad `expected_games` rather than clamping it, and persists the rotation anyway so work in progress survives a reload. This is the third layer: the grading read path never trusts the stored number. Anything that is not a whole number in 0..82 reads as "no opinion" and weights 1.0 — a stored `-5` would otherwise invert every contribution it touched, and a typo'd `950` is not evidence of anything. Such a rotation cannot back a submission at all (validation blocks it); ignoring rather than clamping means the provisional preview a user sees mid-edit shows the same number they saw before the typo instead of an invented one. """ alias TradeMachine.GMGradeV2.RotationValidation @full 1.0 @doc """ The number of games grading already assumes for this player — the ghost default the rotation builder shows, and the denominator of `factor/2`. Reads `games_played` off frozen player evidence (string- or atom-keyed) and clamps it into `1..max_expected_games()`. A player with no usable history — a rookie, or the provisional replacement evidence `ScenarioAssembly` supplements for a player the snapshot never saw — has no historical availability to measure against, so the full season is the honest reference: setting such a player to 41 games means half a season, not half of a zero. """ @spec baseline_games(map() | nil) :: pos_integer() def baseline_games(evidence) when is_map(evidence) do case fetch(evidence, :games_played) do games when is_number(games) and games >= 1 -> games |> min(RotationValidation.max_expected_games()) |> trunc() _ -> RotationValidation.max_expected_games() end end def baseline_games(_evidence), do: RotationValidation.max_expected_games() @doc """ The usable expected-games value on a rotation entry, or `nil`. `nil` covers all three "grading has no opinion" cases: the key is absent (the overwhelmingly common one — an untouched rotation never writes it), the value is blank, or the value is outside what `RotationValidation` accepts. """ @spec expected_games(map()) :: non_neg_integer() | nil def expected_games(entry) when is_map(entry) do entry |> fetch(:expected_games) |> RotationValidation.to_expected_games() |> usable() end def expected_games(_entry), do: nil @doc """ The multiplier for `entry`'s rotation contribution, given that player's frozen evidence. Exactly `1.0` whenever the user has expressed no usable opinion, which is what makes an un-annotated rotation grade identically to how it graded before expected games existed. Float multiplication by `1.0` is the identity, so the parity is bit-for-bit and not merely close. """ @spec factor(map(), map() | nil) :: float() def factor(entry, evidence) do case expected_games(entry) do nil -> @full games -> min(@full, games / baseline_games(evidence)) end end defp usable(games) when is_integer(games) and games >= 0 do if games <= RotationValidation.max_expected_games(), do: games, else: nil end defp usable(_games), do: nil # Entries and evidence arrive atom-keyed in memory and string-keyed out of # Postgres, exactly as everywhere else in the grader. defp fetch(map, key) do case Map.fetch(map, key) do {:ok, value} -> value :error -> Map.get(map, Atom.to_string(key)) end end end