llm-experiment 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +40 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/exe/llmx +6 -0
- data/lib/llm-experiment.rb +3 -0
- data/lib/llm_experiment/auth.rb +143 -0
- data/lib/llm_experiment/cleaner.rb +183 -0
- data/lib/llm_experiment/cli/build_command.rb +37 -0
- data/lib/llm_experiment/cli/clean_command.rb +42 -0
- data/lib/llm_experiment/cli/doctor_command.rb +30 -0
- data/lib/llm_experiment/cli/login_command.rb +25 -0
- data/lib/llm_experiment/cli/metrics_command.rb +32 -0
- data/lib/llm_experiment/cli/new_command.rb +21 -0
- data/lib/llm_experiment/cli/parse_command.rb +77 -0
- data/lib/llm_experiment/cli/run_command.rb +74 -0
- data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
- data/lib/llm_experiment/cli/shell_command.rb +35 -0
- data/lib/llm_experiment/cli/status_command.rb +69 -0
- data/lib/llm_experiment/cli/version_command.rb +19 -0
- data/lib/llm_experiment/cli.rb +120 -0
- data/lib/llm_experiment/container.rb +178 -0
- data/lib/llm_experiment/doctor.rb +126 -0
- data/lib/llm_experiment/experiment.rb +163 -0
- data/lib/llm_experiment/grid.rb +117 -0
- data/lib/llm_experiment/image_builder/app.rb +267 -0
- data/lib/llm_experiment/image_builder/base.rb +64 -0
- data/lib/llm_experiment/metrics_report.rb +138 -0
- data/lib/llm_experiment/pins.rb +22 -0
- data/lib/llm_experiment/sanitizer.rb +144 -0
- data/lib/llm_experiment/scaffold.rb +43 -0
- data/lib/llm_experiment/shell.rb +60 -0
- data/lib/llm_experiment/stats.rb +69 -0
- data/lib/llm_experiment/transcript/claude.rb +104 -0
- data/lib/llm_experiment/transcript/codex.rb +96 -0
- data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
- data/lib/llm_experiment/transcript/parser.rb +105 -0
- data/lib/llm_experiment/transcript.rb +27 -0
- data/lib/llm_experiment/trial.rb +204 -0
- data/lib/llm_experiment/version.rb +5 -0
- data/lib/llm_experiment.rb +70 -0
- data/templates/README.md.erb +26 -0
- data/templates/base.Containerfile +120 -0
- data/templates/experiment.yml.erb +30 -0
- data/templates/gitignore +3 -0
- data/templates/runner.rb +305 -0
- metadata +92 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module LLMExperiment
|
|
7
|
+
# Runs commands. Kept as one small module so container-facing classes can
|
|
8
|
+
# take a `shell:` argument and tests can pass a recorder instead.
|
|
9
|
+
module Shell
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def log(message)
|
|
13
|
+
warn "[llmx] #{message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Command echo, as opposed to progress. Every container invocation goes
|
|
17
|
+
# through here, which is a wall of text during a grid, so it is off unless
|
|
18
|
+
# asked for. Progress messages use `log` and always print.
|
|
19
|
+
def log_command(message)
|
|
20
|
+
log(message) if LLMExperiment.verbose
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Run a command, stream its output, raise unless it succeeds.
|
|
24
|
+
#
|
|
25
|
+
# `log_as` replaces what gets echoed, for a command carrying an argument
|
|
26
|
+
# nobody wants in their scrollback: a trial passes its whole prompt as one
|
|
27
|
+
# base64 environment value, which is kilobytes of noise on every run.
|
|
28
|
+
def sh(*cmd, allow_failure: false, log_as: nil)
|
|
29
|
+
log_command(log_as || cmd.shelljoin)
|
|
30
|
+
ok = system(*cmd)
|
|
31
|
+
return ok if ok || allow_failure
|
|
32
|
+
|
|
33
|
+
raise Error, "command failed: #{log_as || cmd.shelljoin}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Hand the terminal to a command (login device flows need a real TTY).
|
|
37
|
+
def interactive(*cmd)
|
|
38
|
+
system(*cmd)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run a command and capture stdout. Raises unless it succeeds.
|
|
42
|
+
def capture(*cmd, allow_failure: false)
|
|
43
|
+
out, err, status = Open3.capture3(*cmd)
|
|
44
|
+
raise Error, "command failed: #{cmd.shelljoin}\n#{err}" unless status.success? || allow_failure
|
|
45
|
+
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Run a command and report whether it worked.
|
|
50
|
+
def try(*cmd)
|
|
51
|
+
out, err, status = Open3.capture3(*cmd)
|
|
52
|
+
[out + err, status.success?]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def try_input(input, *cmd)
|
|
56
|
+
out, err, status = Open3.capture3(*cmd, stdin_data: input)
|
|
57
|
+
[out + err, status.success?]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# Exact, not approximate. With five tasks per cell the normal approximation to
|
|
5
|
+
# the Mann-Whitney U is not trustworthy, and the whole permutation set is
|
|
6
|
+
# small enough to enumerate: C(10,5) is 252. So the p-values here are counted,
|
|
7
|
+
# not estimated.
|
|
8
|
+
module Stats
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def median(values)
|
|
12
|
+
return nil if values.empty?
|
|
13
|
+
|
|
14
|
+
sorted = values.sort
|
|
15
|
+
mid = sorted.size / 2
|
|
16
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Mid-ranks, so ties do not silently inflate the statistic.
|
|
20
|
+
def ranks(values)
|
|
21
|
+
indexed = values.each_with_index.sort_by { |v, _| v }
|
|
22
|
+
result = Array.new(values.size)
|
|
23
|
+
i = 0
|
|
24
|
+
while i < indexed.size
|
|
25
|
+
j = i
|
|
26
|
+
j += 1 while j + 1 < indexed.size && indexed[j + 1][0] == indexed[i][0]
|
|
27
|
+
mid = ((i + 1) + (j + 1)) / 2.0
|
|
28
|
+
(i..j).each { |k| result[indexed[k][1]] = mid }
|
|
29
|
+
i = j + 1
|
|
30
|
+
end
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# U for sample a against sample b.
|
|
35
|
+
def u_statistic(a, b)
|
|
36
|
+
all = a + b
|
|
37
|
+
r = ranks(all)
|
|
38
|
+
rank_sum_a = r.first(a.size).sum
|
|
39
|
+
rank_sum_a - (a.size * (a.size + 1) / 2.0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Exact two-sided p: enumerate every way to split the pooled values into two
|
|
43
|
+
# samples of the observed sizes and count the splits at least as extreme.
|
|
44
|
+
def exact_mann_whitney(a, b)
|
|
45
|
+
n1 = a.size
|
|
46
|
+
n2 = b.size
|
|
47
|
+
return { u: nil, p: nil, note: "empty sample" } if n1.zero? || n2.zero?
|
|
48
|
+
|
|
49
|
+
total = n1 + n2
|
|
50
|
+
combos = (1..total).reduce(1, :*) / ((1..n1).reduce(1, :*) * (1..n2).reduce(1, :*))
|
|
51
|
+
return { u: u_statistic(a, b), p: nil, note: "#{combos} permutations; too many to enumerate" } if combos > 200_000
|
|
52
|
+
|
|
53
|
+
pooled = a + b
|
|
54
|
+
observed = u_statistic(a, b)
|
|
55
|
+
mean_u = n1 * n2 / 2.0
|
|
56
|
+
observed_deviation = (observed - mean_u).abs
|
|
57
|
+
|
|
58
|
+
at_least_as_extreme = 0
|
|
59
|
+
(0...total).to_a.combination(n1).each do |idx|
|
|
60
|
+
set = idx.to_h { |i| [i, true] }
|
|
61
|
+
left = idx.map { |i| pooled[i] }
|
|
62
|
+
right = (0...total).reject { |i| set[i] }.map { |i| pooled[i] }
|
|
63
|
+
at_least_as_extreme += 1 if (u_statistic(left, right) - mean_u).abs >= observed_deviation - 1e-9
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
{ u: observed, p: at_least_as_extreme.to_f / combos, permutations: combos }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
module Transcript
|
|
7
|
+
# Claude Code emits stream-json: one system/init event, one assistant event
|
|
8
|
+
# per model request, and a final result event carrying the totals.
|
|
9
|
+
#
|
|
10
|
+
# Usage is reported per model request, so the context the very first request
|
|
11
|
+
# carried - the auto-attachment signature - is directly observable here.
|
|
12
|
+
module Claude
|
|
13
|
+
EDIT_TOOLS = %w[Edit Write NotebookEdit MultiEdit].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def summarize(events, impl_files:, test_file: nil)
|
|
18
|
+
init = events.find { |e| e["subtype"] == "init" }
|
|
19
|
+
result = events.reverse.find { |e| e["type"] == "result" }
|
|
20
|
+
|
|
21
|
+
turns = []
|
|
22
|
+
tool_calls = []
|
|
23
|
+
events.each do |event|
|
|
24
|
+
next unless event["type"] == "assistant"
|
|
25
|
+
|
|
26
|
+
u = usage_of(event) || {}
|
|
27
|
+
turns << {
|
|
28
|
+
"input_tokens" => u["input_tokens"].to_i,
|
|
29
|
+
"output_tokens" => u["output_tokens"].to_i,
|
|
30
|
+
"cache_creation_input_tokens" => u["cache_creation_input_tokens"].to_i,
|
|
31
|
+
"cache_read_input_tokens" => u["cache_read_input_tokens"].to_i,
|
|
32
|
+
"context_tokens" => u["input_tokens"].to_i +
|
|
33
|
+
u["cache_creation_input_tokens"].to_i +
|
|
34
|
+
u["cache_read_input_tokens"].to_i,
|
|
35
|
+
"tool_calls" => tool_uses_in(event).map { |t| t["name"] }
|
|
36
|
+
}
|
|
37
|
+
tool_uses_in(event).each do |use|
|
|
38
|
+
tool_calls << { "name" => use["name"], "paths" => paths_in_tool_use(use),
|
|
39
|
+
"blob" => (use["input"] || {}).to_json }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
first_defect_read = tool_calls.index { |t| Transcript.references_impl?(t["blob"], impl_files) }
|
|
44
|
+
first_edit = tool_calls.index { |t| EDIT_TOOLS.include?(t["name"]) }
|
|
45
|
+
|
|
46
|
+
usage = result&.dig("usage") || {}
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"agent" => "claude",
|
|
50
|
+
"model_reported" => init&.dig("model"),
|
|
51
|
+
"cli_version" => init&.dig("claude_code_version"),
|
|
52
|
+
# Hermeticity: a clean container should report none of these. If any is
|
|
53
|
+
# non-empty the trial ran with help it was not supposed to have.
|
|
54
|
+
"mcp_servers" => init&.dig("mcp_servers") || [],
|
|
55
|
+
"memory_paths" => init&.dig("memory_paths") || {},
|
|
56
|
+
"tools_available" => (init&.dig("tools") || []).size,
|
|
57
|
+
"num_turns" => result&.dig("num_turns"),
|
|
58
|
+
"duration_ms" => result&.dig("duration_ms"),
|
|
59
|
+
"duration_api_ms" => result&.dig("duration_api_ms"),
|
|
60
|
+
"total_cost_usd" => result&.dig("total_cost_usd"),
|
|
61
|
+
"stop_reason" => result&.dig("stop_reason"),
|
|
62
|
+
"is_error" => result&.dig("is_error"),
|
|
63
|
+
"input_tokens" => usage["input_tokens"],
|
|
64
|
+
"output_tokens" => usage["output_tokens"],
|
|
65
|
+
"cache_creation_input_tokens" => usage["cache_creation_input_tokens"],
|
|
66
|
+
"cache_read_input_tokens" => usage["cache_read_input_tokens"],
|
|
67
|
+
"thinking_tokens" => usage.dig("output_tokens_details", "thinking_tokens"),
|
|
68
|
+
"total_tool_calls" => tool_calls.size,
|
|
69
|
+
"tool_call_names" => tool_calls.map { |t| t["name"] },
|
|
70
|
+
"tool_calls_to_first_defect_read" => first_defect_read,
|
|
71
|
+
"tool_calls_to_first_edit" => first_edit,
|
|
72
|
+
"read_before_edit" => first_defect_read && first_edit ? first_defect_read < first_edit : nil,
|
|
73
|
+
# The auto-attachment signature: how much context the very first request
|
|
74
|
+
# carried, before any tool could have fetched anything.
|
|
75
|
+
"context_before_first_tool_call" => turns.first&.dig("context_tokens"),
|
|
76
|
+
"turns" => turns,
|
|
77
|
+
"test_file" => test_file
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def usage_of(event)
|
|
82
|
+
event.dig("message", "usage") || event["usage"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tool_uses_in(event)
|
|
86
|
+
content = event.dig("message", "content")
|
|
87
|
+
return [] unless content.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
content.select { |c| c["type"] == "tool_use" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Which file a tool call touched, when it is knowable. Reads, edits and
|
|
93
|
+
# writes name a path directly; a shell command has to be scanned for one.
|
|
94
|
+
def paths_in_tool_use(use)
|
|
95
|
+
input = use["input"] || {}
|
|
96
|
+
direct = [input["file_path"], input["path"], input["notebook_path"]].compact
|
|
97
|
+
return direct unless direct.empty?
|
|
98
|
+
|
|
99
|
+
text = [input["command"], input["pattern"], input["prompt"]].compact.join(" ")
|
|
100
|
+
text.scan(%r{[\w./-]+\.(?:rb|erb|yml|yaml)}).uniq
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
module Transcript
|
|
7
|
+
# Codex emits `item.completed` envelopes and one `turn.completed` carrying
|
|
8
|
+
# usage. Item types seen in practice: command_execution (command,
|
|
9
|
+
# aggregated_output, exit_code, status), file_change, agent_message,
|
|
10
|
+
# reasoning, error.
|
|
11
|
+
#
|
|
12
|
+
# One asymmetry to be honest about: Codex reports tokens per TURN, and a
|
|
13
|
+
# turn contains all of its tool calls. So the context carried before the
|
|
14
|
+
# first tool call - the auto-attachment signature this kind of experiment is
|
|
15
|
+
# chasing - is directly observable for Claude and not for Codex. For Codex
|
|
16
|
+
# the same effect can only show up in the totals, which is weaker but not
|
|
17
|
+
# nothing. It is left nil rather than filled with a number that does not
|
|
18
|
+
# mean what the column says.
|
|
19
|
+
module Codex
|
|
20
|
+
EDIT_ITEMS = %w[file_change patch_apply].freeze
|
|
21
|
+
TOOL_ITEMS = (EDIT_ITEMS + %w[command_execution mcp_tool_call web_search]).freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def summarize(events, impl_files:)
|
|
26
|
+
tool_calls = []
|
|
27
|
+
turns = []
|
|
28
|
+
errors = []
|
|
29
|
+
|
|
30
|
+
events.each do |event|
|
|
31
|
+
case event["type"]
|
|
32
|
+
when "item.completed"
|
|
33
|
+
item = event["item"] || {}
|
|
34
|
+
errors << item["message"] if item["type"] == "error"
|
|
35
|
+
next unless TOOL_ITEMS.include?(item["type"])
|
|
36
|
+
|
|
37
|
+
command = Array(item["command"]).join(" ")
|
|
38
|
+
paths = command.scan(%r{[\w./-]+\.(?:rb|erb|yml|yaml)}).uniq
|
|
39
|
+
paths |= changed_paths(item["changes"])
|
|
40
|
+
|
|
41
|
+
tool_calls << { "name" => item["type"], "paths" => paths, "command" => command,
|
|
42
|
+
"exit_code" => item["exit_code"], "blob" => item.to_json }
|
|
43
|
+
when "turn.completed"
|
|
44
|
+
u = event["usage"] || {}
|
|
45
|
+
turns << {
|
|
46
|
+
"input_tokens" => u["input_tokens"].to_i,
|
|
47
|
+
"output_tokens" => u["output_tokens"].to_i,
|
|
48
|
+
"cache_read_input_tokens" => u["cached_input_tokens"].to_i,
|
|
49
|
+
"cache_creation_input_tokens" => u["cache_write_input_tokens"].to_i,
|
|
50
|
+
"reasoning_output_tokens" => u["reasoning_output_tokens"].to_i,
|
|
51
|
+
"context_tokens" => u["input_tokens"].to_i
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
first_defect_read = tool_calls.index { |t| Transcript.references_impl?(t["blob"], impl_files) }
|
|
57
|
+
first_edit = tool_calls.index { |t| EDIT_ITEMS.include?(t["name"]) }
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"agent" => "codex",
|
|
61
|
+
"input_tokens" => turns.sum { |t| t["input_tokens"] },
|
|
62
|
+
"output_tokens" => turns.sum { |t| t["output_tokens"] },
|
|
63
|
+
"cache_read_input_tokens" => turns.sum { |t| t["cache_read_input_tokens"] },
|
|
64
|
+
"cache_creation_input_tokens" => turns.sum { |t| t["cache_creation_input_tokens"] },
|
|
65
|
+
"thinking_tokens" => turns.sum { |t| t["reasoning_output_tokens"] },
|
|
66
|
+
"total_tool_calls" => tool_calls.size,
|
|
67
|
+
"tool_call_names" => tool_calls.map { |t| t["name"] },
|
|
68
|
+
"tool_calls_to_first_defect_read" => first_defect_read,
|
|
69
|
+
"tool_calls_to_first_edit" => first_edit,
|
|
70
|
+
"read_before_edit" => first_defect_read && first_edit ? first_defect_read < first_edit : nil,
|
|
71
|
+
# Only meaningful if Codex ever reports more than one turn; see the note above.
|
|
72
|
+
"context_before_first_tool_call" => turns.size > 1 ? turns.first["context_tokens"] : nil,
|
|
73
|
+
"codex_errors" => errors,
|
|
74
|
+
"turns" => turns
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A file_change item carries `changes` as a path => diff map.
|
|
79
|
+
#
|
|
80
|
+
# This diverges from the source harness, which wrote
|
|
81
|
+
# `Array(item["changes"]).flat_map { |c| c.is_a?(Hash) ? c.keys : [c.to_s] }`.
|
|
82
|
+
# Array() turns a Hash into [key, value] pairs before the is_a?(Hash)
|
|
83
|
+
# test can ever see one, so that branch was unreachable and every path
|
|
84
|
+
# came out as the string '["app/x.rb", "@@ -1 +1 @@"]' -- the diff text
|
|
85
|
+
# included. Nothing published reads this field today, which is exactly
|
|
86
|
+
# why it could stay wrong unnoticed.
|
|
87
|
+
def changed_paths(changes)
|
|
88
|
+
case changes
|
|
89
|
+
when Hash then changes.keys
|
|
90
|
+
when Array then changes.map(&:to_s)
|
|
91
|
+
else []
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
module Transcript
|
|
5
|
+
# A trial is contaminated when it can reach state that outlives it or is
|
|
6
|
+
# shared with another trial. The CLI always reports an auto-memory path, so
|
|
7
|
+
# the question is not whether one exists -- it always does -- but where it
|
|
8
|
+
# points. Inside the per-trial config directory it dies with the container.
|
|
9
|
+
# Under the mounted credential store it is the same directory for every
|
|
10
|
+
# trial, and what one trial could leave there is the location of the defect.
|
|
11
|
+
# Testing for existence rather than location flags every clean run, which
|
|
12
|
+
# trains you to ignore the warning that matters.
|
|
13
|
+
module Hermeticity
|
|
14
|
+
SHARED_MOUNT = "/home/agent/.agent-auth"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def contamination(row)
|
|
19
|
+
reasons = []
|
|
20
|
+
|
|
21
|
+
servers = Array(row["mcp_servers"])
|
|
22
|
+
unless servers.empty?
|
|
23
|
+
names = servers.map { |s| s.is_a?(Hash) ? s["name"] : s }
|
|
24
|
+
reasons << "mcp servers attached: #{names.join(", ")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Hash(row["memory_paths"]).each do |kind, path|
|
|
28
|
+
reasons << "#{kind} memory lives in the shared mount: #{path}" if path.to_s.start_with?(SHARED_MOUNT)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
reasons
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
module Transcript
|
|
7
|
+
# Reads one trial directory and writes back what can be measured from it:
|
|
8
|
+
# events.jsonl (the transcript with unparsable lines dropped) and
|
|
9
|
+
# metrics.json (the summary the stats read).
|
|
10
|
+
class Parser
|
|
11
|
+
# Claude emits stream-json; Codex emits its own JSONL. Detect rather than
|
|
12
|
+
# configure, so a transcript can be parsed without knowing who produced it.
|
|
13
|
+
def self.detect_agent(events)
|
|
14
|
+
return "claude" if events.any? { |e| e["type"] == "system" && e["subtype"] == "init" }
|
|
15
|
+
return "codex" if events.any? { |e| e["type"].to_s.start_with?("item.", "turn.", "thread.") }
|
|
16
|
+
|
|
17
|
+
"unknown"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(experiment:)
|
|
21
|
+
@experiment = experiment
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every trial that got as far as writing a transcript.
|
|
25
|
+
def trial_dirs
|
|
26
|
+
Dir.glob(File.join(@experiment.results_raw_dir, "**", "transcript.jsonl"))
|
|
27
|
+
.map { |p| File.dirname(p) }.sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the summary it wrote, or nil for a directory with no transcript.
|
|
31
|
+
def parse(dir)
|
|
32
|
+
transcript = File.join(dir, "transcript.jsonl")
|
|
33
|
+
unless File.exist?(transcript)
|
|
34
|
+
Shell.log "skip #{dir}: no transcript.jsonl"
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
meta = read_meta(dir)
|
|
39
|
+
bad = 0
|
|
40
|
+
events = File.readlines(transcript).filter_map do |line|
|
|
41
|
+
JSON.parse(line)
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
bad += 1
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
summary = summarize(events, meta)
|
|
48
|
+
summary["unparsable_lines"] = bad
|
|
49
|
+
summary["raw_events"] = events.size
|
|
50
|
+
summary.merge!(meta_fields(meta))
|
|
51
|
+
summary["trial_dir"] = dir.sub("#{@experiment.root}/", "")
|
|
52
|
+
|
|
53
|
+
File.write(File.join(dir, "events.jsonl"), "#{events.map(&:to_json).join("\n")}\n")
|
|
54
|
+
File.write(File.join(dir, "metrics.json"), JSON.pretty_generate(summary))
|
|
55
|
+
summary
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def summarize(events, meta)
|
|
61
|
+
impl_files, test_file = ground_truth(meta)
|
|
62
|
+
|
|
63
|
+
case meta["agent"] || self.class.detect_agent(events)
|
|
64
|
+
when "claude" then Claude.summarize(events, impl_files: impl_files, test_file: test_file)
|
|
65
|
+
when "codex" then Codex.summarize(events, impl_files: impl_files)
|
|
66
|
+
else { "agent" => "unknown", "events" => events.size }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# experiment.yml is the fallback for impl_files/test_file. A trial recorded
|
|
71
|
+
# before the runner persisted them has neither in meta.json, and matching
|
|
72
|
+
# against an empty list makes tool_calls_to_first_defect_read nil for every
|
|
73
|
+
# row.
|
|
74
|
+
def ground_truth(meta)
|
|
75
|
+
task = task_for(meta["task_id"])
|
|
76
|
+
[Array(meta["impl_files"] || task&.impl_files), meta["test_file"] || task&.test_file]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def task_for(id)
|
|
80
|
+
return nil unless id
|
|
81
|
+
|
|
82
|
+
@experiment.task(id)
|
|
83
|
+
rescue ConfigError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read_meta(dir)
|
|
88
|
+
path = File.join(dir, "meta.json")
|
|
89
|
+
File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def meta_fields(meta)
|
|
93
|
+
{
|
|
94
|
+
"task_id" => meta["task_id"],
|
|
95
|
+
"condition" => meta["condition"],
|
|
96
|
+
"app" => meta["app"],
|
|
97
|
+
"fix_verified" => meta["fix_verified"],
|
|
98
|
+
"wall_seconds" => meta["wall_seconds"],
|
|
99
|
+
"timed_out" => meta["timed_out"],
|
|
100
|
+
"task_reproduces" => meta["task_reproduces"]
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# Turning a trial's raw JSONL transcript into measurements.
|
|
5
|
+
#
|
|
6
|
+
# The headline number an experiment like this is after is context growth
|
|
7
|
+
# BEFORE the first tool call. If a mention makes a CLI attach the file itself,
|
|
8
|
+
# the first request carries the file's tokens and no read is needed to get
|
|
9
|
+
# them; if the mention is inert text, the first request looks like the bare
|
|
10
|
+
# one and the file arrives later through a normal read. Those two shapes are
|
|
11
|
+
# distinguishable only because the per-agent summarizers keep per-turn token
|
|
12
|
+
# counts rather than only the totals.
|
|
13
|
+
module Transcript
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Does this tool call refer to the defect file?
|
|
17
|
+
#
|
|
18
|
+
# Matching on the full relative path rather than the basename: a basename
|
|
19
|
+
# would also fire on an unrelated grep for a common word, and the point of
|
|
20
|
+
# the metric is how many calls it took to arrive at the file, not how many
|
|
21
|
+
# mentioned a similar-sounding name. The whole serialised input is searched,
|
|
22
|
+
# so a Read with a file_path and a shell `cat <path>` both count.
|
|
23
|
+
def references_impl?(blob, impl_files)
|
|
24
|
+
impl_files.any? { |f| blob.include?(f) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|