defmodule TradeMachine.GMGradeV2.ChallengeSnapshot do @moduledoc """ The frozen input set for a single GM Challenge: every value a submission is scored against, captured once when the challenge is prepared. A challenge is a competition, so the requirement is stronger than reproducibility — it is **fairness**. Two users submitting a week apart must be graded against identical evidence. Without this row the first entrant would be scored against one week's player stats and valuations and the second against another, and the leaderboard would rank the calendar as much as the roster. ## The complete evidence set "Identical evidence" is a claim about *every* input, so it is only true if the list is exhaustive — a grade that mixes frozen player stats with a live salary is still calendar-dependent, just harder to notice. Four things decide a challenge grade, and this row now holds all four: * **how good each player is** — the `"players"` impact evidence, plus the `"expected_salaries"` market estimates that price it * **what the cap allows** — the `"thresholds"` table, read by the cap engine and by Plausibility and Contract Health alike * **what each player costs** — `"rosters"`: contract rows for the league, plus each team's persisted buyouts and trade exceptions * **who owns which draft rights** — `"pick_graph"`: every pick, part and swap group, which every asset-management pillar is valued from The last two arrived after some snapshots were already written, and an insert-only row cannot be back-filled. Those documents therefore grade from live reads rather than not at all — and say so: `ScenarioAssembly` records `"evidence_source" => "live_fallback"` in the grade's `snapshot_refs`, naming the inputs it had to read live, where a fully-frozen document records `"frozen"`. A challenge still open when a section ships should have its snapshot rebuilt; the marker is how you find the grades that need it. Ordinary challenge setup creates one snapshot. An explicit audited regrade may retain that row and append a rebuilt immutable snapshot; reads select the newest row while grade revisions permanently retain the exact row they used. Each snapshot points at the `TradeMachine.GMGradeV2.Benchmark` in force (`on_delete: :restrict`, so that benchmark can never be deleted out from under the grades that cite it). ## `data` sections * `"players"` — per-player frozen impact / skill / valuation inputs * `"thresholds"` — cap, tax and apron thresholds by season * `"mandate_profile"` — a *copy* of the challenge's mandate profile, not a reference: the profile is versioned and editable, and a challenge already being played must not silently change what it was asking for * `"model_versions"` — `TradeMachine.GMGradeV2.ModelVersion.versions/0` as of capture, so a grade can name every constant that produced it * `"rookie_contract_policy"` — the complete draft-slot curve, fallback, and NBA-evidence blend frozen for new snapshots. Historical v1 snapshots omit it and replay through the versioned legacy policy. * `"expected_salaries"` — per-player market estimate and per-season projection, built by `TradeMachine.GMGradeV2.ExpectedSalary.snapshot_section/1` (Phase 06). Required for the same reason as the rest: Contract Health, PTV and plausibility read *only* this section, so a snapshot without it grades every entrant's contracts against nothing. * `"season"` — season metadata including ISO-8601 `"start_date"`, freezing the reference date for whole-year player ages (D-12) * `"rosters"` — the league's contract evidence: every rostered player's row with its contracts and contract seasons, plus each team's persisted buyouts and trade exceptions. Built and read back by `TradeMachine.GMGradeV2.FrozenRoster`, which documents why the whole row is frozen. This is the cap-math counterpart to `"players"`: that section says how good a player is, this one says what he costs. Optional, because snapshots written before it existed cannot be back-filled — those grade from live reads and say so (see `optional_section/2`). * `"pick_graph"` — the league's draft rights: every pick, every part, and every swap group with its members and outcomes. Built and read back by `TradeMachine.GMGradeV2.FrozenPickGraph`. Every asset-management pillar is valued from it, and unlike a roster change a pick changing hands fails no validation — it simply stops appearing in a total. Optional for the same reason `"rosters"` is. Later phases may freeze more; the schema requires the first six and permits the rest. ## Every section must be non-empty, and that is not an oversight to relax Unlike a benchmark's `dav` — whose emptiness is a documented state meaning "Phase 07 has not run" (D-35) — none of these six has a reading in which it is legitimately empty. Each is evidence the grader reads, so an empty one does not fail: it scores every entrant in the challenge as though that whole input were simply absent, producing a uniformly wrong leaderboard that looks like a valid one. D-52's rule is that emptiness is meaningful only where it is *documented*, so all six are required non-empty. If a later phase finds a real empty state for one of them, that is a decision to record, not a constraint to quietly loosen — the exemptions are where a fail-closed validator leaks. `expected_salaries` is the sharpest case, because it has a state that *looks* like emptiness and is not: a player the market model cannot price is stored with `"status" => "unavailable"`, present and explicit. The section being empty means the builder never ran; a player being unavailable means it ran and found nothing to say. Only the second is a legitimate answer, which is why the first is rejected here. ## Insert-only Snapshots are never updated. That is the entire point — a mutable snapshot is not a snapshot — and it is why `TradeMachine.GMGradeV2.Benchmarks` exposes `insert_snapshot/1` with no update counterpart, and why this module offers only `insert_changeset/2` (D-37). Correcting a bad snapshot means deleting the challenge's snapshot and inserting a new one, deliberately: an explicit destroy-and-recreate leaves the grades that cited it visibly orphaned, where an in-place edit would leave them looking valid while meaning something else. """ use Ecto.Schema import Ecto.Changeset alias TradeMachine.GMGradeV2.FrozenDocument @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @type t :: %__MODULE__{} schema "challenge_snapshots" do field :data, :map belongs_to :challenge, TradeMachine.Challenges.Challenge belongs_to :benchmark, TradeMachine.GMGradeV2.Benchmark # Matches the migration — see `TradeMachine.GMGradeV2.Benchmark`. timestamps(type: :utc_datetime_usec) end @castable ~w(challenge_id benchmark_id data)a @doc """ Builds the one and only changeset a snapshot ever gets. Named `insert_changeset` rather than `changeset` on purpose: the conventional name invites `Repo.update/1`, and the name is the cheapest place to say that this row has no update path (D-37). `data` is canonicalized and schema-validated by the same fail-closed pipeline the benchmark uses — one implementation, so the two frozen tables cannot drift into enforcing different things (D-54). """ def insert_changeset(snapshot, attrs) do snapshot |> cast(attrs, @castable) |> validate_required(@castable) |> FrozenDocument.validate_document(:data, &data_schema/1) |> foreign_key_constraint(:challenge_id) |> foreign_key_constraint(:benchmark_id) end @doc "The `data` sections every snapshot must carry, in document order." @spec required_sections() :: [String.t()] def required_sections do {:map, entries} = data_schema(%{}) Enum.map(entries, fn {name, _spec} -> name end) end @doc """ The required shape of a canonicalized `data` document. Takes the document for signature parity with `TradeMachine.GMGradeV2.Benchmark.data_schema/1` — a snapshot has no document-relative cardinality to resolve, so nothing here reads it. Public so the D-54 property test enumerates required paths from the schema rather than from a restated list. """ @spec data_schema(term()) :: FrozenDocument.spec() def data_schema(_canonical) do non_empty_map = {:map, [], min_size: 1} {:map, [ {"players", non_empty_map}, {"thresholds", non_empty_map}, {"mandate_profile", non_empty_map}, {"model_versions", non_empty_map}, {"expected_salaries", non_empty_map}, {"season", non_empty_map} ]} end @doc """ Fetches a `data` section by its atom name. Raises `KeyError` on a miss rather than defaulting — see `TradeMachine.GMGradeV2.Benchmark.section/2` for why a silent default is the dangerous choice here. Only string keys are looked up, because that is the only spelling a snapshot is ever stored in. """ @spec section(t(), atom()) :: term() def section(%__MODULE__{data: data}, section) when is_atom(section) do Map.fetch!(data, Atom.to_string(section)) end @doc """ Fetches a section a snapshot may legitimately not carry, or `nil`. The counterpart to `section/2`, not a softer spelling of it. A section is optional here for exactly one reason: it was introduced after some snapshots were already written, and those rows can never be back-filled because snapshots are insert-only. `nil` therefore means "this document predates the section" — a real, dateable state — where a miss on a required section means the builder is broken, which is why that one still raises. A consumer reading through this owes the caller an accounting of what it did instead; see the `"evidence_source"` diagnostics on a grade result. """ @spec optional_section(t(), atom()) :: term() | nil def optional_section(%__MODULE__{data: data}, section) when is_atom(section) do Map.get(data, Atom.to_string(section)) end end