Skip to main content

lib/jidoka/eval/case.ex

defmodule Jidoka.Eval.Case do
  @moduledoc """
  Deterministic evaluation case for one Jidoka turn.

  Eval cases are ordinary data: an agent spec, a turn request, and lightweight
  assertions that can run against fake or live capabilities supplied by the
  caller.
  """

  alias Jidoka.Agent
  alias Jidoka.Id
  alias Jidoka.Schema
  alias Jidoka.Turn

  @schema Zoi.struct(
            __MODULE__,
            %{
              id: Schema.non_empty_string(),
              agent: Zoi.lazy({Agent.Spec, :schema, []}),
              request: Zoi.lazy({Turn.Request, :schema, []}),
              assertions: Zoi.map() |> Zoi.default(%{}),
              metadata: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))
  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @spec schema() :: Zoi.schema()
  def schema, do: @schema

  @spec new(keyword() | map(), keyword()) :: {:ok, t()} | {:error, term()}
  def new(attrs, opts \\ []) do
    attrs = Schema.normalize_attrs(attrs)

    with {:ok, id} <- eval_id(attrs, opts),
         {:ok, agent} <- agent(attrs),
         {:ok, request} <- request(attrs, opts) do
      attrs =
        attrs
        |> drop_keys([:id, :agent, :request, :input, :request_id, :agent_state, :context])
        |> Map.put(:id, id)
        |> Map.put(:agent, agent)
        |> Map.put(:request, request)

      Schema.parse(@schema, attrs)
    end
  end

  @spec new!(keyword() | map(), keyword()) :: t()
  def new!(attrs, opts \\ []) do
    case new(attrs, opts) do
      {:ok, eval_case} ->
        eval_case

      {:error, reason} ->
        raise ArgumentError, "invalid eval case: #{inspect(reason)}"
    end
  end

  @spec from_input(t() | keyword() | map(), keyword()) :: {:ok, t()} | {:error, term()}
  def from_input(input, opts \\ [])
  def from_input(%__MODULE__{} = eval_case, opts), do: new(eval_case, opts)
  def from_input(input, opts), do: new(input, opts)

  defp eval_id(attrs, opts) do
    case Schema.fetch_key(attrs, :id) do
      {:ok, id} when is_binary(id) and id != "" -> {:ok, id}
      {:ok, id} -> {:error, {:invalid_eval_case_id, id}}
      :error -> Id.generate("eval", Keyword.get(opts, :id_generator))
    end
  end

  defp agent(attrs) do
    case Schema.fetch_key(attrs, :agent) do
      {:ok, agent} -> Agent.Spec.from_input(agent)
      :error -> {:error, :missing_eval_agent}
    end
  end

  defp request(attrs, opts) do
    request_opts = Keyword.take(opts, [:id_generator])

    case Schema.fetch_key(attrs, :request) do
      {:ok, request} ->
        Turn.Request.from_input(request, request_opts)

      :error ->
        attrs
        |> request_attrs()
        |> Turn.Request.from_input(request_opts)
    end
  end

  defp request_attrs(attrs) do
    [:input, :request_id, :agent_state, :context, :metadata]
    |> Enum.reduce(%{}, fn key, request ->
      case Schema.fetch_key(attrs, key) do
        {:ok, value} -> Map.put(request, key, value)
        :error -> request
      end
    end)
  end

  defp drop_keys(attrs, keys) do
    Enum.reduce(keys, attrs, fn key, attrs ->
      attrs
      |> Map.delete(key)
      |> Map.delete(Atom.to_string(key))
    end)
  end
end