lib/openagents/persona/evaluation/corpus.ex

58e6347eeb72 · 5 KB

defmodule OpenAgents.Persona.Evaluation.Corpus do
  @moduledoc "Loads and validates the committed, source-labeled persona regression corpus."

  alias OpenAgents.Persona.SourceManifest
  alias OpenAgents.Provenance.Canonical

  @path "sarah/evals/persona/corpus.v1.json"
  @schema "sarah.persona.eval_corpus.v1"
  @required_journeys ~w(greeting identity malformed_input correction capability_boundary)
  @properties ~w(
    ai_disclosure
    answer_first
    calibrated_uncertainty
    capability_honesty
    correction_acknowledgement
    executor_disclosure
    historical_evidence_boundary
    one_question
    openagent_identity
    restrained_humor
    source_state_grammar
  )

  @type reason :: atom() | tuple()

  @spec load() :: {:ok, map()} | {:error, reason()}
  def load do
    with {:ok, path} <- corpus_path(),
         {:ok, contents} <- File.read(path),
         {:ok, corpus} <- Jason.decode(contents),
         {:ok, manifest} <- SourceManifest.load(),
         :ok <- validate(corpus, manifest) do
      {:ok, Map.put(corpus, "digest", Canonical.digest!(corpus))}
    else
      {:error, %Jason.DecodeError{}} -> {:error, :invalid_corpus_json}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec load!() :: map()
  def load! do
    case load() do
      {:ok, corpus} ->
        corpus

      {:error, reason} ->
        raise ArgumentError, "invalid persona regression corpus: #{inspect(reason)}"
    end
  end

  @spec validate(term(), term()) :: :ok | {:error, reason()}
  def validate(%{"schema" => @schema, "cases" => cases} = corpus, %{"sources" => sources})
      when is_list(cases) and is_list(sources) do
    source_index = Map.new(sources, &{&1["id"], &1})

    with :ok <- validate_top_level(corpus),
         :ok <- validate_cases(cases, source_index),
         :ok <- validate_required_journeys(cases),
         :ok <- validate_unique_case_ids(cases) do
      :ok
    end
  end

  def validate(_corpus, _manifest), do: {:error, :invalid_corpus_shape}

  defp validate_top_level(corpus) do
    cond do
      corpus["id"] != "sarah.persona.regression.v1" ->
        {:error, :invalid_corpus_id}

      corpus["revision"] != 1 ->
        {:error, :invalid_corpus_revision}

      not is_number(corpus["minimum_score"]) ->
        {:error, :invalid_minimum_score}

      corpus["minimum_score"] < 0.0 or corpus["minimum_score"] > 1.0 ->
        {:error, :invalid_minimum_score}

      true ->
        :ok
    end
  end

  defp validate_cases(cases, source_index) when length(cases) in 5..100 do
    Enum.reduce_while(cases, :ok, fn regression_case, :ok ->
      case validate_case(regression_case, source_index) do
        :ok -> {:cont, :ok}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp validate_cases(_cases, _source_index), do: {:error, :invalid_case_count}

  defp validate_case(regression_case, source_index) when is_map(regression_case) do
    case_id = regression_case["id"]

    cond do
      not bounded_string?(case_id, 80) ->
        {:error, :invalid_case_id}

      not bounded_string?(regression_case["journey"], 80) ->
        {:error, {:invalid_case_journey, case_id}}

      not bounded_string?(regression_case["prompt"], 2_000) ->
        {:error, {:invalid_case_prompt, case_id}}

      not valid_string_list?(regression_case["required_properties"], @properties, 20) ->
        {:error, {:invalid_required_properties, case_id}}

      not valid_terms?(regression_case["forbidden_terms"]) ->
        {:error, {:invalid_forbidden_terms, case_id}}

      true ->
        validate_evidence(regression_case["evidence"], case_id, source_index)
    end
  end

  defp validate_case(_case, _source_index), do: {:error, :invalid_case}

  defp validate_evidence(evidence, case_id, source_index)
       when is_list(evidence) and length(evidence) in 1..8 do
    Enum.reduce_while(evidence, :ok, fn label, :ok ->
      if is_map(label) do
        source = source_index[label["source_id"]]

        cond do
          is_nil(source) ->
            {:halt, {:error, {:unknown_evidence_source, case_id, label["source_id"]}}}

          label["status"] != source["status"] ->
            {:halt, {:error, {:evidence_status_mismatch, case_id, label["source_id"]}}}

          label["use"] not in source["admitted_uses"] ->
            {:halt, {:error, {:evidence_use_not_admitted, case_id, label["source_id"]}}}

          true ->
            {:cont, :ok}
        end
      else
        {:halt, {:error, {:invalid_evidence_label, case_id}}}
      end
    end)
  end

  defp validate_evidence(_evidence, case_id, _source_index),
    do: {:error, {:invalid_evidence, case_id}}

  defp validate_required_journeys(cases) do
    journeys = MapSet.new(cases, & &1["journey"])

    case Enum.find(@required_journeys, &(not MapSet.member?(journeys, &1))) do
      nil -> :ok
      journey -> {:error, {:missing_required_journey, journey}}
    end
  end

  defp validate_unique_case_ids(cases) do
    ids = Enum.map(cases, & &1["id"])
    if length(ids) == length(Enum.uniq(ids)), do: :ok, else: {:error, :duplicate_case_id}
  end

  defp valid_string_list?(values, allowed, maximum)
       when is_list(values) and length(values) > 0 and length(values) <= maximum do
    Enum.all?(values, &(&1 in allowed)) and length(values) == length(Enum.uniq(values))
  end

  defp valid_string_list?(_values, _allowed, _maximum), do: false

  defp valid_terms?(terms) when is_list(terms) and length(terms) in 1..30 do
    Enum.all?(terms, &bounded_string?(&1, 100))
  end

  defp valid_terms?(_terms), do: false

  defp bounded_string?(value, maximum),
    do: is_binary(value) and value != "" and byte_size(value) <= maximum

  defp corpus_path do
    case :code.priv_dir(:openagents) do
      path when is_list(path) -> {:ok, Path.join(List.to_string(path), @path)}
      {:error, reason} -> {:error, {:priv_dir_unavailable, reason}}
    end
  end
end