defmodule TradeMachine.GMGradeV2.ChallengeSnapshotBuilder do @moduledoc """ Builds the immutable evidence document used by one authored GM Challenge. The builder reads the latest completed-season benchmark available at or before the authored season, freezes league-wide player evidence, contract evidence and expected salaries, and pins the challenge's model and mandate-profile versions in the same transaction as the write-once snapshot insert. """ alias TradeMachine.Challenges alias TradeMachine.Challenges.Challenge alias TradeMachine.DataRefresh alias TradeMachine.GMGradeV2.BenchmarkBuilder alias TradeMachine.GMGradeV2.Benchmarks alias TradeMachine.GMGradeV2.CapState alias TradeMachine.GMGradeV2.ExpectedSalary alias TradeMachine.GMGradeV2.FrozenPickGraph alias TradeMachine.GMGradeV2.FrozenRoster alias TradeMachine.GMGradeV2.MandateProfile alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.GMGradeV2.PlayerImpact alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe alias TradeMachine.CBA.CapCalculator alias TradeMachine.NBA alias TradeMachine.Repo @doc """ Freezes all grading evidence for a draft challenge. Returns `{:error, :benchmark_missing}` when no benchmark has been built at or before the selected season and `{:error, :incomplete_authoring}` when required grading configuration is absent. Returns `{:error, :snapshot_conflict}` when the challenge was edited while its own evidence was being read. The build reads under REPEATABLE READ so the frozen league is internally consistent, and that is the isolation level's way of refusing a write it can no longer serialize. Nothing was written; build again. """ @spec build(Challenge.t()) :: {:ok, TradeMachine.GMGradeV2.ChallengeSnapshot.t()} | {:error, atom() | Ecto.Changeset.t()} def build(%Challenge{} = challenge) do started_at = timestamp() result = try do do_build(challenge) rescue error -> if serialization_conflict?(error) do # Recorded by the `case` below like any other failed build, so this # branch only decides that it is an outcome rather than a crash. {:error, :snapshot_conflict} else record_run(started_at, "failed", challenge, {:exception, Exception.message(error)}) reraise(error, __STACKTRACE__) end end case result do {:ok, _snapshot} -> record_run(started_at, "ok", challenge, result) {:error, reason} when reason in [:incomplete_authoring, :snapshot_exists] -> :ok {:error, _reason} -> record_run(started_at, "failed", challenge, result) end result end defp do_build(%Challenge{mandate_key: nil}), do: {:error, :incomplete_authoring} defp do_build(%Challenge{season: nil}), do: {:error, :incomplete_authoring} defp do_build(%Challenge{asset_valuation_date: nil}), do: {:error, :incomplete_authoring} defp do_build(%Challenge{} = challenge) do with false <- Benchmarks.snapshot_exists?(challenge.id), benchmark when not is_nil(benchmark) <- Benchmarks.latest_benchmark_at_or_before(challenge.season) do Repo.transaction(fn -> request_repeatable_read() data = case build_data(challenge, benchmark) do {:ok, data} -> data {:error, reason} -> Repo.rollback(reason) end attrs = %{ challenge_id: challenge.id, benchmark_id: benchmark.id, data: data } with {:ok, snapshot} <- Benchmarks.insert_snapshot(attrs), {:ok, _challenge} <- Challenges.update_challenge(challenge, %{ model_version: ModelVersion.model_version(), mandate_profile_version: MandateProfile.current_version() }) do snapshot else {:error, reason} -> Repo.rollback(reason) end end) |> unwrap_transaction() else true -> {:error, :snapshot_exists} nil -> {:error, :benchmark_missing} end end # REPEATABLE READ trades a blocking write for an aborted one: where READ # COMMITTED would have waited and re-read, Postgres raises 40001 if the # challenge row this transaction updates at the end was changed by someone # else while the evidence was being read. Nothing is written when it does — # the transaction rolls back whole — so the honest report is a failed build # the operator can simply repeat, not a crashed LiveView. Deliberately not # retried in a loop: a conflict means an admin edited this challenge during # its own snapshot build, and silently rebuilding against the row they just # changed is worse than telling them. defp serialization_conflict?(%Postgrex.Error{postgres: %{code: :serialization_failure}}), do: true defp serialization_conflict?(_error), do: false # The evidence build is a dozen statements — players, their contract and # season preloads, buyouts, trade exceptions, picks, parts, swap groups. Under # READ COMMITTED each takes its own snapshot, so an *atomic* write landing # between two of them is captured half-applied and then frozen forever: # `NBA.waive_player/2` creates a buyout and deletes the contract in one # transaction, so a build that reads contracts before it and buyouts after it # records the salary *and* the dead money, and double-counts that payroll for # every entrant in the challenge. REPEATABLE READ pins one league for the # whole build. # # It cannot be requested under the test sandbox: there the connection's # transaction belongs to the sandbox and has already run queries, so Postgres # rejects `SET TRANSACTION ISOLATION LEVEL` (25001). That is a property of the # pool, not of this code, so it is what gets checked — the production path has # no flag to be misconfigured. `FrozenPickGraph.validate_closed!/3` stays as # the defence in depth that *is* exercised by tests. defp request_repeatable_read do if Application.get_env(:trade_machine, Repo)[:pool] != Ecto.Adapters.SQL.Sandbox do Repo.query!("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") end :ok end @doc """ Builds the canonical frozen document without inserting it. The calibration lab uses this read-only seam to grade actual teams through the same evidence builder as authored challenges. """ @spec build_data(Challenge.t(), TradeMachine.GMGradeV2.Benchmark.t(), keyword()) :: {:ok, map()} | {:error, :player_data_missing | :threshold_data_missing} def build_data(%Challenge{} = challenge, benchmark, opts \\ []) do rows = BenchmarkBuilder.load_rows(benchmark.season) if rows == [] do {:error, :player_data_missing} else official_thresholds = NBA.list_salary_cap_thresholds() |> Map.new(&{&1.season, &1}) case frozen_threshold_horizon(challenge.season, official_thresholds) do {:ok, thresholds} -> player_ids = Enum.map(rows, & &1.player_id) data = %{ "players" => player_evidence(rows), "thresholds" => JSONSafe.value(thresholds), "mandate_profile" => frozen_mandate(challenge.mandate_key), "model_versions" => JSONSafe.value(ModelVersion.versions()), "rookie_contract_policy" => JSONSafe.value(ModelVersion.rookie_contract_policy()), "expected_salaries" => expected_salaries(player_ids, thresholds, opts), # The cap-math half of the league evidence. Populated with the # impact section's players as well as the rostered ones so the # two describe the same league — see `FrozenRoster`. "rosters" => FrozenRoster.build(player_ids), # The draft half. Every asset pillar is valued from this graph, so # a backstage pick edit mid-challenge otherwise revalues one # entrant's inventory and not another's — see `FrozenPickGraph`. "pick_graph" => FrozenPickGraph.build(), "season" => %{ "label" => benchmark.season, "start_date" => Date.to_iso8601(challenge.asset_valuation_date) } } |> JSONSafe.value() |> Jason.encode!() |> Jason.decode!() {:ok, data} {:error, :threshold_data_missing} = error -> error end end end defp expected_salaries(player_ids, thresholds, opts) do case Keyword.get(opts, :expected_salaries, :calculate) do :calculate -> ExpectedSalary.snapshot_section( player_ids: player_ids, thresholds_by_season: thresholds ) :unavailable -> ExpectedSalary.unavailable_section(player_ids) end end defp frozen_threshold_horizon(challenge_season, official_thresholds) do case Map.get(official_thresholds, challenge_season) do nil -> {:error, :threshold_data_missing} current_threshold -> projection = CapCalculator.build_payroll_projection([], current_threshold, %{}, false) # Deliberately the whole official table and not just the horizon: the # cap engine tiers a declined option's hold against the season the # player last earned, which drops behind the horizon at the next # rollover. `CapState.frozen_thresholds/2` documents the re-tiering. {:ok, CapState.frozen_thresholds(projection, official_thresholds)} end end defp player_evidence(rows) do context = %{ regression: PlayerImpact.load_regression_table(), population: %{hd(rows).season => BenchmarkBuilder.population_moments(rows)} } Map.new(rows, fn row -> impact = PlayerImpact.adjust(row, context) evidence = row |> Map.merge(impact) |> Map.put(:name, "#{row.first_name} #{row.last_name}") |> Map.put(:season_minutes, PlayerImpact.season_minutes(row)) |> JSONSafe.value() {row.player_id, evidence} end) end defp frozen_mandate(key) do mandate_key = Enum.find(MandateProfile.keys(), fn known_key -> Atom.to_string(known_key) == key end) case mandate_key do nil -> raise ArgumentError, "unknown mandate profile #{inspect(key)}" known_key -> known_key |> MandateProfile.get!() |> JSONSafe.value() end end defp unwrap_transaction({:ok, snapshot}), do: {:ok, snapshot} defp unwrap_transaction({:error, reason}), do: {:error, reason} defp record_run(started_at, status, challenge, result) do DataRefresh.record_run!(%{ type: "gm_grade_snapshot_build", status: status, started_at: started_at, finished_at: timestamp(), error_count: if(status == "ok", do: 0, else: 1), stats: %{ challenge_id: challenge.id, season: challenge.season, result: inspect_result(result) } }) end defp inspect_result({:ok, snapshot}), do: %{"snapshot_id" => snapshot.id} defp inspect_result({:error, reason}), do: %{"error" => inspect(reason)} defp inspect_result({:exception, message}), do: %{"exception" => message} defp inspect_result(other), do: %{"result" => inspect(other)} defp timestamp, do: DateTime.utc_now() |> DateTime.truncate(:second) end