defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.AssetExchangeValue do @moduledoc """ Scores the value exchanged in scenario player/draft-asset transactions. Player values come from the separate PTV model; pick values come from Phase 7 DAV claims. Signings, waivers, cash, and cap mechanics are intentionally not qualifying Asset Exchange transactions. """ alias TradeMachine.GMGradeV2.DAV.Valuer alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.GMGradeV2.MaterialFinding alias TradeMachine.GMGradeV2.PTV alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.TransactionValueCurve @plausibility_explanation "This transaction appears highly favorable based on package-equivalent market value. Because the return falls outside the normal acceptance range, the counterparty acceptance curve reduces its Asset Exchange score." @negative_outlier_explanation "This transaction appears highly unfavorable based on estimated market value. It falls outside the normal market range, but its Asset Exchange score is already at the lower end of the scale." @typedoc """ Resolution context for scenario transaction components. `player_snapshots` is keyed by player id. An optional `player_value_resolver` may return an explicit provisional value when a traded player is absent from that frozen section. Pick claims must be direction-aware: `pick_claims` may be `%{incoming: [...], outgoing: [...]}`, where each list is built for the owner on that transaction side. A sequential scenario that acquires and later flips the same asset supplies `pick_claim_resolver`, a two-arity function receiving the stored entry and `:incoming | :outgoing`. Scenario assembly may supply a three-arity resolver whose final argument is the zero-based transaction index; this is the authoritative seam for transaction-time claims that no one final owner inventory can represent. A flat claim list remains supported only when the caller deliberately has one complete claim universe for both directions. `dav_context` is passed unchanged to `Valuer.dav/2`. """ @type context :: %{ required(:player_snapshots) => map(), optional(:player_value_resolver) => (String.t(), map() | nil -> map()), optional(:pick_claims) => [map()] | %{incoming: [map()], outgoing: [map()]}, optional(:pick_claim_resolver) => (map(), :incoming | :outgoing -> map() | {:unavailable, term()}) | (map(), :incoming | :outgoing, non_neg_integer() -> map() | {:unavailable, term()}), required(:dav_context) => map(), optional(:ptv_context) => map() } @doc "Scores qualifying trades from a scenario modifications document." @spec score(map(), context()) :: MetricResult.t() def score(modifications, context) when is_map(modifications) and is_map(context) do context = freeze_ptv_context(context) transactions = modifications |> Map.get("trades", []) |> Enum.with_index() |> Enum.map(fn {trade, index} -> resolve_transaction(trade, index, context) end) |> Enum.reject(&(&1.components == [])) document = context.ptv_context.coefficients_document build_result(transactions, document) end @doc "Scores already-resolved transaction components." @spec score_transactions([map()]) :: MetricResult.t() def score_transactions(transactions) when is_list(transactions), do: build_result(transactions, PTV.coefficients_document()) @doc "Returns the explicit AEV status carried by a metric result." @spec status(MetricResult.t()) :: :no_transactions | :provisional | :ok def status(%MetricResult{diagnostics: diagnostics}), do: Map.fetch!(diagnostics, :status) @doc "Applies the versioned package-equivalence and counterparty-acceptance curves." @spec transaction_score([number()], [number()]) :: map() def transaction_score(incoming, outgoing) when is_list(incoming) and is_list(outgoing) do result = TransactionValueCurve.evaluate(incoming, outgoing) Map.merge(result, %{ plausibility_explanation: cond do result.plausibility_flagged? and result.net_advantage > 0 -> @plausibility_explanation result.plausibility_flagged? -> @negative_outlier_explanation true -> nil end }) end defp resolve_transaction(trade, index, context) do incoming = resolve_components( Map.get(trade, "players_in") || [], Map.get(trade, "picks_in") || [], :incoming, index, context ) outgoing = resolve_components( Map.get(trade, "players_out") || [], Map.get(trade, "picks_out") || [], :outgoing, index, context ) score = transaction_score( Enum.map(incoming, & &1.value), Enum.map(outgoing, & &1.value) ) Map.merge(score, %{ id: Map.get(trade, "id", index), components: incoming ++ outgoing, player_involved?: Enum.any?(incoming ++ outgoing, &(&1.type == :player)) }) end defp resolve_components(players, picks, direction, transaction_index, context) do player_components = Enum.map(players, fn player -> player_id = Map.fetch!(player, "player_id") result = resolve_player_value(context, player_id) %{ type: :player, direction: direction, id: player_id, value: result.ptv, confidence: result.confidence, diagnostics: result.diagnostics } end) pick_components = Enum.map(picks, fn pick -> case resolve_claim!(context, pick, direction, transaction_index) do {:unavailable, reason} -> identity = stored_claim_identity(pick) %{ type: :draft_asset, direction: direction, id: JSONSafe.value(identity), claim_identity: identity, value: 0.0, confidence: :provisional, diagnostics: %{method: :unavailable_frozen_draft_claim, reason: inspect(reason)} } claim -> result = Valuer.dav(claim, Map.fetch!(context, :dav_context)) %{ type: :draft_asset, direction: direction, id: claim_identity(claim) |> JSONSafe.value(), claim_identity: claim_identity(claim), value: result.dav, diagnostics: JSONSafe.value(result.diagnostics) } end end) components = player_components ++ pick_components assert_unique_claims!(components, direction) Enum.map(components, &Map.delete(&1, :claim_identity)) end defp resolve_player_value(%{player_value_resolver: resolver} = context, player_id) when is_function(resolver, 2) do snapshots = Map.fetch!(context, :player_snapshots) resolver.(player_id, Map.get(snapshots, player_id)) end defp resolve_player_value(context, player_id) do snapshot = fetch_player_snapshot!(context, player_id) PTV.value(snapshot, Map.get(context, :ptv_context, %{})) end defp fetch_player_snapshot!(context, player_id) do snapshots = Map.fetch!(context, :player_snapshots) case Map.fetch(snapshots, player_id) do {:ok, snapshot} -> snapshot :error -> raise KeyError, key: player_id, term: snapshots end end defp resolve_claim!(%{pick_claim_resolver: resolver}, entry, direction, transaction_index) when is_function(resolver, 3) do validate_resolved_claim(resolver.(entry, direction, transaction_index), direction) end defp resolve_claim!(%{pick_claim_resolver: resolver}, entry, direction, _transaction_index) when is_function(resolver, 2) do validate_resolved_claim(resolver.(entry, direction), direction) end defp resolve_claim!(context, entry, direction, _transaction_index) do context |> claims_for_direction!(direction) |> find_claim!(entry) end defp validate_resolved_claim(result, direction) do case result do %{kind: kind} = claim when kind in [:whole, :range_part, :swap_outcome] -> claim {:unavailable, _reason} = unavailable -> unavailable other -> raise ArgumentError, "pick_claim_resolver returned no valueable DAV claim for #{direction}: " <> inspect(other) end end defp claims_for_direction!(context, direction) do case Map.fetch(context, :pick_claims) do {:ok, claims} when is_list(claims) -> claims {:ok, %{^direction => claims}} when is_list(claims) -> claims {:ok, directional} when is_map(directional) -> raise ArgumentError, "pick_claims has no #{direction} claim list" :error -> raise ArgumentError, "AEV requires direction-aware pick_claims or pick_claim_resolver for draft assets" end end defp find_claim!(claims, %{"asset_kind" => "swap_outcome"} = entry) do group_id = Map.get(entry, "swap_group_id") rank = Map.get(entry, "rank") if is_nil(group_id) or is_nil(rank) do raise ArgumentError, "swap-outcome entry must carry swap_group_id and rank: #{inspect(entry)}" end unique_claim!( Enum.filter( claims, &(&1.kind == :swap_outcome and &1.swap_group_id == group_id and &1.rank == rank) ), {:swap_outcome, group_id, rank} ) end defp find_claim!(claims, entry) do part_id = Map.get(entry, "part_id") pick_id = Map.get(entry, "pick_id") part_matches = if is_nil(part_id), do: [], else: Enum.filter(claims, &(&1.kind != :rollover_fallback and &1.part_id == part_id)) cond do part_matches != [] -> unique_claim!(part_matches, {:part, part_id}) not is_nil(part_id) and is_nil(pick_id) -> unique_claim!([], {:part, part_id}) not is_nil(pick_id) -> claims |> Enum.filter(&(&1.kind == :whole and &1.pick_id == pick_id)) |> unique_claim!({:pick, pick_id}) true -> raise ArgumentError, "draft-asset entry has no part-level, swap-outcome, or whole-pick identity: " <> inspect( Map.take(entry, ["asset_kind", "part_id", "pick_id", "swap_group_id", "rank"]) ) end end defp claim_identity(%{kind: :swap_outcome} = claim), do: {:swap_outcome, claim.swap_group_id, claim.rank} defp claim_identity(%{part_id: part_id}) when not is_nil(part_id), do: {:part, part_id} defp claim_identity(claim), do: {:pick, Map.fetch!(claim, :pick_id)} defp stored_claim_identity(%{"asset_kind" => "swap_outcome"} = entry), do: {:swap_outcome, entry["swap_group_id"], entry["rank"]} defp stored_claim_identity(entry), do: {:draft_asset, entry["part_id"], entry["draft_pick_id"] || entry["pick_id"]} defp unique_claim!([claim], _identity), do: claim defp unique_claim!([], identity) do raise ArgumentError, "no DAV claim matches scenario draft-asset identity #{inspect(identity)}" end defp unique_claim!(claims, identity) do raise ArgumentError, "scenario draft-asset identity #{inspect(identity)} is ambiguous across " <> "#{length(claims)} DAV claims" end defp assert_unique_claims!(components, direction) do identities = for %{type: :draft_asset, claim_identity: identity} <- components do identity end duplicate = identities |> Enum.frequencies() |> Enum.find(fn {_identity, count} -> count > 1 end) if duplicate do {identity, count} = duplicate raise ArgumentError, "#{direction} transaction side resolves #{count} entries to DAV claim " <> "#{inspect(identity)}" end end defp freeze_ptv_context(context) do ptv_context = context |> Map.get(:ptv_context, %{}) |> Map.put_new(:coefficients_document, PTV.coefficients_document()) Map.put(context, :ptv_context, ptv_context) end defp build_result([], _document) do %MetricResult{ key: :asset_exchange_value, version: ModelVersion.model_version(), score: 50.0, inputs: %{transaction_count: 0}, supporting: %{transactions: []}, explanation: "Asset Exchange Value is neutral because the scenario contains no qualifying transactions.", diagnostics: %{status: :no_transactions, plausibility_findings: []} } end defp build_result(transactions, document) do total_scale = Enum.reduce(transactions, 0.0, &(&2 + &1.scale)) weighted_score = Enum.reduce(transactions, 0.0, fn transaction, total -> total + transaction.score * transaction.scale / total_scale end) unavailable_component? = Enum.any?(transactions, fn transaction -> Enum.any?(transaction.components, &(Map.get(&1, :confidence) == :provisional)) end) provisional? = unavailable_component? or (not Map.fetch!(document, "calibrated") and Enum.any?(transactions, & &1.player_involved?)) or not TransactionValueCurve.calibrated?() status = if(provisional?, do: :provisional, else: :ok) findings = for transaction <- transactions, transaction.plausibility_flagged? do MaterialFinding.new(%{ transaction_id: transaction.id, move_ref: MaterialFinding.move_ref(:trade, transaction.id), transaction_type: :trade, display_label: "Trade #{transaction.id}", teams: [], assets: finding_assets(transaction.components), severity: finding_severity(transaction), reason_category: :asset_value_outlier, net_advantage: transaction.net_advantage, scale: transaction.scale, acceptance_multiplier: transaction.acceptance_multiplier, uncapped_score: transaction.uncapped_score, applied_score: transaction.score, explanation: transaction.plausibility_explanation }) end %MetricResult{ key: :asset_exchange_value, version: ModelVersion.model_version(), score: weighted_score, inputs: %{transaction_count: length(transactions), total_scale: total_scale}, supporting: %{transactions: transactions}, fallbacks: provisional_fallbacks(provisional?), explanation: explanation(status, length(transactions)), diagnostics: %{status: status, plausibility_findings: findings} } end defp finding_severity(%{net_advantage: net}) when is_number(net) and net < 0, do: :warning defp finding_severity(_transaction), do: :unlikely defp explanation(:provisional, count), do: "Asset Exchange Value scored #{count} qualifying transactions provisionally because player valuation calibration or frozen component evidence is incomplete." defp explanation(:ok, count), do: "Asset Exchange Value scale-weights #{count} qualifying transactions." defp provisional_fallbacks(false), do: [] defp provisional_fallbacks(true) do [ %{ input: :player_transaction_value, method: :uncalibrated_valuation_curves, note: "Player or transaction valuation is uncalibrated; Asset Exchange Value is displayed but excluded from the pillar score." } ] end defp finding_assets(components) do Enum.map(components, fn component -> %{ id: Map.get(component, :id), type: Map.get(component, :type), direction: Map.get(component, :direction), label: component |> Map.get(:id, "Unknown asset") |> asset_label() } end) end defp asset_label(value) when is_binary(value), do: value defp asset_label(value) when is_atom(value) or is_number(value), do: to_string(value) defp asset_label(value), do: inspect(value) end