Make the WEKA export a corpus a replayer can read

e57f5ea8b166 · AtlantisPleb · · parent 3445eed1c4fd

Make the WEKA export a corpus a replayer can read

The exporter landed in d808cf1 as a plausible-looking document that was not
the format: `weka-trace-v1` with an `events` array of per-event block hashes.
AgentX replays a session — an ordered list of model calls, each with the
offset it happened at, its duration, its token counts, and the ids of the
64-token blocks its prompt was made of, under `block_size` and
`hash_id_scope` (`proxy_to_weka.py` in the InferenceX reference). Chunking
each event separately also destroyed the one property the corpus exists to
carry: prefix reuse. Nothing measured it, so nothing said so.

`OpenAgents.Threads.WekaExport` now emits the real thing. A model call closes
at each `tool.*` and each `turn.assistant`; its prompt is everything recorded
before the model-authored run began, so contexts nest and one call's block ids
are a prefix of the next's. Blocks stay session-salted chained SHA-256 —
chained because a repeated block at a different context position is not
cache-equivalent — remapped to session-local integers. Only whole blocks are
hashed, so `in` is exactly what a replay would send. Consenting child threads
become `subagent` entries with fresh context in the shared id space.

`corpus/2` builds a corpus from a recorded thread-id set, never a query, and
records the set, every refusal with its reason, and the code revision, so the
same inputs rebuild the same bytes. `prefix_reuse/1` reports the measurement
axis 1 compares on, and the test holds that number equal to the same statistic
computed on the raw transcript — the acceptance criterion, proven here rather
than asserted. `mix openagents.weka.export` writes the file; no route
publishes one.

The consent gate is proven twice over and per thread, not per subtree: a dark
thread refuses, a dark child of a consenting parent leaves no entry, no block
count, and no id, and a corpus that names one records the refusal instead of
the trace. THREAD-002 records the new consumer.

Closes #218.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#218

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 381 · 2026-08-25T14:30:24.892044Z

Changed files

  • modified INVARIANTS.md
  • modified docs/2026-08-24-benchmark-workbench-agentx.md
  • added lib/mix/tasks/openagents.weka.export.ex
  • modified lib/openagents/threads/weka_export.ex
  • added test/mix/tasks/openagents_weka_export_test.exs
  • modified test/openagents/threads/weka_export_test.exs

Diff

6 files changed, +1073 -178

INVARIANTS.md modified +22 -1

@@ -2501,12 +2501,33 @@ by joining `threads.visibility` and counting only rows at a tier that permits

