Roster construction
Position coverage, skill coverage, functional depth, and roster completeness measure the composition of the roster.
Aggregation
The table below is extracted from the pillar's declared metric weights at build time. Open the metric implementation notes and source to inspect its raw inputs, normalization, branches, and fallback handling.
pillar_score = Σ(metric_score × metric_weight)
Metric weights
| Metric | Declared weight |
|---|---|
| Roster completeness | 15.0% |
| Position coverage | 25.0% |
| Skill coverage | 35.0% |
| Functional depth | 25.0% |
Implementation
These notes and source files are captured from this deployment's code. Expand a module for its algorithm and branches, or download the Elixir file for local inspection.
roster_construction.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction.ex
Aggregates AVC's four Roster Construction metrics over the final roster.
Roster Completeness, Position Coverage, Skill Coverage, and Functional Depth retain full precision through the fixed 15/25/35/25 weighted sum.
SHA-256 74845faa8ffdaa88a439de5d26cb01c01649cc9443b0845e9c686ffaf65bd3f9
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction do
@moduledoc """
Aggregates AVC's four Roster Construction metrics over the final roster.
Roster Completeness, Position Coverage, Skill Coverage, and Functional Depth
retain full precision through the fixed 15/25/35/25 weighted sum.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.FunctionalDepth
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.RosterCompleteness
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.SkillCoverage
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.Result.PillarResult
@metric_weights %{
roster_completeness: 0.15,
position_coverage: 0.25,
skill_coverage: 0.35,
functional_depth: 0.25
}
@metric_modules [
RosterCompleteness,
PositionCoverage,
SkillCoverage,
FunctionalDepth
]
if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do
raise "Roster Construction metric weights must sum to 1.0"
end
@doc "Returns the full-precision Roster Construction pillar result."
@spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
def score(
%{roster: roster} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
)
when is_list(roster) do
scoring_state =
scenario_state
|> Map.put(:roster, Inputs.hydrate_roster(roster, snapshot))
|> Map.put(
:model_override,
Map.merge(
Inputs.model_override(snapshot),
Map.get(scenario_state, :constants_override, %{})
)
)
metric_results = Enum.map(@metric_modules, & &1.score(scoring_state, snapshot))
{strongest, weakest} = extrema(metric_results)
%PillarResult{
key: :roster_construction,
version: benchmark.version,
score: weighted_score(metric_results),
metric_results: metric_results,
metric_weights: @metric_weights,
explanation: explanation(strongest, weakest),
strongest: strongest.key,
weakest: weakest.key
}
end
defp weighted_score(metric_results) do
Enum.reduce(metric_results, 0.0, fn result, total ->
total + result.score * Map.fetch!(@metric_weights, result.key)
end)
end
# Enum.max_by/min_by retain the first item on a tie, so published metric order
# provides a stable tie-break without a second priority table.
defp extrema(metric_results) do
{
Enum.max_by(metric_results, & &1.score),
Enum.min_by(metric_results, & &1.score)
}
end
defp explanation(
%MetricResult{key: strongest_key, score: strongest_score},
%MetricResult{key: weakest_key, score: weakest_score}
) do
"Roster Construction combines four full-roster metrics; " <>
"#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
"#{label(weakest_key)} is weakest at #{weakest_score}."
end
defp label(:roster_completeness), do: "Roster Completeness"
defp label(:position_coverage), do: "Position Coverage"
defp label(:skill_coverage), do: "Skill Coverage"
defp label(:functional_depth), do: "Functional Depth"
end
functional_depth.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction/functional_depth.ex
Scores AVC's Functional Depth metric over the entire final roster.
Position Depth credits a player's listed position at their shared contribution factor and adjacent positions at 35% of that factor. Skill Redundancy counts contribution-factor units only for reliability-adjusted skill indexes of at least 60. Skill Concentration measures the best player's share of each skill's top-three adjusted indexes.
Per D-24, concentration is neutral (50) for a skill with fewer than three measured indexes or a zero top-three sum, and every such use is recorded as a fallback.
SHA-256 6cb8ac1a33223b0d2261b7b7325fba027dd18da4670ceccb2f5f223fa674d7cd
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.FunctionalDepth do
@moduledoc """
Scores AVC's Functional Depth metric over the entire final roster.
Position Depth credits a player's listed position at their shared
contribution factor and adjacent positions at 35% of that factor. Skill
Redundancy counts contribution-factor units only for reliability-adjusted
skill indexes of at least 60. Skill Concentration measures the best player's
share of each skill's top-three adjusted indexes.
Per D-24, concentration is neutral (50) for a skill with fewer than three
measured indexes or a zero top-three sum, and every such use is recorded as a
fallback.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.SkillIndexes
@adjacent %{
"PG" => ["SG"],
"SG" => ["PG", "SF"],
"SF" => ["SG", "PF"],
"PF" => ["SF", "C"],
"C" => ["PF"]
}
@adjacent_credit 0.35
@qualified_index 60.0
@average_weight 0.70
@weakest_weight 0.30
@weights %{position_depth: 0.35, skill_redundancy: 0.40, skill_concentration: 0.25}
@neutral_concentration 50.0
@doc "Returns a full-precision Functional Depth metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{roster: roster} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
)
when is_list(roster) do
tables =
benchmark
|> Benchmark.section(:skills)
|> Map.fetch!("player_percentiles")
profiles = profiles(roster, tables, Inputs.model_override(scenario_state, snapshot))
{position_depth, position_inputs, position_fallbacks} = position_depth(profiles)
{skill_redundancy, redundancy_inputs} = skill_redundancy(profiles)
{skill_concentration, concentration_inputs, concentration_fallbacks} =
skill_concentration(profiles)
supporting = %{
position_depth: position_depth,
skill_redundancy: skill_redundancy,
skill_concentration: skill_concentration
}
%MetricResult{
key: :functional_depth,
version: benchmark.version,
score:
position_depth * @weights.position_depth +
skill_redundancy * @weights.skill_redundancy +
skill_concentration * @weights.skill_concentration,
inputs: %{
position_depth: position_inputs,
skill_redundancy: redundancy_inputs,
skill_concentration: concentration_inputs
},
supporting: supporting,
weights: @weights,
fallbacks: position_fallbacks ++ concentration_fallbacks,
explanation:
"Functional Depth combines position depth (35%), skill redundancy (40%), " <>
"and skill concentration (25%) across the entire roster."
}
end
@doc "Converts non-negative position-depth units through AVC's piecewise curve."
@spec position_depth_score(number()) :: float()
def position_depth_score(units) when is_number(units) and units >= 0 do
cond do
units <= 2.0 -> units * 50.0
units <= 3.0 -> 100.0
units <= 4.0 -> interpolate(units, 3.0, 100.0, 4.0, 90.0)
units <= 5.0 -> interpolate(units, 4.0, 90.0, 5.0, 75.0)
true -> 75.0
end
end
@doc "Converts non-negative qualified-provider units through AVC's redundancy curve."
@spec redundancy_score(number()) :: float()
def redundancy_score(units) when is_number(units) and units >= 0 do
cond do
units <= 1.0 -> interpolate(units, 0.0, 0.0, 1.0, 45.0)
units <= 2.0 -> interpolate(units, 1.0, 45.0, 2.0, 75.0)
units <= 3.0 -> interpolate(units, 2.0, 75.0, 3.0, 90.0)
units <= 4.0 -> interpolate(units, 3.0, 90.0, 4.0, 100.0)
true -> 100.0
end
end
@doc "Converts a best-player top-three share through AVC's concentration curve."
@spec concentration_score(number()) :: float()
def concentration_score(share) when is_number(share) and share >= 0 do
cond do
share <= 0.40 -> 100.0
share <= 0.45 -> interpolate(share, 0.40, 100.0, 0.45, 85.0)
share <= 0.50 -> interpolate(share, 0.45, 85.0, 0.50, 70.0)
share <= 0.55 -> interpolate(share, 0.50, 70.0, 0.55, 50.0)
share <= 0.60 -> interpolate(share, 0.55, 50.0, 0.60, 30.0)
share <= 0.65 -> interpolate(share, 0.60, 30.0, 0.65, 10.0)
true -> 10.0
end
end
defp profiles(roster, tables, override) do
Enum.map(roster, fn player ->
%{
player: player,
position: PositionCoverage.normalize_position!(fetch_any!(player, :position)),
contribution_factor: SkillIndexes.contribution_factor(player, override),
indexes: SkillIndexes.player_indexes(player, tables, %{override: override}).skills
}
end)
end
defp position_depth(profiles) do
initial = Map.new(PositionCoverage.positions(), &{&1, 0.0})
{units, fallbacks} =
Enum.reduce(profiles, {initial, []}, fn profile, {units, fallbacks} ->
case profile.contribution_factor do
factor when is_number(factor) ->
units =
units
|> Map.update!(profile.position, &(&1 + factor))
|> add_adjacent_units(profile.position, factor)
{units, fallbacks}
nil ->
fallback = %{
input: player_identifier(profile.player),
method: :missing_contribution_factor,
note:
"#{player_label(profile.player)} has no season-minutes sample, so no " <>
"position-depth or qualified-provider credit was invented."
}
{units, [fallback | fallbacks]}
end
end)
scores = Map.new(units, fn {position, value} -> {position, position_depth_score(value)} end)
{score, average, weakest} = weakest_blend(Map.values(scores))
inputs = %{
units: units,
scores: scores,
average_score: average,
weakest_score: weakest
}
{score, inputs, Enum.reverse(fallbacks)}
end
defp skill_redundancy(profiles) do
units =
Map.new(SkillIndexes.skills(), fn skill ->
value =
Enum.reduce(profiles, 0.0, fn profile, total ->
index = get_in(profile, [:indexes, skill, :index])
if is_number(index) and index >= @qualified_index and
is_number(profile.contribution_factor) do
total + profile.contribution_factor
else
total
end
end)
{skill, value}
end)
scores = Map.new(units, fn {skill, value} -> {skill, redundancy_score(value)} end)
{score, average, weakest} = weakest_blend(Map.values(scores))
{score,
%{
qualified_index_threshold: @qualified_index,
units: units,
scores: scores,
average_score: average,
weakest_score: weakest
}}
end
defp skill_concentration(profiles) do
{shares, scores, fallbacks} =
Enum.reduce(SkillIndexes.skills(), {%{}, %{}, []}, fn skill, {shares, scores, fallbacks} ->
top_three =
profiles
|> Enum.map(&get_in(&1, [:indexes, skill, :index]))
|> Enum.filter(&is_number/1)
|> Enum.sort(:desc)
|> Enum.take(3)
total = Enum.sum(top_three)
if length(top_three) < 3 or total == 0 do
reason =
if length(top_three) < 3, do: :fewer_than_three_indexes, else: :zero_top_three_sum
fallback = %{
input: Atom.to_string(skill),
method: :neutral_skill_concentration,
note:
"D-24 applies the neutral concentration score of 50 for #{skill}: " <>
concentration_reason(reason, length(top_three))
}
{
Map.put(shares, skill, nil),
Map.put(scores, skill, @neutral_concentration),
[fallback | fallbacks]
}
else
share = hd(top_three) / total
{
Map.put(shares, skill, share),
Map.put(scores, skill, concentration_score(share)),
fallbacks
}
end
end)
average = scores |> Map.values() |> then(&(Enum.sum(&1) / length(SkillIndexes.skills())))
{average, %{top_player_shares: shares, scores: scores}, Enum.reverse(fallbacks)}
end
defp add_adjacent_units(units, position, factor) do
Enum.reduce(Map.fetch!(@adjacent, position), units, fn adjacent, acc ->
Map.update!(acc, adjacent, &(&1 + factor * @adjacent_credit))
end)
end
defp weakest_blend(scores) do
average = Enum.sum(scores) / length(scores)
weakest = Enum.min(scores)
{average * @average_weight + weakest * @weakest_weight, average, weakest}
end
defp interpolate(value, low_x, low_y, high_x, high_y) do
low_y + (value - low_x) / (high_x - low_x) * (high_y - low_y)
end
defp fetch_any!(map, key) do
case fetch_any(map, key) do
nil -> raise KeyError, key: key, term: map
value -> value
end
end
defp fetch_any(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key)))
defp player_identifier(player) do
fetch_any(player, :id) || fetch_any(player, :player_id) || player_label(player)
end
defp player_label(player) do
fetch_any(player, :name) ||
"Player #{inspect(fetch_any(player, :id) || fetch_any(player, :player_id))}"
end
defp concentration_reason(:fewer_than_three_indexes, count),
do: "only #{count} measured player indexes were available."
defp concentration_reason(:zero_top_three_sum, _count),
do: "the top-three index sum was zero."
end
inputs.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction/inputs.ex
See the implementation below.
SHA-256 d4bbc963c45eb0a61584a37487075d60ea3d5381088cc03fba69ca987c68124c
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs do
@moduledoc false
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
@skill_evidence_keys [
:player_id,
:season_minutes,
:games_played,
:minutes_per_game,
:assists,
:turnovers,
:usg_pct,
:fg3_pct,
:fg3a,
:ts_pct,
:fga,
:fta,
:ft_pct,
:epm_def,
:steals,
:blocks,
:rebounds
]
# The scenario-owned membership fields — identity, listed position, and
# contract/two-way status — exactly the shape `ScenarioState` emits (see its
# `standard_roster/1` and `two_way_roster/1`). Only these may override the
# frozen player: a scenario legitimately re-positions or re-signs a player, so
# position and two-way status must win. Everything else — the write-once
# statistical/skill evidence in `@skill_evidence_keys` — must come from the
# snapshot and can never be replaced by a roster field.
@membership_keys [
:id,
:player_id,
:name,
:position,
:salary,
:is_draft_pick,
:is_two_way
]
# Both spellings, because roster rows arrive atom-keyed in memory and
# string-keyed out of Postgres / form params — the same dual-spelling rule
# `SkillIndexes.contribution_factor/2` follows. `Map.take/2` copies whichever
# spelling the member actually carries.
@membership_key_spellings Enum.flat_map(@membership_keys, &[&1, Atom.to_string(&1)])
@doc """
Joins scenario roster membership to the snapshot's frozen player evidence.
`ScenarioState` deliberately emits membership only; stats and skill evidence
live in the write-once challenge snapshot. Only the allowlisted membership
fields (identity, position, contract/two-way status) merge over the frozen
player, so scenario-owned position and two-way status win on duplicate keys
while frozen skill evidence can never be overwritten — a canonical
`ScenarioState` row carries no stat line, but an enriched or future caller that
did would otherwise silently replace write-once evidence (e.g. a member
`season_minutes: 0` zeroing a frozen `800`) and move Position Coverage, Skill
Coverage, and Functional Depth.
"""
@spec hydrate_roster([map()], ChallengeSnapshot.t()) :: [map()]
def hydrate_roster(roster, %ChallengeSnapshot{} = snapshot) when is_list(roster) do
players = ChallengeSnapshot.section(snapshot, :players)
Enum.map(roster, fn member ->
member
|> player_id!()
|> frozen_player(players)
|> canonicalize_skill_evidence()
|> Map.merge(Map.take(member, @membership_key_spellings))
end)
end
@doc """
Returns the constants override frozen with the benchmark.
Benchmarks created before this provenance field existed use the versioned
defaults. String keys are retained because `ModelVersion` accepts both JSON
and in-memory spellings without interning document input.
"""
@spec model_override(ChallengeSnapshot.t()) :: map()
def model_override(%ChallengeSnapshot{benchmark: %Benchmark{data: data}}) do
with meta when is_map(meta) <- fetch_any(data, :meta),
override when is_map(override) <- fetch_any(meta, :model_override) do
override
else
_ -> %{}
end
end
@doc "Returns an override already attached by the pillar, or the benchmark's frozen value."
@spec model_override(map(), ChallengeSnapshot.t()) :: map()
def model_override(scenario_state, %ChallengeSnapshot{} = snapshot)
when is_map(scenario_state) do
case Map.get(scenario_state, :model_override) do
override when is_map(override) -> override
_ -> model_override(snapshot)
end
end
defp frozen_player(player_id, players) do
case Map.get(players, player_id) || Map.get(players, to_string(player_id)) do
player when is_map(player) -> player
_ -> %{}
end
end
defp player_id!(member) do
case fetch_any(member, :id) || fetch_any(member, :player_id) do
nil -> raise KeyError, key: :id, term: member
player_id -> player_id
end
end
# JSON documents return string keys, while `PlayerImpact.season_minutes/1`
# and the skill formulas consume the schema's atom fields. Only this fixed
# vocabulary is canonicalized; arbitrary document input is never atomized.
defp canonicalize_skill_evidence(player) do
Enum.reduce(@skill_evidence_keys, player, fn key, canonical ->
case Map.fetch(canonical, key) do
{:ok, _value} ->
canonical
:error ->
case Map.fetch(canonical, Atom.to_string(key)) do
{:ok, value} -> Map.put(canonical, key, value)
:error -> canonical
end
end
end)
end
defp fetch_any(map, key) when is_map(map) do
case Map.fetch(map, key) do
{:ok, value} -> value
:error -> Map.get(map, Atom.to_string(key))
end
end
defp fetch_any(_other, _key), do: nil
end
position_coverage.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction/position_coverage.ex
Scores AVC's Position Coverage metric over the entire final roster.
Each player contributes only at their listed position. The historical player
feed's generic
"G"
display value is canonicalized to
"SG"
— the same
conservative rule used by calibration, which avoids inventing primary-handler
credit. Their contribution is the shared
TradeMachine.GMGradeV2.SkillIndexes.contribution_factor/2
, so the 800-minute
experience curve and the two-way availability multiplier remain centralized.
A position reaches full coverage at one contribution unit.
SHA-256 a2c15501449fbdd179226f17e3aabfbfd084d4d3ff36caa17c47370861314ff4
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.PositionCoverage do
@moduledoc """
Scores AVC's Position Coverage metric over the entire final roster.
Each player contributes only at their listed position. The historical player
feed's generic `"G"` display value is canonicalized to `"SG"` — the same
conservative rule used by calibration, which avoids inventing primary-handler
credit. Their contribution is the shared
`TradeMachine.GMGradeV2.SkillIndexes.contribution_factor/2`, so the 800-minute
experience curve and the two-way availability multiplier remain centralized.
A position reaches full coverage at one contribution unit.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.SkillIndexes
@positions ~w(PG SG SF PF C)
@average_weight 0.70
@weakest_weight 0.30
@doc "Returns the five positions in AVC's stated order."
@spec positions() :: [String.t()]
def positions, do: @positions
@doc "Normalizes an AVC position (including legacy `G`) or raises."
@spec normalize_position!(String.t() | atom()) :: String.t()
def normalize_position!(position) when position in @positions, do: position
def normalize_position!("G"), do: "SG"
def normalize_position!(position) when is_atom(position) do
position
|> Atom.to_string()
|> String.upcase()
|> normalize_position!()
end
def normalize_position!(position) do
raise ArgumentError,
"position must be one of #{Enum.join(@positions, ", ")}, got: #{inspect(position)}"
end
@doc "Returns a full-precision Position Coverage metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{roster: roster} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
)
when is_list(roster) do
{units, fallbacks} = coverage_units(roster, Inputs.model_override(scenario_state, snapshot))
position_scores =
Map.new(units, fn {position, value} -> {position, position_score(value)} end)
scores = Map.values(position_scores)
average = Enum.sum(scores) / length(@positions)
weakest = Enum.min(scores)
%MetricResult{
key: :position_coverage,
version: benchmark.version,
score: average * @average_weight + weakest * @weakest_weight,
inputs: %{
coverage_units: units,
average_position_score: average,
weakest_position_score: weakest
},
supporting: position_scores,
weights: %{average: @average_weight, weakest: @weakest_weight},
fallbacks: fallbacks,
explanation:
"Position Coverage gives 70% weight to the five-position average and 30% " <>
"to the weakest listed position."
}
end
@doc """
Sums player contribution factors at each listed position.
Players with unknown season minutes receive no invented coverage credit and
are reported as fallbacks. The legacy generic-guard value `G` uses the
documented `SG` canonicalization; other positions outside AVC's
PG/SG/SF/PF/C vocabulary are rejected rather than silently omitted.
"""
@spec coverage_units([map()], map()) ::
{%{String.t() => float()}, [MetricResult.fallback()]}
def coverage_units(roster, override \\ %{}) when is_list(roster) and is_map(override) do
initial = {Map.new(@positions, &{&1, 0.0}), []}
{units, fallbacks} =
Enum.reduce(roster, initial, fn player, {units, fallbacks} ->
position = player |> fetch_any!(:position) |> normalize_position!()
case SkillIndexes.contribution_factor(player, override) do
factor when is_number(factor) ->
{Map.update!(units, position, &(&1 + factor)), fallbacks}
nil ->
fallback = %{
input: player_identifier(player),
method: :missing_contribution_factor,
note:
"#{player_label(player)} has no season-minutes sample, so no " <>
"#{position} coverage credit was invented."
}
{units, [fallback | fallbacks]}
end
end)
{units, Enum.reverse(fallbacks)}
end
@doc "Converts coverage units to AVC's capped 0–100 individual-position score."
@spec position_score(number()) :: float()
def position_score(units) when is_number(units) and units >= 0 do
100.0 * min(1.0, units)
end
defp fetch_any!(map, key) do
case Map.get(map, key, Map.get(map, Atom.to_string(key))) do
nil -> raise KeyError, key: key, term: map
value -> value
end
end
defp fetch_any(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key)))
defp player_identifier(player) do
fetch_any(player, :id) || fetch_any(player, :player_id) || player_label(player)
end
defp player_label(player) do
fetch_any(player, :name) || "Player #{inspect(player_identifier_without_label(player))}"
end
defp player_identifier_without_label(player) do
fetch_any(player, :id) || fetch_any(player, :player_id) ||
"at #{fetch_any(player, :position)}"
end
end
roster_completeness.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction/roster_completeness.ex
Scores AVC's Roster Completeness metric over the final scenario roster.
Standard and two-way roster limits come from
TradeMachine.CBA.Rules
; this
module does not duplicate CBA constants. Standard contracts receive AVC's
piecewise base score, and occupied two-way slots add at most five points.
SHA-256 77b66dd36c129b2b8e64b79b81f61a6c1a650289db99f99225bb4c482031cc6f
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.RosterCompleteness do
@moduledoc """
Scores AVC's Roster Completeness metric over the final scenario roster.
Standard and two-way roster limits come from `TradeMachine.CBA.Rules`; this
module does not duplicate CBA constants. Standard contracts receive AVC's
piecewise base score, and occupied two-way slots add at most five points.
"""
alias TradeMachine.CBA.Rules
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Result.MetricResult
@two_way_bonus_cap 5.0
@doc "Returns a full-precision Roster Completeness metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{roster: roster},
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark}
)
when is_list(roster) do
{two_way_players, standard_players} = Enum.split_with(roster, &two_way?/1)
standard_count = length(standard_players)
two_way_count = length(two_way_players)
minimum = Rules.min_standard_roster()
maximum = Rules.max_standard_roster()
maximum_two_way = Rules.max_two_way_contracts()
standard_score = standard_roster_score(standard_count, minimum, maximum)
bonus = two_way_bonus(two_way_count, maximum_two_way)
%MetricResult{
key: :roster_completeness,
version: benchmark.version,
score: min(100.0, standard_score + bonus),
inputs: %{
standard_count: standard_count,
two_way_count: two_way_count,
minimum_standard_roster: minimum,
maximum_standard_roster: maximum,
maximum_two_way_contracts: maximum_two_way
},
supporting: %{
standard_roster_score: standard_score,
two_way_bonus: bonus
},
explanation:
"Roster Completeness scores #{standard_count} standard contracts and " <>
"#{two_way_count} of #{maximum_two_way} available two-way slots."
}
end
@doc """
Applies AVC's season-aware piecewise curve to a standard-roster count.
When the legal range spans more than two players, values between the legal
minimum (80) and one below the maximum (90) are linearly interpolated.
"""
@spec standard_roster_score(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: float()
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def standard_roster_score(count, minimum, maximum)
when is_integer(count) and count >= 0 and is_integer(minimum) and minimum >= 0 and
is_integer(maximum) and maximum >= minimum do
cond do
count >= maximum ->
100.0
count == maximum - 1 ->
90.0
count >= minimum and maximum - minimum > 2 ->
80.0 + 10.0 * (count - minimum) / (maximum - minimum - 1)
count >= minimum ->
80.0
count == minimum - 1 ->
60.0
count == minimum - 2 ->
40.0
count == minimum - 3 ->
20.0
true ->
0.0
end
end
@doc "Calculates the capped five-point two-way roster bonus."
@spec two_way_bonus(non_neg_integer(), non_neg_integer()) :: float()
def two_way_bonus(_count, 0), do: 0.0
def two_way_bonus(count, maximum)
when is_integer(count) and count >= 0 and is_integer(maximum) and maximum > 0 do
min(@two_way_bonus_cap, @two_way_bonus_cap * count / maximum)
end
defp two_way?(row) when is_map(row) do
Map.get(row, :is_two_way, Map.get(row, "is_two_way")) == true
end
end
skill_coverage.exElixir
lib/trade_machine/gm_grade_v2/pillars/roster_construction/skill_coverage.ex
Scores AVC's Skill Coverage metric over the entire final roster.
Each player's five reliability-adjusted indexes come from
TradeMachine.GMGradeV2.SkillIndexes
. The best providers are combined with
AVC's skill-specific ranked weights, then each team raw is converted to a
percentile against the frozen 30-team benchmark.
Per D-23, a ranked slot without a measured player index is filled with the reliability-shrunk league median of 50. The slot remains in the formula and its weight is not renormalized.
SHA-256 b686ab0410f582820b79644dfcabc34a5c438044771998a567846dfc78f1af94
defmodule TradeMachine.GMGradeV2.Pillars.RosterConstruction.SkillCoverage do
@moduledoc """
Scores AVC's Skill Coverage metric over the entire final roster.
Each player's five reliability-adjusted indexes come from
`TradeMachine.GMGradeV2.SkillIndexes`. The best providers are combined with
AVC's skill-specific ranked weights, then each team raw is converted to a
percentile against the frozen 30-team benchmark.
Per D-23, a ranked slot without a measured player index is filled with the
reliability-shrunk league median of 50. The slot remains in the formula and
its weight is not renormalized.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Percentile
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction.Inputs
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.SkillIndexes
@team_skill_weights [
playmaking: [0.65, 0.35],
shooting: [0.30, 0.25, 0.20, 0.15, 0.10],
defense: [0.35, 0.30, 0.20, 0.15],
rim_protection: [0.70, 0.30],
rebounding: [0.50, 0.30, 0.20]
]
@empty_slot_index 50.0
@average_weight 0.70
@weakest_weight 0.30
if Keyword.keys(@team_skill_weights) != SkillIndexes.skills() do
raise "team skill formulas must cover exactly SkillIndexes.skills/0, in order"
end
if Enum.any?(@team_skill_weights, fn {_skill, weights} ->
abs(Enum.sum(weights) - 1.0) > 1.0e-9
end) do
raise "each team skill formula's weights must sum to 1.0"
end
@doc "The five AVC team skill raw formulas, in scoring order."
@spec team_skill_weights() :: keyword([float()])
def team_skill_weights, do: @team_skill_weights
@doc "Returns a full-precision Skill Coverage metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{roster: roster} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
)
when is_list(roster) do
skills = Benchmark.section(benchmark, :skills)
tables = Map.fetch!(skills, "player_percentiles")
benchmark_arrays = Map.fetch!(skills, "metrics")
override = Inputs.model_override(scenario_state, snapshot)
{raw_scores, ranked_indexes, fallbacks} =
team_raw_details(roster, tables, %{override: override})
skill_scores =
Map.new(raw_scores, fn {skill, raw} ->
array = Map.fetch!(benchmark_arrays, Atom.to_string(skill))
{skill, Percentile.score(raw, array)}
end)
scores = Map.values(skill_scores)
average = Enum.sum(scores) / length(@team_skill_weights)
weakest = Enum.min(scores)
%MetricResult{
key: :skill_coverage,
version: benchmark.version,
score: average * @average_weight + weakest * @weakest_weight,
inputs: %{
team_raw_scores: raw_scores,
ranked_player_indexes: ranked_indexes,
average_skill_score: average,
weakest_skill_score: weakest
},
supporting: skill_scores,
weights: %{average: @average_weight, weakest: @weakest_weight},
fallbacks: fallbacks,
explanation:
"Skill Coverage gives 70% weight to the five-skill average and 30% " <>
"to the weakest roster-wide capability."
}
end
@doc """
Calculates the five pre-percentile team skill raws.
This is also the benchmark builder's scoring seam, ensuring real-team arrays
and challenge rosters use the same ranking, weights, and D-23 median fill.
"""
@spec team_raw_scores([map()], map(), map()) :: %{atom() => float()}
def team_raw_scores(roster, percentile_tables, opts \\ %{})
when is_list(roster) and is_map(percentile_tables) and is_map(opts) do
{raw_scores, _ranked_indexes, _fallbacks} =
team_raw_details(roster, percentile_tables, opts)
raw_scores
end
@doc false
@spec team_raw_details([map()], map(), map()) ::
{%{atom() => float()}, %{atom() => [float()]}, [MetricResult.fallback()]}
def team_raw_details(roster, percentile_tables, opts \\ %{})
when is_list(roster) and is_map(percentile_tables) and is_map(opts) do
override = Map.get(opts, :override, %{})
indexes =
Enum.map(roster, &SkillIndexes.player_indexes(&1, percentile_tables, %{override: override}))
{raw_scores, ranked_indexes, fallbacks} =
Enum.reduce(
@team_skill_weights,
{%{}, %{}, []},
fn {skill, weights}, {raw_scores, ranked_indexes, fallbacks} ->
measured =
indexes
|> Enum.map(&get_in(&1, [:skills, skill, :index]))
|> Enum.filter(&is_number/1)
|> Enum.sort(:desc)
|> Enum.take(length(weights))
missing_count = max(0, length(weights) - length(measured))
ranked = measured ++ List.duplicate(@empty_slot_index, missing_count)
raw =
ranked
|> Enum.zip(weights)
|> Enum.reduce(0.0, fn {index, weight}, total -> total + index * weight end)
skill_fallbacks =
if missing_count == 0 do
[]
else
[
%{
input: Atom.to_string(skill),
method: :league_median_ranked_slot,
note:
"#{missing_count} unfilled #{skill} ranked #{slot_label(missing_count)} " <>
"used the D-23 reliability-shrunk league median of 50."
}
]
end
{
Map.put(raw_scores, skill, raw),
Map.put(ranked_indexes, skill, ranked),
fallbacks ++ skill_fallbacks
}
end
)
{raw_scores, ranked_indexes, fallbacks}
end
defp slot_label(1), do: "slot"
defp slot_label(_count), do: "slots"
end