defmodule TradeMachine.GMGradeV2.DAV.Valuer do @moduledoc """ Where a controlled claim becomes a number: Draft Asset Value, discounted to the frozen valuation date. This is the last of the four DAV modules and the only one that multiplies the other three together. `TradeMachine.GMGradeV2.DAV.OutcomeEvaluator` says what a claim resolves into and when; `TradeMachine.GMGradeV2.DAV.SlotProbability` says where each pick lands; `TradeMachine.GMGradeV2.DAV.SlotCurve` says what a slot is worth. The whole of a protected pick's value is: DAV = Σ probability(outcome) × SlotCurve.value(slot) × 0.92^(outcome year − valuation year) over terminal outcomes ## One discount per terminal outcome, applied here and nowhere else AVC requires a time discount "at every stage" of a rollover chain, which is the one instruction in Pillar 5 that is easy to implement twice. The safe convention (interpretation notes, "Recursive Protection Discount Semantics") is the second of the two AVC offers: enumerate terminal outcomes, apply one **absolute** `0.92^years` per outcome, never multiply an already-discounted fallback by the current node's discount. That convention is structural rather than vigilant. `OutcomeEvaluator` returns a flat list of terminal outcomes, each carrying exactly one `:year` and no value, so there is no intermediate node holding a number that could be re-multiplied. Double-discounting a chain here would require deliberately discounting twice in one expression. ## Swaps are worth the improvement, not the pick AVC's Important Rule: "A swap should never be valued as though the team owns both first-round picks." `swap_dav/2` returns E[ max(value of the rank-N pick received − value of the pick otherwise kept, 0) ] × discount with the `max` taken **inside** each simulation and averaged after, never `max(E[received] − E[kept], 0)`. The difference is the option value and it is the entire asset: two picks with identical distributions have equal means, so the outside-the-expectation form values every swap right in the league at exactly zero. ### `:dav` is the swap right; `:inventory_dav` is the pick inventory These are different questions and a swap answers them with different numbers. `OutcomeEvaluator` **absorbs** a pooled pick out of its owner's range claims — the pool's outcome holders are paid instead — so a team that contributed its own pick to a pool holds only its swap outcome afterwards. Valuing that outcome incrementally is right for "what is this swap right worth" and badly wrong for "what is this team's draft capital": NOP contributes its 2027 first and holds the more-favorable rank, so its incremental gain is a few DAV points while the pick it actually receives is worth 60+. Every result therefore carries both: * `:dav` — AVC's asset value. Incremental for a swap, full for a pick claim. * `:inventory_dav` — what the team ends the draft holding. Identical to `:dav` for pick claims; the discounted expected value of the rank-N pick **received** for a swap outcome. Draft Capital Value (Phase 07 T6, Pillar 5 Metric 1) sums `:inventory_dav`. Summing `:dav` across a league understates every pool contributor by roughly the whole value of its own pick; summing `:inventory_dav` conserves it exactly, because each pooled pick's value flows to whichever rank receives it (in the 2027 UTA/CLE/MIN ladder, three member picks and three ranks: MEM takes the most favorable, UTA the second, PHX the least, and CLE and MIN — members holding no rank — correctly end with nothing). ## Determinism Two picks in a pool are enumerated exactly (900 slot pairs); three or more are sampled, because the joint enumeration grows as 30ⁿ. Monte Carlo is seeded from the pool's sorted member pick ids plus the valuation date, so the same pool values identically on every run and every machine, and the seed, iteration count, variance and standard error are returned for storage alongside the grade (interpretation notes, "Deterministic Monte Carlo"). Deriving the seed from the *pool* rather than the outcome has a property worth keeping: a team holding two ranks of the same pool (HOU holds ranks 1 and 2 of HOU/DAL/PHX 2029) values both against the same sampled draft, so its two rights cannot contradict each other about which pick was most favorable. Sampling is threaded through `:rand`'s explicit state rather than the process dictionary, so valuing an asset never perturbs a caller's random state — a grade must not depend on how many swaps were valued before it. ## What this module does not decide * **Correlation between picks.** Members of a pool are sampled independently; two picks from the same draft can land on the same slot. AVC names this limitation. Ranks are still assigned deterministically among ties, so a collision changes no value. * **Unresolved continuations.** An `{:asset, _}` terminal — a chain whose next pick was not supplied, cycles, or exceeded the hop cap — is not silently worth zero. `dav/2` raises unless the caller passes `unresolved: :provisional`, which returns the resolved mass as an explicit lower bound flagged `provisional?: true` (AVC: expose a conservative range, never fabricate precision). """ alias TradeMachine.GMGradeV2.DAV.OutcomeEvaluator alias TradeMachine.GMGradeV2.DAV.SlotCurve alias TradeMachine.GMGradeV2.DAV.SlotProbability alias TradeMachine.GMGradeV2.ModelVersion # AVC's recommended simulation count per swap asset. @default_iterations 10_000 # Pools up to this size are enumerated exactly instead of sampled: two 30-slot # distributions are 900 joint outcomes, which is cheaper than 10_000 samples # and carries no sampling error at all. @exact_member_limit 2 @typedoc """ Everything a valuation needs that is not on the claim itself. * `:valuation_date` — the frozen date the grade is anchored to. Required. Both `years_ahead` and the time discount are measured from its **calendar year**: the draft falls mid-year, so a valuation date anywhere in year Y treats the year-Y draft as the current one. `Date.utc_today()` is never called here. * `:team_cs_scores` — `%{team_id => 0..100}`, the frozen Competitive Strength score per originating team, from the Phase 04 benchmark and never recomputed from a scenario roster. Required for any pick whose slot is not already known. * `:slot_distribution_fn` — supply a distribution function directly instead, taking `%{pick_id:, year:, round:, original_team_id:, pick_number:}` and returning `[{absolute_slot, probability}]`. Wins over `:team_cs_scores`. This is the memoization seam: a 30-team benchmark build otherwise recomputes the same team-year distribution once per claim that touches it. * `:iterations` — Monte Carlo sample count for pools of 3+ (default 10_000). * `:unresolved` — `:raise` (default) or `:provisional`; see the moduledoc. * `:override` — Phase 16 lab constant overrides, forwarded to `ModelVersion`. """ @type ctx :: %{ required(:valuation_date) => Date.t(), optional(:team_cs_scores) => %{binary() => number()}, optional(:slot_distribution_fn) => (map() -> [{pos_integer(), number()}]), optional(:iterations) => pos_integer(), optional(:unresolved) => :raise | :provisional, optional(:override) => map() } @typedoc """ A valued claim. `:dav` is the asset's value, `:inventory_dav` is what the team ends up holding — see the moduledoc on why a swap answers those differently. """ @type result :: %{dav: float(), inventory_dav: float(), diagnostics: map()} @doc """ DAV of one controlled claim from `OutcomeEvaluator.controlled_claims/5`. A `:whole` or `:range_part` claim is valued over its terminal outcomes; a `:swap_outcome` claim delegates to `swap_dav/2`. A `:rollover_fallback` claim raises — it is a marker for a continuation the evaluator could not resolve, and what it is worth is a decision for the caller, not a default. Diagnostics for a pick claim: %{ method: :exact, version: "1.0.0", terminal_outcomes: 30, undiscounted_dav: 55.4, # Σ p × value(slot), before any discount conveyance_probability: 0.667, # mass that resolves to a real slot extinguished_probability: 0.333, unresolved_probability: 0.0, provisional?: false, years: [2028, 2029] # the distinct outcome years discounted } """ @spec dav(map(), ctx()) :: result() def dav(claim, ctx) def dav(%{kind: :swap_outcome} = claim, ctx), do: swap_dav(claim, ctx) def dav(%{kind: :rollover_fallback} = claim, _ctx) do raise ArgumentError, "cannot value a :rollover_fallback claim (reason: #{inspect(claim.reason)}) — it marks " <> "a continuation OutcomeEvaluator could not resolve from the supplied graph. Supply " <> "the missing pick and rebuild the claims, or decide explicitly what the unresolved " <> "continuation is worth. It is not zero by default." end def dav(%{kind: kind} = claim, ctx) when kind in [:whole, :range_part] do ctx = normalize!(ctx) outcomes = OutcomeEvaluator.terminal_outcomes(claim, ctx.slot_distribution_fn) {contributions, undiscounted, conveyed, extinguished, unresolved, years} = Enum.reduce(outcomes, {[], [], [], [], [], MapSet.new()}, fn outcome, acc -> accumulate(outcome, ctx, acc) end) unresolved_probability = canonical_sum(unresolved) provisional? = unresolved_probability > 0.0 if provisional? and ctx.unresolved == :raise do raise ArgumentError, "claim #{inspect(claim.part_id || claim.pick_id)} carries " <> "#{Float.round(unresolved_probability * 100, 2)}% probability of converting into " <> "an asset this model cannot value (an unresolved rollover continuation). Treating " <> "that mass as worthless understates the claim and treating it as conveying " <> "overstates it, so neither is chosen for you. Supply the missing picks, or pass " <> "`unresolved: :provisional` to accept the resolved mass as an explicit lower bound." end value = canonical_sum(contributions) %{ dav: value, inventory_dav: value, diagnostics: %{ method: :exact, version: version(), terminal_outcomes: length(outcomes), undiscounted_dav: canonical_sum(undiscounted), conveyance_probability: canonical_sum(conveyed), extinguished_probability: canonical_sum(extinguished), unresolved_probability: unresolved_probability, provisional?: provisional?, years: years |> MapSet.to_list() |> Enum.sort() } } end def dav(other, _ctx) do raise ArgumentError, "expected a claim from OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}" end @doc """ DAV of a swap right: the expected improvement it creates, discounted once. `swap_claim` is the `:swap_outcome` claim from `OutcomeEvaluator.controlled_claims/5` — the swap group as *this* team holds it, carrying the pool's members and the rank the team owns. A bare `TradeMachine.NBA.SwapGroup` is deliberately not accepted: a group with three ranked outcomes is three different assets held by up to three teams, and "value this group" has no single answer. Per simulation: draw a slot for every pool member, drop the members whose slot falls outside the band they contributed (a protected swap only reaches the pool when the protection misses — the retained band is a separate range claim and is already counted there), rank the survivors most-favorable-first, and compare the pick at this claim's rank against the pick the team would otherwise have kept. That baseline is the pool member originating from this team, and it is `0` when the team contributed nothing — a team that pooled no pick of its own is not swapping, it is receiving, and the rank it holds is worth the whole pick it delivers. Diagnostics: %{ method: :exact | :monte_carlo, version: "1.0.0", seed: {a, b, c} | nil, # nil for exact enumeration iterations: 10_000 | nil, std_error: 0.0, # in :dav units (discounted); 0.0 when exact variance: 0.0, members: 2, baseline_dav: 41.2, # discounted E[pick otherwise kept] received_dav: 48.9, # discounted E[rank-N pick received] = :inventory_dav participation_probability: 1.0 # mass where the rank exists at all } """ @spec swap_dav(map(), ctx()) :: result() def swap_dav(%{kind: :swap_outcome} = claim, ctx) do ranks = [pool_rank!(claim, pool_members!(claim))] swap_portfolio_dav(claim, ranks, ctx) end def swap_dav(other, _ctx) do raise ArgumentError, "swap_dav/2 takes a :swap_outcome claim from " <> "OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}" end @doc """ Every rank one team holds in one pool, valued as a single joint holding. `swap_dav/2` is the one-rank case of this and calls it, so there is exactly one definition of a swap's gain rather than one per arity. AVC's rule is `E[max(received − surrendered, 0)]` with the max taken **inside each draft**, and that does not decompose across ranks: a team holding ranks 1 and 2 surrenders one pick and receives two, so the surplus that matters is `Σ received − kept` in each simulated draft, floored once. Summing the ranks' individual `:dav` values subtracts the baseline once per rank *and* discards the drafts each rank's own floor zeroed; taking the floor outside the expectation instead — `max(Σ E[received] − E[kept], 0)` — is the same substitution this module's moduledoc calls wrong for a single rank, and it prices a stochastic holding at 0 whenever the joint surplus changes sign between drafts. `:dav` is the joint incremental gain. `:inventory_dav` is `E[Σ received]`, what the team ends the draft holding across those ranks. """ @spec swap_portfolio_dav(map(), [pos_integer()], ctx()) :: result() def swap_portfolio_dav(%{kind: :swap_outcome} = claim, ranks, ctx) when is_list(ranks) and ranks != [] do ctx = normalize!(ctx) members = pool_members!(claim) ranks = ranks |> Enum.map(&pool_rank!(%{claim | rank: &1}, members)) |> Enum.sort() |> unique!() baseline_pick_id = baseline_pick_id(claim, members) # Sorted by pick id, so neither the seed, the sampling order, nor the order # 900 enumerated products are summed in depends on how the caller happened to # order the pool (D-50). pool = members |> Enum.map(fn member -> {member, member |> pick_ref() |> ctx.slot_distribution_fn.() |> validate!(member)} end) |> Enum.sort_by(fn {member, _distribution} -> member.pick_id end) {stats, method, seed, iterations} = if length(members) <= @exact_member_limit, do: enumerate(pool, ranks, baseline_pick_id), else: simulate(pool, ranks, baseline_pick_id, ctx) discount = discount(claim.year, ctx) %{ dav: stats.gain * discount, inventory_dav: stats.received * discount, diagnostics: %{ method: method, version: version(), seed: seed, iterations: iterations, ranks: ranks, std_error: std_error(stats.gain_variance, iterations) * discount, variance: stats.gain_variance * discount * discount, # `:inventory_dav` is what all three benchmark metrics sum, and when it # is sampled it carries its own uncertainty — one that is *not* the # gain's. A rank whose gain is identically zero (it never beats the pick # the team pooled) still receives a pick that varies from draft to draft, # so reporting the gain's standard error as the claim's would state zero # uncertainty on a number that has some, on a write-once row. inventory_std_error: std_error(stats.received_variance, iterations) * discount, inventory_variance: stats.received_variance * discount * discount, members: length(members), baseline_dav: stats.baseline * discount, received_dav: stats.received * discount, participation_probability: stats.participation } } end def swap_portfolio_dav(%{kind: :swap_outcome}, ranks, _ctx) do raise ArgumentError, "swap_portfolio_dav/3 takes a non-empty list of ranks the team holds in one pool, " <> "got: #{inspect(ranks)}" end def swap_portfolio_dav(other, _ranks, _ctx) do raise ArgumentError, "swap_portfolio_dav/3 takes a :swap_outcome claim from " <> "OutcomeEvaluator.controlled_claims/5, got: #{inspect(other)}" end defp unique!(ranks) do if length(Enum.uniq(ranks)) == length(ranks) do ranks else raise ArgumentError, "a team cannot hold the same swap rank twice, got: #{inspect(ranks)}. Counting one " <> "rank twice would pay it twice." end end @doc """ AVC's time discount for an outcome resolving in `year`: `0.92^(year − valuation year)`. Years before the valuation date raise rather than compounding upward into a premium — a draft that has already happened is not a more valuable asset. """ @spec discount(integer(), ctx() | map()) :: float() def discount(year, ctx) when is_integer(year) do ctx = normalize!(ctx) years_ahead = year - ctx.valuation_year if years_ahead < 0 do raise ArgumentError, "outcome year #{year} precedes the valuation year #{ctx.valuation_year}; the time " <> "discount is only defined forward. A past draft is not an asset." end :math.pow(ctx.discount_rate, years_ahead) end @doc "Version of the DAV constants, frozen onto grades that read this model." @spec version() :: String.t() def version, do: Map.fetch!(ModelVersion.versions(), :dav) # ── Pick-claim accumulation ─────────────────────────────────────────── defp accumulate(outcome, ctx, {values, undiscounted, conveyed, gone, unresolved, years}) do case outcome.value_basis do {:slot, slot} -> base = outcome.probability * SlotCurve.value(slot) {[base * discount(outcome.year, ctx) | values], [base | undiscounted], [outcome.probability | conveyed], gone, unresolved, MapSet.put(years, outcome.year)} :extinguished -> {values, undiscounted, conveyed, [outcome.probability | gone], unresolved, years} {:asset, _fallback} -> {values, undiscounted, conveyed, gone, [outcome.probability | unresolved], years} end end # Float addition is not associative, so a total that depends on term order is # not reproducible (D-50). Sorting first makes every sum canonical regardless # of the order the walk happened to emit its outcomes in. # # The `* 1.0` is not decoration: `Enum.sum([])` is the integer `0`, and a # diagnostics map that reports `0` for one claim and `0.0` for the next is a # different jsonb document for the same state on a write-once table. defp canonical_sum(terms), do: terms |> Enum.sort() |> Enum.sum() |> Kernel.*(1.0) # ── Swap: exact enumeration ─────────────────────────────────────────── # Every joint slot assignment, weighted by the product of its marginals. Exact # for one or two members; 30ⁿ makes it the wrong tool beyond that. # # Enumeration is exact, so both variances are 0.0 — the estimate has no # sampling error, not merely an unmeasured one. defp enumerate(pool, ranks, baseline_pick_id) do {gain, received, baseline, participation} = pool |> joint_assignments() |> Enum.reduce({0.0, 0.0, 0.0, 0.0}, fn {assignment, probability}, {g, r, b, part} -> {slot_received, slot_baseline, participated?} = evaluate(assignment, ranks, baseline_pick_id) {g + probability * max(slot_received - slot_baseline, 0.0), r + probability * slot_received, b + probability * slot_baseline, part + if(participated?, do: probability, else: 0.0)} end) stats = %{ gain: gain, received: received, baseline: baseline, participation: participation, gain_variance: 0.0, received_variance: 0.0 } {stats, :exact, nil, nil} end defp joint_assignments([]), do: [{[], 1.0}] defp joint_assignments([{member, distribution} | rest]) do tails = joint_assignments(rest) for {slot, probability} <- distribution, {tail, tail_probability} <- tails, do: {[{member, slot} | tail], probability * tail_probability} end # ── Swap: seeded Monte Carlo ────────────────────────────────────────── defp simulate(pool, ranks, baseline_pick_id, ctx) do iterations = ctx.iterations seed = seed_for(pool, ctx) cumulative = Enum.map(pool, fn {member, distribution} -> {member, cumulative(distribution)} end) zero = {0.0, 0.0, 0.0, 0.0, 0.0, 0} {{gain, gain_squared, received, received_squared, baseline, hits}, _state} = Enum.reduce(1..iterations, {zero, :rand.seed_s(:exsss, seed)}, fn _i, {{g, g2, r, r2, b, hits}, state} -> {assignment, state} = draw(cumulative, state) {slot_received, slot_baseline, participated?} = evaluate(assignment, ranks, baseline_pick_id) sample = max(slot_received - slot_baseline, 0.0) {{g + sample, g2 + sample * sample, r + slot_received, r2 + slot_received * slot_received, b + slot_baseline, hits + if(participated?, do: 1, else: 0)}, state} end) n = iterations gain_mean = gain / n received_mean = received / n stats = %{ gain: gain_mean, received: received_mean, baseline: baseline / n, participation: hits / n, gain_variance: sample_variance(gain_squared, gain_mean, n), received_variance: sample_variance(received_squared, received_mean, n) } {stats, :monte_carlo, seed, iterations} end # The pool's pick ids (already canonically ordered) plus the valuation date — # stable across runs, machines and Erlang releases, and identical for every # rank drawn from the same pool, so a team holding two ranks sees one # consistent draft. defp seed_for(pool, ctx) do material = Enum.map_join(pool, "|", fn {member, _distribution} -> to_string(member.pick_id) end) |> Kernel.<>("@" <> Date.to_iso8601(ctx.valuation_date)) <> = :crypto.hash(:sha256, material) {a, b, c} end defp cumulative(distribution) do distribution |> Enum.reduce({[], 0.0}, fn {slot, probability}, {acc, total} -> {[{total + probability, slot} | acc], total + probability} end) |> elem(0) |> Enum.reverse() end defp draw(cumulative, state) do Enum.map_reduce(cumulative, state, fn {member, bands}, state -> {u, state} = :rand.uniform_s(state) {{member, inverse_cdf(bands, u)}, state} end) end # Inverse-CDF sampling. The final band's cumulative probability can land a # hair under 1.0 after renormalization, so an unmatched draw takes the last # slot rather than returning nil. defp inverse_cdf(bands, u) do Enum.find_value(bands, fn {cumulative, slot} -> if u <= cumulative, do: slot end) || bands |> List.last() |> elem(1) end defp sample_variance(_sum_of_squares, _mean, n) when n < 2, do: 0.0 defp sample_variance(sum_of_squares, mean, n), do: max((sum_of_squares - n * mean * mean) / (n - 1), 0.0) defp std_error(_variance, nil), do: 0.0 defp std_error(variance, iterations), do: :math.sqrt(variance / iterations) # ── Swap: one simulated draft ───────────────────────────────────────── # A member only reaches the pool when its slot falls inside the band it # contributed: a swap over the 6-30 slice of a top-5-protected pick simply does # not happen in the worlds where the pick lands 1-5, and that world is already # valued as the original team's retained range claim. # Sums every rank the team holds in this draft, so the floor above is applied # to the joint surplus once rather than to each rank separately. # `participated?` means every held rank was filled — for one rank that is the # original meaning unchanged. defp evaluate(assignment, ranks, baseline_pick_id) do participants = for {member, slot} <- assignment, in_band?(member, slot), do: {slot, member.pick_id} sorted = Enum.sort(participants) {received, filled} = Enum.reduce(ranks, {0.0, 0}, fn rank, {sum, filled} -> case Enum.at(sorted, rank - 1) do nil -> {sum, filled} {slot, _pick_id} -> {sum + SlotCurve.value(slot), filled + 1} end end) baseline = Enum.find_value(assignment, 0.0, fn {member, slot} -> if member.pick_id == baseline_pick_id and in_band?(member, slot), do: SlotCurve.value(slot) end) {received, baseline, filled == length(ranks)} end defp in_band?(%{range: %Range{} = range}, slot), do: slot in range # ── Swap: claim shape ───────────────────────────────────────────────── defp pool_members!(%{members: members}) when is_list(members) and members != [], do: members defp pool_members!(claim) do raise ArgumentError, "swap claim #{inspect(claim.swap_group_id)} has no resolvable pool members " <> "(#{inspect(claim.members)}). A pool whose picks were not supplied to " <> "controlled_claims/5 would silently value at zero; supply them." end defp pool_rank!(%{rank: rank}, members) when is_integer(rank) and rank >= 1 do if rank > length(members) do raise ArgumentError, "swap outcome holds rank #{rank} of a #{length(members)}-pick pool, so it can never " <> "be satisfied. That is a data error, not a worthless asset." end rank end defp pool_rank!(claim, _members) do raise ArgumentError, "swap claim #{inspect(claim.swap_group_id)} has no rank (#{inspect(claim.rank)}); a " <> "swap outcome is a rank in a pool." end # The pick this team would otherwise have kept: the pool member that originates # with the outcome holder. `nil` — the holder pooled nothing of its own — means # a baseline of zero, so the rank is worth the whole pick it delivers. defp baseline_pick_id(claim, members) do members |> Enum.filter(&(&1.original_team_id == claim.team_id)) |> Enum.map(& &1.pick_id) |> Enum.sort() |> List.first() end # ── Context ─────────────────────────────────────────────────────────── defp normalize!(%{__dav_ctx__: true} = ctx), do: ctx defp normalize!(ctx) when is_map(ctx) do valuation_date = valuation_date!(ctx) override = Map.get(ctx, :override, %{}) normalized = %{ __dav_ctx__: true, valuation_date: valuation_date, valuation_year: valuation_date.year, discount_rate: ModelVersion.dav_time_discount(override), team_cs_scores: Map.get(ctx, :team_cs_scores, %{}), iterations: iterations!(ctx), unresolved: unresolved!(ctx), override: override } Map.put(normalized, :slot_distribution_fn, slot_distribution_fn(ctx, normalized)) end defp normalize!(other) do raise ArgumentError, "ctx must be a map carrying at least :valuation_date, got: #{inspect(other)}" end defp valuation_date!(ctx) do case Map.fetch(ctx, :valuation_date) do {:ok, %Date{} = date} -> date other -> raise ArgumentError, ":valuation_date must be the frozen Date the grade is anchored to, got: " <> "#{inspect(other)}. Date.utc_today/0 is not a valuation date — a stored grade " <> "has to reproduce, and today moves." end end # At least two: the sample variance of one draw is undefined, and reporting it # as 0.0 states perfect precision on a single sample written to a row that can # never be corrected. defp iterations!(ctx) do case Map.get(ctx, :iterations, @default_iterations) do n when is_integer(n) and n >= 2 -> n other -> raise ArgumentError, ":iterations must be an integer of at least 2 — a single draw has no measurable " <> "sampling error, and reporting 0.0 for it would freeze false precision. Got: " <> "#{inspect(other)}" end end defp unresolved!(ctx) do case Map.get(ctx, :unresolved, :raise) do policy when policy in [:raise, :provisional] -> policy other -> raise ArgumentError, ":unresolved must be :raise or :provisional, got: #{inspect(other)}" end end defp slot_distribution_fn(ctx, normalized) do case Map.get(ctx, :slot_distribution_fn) do nil -> fn pick_ref -> SlotProbability.distribution(spec(pick_ref, normalized)) end fun when is_function(fun, 1) -> fun other -> raise ArgumentError, ":slot_distribution_fn must be a 1-arity function, got: #{inspect(other)}" end end defp spec(pick_ref, ctx) do %{ round: pick_ref.round, years_ahead: pick_ref.year - ctx.valuation_year, pick_number: pick_ref.pick_number, team_cs_score: cs_score(pick_ref, ctx) } end defp cs_score(%{pick_number: pick_number}, _ctx) when is_integer(pick_number), do: nil defp cs_score(pick_ref, ctx) do case Map.fetch(ctx.team_cs_scores, pick_ref.original_team_id) do {:ok, score} -> score :error -> raise ArgumentError, "no frozen Competitive Strength score for team " <> "#{inspect(pick_ref.original_team_id)} (pick #{inspect(pick_ref.pick_id)}, " <> "#{pick_ref.year} round #{pick_ref.round}). Its slot is unknown, so it can only " <> "be projected from team strength — pass :team_cs_scores from the frozen Phase 04 " <> "benchmark, or supply :slot_distribution_fn directly." end end defp pick_ref(source), do: Map.take(source, [:pick_id, :year, :round, :original_team_id, :pick_number]) # The swap path does not go through terminal_outcomes/2, so it fails closed on # the same distribution errors — a round-2 pool handed round-1 slots would put # every member outside its own band and value the whole pool at zero. defp validate!(distribution, member), do: OutcomeEvaluator.assert_distribution!(distribution, member.round, pick_ref(member)) end