Armchair GM
2026-27 The league's active season — trades, cap positions, and scenarios are computed against it.

Documentation The six pillars

Timeline alignment

Core age fit, prime-window alignment, development environment, and continuity. This pillar explicitly reads mandate-specific targets; its raw evaluation is not mandate-independent.

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
Core age fit 30.0%
Prime window alignment 25.0%
Core continuity 25.0%
Development environment 20.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.

timeline_alignment.exElixir

lib/trade_machine/gm_grade_v2/pillars/timeline_alignment.ex

Aggregates AVC's four mandate-dependent Timeline Alignment metrics.

A valid 240-minute rotation is authoritative input. Missing or invalid rotations produce a provisional pillar with no component scores.

Download source ↓

SHA-256 1f330da79d797d7258820542d09053f07f316e9509706cf37402a70f8c137ee3

defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment do
  @moduledoc """
  Aggregates AVC's four mandate-dependent Timeline Alignment metrics.

  A valid 240-minute rotation is authoritative input. Missing or invalid
  rotations produce a provisional pillar with no component scores.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.ChallengeSnapshot
  alias TradeMachine.GMGradeV2.ControlProjection
  alias TradeMachine.GMGradeV2.CoreEvaluator
  alias TradeMachine.GMGradeV2.MandateProfile
  alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs
  alias TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared
  alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreAgeFit
  alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreContinuity
  alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.DevelopmentEnvironment
  alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.PrimeWindowAlignment
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult
  alias TradeMachine.GMGradeV2.RotationValidation
  alias TradeMachine.GMGradeV2.ScenarioValidation

  @metric_weights %{
    core_age_fit: 0.30,
    prime_window_alignment: 0.25,
    core_continuity: 0.25,
    development_environment: 0.20
  }

  if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do
    raise "Timeline Alignment metric weights must sum to 1.0"
  end

  @doc """
  Scores a scenario against the challenge's frozen timeline evidence.

  The snapshot carries player impacts, birth dates, season start date, and
  mandate profile; its benchmark carries the league adjusted-EPM population.
  Control projections are composed from `ScenarioState`'s public player/payroll
  facts, while tests and other pure callers may provide precomputed projections.
  """
  @spec score(map(), ChallengeSnapshot.t()) :: PillarResult.t()
  def score(
        scenario_state,
        %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot
      )
      when is_map(scenario_state) do
    with {:ok, profile} <- mandate_profile(scenario_state, snapshot),
         {:ok, entries} <- valid_entries(scenario_state),
         {:ok, inputs} <- timeline_inputs(scenario_state, snapshot, benchmark, profile) do
      evaluate(entries, inputs, profile, benchmark.version)
    else
      {:error, {:mandate_profile, reason}} ->
        provisional(benchmark.version, nil, reason)

      {:error, reason} ->
        profile =
          case mandate_profile(scenario_state, snapshot) do
            {:ok, profile} -> profile
            _ -> nil
          end

        provisional(benchmark.version, profile, reason)
    end
  end

  @doc "Combines the four metric results at AVC's fixed 30/25/25/20 weights."
  @spec weighted_score([MetricResult.t()]) :: float()
  def weighted_score(results) do
    Enum.reduce(results, 0.0, fn result, total ->
      total + result.score * Map.fetch!(@metric_weights, result.key)
    end)
  end

  defp evaluate(entries, inputs, profile, version) do
    case CoreEvaluator.evaluate(
           entries,
           inputs.players,
           inputs.players,
           inputs.season_start,
           inputs.league_epms
         ) do
      {:ok, evaluation} ->
        metrics =
          [
            CoreAgeFit.score(evaluation.core, profile),
            PrimeWindowAlignment.score(evaluation.core, profile),
            CoreContinuity.score(
              evaluation.core,
              profile,
              inputs.control_projection.projections
            ),
            DevelopmentEnvironment.score(evaluation.rotation_players, profile)
          ]
          |> Enum.map(&attach_evaluation_metadata(&1, evaluation, inputs.control_projection))

        {strongest, weakest} = extrema(metrics)

        %PillarResult{
          key: :timeline_alignment,
          version: version,
          score: weighted_score(metrics),
          metric_results: metrics,
          metric_weights: @metric_weights,
          mandate_profile: profile_echo(profile),
          explanation:
            "Timeline Alignment applies #{profile.label}'s targets; " <>
              "#{label(strongest.key)} is strongest and #{label(weakest.key)} is weakest.",
          strongest: strongest.key,
          weakest: weakest.key
        }

      {:error, :degenerate_core} ->
        provisional(version, profile, "the eligible core has zero total importance.")
    end
  end

  defp timeline_inputs(scenario_state, snapshot, benchmark, profile) do
    with {:ok, season_start} <- season_start(snapshot),
         {:ok, league_epms} <- league_adjusted_epms(benchmark),
         {:ok, control_projection} <- control_projections(scenario_state, profile) do
      {:ok,
       %{
         players: ChallengeSnapshot.section(snapshot, :players),
         season_start: season_start,
         league_epms: league_epms,
         control_projection: control_projection
       }}
    end
  end

  defp attach_evaluation_metadata(metric, evaluation, control_projection) do
    metric =
      %{
        metric
        | fallbacks: metric.fallbacks ++ evaluation.fallbacks,
          confidence: Map.merge(metric.confidence, evaluation.confidence),
          diagnostics: Map.put(metric.diagnostics, :core_evaluation, evaluation.diagnostics)
      }

    if metric.key == :core_continuity do
      %{
        metric
        | fallbacks: metric.fallbacks ++ control_projection.fallbacks,
          confidence: Map.merge(metric.confidence, control_projection.confidence)
      }
    else
      metric
    end
  end

  defp valid_entries(scenario_state) do
    case Map.get(scenario_state, :rotation) do
      nil ->
        {:error, "the scenario has no 240-minute rotation."}

      rotation ->
        entries = MetricInputs.rotation_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} ->
            {:ok, normalized}

          {:error, errors} ->
            {:error,
             "the scenario rotation is invalid. " <> Enum.map_join(errors, " ", & &1.message)}
        end
    end
  end

  defp mandate_profile(%{mandate_profile: %MandateProfile{} = profile}, _snapshot),
    do: {:ok, profile}

  defp mandate_profile(_scenario_state, snapshot) do
    stored = ChallengeSnapshot.section(snapshot, :mandate_profile)

    case MandateProfile.from_frozen(stored) do
      {:ok, profile} ->
        {:ok, profile}

      _ ->
        {:error, {:mandate_profile, "the frozen mandate profile is incomplete or unknown."}}
    end
  end

  defp season_start(snapshot) do
    value =
      snapshot.data
      |> Map.get("season", %{})
      |> Map.get("start_date")

    case value do
      %Date{} = value ->
        {:ok, value}

      value when is_binary(value) ->
        case Date.from_iso8601(value) do
          {:ok, date} -> {:ok, date}
          {:error, _reason} -> {:error, "the frozen season-start date is invalid."}
        end

      _ ->
        {:error, "the frozen season-start date is missing."}
    end
  end

  defp league_adjusted_epms(benchmark) do
    with %{} = skills <- Benchmark.section(benchmark, :skills),
         %{} = tables <- Map.get(skills, "player_percentiles"),
         values when is_list(values) and values != [] <- Map.get(tables, "adjusted_epm") do
      {:ok, values}
    else
      _ -> {:error, "the frozen league adjusted-EPM population is missing."}
    end
  end

  defp control_projections(%{control_projections: projections}, _profile)
       when is_map(projections) do
    {:ok, %{projections: projections, fallbacks: [], confidence: %{control_inputs: :complete}}}
  end

  defp control_projections(scenario_state, profile) do
    with {:ok, seasons} <- horizon_seasons(scenario_state, profile.continuity_horizon),
         contracts when is_list(contracts) <- Shared.active_contracts(scenario_state),
         true <- contracts != [] do
      contract_index =
        Map.new(contracts, fn contract ->
          {fetch_value(contract, :player_id), contract}
        end)

      {projections, fallbacks} =
        scenario_state
        |> Map.get(:roster, [])
        |> Enum.map_reduce([], fn roster_player, fallbacks ->
          player_id = Map.get(roster_player, :id) || Map.get(roster_player, "id")
          contract = Map.get(contract_index, player_id)

          case contract && Shared.source_player(scenario_state, contract) do
            nil ->
              projection = ControlProjection.project(%{id: player_id}, seasons)

              fallback = %{
                input: player_id,
                method: :missing_control_input,
                note:
                  "No active scenario contract could be resolved; continuity uses no-control statuses."
              }

              {{player_id, projection}, [fallback | fallbacks]}

            source_player ->
              {control_player, modifications} =
                Shared.control_projection_input(
                  source_player,
                  contract,
                  Map.get(scenario_state, :modifications, %{})
                )

              projection =
                ControlProjection.project(
                  control_player,
                  seasons,
                  modifications: modifications
                )

              {{player_id, projection}, fallbacks}
          end
        end)

      fallbacks = Enum.reverse(fallbacks)

      {:ok,
       %{
         projections: Map.new(projections),
         fallbacks: fallbacks,
         confidence: %{
           control_inputs: if(fallbacks == [], do: :complete, else: :reduced),
           unresolved_control_player_count: length(fallbacks)
         }
       }}
    else
      _ -> {:error, "the scenario control-projection inputs are missing."}
    end
  end

  defp horizon_seasons(scenario_state, horizon) do
    graded_season = Map.get(scenario_state, :season)

    with %{seasons: seasons} when is_list(seasons) <-
           Map.get(scenario_state, :payroll_projection),
         index when is_integer(index) <- Enum.find_index(seasons, &(&1 == graded_season)),
         selected when length(selected) == horizon <- Enum.slice(seasons, index, horizon) do
      {:ok, selected}
    else
      _ -> {:error, :missing_horizon}
    end
  end

  defp provisional(version, profile, reason) do
    %PillarResult{
      key: :timeline_alignment,
      version: version,
      score: 0.0,
      metric_results: [],
      metric_weights: @metric_weights,
      provisional?: true,
      mandate_profile: profile_echo(profile),
      explanation: "Timeline Alignment is provisional and was not calculated because #{reason}"
    }
  end

  defp profile_echo(nil), do: nil

  defp profile_echo(profile) do
    Map.take(Map.from_struct(profile), [
      :key,
      :version,
      :ideal_core_age,
      :age_penalty_below,
      :age_penalty_above,
      :prime_offset_range,
      :continuity_horizon,
      :young_minute_share_range,
      :veteran_count_range,
      :young_role_range
    ])
  end

  defp extrema(results), do: {Enum.max_by(results, & &1.score), Enum.min_by(results, & &1.score)}

  defp fetch_value(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key)))

  defp label(:core_age_fit), do: "Core Age Fit"
  defp label(:prime_window_alignment), do: "Prime Window Alignment"
  defp label(:core_continuity), do: "Core Continuity"
  defp label(:development_environment), do: "Development Environment"
end
core_age_fit.exElixir

lib/trade_machine/gm_grade_v2/pillars/timeline_alignment/core_age_fit.ex

Scores each core player's whole-year age against the selected mandate.

Download source ↓

SHA-256 7a7d5cf4e090462dd12c360ef50cee938dfc55ddb4ddf64c9b260b4d4328c6f8

defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreAgeFit do
  @moduledoc "Scores each core player's whole-year age against the selected mandate."

  alias TradeMachine.GMGradeV2.MandateProfile
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @doc "Returns AVC's core-weighted Core Age Fit metric."
  @spec score([map()], MandateProfile.t()) :: MetricResult.t()
  def score(core, %MandateProfile{} = profile) when is_list(core) do
    players =
      Enum.map(core, fn player ->
        player_score = player_score(player.age, profile)

        %{
          player_id: player.player_id,
          age: player.age,
          core_weight: player.core_weight,
          score: player_score
        }
      end)

    score = Enum.reduce(players, 0.0, &(&1.score * &1.core_weight + &2))

    %MetricResult{
      key: :core_age_fit,
      version: profile.version,
      score: score,
      inputs: %{mandate: profile.key},
      supporting: %{
        players: players,
        ideal_core_age: profile.ideal_core_age,
        penalty_below: profile.age_penalty_below,
        penalty_above: profile.age_penalty_above
      },
      weights: Map.new(players, &{&1.player_id, &1.core_weight}),
      explanation: "Core players are scored against #{profile.label}'s ideal age range."
    }
  end

  @doc "Scores one whole-year age against a mandate's asymmetric range."
  @spec player_score(number(), MandateProfile.t()) :: float()
  def player_score(age, %MandateProfile{} = profile) when is_number(age) do
    penalty =
      cond do
        age < profile.ideal_core_age.min ->
          (profile.ideal_core_age.min - age) * profile.age_penalty_below

        age > profile.ideal_core_age.max ->
          (age - profile.ideal_core_age.max) * profile.age_penalty_above

        true ->
          0
      end

    max(0.0, 100.0 - penalty)
  end
end
core_continuity.exElixir

lib/trade_machine/gm_grade_v2/pillars/timeline_alignment/core_continuity.ex

Scores control of the six-player core through a mandate-specific horizon.

The input projections are normalized facts from ControlProjection ; this module only applies AVC's shared factors and continuity weights.

Download source ↓

SHA-256 e83c148e7289a21eaa8d61fcf7dcfb15a0989fea220b8153119abf97c9453d2d

defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreContinuity do
  @moduledoc """
  Scores control of the six-player core through a mandate-specific horizon.

  The input projections are normalized facts from `ControlProjection`; this
  module only applies AVC's shared factors and continuity weights.
  """

  alias TradeMachine.GMGradeV2.ControlFactors
  alias TradeMachine.GMGradeV2.MandateProfile
  alias TradeMachine.GMGradeV2.Pillars.TimelineAlignment.CoreAgeFit
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @doc """
  Scores control projections keyed by player id.

  Each projection may be either an ordered list of season evidence or a map
  keyed by season. Exactly the first `continuity_horizon` seasons are scored.
  """
  @spec score([map()], MandateProfile.t(), map()) :: MetricResult.t()
  def score(core, %MandateProfile{} = profile, projections)
      when is_list(core) and is_map(projections) do
    factors = ControlFactors.all()

    players =
      Enum.map(core, fn player ->
        seasons =
          projections
          |> fetch_projection(player.player_id)
          |> ordered_seasons()
          |> Enum.take(profile.continuity_horizon)

        if length(seasons) != profile.continuity_horizon do
          raise ArgumentError,
                "control projection for #{inspect(player.player_id)} must cover " <>
                  "#{profile.continuity_horizon} seasons"
        end

        horizon_table =
          Enum.map(seasons, fn season ->
            status = normalize_status(fetch!(season, :status))
            Map.put(season, :control_factor, Map.fetch!(factors, status))
          end)

        continuity =
          horizon_table
          |> Enum.reduce(0.0, &(fetch!(&1, :control_factor) + &2))
          |> Kernel./(profile.continuity_horizon)
          |> Kernel.*(100)

        age_fit = CoreAgeFit.player_score(player.age, profile)
        timeline_raw = player.core_weight * (0.40 + 0.60 * age_fit / 100)

        %{
          player_id: player.player_id,
          core_importance_value: player.core_importance_value,
          original_core_weight: player.core_weight,
          age_fit_score: age_fit,
          timeline_weight_raw: timeline_raw,
          continuity_score: continuity,
          horizon: horizon_table
        }
      end)

    timeline_total = Enum.reduce(players, 0.0, &(&1.timeline_weight_raw + &2))

    players =
      Enum.map(players, fn player ->
        Map.put(player, :timeline_relevant_weight, player.timeline_weight_raw / timeline_total)
      end)

    overall =
      Enum.reduce(players, 0.0, &(&1.continuity_score * &1.timeline_relevant_weight + &2))

    top_three = Enum.take(players, 3)
    top_weight = Enum.reduce(top_three, 0.0, &(&1.original_core_weight + &2))

    top_end =
      Enum.reduce(top_three, 0.0, fn player, total ->
        total + player.continuity_score * player.original_core_weight / top_weight
      end)

    %MetricResult{
      key: :core_continuity,
      version: profile.version,
      score: overall * 0.70 + top_end * 0.30,
      inputs: %{mandate: profile.key, continuity_horizon: profile.continuity_horizon},
      supporting: %{
        players: players,
        overall_core_control: overall,
        top_end_control: top_end
      },
      weights: %{overall_core_control: 0.70, top_end_control: 0.30},
      fallbacks: estimated_control_fallbacks(players),
      confidence: %{
        estimated_control_seasons:
          Enum.sum(
            for player <- players do
              Enum.count(player.horizon, &fetch(&1, :estimated, false))
            end
          )
      },
      explanation:
        "Core Continuity measures franchise control through the #{profile.continuity_horizon}-season mandate window."
    }
  end

  defp fetch_projection(projections, player_id) do
    Map.get(projections, player_id) || Map.fetch!(projections, to_string(player_id))
  end

  defp ordered_seasons(projection) when is_list(projection), do: projection

  defp ordered_seasons(projection) when is_map(projection) do
    projection
    |> Enum.sort_by(fn {season, _evidence} -> season end)
    |> Enum.map(&elem(&1, 1))
  end

  defp normalize_status(status) when is_atom(status), do: status
  defp normalize_status(status) when is_binary(status), do: String.to_existing_atom(status)

  defp fetch!(map, key) do
    case Map.fetch(map, key) do
      {:ok, value} -> value
      :error -> Map.fetch!(map, Atom.to_string(key))
    end
  end

  defp estimated_control_fallbacks(players) do
    for player <- players,
        season <- player.horizon,
        fetch(season, :estimated, false) == true do
      %{
        input: player.player_id,
        method: :estimated_rfa_rights,
        note: "Control status for #{fetch!(season, :season)} uses the documented RFA estimate."
      }
    end
  end

  defp fetch(map, key, default) do
    Map.get(map, key, Map.get(map, Atom.to_string(key), default))
  end
end
development_environment.exElixir

lib/trade_machine/gm_grade_v2/pillars/timeline_alignment/development_environment.ex

Scores youth opportunity, veteran infrastructure, and developmental roles.

All threshold comparisons are inclusive and intentionally preserve AVC's cliffs for the Phase 16 sensitivity lab.

Download source ↓

SHA-256 1f666082e1a4de835bd96489fffc67970bf161926c3a6a9c556ebfff40a9bb2d

defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.DevelopmentEnvironment do
  @moduledoc """
  Scores youth opportunity, veteran infrastructure, and developmental roles.

  All threshold comparisons are inclusive and intentionally preserve AVC's
  cliffs for the Phase 16 sensitivity lab.
  """

  alias TradeMachine.GMGradeV2.MandateProfile
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @rotation_minutes 240

  @doc "Returns AVC's 45/35/20 Development Environment metric."
  @spec score([map()], MandateProfile.t()) :: MetricResult.t()
  def score(players, %MandateProfile{} = profile) when is_list(players) do
    young = Enum.filter(players, &(&1.age <= 24))
    young_minutes = Enum.reduce(young, 0, &(&1.assigned_minutes + &2))
    young_share = young_minutes / @rotation_minutes

    veteran_count =
      Enum.count(players, fn player ->
        player.age >= 27 and player.assigned_minutes >= 12 and
          is_number(player.season_minutes) and player.season_minutes >= 500 and
          player.adjusted_epm >= -1.5
      end)

    young_role_count = Enum.count(young, &(&1.assigned_minutes >= 8))
    opportunity = opportunity_score(young_share, profile.young_minute_share_range)
    infrastructure = count_score(veteran_count, profile.veteran_count_range, 25, 10)
    distribution = count_score(young_role_count, profile.young_role_range, 20, 10)

    %MetricResult{
      key: :development_environment,
      version: profile.version,
      score: combine(opportunity, infrastructure, distribution),
      inputs: %{mandate: profile.key, rotation_minutes: @rotation_minutes},
      supporting: %{
        young_player_opportunity: opportunity,
        veteran_infrastructure: infrastructure,
        developmental_role_distribution: distribution,
        young_rotation_minutes: young_minutes,
        young_minute_share: young_share,
        established_veteran_count: veteran_count,
        meaningful_young_role_count: young_role_count,
        targets: %{
          young_minute_share: profile.young_minute_share_range,
          veteran_count: profile.veteran_count_range,
          young_roles: profile.young_role_range
        }
      },
      weights: %{
        young_player_opportunity: 0.45,
        veteran_infrastructure: 0.35,
        developmental_role_distribution: 0.20
      },
      explanation:
        "Development Environment balances mandate-appropriate youth minutes, veterans, and meaningful young roles."
    }
  end

  @doc "Combines AVC's three development sub-scores at 45/35/20."
  def combine(opportunity, infrastructure, distribution) do
    opportunity * 0.45 + infrastructure * 0.35 + distribution * 0.20
  end

  @doc "Scores young-minute share, applying two points per percentage point outside."
  def opportunity_score(share, %{min: minimum, max: maximum}) when is_number(share) do
    percentage_points =
      cond do
        share < minimum -> (minimum - share) * 100
        share > maximum -> (share - maximum) * 100
        true -> 0
      end

    max(0.0, 100.0 - 2 * percentage_points)
  end

  @doc "Scores an integer target range with asymmetric missing/excess penalties."
  def count_score(count, %{min: minimum, max: maximum}, missing_penalty, excess_penalty) do
    penalty =
      cond do
        count < minimum -> (minimum - count) * missing_penalty
        count > maximum -> (count - maximum) * excess_penalty
        true -> 0
      end

    max(0.0, 100.0 - penalty)
  end
end
prime_window_alignment.exElixir

lib/trade_machine/gm_grade_v2/pillars/timeline_alignment/prime_window_alignment.ex

Scores mandate window timing and weighted core synchronization.

Download source ↓

SHA-256 60d5beaf6e0541be23aec322d07d89bda9bebcc3b2cb9e42f2a0b9d29b8a0576

defmodule TradeMachine.GMGradeV2.Pillars.TimelineAlignment.PrimeWindowAlignment do
  @moduledoc "Scores mandate window timing and weighted core synchronization."

  alias TradeMachine.GMGradeV2.MandateProfile
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @prime_midpoint 27.5
  @curve [{1.5, 100.0}, {2.5, 85.0}, {3.5, 65.0}, {4.5, 40.0}, {6.0, 10.0}]

  @doc "Returns AVC's 65/35 Prime Window Alignment metric."
  @spec score([map()], MandateProfile.t()) :: MetricResult.t()
  def score(core, %MandateProfile{} = profile) when is_list(core) do
    players =
      Enum.map(core, fn player ->
        offset = @prime_midpoint - player.age

        %{
          player_id: player.player_id,
          age: player.age,
          core_weight: player.core_weight,
          prime_timing_offset: offset,
          window_timing_score: timing_score(offset, profile.prime_offset_range)
        }
      end)

    timing = Enum.reduce(players, 0.0, &(&1.window_timing_score * &1.core_weight + &2))
    spread = weighted_population_sd(players)
    synchronization = synchronization_score(spread)
    final = combine(timing, synchronization)

    %MetricResult{
      key: :prime_window_alignment,
      version: profile.version,
      score: final,
      inputs: %{mandate: profile.key, prime_midpoint: @prime_midpoint},
      supporting: %{
        players: players,
        prime_offset_range: profile.prime_offset_range,
        window_timing: timing,
        prime_timing_spread: spread,
        core_synchronization: synchronization
      },
      weights: %{window_timing: 0.65, core_synchronization: 0.35},
      explanation: "Prime Window Alignment combines mandate timing and core synchronization."
    }
  end

  @doc "Combines Window Timing and Core Synchronization at AVC's 65/35 weights."
  def combine(window_timing, core_synchronization) do
    window_timing * 0.65 + core_synchronization * 0.35
  end

  @doc "Scores a prime offset against the selected mandate range."
  def timing_score(offset, %{min: minimum, max: maximum}) when is_number(offset) do
    distance =
      if offset < minimum, do: minimum - offset, else: Kernel.max(offset - maximum, 0)

    max(0.0, 100.0 - 15 * distance)
  end

  @doc "Maps weighted population spread onto AVC's piecewise-linear curve."
  def synchronization_score(spread) when spread <= 1.5, do: 100.0
  def synchronization_score(spread) when spread >= 6.0, do: 10.0

  def synchronization_score(spread) do
    [{x1, y1}, {x2, y2}] =
      @curve
      |> Enum.chunk_every(2, 1, :discard)
      |> Enum.find(fn [{x1, _}, {x2, _}] -> spread >= x1 and spread <= x2 end)

    y1 + (spread - x1) / (x2 - x1) * (y2 - y1)
  end

  defp weighted_population_sd(players) do
    mean =
      Enum.reduce(players, 0.0, &(&1.prime_timing_offset * &1.core_weight + &2))

    variance =
      Enum.reduce(players, 0.0, fn player, total ->
        delta = player.prime_timing_offset - mean
        total + player.core_weight * delta * delta
      end)

    :math.sqrt(variance)
  end
end

Teams

More

Current challenges How challenges are graded Explore the six pillars of a good GM
Browse
Team Contracts Player Contracts FA Market
App
Install app

Open trades and cap sheets straight from your home screen in their own app window.

iPhone or iPad

In Safari, open the Share menu, choose Add to Home Screen, then tap Add. Keep Open as Web App enabled if shown.

Android or desktop

Use Install Armchair GM above when available, or open your browser menu and choose Install app or Add to Home screen.

An internet connection is required for live data, trades, and scenario changes.

What's new About
Account
Sign in Tip jar Support Armchair GM