defmodule TradeMachine.GMGradeV2.Plausibility do @moduledoc """ Deterministic contract and trade plausibility for the final scenario. Every constant in this module is an AVC v1 calibration default (D-28). Contract realism compares first-season salary with the frozen expected-salary evidence. The comparison uses: adjusted_expected = actual + confidence * (expected - actual) ratio = actual / adjusted_expected effective_threshold = published_threshold * confidence Multiplying thresholds by confidence widens the acceptable range when the market estimate is uncertain. Findings originating in Asset Exchange Value are retained for explanation but are already capped there and never contribute another grade-level penalty or cap. """ alias TradeMachine.CBA.Exceptions alias TradeMachine.CBA.Rules alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.ExpectedSalary alias TradeMachine.GMGradeV2.MaterialFinding alias TradeMachine.GMGradeV2.Result.PillarResult @unlikely_penalty 5.0 @maximum_penalty 10.0 @unlikely_cap 65.0 @unrealistic_cap 40.0 @material_severities [:aggressive, :unlikely, :unrealistic] @type finding :: %{ origin: :aev_transaction | :contract | :trade, move_ref: String.t() | nil, transaction_type: atom(), display_label: String.t(), teams: [String.t()], assets: [map()], severity: atom(), reason_category: atom(), subject: term(), evidence: map(), message: String.t(), explanation: String.t() } @doc "Normalizes a current or historical persisted plausibility finding." @spec normalize_finding(map()) :: MaterialFinding.t() def normalize_finding(finding), do: MaterialFinding.normalize(finding) @doc "Matches findings to normalized moves and freezes the user-visible material subset." @spec integrate_moves(map(), [map()]) :: map() def integrate_moves(plausibility, moves) when is_map(plausibility) and is_list(moves) do findings = plausibility |> field(:findings, []) |> list() |> Enum.map(&match_move(&1, moves)) material_findings = Enum.filter(findings, &material_finding?/1) plausibility |> Map.put(:findings, findings) |> Map.put(:material_findings, material_findings) |> Map.put(:material_concern_count, length(material_findings)) end @doc "Returns the same material finding list used for the aggregate concern count." @spec material_findings(map()) :: [MaterialFinding.t()] def material_findings(plausibility) when is_map(plausibility) do case field(plausibility, :material_findings, :missing) do findings when is_list(findings) -> Enum.map(findings, &MaterialFinding.normalize/1) :missing -> plausibility |> field(:findings, []) |> list() |> Enum.map(&MaterialFinding.normalize/1) |> Enum.filter(&material_finding?/1) _other -> [] end end def material_findings(_plausibility), do: [] @doc "Counts exactly the material findings returned by `material_findings/1`." @spec material_concern_count(map()) :: non_neg_integer() def material_concern_count(plausibility), do: plausibility |> material_findings() |> length() @doc """ Assesses the final scenario against frozen evidence. `pillar_results` is optional so callers assessing contract realism alone do not need to manufacture Asset Management output. """ @spec assess(map(), ChallengeSnapshot.t(), [PillarResult.t()]) :: %{ findings: [finding()], penalty: float(), cap: float() | nil } def assess(scenario_state, snapshot, pillar_results \\ []) def assess(scenario_state, %ChallengeSnapshot{} = snapshot, pillar_results) when is_map(scenario_state) and is_list(pillar_results) do findings = contract_findings(scenario_state, snapshot) ++ aev_findings(pillar_results) grade_findings = Enum.filter(findings, &(&1.origin in [:contract, :trade])) penalty = grade_findings |> Enum.count(&(&1.severity == :unlikely)) |> Kernel.*(@unlikely_penalty) |> min(@maximum_penalty) cap = grade_findings |> Enum.flat_map(fn %{severity: :unrealistic} -> [@unrealistic_cap] %{severity: :unlikely} -> [@unlikely_cap] _ -> [] end) |> minimum_cap() %{findings: findings, penalty: penalty, cap: cap} end defp contract_findings(scenario_state, snapshot) do modifications = Map.get(scenario_state, :modifications, %{}) || %{} season = Map.fetch!(scenario_state, :season) expected = ChallengeSnapshot.section(snapshot, :expected_salaries) signing_entries(modifications, season) |> Enum.map(fn signing -> contract_finding( signing, expected, veteran_minimum(scenario_state, snapshot, signing.season) ) end) end defp contract_finding(signing, expected_section, veteran_minimum) do with true <- is_number(signing.salary) and signing.salary >= 0, true <- is_number(veteran_minimum) and veteran_minimum > 0, {:ok, expectation} <- ExpectedSalary.season_expectation(expected_section, signing.player_id, signing.season) do cond do expectation.confidence == 0 -> finding( :contract, :insufficient_evidence, :insufficient_evidence, signing, %{ player_id: signing.player_id, season: signing.season, confidence: expectation.confidence }, "Frozen expected-salary evidence has zero confidence for #{signing.subject}; no plausibility penalty applies." ) expectation.expected <= 2 * veteran_minimum -> finding( :contract, :plausible, :minimum_salary_market, signing, %{ exemption: :minimum_salary_market, actual_salary: signing.salary, expected_salary: expectation.expected, veteran_minimum: veteran_minimum }, "#{signing.subject} is exempt because the expected salary is within twice the veteran minimum." ) true -> adjusted = ExpectedSalary.confidence_adjusted( signing.salary, expectation.expected, expectation.confidence ) ratio = if adjusted > 0, do: signing.salary / adjusted, else: 0.0 severity = severity(ratio, expectation.confidence) finding( :contract, severity, :salary_below_expected, signing, %{ player_id: signing.player_id, season: signing.season, actual_salary: signing.salary, expected_salary: expectation.expected, confidence: expectation.confidence, confidence_adjusted_expected: adjusted, ratio: ratio, effective_thresholds: effective_thresholds(expectation.confidence) }, "#{signing.subject}'s first-season salary is #{ratio_text(ratio)} of the confidence-adjusted frozen expectation." ) end else false -> finding( :contract, :insufficient_evidence, :insufficient_evidence, signing, %{player_id: signing.player_id, season: signing.season, salary: signing.salary}, "Salary or veteran-minimum evidence is unavailable for #{signing.subject}; no plausibility penalty applies." ) {:error, :unavailable} -> finding( :contract, :insufficient_evidence, :insufficient_evidence, signing, %{player_id: signing.player_id, season: signing.season}, "Frozen expected-salary evidence is unavailable for #{signing.subject}; no plausibility penalty applies." ) end end defp severity(ratio, confidence) do thresholds = effective_thresholds(confidence) cond do ratio >= thresholds.plausible -> :plausible ratio >= thresholds.aggressive -> :aggressive ratio >= thresholds.unlikely -> :unlikely true -> :unrealistic end end defp effective_thresholds(confidence) do %{ plausible: 0.80 * confidence, aggressive: 0.60 * confidence, unlikely: 0.40 * confidence } end # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp signing_entries(modifications, season) do re_signings = modifications |> Map.get("re_signings", %{}) |> Enum.map(fn {player_id, entry} -> signing( :re_signing, player_id, entry["name"] || player_id || "Unknown re-signing", entry["signing_season"] || List.first(Rules.future_seasons()) || season, entry["salary"] ) end) one_year = modifications |> Map.get("added_players", []) |> Enum.map(fn entry -> signing( :signing, entry["id"], entry["name"] || entry["id"] || "Unknown signing", season, entry["salary"] ) end) multi_year = modifications |> Map.get("payroll_added_players", []) |> Enum.reject(&(&1["is_draft_pick"] == true)) |> Enum.map(fn entry -> signing_season = entry["signing_season"] || List.first(Rules.future_seasons()) || season salaries = entry["salaries"] || %{} signing( :signing, entry["id"], entry["name"] || entry["id"] || "Unknown signing", signing_season, Map.get(salaries, signing_season) || earliest_salary(salaries) ) end) (re_signings ++ one_year ++ multi_year) |> Enum.sort_by(&{&1.subject, &1.player_id, &1.season}) end defp signing(transaction_type, player_id, subject, season, salary) do %{ transaction_type: transaction_type, move_ref: MaterialFinding.move_ref(transaction_type, player_id), player_id: player_id, subject: subject, season: season, salary: salary } end defp veteran_minimum(scenario_state, snapshot, season) do frozen = snapshot |> ChallengeSnapshot.section(:thresholds) |> threshold_minimum(season) if is_number(frozen) and frozen > 0 do frozen else scenario_state |> Map.get(:thresholds_by_season, %{}) |> Map.get(season) |> case do nil -> nil thresholds -> Exceptions.minimum_salary(thresholds, 10) end end end defp earliest_salary(salaries) when is_map(salaries) do salaries |> Enum.filter(fn {season, salary} -> is_binary(season) and is_number(salary) end) |> Enum.min_by(fn {season, _salary} -> season end, fn -> {nil, nil} end) |> elem(1) end defp threshold_minimum(thresholds, season) do with season_thresholds when is_map(season_thresholds) <- Map.get(thresholds, season), minimums when is_map(minimums) <- Map.get(season_thresholds, "minimum_salaries"), values = Enum.filter(Map.values(minimums), &is_number/1), true <- values != [] do Map.get(minimums, "10") || Enum.max(values) else _ -> nil end end defp aev_findings(pillar_results) do with %PillarResult{} = pillar <- Enum.find(pillar_results, &(&1.key == :asset_management)), metric when not is_nil(metric) <- Enum.find(pillar.metric_results, &(&1.key == :asset_exchange_value)) do metric.diagnostics |> Map.get(:plausibility_findings, []) |> Enum.map(fn source -> transaction_id = Map.get(source, :transaction_id) || Map.get(source, "transaction_id") severity = Map.get(source, :severity) || Map.get(source, "severity") || :unlikely source |> Map.merge(%{ origin: :aev_transaction, move_ref: MaterialFinding.move_ref(:trade, transaction_id), transaction_type: :trade, display_label: "Trade #{transaction_id}", severity: severity, reason_category: :asset_value_outlier, subject: transaction_id, evidence: source }) |> MaterialFinding.normalize() end) else _ -> [] end end defp match_move(finding, moves) do normalized = MaterialFinding.normalize(finding) case matching_move(normalized, moves) do nil -> normalized move -> normalized |> Map.put(:move_ref, field(move, :move_ref, normalized.move_ref)) |> Map.put(:transaction_type, field(move, :transaction_type, normalized.transaction_type)) |> Map.put(:display_label, field(move, :display_label, normalized.display_label)) |> Map.put(:teams, preferred_context(field(move, :teams, []), normalized.teams)) |> Map.put(:assets, preferred_context(field(move, :assets, []), normalized.assets)) |> Map.put(:move_summary, move) end end defp matching_move(finding, moves) do exact_matches = case field(finding, :move_ref) do move_ref when is_binary(move_ref) and move_ref != "" -> Enum.filter(moves, &(field(&1, :move_ref) == move_ref)) _other -> [] end case exact_matches do [move] -> move [] -> legacy_matching_move(finding, moves) _ambiguous -> nil end end # Historical findings may predate stable move references. Match them only # when their recorded type plus label/subject identifies exactly one move. defp legacy_matching_move(finding, moves) do type = field(finding, :transaction_type) labels = [field(finding, :display_label), field(finding, :subject)] candidates = Enum.filter(moves, fn move -> type_matches?(type, field(move, :transaction_type)) and Enum.any?(labels, &same_label?(&1, field(move, :display_label))) end) case candidates do [move] -> move _other -> nil end end defp type_matches?(type, _move_type) when type in [nil, :unknown, "unknown"], do: true defp type_matches?(type, move_type), do: to_string(type) == to_string(move_type) defp same_label?(label, move_label) when is_binary(label) and is_binary(move_label), do: label != "" and String.downcase(label) == String.downcase(move_label) defp same_label?(_label, _move_label), do: false defp preferred_context(value, _fallback) when is_list(value) and value != [], do: value defp preferred_context(_value, fallback), do: fallback defp material_finding?(finding) do severity = field(finding, :severity) severity in @material_severities or severity in Enum.map(@material_severities, &to_string/1) end defp field(map, key, default \\ nil), do: Map.get(map, key, Map.get(map, Atom.to_string(key), default)) defp list(value) when is_list(value), do: value defp list(_value), do: [] defp finding(origin, severity, reason_category, signing, evidence, message) do MaterialFinding.new(%{ origin: origin, move_ref: signing.move_ref, transaction_type: signing.transaction_type, display_label: signing.subject, teams: [], assets: [ %{type: :player, id: signing.player_id, label: signing.subject} ], severity: severity, reason_category: reason_category, subject: signing.subject, evidence: evidence, message: message, explanation: message }) end defp minimum_cap([]), do: nil defp minimum_cap(caps), do: Enum.min(caps) defp ratio_text(ratio), do: "#{Float.round(ratio * 100, 1)}%" end