Skip to main content

lib/firebreak/risk_score.ex

defmodule Firebreak.RiskScore do
  @moduledoc """
  A single, transparent supervision-risk number per project (and per supervisor),
  derived from the model IR — for dashboards and for tracking whether a tree is
  getting riskier over time.

  It is a **weighted count of structural hazards**, not an absolute grade. Three
  components, each read straight off the IR:

    * `sync_crossings` — synchronous cross-tree crossings (a caller in one subtree
      blocking on a process in another). The highest-signal hazard.
    * `blast_radius` — children that co-restart together under `:one_for_all` /
      `:rest_for_one` (a wide shared-fate set).
    * `intensity_pressure` — supervisors running many children (≥5) on a tight
      restart budget (≤3 restarts), where one crash-loop takes the whole subtree.

  The weights are relative, not calibrated absolutes — the value is the *trend* and
  the *per-supervisor ranking*, which point at where to look. Every score ships
  with its component breakdown, so it's never a black box. A clean app scores 0.
  """

  alias Firebreak.{Analysis, Model}

  # Relative weights — tune to taste; what matters is monotonicity (more hazards
  # => higher) and the per-supervisor ranking, not the absolute number.
  @w_sync_crossing 5
  @w_blast 1
  @w_intensity 2

  @type t :: %{
          score: non_neg_integer(),
          components: %{
            sync_crossings: non_neg_integer(),
            blast_radius: non_neg_integer(),
            intensity_pressure: non_neg_integer()
          },
          weights: map(),
          per_supervisor: [map()]
        }

  @doc "The risk score for an analysis: total, component breakdown, and per-supervisor ranking."
  @spec score(Analysis.t()) :: t()
  def score(%Analysis{} = a) do
    per_sup =
      a
      |> Model.build()
      |> Enum.map(&supervisor_score/1)
      |> Enum.reject(&(&1.score == 0))
      |> Enum.sort_by(& &1.score, :desc)

    components = %{
      sync_crossings: sum(per_sup, :sync_crossings),
      blast_radius: sum(per_sup, :blast_radius),
      intensity_pressure: sum(per_sup, :intensity_pressure)
    }

    total =
      components.sync_crossings * @w_sync_crossing +
        components.blast_radius * @w_blast +
        components.intensity_pressure * @w_intensity

    %{
      score: total,
      components: components,
      weights: %{
        sync_crossing: @w_sync_crossing,
        blast_radius: @w_blast,
        intensity_pressure: @w_intensity
      },
      per_supervisor: per_sup
    }
  end

  defp supervisor_score(bundle) do
    sync = Enum.count(bundle.inbound_crossings, & &1.sync)

    blast =
      if bundle.strategy in [:one_for_all, :rest_for_one], do: length(bundle.children), else: 0

    pressure = if length(bundle.children) >= 5 and (bundle.max_restarts || 3) <= 3, do: 1, else: 0

    %{
      supervisor: bundle.supervisor,
      sync_crossings: sync,
      blast_radius: blast,
      intensity_pressure: pressure,
      score: sync * @w_sync_crossing + blast * @w_blast + pressure * @w_intensity
    }
  end

  defp sum(per_sup, key), do: Enum.reduce(per_sup, 0, &(&2 + Map.fetch!(&1, key)))

  @doc "The risk score as a JSON string (`mix firebreak --format score`)."
  @spec json(Analysis.t()) :: String.t()
  def json(%Analysis{} = a), do: a |> score() |> Firebreak.JSON.encode_to_string()
end