defmodule TradeMachine.GMGradeV2.Result.GradeResult do @moduledoc """ The final GM Grade result for a scenario. Holds the six `PillarResult`s and their mandate weights, the full-precision mandate-weighted `base_score`, the deterministic plausibility outcome (findings + penalty + optional cap), the full-precision `final_score`, and the deterministic plain-English `narrative` derived from those same frozen inputs. **Rounding happens in exactly one place: `finalize/1`.** It derives the integer `display_score` and its `letter` via `TradeMachine.Grading.Scale`, the shared rounding boundary for both GM Grade and Trade Grade. Every intermediate value stays full-precision so an independently reproduced grade agrees to the last decimal. `snapshot_refs` names every frozen input version (benchmark, challenge snapshot, model + sub-model versions, mandate profile) so the grade can be reproduced after real-world data changes. Its `evidence_source` says whether that reproduction is actually possible: `:frozen` means the snapshot answered every question the grade asked, while `:live_fallback` means at least one input was read from the database at grade time — `live_evidence` names which, and a grade carrying it is only reproducible while that data holds still. """ alias TradeMachine.Grading.Scale alias TradeMachine.GMGradeV2.Result.PillarResult alias TradeMachine.GMGradeV2.Result.ProgressionResult @enforce_keys [:model_version, :mandate_key, :mandate_profile_version] defstruct model_version: nil, mandate_key: nil, mandate_profile_version: nil, pillar_results: [], pillar_weights: %{}, base_score: 0.0, plausibility: %{ findings: [], material_findings: [], material_concern_count: 0, penalty: 0.0, cap: nil }, final_score: 0.0, display_score: nil, letter: nil, validation_status: :ok, snapshot_refs: %{}, starting: %{}, deltas: %{}, progression: nil, narrative: nil @type plausibility :: %{ findings: [map()], material_findings: [map()], material_concern_count: non_neg_integer(), penalty: float(), cap: float() | nil } @type t :: %__MODULE__{ model_version: String.t(), mandate_key: atom(), mandate_profile_version: String.t(), pillar_results: [PillarResult.t()], pillar_weights: %{atom() => float()}, base_score: float(), plausibility: plausibility(), final_score: float(), display_score: non_neg_integer() | nil, letter: String.t() | nil, validation_status: :ok | :provisional | {:invalid, [term()]}, snapshot_refs: map(), starting: map(), deltas: map(), progression: ProgressionResult.t() | nil, narrative: TradeMachine.GMGradeV2.Narrative.t() | nil } @doc """ The single rounding boundary: derives `display_score` (integer) and `letter` from the full-precision `final_score`. Call this once, at the presentation edge. ## Examples iex> alias TradeMachine.GMGradeV2.Result.GradeResult iex> r = %GradeResult{model_version: "avc-1.0.0", mandate_key: :ascending, ...> mandate_profile_version: "1.0.0", final_score: 76.4} iex> r = GradeResult.finalize(r) iex> {r.display_score, r.letter} {76, "C"} """ @spec finalize(t()) :: t() def finalize(%__MODULE__{final_score: final} = result) do display = final |> round() |> Scale.clamp(0, 100) %{result | display_score: display, letter: Scale.letter(display)} end end