defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs do @moduledoc false alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot @skill_evidence_keys [ :player_id, :season_minutes, :games_played, :minutes_per_game, :assists, :turnovers, :usg_pct, :fg3_pct, :fg3a, :ts_pct, :fga, :fta, :ft_pct, :epm_def, :steals, :blocks, :rebounds ] # The scenario-owned membership fields — identity, listed position, and # contract/two-way status — exactly the shape `ScenarioState` emits (see its # `standard_roster/1` and `two_way_roster/1`). Only these may override the # frozen player: a scenario legitimately re-positions or re-signs a player, so # position and two-way status must win. Everything else — the write-once # statistical/skill evidence in `@skill_evidence_keys` — must come from the # snapshot and can never be replaced by a roster field. @membership_keys [ :id, :player_id, :name, :position, :salary, :is_draft_pick, :is_two_way ] # Both spellings, because roster rows arrive atom-keyed in memory and # string-keyed out of Postgres / form params — the same dual-spelling rule # `SkillIndexes.contribution_factor/2` follows. `Map.take/2` copies whichever # spelling the member actually carries. @membership_key_spellings Enum.flat_map(@membership_keys, &[&1, Atom.to_string(&1)]) @doc """ Joins scenario roster membership to the snapshot's frozen player evidence. `ScenarioState` deliberately emits membership only; stats and skill evidence live in the write-once challenge snapshot. Only the allowlisted membership fields (identity, position, contract/two-way status) merge over the frozen player, so scenario-owned position and two-way status win on duplicate keys while frozen skill evidence can never be overwritten — a canonical `ScenarioState` row carries no stat line, but an enriched or future caller that did would otherwise silently replace write-once evidence (e.g. a member `season_minutes: 0` zeroing a frozen `800`) and move Position Coverage, Skill Coverage, and Functional Depth. """ @spec hydrate_roster([map()], ChallengeSnapshot.t()) :: [map()] def hydrate_roster(roster, %ChallengeSnapshot{} = snapshot) when is_list(roster) do players = ChallengeSnapshot.section(snapshot, :players) Enum.map(roster, fn member -> member |> player_id!() |> frozen_player(players) |> canonicalize_skill_evidence() |> Map.merge(Map.take(member, @membership_key_spellings)) end) end @doc """ Returns the constants override frozen with the benchmark. Benchmarks created before this provenance field existed use the versioned defaults. String keys are retained because `ModelVersion` accepts both JSON and in-memory spellings without interning document input. """ @spec model_override(ChallengeSnapshot.t()) :: map() def model_override(%ChallengeSnapshot{benchmark: %Benchmark{data: data}}) do with meta when is_map(meta) <- fetch_any(data, :meta), override when is_map(override) <- fetch_any(meta, :model_override) do override else _ -> %{} end end @doc "Returns an override already attached by the pillar, or the benchmark's frozen value." @spec model_override(map(), ChallengeSnapshot.t()) :: map() def model_override(scenario_state, %ChallengeSnapshot{} = snapshot) when is_map(scenario_state) do case Map.get(scenario_state, :model_override) do override when is_map(override) -> override _ -> model_override(snapshot) end end defp frozen_player(player_id, players) do case Map.get(players, player_id) || Map.get(players, to_string(player_id)) do player when is_map(player) -> player _ -> %{} end end defp player_id!(member) do case fetch_any(member, :id) || fetch_any(member, :player_id) do nil -> raise KeyError, key: :id, term: member player_id -> player_id end end # JSON documents return string keys, while `PlayerImpact.season_minutes/1` # and the skill formulas consume the schema's atom fields. Only this fixed # vocabulary is canonicalized; arbitrary document input is never atomized. defp canonicalize_skill_evidence(player) do Enum.reduce(@skill_evidence_keys, player, fn key, canonical -> case Map.fetch(canonical, key) do {:ok, _value} -> canonical :error -> case Map.fetch(canonical, Atom.to_string(key)) do {:ok, value} -> Map.put(canonical, key, value) :error -> canonical end end end) end defp fetch_any(map, key) when is_map(map) do case Map.fetch(map, key) do {:ok, value} -> value :error -> Map.get(map, Atom.to_string(key)) end end defp fetch_any(_other, _key), do: nil end