lib/openagents/memory/evaluation/runner.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Memory.Evaluation.Runner do
  @moduledoc "Executes synthetic recall fixtures against PostgreSQL without retaining eval data."

  alias OpenAgents.{Conversations, Repo}
  alias OpenAgents.Conversations.Message
  alias OpenAgents.Memory.Evaluation.{Corpus, Report}
  alias OpenAgents.Memory.LexicalRecall

  @spec run() :: {:ok, map()} | {:error, term()}
  def run do
    corpus = Corpus.load!()

    case Repo.transaction(fn -> Repo.rollback(run_corpus(corpus)) end) do
      {:error, %{"schema" => "sarah.recall.eval_report.v1"} = report} -> {:ok, report}
      {:error, reason} -> {:error, reason}
      {:ok, _unexpected} -> {:error, :evaluation_transaction_not_rolled_back}
    end
  end

  defp run_corpus(corpus) do
    results = Enum.map(corpus["cases"], &run_case/1)
    Report.build(corpus, results)
  end

  defp run_case(evaluation_case) do
    suffix = Integer.to_string(System.unique_integer([:positive]))
    {:ok, conversation} = Conversations.ensure_conversation("recall-eval-#{suffix}")
    {:ok, foreign} = Conversations.ensure_conversation("recall-eval-foreign-#{suffix}")
    now = DateTime.utc_now()

    current_index = insert_messages(conversation.id, evaluation_case["messages"], now)
    foreign_index = insert_messages(foreign.id, evaluation_case["foreign_messages"], now)
    insert_fillers(conversation.id, evaluation_case["filler_count"], now)

    {:ok, snapshot_ref} = LexicalRecall.capture_ref(Repo, conversation.id, Ecto.UUID.generate())
    {:ok, snapshot} = LexicalRecall.load_snapshot(conversation, snapshot_ref)

    late_index = maybe_insert_late_match(conversation.id, evaluation_case, snapshot)
    label_index = Map.merge(current_index, late_index)
    source_to_label = Map.new(label_index, fn {label, source_ref} -> {source_ref, label} end)
    foreign_refs = foreign_index |> Map.values() |> MapSet.new()

    {:ok, page_10} =
      LexicalRecall.search_page(conversation, snapshot, evaluation_case["query"], first: 10)

    {:ok, requested_page} =
      LexicalRecall.search_page(
        conversation,
        snapshot,
        evaluation_case["query"],
        first: evaluation_case["first"]
      )

    refs_10 = Enum.map(page_10.matches, & &1.source_ref)

    retrieved_labels = Enum.map(refs_10, &Map.get(source_to_label, &1, "unexpected"))
    expected_labels = MapSet.new(evaluation_case["relevant_labels"])
    empty = refs_10 == []
    foreign_ref_count = Enum.count(refs_10, &MapSet.member?(foreign_refs, &1))
    excerpt_truncated = Enum.any?(page_10.matches, & &1.truncated)

    late_match_excluded =
      Enum.all?(Map.values(late_index), fn source_ref -> source_ref not in refs_10 end)

    provider_window_excluded =
      conversation.id
      |> Conversations.provider_messages()
      |> Enum.all?(&(not String.contains?(&1.content, evaluation_case["query"])))

    window_expectation_met =
      evaluation_case["journey"] != "outside_provider_window" or provider_window_excluded

    case_passed =
      empty == evaluation_case["expect_empty"] and
        requested_page.truncated == evaluation_case["expect_truncated"] and
        excerpt_truncated == evaluation_case["expect_excerpt_truncated"] and
        MapSet.subset?(MapSet.new(retrieved_labels), expected_labels) and
        MapSet.subset?(expected_labels, MapSet.new(retrieved_labels)) and
        foreign_ref_count == 0 and late_match_excluded and window_expectation_met

    %{
      "case_id" => evaluation_case["id"],
      "retrieved_at_3" =>
        refs_10 |> Enum.take(3) |> Enum.map(&Map.get(source_to_label, &1, "unexpected")),
      "retrieved_at_10" => retrieved_labels,
      "empty" => empty,
      "requested_page_truncated" => requested_page.truncated,
      "excerpt_truncated" => excerpt_truncated,
      "foreign_ref_count" => foreign_ref_count,
      "late_match_excluded" => late_match_excluded,
      "provider_window_excluded" => provider_window_excluded,
      "case_passed" => case_passed
    }
  end

  defp insert_messages(conversation_id, messages, now) do
    Map.new(messages, fn message ->
      timestamp = DateTime.add(now, -message["age_seconds"], :second)

      inserted =
        Repo.insert!(%Message{
          conversation_id: conversation_id,
          role: message["role"],
          content: message["content"],
          status: message["status"],
          inserted_at: timestamp,
          updated_at: timestamp
        })

      {message["label"], "message:#{inserted.id}"}
    end)
  end

  defp insert_fillers(_conversation_id, 0, _now), do: :ok

  defp insert_fillers(conversation_id, count, now) do
    for index <- 1..count do
      timestamp = DateTime.add(now, -(count - index + 1), :second)

      Repo.insert!(%Message{
        conversation_id: conversation_id,
        role: if(rem(index, 2) == 0, do: "user", else: "assistant"),
        content: "synthetic provider-window filler #{index}",
        status: "complete",
        inserted_at: timestamp,
        updated_at: timestamp
      })
    end

    :ok
  end

  defp maybe_insert_late_match(
         _conversation_id,
         %{"insert_match_after_snapshot" => false},
         _snapshot
       ),
       do: %{}

  defp maybe_insert_late_match(conversation_id, evaluation_case, snapshot) do
    timestamp = DateTime.add(snapshot.inserted_at, 1, :second)

    inserted =
      Repo.insert!(%Message{
        conversation_id: conversation_id,
        role: "user",
        content: "late frozen-boundary #{evaluation_case["query"]}",
        status: "complete",
        inserted_at: timestamp,
        updated_at: timestamp
      })

    %{"late_match" => "message:#{inserted.id}"}
  end
end