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