lib/openagents/token_productivity.ex

main at 58e6347eeb72 · 12 KB

defmodule OpenAgents.TokenProductivity do
  @moduledoc """
  Productive token accounting: raw volume versus tokens that produced durable
  outcomes.

  Raw volume alone rewards burn. This module reads the four tables that hold
  token truth — `turn_receipts`, `voice_sessions`, `work_jobs`, and `scv_runs`
  — and splits the same tokens a second way: how many are attached to evidence
  that the work landed. Three buckets, strongest evidence first, each usage
  row counted at most once:

    * **Merged work** — a work job whose assignment's issue carries a merged
      pull request.
    * **Closed issues** — a work job whose assignment's issue closed without a
      merged pull request.
    * **Verified receipts** — an SCV run that succeeded with a digest-verified
      terminal receipt, or a work job that completed with its bounded report,
      where no stronger outcome evidence exists.

  ## Which record says a token produced an outcome

  A `forge_assignments` row is the one record binding an issue to work, and
  `forge_assignments.work_job_id` is the typed edge from that attempt to the
  execution that ran it. Outcome evidence is therefore read through the
  assignment, never through a second issue reference on the execution: `#152`
  dropped the one that existed, an `scv_runs.issue_id` no caller ever set,
  which held the two strongest buckets permanently at zero.

  An SCV run reaches the verified-receipt bucket on its own terminal receipt.
  It reaches no stronger bucket until the Codex lane becomes an assignment
  target the way a Box and a connected Computer already are, at which point it
  is graded through the same assignment as every other attempt rather than
  through a table of its own.

  Typed and spoken conversation tokens count toward raw volume only: a chat
  turn has no durable outcome record to attribute it to.

  ## Which usage counts

  The same double-count rule as `OpenAgents.Leaderboard` applies:
  `turn_receipts.usage` is already the merge of every tool-loop provider call
  in a typed turn, and `voice_sessions.usage` is already the merge of that
  session's responses, so `turn_provider_steps.usage` is never summed into raw
  totals. Provider steps are read separately — and only for the per-provider
  throughput table, where attributing tokens to the provider that produced
  them is the whole point. `work_jobs` rows come from the OpenCode-driven work
  machinery and `scv_runs` rows from the Codex driver; the two never describe
  the same run.

  ## Rates

  Stored usage maps spell their cache field three ways: `cached_input_tokens`
  (typed turns, Codex runs) and `input_cached_tokens` (voice) count cached
  tokens inside `input_tokens`, while `cache_read_tokens` (OpenCode) counts
  them separately. The cache hit rate therefore divides all cached tokens by
  `input_tokens` plus the exclusive `cache_read_tokens`, so both spellings
  land in the same denominator. The input share uses that same inclusive
  input count over input plus output.
  """

  import Ecto.Query

  alias OpenAgents.Conversations.ProviderStep
  alias OpenAgents.Conversations.TurnReceipt
  alias OpenAgents.Forge.Assignment
  alias OpenAgents.Issues.Issue
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.SCV.Execution
  alias OpenAgents.Voice.Session
  alias OpenAgents.Work.Job

  @schema "openagents.token_productivity.v1"

  @empty_totals %{
    input_tokens: 0,
    output_tokens: 0,
    cached_input_tokens: 0,
    cache_read_tokens: 0,
    total_tokens: 0
  }

  @type totals :: %{
          input_tokens: non_neg_integer(),
          output_tokens: non_neg_integer(),
          cached_input_tokens: non_neg_integer(),
          cache_read_tokens: non_neg_integer(),
          total_tokens: non_neg_integer()
        }

  @type provider_row :: %{
          provider_id: String.t(),
          steps: non_neg_integer(),
          input_tokens: non_neg_integer(),
          output_tokens: non_neg_integer(),
          cached_input_tokens: non_neg_integer(),
          total_tokens: non_neg_integer(),
          duration_ms: non_neg_integer(),
          tokens_per_second: float() | nil
        }

  @type report :: %{
          schema: String.t(),
          generated_at: DateTime.t(),
          raw: totals(),
          sources: %{
            typed_turns: totals(),
            voice_sessions: totals(),
            work_jobs: totals(),
            scv_runs: totals()
          },
          productive: %{
            merged_work: totals(),
            closed_issues: totals(),
            verified_receipts: totals(),
            total_tokens: non_neg_integer(),
            share: float() | nil
          },
          cache: %{
            cached_input_tokens: non_neg_integer(),
            input_tokens: non_neg_integer(),
            hit_rate: float() | nil
          },
          split: %{
            input_tokens: non_neg_integer(),
            output_tokens: non_neg_integer(),
            input_share: float() | nil
          },
          providers: [provider_row()]
        }

  # SUM of one numeric JSON field, defensive against absent keys and
  # non-numeric values, exactly like the leaderboard's usage fragments. The
  # key is a compile-time literal, inlined so the operator stays jsonb ->> text.
  defmacrop summed(usage, key) when is_binary(key) do
    expression =
      "COALESCE(SUM(CASE WHEN ? ->> '#{key}' ~ '^[0-9]+$' THEN (? ->> '#{key}')::bigint ELSE 0 END), 0)"

    quote do
      fragment(unquote(expression), unquote(usage), unquote(usage))
    end
  end

  # Per-row effective total: providers that omit total_tokens still count.
  defmacrop summed_effective_total(usage) do
    quote do
      fragment(
        """
        COALESCE(SUM(GREATEST(
          CASE WHEN ? ->> 'total_tokens' ~ '^[0-9]+$' THEN (? ->> 'total_tokens')::bigint ELSE 0 END,
          CASE WHEN ? ->> 'input_tokens' ~ '^[0-9]+$' THEN (? ->> 'input_tokens')::bigint ELSE 0 END
            + CASE WHEN ? ->> 'output_tokens' ~ '^[0-9]+$' THEN (? ->> 'output_tokens')::bigint ELSE 0 END
        )), 0)
        """,
        unquote(usage),
        unquote(usage),
        unquote(usage),
        unquote(usage),
        unquote(usage),
        unquote(usage)
      )
    end
  end

  @doc "Computes the full report straight from PostgreSQL."
  @spec report() :: report()
  def report do
    sources = %{
      typed_turns: totals(from(receipt in TurnReceipt, as: :usage_row)),
      voice_sessions: totals(from(session in Session, as: :usage_row)),
      work_jobs: totals(from(job in Job, as: :usage_row)),
      scv_runs: totals(from(run in Execution, as: :usage_row))
    }

    raw =
      Enum.reduce(Map.values(sources), @empty_totals, &merge_totals/2)

    merged_work = totals(merged_work_query())
    closed_issues = totals(closed_issues_query())

    verified_receipts =
      merge_totals(totals(receipt_runs_query()), totals(completed_jobs_query()))

    productive_total =
      merged_work.total_tokens + closed_issues.total_tokens + verified_receipts.total_tokens

    cached = raw.cached_input_tokens
    input_inclusive = raw.input_tokens + raw.cache_read_tokens

    %{
      schema: @schema,
      generated_at: DateTime.utc_now(),
      raw: raw,
      sources: sources,
      productive: %{
        merged_work: merged_work,
        closed_issues: closed_issues,
        verified_receipts: verified_receipts,
        total_tokens: productive_total,
        share: ratio(productive_total, raw.total_tokens)
      },
      cache: %{
        cached_input_tokens: cached,
        input_tokens: input_inclusive,
        hit_rate: ratio(cached, input_inclusive)
      },
      split: %{
        input_tokens: input_inclusive,
        output_tokens: raw.output_tokens,
        input_share: ratio(input_inclusive, input_inclusive + raw.output_tokens)
      },
      providers: providers()
    }
  end

  @doc """
  Per-provider volume and throughput from completed provider steps.

  Provider steps carry each attempt's own usage and wall clock, so this is the
  one place they are read: tokens per second come from output tokens over the
  step's recorded duration.
  """
  @spec providers() :: [provider_row()]
  def providers do
    from(step in ProviderStep,
      as: :usage_row,
      where: step.status == "completed" and not is_nil(step.usage),
      group_by: step.provider_id,
      order_by: [desc: summed_effective_total(step.usage)],
      select: %{
        provider_id: step.provider_id,
        steps: count(step.id),
        input_tokens: summed(step.usage, "input_tokens"),
        output_tokens: summed(step.usage, "output_tokens"),
        cached_input_tokens: summed(step.usage, "cached_input_tokens"),
        total_tokens: summed_effective_total(step.usage),
        duration_ms:
          fragment(
            "COALESCE(SUM(EXTRACT(EPOCH FROM (? - ?)) * 1000) FILTER (WHERE ? IS NOT NULL), 0)::bigint",
            step.completed_at,
            step.started_at,
            step.completed_at
          )
      }
    )
    |> Repo.all()
    |> Enum.map(fn row ->
      row =
        Map.new(row, fn
          {:provider_id, value} -> {:provider_id, value}
          {key, value} -> {key, integer(value)}
        end)

      Map.put(row, :tokens_per_second, throughput(row.output_tokens, row.duration_ms))
    end)
  end

  # Every totals query names its usage-bearing binding :usage_row so one
  # select works for all of them.
  defp totals(query) do
    query
    |> select([usage_row: row], %{
      input_tokens: summed(row.usage, "input_tokens"),
      output_tokens: summed(row.usage, "output_tokens"),
      cached_input_tokens:
        summed(row.usage, "cached_input_tokens") +
          summed(row.usage, "input_cached_tokens") +
          summed(row.usage, "cache_read_tokens"),
      cache_read_tokens: summed(row.usage, "cache_read_tokens"),
      total_tokens: summed_effective_total(row.usage)
    })
    |> Repo.one()
    |> Map.new(fn {key, value} -> {key, integer(value)} end)
  end

  # The work jobs an assignment bound to an issue that carries a merged pull
  # request. Read as a set of ids rather than a join, so a job an assignment
  # names twice, or an issue with two merged pull requests, still counts its
  # usage once.
  defp merged_job_ids do
    from(assignment in Assignment,
      join: pull_request in PullRequest,
      on: pull_request.issue_id == assignment.issue_id,
      where: not is_nil(assignment.work_job_id),
      where: not is_nil(pull_request.merged_at),
      select: assignment.work_job_id
    )
  end

  # The work jobs an assignment bound to an issue that has closed.
  defp closed_issue_job_ids do
    from(assignment in Assignment,
      join: issue in Issue,
      on: issue.id == assignment.issue_id,
      where: not is_nil(assignment.work_job_id),
      where: issue.state == "closed",
      select: assignment.work_job_id
    )
  end

  # A merged pull request on the attempt's issue is the strongest evidence.
  defp merged_work_query do
    from(job in Job, as: :usage_row, where: job.id in subquery(merged_job_ids()))
  end

  # Closed without a merged pull request: the issue itself was the outcome.
  defp closed_issues_query do
    from(job in Job,
      as: :usage_row,
      where: job.id in subquery(closed_issue_job_ids()),
      where: job.id not in subquery(merged_job_ids())
    )
  end

  # Succeeded runs carry a digest-verified terminal receipt. No assignment
  # names an SCV run yet, so no stronger bucket can already hold one.
  defp receipt_runs_query do
    from(run in Execution, as: :usage_row, where: run.status == "succeeded")
  end

  # A completed work job's terminal row must carry its bounded report — that
  # report is the receipt. Count the ones no stronger bucket already counted.
  defp completed_jobs_query do
    from(job in Job,
      as: :usage_row,
      where: job.status == "completed",
      where: job.id not in subquery(merged_job_ids()),
      where: job.id not in subquery(closed_issue_job_ids())
    )
  end

  defp merge_totals(left, right) do
    Map.new(@empty_totals, fn {key, _zero} ->
      {key, Map.fetch!(left, key) + Map.fetch!(right, key)}
    end)
  end

  defp ratio(_numerator, denominator) when denominator in [0, nil], do: nil
  defp ratio(numerator, denominator), do: numerator / denominator

  defp throughput(_output_tokens, duration_ms) when duration_ms <= 0, do: nil
  defp throughput(output_tokens, duration_ms), do: output_tokens / (duration_ms / 1000)

  defp integer(value) when is_integer(value), do: value
  defp integer(%Decimal{} = value), do: Decimal.to_integer(value)
  defp integer(_value), do: 0
end