Skip to main content

lib/council_ex/councils/voting.ex

defmodule CouncilEx.Councils.Voting do
  @moduledoc """
  Topology #5 — independent_analysis → vote (with aggregator) → chair.

  All members analyse independently, then cast structured votes. The vote
  round aggregates results; the chair synthesizes from the vote outcome.

  ## Usage

      council =
        CouncilEx.Councils.Voting.new(
          as: MyApp.VoteCouncil,
          members: [
            {:m1, MyApp.Members.Voter, [provider: :openai, model: "gpt-4o-mini"]},
            {:m2, MyApp.Members.Voter, [provider: :openai, model: "gpt-4o-mini"]}
          ],
          chair: {MyApp.Members.Synth, [provider: :openai, model: "gpt-4o"]},
          aggregator: CouncilEx.Aggregators.WeightedMean
        )
  """

  @new_opts_schema NimbleOptions.new!(
                     as: [type: :atom, required: false],
                     members: [type: {:list, :any}, required: true],
                     chair: [type: :any, required: true],
                     aggregator: [type: :atom, default: CouncilEx.Aggregators.Plurality]
                   )

  @spec new(keyword()) :: module()
  def new(opts) do
    NimbleOptions.validate!(opts, @new_opts_schema)

    module_name = Keyword.get_lazy(opts, :as, &generate_name/0)
    members = Keyword.fetch!(opts, :members)
    {chair_mod, chair_opts} = Keyword.fetch!(opts, :chair)
    aggregator = Keyword.get(opts, :aggregator, CouncilEx.Aggregators.Plurality)

    member_calls =
      Enum.map(members, fn {id, mod, mem_opts} ->
        quote do
          member(unquote(id), unquote(mod), unquote(mem_opts))
        end
      end)

    body =
      quote do
        use CouncilEx
        unquote_splicing(member_calls)
        CouncilEx.round(:independent_analysis)
        CouncilEx.round(:vote, aggregator: unquote(aggregator))
        CouncilEx.chair(unquote(chair_mod), unquote(chair_opts))
      end

    Module.create(module_name, body, Macro.Env.location(__ENV__))
    module_name
  end

  defp generate_name do
    Module.concat([__MODULE__, "Generated", "#{:erlang.unique_integer([:positive, :monotonic])}"])
  end
end