Competitive strength
Star power, starting five, rotation quality, and two-way balance. A missing or invalid 240-minute rotation produces a provisional placeholder, not an evaluated zero.
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 |
|---|---|
| Star power | 30.0% |
| Starting five | 25.0% |
| Rotation quality | 30.0% |
| Two way balance | 15.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.
competitive_strength.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength.ex
Aggregates AVC's four Competitive Strength metrics.
A valid frozen 240-minute rotation is a prerequisite for every component metric. When the rotation is missing or invalid, the pillar deliberately computes none of them and returns a provisional result instead. The authoritative submission gate remains responsible for blocking submission.
SHA-256 1e6821548581e555984a90bea7aa48689c4840411e9408022bdf2e32555c5f55
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength do
@moduledoc """
Aggregates AVC's four Competitive Strength metrics.
A valid frozen 240-minute rotation is a prerequisite for every component
metric. When the rotation is missing or invalid, the pillar deliberately
computes none of them and returns a provisional result instead. The
authoritative submission gate remains responsible for blocking submission.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.Result.PillarResult
alias TradeMachine.GMGradeV2.RotationValidation
alias TradeMachine.GMGradeV2.ScenarioValidation
alias TradeMachine.NBA.ScenarioRotation
@metric_weights %{
star_power: 0.30,
starting_five: 0.25,
rotation_quality: 0.30,
two_way_balance: 0.15
}
@metric_modules [StarPower, StartingFive, RotationQuality, TwoWayBalance]
if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do
raise "Competitive Strength metric weights must sum to 1.0"
end
@doc """
Returns the full-precision Competitive Strength pillar result.
All four metrics consume the same scenario state and frozen snapshot. A
missing or currently invalid rotation produces a provisional zero placeholder
with no metric results; zero is not an evaluated pillar score in that state.
"""
@spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
def score(
scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
)
when is_map(scenario_state) do
case validate_rotation(scenario_state) do
{:ok, _entries} ->
metric_results = Enum.map(@metric_modules, & &1.score(scenario_state, snapshot))
{strongest, weakest} = extrema(metric_results)
%PillarResult{
key: :competitive_strength,
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
}
{:error, reason} ->
%PillarResult{
key: :competitive_strength,
version: benchmark.version,
score: 0.0,
metric_results: [],
metric_weights: @metric_weights,
provisional?: true,
explanation:
"Competitive Strength is provisional and was not calculated because #{reason}"
}
end
end
defp validate_rotation(scenario_state) do
rotation = Map.get(scenario_state, :rotation)
if is_nil(rotation) do
{:error, "the scenario has no 240-minute rotation."}
else
entries = ScenarioRotation.entries(rotation)
roster_ids =
scenario_state
|> Map.get(:roster, [])
|> ScenarioValidation.rotation_eligible_player_ids()
injured_ids =
scenario_state
|> Map.get(:roster, [])
|> ScenarioValidation.out_for_season_player_ids()
case RotationValidation.validate(entries, roster_ids, injured_ids) do
{:ok, normalized_entries} ->
{:ok, normalized_entries}
{:error, errors} ->
details = Enum.map_join(errors, " ", & &1.message)
{:error, "the scenario rotation is invalid. #{details}"}
end
end
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, giving the published
# metric order a stable tie-break without inventing 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
"Competitive Strength combines four frozen-rotation metrics; " <>
"#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
"#{label(weakest_key)} is weakest at #{weakest_score}."
end
defp label(:star_power), do: "Star Power"
defp label(:starting_five), do: "Starting Five Quality"
defp label(:rotation_quality), do: "Rotation Quality"
defp label(:two_way_balance), do: "Two-Way Balance"
end
metric_inputs.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength/metric_inputs.ex
See the implementation below.
SHA-256 368ca5b22e8d7c7d230f03e7411e69e7ae6baf93cafc35db396ca1e28a64ca56
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs do
@moduledoc false
alias TradeMachine.GMGradeV2.Availability
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.NBA.ScenarioRotation
def rotation_entries(entries) when is_list(entries), do: entries
def rotation_entries(%{entries: entries}) when is_list(entries), do: entries
def rotation_entries(rotation), do: ScenarioRotation.entries(rotation)
def player_id(entry), do: fetch_any!(entry, :player_id)
def minutes(entry), do: fetch_number!(entry, :minutes)
def starter?(entry), do: fetch_any!(entry, :starter) == true
@doc """
The availability multiplier for one entry, plus the diagnostic keys to merge
into that player's contribution.
The annotation is `%{}` unless the user actually set a usable expected-games
value, so a contribution map for an un-annotated rotation is byte-identical to
the one this metric produced before availability existed — the stored grade
document has to be, not just the score. When the annotation *is* present it
records both halves of the arithmetic, because a discounted contribution the
user later disputes has to be explainable from the grade alone.
"""
def availability(entry, evidence) do
case Availability.expected_games(entry) do
nil ->
{1.0, %{}}
games ->
factor = Availability.factor(entry, evidence)
{factor, %{expected_games: games, availability: factor}}
end
end
def impact_number!(impacts, player_id, field) do
impacts
|> player_impact!(player_id)
|> fetch_number!(field)
end
def player_impact!(impacts, player_id) do
case Map.fetch(impacts, player_id) do
{:ok, impact} ->
impact
:error ->
Map.fetch!(impacts, to_string(player_id))
end
end
def roster_names(roster) when is_list(roster) do
Map.new(roster, fn player ->
{fetch_any!(player, :id), fetch_any!(player, :name)}
end)
end
def roster_names(_), do: %{}
def player_name(names, player_id), do: Map.get(names, player_id, to_string(player_id))
def benchmark_values(%Benchmark{} = benchmark, metric) when is_atom(metric) do
benchmark
|> Benchmark.section(:competitive_strength)
|> Map.fetch!("metrics")
|> Map.fetch!(Atom.to_string(metric))
end
def impact_fallbacks(contributions, impacts, impact_label \\ "Adjusted EPM", opts \\ []) do
include_imputed_splits? = Keyword.get(opts, :include_imputed_splits?, false)
contributions
|> Enum.reject(&Map.get(&1, :replacement?, false))
|> Enum.flat_map(fn contribution ->
impact = player_impact!(impacts, contribution.player_id)
source_fallback(contribution, impact, impact_label) ++
imputed_split_fallbacks(contribution, impact, include_imputed_splits?)
end)
end
def fetch_any!(map, key) do
case fetch_any(map, key) do
nil -> raise KeyError, key: key, term: map
value -> value
end
end
def fetch_any(map, key) when is_map(map) do
Map.get(map, key, Map.get(map, Atom.to_string(key)))
end
defp source_fallback(contribution, impact, impact_label) do
case fetch_any(impact, :source) do
source when source in [nil, :epm, "epm"] ->
[]
source ->
[
%{
input: contribution.player_id,
method: source,
note: "#{contribution.name}'s #{impact_label} uses the frozen #{source} fallback."
}
]
end
end
defp imputed_split_fallbacks(_contribution, _impact, false), do: []
defp imputed_split_fallbacks(contribution, impact, true) do
impact
|> fetch_any(:imputed_splits)
|> List.wrap()
|> Enum.map(fn split ->
split = normalize_split!(split)
%{
input: contribution.player_id,
method: :split_imputation,
note:
"#{contribution.name}'s adjusted #{split_label(split)} was derived rather than measured."
}
end)
end
defp normalize_split!(:offensive), do: :offensive
defp normalize_split!("offensive"), do: :offensive
defp normalize_split!(:defensive), do: :defensive
defp normalize_split!("defensive"), do: :defensive
defp normalize_split!(split) do
raise ArgumentError,
"imputed_splits must contain offensive or defensive, got: #{inspect(split)}"
end
defp split_label(:offensive), do: "OEPM"
defp split_label(:defensive), do: "DEPM"
defp fetch_number!(map, key) do
case fetch_any!(map, key) do
value when is_number(value) -> value
value -> raise ArgumentError, "#{key} must be numeric, got: #{inspect(value)}"
end
end
end
rotation_quality.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength/rotation_quality.ex
Scores AVC's Rotation Quality metric across the full 240-minute rotation.
Every player's Adjusted EPM is multiplied by the shared fatigue-adjusted
minutes curve, then by their season-availability factor
(
TradeMachine.GMGradeV2.Availability
—
1.0
unless the user set expected
games). The contributions are summed and divided by 240 before conversion
against the frozen
rotation_quality
benchmark.
The denominator stays 240 when a player is discounted. That is the point: a rotation slot that is empty for half the season really does lower the team's season-long per-minute strength, and renormalizing to the available minutes would divide the discount straight back out.
SHA-256 6d4478abe06a809e38405cfc764dee19af167b281f50640dddece4202b192567
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality do
@moduledoc """
Scores AVC's Rotation Quality metric across the full 240-minute rotation.
Every player's Adjusted EPM is multiplied by the shared fatigue-adjusted
minutes curve, then by their season-availability factor
(`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
games). The contributions are summed and divided by 240 before conversion
against the frozen `rotation_quality` benchmark.
The denominator stays 240 when a player is discounted. That is the point: a
rotation slot that is empty for half the season really does lower the team's
season-long per-minute strength, and renormalizing to the available minutes
would divide the discount straight back out.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Fatigue
alias TradeMachine.GMGradeV2.Percentile
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.RotationValidation
@doc "Returns a full-precision Rotation Quality metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{rotation: rotation} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
) do
entries = MetricInputs.rotation_entries(rotation)
impacts = ChallengeSnapshot.section(snapshot, :players)
names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
contributions = rotation_contributions(entries, impacts, names)
raw_value = average(contributions)
benchmark_values = MetricInputs.benchmark_values(benchmark, :rotation_quality)
assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes))
%MetricResult{
key: :rotation_quality,
version: benchmark.version,
score: Percentile.score(raw_value, benchmark_values),
inputs: %{
raw: raw_value,
assigned_minutes: assigned_minutes,
effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)),
denominator_minutes: RotationValidation.total_minutes(),
benchmark: benchmark_values
},
supporting: %{contributions: contributions},
weights: %{
minutes_1_36: 1.0,
minutes_37_40: 0.75,
minutes_41_44: 0.5,
minutes_45_48: 0.25
},
fallbacks: MetricInputs.impact_fallbacks(contributions, impacts),
explanation:
"Rotation Quality averages fatigue-adjusted impact across " <>
"#{length(contributions)} rotation players and #{assigned_minutes} assigned minutes."
}
end
@doc "Calculates Rotation Quality before percentile conversion."
@spec raw(term(), map()) :: float()
def raw(rotation, impacts) when is_map(impacts) do
rotation
|> MetricInputs.rotation_entries()
|> rotation_contributions(impacts, %{})
|> average()
end
defp rotation_contributions(entries, impacts, names) do
Enum.map(entries, fn entry ->
player_id = MetricInputs.player_id(entry)
assigned = MetricInputs.minutes(entry)
evidence = MetricInputs.player_impact!(impacts, player_id)
{availability, annotation} = MetricInputs.availability(entry, evidence)
adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
effective = Fatigue.effective_minutes(assigned)
Map.merge(
%{
player_id: player_id,
name: MetricInputs.player_name(names, player_id),
assigned_minutes: assigned,
effective_minutes: effective,
adjusted_epm: adjusted_epm,
contribution: Fatigue.weighted_contribution(adjusted_epm, assigned) * availability,
replacement?: false
},
annotation
)
end)
end
defp average(contributions) do
total = Enum.reduce(contributions, 0.0, &(&2 + &1.contribution))
total / RotationValidation.total_minutes()
end
end
star_power.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength/star_power.ex
Scores AVC's Star Power metric from a frozen scenario rotation.
Every player assigned more than zero minutes receives a role factor of
min(1, assigned_minutes / 30)
and their season-availability factor
(
TradeMachine.GMGradeV2.Availability
—
1.0
unless the user set expected
games). The three highest resulting Adjusted EPM contributions are weighted
55/30/15. When fewer than three players are eligible, each empty slot receives
the provisional replacement value at a full role factor (D-20).
Ranking reads the availability-weighted contribution, so a star the user expects to miss most of the season can fall out of the top three in favour of a teammate who will actually play — which is the honest answer to "who carries this roster", and the one thing a per-game rotation could not say before.
score/2
reads both player impacts and the benchmark distribution from the
challenge snapshot.
raw/2
is the shared pre-percentile calculator used by
the scenario scorer and, from Phase 08 T6, the benchmark builder.
SHA-256 172fde8e53d28c9da488223690bc913de391b077af44192654d0495e8c1412ba
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StarPower do
@moduledoc """
Scores AVC's Star Power metric from a frozen scenario rotation.
Every player assigned more than zero minutes receives a role factor of
`min(1, assigned_minutes / 30)` and their season-availability factor
(`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
games). The three highest resulting Adjusted EPM contributions are weighted
55/30/15. When fewer than three players are eligible, each empty slot receives
the provisional replacement value at a full role factor (D-20).
Ranking reads the availability-weighted contribution, so a star the user
expects to miss most of the season can fall out of the top three in favour of
a teammate who will actually play — which is the honest answer to "who carries
this roster", and the one thing a per-game rotation could not say before.
`score/2` reads both player impacts and the benchmark distribution from the
challenge snapshot. `raw/2` is the shared pre-percentile calculator used by
the scenario scorer and, from Phase 08 T6, the benchmark builder.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.ModelVersion
alias TradeMachine.GMGradeV2.Percentile
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
alias TradeMachine.GMGradeV2.Result.MetricResult
@role_minutes 30
@weights [0.55, 0.30, 0.15]
@type entry :: %{required(:player_id) => term(), required(:minutes) => number()}
@type impact :: %{required(:adjusted_epm) => number()}
@doc """
Returns a full-precision Star Power metric result.
`scenario_state` must carry the persisted rotation and its roster. Player
impact values come only from `snapshot.data["players"]`; the score is
percentiled only against the snapshot's frozen `star_power` array.
"""
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{rotation: rotation} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
) do
entries = MetricInputs.rotation_entries(rotation)
impacts = ChallengeSnapshot.section(snapshot, :players)
names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
replacement =
scenario_state
|> Map.get(:constants_override, %{})
|> ModelVersion.provisional_epm()
|> Map.fetch!(:overall)
contributions = ranked_contributions(entries, impacts, names, replacement)
raw_value = weighted_raw(contributions)
benchmark_values = MetricInputs.benchmark_values(benchmark, :star_power)
%MetricResult{
key: :star_power,
version: benchmark.version,
score: Percentile.score(raw_value, benchmark_values),
inputs: %{
raw: raw_value,
eligible_player_count: Enum.count(entries, &(entry_minutes(&1) > 0)),
role_factor_minutes: @role_minutes,
replacement_value: replacement,
benchmark: benchmark_values
},
supporting: %{contributions: contributions},
weights: %{first: 0.55, second: 0.30, third: 0.15},
fallbacks: fallbacks(contributions, impacts),
explanation: explanation(contributions)
}
end
@doc """
Calculates Star Power before percentile conversion.
Accepts either a rotation value understood by `ScenarioRotation.entries/1`,
a benchmark rotation carrying an `:entries` list, or the entry list itself.
`impacts` is keyed by player id and may contain canonical string-keyed frozen
values or in-memory atom-keyed `PlayerImpact.adjust/1` outputs.
"""
@spec raw(term(), %{required(term()) => impact() | map()}) :: float()
def raw(rotation, impacts) when is_map(impacts) do
replacement = ModelVersion.provisional_epm().overall
rotation
|> MetricInputs.rotation_entries()
|> ranked_contributions(impacts, %{}, replacement)
|> weighted_raw()
end
defp ranked_contributions(entries, impacts, names, replacement) do
players =
entries
|> Enum.filter(&(entry_minutes(&1) > 0))
|> Enum.map(&player_contribution(&1, impacts, names))
|> Enum.sort_by(&{-&1.contribution, to_string(&1.player_id)})
|> Enum.take(length(@weights))
players ++ replacement_contributions(length(@weights) - length(players), replacement)
end
defp player_contribution(entry, impacts, names) do
player_id = MetricInputs.player_id(entry)
assigned = MetricInputs.minutes(entry)
evidence = MetricInputs.player_impact!(impacts, player_id)
{availability, annotation} = MetricInputs.availability(entry, evidence)
adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
role_factor = min(1.0, assigned / @role_minutes)
Map.merge(
%{
player_id: player_id,
name: MetricInputs.player_name(names, player_id),
assigned_minutes: assigned,
adjusted_epm: adjusted_epm,
role_factor: role_factor,
contribution: adjusted_epm * role_factor * availability,
replacement?: false
},
annotation
)
end
defp replacement_contributions(count, replacement) do
for slot <- 1..count//1 do
%{
player_id: nil,
name: "Replacement-level slot #{slot}",
assigned_minutes: 30,
adjusted_epm: replacement,
role_factor: 1.0,
contribution: replacement * 1.0,
replacement?: true
}
end
end
defp weighted_raw(contributions) do
contributions
|> Enum.zip(@weights)
|> Enum.reduce(0.0, fn {contribution, weight}, total ->
total + contribution.contribution * weight
end)
end
defp fallbacks(contributions, impacts) do
impact_fallbacks = MetricInputs.impact_fallbacks(contributions, impacts)
replacement_fallbacks =
for contribution <- contributions, contribution.replacement? do
%{
input: :star_power_slot,
method: :provisional_replacement,
note:
"#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor."
}
end
impact_fallbacks ++ replacement_fallbacks
end
defp explanation(contributions) do
names = Enum.map_join(contributions, ", ", & &1.name)
"The top three Star Power contributions come from #{names}."
end
defp entry_minutes(entry), do: MetricInputs.minutes(entry)
end
starting_five.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength/starting_five.ex
Scores AVC's Starting Five Quality metric from the designated starters.
Each starter contributes Adjusted EPM multiplied by
min(1, assigned_minutes / 28)
and by their season-availability factor
(
TradeMachine.GMGradeV2.Availability
—
1.0
unless the user set expected
games). The five contributions are summed equally before conversion against
the frozen
starting_five
benchmark.
A starter the user expects to miss most of the season stays the designated starter and keeps their role factor: the discount is on the season value of the five names chosen, not a re-designation of who starts. Nobody is promoted into their place, because the rotation the user authored is the rotation being graded.
SHA-256 fe83867bd22ab02c1fc2916ad3979b95502db0e39d86611dc047441dbce57b19
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.StartingFive do
@moduledoc """
Scores AVC's Starting Five Quality metric from the designated starters.
Each starter contributes Adjusted EPM multiplied by
`min(1, assigned_minutes / 28)` and by their season-availability factor
(`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected
games). The five contributions are summed equally before conversion against
the frozen `starting_five` benchmark.
A starter the user expects to miss most of the season stays the designated
starter and keeps their role factor: the discount is on the season value of
the five names chosen, not a re-designation of who starts. Nobody is promoted
into their place, because the rotation the user authored is the rotation being
graded.
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.ModelVersion
alias TradeMachine.GMGradeV2.Percentile
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.RotationValidation
@role_minutes 28
@doc "Returns a full-precision Starting Five Quality metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{rotation: rotation} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
) do
entries = MetricInputs.rotation_entries(rotation)
impacts = ChallengeSnapshot.section(snapshot, :players)
names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
replacement =
scenario_state
|> Map.get(:constants_override, %{})
|> ModelVersion.provisional_epm()
|> Map.fetch!(:overall)
contributions = starter_contributions(entries, impacts, names, replacement)
raw_value = sum_contributions(contributions)
benchmark_values = MetricInputs.benchmark_values(benchmark, :starting_five)
%MetricResult{
key: :starting_five,
version: benchmark.version,
score: Percentile.score(raw_value, benchmark_values),
inputs: %{
raw: raw_value,
designated_starter_count: Enum.count(entries, &MetricInputs.starter?/1),
role_factor_minutes: @role_minutes,
replacement_value: replacement,
benchmark: benchmark_values
},
supporting: %{contributions: contributions},
weights: %{each_starter: 1.0},
fallbacks:
MetricInputs.impact_fallbacks(contributions, impacts) ++
replacement_fallbacks(contributions),
explanation: explanation(contributions)
}
end
@doc """
Calculates Starting Five Quality before percentile conversion.
This is also the benchmark builder's shared formula. A designated
zero-minute starter remains in the sum with a zero role factor.
"""
@spec raw(term(), map()) :: float()
def raw(rotation, impacts) when is_map(impacts) do
replacement = ModelVersion.provisional_epm().overall
rotation
|> MetricInputs.rotation_entries()
|> starter_contributions(impacts, %{}, replacement)
|> sum_contributions()
end
defp starter_contributions(entries, impacts, names, replacement) do
starters =
entries
|> Enum.filter(&MetricInputs.starter?/1)
|> Enum.take(RotationValidation.starter_count())
|> Enum.map(&player_contribution(&1, impacts, names))
starters ++
replacement_contributions(
RotationValidation.starter_count() - length(starters),
replacement
)
end
defp player_contribution(entry, impacts, names) do
player_id = MetricInputs.player_id(entry)
assigned = MetricInputs.minutes(entry)
evidence = MetricInputs.player_impact!(impacts, player_id)
{availability, annotation} = MetricInputs.availability(entry, evidence)
adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm)
role_factor = min(1.0, assigned / @role_minutes)
Map.merge(
%{
player_id: player_id,
name: MetricInputs.player_name(names, player_id),
assigned_minutes: assigned,
adjusted_epm: adjusted_epm,
role_factor: role_factor,
contribution: adjusted_epm * role_factor * availability,
replacement?: false
},
annotation
)
end
defp replacement_contributions(count, replacement) do
for slot <- 1..count//1 do
%{
player_id: nil,
name: "Replacement-level starter #{slot}",
assigned_minutes: @role_minutes,
adjusted_epm: replacement,
role_factor: 1.0,
contribution: replacement,
replacement?: true
}
end
end
defp sum_contributions(contributions) do
Enum.reduce(contributions, 0.0, &(&2 + &1.contribution))
end
defp replacement_fallbacks(contributions) do
for contribution <- contributions, contribution.replacement? do
%{
input: :starting_five_slot,
method: :provisional_replacement,
note:
"#{contribution.name} uses Adjusted EPM #{contribution.adjusted_epm} at a full role factor."
}
end
end
defp explanation(contributions) do
names = Enum.map_join(contributions, ", ", & &1.name)
"Starting Five Quality combines equal contributions from #{names}."
end
end
two_way_balance.exElixir
lib/trade_machine/gm_grade_v2/pillars/competitive_strength/two_way_balance.ex
Scores AVC's Two-Way Balance metric across the full 240-minute rotation.
Rotation OEPM and DEPM use the same fatigue-adjusted minutes and the same
season-availability factor as Rotation Quality
(
TradeMachine.GMGradeV2.Availability
), applied to both sides of a player's
split so availability cannot tilt the balance it is discounting. Each raw side
is converted independently against its frozen benchmark array before the
weaker side receives 70% of the final score and the stronger side receives
30%.
Offensive and Defensive Strength remain supporting scores. There is no combined Two-Way Balance raw benchmark because the combination happens after percentile conversion (D-43).
SHA-256 772da3cc7de6fd598db86d7ef06ff591e8846633da70e10224b8f47655fa9be6
defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.TwoWayBalance do
@moduledoc """
Scores AVC's Two-Way Balance metric across the full 240-minute rotation.
Rotation OEPM and DEPM use the same fatigue-adjusted minutes and the same
season-availability factor as Rotation Quality
(`TradeMachine.GMGradeV2.Availability`), applied to both sides of a player's
split so availability cannot tilt the balance it is discounting. Each raw side
is converted independently against its frozen benchmark array before the
weaker side receives 70% of the final score and the stronger side receives
30%.
Offensive and Defensive Strength remain supporting scores. There is no
combined Two-Way Balance raw benchmark because the combination happens after
percentile conversion (D-43).
"""
alias TradeMachine.GMGradeV2.Benchmark
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.Fatigue
alias TradeMachine.GMGradeV2.Percentile
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
alias TradeMachine.GMGradeV2.Result.MetricResult
alias TradeMachine.GMGradeV2.RotationValidation
@lower_weight 0.70
@higher_weight 0.30
@doc "Returns a full-precision Two-Way Balance metric result."
@spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t()
def score(
%{rotation: rotation} = scenario_state,
%ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
) do
entries = MetricInputs.rotation_entries(rotation)
impacts = ChallengeSnapshot.section(snapshot, :players)
names = MetricInputs.roster_names(Map.get(scenario_state, :roster, []))
contributions = rotation_contributions(entries, impacts, names)
raw_values = averages(contributions)
offense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_oepm)
defense_benchmark = MetricInputs.benchmark_values(benchmark, :rotation_depm)
offensive_strength = Percentile.score(raw_values.rotation_oepm, offense_benchmark)
defensive_strength = Percentile.score(raw_values.rotation_depm, defense_benchmark)
{lower, higher} = min_max(offensive_strength, defensive_strength)
assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes))
%MetricResult{
key: :two_way_balance,
version: benchmark.version,
score: lower * @lower_weight + higher * @higher_weight,
inputs: %{
rotation_oepm: raw_values.rotation_oepm,
rotation_depm: raw_values.rotation_depm,
assigned_minutes: assigned_minutes,
effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)),
denominator_minutes: RotationValidation.total_minutes(),
offense_benchmark: offense_benchmark,
defense_benchmark: defense_benchmark
},
supporting: %{
offensive_strength: offensive_strength,
defensive_strength: defensive_strength,
contributions: contributions
},
weights: %{lower_side: @lower_weight, higher_side: @higher_weight},
fallbacks:
MetricInputs.impact_fallbacks(
contributions,
impacts,
"Adjusted OEPM and DEPM",
include_imputed_splits?: true
),
explanation: explanation(offensive_strength, defensive_strength, assigned_minutes)
}
end
@doc """
Calculates fatigue-adjusted Rotation OEPM and DEPM before percentile conversion.
The two raw values are kept separate because Two-Way Balance combines their
percentile scores, not their EPM-scale values.
"""
@spec raw(term(), map()) :: %{rotation_oepm: float(), rotation_depm: float()}
def raw(rotation, impacts) when is_map(impacts) do
rotation
|> MetricInputs.rotation_entries()
|> rotation_contributions(impacts, %{})
|> averages()
end
defp rotation_contributions(entries, impacts, names) do
Enum.map(entries, fn entry ->
player_id = MetricInputs.player_id(entry)
assigned = MetricInputs.minutes(entry)
evidence = MetricInputs.player_impact!(impacts, player_id)
{availability, annotation} = MetricInputs.availability(entry, evidence)
adjusted_oepm = MetricInputs.impact_number!(impacts, player_id, :adjusted_oepm)
adjusted_depm = MetricInputs.impact_number!(impacts, player_id, :adjusted_depm)
effective = Fatigue.effective_minutes(assigned)
Map.merge(
%{
player_id: player_id,
name: MetricInputs.player_name(names, player_id),
assigned_minutes: assigned,
effective_minutes: effective,
adjusted_oepm: adjusted_oepm,
adjusted_depm: adjusted_depm,
offensive_contribution: adjusted_oepm * effective * availability,
defensive_contribution: adjusted_depm * effective * availability,
replacement?: false
},
annotation
)
end)
end
defp averages(contributions) do
{offense, defense} =
Enum.reduce(contributions, {0.0, 0.0}, fn contribution, {offense, defense} ->
{
offense + contribution.offensive_contribution,
defense + contribution.defensive_contribution
}
end)
denominator = RotationValidation.total_minutes()
%{rotation_oepm: offense / denominator, rotation_depm: defense / denominator}
end
defp min_max(first, second) when first <= second, do: {first, second}
defp min_max(first, second), do: {second, first}
defp explanation(offensive_strength, defensive_strength, assigned_minutes) do
weaker_side =
if offensive_strength <= defensive_strength, do: "offense", else: "defense"
"Two-Way Balance weights the weaker #{weaker_side} side at 70% across " <>
"#{assigned_minutes} assigned rotation minutes."
end
end