Plausibility & progression
Canonical penalties and caps, separated from shadow improvement experiments.
Canonical plausibility
Plausibility findings are assessed against frozen evidence and associated with scenario moves. The canonical grade subtracts the combined penalty and then applies the score cap. These adjustments operate after the six-pillar composite.
final = max(0, min(100, min(base − penalty, cap or 100)))
Progression is a separate experiment
The pair grader can attach a versioned Progression result when requested. It does not replace the canonical final score. Comparable State Score neutralizes transaction-path exchange evidence for starting/final comparison; it must not be confused with the canonical Asset Management calculation.
Evaluate before promotion
Treat shadow results as research. Compare directional examples, inspect outliers and data quality, and validate against held-out human judgments before changing the model users are scored by.
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.
plausibility.exElixir
lib/trade_machine/gm_grade_v2/plausibility.ex
Deterministic contract and trade plausibility for the final scenario.
Every constant in this module is an AVC v1 calibration default (D-28). Contract realism compares first-season salary with the frozen expected-salary evidence. The comparison uses:
adjusted_expected = actual + confidence * (expected - actual)
ratio = actual / adjusted_expected
effective_threshold = published_threshold * confidence
Multiplying thresholds by confidence widens the acceptable range when the market estimate is uncertain. Findings originating in Asset Exchange Value are retained for explanation but are already capped there and never contribute another grade-level penalty or cap.
SHA-256 ce9df3f6e8b31f01216a4a688cf87e84b4453bc18beb0d81febf4efc0c345db9
defmodule TradeMachine.GMGradeV2.Plausibility do
@moduledoc """
Deterministic contract and trade plausibility for the final scenario.
Every constant in this module is an AVC v1 calibration default (D-28).
Contract realism compares first-season salary with the frozen expected-salary
evidence. The comparison uses:
adjusted_expected = actual + confidence * (expected - actual)
ratio = actual / adjusted_expected
effective_threshold = published_threshold * confidence
Multiplying thresholds by confidence widens the acceptable range when the
market estimate is uncertain. Findings originating in Asset Exchange Value
are retained for explanation but are already capped there and never contribute
another grade-level penalty or cap.
"""
alias TradeMachine.CBA.Exceptions
alias TradeMachine.CBA.Rules
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.ExpectedSalary
alias TradeMachine.GMGradeV2.MaterialFinding
alias TradeMachine.GMGradeV2.Result.PillarResult
@unlikely_penalty 5.0
@maximum_penalty 10.0
@unlikely_cap 65.0
@unrealistic_cap 40.0
@material_severities [:aggressive, :unlikely, :unrealistic]
@type finding :: %{
origin: :aev_transaction | :contract | :trade,
move_ref: String.t() | nil,
transaction_type: atom(),
display_label: String.t(),
teams: [String.t()],
assets: [map()],
severity: atom(),
reason_category: atom(),
subject: term(),
evidence: map(),
message: String.t(),
explanation: String.t()
}
@doc "Normalizes a current or historical persisted plausibility finding."
@spec normalize_finding(map()) :: MaterialFinding.t()
def normalize_finding(finding), do: MaterialFinding.normalize(finding)
@doc "Matches findings to normalized moves and freezes the user-visible material subset."
@spec integrate_moves(map(), [map()]) :: map()
def integrate_moves(plausibility, moves) when is_map(plausibility) and is_list(moves) do
findings =
plausibility
|> field(:findings, [])
|> list()
|> Enum.map(&match_move(&1, moves))
material_findings = Enum.filter(findings, &material_finding?/1)
plausibility
|> Map.put(:findings, findings)
|> Map.put(:material_findings, material_findings)
|> Map.put(:material_concern_count, length(material_findings))
end
@doc "Returns the same material finding list used for the aggregate concern count."
@spec material_findings(map()) :: [MaterialFinding.t()]
def material_findings(plausibility) when is_map(plausibility) do
case field(plausibility, :material_findings, :missing) do
findings when is_list(findings) ->
Enum.map(findings, &MaterialFinding.normalize/1)
:missing ->
plausibility
|> field(:findings, [])
|> list()
|> Enum.map(&MaterialFinding.normalize/1)
|> Enum.filter(&material_finding?/1)
_other ->
[]
end
end
def material_findings(_plausibility), do: []
@doc "Counts exactly the material findings returned by `material_findings/1`."
@spec material_concern_count(map()) :: non_neg_integer()
def material_concern_count(plausibility), do: plausibility |> material_findings() |> length()
@doc """
Assesses the final scenario against frozen evidence.
`pillar_results` is optional so callers assessing contract realism alone do
not need to manufacture Asset Management output.
"""
@spec assess(map(), ChallengeSnapshot.t(), [PillarResult.t()]) :: %{
findings: [finding()],
penalty: float(),
cap: float() | nil
}
def assess(scenario_state, snapshot, pillar_results \\ [])
def assess(scenario_state, %ChallengeSnapshot{} = snapshot, pillar_results)
when is_map(scenario_state) and is_list(pillar_results) do
findings =
contract_findings(scenario_state, snapshot) ++ aev_findings(pillar_results)
grade_findings = Enum.filter(findings, &(&1.origin in [:contract, :trade]))
penalty =
grade_findings
|> Enum.count(&(&1.severity == :unlikely))
|> Kernel.*(@unlikely_penalty)
|> min(@maximum_penalty)
cap =
grade_findings
|> Enum.flat_map(fn
%{severity: :unrealistic} -> [@unrealistic_cap]
%{severity: :unlikely} -> [@unlikely_cap]
_ -> []
end)
|> minimum_cap()
%{findings: findings, penalty: penalty, cap: cap}
end
defp contract_findings(scenario_state, snapshot) do
modifications = Map.get(scenario_state, :modifications, %{}) || %{}
season = Map.fetch!(scenario_state, :season)
expected = ChallengeSnapshot.section(snapshot, :expected_salaries)
signing_entries(modifications, season)
|> Enum.map(fn signing ->
contract_finding(
signing,
expected,
veteran_minimum(scenario_state, snapshot, signing.season)
)
end)
end
defp contract_finding(signing, expected_section, veteran_minimum) do
with true <- is_number(signing.salary) and signing.salary >= 0,
true <- is_number(veteran_minimum) and veteran_minimum > 0,
{:ok, expectation} <-
ExpectedSalary.season_expectation(expected_section, signing.player_id, signing.season) do
cond do
expectation.confidence == 0 ->
finding(
:contract,
:insufficient_evidence,
:insufficient_evidence,
signing,
%{
player_id: signing.player_id,
season: signing.season,
confidence: expectation.confidence
},
"Frozen expected-salary evidence has zero confidence for #{signing.subject}; no plausibility penalty applies."
)
expectation.expected <= 2 * veteran_minimum ->
finding(
:contract,
:plausible,
:minimum_salary_market,
signing,
%{
exemption: :minimum_salary_market,
actual_salary: signing.salary,
expected_salary: expectation.expected,
veteran_minimum: veteran_minimum
},
"#{signing.subject} is exempt because the expected salary is within twice the veteran minimum."
)
true ->
adjusted =
ExpectedSalary.confidence_adjusted(
signing.salary,
expectation.expected,
expectation.confidence
)
ratio = if adjusted > 0, do: signing.salary / adjusted, else: 0.0
severity = severity(ratio, expectation.confidence)
finding(
:contract,
severity,
:salary_below_expected,
signing,
%{
player_id: signing.player_id,
season: signing.season,
actual_salary: signing.salary,
expected_salary: expectation.expected,
confidence: expectation.confidence,
confidence_adjusted_expected: adjusted,
ratio: ratio,
effective_thresholds: effective_thresholds(expectation.confidence)
},
"#{signing.subject}'s first-season salary is #{ratio_text(ratio)} of the confidence-adjusted frozen expectation."
)
end
else
false ->
finding(
:contract,
:insufficient_evidence,
:insufficient_evidence,
signing,
%{player_id: signing.player_id, season: signing.season, salary: signing.salary},
"Salary or veteran-minimum evidence is unavailable for #{signing.subject}; no plausibility penalty applies."
)
{:error, :unavailable} ->
finding(
:contract,
:insufficient_evidence,
:insufficient_evidence,
signing,
%{player_id: signing.player_id, season: signing.season},
"Frozen expected-salary evidence is unavailable for #{signing.subject}; no plausibility penalty applies."
)
end
end
defp severity(ratio, confidence) do
thresholds = effective_thresholds(confidence)
cond do
ratio >= thresholds.plausible -> :plausible
ratio >= thresholds.aggressive -> :aggressive
ratio >= thresholds.unlikely -> :unlikely
true -> :unrealistic
end
end
defp effective_thresholds(confidence) do
%{
plausible: 0.80 * confidence,
aggressive: 0.60 * confidence,
unlikely: 0.40 * confidence
}
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp signing_entries(modifications, season) do
re_signings =
modifications
|> Map.get("re_signings", %{})
|> Enum.map(fn {player_id, entry} ->
signing(
:re_signing,
player_id,
entry["name"] || player_id || "Unknown re-signing",
entry["signing_season"] || List.first(Rules.future_seasons()) || season,
entry["salary"]
)
end)
one_year =
modifications
|> Map.get("added_players", [])
|> Enum.map(fn entry ->
signing(
:signing,
entry["id"],
entry["name"] || entry["id"] || "Unknown signing",
season,
entry["salary"]
)
end)
multi_year =
modifications
|> Map.get("payroll_added_players", [])
|> Enum.reject(&(&1["is_draft_pick"] == true))
|> Enum.map(fn entry ->
signing_season = entry["signing_season"] || List.first(Rules.future_seasons()) || season
salaries = entry["salaries"] || %{}
signing(
:signing,
entry["id"],
entry["name"] || entry["id"] || "Unknown signing",
signing_season,
Map.get(salaries, signing_season) || earliest_salary(salaries)
)
end)
(re_signings ++ one_year ++ multi_year)
|> Enum.sort_by(&{&1.subject, &1.player_id, &1.season})
end
defp signing(transaction_type, player_id, subject, season, salary) do
%{
transaction_type: transaction_type,
move_ref: MaterialFinding.move_ref(transaction_type, player_id),
player_id: player_id,
subject: subject,
season: season,
salary: salary
}
end
defp veteran_minimum(scenario_state, snapshot, season) do
frozen =
snapshot
|> ChallengeSnapshot.section(:thresholds)
|> threshold_minimum(season)
if is_number(frozen) and frozen > 0 do
frozen
else
scenario_state
|> Map.get(:thresholds_by_season, %{})
|> Map.get(season)
|> case do
nil -> nil
thresholds -> Exceptions.minimum_salary(thresholds, 10)
end
end
end
defp earliest_salary(salaries) when is_map(salaries) do
salaries
|> Enum.filter(fn {season, salary} -> is_binary(season) and is_number(salary) end)
|> Enum.min_by(fn {season, _salary} -> season end, fn -> {nil, nil} end)
|> elem(1)
end
defp threshold_minimum(thresholds, season) do
with season_thresholds when is_map(season_thresholds) <- Map.get(thresholds, season),
minimums when is_map(minimums) <- Map.get(season_thresholds, "minimum_salaries"),
values = Enum.filter(Map.values(minimums), &is_number/1),
true <- values != [] do
Map.get(minimums, "10") || Enum.max(values)
else
_ -> nil
end
end
defp aev_findings(pillar_results) do
with %PillarResult{} = pillar <-
Enum.find(pillar_results, &(&1.key == :asset_management)),
metric when not is_nil(metric) <-
Enum.find(pillar.metric_results, &(&1.key == :asset_exchange_value)) do
metric.diagnostics
|> Map.get(:plausibility_findings, [])
|> Enum.map(fn source ->
transaction_id = Map.get(source, :transaction_id) || Map.get(source, "transaction_id")
severity = Map.get(source, :severity) || Map.get(source, "severity") || :unlikely
source
|> Map.merge(%{
origin: :aev_transaction,
move_ref: MaterialFinding.move_ref(:trade, transaction_id),
transaction_type: :trade,
display_label: "Trade #{transaction_id}",
severity: severity,
reason_category: :asset_value_outlier,
subject: transaction_id,
evidence: source
})
|> MaterialFinding.normalize()
end)
else
_ -> []
end
end
defp match_move(finding, moves) do
normalized = MaterialFinding.normalize(finding)
case matching_move(normalized, moves) do
nil ->
normalized
move ->
normalized
|> Map.put(:move_ref, field(move, :move_ref, normalized.move_ref))
|> Map.put(:transaction_type, field(move, :transaction_type, normalized.transaction_type))
|> Map.put(:display_label, field(move, :display_label, normalized.display_label))
|> Map.put(:teams, preferred_context(field(move, :teams, []), normalized.teams))
|> Map.put(:assets, preferred_context(field(move, :assets, []), normalized.assets))
|> Map.put(:move_summary, move)
end
end
defp matching_move(finding, moves) do
exact_matches =
case field(finding, :move_ref) do
move_ref when is_binary(move_ref) and move_ref != "" ->
Enum.filter(moves, &(field(&1, :move_ref) == move_ref))
_other ->
[]
end
case exact_matches do
[move] -> move
[] -> legacy_matching_move(finding, moves)
_ambiguous -> nil
end
end
# Historical findings may predate stable move references. Match them only
# when their recorded type plus label/subject identifies exactly one move.
defp legacy_matching_move(finding, moves) do
type = field(finding, :transaction_type)
labels = [field(finding, :display_label), field(finding, :subject)]
candidates =
Enum.filter(moves, fn move ->
type_matches?(type, field(move, :transaction_type)) and
Enum.any?(labels, &same_label?(&1, field(move, :display_label)))
end)
case candidates do
[move] -> move
_other -> nil
end
end
defp type_matches?(type, _move_type) when type in [nil, :unknown, "unknown"], do: true
defp type_matches?(type, move_type), do: to_string(type) == to_string(move_type)
defp same_label?(label, move_label) when is_binary(label) and is_binary(move_label),
do: label != "" and String.downcase(label) == String.downcase(move_label)
defp same_label?(_label, _move_label), do: false
defp preferred_context(value, _fallback) when is_list(value) and value != [], do: value
defp preferred_context(_value, fallback), do: fallback
defp material_finding?(finding) do
severity = field(finding, :severity)
severity in @material_severities or severity in Enum.map(@material_severities, &to_string/1)
end
defp field(map, key, default \\ nil),
do: Map.get(map, key, Map.get(map, Atom.to_string(key), default))
defp list(value) when is_list(value), do: value
defp list(_value), do: []
defp finding(origin, severity, reason_category, signing, evidence, message) do
MaterialFinding.new(%{
origin: origin,
move_ref: signing.move_ref,
transaction_type: signing.transaction_type,
display_label: signing.subject,
teams: [],
assets: [
%{type: :player, id: signing.player_id, label: signing.subject}
],
severity: severity,
reason_category: reason_category,
subject: signing.subject,
evidence: evidence,
message: message,
explanation: message
})
end
defp minimum_cap([]), do: nil
defp minimum_cap(caps), do: Enum.min(caps)
defp ratio_text(ratio), do: "#{Float.round(ratio * 100, 1)}%"
end
grader.exElixir
lib/trade_machine/gm_grade_v2/grader.ex
Pure end-to-end AVC GM grader.
Pillars and plausibility retain full precision. Display rounding occurs only
when
TradeMachine.GMGradeV2.Result.GradeResult.finalize/1
is called.
SHA-256 8ed93893b8dec3125ee587d15ebf1a061e2157566f823243ad7fb9986218bd5f
defmodule TradeMachine.GMGradeV2.Grader do
@moduledoc """
Pure end-to-end AVC GM grader.
Pillars and plausibility retain full precision. Display rounding occurs only
when `TradeMachine.GMGradeV2.Result.GradeResult.finalize/1` is called.
"""
alias TradeMachine.GMGradeV2.CapState
alias TradeMachine.GMGradeV2.ChallengeSnapshot
alias TradeMachine.GMGradeV2.ComparableState
alias TradeMachine.GMGradeV2.ModelVersion
alias TradeMachine.GMGradeV2.MoveSummary
alias TradeMachine.GMGradeV2.Narrative
alias TradeMachine.GMGradeV2.Plausibility
alias TradeMachine.GMGradeV2.Progression
alias TradeMachine.GMGradeV2.Pillars.AssetManagement
alias TradeMachine.GMGradeV2.Pillars.CapFlexibility
alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength
alias TradeMachine.GMGradeV2.Pillars.ContractHealth
alias TradeMachine.GMGradeV2.Pillars.RosterConstruction
alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment
alias TradeMachine.GMGradeV2.Result.GradeResult
alias TradeMachine.GMGradeV2.Result.PillarResult
@weight_tolerance 1.0e-9
@pillar_order [
:competitive_strength,
:roster_construction,
:contract_health,
:cap_flexibility,
:asset_management,
:timeline_alignment
]
@doc """
Runs all six pillar aggregators and returns an unrounded grade.
The assembled state must include `:asset_metric_results`; callers that begin
with a persisted scenario should use `ScenarioAssembly.grade/2`.
"""
@spec grade(map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t()
def grade(scenario_state, %ChallengeSnapshot{} = snapshot, mandate_profile) do
grade(scenario_state, snapshot, mandate_profile, [])
end
@doc "Grades with optional lab-only `:constants_override` values."
@spec grade(map(), ChallengeSnapshot.t(), struct(), keyword()) :: GradeResult.t()
def grade(scenario_state, %ChallengeSnapshot{} = snapshot, mandate_profile, opts)
when is_list(opts) do
scenario_state =
Map.put(
scenario_state,
:constants_override,
Keyword.get(opts, :constants_override, %{})
)
pillar_results = score_pillars(scenario_state, snapshot, mandate_profile)
aggregate(pillar_results, scenario_state, snapshot, mandate_profile)
end
@doc """
Aggregates already-computed pillar results.
This is the narrow unit-test seam for the final arithmetic; production uses
`grade/3`, which always computes the six pillars.
"""
@spec aggregate([PillarResult.t()], map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t()
def aggregate(pillar_results, scenario_state, snapshot, mandate_profile)
when is_list(pillar_results) do
weights = mandate_profile.pillar_weights
base_score = composite_score(pillar_results, weights)
moves = build_moves([scenario_state])
plausibility =
scenario_state
|> Plausibility.assess(snapshot, pillar_results)
|> Plausibility.integrate_moves(moves)
final_score = apply_plausibility(base_score, plausibility)
result = %GradeResult{
model_version: ModelVersion.model_version(),
mandate_key: mandate_profile.key,
mandate_profile_version: mandate_profile.version,
pillar_results: pillar_results,
pillar_weights: weights,
base_score: base_score,
plausibility: plausibility,
final_score: final_score,
validation_status: validation_status(pillar_results, scenario_state),
snapshot_refs: snapshot_refs(snapshot, mandate_profile, scenario_state)
}
%{result | narrative: Narrative.build(result, mandate_profile, moves)}
end
@doc "Applies penalty, then the minimum cap, and clamps once at full precision."
@spec apply_plausibility(number(), %{penalty: number(), cap: number() | nil}) :: float()
def apply_plausibility(base_score, plausibility) when is_number(base_score) do
base_score
|> Kernel.-(Map.fetch!(plausibility, :penalty))
|> min(Map.get(plausibility, :cap) || 100.0)
|> max(0.0)
|> min(100.0)
|> Kernel.*(1.0)
end
@doc """
Computes the canonical mandate-weighted composite for six pillar results.
This is also the reusable arithmetic boundary for projections that retain
AVC's pillar weights while deliberately omitting the plausibility layer.
"""
@spec composite_score([PillarResult.t()], %{atom() => number()}) :: float()
def composite_score(pillar_results, weights)
when is_list(pillar_results) and is_map(weights) do
assert_weights!(weights)
assert_pillars!(pillar_results, weights)
pillar_results
|> Enum.sort_by(&pillar_order!/1)
|> Enum.reduce(0.0, fn pillar, total ->
total + pillar.score * Map.fetch!(weights, pillar.key)
end)
|> Kernel.*(1.0)
end
@doc """
Grades starting and final states against identical frozen evidence.
The returned result is the final grade. Starting scores and pillar/metric
deltas are explanatory fields and never enter the composite.
"""
@spec grade_pair(map(), map(), ChallengeSnapshot.t(), struct()) :: GradeResult.t()
def grade_pair(starting_state, final_state, snapshot, mandate_profile) do
grade_pair(starting_state, final_state, snapshot, mandate_profile, [])
end
@doc """
Grades a state pair with optional lab-only overrides.
The separately versioned Progression result is opt-in shadow data. Pass
`progression?: true` from an explicitly authorized orchestration boundary;
the canonical grade is identical either way.
"""
@spec grade_pair(map(), map(), ChallengeSnapshot.t(), struct(), keyword()) :: GradeResult.t()
def grade_pair(starting_state, final_state, snapshot, mandate_profile, opts)
when is_list(opts) do
starting = grade(starting_state, snapshot, mandate_profile, opts)
final = grade(final_state, snapshot, mandate_profile, opts)
starting_by_key = Map.new(starting.pillar_results, &{&1.key, &1})
pillar_results =
Enum.map(final.pillar_results, fn result ->
starting_result = Map.fetch!(starting_by_key, result.key)
%{
result
| starting_score: starting_result.score,
delta: result.score - starting_result.score
}
end)
starting_scores = Map.new(starting.pillar_results, &{&1.key, &1.score})
deltas =
Map.new(pillar_results, fn result ->
starting_result = Map.fetch!(starting_by_key, result.key)
{result.key,
%{
score: result.delta,
metrics: metric_deltas(starting_result, result)
}}
end)
progression =
if Keyword.get(opts, :progression?, false) do
progression(starting, final, mandate_profile, opts)
end
result = %{
final
| pillar_results: pillar_results,
starting: starting_scores,
deltas: deltas,
progression: progression
}
%{
result
| narrative: build_narrative(result, mandate_profile, [starting_state, final_state])
}
end
defp build_narrative(result, mandate_profile, states) do
Narrative.build(result, mandate_profile, build_moves(states))
end
defp build_moves(states) do
final_state = List.last(states) || %{}
final_state
|> Map.get(:modifications, %{})
|> MoveSummary.build(players_by_id(states))
end
defp players_by_id(states) do
states
|> Enum.flat_map(&Map.get(&1, :roster, []))
|> Enum.reduce(%{}, fn player, players ->
case Map.get(player, :id) || Map.get(player, "id") do
nil -> players
id -> Map.put(players, id, player)
end
end)
end
defp progression(starting, final, mandate_profile, opts) do
progression_opts = [model_override: Keyword.get(opts, :progression_override, %{})]
progression_opts =
case Keyword.get(opts, :progression_policy) do
nil -> progression_opts
policy -> Keyword.put(progression_opts, :comparison_policy, policy)
end
case ComparableState.project_pair(starting, final) do
{:ok, comparable} ->
Progression.evaluate(
mandate_profile.key,
comparable.baseline.score,
comparable.final.score,
final.final_score,
progression_opts ++
[
pillar_deltas: comparable.pillar_deltas,
metric_deltas: comparable.metric_deltas
]
)
{:error, reason} ->
Progression.unavailable(
mandate_profile.key,
final.final_score,
reason,
progression_opts
)
end
end
defp score_pillars(state, snapshot, _mandate_profile) do
unless Map.has_key?(state, :asset_metric_results) do
raise ArgumentError,
"Grader.grade/3 requires an assembled state with :asset_metric_results; " <>
"use ScenarioAssembly.grade/2 for persisted scenarios"
end
cap_state =
Map.get_lazy(state, :cap_state, fn -> CapState.build(state, season_start!(snapshot)) end)
asset_results =
Map.fetch!(state, :asset_metric_results)
[
CompetitiveStrength.score(state, snapshot),
RosterConstruction.score(state, snapshot),
ContractHealth.score(state, snapshot),
CapFlexibility.score(cap_state),
AssetManagement.score(asset_results),
TimelineAlignment.score(state, snapshot)
]
end
defp season_start!(snapshot) do
snapshot
|> ChallengeSnapshot.section(:season)
|> Map.fetch!("start_date")
|> Date.from_iso8601!()
end
defp assert_weights!(weights) do
sum = weights |> Map.values() |> Enum.sum()
if abs(sum - 1.0) > @weight_tolerance do
raise ArgumentError, "mandate pillar weights sum to #{sum}, not 1.0"
end
end
defp assert_pillars!(results, weights) do
result_keys = results |> Enum.map(& &1.key) |> MapSet.new()
weight_keys = weights |> Map.keys() |> MapSet.new()
if length(results) != map_size(weights) or result_keys != weight_keys do
raise ArgumentError,
"pillar results must contain exactly #{inspect(Map.keys(weights))}; got " <>
inspect(Enum.map(results, & &1.key))
end
end
defp validation_status(results, state) do
if Enum.any?(results, & &1.provisional?) or
Map.get(state, :provisional_player_ids, []) != [] do
:provisional
else
:ok
end
end
# `evidence_source` is `nil` for a hand-assembled state, which is the honest
# answer: nothing accounted for where its evidence came from. Only
# `ScenarioAssembly` can say, and it always does.
defp snapshot_refs(snapshot, profile, state) do
benchmark = snapshot.benchmark
%{
benchmark_id: benchmark.id,
benchmark_version: benchmark.version,
snapshot_id: snapshot.id,
model_version: ModelVersion.model_version(),
model_versions: ChallengeSnapshot.section(snapshot, :model_versions),
mandate_key: profile.key,
mandate_profile_version: profile.version,
provisional_player_ids: Map.get(state, :provisional_player_ids, []),
starting_rotation_fallback: Map.get(state, :starting_rotation_fallback),
evidence_source: Map.get(state, :evidence_source),
live_evidence: Map.get(state, :live_evidence, [])
}
end
defp metric_deltas(starting, final) do
starting_metrics = Map.new(starting.metric_results, &{&1.key, &1.score})
Map.new(final.metric_results, fn metric ->
delta =
case Map.fetch(starting_metrics, metric.key) do
{:ok, starting_score} -> metric.score - starting_score
:error -> nil
end
{metric.key, delta}
end)
end
defp pillar_order!(%PillarResult{key: key}) do
case Enum.find_index(@pillar_order, &(&1 == key)) do
nil -> raise ArgumentError, "unknown pillar key #{inspect(key)}"
index -> index
end
end
end
progression.exElixir
lib/trade_machine/gm_grade_v2/progression.ex
Pure evaluator for the separately versioned scenario Progression layer.
The inherited and final inputs are Comparable State Scores: canonical pillar results projected with AEV neutralized and plausibility excluded. The canonical final GM Grade is a separate input used only as the base for the bounded adjustment.
SHA-256 86e37ce0adc18d5cf488740e2da1b8b904c6c3c1a63dd5013d575ac3155e2569
defmodule TradeMachine.GMGradeV2.Progression do
@moduledoc """
Pure evaluator for the separately versioned scenario Progression layer.
The inherited and final inputs are Comparable State Scores: canonical pillar
results projected with AEV neutralized and plausibility excluded. The
canonical final GM Grade is a separate input used only as the base for the
bounded adjustment.
"""
alias TradeMachine.GMGradeV2.ModelVersion
alias TradeMachine.GMGradeV2.Result.ProgressionResult
@policies [:longitudinal, :maintenance, :episodic]
@doc """
Evaluates a mandate curve over two Comparable State Scores.
Product-v0 Retool and Selling are neutral by default. A Lab may explicitly
model Retool with `comparison_policy: :longitudinal`; Selling is always
`:not_applicable` with an exact `1.0` multiplier.
"""
@spec evaluate(
atom(),
number() | nil,
number() | nil,
number(),
keyword() | map()
) :: ProgressionResult.t()
def evaluate(
mandate_key,
baseline_comparable_score,
final_comparable_score,
canonical_final_grade,
opts \\ []
) do
config = ModelVersion.progression_config(option(opts, :model_override, %{}))
policy = option(opts, :comparison_policy, default_policy(config, mandate_key))
with :ok <- validate_root_config(config),
:ok <- validate_mandate(config, mandate_key),
:ok <- validate_score(canonical_final_grade, :invalid_canonical_final_grade),
:ok <- validate_policy(policy) do
evaluate_validated(
config,
mandate_key,
baseline_comparable_score,
final_comparable_score,
canonical_final_grade * 1.0,
policy,
opts
)
else
{:error, reason} ->
invalid_result(config, mandate_key, canonical_final_grade, policy, reason)
end
end
@doc "Builds an exact-neutral unavailable result for a malformed grading pair."
@spec unavailable(atom(), number(), atom(), keyword() | map()) :: ProgressionResult.t()
def unavailable(mandate_key, canonical_final_grade, reason, opts \\ []) do
config = ModelVersion.progression_config(option(opts, :model_override, %{}))
policy = option(opts, :comparison_policy, default_policy(config, mandate_key))
neutral_result(
config,
mandate_key,
canonical_final_grade * 1.0,
policy,
:unavailable,
reason,
invalid_reason(reason)
)
end
defp evaluate_validated(
config,
:selling,
_baseline_comparable,
_final_comparable,
canonical_final,
policy,
_opts
) do
neutral_result(
config,
:selling,
canonical_final,
policy,
:not_applicable,
:selling_has_zero_sensitivity,
"Selling is not applicable to product-v0 scenario progression; its multiplier is exactly 1.0."
)
end
defp evaluate_validated(
config,
:retool,
_baseline_comparable,
_final_comparable,
canonical_final,
policy,
_opts
)
when policy != :longitudinal do
neutral_result(
config,
:retool,
canonical_final,
policy,
:not_applicable,
:retool_requires_longitudinal_policy,
"Retool is not applicable in product v0; only a Lab-only longitudinal experiment may apply its curve."
)
end
defp evaluate_validated(
config,
mandate,
nil,
_final_comparable,
canonical_final,
policy,
_opts
) do
neutral_result(
config,
mandate,
canonical_final,
policy,
:unavailable,
:baseline_comparable_score_missing,
"An inherited Comparable State Score is required for this mandate."
)
end
defp evaluate_validated(
config,
mandate,
_baseline_comparable,
nil,
canonical_final,
policy,
_opts
) do
neutral_result(
config,
mandate,
canonical_final,
policy,
:unavailable,
:final_comparable_score_missing,
"A final Comparable State Score is required for this mandate."
)
end
defp evaluate_validated(
config,
mandate,
baseline_comparable,
final_comparable,
canonical_final,
policy,
opts
) do
with :ok <-
validate_score(baseline_comparable, :invalid_baseline_comparable_score),
:ok <- validate_score(final_comparable, :invalid_final_comparable_score) do
apply_progression(
config,
mandate,
baseline_comparable * 1.0,
final_comparable * 1.0,
canonical_final,
policy,
opts
)
else
{:error, reason} ->
invalid_result(config, mandate, canonical_final, policy, reason)
end
end
defp apply_progression(
config,
mandate,
baseline_comparable,
final_comparable,
canonical_final,
policy,
opts
) do
mandate_config = get_in(config, [:mandates, mandate])
if valid_mandate_config?(mandate_config) do
comparable_delta = final_comparable - baseline_comparable
target_change = target_change(mandate_config.target, baseline_comparable)
signal = :math.tanh((comparable_delta - target_change) / mandate_config.softness)
sensitivity =
if signal >= 0.0,
do: mandate_config.upside_sensitivity,
else: mandate_config.downside_sensitivity
multiplier_bounds = %{
min: 1.0 - mandate_config.downside_sensitivity,
max: 1.0 + mandate_config.upside_sensitivity
}
raw_multiplier =
(1.0 + sensitivity * signal)
|> clamp(multiplier_bounds.min, multiplier_bounds.max)
raw_adjustment = canonical_final * (raw_multiplier - 1.0)
bounded_adjustment =
clamp(
raw_adjustment,
-mandate_config.downside_point_cap,
mandate_config.upside_point_cap
)
multiplier =
if canonical_final == 0.0,
do: 1.0,
else: 1.0 + bounded_adjustment / canonical_final
proposed_grade = clamp(canonical_final + bounded_adjustment, 0.0, 100.0)
%ProgressionResult{
status: :applied,
reason: :progression_applied,
reasons: [applied_reason(mandate, comparable_delta, target_change)],
model_version: config.version,
mandate_key: mandate,
comparison_policy: policy,
curve_key: mandate_config.curve_key,
curve_version: config.version,
baseline_comparable_score: baseline_comparable,
final_comparable_score: final_comparable,
canonical_final_grade: canonical_final,
comparable_delta: comparable_delta,
target_change: target_change,
curve_softness: mandate_config.softness,
progress_signal: signal,
applied_sensitivity: sensitivity,
multiplier_bounds: multiplier_bounds,
raw_multiplier: raw_multiplier,
multiplier: multiplier,
raw_adjustment: raw_adjustment,
point_caps: %{
upside: mandate_config.upside_point_cap,
downside: mandate_config.downside_point_cap
},
bounded_adjustment: bounded_adjustment,
proposed_grade: proposed_grade,
pillar_deltas: option(opts, :pillar_deltas, %{}),
metric_deltas: option(opts, :metric_deltas, %{})
}
else
invalid_result(config, mandate, canonical_final, policy, :invalid_progression_config)
end
end
defp neutral_result(config, mandate, canonical_final, policy, status, reason, explanation) do
%ProgressionResult{
status: status,
reason: reason,
reasons: [explanation],
model_version: config_version(config),
mandate_key: mandate,
comparison_policy: policy,
canonical_final_grade: canonical_final,
multiplier: 1.0,
bounded_adjustment: 0.0,
proposed_grade: canonical_final
}
end
defp invalid_result(config, mandate, canonical_final, policy, reason) do
canonical_final =
if valid_score?(canonical_final), do: canonical_final * 1.0, else: nil
%ProgressionResult{
status: :invalid,
reason: reason,
reasons: [invalid_reason(reason)],
model_version: config_version(config),
mandate_key: mandate,
comparison_policy: policy,
canonical_final_grade: canonical_final,
multiplier: 1.0,
bounded_adjustment: 0.0,
proposed_grade: canonical_final
}
end
defp target_change(%{type: :fixed, value: value}, _baseline), do: value * 1.0
defp target_change(target, baseline) do
(target.intercept + target.rate * (target.reference_score - baseline))
|> clamp(target.min, target.max)
end
defp default_policy(config, mandate) do
case mandate_config(config, mandate) do
%{default_policy: policy} -> policy
_config -> :episodic
end
end
defp validate_root_config(%{version: version, mandates: mandates})
when is_binary(version) and is_map(mandates),
do: :ok
defp validate_root_config(_config), do: {:error, :invalid_progression_config}
defp validate_mandate(config, mandate) do
if is_map(mandate_config(config, mandate)),
do: :ok,
else: {:error, :invalid_mandate}
end
defp validate_policy(policy) do
if policy in @policies, do: :ok, else: {:error, :invalid_comparison_policy}
end
defp validate_score(score, reason) do
if valid_score?(score), do: :ok, else: {:error, reason}
end
defp valid_score?(score), do: is_number(score) and score >= 0 and score <= 100
defp valid_mandate_config?(config) do
is_map(config) and is_atom(config.curve_key) and
is_number(config.softness) and config.softness > 0 and
non_negative?(config.upside_sensitivity) and
non_negative?(config.downside_sensitivity) and
non_negative?(config.upside_point_cap) and
non_negative?(config.downside_point_cap) and valid_target?(config.target)
end
defp valid_target?(%{type: :fixed, value: value}), do: is_number(value)
defp valid_target?(%{
type: :headroom,
intercept: intercept,
reference_score: reference,
rate: rate,
min: low,
max: high
}) do
Enum.all?([intercept, reference, rate, low, high], &is_number/1) and low <= high
end
defp valid_target?(_target), do: false
defp non_negative?(value), do: is_number(value) and value >= 0
defp applied_reason(:championship_push, comparable_delta, _target)
when comparable_delta < 0 do
"Championship Push regression always produces a negative scenario Progression signal."
end
defp applied_reason(mandate, comparable_delta, target) do
comparison =
cond do
comparable_delta > target -> "exceeded"
comparable_delta < target -> "fell below"
true -> "met"
end
"#{mandate_label(mandate)} scenario progress #{comparison} its #{format_number(target)}-point target."
end
defp invalid_reason(:invalid_mandate), do: "The mandate is not configured for Progression."
defp invalid_reason(:invalid_canonical_final_grade),
do: "The canonical Final GM Grade must be from 0 to 100."
defp invalid_reason(:invalid_baseline_comparable_score),
do: "The inherited Comparable State Score must be from 0 to 100."
defp invalid_reason(:invalid_final_comparable_score),
do: "The final Comparable State Score must be from 0 to 100."
defp invalid_reason(:invalid_comparison_policy),
do: "Comparison policy must be longitudinal, maintenance, or episodic."
defp invalid_reason(:invalid_progression_config),
do: "The selected Progression configuration is invalid."
defp invalid_reason(:incompatible_grade_pair),
do: "The inherited and final grades do not share one frozen comparison boundary."
defp invalid_reason(:invalid_pillar_weights),
do: "The Comparable State Score requires one valid set of canonical mandate weights."
defp invalid_reason(:missing_comparable_pillar),
do: "A required comparable pillar result is missing or malformed."
defp invalid_reason(:missing_comparable_metric),
do: "A required comparable Asset Management metric is missing or malformed."
defp mandate_label(mandate) do
mandate
|> Atom.to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp format_number(number), do: :erlang.float_to_binary(number * 1.0, decimals: 2)
defp config_version(%{version: version}) when is_binary(version), do: version
defp config_version(_config), do: Map.fetch!(ModelVersion.versions(), :progression)
defp mandate_config(%{mandates: mandates}, mandate) when is_map(mandates),
do: Map.get(mandates, mandate)
defp mandate_config(_config, _mandate), do: nil
defp option(opts, key, default) when is_list(opts), do: Keyword.get(opts, key, default)
defp option(opts, key, default) when is_map(opts),
do: Map.get(opts, key, Map.get(opts, Atom.to_string(key), default))
defp option(_opts, _key, default), do: default
defp clamp(value, low, high), do: value |> max(low) |> min(high)
end