Skip to main content

lib/agent_sea/capability.ex

defmodule AgentSea.Capability do
  @moduledoc """
  A named capability an agent has, with a proficiency level. Capability matching
  is pure: given an agent's capabilities and the names a task requires, compute
  which are matched/missing, an aggregate score in `[0, 1]`, and whether the
  agent can execute the task at all.
  """

  @enforce_keys [:name]
  defstruct [:name, :description, proficiency: :intermediate, keywords: []]

  @type proficiency :: :novice | :intermediate | :expert | :master

  @type t :: %__MODULE__{
          name: String.t(),
          description: String.t() | nil,
          proficiency: proficiency(),
          keywords: [String.t()]
        }

  @type match :: %{
          matched: [String.t()],
          missing: [String.t()],
          score: float(),
          can_execute: boolean()
        }

  @scores %{novice: 0.25, intermediate: 0.5, expert: 0.75, master: 1.0}

  @doc "Numeric weight for a capability's proficiency."
  @spec proficiency_score(t()) :: float()
  def proficiency_score(%__MODULE__{proficiency: p}), do: Map.get(@scores, p, 0.5)

  @doc """
  Match agent capabilities against the capability names a task requires.

  With no required capabilities, the score reflects the agent's overall
  proficiency and `can_execute` is true. Otherwise the score is coverage
  (fraction of required capabilities present) times the average proficiency of
  the matched ones, and `can_execute` is true only when nothing is missing.
  """
  @spec match([t()], [String.t()]) :: match()
  def match(agent_capabilities, [] = _required) do
    %{matched: [], missing: [], score: avg_proficiency(agent_capabilities), can_execute: true}
  end

  def match(agent_capabilities, required_names) do
    have = Map.new(agent_capabilities, &{&1.name, &1})
    required = Enum.uniq(required_names)
    {matched, missing} = Enum.split_with(required, &Map.has_key?(have, &1))

    coverage = length(matched) / length(required)
    avg = matched |> Enum.map(&proficiency_score(have[&1])) |> average(0.0)

    %{
      matched: matched,
      missing: missing,
      score: coverage * avg,
      can_execute: missing == []
    }
  end

  defp avg_proficiency([]), do: 0.0
  defp avg_proficiency(caps), do: caps |> Enum.map(&proficiency_score/1) |> average(0.0)

  defp average([], default), do: default
  defp average(list, _default), do: Enum.sum(list) / length(list)
end