2501 2501
it; a counter that read `thread_events` without that join would republish, in
2502 2502
aggregate, transcripts their owners kept `dark`.
2503 2503
2504
Amended 2026-08-25 (issue #218): the tier now gates a second consumer, the
2505
benchmark workbench's trace corpus. `OpenAgents.Threads.WekaExport` turns a
2506
consenting thread's transcript into a WEKA v1 trace — the block-hash format
2507
AgentX replays (`docs/2026-08-24-benchmark-workbench-agentx.md`, section 5) —
2508
and refuses a `dark` thread with `:consent_required`. The gate is applied per
2509
thread rather than per subtree, because a consenting parent may spawn a
2510
narrower child (THREAD-003): a `dark` child produces no sub-agent entry, no
2511
request, no block count, and no record that it existed. `corpus/2` takes a
2512
recorded thread-id set rather than running a query — a query is how a consent
2513
gate gets widened by accident — and records every refusal by id and reason
2514
beside the code revision that built the document, so a corpus is reproducible
2515
and honest about what it does not contain. `mix openagents.weka.export` is the
2516
only surface that writes one; no route publishes a corpus, and publication
2517
stays a separate, explicit decision. What leaves is structure and timing:
2518
prompts are cut into 64-token blocks replaced by session-salted chained
2519
hashes, remapped to session-local integers, so prefix reuse survives and
2520
content does not.
2521
2504 2522
Evidence: `OpenAgents.Threads.fetch_readable/2`,
2505 2523
`OpenAgents.Threads.Thread.visibilities/0`, `OpenAgentsWeb.ThreadController`,
2506 2524
`OpenAgentsWeb.ThreadShowLive`, `OpenAgents.DataRights.AccountExport`,
2525
`OpenAgents.Threads.WekaExport`,
2507 2526
`priv/repo/migrations/20260824210500_add_visibility_to_threads.exs`,
2508 2527
`test/openagents/threads/visibility_test.exs`,
2509
`test/openagents_web/thread_visibility_test.exs`, and
2528
`test/openagents_web/thread_visibility_test.exs`,
2529
`test/openagents/threads/weka_export_test.exs`,
2530
`test/mix/tasks/openagents_weka_export_test.exs`, and
2510 2531
`test/openagents/threads/grant_token_reach_test.exs`.
2511 2532
2512 2533
### THREAD-003 — Child threads are nested, budgeted, and typed
docs/2026-08-24-benchmark-workbench-agentx.md modified +27

@@ -165,6 +165,33 @@ Shape:

165 165
  users, and — if published — the corpus is a contribution back to the
166 166
  AgentX ecosystem under our own consent rules.
167 167
168
**Shipped (issue #218).** `OpenAgents.Threads.WekaExport` is the exporter.
169
`export/2` turns one consenting thread into a WEKA v1 trace: an ordered list
170
of model calls, each carrying its wall-clock offset, call duration, think
171
time, input and output token counts, and the ids of the 64-token blocks its
172
prompt was made of, under `block_size: 64` and `hash_id_scope: "local"` — the
173
shape `proxy_to_weka.py` writes and AIPerf replays. A call closes at each
174
`tool.*` and each `turn.assistant`; its prompt is everything recorded before
175
the model-authored run began. Blocks are session-salted chained SHA-256 —
176
chained because a repeated block at a different context position is not
177
cache-equivalent, and only chaining tells the two apart — then remapped to
178
session-local integers. Only whole blocks are hashed, so `in` is exactly what
179
a replay would send. Consenting child threads become `subagent` entries with
180
their own fresh context in the shared id space; a `dark` child is absent
181
entirely.
182
183
`corpus/2` builds a corpus from a recorded thread-id set, never from a query,
184
and records the set, the refusals with their reasons, and the code revision,
185
so the same set and revision rebuild the same document. `prefix_reuse/1`
186
reports the measurement the axis-1 comparison needs — reused leading blocks
187
over all blocks, per agent — and
188
`test/openagents/threads/weka_export_test.exs` holds that number equal to the
189
same statistic computed on the raw transcript, so the anonymized trace carries
190
the source session's prefix-reuse characteristics rather than merely claiming
191
to. `mix openagents.weka.export --threads SET_FILE --out CORPUS_FILE` is the
192
operator surface. No route publishes a corpus; publication stays a separate
193
decision, as this section always said.
194
168 195
## 6. Non-goals
169 196
170 197
- No 2MW GPU fleet, no hardware SKU comparisons — that is InferenceX's
lib/mix/tasks/openagents.weka.export.ex added +124

@@ -0,0 +1,124 @@

1
defmodule Mix.Tasks.Openagents.Weka.Export do
2
  @shortdoc "Export consenting thread transcripts as a WEKA trace corpus"
3
4
  @moduledoc """
5
  Builds the WEKA v1 trace corpus the benchmark workbench replays
6
  (`docs/2026-08-24-benchmark-workbench-agentx.md`, section 5; issue #218).
7
8
  The thread-id set is an input, not a query. Nothing here selects threads on
9
  the operator's behalf, because a corpus is only reproducible if the set that
10
  built it was recorded, and because a query is how a consent gate gets widened
11
  by accident. Name the ids, or name a file of them:
12
13
      mix openagents.weka.export --threads corpus-set.txt --out corpus.json
14
15
      mix openagents.weka.export --thread THREAD_ID --thread THREAD_ID \\
16
        --out /tmp/corpus.json --salt CORPUS_SALT
17
18
  A thread whose owner has not widened it is refused, recorded in the corpus by
19
  id and reason, and contributes nothing. The written document carries the
20
  requested set and the code revision that produced it, so the same file can be
21
  rebuilt and compared byte for byte.
22
23
  Publication is a separate, explicit decision. This task writes a file.
24
  """
25
26
  use Mix.Task
27
28
  alias OpenAgents.Threads.WekaExport
29
30
  @requirements ["app.config"]
31
32
  @switches [
33
    thread: :keep,
34
    threads: :string,
35
    out: :string,
36
    salt: :string,
37
    revision: :string,
38
    database_url: :string
39
  ]
40
41
  @impl Mix.Task
42
  def run(arguments) do
43
    {options, remaining, invalid} = OptionParser.parse(arguments, strict: @switches)
44
45
    if remaining != [] or invalid != [], do: Mix.raise("invalid WEKA export options")
46
47
    thread_ids = thread_ids(options)
48
    if thread_ids == [], do: Mix.raise("--thread THREAD_ID or --threads FILE is required")
49
50
    ensure_repo(Keyword.get(options, :database_url))
51
52
    build_options =
53
      options
54
      |> Keyword.take([:salt, :revision])
55
      |> Keyword.new()
56
57
    {:ok, corpus} = WekaExport.corpus(thread_ids, build_options)
58
59
    output = Keyword.get(options, :out, "openagents-weka-corpus.json")
60
    File.write!(output, Jason.encode!(corpus, pretty: true))
61
62
    reuse = corpus["prefix_reuse"]
63
64
    Mix.shell().info(
65
      "wrote #{output}: #{length(corpus["traces"])} trace(s), " <>
66
        "#{length(corpus["refused"])} refused, #{reuse["requests"]} request(s), " <>
67
        "#{reuse["blocks"]} block(s), prefix reuse " <>
68
        "#{Float.round(reuse["rate"] * 100, 1)}%, revision #{corpus["code_revision"]}"
69
    )
70
71
    for refusal <- corpus["refused"] do
72
      Mix.shell().info("  refused #{refusal["thread_id"]}: #{refusal["reason"]}")
73
    end
74
  end
75
76
  defp thread_ids(options) do
77
    named = for {:thread, id} <- options, do: String.trim(id)
78
79
    from_file =
80
      case Keyword.get(options, :threads) do
81
        nil ->
82
          []
83
84
        path ->
85
          path
86
          |> File.read!()
87
          |> String.split(~r/\r?\n/, trim: true)
88
          |> Enum.map(&String.trim/1)
89
          |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#")))
90
      end
91
92
    Enum.uniq(from_file ++ named)
93
  end
94
95
  # Inside the application — a test, a release console — the repository is
96
  # already up and starting a second one would take a connection outside the
97
  # caller's ownership. Outside it, start only the repository: this task has no
98
  # use for the supervision tree's recovery workers.
99
  defp ensure_repo(database_url) do
100
    if Process.whereis(OpenAgents.Repo) do
101
      :ok
102
    else
103
      start_repo_only(database_url)
104
    end
105
  end
106
107
  defp start_repo_only(database_url) do
108
    {:ok, _apps} = Application.ensure_all_started(:ssl)
109
    {:ok, _apps} = Application.ensure_all_started(:postgrex)
110
    {:ok, _apps} = Application.ensure_all_started(:ecto_sql)
111
112
    repo_options =
113
      case database_url do
114
        nil -> [pool_size: 2]
115
        url when is_binary(url) -> [url: url, pool_size: 2, ssl: false]
116
      end
117
118
    case OpenAgents.Repo.start_link(repo_options) do
119
      {:ok, _repo} -> :ok
120
      {:error, {:already_started, _repo}} -> :ok
121
      {:error, reason} -> Mix.raise("repo start failed: #{inspect(reason)}")
122
    end
123
  end
124
end
lib/openagents/threads/weka_export.ex modified +466 -91

@@ -1,126 +1,503 @@

1 1
defmodule OpenAgents.Threads.WekaExport do
2 2
  @moduledoc """
3
  Exports a ledger-visible thread transcript into a WEKA-trace v1 document.
3
  Exports consenting thread transcripts as WEKA v1 traces, and a recorded set
4
  of them as a corpus (issue #218).
4 5
5
  The output is a content-anonymized, chain-hashed trajectory that preserves
6
  event metadata (role, type, timestamps, block counts) but replaces every
7
  raw text payload with deterministic SHA-256 block hashes. Export is
8
  consent-gated by the thread's visibility tier (THREAD-002).
6
  WEKA is the trace format AgentX replays under AIPerf
7
  (`docs/2026-08-24-benchmark-workbench-agentx.md`, section 5). A trace is a
8
  session: an ordered list of model calls, each carrying the wall-clock offset
9
  it happened at, how long the call took, how many tokens went in and came
10
  out, and the identity of the 64-token blocks its prompt was made of. Content
11
  never appears. What survives is the shape of the traffic — multi-turn
12
  accumulation, context growth, prefix reuse, and sub-agent bursts — which is
13
  the thing a serving stack is measured against.
14
15
  ## What a request is
16
17
  The transcript vocabulary is `turn.user`, `turn.reasoning`, `tool.ran`,
18
  `turn.assistant` (`docs/2026-08-24-coder-account-integration-audit.md`). One
19
  model call produces a run of model-authored events that ends either at a tool
20
  call or at an answer, so a request closes at each `tool.*` and each
21
  `turn.assistant`. Its prompt is everything recorded before that run began;
22
  its output is the run. Anything else — the user's turn, the thread's own
23
  lifecycle records — accumulates into the context the next call carries.
24
25
  Because a call's prompt is a prefix of the next call's prompt, the block ids
26
  of one request are a prefix of the next request's. That is not asserted here;
27
  it falls out of how contexts grow, which is exactly why the measurement in
28
  `prefix_reuse/1` says something about the source session.
29
30
  ## How blocks are anonymized
31
32
  A prompt is cut into 64-token blocks and each block is replaced by a
33
  session-scoped chained hash: `sha256(previous_hash <> block_text)`, seeded
34
  from a salt derived from the thread id and the caller's salt. The chain is
35
  load-bearing rather than decorative. A block of text that appears twice in
36
  one context at different positions is not cache-equivalent — only a matching
37
  *prefix* hits a warm KV cache — and chaining is what tells the two apart.
38
  The salt makes block identity session-scoped, so nothing can be matched
39
  across sessions or attacked with a dictionary of likely blocks.
40
41
  The emitted ids are then remapped to session-local integers in first-seen
42
  order, `hash_id_scope: "local"`, which is what the reference converter
43
  publishes and what the replayer consumes. Only whole blocks are hashed: the
44
  trailing partial block is dropped and `in` counts the tokens actually
45
  covered, so a replayed prompt is exactly what was hashed and nothing more. A
46
  prompt with no whole block reports its true token count instead, the same
47
  fallback the reference converter uses when no hash coverage exists.
48
49
  Tokens here are whitespace-separated words, not a model's byte-pair tokens.
50
  The measure that matters is block *structure* — which blocks repeat, and
51
  where — and that is preserved under any consistent tokenizer.
52
53
  ## Consent
54
55
  Export is gated by the thread's visibility tier (THREAD-002). A `dark`
56
  thread refuses with `:consent_required` and contributes nothing: not a
57
  request, not a block count, not a token. The gate applies to sub-agents
58
  independently, because a consenting parent may spawn a narrower child
59
  (THREAD-003) — a `dark` child produces no sub-agent entry and no trace of
60
  having existed. `corpus/2` records every refusal by thread id and reason, so
61
  a corpus is honest about what it does not contain.
62
63
  Publication of a corpus is a separate, explicit decision. This module builds
64
  documents; nothing here publishes one.
9 65
  """
10 66
11 67
  import Ecto.Query
12 68
69
  alias OpenAgents.BuildInfo
13 70
  alias OpenAgents.Repo
14 71
  alias OpenAgents.Threads.Event
15 72
  alias OpenAgents.Threads.Thread
16 73
17
  @weka_format "weka-trace-v1"
18
  @chunk_size 64
19
  @hash_display 16
74
  @block_size 64
75
  @hash_id_scope "local"
76
  @corpus_format "openagents-weka-corpus-v1"
77
  @subagent_type "child_thread"
78
79
  @typedoc "A WEKA v1 trace document."
80
  @type trace :: %{String.t() => term()}
81
82
  @typedoc "A corpus of WEKA v1 traces with the set and revision that built it."
83
  @type corpus :: %{String.t() => term()}
84
85
  @doc "The KV-cache block size the WEKA format is defined against."
86
  @spec block_size() :: pos_integer()
87
  def block_size, do: @block_size
20 88
21 89
  @doc """
22
  Exports a thread to a WEKA-trace v1 document.
90
  Exports one consenting thread as a WEKA v1 trace.
91
92
  Accepts a `Thread` struct or a thread id. Options:
93
94
    * `:salt` — caller-supplied salt folded into the session's block identity.
95
      Defaults to `""`. It changes nothing an exported document shows, because
96
      ids are remapped session-locally; it changes what the hashes behind them
97
      are, which is what keeps block identity from being guessable.
23 98
24
  Accepts a `Thread` struct or a thread id (UUID string). The optional `salt`
25
  is caller-supplied and defaults to `""`. Returns `{:ok, document}` for a
26
  ledger-visible thread, or `{:error, :consent_required}` /
27
  `{:error, :thread_not_found}` otherwise.
99
  Returns `{:error, :consent_required}` for a thread its owner left `dark`,
100
  and `{:error, :thread_not_found}` for an id that resolves to nothing.
28 101
  """
29
  @spec export(Thread.t() | String.t(), String.t()) :: {:ok, map()} | {:error, atom()}
30
  def export(thread_or_id, salt \\ "")
102
  @spec export(Thread.t() | String.t(), keyword()) ::
103
          {:ok, trace()} | {:error, :consent_required | :thread_not_found}
104
  def export(thread_or_id, options \\ [])
31 105
32
  def export(%Thread{} = thread, salt) when is_binary(salt) do
33
    do_export(thread, salt)
106
  def export(%Thread{} = thread, options) when is_list(options), do: gate(thread, options)
107
108
  def export(thread_id, options) when is_binary(thread_id) and is_list(options) do
109
    with {:ok, id} <- Ecto.UUID.cast(thread_id),
110
         %Thread{} = thread <- Repo.get(Thread, id) do
111
      gate(thread, options)
112
    else
113
      _unresolved -> {:error, :thread_not_found}
114
    end
34 115
  end
35 116
36
  def export(thread_id, salt) when is_binary(thread_id) and is_binary(salt) do
37
    case Ecto.UUID.cast(thread_id) do
38
      {:ok, id} ->
39
        case Repo.get(Thread, id) do
40
          %Thread{} = thread -> do_export(thread, salt)
41
          nil -> {:error, :thread_not_found}
117
  @doc """
118
  Builds a corpus from a recorded set of thread ids.
119
120
  The document records the ids it was asked for, the ids it included, every
121
  refusal with its reason, and the code revision that built it, so the same set
122
  and the same revision rebuild the same corpus. Options are `:salt` (see
123
  `export/2`) and `:revision`, which defaults to this build's own.
124
  """
125
  @spec corpus([String.t()], keyword()) :: {:ok, corpus()}
126
  def corpus(thread_ids, options \\ []) when is_list(thread_ids) do
127
    requested = Enum.map(thread_ids, &to_string/1)
128
    salt = Keyword.get(options, :salt, "")
129
    revision = Keyword.get(options, :revision, build_revision())
130
131
    {traces, refused} =
132
      Enum.reduce(requested, {[], []}, fn thread_id, {traces, refused} ->
133
        case export(thread_id, salt: salt) do
134
          {:ok, trace} ->
135
            {[trace | traces], refused}
136
137
          {:error, reason} ->
138
            {traces, [%{"thread_id" => thread_id, "reason" => to_string(reason)} | refused]}
42 139
        end
140
      end)
43 141
44
      :error ->
45
        {:error, :thread_not_found}
46
    end
142
    traces = Enum.reverse(traces)
143
144
    {:ok,
145
     %{
146
       "format" => @corpus_format,
147
       "block_size" => @block_size,
148
       "hash_id_scope" => @hash_id_scope,
149
       "code_revision" => revision,
150
       "salt_digest" => digest(salt),
151
       "requested_thread_ids" => requested,
152
       "included_thread_ids" => Enum.map(traces, & &1["id"]),
153
       "refused" => Enum.reverse(refused),
154
       "traces" => traces,
155
       "prefix_reuse" => prefix_reuse(%{"traces" => traces})
156
     }}
157
  end
158
159
  @doc """
160
  Measures prefix reuse over an exported trace or corpus.
161
162
  For each agent — the main thread and each sub-agent entry — every call after
163
  the agent's first contributes the number of leading blocks it shares with the
164
  call before it. `rate` is those reused blocks over all blocks, which is the
165
  KV-cache hit rate a replay of this traffic would see with a perfect cache.
166
167
  The number is computed from the anonymized block ids, and it is the same
168
  number the raw transcript gives, because the ids carry the source's block
169
  structure. `test/openagents/threads/weka_export_test.exs` computes both and
170
  holds them equal.
171
  """
172
  @spec prefix_reuse(trace() | corpus()) :: %{String.t() => number()}
173
  def prefix_reuse(%{"traces" => traces}) when is_list(traces) do
174
    traces
175
    |> Enum.flat_map(&agent_sequences/1)
176
    |> tally()
177
  end
178
179
  def prefix_reuse(%{"requests" => _requests} = trace) do
180
    trace |> agent_sequences() |> tally()
47 181
  end
48 182
49
  defp do_export(%Thread{} = thread, salt) do
183
  # ── the consent gate ───────────────────────────────────────────────────────
184
185
  defp gate(%Thread{} = thread, options) do
50 186
    if Thread.wide?(thread) do
51
      events = load_events(thread)
52
      session_salt = derive_session_salt(thread, salt)
53
      {event_docs, total_blocks} = build_trace(events, session_salt)
54
55
      document = %{
56
        "format" => @weka_format,
57
        "thread_id" => thread.id,
58
        "generation" => thread.generation,
59
        "visibility" => thread.visibility,
60
        "started_at" => format_dt(thread.started_at),
61
        "completed_at" => format_dt(thread.completed_at),
62
        "event_count" => length(events),
63
        "total_blocks" => total_blocks,
64
        "events" => event_docs
187
      {:ok, build(thread, options)}
188
    else
189
      {:error, :consent_required}
190
    end
191
  end
192
193
  # ── the trace ──────────────────────────────────────────────────────────────
194
195
  defp build(%Thread{} = thread, options) do
196
    salt = Keyword.get(options, :salt, "")
197
    hashes = %{salt: session_salt(thread, salt), assigned: %{}, next: 0}
198
    events = load_events(thread.id)
199
    origin = origin(thread, events)
200
201
    {entries, _hashes} =
202
      walk(events,
203
        hashes: hashes,
204
        model: thread.model,
205
        children: load_consenting_children(thread.id),
206
        origin: origin,
207
        call_start: origin
208
      )
209
210
    %{
211
      "id" => thread.id,
212
      "models" => models(thread, entries),
213
      "block_size" => @block_size,
214
      "hash_id_scope" => @hash_id_scope,
215
      "requests" => entries
216
    }
217
  end
218
219
  defp models(%Thread{model: model}, entries) do
220
    entries
221
    |> Enum.flat_map(&Map.get(&1, "models", []))
222
    |> List.insert_at(0, model)
223
    |> Enum.reject(&is_nil/1)
224
    |> Enum.uniq()
225
    |> Enum.sort()
226
  end
227
228
  # One agent's events become one list of entries. The parent walks with the
229
  # spawn children it may expand; a sub-agent walks with none, because the WEKA
230
  # format nests one level and a grandchild's spawn is only context.
231
  defp walk(events, options) do
232
    accumulator = %{
233
      context: new_context(Keyword.fetch!(options, :hashes).salt),
234
      hashes: Keyword.fetch!(options, :hashes),
235
      model: Keyword.fetch!(options, :model),
236
      children: Keyword.get(options, :children, %{}),
237
      origin: Keyword.fetch!(options, :origin),
238
      call_start: Keyword.fetch!(options, :call_start),
239
      previous_end: nil,
240
      entries: [],
241
      run: []
242
    }
243
244
    final = Enum.reduce(events, accumulator, &step/2)
245
    {Enum.reverse(final.entries), final.hashes}
246
  end
247
248
  defp step(%Event{} = event, accumulator) do
249
    tokens = tokenize(extract_text(event.payload))
250
251
    cond do
252
      terminating?(event.event_type) -> close_request(accumulator, event, tokens)
253
      model_authored?(event.event_type) -> %{accumulator | run: accumulator.run ++ tokens}
254
      true -> absorb_input(accumulator, event, tokens)
255
    end
256
  end
257
258
  defp absorb_input(accumulator, %Event{} = event, tokens) do
259
    {context, hashes} =
260
      absorb(accumulator.context, tokens, accumulator.hashes)
261
262
    accumulator
263
    |> maybe_subagent(event)
264
    |> Map.merge(%{context: context, hashes: hashes, call_start: event.emitted_at})
265
  end
266
267
  defp close_request(accumulator, %Event{} = event, tokens) do
268
    run = accumulator.run ++ tokens
269
    {ids, input_tokens} = prompt(accumulator.context)
270
    call_start = accumulator.call_start
271
272
    request =
273
      %{
274
        "t" => elapsed(accumulator.origin, call_start),
275
        "type" => "n",
276
        "model" => accumulator.model,
277
        "in" => input_tokens,
278
        "out" => length(run),
279
        "hash_ids" => ids,
280
        "api_time" => elapsed(call_start, event.emitted_at)
65 281
      }
282
      |> put_think_time(accumulator.previous_end, call_start)
66 283
67
      {:ok, document}
284
    {context, hashes} = absorb(accumulator.context, run, accumulator.hashes)
285
286
    %{
287
      accumulator
288
      | entries: [request | accumulator.entries],
289
        context: context,
290
        hashes: hashes,
291
        run: [],
292
        call_start: event.emitted_at,
293
        previous_end: event.emitted_at
294
    }
295
  end
296
297
  defp put_think_time(request, nil, _call_start), do: request
298
299
  defp put_think_time(request, previous_end, call_start) do
300
    Map.put(request, "think_time", elapsed(previous_end, call_start))
301
  end
302
303
  # ── sub-agents ─────────────────────────────────────────────────────────────
304
305
  defp maybe_subagent(accumulator, %Event{event_type: "thread.spawn", payload: payload}) do
306
    with child_id when is_binary(child_id) <- spawned_id(payload),
307
         %{thread: child, events: events} <- Map.get(accumulator.children, child_id) do
308
      {inner, hashes} =
309
        walk(events,
310
          hashes: accumulator.hashes,
311
          model: child.model,
312
          origin: accumulator.origin,
313
          call_start: child.started_at || accumulator.call_start
314
        )
315
316
      case inner do
317
        [] -> %{accumulator | hashes: hashes}
318
        requests -> add_subagent(accumulator, hashes, child, events, requests)
319
      end
68 320
    else
69
      {:error, :consent_required}
321
      _absent -> accumulator
70 322
    end
71 323
  end
72 324
73
  defp load_events(%Thread{id: thread_id}) do
74
    from(e in Event,
75
      where: e.thread_id == ^thread_id,
76
      order_by: [asc: e.id]
325
  defp maybe_subagent(accumulator, %Event{}), do: accumulator
326
327
  defp add_subagent(accumulator, hashes, %Thread{} = child, events, requests) do
328
    first = List.first(requests)
329
    last = List.last(requests)
330
    finished = last["t"] + last["api_time"]
331
332
    entry = %{
333
      "t" => first["t"],
334
      "type" => "subagent",
335
      "agent_id" => "thread_" <> child.id,
336
      "subagent_type" => @subagent_type,
337
      "duration_ms" => round(max(finished - first["t"], 0.0) * 1000),
338
      "total_tokens" => Enum.reduce(requests, 0, &(&2 + &1["in"] + &1["out"])),
339
      "tool_use_count" => Enum.count(events, &String.starts_with?(&1.event_type, "tool.")),
340
      "status" => subagent_status(child.status),
341
      "requests" => requests,
342
      "models" => child.model |> List.wrap() |> Enum.sort()
343
    }
344
345
    %{accumulator | entries: [entry | accumulator.entries], hashes: hashes}
346
  end
347
348
  # The reference converter writes "completed" because its source has no other
349
  # word. Ours does: a thread that is still open did not complete, and saying
350
  # so costs nothing.
351
  defp subagent_status("succeeded"), do: "completed"
352
  defp subagent_status("open"), do: "running"
353
  defp subagent_status(status) when is_binary(status), do: status
354
  defp subagent_status(_status), do: "running"
355
356
  defp spawned_id(payload) when is_map(payload) do
357
    case payload["child_thread_id"] do
358
      id when is_binary(id) -> id
359
      _other -> nil
360
    end
361
  end
362
363
  defp spawned_id(_payload), do: nil
364
365
  # ── the block chain ────────────────────────────────────────────────────────
366
367
  defp new_context(salt), do: %{ids: [], chain: salt, tail: [], seen: 0}
368
369
  defp absorb(context, tokens, hashes) do
370
    commit(
371
      %{context | tail: context.tail ++ tokens, seen: context.seen + length(tokens)},
372
      hashes
77 373
    )
78
    |> Repo.all()
79 374
  end
80 375
81
  defp derive_session_salt(%Thread{id: thread_id}, salt) do
82
    :crypto.hash(:sha256, "#{thread_id}:#{salt}")
83
    |> Base.encode16(case: :lower)
376
  defp commit(%{tail: tail} = context, hashes) when length(tail) >= @block_size do
377
    {block, rest} = Enum.split(tail, @block_size)
378
    chain = :crypto.hash(:sha256, context.chain <> "\n" <> Enum.join(block, " "))
379
    {id, hashes} = intern(hashes, Base.encode16(chain, case: :lower))
380
    commit(%{context | tail: rest, chain: chain, ids: [id | context.ids]}, hashes)
84 381
  end
85 382
86
  defp build_trace(events, session_salt) do
87
    events
88
    |> Enum.reduce({[], 0, session_salt}, fn event, {docs, total, prev_hash} ->
89
      text = extract_text(event.payload)
90
      tokens = String.split(text, ~r/\s+/, trim: true)
91
      chunks = Enum.chunk_every(tokens, @chunk_size)
92
      {blocks, next_hash} = hash_chunks(chunks, session_salt, prev_hash)
383
  defp commit(context, hashes), do: {context, hashes}
93 384
94
      event_doc = %{
95
        "id" => event.id,
96
        "event_type" => event.event_type,
97
        "emitted_at" => format_dt(event.emitted_at),
98
        "role" => extract_role(event),
99
        "block_count" => length(blocks),
100
        "blocks" => blocks
101
      }
385
  defp intern(%{assigned: assigned, next: next} = hashes, hash) do
386
    case Map.fetch(assigned, hash) do
387
      {:ok, id} -> {id, hashes}
388
      :error -> {next, %{hashes | assigned: Map.put(assigned, hash, next), next: next + 1}}
389
    end
390
  end
102 391
103
      {[event_doc | docs], total + length(blocks), next_hash}
104
    end)
105
    |> then(fn {docs, total, _hash} -> {Enum.reverse(docs), total} end)
392
  # Whole blocks only, the way the reference converter emits them: the replayed
393
  # prompt is exactly what was hashed. A prompt with no whole block has no hash
394
  # coverage at all, so it reports its true token count instead of zero.
395
  defp prompt(%{ids: []} = context), do: {[], context.seen}
396
397
  defp prompt(context) do
398
    ids = Enum.reverse(context.ids)
399
    {ids, length(ids) * @block_size}
400
  end
401
402
  defp session_salt(%Thread{id: thread_id}, salt) do
403
    :crypto.hash(:sha256, thread_id <> "\n" <> salt)
404
  end
405
406
  defp digest(value) do
407
    :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)
106 408
  end
107 409
108
  defp hash_chunks(chunks, session_salt, initial_hash) do
109
    {blocks, final_hash} =
110
      Enum.reduce(chunks, {[], initial_hash}, fn chunk, {blocks, prev} ->
111
        chunk_text = Enum.join(chunk, " ")
410
  # ── the measurement ────────────────────────────────────────────────────────
411
412
  defp agent_sequences(%{"requests" => requests}) do
413
    main = requests |> Enum.reject(&(&1["type"] == "subagent")) |> Enum.map(& &1["hash_ids"])
112 414
113
        hash =
114
          :crypto.hash(:sha256, session_salt <> prev <> chunk_text)
115
          |> Base.encode16(case: :lower)
116
          |> String.slice(0, @hash_display)
415
    inner =
416
      requests
417
      |> Enum.filter(&(&1["type"] == "subagent"))
418
      |> Enum.map(fn entry -> Enum.map(entry["requests"], & &1["hash_ids"]) end)
419
420
    Enum.reject([main | inner], &(&1 == []))
421
  end
117 422
118
        {[hash | blocks], hash}
423
  defp tally(sequences) do
424
    {requests, blocks, reused} =
425
      Enum.reduce(sequences, {0, 0, 0}, fn sequence, {requests, blocks, reused} ->
426
        {sequence_blocks, sequence_reused} = sequence_reuse(sequence)
427
        {requests + length(sequence), blocks + sequence_blocks, reused + sequence_reused}
119 428
      end)
120 429
121
    {Enum.reverse(blocks), final_hash}
430
    %{
431
      "requests" => requests,
432
      "blocks" => blocks,
433
      "reused_blocks" => reused,
434
      "rate" => if(blocks == 0, do: 0.0, else: reused / blocks)
435
    }
122 436
  end
123 437
438
  defp sequence_reuse(sequence) do
439
    sequence
440
    |> Enum.reduce({0, 0, nil}, fn ids, {blocks, reused, previous} ->
441
      shared = if previous, do: common_prefix(previous, ids), else: 0
442
      {blocks + length(ids), reused + shared, ids}
443
    end)
444
    |> then(fn {blocks, reused, _previous} -> {blocks, reused} end)
445
  end
446
447
  defp common_prefix(earlier, later) do
448
    earlier |> Enum.zip(later) |> Enum.take_while(fn {a, b} -> a == b end) |> length()
449
  end
450
451
  # ── the transcript ─────────────────────────────────────────────────────────
452
453
  defp load_events(thread_id) do
454
    Repo.all(
455
      from(event in Event, where: event.thread_id == ^thread_id, order_by: [asc: event.id])
456
    )
457
  end
458
459
  defp load_consenting_children(thread_id) do
460
    wide = Thread.wide_visibilities()
461
462
    children =
463
      Repo.all(
464
        from(thread in Thread,
465
          where: thread.parent_thread_id == ^thread_id and thread.visibility in ^wide,
466
          order_by: [asc: thread.inserted_at]
467
        )
468
      )
469
470
    grouped =
471
      children
472
      |> Enum.map(& &1.id)
473
      |> child_events()
474
      |> Enum.group_by(& &1.thread_id)
475
476
    Map.new(children, fn child ->
477
      {child.id, %{thread: child, events: Map.get(grouped, child.id, [])}}
478
    end)
479
  end
480
481
  defp child_events([]), do: []
482
483
  defp child_events(ids) do
484
    Repo.all(from(event in Event, where: event.thread_id in ^ids, order_by: [asc: event.id]))
485
  end
486
487
  defp origin(%Thread{started_at: %DateTime{} = started_at}, _events), do: started_at
488
  defp origin(%Thread{}, [%Event{emitted_at: emitted_at} | _rest]), do: emitted_at
489
  defp origin(%Thread{inserted_at: inserted_at}, []), do: inserted_at
490
491
  defp elapsed(from, to) do
492
    max(DateTime.diff(to, from, :microsecond) / 1_000_000, 0.0)
493
  end
494
495
  defp tokenize(text), do: String.split(text, ~r/\s+/, trim: true)
496
497
  # A payload is a JSON object carrying whatever happened. Where it names its
498
  # text, that is the text; where it does not, the encoded object stands in, so
499
  # a structured record still contributes its shape rather than being skipped.
500
  # Either way what leaves is a hash of it.
124 501
  defp extract_text(payload) when is_map(payload) do
125 502
    case payload["content"] || payload["text"] || payload["message"] || payload["output"] do
126 503
      value when is_binary(value) -> value

@@ -132,18 +509,16 @@ defmodule OpenAgents.Threads.WekaExport do

132 509
  defp extract_text(payload) when is_binary(payload), do: payload
133 510
  defp extract_text(_payload), do: ""
134 511
135
  defp extract_role(%Event{event_type: type, payload: payload}) do
136
    case payload do
137
      %{"role" => role} when is_binary(role) -> role
138
      _ -> role_from_type(type)
139
    end
140
  end
512
  # One model call ends when the model either asks for a tool or answers.
513
  defp terminating?("turn.assistant"), do: true
514
  defp terminating?("tool." <> _rest), do: true
515
  defp terminating?(_type), do: false
141 516
142
  defp role_from_type("turn." <> rest), do: rest
143
  defp role_from_type("tool." <> _), do: "tool"
144
  defp role_from_type("thread." <> _), do: "system"
145
  defp role_from_type(_), do: "unknown"
517
  defp model_authored?("turn.reasoning"), do: true
518
  defp model_authored?(type), do: terminating?(type)
146 519
147
  defp format_dt(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
148
  defp format_dt(nil), do: nil
520
  defp build_revision do
521
    version = to_string(Application.spec(:openagents, :vsn) || "unknown")
522
    version <> "+" <> to_string(BuildInfo.revision())
523
  end
149 524
end
test/mix/tasks/openagents_weka_export_test.exs added +91

@@ -0,0 +1,91 @@

1
defmodule Mix.Tasks.Openagents.Weka.ExportTest do
2
  @moduledoc """
3
  The operator surface for the WEKA corpus. The consent gate is proven again
4
  here rather than only at `OpenAgents.Threads.WekaExport`, because this is the
5
  path a corpus actually leaves by, and a gate that holds in the context and
6
  not at the surface is not a gate.
7
  """
8
9
  use OpenAgents.DataCase
10
11
  import ExUnit.CaptureIO
12
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
13
14
  alias OpenAgents.Repo
15
  alias OpenAgents.Threads
16
  alias OpenAgents.Threads.Event
17
18
  test "writes a corpus from a recorded id file and refuses a dark thread" do
19
    consenting = session("weka-task-open", "ledger")
20
    dark = session("weka-task-dark", "dark")
21
22
    directory = tmp_dir()
23
    set = Path.join(directory, "corpus-set.txt")
24
    out = Path.join(directory, "corpus.json")
25
26
    File.write!(set, """
27
    # the recorded thread-id set
28
    #{consenting.id}
29
    #{dark.id}
30
    """)
31
32
    output =
33
      capture_io(fn ->
34
        Mix.Tasks.Openagents.Weka.Export.run(["--threads", set, "--out", out])
35
      end)
36
37
    assert output =~ "1 trace(s)"
38
    assert output =~ "1 refused"
39
    assert output =~ "refused #{dark.id}: consent_required"
40
41
    corpus = out |> File.read!() |> Jason.decode!()
42
43
    assert corpus["requested_thread_ids"] == [consenting.id, dark.id]
44
    assert corpus["included_thread_ids"] == [consenting.id]
45
    assert corpus["refused"] == [%{"thread_id" => dark.id, "reason" => "consent_required"}]
46
    assert is_binary(corpus["code_revision"])
47
48
    refute String.contains?(File.read!(out), "zqdark")
49
  after
50
    Mix.Task.reenable("openagents.weka.export")
51
  end
52
53
  test "refuses to run without a thread-id set" do
54
    assert_raise Mix.Error, fn -> Mix.Tasks.Openagents.Weka.Export.run([]) end
55
  after
56
    Mix.Task.reenable("openagents.weka.export")
57
  end
58
59
  defp session(handle, visibility) do
60
    user = github_user(handle)
61
    marker = if visibility == "dark", do: "zqdark", else: "zqopen"
62
    {:ok, thread} = Threads.open(user, "Objective", visibility: visibility)
63
64
    insert_event(thread, "turn.user", %{"content" => words(marker <> "u", 200)})
65
    insert_event(thread, "turn.assistant", %{"output" => words(marker <> "a", 120)})
66
67
    thread
68
  end
69
70
  defp insert_event(thread, event_type, payload) do
71
    %Event{}
72
    |> Event.changeset(%{
73
      thread_id: thread.id,
74
      event_type: event_type,
75
      payload: payload,
76
      emitted_at: DateTime.utc_now()
77
    })
78
    |> Repo.insert!()
79
  end
80
81
  defp words(prefix, count), do: Enum.map_join(1..count, " ", &"#{prefix}#{&1}")
82
83
  defp tmp_dir do
84
    directory =
85
      Path.join(System.tmp_dir!(), "weka-export-#{System.unique_integer([:positive])}")
86
87
    File.mkdir_p!(directory)
88
    on_exit(fn -> File.rm_rf!(directory) end)
89
    directory
90
  end
91
end
test/openagents/threads/weka_export_test.exs modified +343 -86

@@ -1,6 +1,16 @@

1 1
defmodule OpenAgents.Threads.WekaExportTest do
2 2
  @moduledoc """
3
  Tests for `OpenAgents.Threads.WekaExport`.
3
  The proof behind `OpenAgents.Threads.WekaExport` and the export half of
4
  THREAD-002.
5
6
  Two claims carry the weight. The first is the consent gate: a thread its
7
  owner left `dark` — and a `dark` child of a consenting parent — contributes
8
  nothing to a trace or a corpus, not a request, not a block count, not a
9
  token. The second is fidelity: the exported block ids carry the source
10
  session's prefix structure, so prefix reuse measured on the anonymized trace
11
  is the same number as prefix reuse measured on the raw transcript. Both are
12
  falsifiable — an exporter that chunked per event, salted per request, or
13
  copied text through would fail one of them.
4 14
  """
5 15
6 16
  use OpenAgents.DataCase, async: true

@@ -12,131 +22,378 @@ defmodule OpenAgents.Threads.WekaExportTest do

12 22
  alias OpenAgents.Threads.Event
13 23
  alias OpenAgents.Threads.WekaExport
14 24
15
  describe "export/2" do
16
    test "a ledger-visible thread exports a deterministic, content-free document" do
17
      user = github_user("weka-export-consent")
18
      {:ok, thread} = Threads.open(user, "Test objective", visibility: "ledger")
19
20
      fixed_dt = DateTime.from_naive!(~N[2026-08-24 12:00:00.000000], "Etc/UTC")
21
      thread = thread |> change(started_at: fixed_dt) |> Repo.update!()
22
23
      insert_event(thread, "turn.user", %{"content" => "secret user prompt"}, fixed_dt)
24
25
      insert_event(
26
        thread,
27
        "turn.assistant",
28
        %{"output" => long_text_with("confidential code")},
29
        fixed_dt
30
      )
31
32
      assert {:ok, doc} = WekaExport.export(thread, "fixed-salt")
33
34
      assert doc["format"] == "weka-trace-v1"
35
      assert doc["thread_id"] == thread.id
36
      assert doc["generation"] == thread.generation
37
      assert doc["visibility"] == "ledger"
38
      assert doc["started_at"] == DateTime.to_iso8601(fixed_dt)
39
      assert doc["completed_at"] == nil
40
      assert doc["event_count"] == 4
41
42
      json = Jason.encode!(doc)
43
      refute String.contains?(json, "secret user prompt")
44
      refute String.contains?(json, "confidential code")
45
46
      for event <- doc["events"] do
47
        assert is_binary(event["emitted_at"])
48
        assert is_integer(event["block_count"])
49
        assert is_list(event["blocks"])
50
        assert length(event["blocks"]) == event["block_count"]
25
  @block 64
26
  @base ~U[2026-08-25 10:00:00.000000Z]
27
28
  describe "export/2 — the WEKA v1 document" do
29
    test "a consenting thread exports a replayable weka trace" do
30
      thread = coding_session("weka-shape")
31
32
      assert {:ok, trace} = WekaExport.export(thread)
33
34
      assert trace["id"] == thread.id
35
      assert trace["block_size"] == @block
36
      assert trace["hash_id_scope"] == "local"
37
      assert is_list(trace["models"])
38
      assert thread.model in trace["models"]
39
40
      requests = trace["requests"]
41
      assert length(requests) == 3
42
43
      for request <- requests do
44
        assert request["type"] == "n"
45
        assert is_binary(request["model"])
46
        assert is_integer(request["in"]) and request["in"] >= 0
47
        assert is_integer(request["out"]) and request["out"] > 0
48
        assert is_list(request["hash_ids"])
49
        assert Enum.all?(request["hash_ids"], &is_integer/1)
50
        assert is_float(request["t"]) and request["t"] >= 0.0
51
        assert is_float(request["api_time"]) and request["api_time"] > 0.0
51 52
      end
52 53
53
      user_event = Enum.find(doc["events"], &(&1["event_type"] == "turn.user"))
54
      assistant_event = Enum.find(doc["events"], &(&1["event_type"] == "turn.assistant"))
54
      # The hashed prompt is whole blocks, exactly as the reference converter
55
      # emits it, so `in` is what the replayer will actually send.
56
      for request <- requests, request["hash_ids"] != [] do
57
        assert request["in"] == length(request["hash_ids"]) * @block
58
      end
59
60
      # The first call has nothing to wait for; every later one records the gap.
61
      [first | rest] = requests
62
      refute Map.has_key?(first, "think_time")
63
      assert Enum.all?(rest, &(&1["think_time"] >= 0.0))
64
    end
65
66
    test "no transcript text survives the export" do
67
      thread = coding_session("weka-content")
55 68
56
      assert user_event["role"] == "user"
57
      assert user_event["block_count"] == 1
69
      assert {:ok, trace} = WekaExport.export(thread)
70
71
      encoded = Jason.encode!(trace)
72
73
      for marker <- ~w(zqu1 zqr1 zqt1 zqa1 zqobj1) do
74
        refute String.contains?(encoded, marker),
75
               "#{marker} reached the exported trace"
76
      end
77
    end
58 78
59
      assert user_event["emitted_at"] ==
60
               DateTime.to_iso8601(DateTime.truncate(fixed_dt, :microsecond))
79
    test "the same thread exports byte for byte" do
80
      thread = coding_session("weka-repro")
61 81
62
      assert assistant_event["role"] == "assistant"
63
      assert assistant_event["block_count"] == 3
82
      assert {:ok, first} = WekaExport.export(thread, salt: "pinned")
83
      assert {:ok, second} = WekaExport.export(thread, salt: "pinned")
64 84
65
      total = Enum.reduce(doc["events"], 0, &(&2 + &1["block_count"]))
66
      assert doc["total_blocks"] == total
85
      assert Jason.encode!(first) == Jason.encode!(second)
67 86
    end
68 87
69
    test "a dark thread refuses export" do
70
      user = github_user("weka-export-dark")
88
    test "an unknown or malformed thread id refuses" do
89
      assert WekaExport.export(Ecto.UUID.generate()) == {:error, :thread_not_found}
90
      assert WekaExport.export("not-a-uuid") == {:error, :thread_not_found}
91
    end
92
  end
93
94
  describe "export/2 — the consent gate" do
95
    test "a dark thread refuses, by struct and by id" do
96
      user = github_user("weka-dark")
71 97
      {:ok, thread} = Threads.open(user, "Dark work")
72 98
73 99
      assert WekaExport.export(thread) == {:error, :consent_required}
74 100
      assert WekaExport.export(thread.id) == {:error, :consent_required}
75 101
    end
76 102
77
    test "an unknown or invalid thread id refuses export" do
78
      assert WekaExport.export(Ecto.UUID.generate()) == {:error, :thread_not_found}
79
      assert WekaExport.export("not-a-uuid") == {:error, :thread_not_found}
103
    test "a dark child of a consenting parent contributes nothing" do
104
      user = github_user("weka-dark-child")
105
      {:ok, parent} = Threads.open(user, "Parent objective", visibility: "ledger")
106
107
      {:ok, dark} =
108
        Threads.open(user, "zqdarkchild objective",
109
          parent_thread_id: parent.id,
110
          visibility: "dark"
111
        )
112
113
      insert_event(dark, "turn.user", %{"content" => words("darkuser", 200)})
114
      insert_event(dark, "turn.assistant", %{"output" => words("darkanswer", 200)})
115
116
      insert_event(parent, "turn.user", %{"content" => words("u", 200)})
117
      insert_event(parent, "turn.assistant", %{"output" => words("a", 120)})
118
      restamp(parent)
119
      restamp(dark)
120
121
      assert {:ok, trace} = WekaExport.export(parent)
122
123
      refute Enum.any?(trace["requests"], &(&1["type"] == "subagent"))
124
125
      encoded = Jason.encode!(trace)
126
      refute String.contains?(encoded, "zqdarkuser1")
127
      refute String.contains?(encoded, "zqdarkanswer1")
128
      refute String.contains?(encoded, dark.id)
80 129
    end
81 130
82
    test "the same thread and salt export to a byte-identical document" do
83
      user = github_user("weka-export-repro")
84
      {:ok, thread} = Threads.open(user, "Repro", visibility: "ledger")
85
      insert_event(thread, "turn.user", %{"content" => "hello"}, DateTime.utc_now())
131
    test "a consenting child becomes a sub-agent entry" do
132
      user = github_user("weka-child")
133
      {:ok, parent} = Threads.open(user, "Parent objective", visibility: "ledger")
134
135
      {:ok, child} =
136
        Threads.open(user, "Child objective",
137
          parent_thread_id: parent.id,
138
          visibility: "ledger"
139
        )
140
141
      insert_event(child, "turn.user", %{"content" => words("cu", 200)})
142
      insert_event(child, "tool.ran", %{"content" => words("ct", 150)})
143
      insert_event(child, "turn.assistant", %{"output" => words("ca", 90)})
144
145
      insert_event(parent, "turn.user", %{"content" => words("u", 200)})
146
      insert_event(parent, "turn.assistant", %{"output" => words("a", 120)})
147
      restamp(parent)
148
      restamp(child)
149
150
      assert {:ok, trace} = WekaExport.export(parent)
151
152
      assert entry = Enum.find(trace["requests"], &(&1["type"] == "subagent"))
153
      assert entry["subagent_type"] == "child_thread"
154
      assert entry["status"] == "running"
155
      assert entry["tool_use_count"] == 1
156
      assert entry["models"] == [child.model]
157
      assert length(entry["requests"]) == 2
158
      assert entry["total_tokens"] > 0
159
      assert entry["duration_ms"] >= 0
160
      assert Enum.all?(entry["requests"], &(&1["type"] == "n"))
161
      assert child.model in trace["models"]
162
    end
163
  end
86 164
87
      salt = "same-salt"
88
      assert WekaExport.export(thread, salt) == WekaExport.export(thread, salt)
165
  describe "export/2 — block identity" do
166
    test "each call's blocks extend the call before it" do
167
      thread = coding_session("weka-prefix")
168
169
      assert {:ok, trace} = WekaExport.export(thread)
170
171
      trace["requests"]
172
      |> Enum.map(& &1["hash_ids"])
173
      |> Enum.chunk_every(2, 1, :discard)
174
      |> Enum.each(fn [earlier, later] ->
175
        assert length(later) > length(earlier)
176
        assert Enum.take(later, length(earlier)) == earlier
177
      end)
89 178
    end
90 179
91
    test "different salts produce different block hashes" do
92
      user = github_user("weka-export-salt")
93
      {:ok, thread} = Threads.open(user, "Salt", visibility: "ledger")
94
      insert_event(thread, "turn.user", %{"content" => "salted"}, DateTime.utc_now())
180
    test "the same block text at a different context position gets a different id" do
181
      user = github_user("weka-chain")
182
      {:ok, thread} = Threads.open(user, "Chained", visibility: "ledger")
183
184
      repeated = words("rep", @block)
185
186
      insert_event(thread, "turn.user", %{"content" => repeated})
187
      insert_event(thread, "turn.assistant", %{"output" => words("pad", @block)})
188
      insert_event(thread, "turn.user", %{"content" => repeated})
189
      insert_event(thread, "turn.assistant", %{"output" => words("tail", 8)})
190
      restamp(thread)
95 191
96
      {:ok, doc1} = WekaExport.export(thread, "salt-a")
97
      {:ok, doc2} = WekaExport.export(thread, "salt-b")
192
      assert {:ok, trace} = WekaExport.export(thread)
98 193
99
      blocks1 = doc1 |> Map.get("events") |> Enum.flat_map(& &1["blocks"])
100
      blocks2 = doc2 |> Map.get("events") |> Enum.flat_map(& &1["blocks"])
194
      ids = trace["requests"] |> List.last() |> Map.fetch!("hash_ids")
101 195
102
      refute blocks1 == blocks2
196
      # The identical 64 tokens appear twice in one context. Chained hashing
197
      # means the second copy hashes over a different prefix, so the block that
198
      # would hit a warm KV cache and the block that would not are told apart.
199
      assert length(ids) == length(Enum.uniq(ids))
103 200
    end
201
  end
104 202
105
    test "multi-turn context grows and block hashes chain" do
106
      user = github_user("weka-export-chain")
107
      {:ok, thread} = Threads.open(user, "Chain", visibility: "ledger")
203
  describe "prefix_reuse/1" do
204
    test "the anonymized trace reports the source session's prefix reuse" do
205
      thread = coding_session("weka-reuse")
108 206
109
      fixed_dt = DateTime.from_naive!(~N[2026-08-24 12:00:00.000000], "Etc/UTC")
110
      insert_event(thread, "turn.user", %{"content" => "word"}, fixed_dt)
111
      insert_event(thread, "turn.assistant", %{"output" => long_text_with("end")}, fixed_dt)
207
      assert {:ok, trace} = WekaExport.export(thread)
112 208
113
      assert {:ok, doc} = WekaExport.export(thread, "chain-salt")
209
      measured = WekaExport.prefix_reuse(trace)
210
      source = source_prefix_reuse(thread)
114 211
115
      user_event = Enum.find(doc["events"], &(&1["event_type"] == "turn.user"))
116
      assistant_event = Enum.find(doc["events"], &(&1["event_type"] == "turn.assistant"))
212
      assert measured["blocks"] == source.blocks
213
      assert measured["reused_blocks"] == source.reused
214
      assert measured["requests"] == 3
215
      assert source.blocks > 0
216
      assert source.reused > 0
217
      assert measured["rate"] == source.reused / source.blocks
117 218
118
      assert user_event["block_count"] == 1
119
      assert assistant_event["block_count"] == 3
219
      # Contexts only grow, so every block of every call but the last is
220
      # carried into the call after it. That identity is what a serving stack's
221
      # KV cache would see, and it is derived here rather than assumed.
222
      last = trace["requests"] |> List.last() |> Map.fetch!("hash_ids")
223
      assert measured["reused_blocks"] == measured["blocks"] - length(last)
224
    end
225
  end
120 226
121
      all_blocks = doc["events"] |> Enum.flat_map(& &1["blocks"])
122
      assert length(all_blocks) == length(Enum.uniq(all_blocks))
123
      assert doc["total_blocks"] == length(all_blocks)
227
  describe "corpus/2" do
228
    test "a corpus is reproducible from its recorded thread-id set and revision" do
229
      first = coding_session("weka-corpus-a")
230
      second = coding_session("weka-corpus-b")
231
232
      assert {:ok, corpus} =
233
               WekaExport.corpus([first.id, second.id], salt: "pinned", revision: "rev-1")
234
235
      assert corpus["format"] == "openagents-weka-corpus-v1"
236
      assert corpus["block_size"] == @block
237
      assert corpus["hash_id_scope"] == "local"
238
      assert corpus["code_revision"] == "rev-1"
239
      assert corpus["requested_thread_ids"] == [first.id, second.id]
240
      assert corpus["included_thread_ids"] == [first.id, second.id]
241
      assert corpus["refused"] == []
242
      assert length(corpus["traces"]) == 2
243
      assert corpus["prefix_reuse"]["blocks"] > 0
244
245
      assert {:ok, again} =
246
               WekaExport.corpus(corpus["requested_thread_ids"],
247
                 salt: "pinned",
248
                 revision: corpus["code_revision"]
249
               )
250
251
      assert Jason.encode!(again) == Jason.encode!(corpus)
252
    end
253
254
    test "a corpus records its revision without being told one" do
255
      thread = coding_session("weka-corpus-rev")
256
257
      assert {:ok, corpus} = WekaExport.corpus([thread.id])
258
      assert is_binary(corpus["code_revision"])
259
      assert corpus["code_revision"] != ""
260
    end
261
262
    test "a named thread that has not consented is refused, not exported" do
263
      consenting = coding_session("weka-corpus-open")
264
265
      user = github_user("weka-corpus-dark")
266
      {:ok, dark} = Threads.open(user, "zqdarkobjective")
267
      insert_event(dark, "turn.user", %{"content" => words("darkuser", 200)})
268
      insert_event(dark, "turn.assistant", %{"output" => words("darkanswer", 200)})
269
      restamp(dark)
270
271
      missing = Ecto.UUID.generate()
272
273
      assert {:ok, corpus} = WekaExport.corpus([consenting.id, dark.id, missing])
274
275
      assert corpus["included_thread_ids"] == [consenting.id]
276
277
      assert corpus["refused"] == [
278
               %{"thread_id" => dark.id, "reason" => "consent_required"},
279
               %{"thread_id" => missing, "reason" => "thread_not_found"}
280
             ]
281
282
      encoded = Jason.encode!(corpus)
283
      refute String.contains?(encoded, "zqdarkuser1")
284
      refute String.contains?(encoded, "zqdarkanswer1")
285
      assert Enum.all?(corpus["traces"], &(&1["id"] != dark.id))
124 286
    end
125 287
  end
126 288
127
  defp insert_event(thread, event_type, payload, emitted_at) do
289
  # ── fixtures ───────────────────────────────────────────────────────────────
290
291
  # One coder-shaped session: a question, the model's reasoning, a tool call,
292
  # more reasoning, an answer, a follow-up question, a second answer. Three
293
  # model calls, each carrying everything recorded before it.
294
  defp coding_session(handle) do
295
    user = github_user(handle)
296
    {:ok, thread} = Threads.open(user, "Objective " <> words("obj", 10), visibility: "ledger")
297
298
    insert_event(thread, "turn.user", %{"content" => words("u", 200)})
299
    insert_event(thread, "turn.reasoning", %{"content" => words("r", 100)})
300
    insert_event(thread, "tool.ran", %{"content" => words("t", 150)})
301
    insert_event(thread, "turn.reasoning", %{"content" => words("r2", 80)})
302
    insert_event(thread, "turn.assistant", %{"output" => words("a", 120)})
303
    insert_event(thread, "turn.user", %{"content" => words("u2", 90)})
304
    insert_event(thread, "turn.assistant", %{"output" => words("a2", 70)})
305
306
    restamp(thread)
307
  end
308
309
  defp insert_event(thread, event_type, payload) do
128 310
    %Event{}
129 311
    |> Event.changeset(%{
130 312
      thread_id: thread.id,
131 313
      event_type: event_type,
132 314
      payload: payload,
133
      emitted_at: emitted_at
315
      emitted_at: @base
134 316
    })
135 317
    |> Repo.insert!()
136 318
  end
137 319
138
  defp long_text_with(suffix) do
139
    words = List.duplicate("word", 130) ++ [suffix]
140
    Enum.join(words, " ")
320
  # Every event one second apart from a fixed start, so `t`, `api_time`, and
321
  # `think_time` are facts about the fixture rather than about the clock.
322
  defp restamp(thread) do
323
    thread.id
324
    |> events()
325
    |> Enum.with_index()
326
    |> Enum.each(fn {event, index} ->
327
      event
328
      |> change(emitted_at: DateTime.add(@base, index + 1, :second))
329
      |> Repo.update!()
330
    end)
331
332
    thread |> change(started_at: @base) |> Repo.update!()
333
  end
334
335
  defp events(thread_id) do
336
    Repo.all(
337
      from(event in Event, where: event.thread_id == ^thread_id, order_by: [asc: event.id])
338
    )
339
  end
340
341
  defp words(prefix, count) do
342
    Enum.map_join(1..count, " ", &"zq#{prefix}#{&1}")
343
  end
344
345
  # ── an independent reading of the source ───────────────────────────────────
346
347
  # Rebuild each model call's prompt from the raw transcript, cut it into
348
  # 64-token blocks of plain text, and count the leading blocks each call
349
  # shares with the call before it. No hashing and no id remapping: if the
350
  # exported hash ids carry the source's block structure, this number and
351
  # `prefix_reuse/1`'s number are the same one.
352
  defp source_prefix_reuse(thread) do
353
    {prompts, _context, _run} =
354
      thread.id
355
      |> events()
356
      |> Enum.reduce({[], [], []}, fn event, {prompts, context, run} ->
357
        tokens = String.split(source_text(event.payload), ~r/\s+/, trim: true)
358
359
        cond do
360
          terminating?(event.event_type) ->
361
            {[context | prompts], context ++ run ++ tokens, []}
362
363
          model_authored?(event.event_type) ->
364
            {prompts, context, run ++ tokens}
365
366
          true ->
367
            {prompts, context ++ tokens, run}
368
        end
369
      end)
370
371
    prompts
372
    |> Enum.reverse()
373
    |> Enum.map(&Enum.chunk_every(&1, @block, @block, :discard))
374
    |> Enum.reduce({0, 0, nil}, fn blocks, {reused, total, previous} ->
375
      shared = if previous, do: common_prefix(previous, blocks), else: 0
376
      {reused + shared, total + length(blocks), blocks}
377
    end)
378
    |> then(fn {reused, total, _previous} -> %{reused: reused, blocks: total} end)
141 379
  end
380
381
  defp common_prefix(earlier, later) do
382
    earlier |> Enum.zip(later) |> Enum.take_while(fn {a, b} -> a == b end) |> length()
383
  end
384
385
  defp source_text(payload) do
386
    case payload["content"] || payload["text"] || payload["message"] || payload["output"] do
387
      value when is_binary(value) -> value
388
      nil -> Jason.encode!(payload)
389
      value -> Jason.encode!(value)
390
    end
391
  end
392
393
  defp terminating?("turn.assistant"), do: true
394
  defp terminating?("tool." <> _rest), do: true
395
  defp terminating?(_type), do: false
396
397
  defp model_authored?("turn.reasoning"), do: true
398
  defp model_authored?(type), do: terminating?(type)
142 399
end

This page updates live while a promote is in flight · changelog