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