defmodule TradeMachine.GMGradeV2.DAV.OutcomeEvaluator do @moduledoc """ The canonical rights outcome evaluator: what a team actually controls, and what each of those claims resolves into. Two functions, answering the two questions every draft-asset number depends on: * `controlled_claims/5` — *which assets are mine?* Returns the *mutually exclusive* claims a team may be paid for. This is the duplicate-claim firewall: a whole pick, the conveyed slice of that pick, and a swap outcome over that slice are three descriptions of overlapping rights, and summing all three would inflate a team's inventory (AVC, "Asset Identity and Duplicate Claims"). * `terminal_outcomes/2` — *what does one claim turn into?* Walks the protection band, the rollover chain, and any second-round conversion until the asset **conveys**, **converts** into a different asset, or **extinguishes** — AVC's three terminal states, which are exactly the three shapes of `:value_basis`. Everything here is pure. Picks, parts, and swap groups are passed in; nothing queries. That is what keeps the frozen-valuation rule structural rather than aspirational — this module cannot reach for a live scenario roster because it cannot reach at all — and it is why `TradeMachine.GMGradeV2.DAV.Valuer`, the swap Monte Carlo, and the Phase 04 benchmark builder can all share one interpretation of pick language instead of growing three. ## Absolute slots, everywhere `draft_pick_parts.range_low`/`range_high` are stored **within the round** (1–30 for both rounds); `draft_picks.pick_number` and `TradeMachine.GMGradeV2.DAV.SlotCurve` are **absolute** (1–60). Everything this module emits — `:range` on a claim, `:retained_range` on a chain hop, `{:slot, s}` on an outcome — is absolute, converted once at the boundary. A second-rounder's 1–30 band becomes 31–60 here. Mixing the two conventions is the single most expensive mistake available in this file: a round-2 band read as round-1 slots misses every slot of its own distribution and silently values the asset at zero. `terminal_outcomes/2` raises rather than allow it. ## What counts as controlled Claim eligibility mirrors `TradeMachine.NBA.Trades.list_tradeable_pick_parts_for_team/1`, which is the audited answer the trade builders already use — deliberately, so the grader and the builder cannot disagree about who owns what: * Range parts the team owns that are not conveyed, on picks that are not conveyed and are not legacy `is_swap_rights` companion rows. * Minus parts absorbed into a swap pool. Membership is part-scoped, so a protected split's retained band stays visible even when its conveyable band sits in a pool (#379); a membership recorded without a part absorbs the pick's non-retained parts. * Plus the swap-group **outcomes** the team holds — the right to the rank-N most favorable pick in a pool, which is what a swap actually is. Frozen picks (`is_frozen`) are still controlled assets with real DAV — they are merely untradeable — so they are claimed here and flagged `:frozen` for Phase 07's capacity analysis to exclude. Picks outside `window_years` are not claimed at all (AVC: "Picks outside the evaluation window"). ## The chain means two different things, so every claim says which A protected pick that fails to convey moves the obligation forward via `draft_pick_parts.rolls_to_pick_id`, which lives on the **retained** part — the original team's band. Both sides of that split share the same chain and read it in opposite directions: * `chain_role: :fallback` — the *receiver's* band (`convey_type: "as_pick"`). When the protection hits, this claim's value continues into the next year's pick. `terminal_outcomes/2` walks it. * `chain_role: :obligation` — the *original team's* retained band. The chain is what this team **owes**, not what it gains: outside its protected band the pick conveys away and the claim is worth nothing. `terminal_outcomes/2` deliberately does **not** walk it. Following it here would value an obligation as an asset — exactly backwards — and would also double-count against the receiver, who is already paid for that same chain. The walk mirrors `TradeMachine.NBA.Trades.rollover_chain/1`: same fallback semantics, same cycle guard, same eight-hop cap, but resolving hops from the collections passed in rather than querying, so a 30-team benchmark build costs no round trips and the tests need no database. One deliberate tightening — where the engine takes an unordered `limit: 1` among a pick's protected parts, this walk sorts first, because a benchmark frozen forever must not depend on row order. ## Known limitation: rank conditions are not modelled A pool member's *band* is structural — `draft_pick_parts.range_low/high` say exactly which slots reach the pool, and the walk honours them. A condition on an **outcome** is not: `TradeMachine.NBA.SwapGroupOutcome` carries a rank and an owner and nothing else, so a contract like *"ATL less favorable, protected 1-4 — if 1-4 the obligation is extinguished and NOP keeps both"* (2027 R1) or *"POR keeps less favorable but conveys it to HOU only at 56-60"* (2027 R2) has nowhere to live but the group's free-text `note`. Nine of the 43 seeded groups carry such a condition, including four where a rank is pledged onward to a team that is neither a member nor an outcome holder. This module reads ranks as unconditional, which is the only reading the schema supports — and would misassign a pick in the worlds where such a condition fires. Expressing them properly needs new fields on `SwapGroupOutcome`, i.e. a [[D-55]] engine change, so the mitigation is to make the gap **visible rather than invisible**: every swap claim carries `:swap_note`, the pool's contract text verbatim, which `TradeMachine.GMGradeV2.DAV.Inventory` stores beside the number on the frozen document. A later reader can see what the model was not told. Recorded as `07-R1-SWAP-CONDITIONS` in the phase `DEFERRED.md`. ## Known limitation: cross-team correlation Claims are per-team and the graph is not conditioned on other years' outcomes. Where a rollover chain retries against a pick the original team still owns in full — DEN's 2028/2029 firsts, split 1-5/6-30 with **both** bands DEN-owned because OKC only gains the 6-30 band in the world where the earlier year failed (`043_seed_rollover_chains.ex`) — DEN is claimed for the whole 2028 pick while OKC's 2027 claim is separately paid for its 2028 fallback branch. Both are right about their own team; the league total is overstated. Fixing it needs joint conditioning across teams, not a change here. `:incoming_rollover_from` names the conditioning picks on every claim it applies to, so the effect is visible rather than silent. """ alias TradeMachine.NBA.DraftPick alias TradeMachine.NBA.DraftPickPart alias TradeMachine.NBA.SwapGroup # Slots per round. Absolute slot = (round - 1) * 30 + position-in-round. @round_slots 30 # Mirrors `Trades.rollover_chain/1`'s cap. Real chains are 2–3 hops (DEN's # 2027→2028→2029 is the longest seeded); the cap is a backstop against data # that cycles or fans out further than anyone intends. @max_hops 8 # Parts whose band the original team keeps when the protection hits. Both # spellings carry the retained semantics; only `rolls_to` also points forward. @retained_convey_types ~w(retained rolls_to) # A distribution that sums to 1.0 only to this tolerance is already suspect; # anything looser lets a renormalization bug through as a rounding artifact. @probability_tolerance 1.0e-6 @typedoc """ One rollover hop: the pick an unconveyed obligation retries against. `:retained_range` is the protected band **on that pick** (absolute slots) — the obligation conveys *outside* it. `nil` means the hop carries no protection at all: an unconditional obligation that conveys at every slot and ends the chain. `resolved?: false` marks a hop the walk could not follow — the pick was not supplied, the chain cycles, or the hop cap was reached. It carries `:reason`, and it is what produces an `{:asset, claim}` terminal instead of a silent extinguish. """ @type hop :: %{ pick_id: binary() | nil, part_id: binary() | nil, year: integer() | nil, round: 1 | 2 | nil, original_team_id: binary() | nil, pick_number: integer() | nil, retained_range: Range.t() | nil, resolved?: boolean(), reason: :cycle | :not_supplied | :hop_limit | nil } @typedoc """ A mutually exclusive right a team may be paid for. * `:kind` — `:whole` (unconditional control of an entire pick), `:range_part` (one side of a protected split), `:swap_outcome` (a rank in a swap pool), `:rollover_fallback` (a continuation this module could not resolve; see `terminal_outcomes/2` — never returned by `controlled_claims/5`). * `:range` — the absolute slots this claim realizes at; `nil` for a swap outcome, which is a rank rather than a band. * `:chain_role` — `:fallback` (walked for value) or `:obligation` (reported, never walked). See the moduledoc. * `:incoming_rollover_from` — picks whose failed conveyance would encumber this claim's pick. Non-empty means the claim is conditional on another year's outcome, which this module does not model (see the moduledoc). * `:swap_note` — the pool's free-text contract note, carried verbatim on a swap outcome. See "Rank conditions are not modelled" in the moduledoc: it is the only record of a condition the schema cannot express. """ @type claim :: %{ kind: :whole | :range_part | :swap_outcome | :rollover_fallback, team_id: binary() | nil, pick_id: binary() | nil, part_id: binary() | nil, range: Range.t() | nil, chain: [hop()], chain_role: :fallback | :obligation, year: integer() | nil, round: 1 | 2 | nil, original_team_id: binary() | nil, pick_number: integer() | nil, convey_type: String.t() | nil, frozen: boolean(), incoming_rollover_from: [binary()], swap_group_id: binary() | nil, rank: pos_integer() | nil, members: [map()] | nil, swap_note: String.t() | nil, resolved?: boolean(), reason: atom() | nil } @typedoc """ One terminal state of a claim, with the probability of reaching it. `:year` is the draft year the outcome resolves in — the *only* input a discount belongs on. This module never discounts; the caller applies exactly one absolute `0.92^(year − valuation_year)` per outcome, which is the convention that makes chained double-discounting structurally impossible (AVC, "Recursive Protection Discount Semantics"). `:value_basis` is AVC's three terminal states: * `{:slot, s}` — **conveys** at absolute slot `s`. * `{:asset, claim}` — **converts** into a different asset this module will not value. Must not be silently treated as zero; see `terminal_outcomes/2`. * `:extinguished` — the right lapses and is worth nothing. """ @type outcome :: %{ probability: float(), year: integer() | nil, pick_id: binary() | nil, value_basis: {:slot, pos_integer()} | {:asset, claim()} | :extinguished } @doc """ The mutually exclusive claims `team_id` controls, ordered by year, round, and slot. `picks`, `parts`, and `swap_groups` are the graph to evaluate — `DraftPick`, `DraftPickPart`, and `SwapGroup` structs (groups with `:members` and `:outcomes` loaded). They need not be scoped to the team: eligibility is decided here, and picks the team does not own still matter as rollover targets. `window_years` is the evaluation window — a `Range` of draft years (`2026..2032`) or a list of them. Only claims whose own pick falls inside it are returned; a rollover chain then follows its obligation wherever it leads, since a fallback landing in year 8 is still part of what this claim is worth. The result is pairwise disjoint under `footprint/1`; `disjoint?/1` checks it. """ @spec controlled_claims(binary(), [DraftPick.t()], [DraftPickPart.t()], [SwapGroup.t()], term()) :: [claim()] def controlled_claims(team_id, picks, parts, swap_groups, window_years) when is_list(picks) and is_list(parts) and is_list(swap_groups) do years = normalize_window(window_years) picks_by_id = Map.new(picks, &{&1.id, &1}) parts_by_pick = Enum.group_by(parts, & &1.draft_pick_id) {member_part_ids, partless_pick_ids} = swap_member_exclusions(swap_groups) rollover_sources = rollover_sources(parts) range_claims = parts |> Enum.filter(fn part -> owned_range_part?(part, team_id) and not absorbed_by_pool?(part, member_part_ids, partless_pick_ids) and picks_by_id |> Map.get(part.draft_pick_id) |> claimable_pick?(years) end) |> Enum.map(fn part -> pick = Map.fetch!(picks_by_id, part.draft_pick_id) range_claim(team_id, pick, part, picks_by_id, parts_by_pick, rollover_sources) end) swap_claims = swap_claims(team_id, swap_groups, years, picks_by_id, parts_by_pick, rollover_sources) Enum.sort_by(range_claims ++ swap_claims, &sort_key/1) end @doc """ Every terminal state `claim` can reach, with probabilities summing to 1.0. `slot_distribution_fn` is called once per pick the walk visits, with a pick reference — %{pick_id: id, year: 2028, round: 1, original_team_id: id, pick_number: nil} — and must return `[{absolute_slot, probability}]` covering that pick's round and summing to 1.0: `TradeMachine.GMGradeV2.DAV.SlotProbability.distribution/1` with the valuation date and the frozen Competitive Strength score applied by the caller. A distribution that misses its round, sums to anything else, repeats a slot, or carries a negative probability raises here rather than quietly producing a plausible number. The walk: the claim conveys at any slot inside its band. Outside it, a `:fallback` claim continues into the rollover chain — repeating the same test against each hop's own distribution — while an `:obligation` claim extinguishes. When the chain runs out, the residual mass extinguishes; when a hop could not be resolved, the residual mass becomes `{:asset, claim}`. Zero-probability slots are returned rather than filtered, so the outcome list's shape does not change with σ. Raises for `:swap_outcome` claims (a swap is a joint distribution over the pool's picks, worth only its incremental gain — see `TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2`) and for `:rollover_fallback` claims, which are terminal markers rather than walkable assets. """ @spec terminal_outcomes(claim(), (map() -> [{pos_integer(), number()}])) :: [outcome()] def terminal_outcomes(claim, slot_distribution_fn) def terminal_outcomes(%{kind: :swap_outcome}, _slot_distribution_fn) do raise ArgumentError, "a swap outcome has no protection chain to walk — it is a joint distribution over " <> "the pool's picks, and AVC's rule is that it is worth only the incremental gain " <> "over the pick the team would otherwise keep, never a whole pick. Use " <> "TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2." end def terminal_outcomes(%{kind: :rollover_fallback} = claim, _slot_distribution_fn) do raise ArgumentError, "a :rollover_fallback claim (reason: #{inspect(claim.reason)}) is a terminal marker, " <> "not a walkable asset — the evaluator stopped because the continuation could not " <> "be resolved from the supplied graph. Supply the missing pick and rebuild the " <> "claims, or decide explicitly what the unresolved continuation is worth. Do not " <> "treat it as zero by default." end def terminal_outcomes(%{kind: kind} = claim, slot_distribution_fn) when kind in [:whole, :range_part] and is_function(slot_distribution_fn, 1) do nodes = case claim.chain_role do :fallback -> [claim_node(claim) | Enum.map(claim.chain, &hop_node/1)] :obligation -> [claim_node(claim)] end walk(nodes, 1.0, slot_distribution_fn, []) end @doc """ The cells a claim asserts a primary right over — the duplicate-claim check. A pick band contributes `{:pick, pick_id, absolute_slot}` per slot. A swap outcome contributes a single `{:swap, swap_group_id, rank}`, deliberately *not* its pool's slots: a team holding ranks 1 and 2 of a three-pick pool receives two distinct picks and double-counts nothing, while the pooled bands themselves are already excluded from that team's range claims. """ @spec footprint(claim()) :: MapSet.t() def footprint(%{kind: :swap_outcome} = claim), do: MapSet.new([{:swap, claim.swap_group_id, claim.rank}]) def footprint(%{range: %Range{} = range, pick_id: pick_id}), do: MapSet.new(range, &{:pick, pick_id, &1}) def footprint(_claim), do: MapSet.new() @doc """ Whether every claim in `claims` covers rights no other claim covers — the property `controlled_claims/5` exists to guarantee. False means some asset is being counted twice. """ @spec disjoint?([claim()]) :: boolean() def disjoint?(claims) when is_list(claims) do Enum.reduce_while(claims, MapSet.new(), fn claim, seen -> cells = footprint(claim) if MapSet.disjoint?(seen, cells), do: {:cont, MapSet.union(seen, cells)}, else: {:halt, :overlap} end) != :overlap end # ── Claim construction ──────────────────────────────────────────────── defp range_claim(team_id, pick, part, picks_by_id, parts_by_pick, rollover_sources) do role = if part.convey_type == "as_pick", do: :fallback, else: :obligation anchor = case role do :obligation -> part :fallback -> sibling_protected_part(pick, parts_by_pick) end %{ kind: claim_kind(part), team_id: team_id, pick_id: pick.id, part_id: part.id, range: absolute_range(pick.round, part.range_low, part.range_high), chain: build_chain(anchor, picks_by_id, parts_by_pick), chain_role: role, year: pick.year, round: pick.round, original_team_id: pick.original_team_id, pick_number: pick.pick_number, convey_type: part.convey_type, frozen: pick.is_frozen == true, incoming_rollover_from: Map.get(rollover_sources, pick.id, []), swap_group_id: nil, rank: nil, members: nil, swap_note: nil, resolved?: true, reason: nil } end # `:whole` means unconditional control of an entire pick, so it turns on the # conveyance terms and not merely on the band's width: a full-round `rolls_to` # part covers 1-30 and is still a fully-protected obligation, not a whole pick. defp claim_kind(%DraftPickPart{convey_type: "as_pick"} = part) do if part.range_low == 1 and part.range_high == @round_slots, do: :whole, else: :range_part end defp claim_kind(_part), do: :range_part # The retained band that anchors a conveyed band's fallback chain: what rolls # forward is the obligation, and the obligation is recorded on the sibling. # The claim's own part cannot be selected here — it is `as_pick` by the only # branch that calls this, and `protected_part/1` takes only retained bands. defp sibling_protected_part(pick, parts_by_pick), do: pick |> parts_of(parts_by_pick) |> protected_part() defp swap_claims(team_id, swap_groups, years, picks_by_id, parts_by_pick, rollover_sources) do for group <- swap_groups, group.year in years, outcome <- outcomes_of(group), outcome.owner_team_id == team_id do swap_claim(team_id, group, outcome, picks_by_id, parts_by_pick, rollover_sources) end end defp swap_claim(team_id, group, outcome, picks_by_id, parts_by_pick, rollover_sources) do members = swap_members(group, picks_by_id, parts_by_pick, rollover_sources) %{ kind: :swap_outcome, team_id: team_id, pick_id: nil, part_id: nil, range: nil, chain: [], chain_role: :fallback, year: group.year, round: group.round, original_team_id: nil, pick_number: nil, convey_type: nil, frozen: false, # A pool is only as certain as the picks in it: if a member's pick is the # target of an earlier year's protection, that pick can convey away before # this draft and the pool is one pick smaller. Read up from the members so # a swap claim answers this question the same way a pick claim does, # rather than hardcoding "unconditional" and leaving every reader of the # field — `PickCapacity.coverage?/1`, and the frozen document — to believe # it. incoming_rollover_from: members |> Enum.flat_map(& &1.incoming_rollover_from) |> Enum.uniq() |> Enum.sort(), swap_group_id: group.id, rank: outcome.rank, members: members, swap_note: Map.get(group, :note), resolved?: true, reason: nil } end @doc """ Whether a swap outcome's rank is certain to receive a pick. A member only reaches the pool when its slot falls inside the band it contributed, so a pool holding a protected slice has fewer picks in it in the drafts where that protection hits, and the bottom ranks go unfilled — the behaviour `TradeMachine.GMGradeV2.DAV.Valuer.swap_dav/2` already values correctly (an unsatisfied rank is worth nothing in those worlds). A rank is guaranteed exactly when at least that many members are *certain* to reach the pool. Six of the 108 seeded outcomes fail this, and for two of them — TOR's 2027 rank 4 and PHX's 2028 rank 5 — the conditional rank is that team's *only* first-round asset in the year, which is why `TradeMachine.GMGradeV2.PickCapacity` must not read it as Stepien coverage. On the live graph this predicate moves exactly one team's capacity (LAC, 5 → 4) and raises none, which is the direction it must move in. """ @spec guaranteed_rank?(claim()) :: boolean() def guaranteed_rank?(%{kind: :swap_outcome, rank: rank, members: members}) when is_integer(rank) and is_list(members), do: rank <= Enum.count(members, &certain_member?/1) def guaranteed_rank?(_claim), do: false # Certain means nothing can keep this pick out of the pool. Two ways that # fails, and both must be checked, because this predicate is the *only* thing # standing between a conditional asset and a Stepien package built on it: # # * the band is a slice of the round, so a slot outside it never arrives; # * the pick is itself the target of an earlier year's protection, so a # failed conveyance can take it away before this draft happens. # # A known final slot would be a third way for a narrow band to still be # certain, and it is deliberately absent: every member of a pool this module # claims sits in the capacity window, which starts at the first *future* draft, # so a member cannot have a settled slot. `member_pick!/3` refuses that row # outright rather than reading it as certainty — the reading # `SlotProbability.distribution/1` already refuses for the same reason. defp certain_member?(%{incoming_rollover_from: [_ | _]}), do: false defp certain_member?(%{range: %Range{} = range, round: round}) when round in [1, 2], do: range == round_slots(round) defp certain_member?(_member), do: false # The picks a pool draws from, each narrowed to the band it contributes. A # member without an explicit part contributes exactly the band the pool # absorbed from its owner; anything ambiguous raises (see # `pool_member_part/2`). defp swap_members(group, picks_by_id, parts_by_pick, rollover_sources) do group |> members_of() |> Enum.map(fn member -> pick = member_pick!(group, member, picks_by_id) part = pool_member_part(member, parts_by_pick) # Always a resolved positional part now — `pool_member_part/2` raises # rather than inferring a whole-round band from absent input, so a pool can # no longer draw over slots its owner still claims. range = absolute_range(pick.round, part.range_low, part.range_high) %{ pick_id: pick.id, part_id: part.id, year: pick.year, round: pick.round, original_team_id: pick.original_team_id, pick_number: pick.pick_number, range: range, incoming_rollover_from: Map.get(rollover_sources, pick.id, []) } end) |> Enum.sort_by(&{&1.year, &1.round, &1.range.first, &1.pick_id}) end # A pool is valued as a whole, so a membership that quietly vanishes does not # make the pool smaller — it makes it a *different* pool, and a plausible one. # Drop one member of three and the group becomes a two-pick pool that # `Valuer.pool_rank!/2` still accepts at ranks 1 and 2, valued by exact # enumeration rather than sampling, and frozen with a method and a seed that # look deliberate. A member drawn from another draft is the same failure one # level down: its slots would come from its own year while the discount and # the round split come from the group's. defp member_pick!(group, member, picks_by_id) do pick = Map.get(picks_by_id, member.draft_pick_id) || raise ArgumentError, "swap group #{inspect(group.id)} (#{group.year} round #{group.round}) has a " <> "member on pick #{inspect(member.draft_pick_id)}, which was not supplied in " <> "`picks`. Skipping it would reprice the pool as a smaller one every rank still " <> "fits into. Supply the pick." unless pick.year == group.year and pick.round == group.round do raise ArgumentError, "swap group #{inspect(group.id)} is #{group.year} round #{group.round}, but its " <> "member #{inspect(pick.id)} is #{pick.year} round #{pick.round}. The pool would " <> "draw slots from one draft and be discounted and classified into another." end # Every pool this module claims sits in a window starting at the first # *future* draft, so no member can have a settled slot. A stored one is a # stale or prematurely populated row, and the two readers would disagree # about it: `certain_member?/1` would take it as guaranteed Stepien coverage # while `SlotProbability.distribution/1` refuses to value the same row at # all. Refusing here is the reading that keeps them agreeing. if pick.pick_number != nil do raise ArgumentError, "swap group member #{inspect(pick.id)} (#{pick.year} round #{pick.round}) carries " <> "pick_number #{pick.pick_number}, but #{pick.year} has not been drafted — every " <> "pool member sits in the future window. A settled slot here would be read as " <> "certain coverage while the valuation refuses the same row. Clear it or exclude " <> "the draft from the window." end pick end # A partless membership on a *split* pick is resolvable, and resolving it is # what stops the retained band being paid twice. `absorbed_by_pool?/3` already # decides what such a membership absorbs — the pick's non-retained parts, the # #379 rule — so the band it contributes must be exactly those. Naming the # whole round instead (the reading `SwapGroups.member_part/1` leaves to its UI # caller) lets the pool draw over slots the original team still holds as a # range claim, so that band is counted once through the rank holder and once # directly. # # A pick with *no* parts supplied is not "obviously the whole round" — it is # indistinguishable from a caller that omitted the pick's real protected # parts, and every pick in this database has at least one part (measured: 0 of # 480 have none). Inferring a full 1-30 band from missing input is the one # error this module cannot detect downstream, because a fabricated whole-round # member draws over slots the owner may still claim directly. So it raises. # # Every other partless membership resolves through `absorbed_part!/2`, # including a pick with exactly one part: a lone *retained* band is absorbed by # nothing, so pooling it would be the double-count in its purest form. defp pool_member_part(member, parts_by_pick) do parts = Map.get(parts_by_pick, member.draft_pick_id, []) case {member.draft_pick_part_id, parts} do {nil, []} -> raise ArgumentError, "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <> "no parts were supplied for that pick. Reading it as the whole round cannot be " <> "distinguished from a caller that omitted the pick's protected bands, and would " <> "pool slots its owner may still claim. Supply the pick's parts." {nil, parts} -> member |> absorbed_part!(parts) |> positional_part!(member) {part_id, _parts} -> parts |> Enum.find(&(&1.id == part_id)) |> case do nil -> raise ArgumentError, "swap group member on pick #{inspect(member.draft_pick_id)} names part " <> "#{inspect(part_id)}, which was not supplied in `parts`. The band it " <> "contributes is unknown, and defaulting to the whole round would pool slots " <> "its owner may still retain. Supply the pick's parts." part -> positional_part!(part, member) end end end # `range_low`/`range_high` only mean draft slots on a `"range"` part. A legacy # `"swap"` part carries the same two columns at their defaults, so reading one # as a band silently values a non-positional row as a real 1-30 slice — # `owned_range_part?/2` already refuses them on the claim side for exactly this # reason. All 497 live parts are `"range"`, so this is a guard against drift, # not a live condition. defp positional_part!(%DraftPickPart{part_type: "range"} = part, _member), do: part defp positional_part!(%DraftPickPart{} = part, member) do raise ArgumentError, "swap group member on pick #{inspect(member.draft_pick_id)} resolves to part " <> "#{inspect(part.id)}, whose part_type is #{inspect(part.part_type)} rather than " <> "\"range\". Only a positional part has slot bounds; reading this one's range_low/" <> "range_high as a band would pool a slice that does not exist." end # Exactly the band `absorbed_by_pool?/3` removed from the owner's claims, by # exactly the same predicate. The two must name one band or the slots between # them are paid twice or not at all, and nothing downstream can detect it: # `footprint/1` gives a swap outcome one opaque cell, so `disjoint?/1` sees a # pool and a range claim as different namespaces however much they overlap. # # `is_conveyed` is deliberately not consulted on either side. A part flagged # conveyed is precisely the part whose value now flows through the pool # (`033_qa_wave1_pick_corrections.ex`, `026_*`), and refusing it here would # fail the build on data the owner-side rule already handles correctly. defp absorbed_part!(member, parts) do parts |> Enum.filter(&(&1.convey_type not in @retained_convey_types)) |> case do [only_conveyable] -> only_conveyable [] -> raise ArgumentError, "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <> "every part of that pick is a band its owner retains. Nothing was absorbed into " <> "the pool, so treating the membership as the whole round would draw over slots " <> "the owner still claims directly, paying them twice. Point the membership at a " <> "part." other -> raise ArgumentError, "swap group member on pick #{inspect(member.draft_pick_id)} names no part, and " <> "the pick has #{length(other)} conveyable bands, so which one reaches the pool " <> "is ambiguous. Treating it as the whole round would pool slots the original " <> "team still retains, paying that band twice. Point the membership at a part." end end # ── Eligibility ─────────────────────────────────────────────────────── defp owned_range_part?(part, team_id), do: part.owner_team_id == team_id and part.part_type == "range" and part.is_conveyed != true # A conveyed pick is spent, and a legacy `is_swap_rights` companion row is a # display artifact whose real value lives in the pool's outcomes — claiming # either would be pure duplication. A forfeited pick was voided by the league: # no team will ever select with it, so it is worth nothing to anyone. defp claimable_pick?(nil, _years), do: false defp claimable_pick?(pick, years), do: pick.is_conveyed != true and pick.is_forfeited != true and pick.is_swap_rights != true and pick.round in [1, 2] and pick.year in years # Parts absorbed into a swap pool are no longer independently claimable — the # pool's outcome holders are paid instead. Part-scoped membership absorbs # exactly its part; a partless membership absorbs the pick's non-retained # parts, so a protected split's retained band never vanishes just because the # membership predates part scoping (#379). defp absorbed_by_pool?(part, member_part_ids, partless_pick_ids) do MapSet.member?(member_part_ids, part.id) or (MapSet.member?(partless_pick_ids, part.draft_pick_id) and part.convey_type not in @retained_convey_types) end defp swap_member_exclusions(swap_groups) do Enum.each(swap_groups, &validate_group!/1) members = Enum.flat_map(swap_groups, &members_of/1) part_ids = for m <- members, m.draft_pick_part_id != nil, into: MapSet.new(), do: m.draft_pick_part_id partless = for m <- members, m.draft_pick_part_id == nil, into: MapSet.new(), do: m.draft_pick_id {part_ids, partless} end # Exclusion happens here, once, for every group — but claims are built later # and only for the outcomes a team owns, so a group can take assets out of the # league's inventory and put nothing back. That failure is silent and total: # `members_of/1` returns `[]` for an unloaded association, so a group whose # members were never preloaded excludes nothing and values nothing, while a # group with members and no outcomes excludes its picks and produces no claim # to carry them. Either way the picks simply vanish from all 30 teams and from # a frozen league total, with no unresolved marker on anything. # # So each group is validated as a unit before its exclusions are trusted. The # database permits every shape refused here (`create_swap_group/1` enforces # none of it); all 43 live groups pass. defp validate_group!(group) do members = members_of(group) outcomes = outcomes_of(group) ranks = outcomes |> Enum.map(& &1.rank) |> Enum.sort() pick_ids = Enum.map(members, & &1.draft_pick_id) cond do members == [] -> raise ArgumentError, "swap group #{inspect(Map.get(group, :id))} has no members (association not " <> "loaded, or an empty pool). It would exclude nothing and value nothing; a pool " <> "with no picks in it is a data error, not an empty asset." outcomes == [] -> raise ArgumentError, "swap group #{inspect(Map.get(group, :id))} has #{length(members)} member(s) and " <> "no outcomes. Its members' bands are absorbed out of their owners' claims and " <> "no rank exists to receive them, so those picks would leave the league's " <> "inventory entirely." length(Enum.uniq(pick_ids)) != length(pick_ids) -> raise ArgumentError, "swap group #{inspect(Map.get(group, :id))} names the same pick more than once " <> "(#{inspect(pick_ids)}). One pick cannot be two members of one pool; the ranks " <> "would draw it twice." ranks != Enum.to_list(1..length(members)//1) -> raise ArgumentError, "swap group #{inspect(Map.get(group, :id))} has #{length(members)} member(s) but " <> "ranks #{inspect(ranks)}, which is not 1..#{length(members)}. A ladder that " <> "does not assign every pooled pick leaves one unaccounted for, and a repeated " <> "or out-of-range rank has no meaning." true -> :ok end end # pick_id => the picks whose failed conveyance would encumber it. defp rollover_sources(parts) do parts |> Enum.filter(&(&1.rolls_to_pick_id != nil)) |> Enum.group_by(& &1.rolls_to_pick_id, & &1.draft_pick_id) |> Map.new(fn {target, sources} -> {target, sources |> Enum.uniq() |> Enum.sort()} end) end # ── Rollover chain ──────────────────────────────────────────────────── defp build_chain(nil, _picks_by_id, _parts_by_pick), do: [] defp build_chain(%DraftPickPart{} = anchor, picks_by_id, parts_by_pick) do walk_chain( anchor, MapSet.new([anchor.draft_pick_id]), @max_hops, picks_by_id, parts_by_pick, [] ) end defp walk_chain(%DraftPickPart{rolls_to_pick_id: nil}, _visited, _hops, _picks, _parts, acc), do: Enum.reverse(acc) defp walk_chain(part, visited, hops, picks_by_id, parts_by_pick, acc) do next_id = part.rolls_to_pick_id next_pick = Map.get(picks_by_id, next_id) cond do next_pick == nil -> stop(acc, unresolved_hop(next_id, :not_supplied, nil)) MapSet.member?(visited, next_id) -> stop(acc, unresolved_hop(next_id, :cycle, next_pick)) hops == 0 -> stop(acc, unresolved_hop(next_id, :hop_limit, next_pick)) true -> next_part = next_pick |> parts_of(parts_by_pick) |> protected_part() hop = resolved_hop(next_pick, next_part) case next_part do nil -> stop(acc, hop) %DraftPickPart{} = p -> walk_chain( p, MapSet.put(visited, next_id), hops - 1, picks_by_id, parts_by_pick, [hop | acc] ) end end end defp stop(acc, hop), do: Enum.reverse([hop | acc]) defp parts_of(pick, parts_by_pick), do: Map.get(parts_by_pick, pick.id, []) # The band the original team keeps. Sorted before taking one: the engine's # equivalent query is an unordered `limit: 1`, and a benchmark frozen forever # must not depend on row order. # # Two retained bands raise rather than resolving to the first. The team keeps # both, so the pick only conveys outside their *union* — bands 1-3 and 4-8 # convey over 9-30, while taking 1-3 alone conveys over 4-30 and pays the # receiver for five slots the original team never gave up. Distinct # `rolls_to_pick_id`s on the two would not be resolvable at all. No live pick # has the shape; a deterministic wrong answer is worse than a refusal here, # because it freezes with no unresolved marker on it. defp protected_part(parts) do parts |> Enum.filter(&(&1.convey_type in @retained_convey_types and &1.is_conveyed != true)) |> Enum.sort_by(&{&1.range_low, &1.range_high, &1.id}) |> case do [] -> nil [one] -> one many -> raise ArgumentError, "pick #{inspect(List.first(many).draft_pick_id)} has #{length(many)} retained " <> "bands (#{Enum.map_join(many, ", ", &"#{&1.range_low}-#{&1.range_high}")}). The " <> "team keeps all of them, so the pick conveys only outside their union and no " <> "single band anchors the rollover chain. Model the retained range as one part." end end defp resolved_hop(pick, part) do %{ pick_id: pick.id, part_id: part && part.id, year: pick.year, round: pick.round, original_team_id: pick.original_team_id, pick_number: pick.pick_number, retained_range: part && absolute_range(pick.round, part.range_low, part.range_high), resolved?: true, reason: nil } end defp unresolved_hop(pick_id, reason, pick) do %{ pick_id: pick_id, part_id: nil, year: pick && pick.year, round: pick && pick.round, original_team_id: pick && pick.original_team_id, pick_number: pick && pick.pick_number, retained_range: nil, resolved?: false, reason: reason } end # ── Outcome walk ────────────────────────────────────────────────────── # The claim's own node realizes *inside* its band. defp claim_node(claim) do %{ pick_ref: pick_ref(claim), convey_slots: MapSet.new(claim.range), year: claim.year, round: claim.round, pick_id: claim.pick_id, resolved?: true, hop: nil } end # A hop realizes *outside* the band the original team retained — that is what # "the obligation conveys" means. A hop with no protected part is # unconditional and conveys at every slot. defp hop_node(%{resolved?: false} = hop) do %{ pick_ref: nil, convey_slots: nil, year: hop.year, round: hop.round, pick_id: hop.pick_id, resolved?: false, hop: hop } end defp hop_node(hop) do all = MapSet.new(round_slots(hop.round)) convey = case hop.retained_range do nil -> all %Range{} = retained -> MapSet.difference(all, MapSet.new(retained)) end %{ pick_ref: pick_ref(hop), convey_slots: convey, year: hop.year, round: hop.round, pick_id: hop.pick_id, resolved?: true, hop: hop } end defp pick_ref(source), do: Map.take(source, [:pick_id, :year, :round, :original_team_id, :pick_number]) defp walk([%{resolved?: false} = node | _rest], carried, _dist_fn, acc) do acc ++ [ %{ probability: carried, year: node.year, pick_id: node.pick_id, value_basis: {:asset, fallback_claim(node.hop)} } ] end defp walk([node | rest], carried, dist_fn, acc) do distribution = node.pick_ref |> dist_fn.() |> assert_distribution!(node.round, node.pick_ref) {conveyed, missed} = Enum.reduce(distribution, {[], 0.0}, fn {slot, probability}, {hits, miss} -> if MapSet.member?(node.convey_slots, slot) do hit = %{ probability: carried * probability, year: node.year, pick_id: node.pick_id, value_basis: {:slot, slot} } {[hit | hits], miss} else {hits, miss + probability} end end) acc = acc ++ Enum.reverse(conveyed) if missed <= 0.0, do: acc, else: continue(rest, carried * missed, dist_fn, acc, node) end defp continue([], carried, _dist_fn, acc, node) do acc ++ [ %{ probability: carried, year: node.year, pick_id: node.pick_id, value_basis: :extinguished } ] end defp continue(rest, carried, dist_fn, acc, _node), do: walk(rest, carried, dist_fn, acc) defp fallback_claim(hop) do %{ kind: :rollover_fallback, team_id: nil, pick_id: hop.pick_id, part_id: nil, range: nil, chain: [], chain_role: :fallback, year: hop.year, round: hop.round, original_team_id: hop.original_team_id, pick_number: hop.pick_number, convey_type: nil, frozen: false, incoming_rollover_from: [], swap_group_id: nil, rank: nil, members: nil, swap_note: nil, resolved?: false, reason: hop.reason } end @doc """ Validates a caller-supplied slot distribution against `round`, returning it sorted by slot; raises otherwise. Public because `TradeMachine.GMGradeV2.DAV.Valuer`'s swap path samples pool members directly rather than walking a protection chain, and one wrong distribution must not mean two different answers about what "valid" is. Fail closed, because every way a distribution can be wrong produces a number rather than an error: the wrong round misses the whole band (zero), a sub-unit total silently shrinks the asset, and a repeated slot pays twice. The sort is not cosmetic — float addition is not associative, so the residual mass would otherwise depend on the caller's ordering (D-50). """ @spec assert_distribution!([{pos_integer(), number()}], 1 | 2, map()) :: [{pos_integer(), number()}] def assert_distribution!(distribution, round, pick_ref) when is_list(distribution) do slots = round_slots(round) total = distribution |> Enum.map(&elem(&1, 1)) |> Enum.sort() |> Enum.sum() cond do distribution == [] -> raise ArgumentError, "slot_distribution_fn returned an empty distribution for #{inspect(pick_ref)}" not Enum.all?(distribution, &valid_entry?/1) -> raise ArgumentError, "slot_distribution_fn must return [{integer_slot, non_negative_probability}] " <> "for #{inspect(pick_ref)}, got: #{inspect(distribution)}" Enum.any?(distribution, fn {slot, _p} -> slot not in slots end) -> raise ArgumentError, "slot_distribution_fn returned slots outside round #{round} " <> "(#{inspect(slots)}) for #{inspect(pick_ref)}. Slots are ABSOLUTE here: a " <> "second-round pick occupies 31..60. A round mismatch makes every slot miss the " <> "claim's band and values the asset at zero." duplicate_slots?(distribution) -> raise ArgumentError, "slot_distribution_fn repeated a slot for #{inspect(pick_ref)}; each slot " <> "must appear once, or the claim is paid twice for the same outcome." abs(total - 1.0) > @probability_tolerance -> raise ArgumentError, "slot_distribution_fn returned a distribution summing to #{total} for " <> "#{inspect(pick_ref)}; it must sum to 1.0." true -> Enum.sort_by(distribution, &elem(&1, 0)) end end def assert_distribution!(other, _round, pick_ref) do raise ArgumentError, "slot_distribution_fn must return [{slot, probability}] for #{inspect(pick_ref)}, " <> "got: #{inspect(other)}" end defp valid_entry?({slot, probability}), do: is_integer(slot) and is_number(probability) and probability >= 0 defp valid_entry?(_other), do: false defp duplicate_slots?(distribution), do: distribution |> Enum.uniq_by(&elem(&1, 0)) |> length() != length(distribution) # ── Shared helpers ──────────────────────────────────────────────────── # `draft_pick_parts` stores bands within the round (1-30 for both rounds); # everything downstream speaks absolute slots. defp absolute_range(round, low, high) do offset = (round - 1) * @round_slots (offset + low)..(offset + high)//1 end defp round_slots(round), do: absolute_range(round, 1, @round_slots) defp members_of(%{members: members}) when is_list(members), do: members defp members_of(_group), do: [] defp outcomes_of(%{outcomes: outcomes}) when is_list(outcomes), do: outcomes defp outcomes_of(_group), do: [] defp sort_key(claim), do: {claim.year, claim.round, (claim.range && claim.range.first) || 0, claim.rank || 0, claim.part_id || claim.swap_group_id || ""} defp normalize_window(%Range{} = years), do: years |> MapSet.new() |> ensure_non_empty(years) defp normalize_window(years) when is_list(years) do if Enum.all?(years, &is_integer/1) do years |> MapSet.new() |> ensure_non_empty(years) else raise ArgumentError, "window_years must be draft years — a Range like 2026..2032 or a list of " <> "integers, got: #{inspect(years)}" end end defp normalize_window(other) do raise ArgumentError, "window_years must be a Range of draft years (2026..2032) or a list of integer " <> "years, got: #{inspect(other)}" end defp ensure_non_empty(set, original) do if MapSet.size(set) == 0 do raise ArgumentError, "window_years is empty (#{inspect(original)}), so no claim could ever be " <> "returned. An empty inventory and an empty window read identically downstream; " <> "pass the real evaluation window." end set end end