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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +190 -0
  6. data/exe/llmx +6 -0
  7. data/lib/llm-experiment.rb +3 -0
  8. data/lib/llm_experiment/auth.rb +143 -0
  9. data/lib/llm_experiment/cleaner.rb +183 -0
  10. data/lib/llm_experiment/cli/build_command.rb +37 -0
  11. data/lib/llm_experiment/cli/clean_command.rb +42 -0
  12. data/lib/llm_experiment/cli/doctor_command.rb +30 -0
  13. data/lib/llm_experiment/cli/login_command.rb +25 -0
  14. data/lib/llm_experiment/cli/metrics_command.rb +32 -0
  15. data/lib/llm_experiment/cli/new_command.rb +21 -0
  16. data/lib/llm_experiment/cli/parse_command.rb +77 -0
  17. data/lib/llm_experiment/cli/run_command.rb +74 -0
  18. data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
  19. data/lib/llm_experiment/cli/shell_command.rb +35 -0
  20. data/lib/llm_experiment/cli/status_command.rb +69 -0
  21. data/lib/llm_experiment/cli/version_command.rb +19 -0
  22. data/lib/llm_experiment/cli.rb +120 -0
  23. data/lib/llm_experiment/container.rb +178 -0
  24. data/lib/llm_experiment/doctor.rb +126 -0
  25. data/lib/llm_experiment/experiment.rb +163 -0
  26. data/lib/llm_experiment/grid.rb +117 -0
  27. data/lib/llm_experiment/image_builder/app.rb +267 -0
  28. data/lib/llm_experiment/image_builder/base.rb +64 -0
  29. data/lib/llm_experiment/metrics_report.rb +138 -0
  30. data/lib/llm_experiment/pins.rb +22 -0
  31. data/lib/llm_experiment/sanitizer.rb +144 -0
  32. data/lib/llm_experiment/scaffold.rb +43 -0
  33. data/lib/llm_experiment/shell.rb +60 -0
  34. data/lib/llm_experiment/stats.rb +69 -0
  35. data/lib/llm_experiment/transcript/claude.rb +104 -0
  36. data/lib/llm_experiment/transcript/codex.rb +96 -0
  37. data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
  38. data/lib/llm_experiment/transcript/parser.rb +105 -0
  39. data/lib/llm_experiment/transcript.rb +27 -0
  40. data/lib/llm_experiment/trial.rb +204 -0
  41. data/lib/llm_experiment/version.rb +5 -0
  42. data/lib/llm_experiment.rb +70 -0
  43. data/templates/README.md.erb +26 -0
  44. data/templates/base.Containerfile +120 -0
  45. data/templates/experiment.yml.erb +30 -0
  46. data/templates/gitignore +3 -0
  47. data/templates/runner.rb +305 -0
  48. metadata +92 -0
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs inside the trial container. `llmx run` copies this in and starts it;
5
+ # it is never executed on the host.
6
+ #
7
+ # Order matters here. Everything that could tell the agent where the defect is
8
+ # has to be gone before the agent starts, and the test has to be observed
9
+ # failing before the agent starts, so a mis-planted bug is caught as a bad trial
10
+ # instead of being scored as an instant fix.
11
+
12
+ require "json"
13
+ require "open3"
14
+ require "fileutils"
15
+
16
+ APP_DIR = "/workspace/app"
17
+ RESULTS = "/results"
18
+
19
+ def cfg(key, default = nil)
20
+ value = ENV["LLMX_#{key.to_s.upcase}"]
21
+ value.nil? || value.empty? ? default : value
22
+ end
23
+
24
+ def run(cmd, dir: APP_DIR, timeout: nil, env: {})
25
+ cmd = "timeout #{timeout} #{cmd}" if timeout
26
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ out, err, status = Open3.capture3(env, cmd, chdir: dir)
28
+ {
29
+ "cmd" => cmd,
30
+ "exit" => status.exitstatus,
31
+ "stdout" => out,
32
+ "stderr" => err,
33
+ "seconds" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
34
+ }
35
+ end
36
+
37
+ def log(msg)
38
+ warn "[runner] #{msg}"
39
+ end
40
+
41
+ branch = cfg(:branch) or abort "LLMX_BRANCH required"
42
+ agent = cfg(:agent) or abort "LLMX_AGENT required"
43
+ condition = cfg(:condition) or abort "LLMX_CONDITION required"
44
+ test_file = cfg(:test_file) or abort "LLMX_TEST_FILE required"
45
+ prompt_b64 = cfg(:prompt_b64) or abort "LLMX_PROMPT_B64 required"
46
+ impl_files = (cfg(:impl_files) || "").split(",")
47
+ model = cfg(:model)
48
+ db_prepare = cfg(:db_prepare, "bin/rails db:test:prepare")
49
+ timeout_s = cfg(:timeout_seconds, "900").to_i
50
+ database = cfg(:database, "sqlite3")
51
+ test_command = cfg(:test_command, "bin/rails test")
52
+ suite_ran_pattern = Regexp.new(cfg(:suite_ran_pattern, '\d+ runs?, \d+ assertions?'))
53
+
54
+ # unpack1("m0") is strict_decode64 without the base64 gem, which Ruby 3.4
55
+ # unbundled. This script runs inside a container against whichever ruby the
56
+ # app pins, so it leans on core only and requires nothing that could be absent.
57
+ prompt = prompt_b64.unpack1("m0")
58
+
59
+ meta = {
60
+ "task_id" => cfg(:task_id),
61
+ "app" => cfg(:app),
62
+ "agent" => agent,
63
+ "condition" => condition,
64
+ "branch" => branch,
65
+ "model_requested" => model,
66
+ # Persisted because parse_transcript.rb needs them to compute
67
+ # tool_calls_to_first_defect_read. Without them it matches against an empty
68
+ # list, and the metric comes out nil on every trial while looking like a
69
+ # null observation rather than a broken one.
70
+ "impl_files" => impl_files,
71
+ "test_file" => test_file,
72
+ "started_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
73
+ }
74
+
75
+ # --- Postgres, if this app needs it -----------------------------------------
76
+ #
77
+ # The cluster is the agent user's own, created at image build, so starting it
78
+ # needs no privileges. The packaged system cluster would need root, and root is
79
+ # not something to hand a container that runs model-authored shell commands.
80
+ if database == "postgresql"
81
+ log "starting postgres"
82
+ pgbin = File.dirname(Dir.glob("/usr/lib/postgresql/*/bin/pg_ctl").first)
83
+ pgdata = ENV.fetch("PGDATA", "/home/agent/pgdata")
84
+ system("#{pgbin}/pg_ctl -D #{pgdata} -l /tmp/postgres.log -o '-p 5432' -w start > /dev/null 2>&1")
85
+ ready = false
86
+ 30.times do
87
+ ready = system("#{pgbin}/pg_isready -q -h 127.0.0.1 -p 5432")
88
+ break if ready
89
+
90
+ sleep 0.5
91
+ end
92
+ meta["postgres_ready"] = ready
93
+ log "postgres ready: #{ready}"
94
+ end
95
+
96
+ # --- Isolate the branch under test ------------------------------------------
97
+ #
98
+ # The image ships every trial branch. Leaving them in place would let one
99
+ # `git diff trial/base` show the planted change, which is the answer. Delete
100
+ # everything except the branch being tested, then drop the objects so the trees
101
+ # are not merely unreferenced but gone.
102
+
103
+ # The format string is quoted because `run` hands a single string to /bin/sh:
104
+ # unquoted, sh reads `%(refname:short)` as a subshell, dies with a syntax
105
+ # error and returns empty stdout. `run` ignores exit codes, so the deletion
106
+ # loop then had nothing to iterate and silently deleted nothing, while
107
+ # branches_visible_to_agent recorded [] and read like a success.
108
+ BRANCH_LIST = "git branch --format='%(refname:short)'"
109
+
110
+ log "isolating #{branch}"
111
+ run("git checkout -q #{branch}")
112
+ others = run(BRANCH_LIST)["stdout"].split("\n").map(&:strip)
113
+ .reject { |b| b.empty? || b == branch }
114
+ others.each { |b| run("git branch -q -D #{b}") }
115
+ run("git reflog expire --expire=now --all")
116
+ run("git gc --prune=now --quiet")
117
+
118
+ visible = run("git log --all --oneline")["stdout"].strip
119
+ meta["history_visible_to_agent"] = visible
120
+ meta["branches_visible_to_agent"] = run(BRANCH_LIST)["stdout"].split("\n").map(&:strip)
121
+
122
+ # Isolation is the guarantee the whole experiment rests on, and every command
123
+ # above ignores its exit code, so it is checked rather than assumed. A trial
124
+ # whose repository still holds the other branches is not merely imperfect: one
125
+ # `git diff trial/base` shows the planted change, so the trial is worthless and
126
+ # must not be scored.
127
+ leftover = meta["branches_visible_to_agent"] - [branch]
128
+ if !leftover.empty? || visible.lines.size != 1
129
+ log "aborting: isolation failed (branches left: #{leftover.inspect}, " \
130
+ "#{visible.lines.size} commit(s) visible)"
131
+ meta["aborted"] = "isolation_failed"
132
+ File.write(File.join(RESULTS, "meta.json"), JSON.pretty_generate(meta))
133
+ exit 4
134
+ end
135
+
136
+ dirty = run("git status --porcelain")["stdout"].strip
137
+ meta["clean_tree_before"] = dirty.empty?
138
+ log "tree clean before agent: #{dirty.empty?}"
139
+
140
+ # --- Database ----------------------------------------------------------------
141
+ prep = run(db_prepare, timeout: 300)
142
+ meta["db_prepare_exit"] = prep["exit"]
143
+ File.write(File.join(RESULTS, "db_prepare.txt"), prep["stdout"] + prep["stderr"])
144
+
145
+ # --- Confirm the bug is real before spending a trial on it -------------------
146
+ #
147
+ # A non-zero exit is not enough. An app that cannot boot also exits non-zero,
148
+ # and scoring that as "the planted bug reproduces" would spend a trial on a
149
+ # broken container and then read the agent's confusion as data. Require a
150
+ # Minitest summary line, which only appears if the suite actually ran.
151
+ before = run("#{test_command} #{test_file}", timeout: 600)
152
+ output = before["stdout"] + before["stderr"]
153
+ meta["test_before"] = { "exit" => before["exit"], "seconds" => before["seconds"] }
154
+ File.write(File.join(RESULTS, "test_before.txt"), output)
155
+
156
+ suite_ran = output.match?(suite_ran_pattern)
157
+ meta["suite_ran_before"] = suite_ran
158
+ meta["task_reproduces"] = suite_ran && before["exit"] != 0
159
+
160
+ unless meta["task_reproduces"]
161
+ reason = suite_ran ? "task_did_not_reproduce" : "suite_did_not_run"
162
+ log "aborting: #{reason} (see test_before.txt)"
163
+ meta["aborted"] = reason
164
+ File.write(File.join(RESULTS, "meta.json"), JSON.pretty_generate(meta))
165
+ exit 3
166
+ end
167
+
168
+ # --- Give the agent a private config directory -------------------------------
169
+ #
170
+ # CLAUDE_CONFIG_DIR points at the mounted credential store, and Claude Code
171
+ # derives its project state and auto-memory directory from it: the init event
172
+ # reports memory at <config>/projects/-workspace-app/memory/. That directory is
173
+ # writable, shared by every trial and persisted on the host, which opens three
174
+ # channels between trials that are supposed to be independent:
175
+ #
176
+ # - session transcripts accumulate under projects/
177
+ # - .claude.json records per-project onboarding and startup state, so the
178
+ # first trial of a grid does not start from the same place as the rest
179
+ # - anything written to the memory directory in one trial is read by the
180
+ # next, and in this experiment that could be the location of the defect
181
+ #
182
+ # So each trial copies the credentials into its own directory and points the
183
+ # CLI at the copy. The copy lives in the container and dies with it; the
184
+ # mounted store is only ever read, which keeps every trial starting from
185
+ # byte-identical state. Codex already has this guarantee from
186
+ # --ignore-user-config --ephemeral.
187
+ SEEDED = %w[.credentials.json .claude.json settings.json auth.json config.toml].freeze
188
+
189
+ def private_config(source, dest)
190
+ return nil unless source && Dir.exist?(source)
191
+
192
+ FileUtils.rm_rf(dest)
193
+ FileUtils.mkdir_p(dest)
194
+ SEEDED.each do |f|
195
+ from = File.join(source, f)
196
+ FileUtils.cp(from, File.join(dest, f)) if File.exist?(from)
197
+ end
198
+ dest
199
+ end
200
+
201
+ if agent == "claude" && (dir = private_config(ENV["CLAUDE_CONFIG_DIR"], "/home/agent/.claude-trial"))
202
+ ENV["CLAUDE_CONFIG_DIR"] = dir
203
+ end
204
+ if agent == "codex" && (dir = private_config(ENV["CODEX_HOME"], "/home/agent/.codex-trial"))
205
+ ENV["CODEX_HOME"] = dir
206
+ end
207
+ meta["config_dir"] = agent == "claude" ? ENV["CLAUDE_CONFIG_DIR"] : ENV["CODEX_HOME"]
208
+
209
+ # --- Run the agent -----------------------------------------------------------
210
+ transcript = File.join(RESULTS, "transcript.jsonl")
211
+
212
+ cmd =
213
+ case agent
214
+ when "claude"
215
+ base = ["claude", "-p", "--output-format", "stream-json", "--verbose",
216
+ "--permission-mode", "bypassPermissions"]
217
+ base += ["--model", model] if model
218
+ base
219
+ when "codex"
220
+ base = ["codex", "exec", "--json",
221
+ "--dangerously-bypass-approvals-and-sandbox",
222
+ "--skip-git-repo-check", "--ignore-user-config", "--ephemeral",
223
+ "-C", APP_DIR]
224
+ base += ["--model", model] if model
225
+ base << "-"
226
+ else
227
+ abort "unknown agent #{agent}"
228
+ end
229
+
230
+ meta["agent_argv"] = cmd
231
+ log "running #{cmd.join(" ")}"
232
+
233
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+ agent_exit = nil
235
+ timed_out = false
236
+
237
+ File.open(transcript, "w") do |sink|
238
+ Open3.popen3({}, *cmd, chdir: APP_DIR) do |stdin, stdout, stderr, wait|
239
+ stdin.write(prompt)
240
+ stdin.close
241
+
242
+ err_buf = +""
243
+ err_thread = Thread.new { stderr.each_line { |l| err_buf << l } }
244
+ out_thread = Thread.new do
245
+ stdout.each_line do |line|
246
+ sink.write(line)
247
+ sink.flush
248
+ end
249
+ end
250
+
251
+ unless wait.join(timeout_s)
252
+ timed_out = true
253
+ begin
254
+ Process.kill("KILL", wait.pid)
255
+ rescue StandardError
256
+ nil
257
+ end
258
+ end
259
+ out_thread.join(10)
260
+ err_thread.join(5)
261
+ agent_exit = begin
262
+ wait.value.exitstatus
263
+ rescue StandardError
264
+ nil
265
+ end
266
+ File.write(File.join(RESULTS, "agent_stderr.txt"), err_buf)
267
+ end
268
+ end
269
+
270
+ meta["agent_exit"] = agent_exit
271
+ meta["timed_out"] = timed_out
272
+ meta["wall_seconds"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
273
+ log "agent finished in #{meta["wall_seconds"]}s (exit #{agent_exit}, timeout #{timed_out})"
274
+
275
+ # --- What did it change? -----------------------------------------------------
276
+ diff = run("git diff")
277
+ File.write(File.join(RESULTS, "agent.diff"), diff["stdout"])
278
+ meta["changed_files"] = run("git diff --name-only")["stdout"].split("\n").map(&:strip).reject(&:empty?)
279
+ meta["untracked_files"] = run("git ls-files --others --exclude-standard")["stdout"]
280
+ .split("\n").map(&:strip).reject(&:empty?)
281
+ meta["touched_impl_file"] = (meta["changed_files"] & impl_files).any?
282
+ meta["edited_test_dir"] = meta["changed_files"].any? { |f| f.start_with?("test/") }
283
+
284
+ # --- Did the fix hold? -------------------------------------------------------
285
+ after = run("#{test_command} #{test_file}", timeout: 600)
286
+ after_output = after["stdout"] + after["stderr"]
287
+ meta["test_after"] = { "exit" => after["exit"], "seconds" => after["seconds"] }
288
+ File.write(File.join(RESULTS, "test_after.txt"), after_output)
289
+
290
+ # A green run only counts if the suite ran and the agent did not simply edit the
291
+ # test until it agreed with the bug.
292
+ meta["fix_verified"] = after["exit"].zero? &&
293
+ after_output.match?(suite_ran_pattern) &&
294
+ !meta["edited_test_dir"]
295
+
296
+ meta["toolchain"] = begin
297
+ JSON.parse(File.read("/etc/llmx-versions.json"))
298
+ rescue StandardError
299
+ nil
300
+ end
301
+ meta["finished_at"] = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
302
+
303
+ File.write(File.join(RESULTS, "meta.json"), JSON.pretty_generate(meta))
304
+ log "wrote #{File.join(RESULTS, "meta.json")}"
305
+ exit 0
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: llm-experiment
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucian Ghinda
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'A clean-room harness for measuring coding agents (Claude Code, Codex
13
+ CLI): fresh Apple containers per trial, pinned toolchains, transcript parsing, exact
14
+ statistics, and a sanitize gate for publishing results.'
15
+ email: dev@ghinda.com
16
+ executables:
17
+ - llmx
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - CODE_OF_CONDUCT.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - exe/llmx
26
+ - lib/llm-experiment.rb
27
+ - lib/llm_experiment.rb
28
+ - lib/llm_experiment/auth.rb
29
+ - lib/llm_experiment/cleaner.rb
30
+ - lib/llm_experiment/cli.rb
31
+ - lib/llm_experiment/cli/build_command.rb
32
+ - lib/llm_experiment/cli/clean_command.rb
33
+ - lib/llm_experiment/cli/doctor_command.rb
34
+ - lib/llm_experiment/cli/login_command.rb
35
+ - lib/llm_experiment/cli/metrics_command.rb
36
+ - lib/llm_experiment/cli/new_command.rb
37
+ - lib/llm_experiment/cli/parse_command.rb
38
+ - lib/llm_experiment/cli/run_command.rb
39
+ - lib/llm_experiment/cli/sanitize_command.rb
40
+ - lib/llm_experiment/cli/shell_command.rb
41
+ - lib/llm_experiment/cli/status_command.rb
42
+ - lib/llm_experiment/cli/version_command.rb
43
+ - lib/llm_experiment/container.rb
44
+ - lib/llm_experiment/doctor.rb
45
+ - lib/llm_experiment/experiment.rb
46
+ - lib/llm_experiment/grid.rb
47
+ - lib/llm_experiment/image_builder/app.rb
48
+ - lib/llm_experiment/image_builder/base.rb
49
+ - lib/llm_experiment/metrics_report.rb
50
+ - lib/llm_experiment/pins.rb
51
+ - lib/llm_experiment/sanitizer.rb
52
+ - lib/llm_experiment/scaffold.rb
53
+ - lib/llm_experiment/shell.rb
54
+ - lib/llm_experiment/stats.rb
55
+ - lib/llm_experiment/transcript.rb
56
+ - lib/llm_experiment/transcript/claude.rb
57
+ - lib/llm_experiment/transcript/codex.rb
58
+ - lib/llm_experiment/transcript/hermeticity.rb
59
+ - lib/llm_experiment/transcript/parser.rb
60
+ - lib/llm_experiment/trial.rb
61
+ - lib/llm_experiment/version.rb
62
+ - templates/README.md.erb
63
+ - templates/base.Containerfile
64
+ - templates/experiment.yml.erb
65
+ - templates/gitignore
66
+ - templates/runner.rb
67
+ homepage: https://github.com/lucianghinda/llm-experiment
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ rubygems_mfa_required: 'true'
72
+ source_code_uri: https://github.com/lucianghinda/llm-experiment
73
+ changelog_uri: https://github.com/lucianghinda/llm-experiment/blob/main/CHANGELOG.md
74
+ bug_tracker_uri: https://github.com/lucianghinda/llm-experiment/issues
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.4'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.11
90
+ specification_version: 4
91
+ summary: Run controlled, reproducible experiments on coding agents
92
+ test_files: []