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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+
6
+ module LLMExperiment
7
+ module ImageBuilder
8
+ # Builds a per-app image on top of the base image, so a trial starts instantly
9
+ # instead of paying for `bundle install` and database setup every time.
10
+ #
11
+ # The app arrives as a git bundle containing only the experiment branches. That
12
+ # matters: a bundle carries committed history and nothing else, so untracked
13
+ # secrets, stale local bundler overrides and repository hooks on the host can
14
+ # never reach the image.
15
+ #
16
+ # The image also strips the repository of anything that would let an agent read
17
+ # the answer instead of finding it. See FLATTENING below.
18
+ #
19
+ # BRANCH NAMING
20
+ #
21
+ # The source branches are not created here. The experiment's own planting
22
+ # script owns them, and this builder expects exactly these names:
23
+ #
24
+ # <branch_prefix>/base the app as it ships
25
+ # <branch_prefix>/<task id> one branch per task in experiment.yml
26
+ #
27
+ # `branch_prefix` comes from the app's entry in experiment.yml. Nothing else
28
+ # in the name is read, and nothing else may be encoded in it: the harness this
29
+ # was ported from named branches `exp/path-hints/bug-campfire-01-nat64-recheck`
30
+ # and the trailing slug handed the agent the answer. A missing branch is an
31
+ # error, never a silently skipped task.
32
+ #
33
+ # Images holding private code are never pushed to a registry.
34
+ class App
35
+ BUILD_ROOT = ENV.fetch("LLMX_BUILD_ROOT", "/tmp/llmx/build")
36
+
37
+ def initialize(experiment:, container: Container.new, shell: Shell)
38
+ @experiment = experiment
39
+ @container = container
40
+ @shell = shell
41
+ end
42
+
43
+ def build(app:, no_cache: false)
44
+ repo = app.repo_path
45
+ if repo.nil? || repo.empty?
46
+ raise ConfigError,
47
+ "LLMX_APP_#{app.key.upcase} is not set; point it at the #{app.key} checkout on this machine"
48
+ end
49
+
50
+ @container.ensure_disk!
51
+ @container.ensure_builder!
52
+ unless @container.image?(LLMExperiment.base_image)
53
+ raise Error, "base image #{LLMExperiment.base_image} not found; run llmx build base first"
54
+ end
55
+
56
+ context = File.join(BUILD_ROOT, app.key)
57
+ FileUtils.rm_rf(context)
58
+ FileUtils.mkdir_p(context)
59
+
60
+ raise ConfigError, "#{app.key}: no tasks in experiment.yml use this app" if tasks_for(app).empty?
61
+
62
+ map = branch_map(app)
63
+ require_branches!(app, repo, map)
64
+
65
+ bundle_path = File.join(context, "app.bundle")
66
+ @shell.log "bundling #{map.size} branch(es) from #{app.key}"
67
+ @shell.sh("git", "-C", repo, "bundle", "create", bundle_path, *map.keys)
68
+ # -C names the repository. `git bundle verify` resolves the bundle's
69
+ # prerequisites against a repository, and without -C it uses the working
70
+ # directory, which for an experiment directory is usually not one.
71
+ @shell.sh("git", "-C", repo, "bundle", "verify", bundle_path)
72
+
73
+ containerfile = File.join(context, "Containerfile")
74
+ File.write(containerfile, containerfile_for(app, map))
75
+
76
+ @shell.log "building #{app.image}"
77
+ @container.build(tag: app.image, file: containerfile, context: context,
78
+ build_args: { "APP_KEY" => app.key, "RUBY_VERSION" => app.ruby },
79
+ no_cache: no_cache)
80
+ @shell.log "app image ready: #{app.image} (never push: it carries the subject app's code)"
81
+ end
82
+
83
+ # FLATTENING
84
+ #
85
+ # Each experiment branch becomes one orphan commit called "Import application
86
+ # source", renamed to an opaque `trial/<task id>`.
87
+ #
88
+ # Three leaks close here, all of which would let an agent read the answer rather
89
+ # than search for it:
90
+ #
91
+ # the commit message said "reintroduce the defect fixed in <sha>"
92
+ # the commit diff was the real fix, inverted
93
+ # the branch name carried the defect's slug, e.g. -nat64-recheck
94
+ #
95
+ # Leaks like these do not just add noise, they bias: an agent given no path is
96
+ # far likelier to go digging through history than one handed the path outright,
97
+ # so the shortcut would help exactly the condition the experiment expects to be
98
+ # slowest. The trial runner closes the fourth leak by deleting every branch
99
+ # except the one under test, so `git diff trial/base` cannot reveal it either.
100
+ def branch_map(app)
101
+ map = { "#{app.branch_prefix}/base" => "trial/base" }
102
+ tasks_for(app).each { |task| map["#{app.branch_prefix}/#{task.id}"] = task.branch }
103
+ map
104
+ end
105
+
106
+ # Pure: builds the Containerfile text from the app and its branch map, so the
107
+ # generated recipe can be read and tested without a container or a checkout.
108
+ def containerfile_for(app, branch_map)
109
+ # Cloning a bundle materialises only the checked-out branch locally; the rest
110
+ # arrive as origin/* refs, which disappear along with the remote. Create real
111
+ # local branches first, then drop the remote.
112
+ localise = branch_map.keys.map { |b| "git branch -f #{b} origin/#{b} 2>/dev/null || true;" }
113
+ .join(" \\\n ")
114
+
115
+ flatten = branch_map.map do |source, target|
116
+ "git checkout -q #{source}; git checkout -q --orphan #{target}; " \
117
+ "#{neutralize_fragment(app)}" \
118
+ "git add -A; git commit -q -m 'Import application source';"
119
+ end.join(" \\\n ")
120
+
121
+ drop_originals = branch_map.keys.map { |b| "git branch -q -D #{b};" }.join(" ")
122
+
123
+ containerfile = +<<~DOCKER
124
+ FROM #{LLMExperiment.base_image}
125
+
126
+ ARG APP_KEY
127
+ ARG RUBY_VERSION
128
+
129
+ ENV LLMX_APP=${APP_KEY} \\
130
+ MISE_RUBY_VERSION=${RUBY_VERSION} \\
131
+ RAILS_ENV=test
132
+
133
+ USER agent
134
+ WORKDIR /workspace
135
+
136
+ COPY --chown=agent:agent app.bundle /home/agent/app.bundle
137
+
138
+ RUN set -eux; \\
139
+ git clone --branch #{app.branch_prefix}/base /home/agent/app.bundle /workspace/app; \\
140
+ cd /workspace/app; \\
141
+ #{localise} \\
142
+ git remote remove origin; \\
143
+ git config user.email dev@example.invalid; \\
144
+ git config user.name Developer; \\
145
+ #{flatten} \\
146
+ #{drop_originals} \\
147
+ git checkout -q trial/base; \\
148
+ rm -f /home/agent/app.bundle; \\
149
+ git reflog expire --expire=now --all; \\
150
+ git gc --prune=now --quiet; \\
151
+ git branch; \\
152
+ test -z "$(git log --all --oneline --format='%s' | grep -viE '^Import application source$' || true)"
153
+
154
+ WORKDIR /workspace/app
155
+ DOCKER
156
+
157
+ containerfile << postgres_block if app.database == "postgresql"
158
+ containerfile << bundler_block(app)
159
+ containerfile
160
+ end
161
+
162
+ private
163
+
164
+ def tasks_for(app)
165
+ @experiment.tasks.select { |task| task.app == app.key }
166
+ end
167
+
168
+ def require_branches!(app, repo, map)
169
+ branches = @shell.capture("git", "-C", repo, "branch", "--list", "#{app.branch_prefix}/*",
170
+ "--format=%(refname:short)").split("\n").map(&:strip).reject(&:empty?)
171
+ missing = map.keys - branches
172
+ return if missing.empty?
173
+
174
+ raise Error, "#{app.key}: missing branches #{missing.inspect} in #{repo}; " \
175
+ "the experiment's planting script has to create them first"
176
+ end
177
+
178
+ # Coaching files - CLAUDE.md, .mcp.json, .claude and friends - must never
179
+ # reach an image, whatever the planting script did or forgot to do. Deleting
180
+ # them inside the flatten sequence, after the orphan checkout and before the
181
+ # single commit, means no branch and no history can carry them back in.
182
+ def neutralize_fragment(app)
183
+ return "" if app.neutralize.empty?
184
+
185
+ "git rm -r -f -q --ignore-unmatch #{app.neutralize.map { |p| Shellwords.escape(p) }.join(" ")}; "
186
+ end
187
+
188
+ # A private cluster owned by the agent user, not the packaged system cluster.
189
+ # The system one needs root to start, root is not available mid-trial, and
190
+ # installing sudo just to reach it would put a privilege-escalation path in a
191
+ # container that runs a model's shell commands. initdb as `agent` makes agent
192
+ # the superuser and pg_ctl needs no privileges at all.
193
+ #
194
+ # The socket directory has to move with it. Postgres creates a Unix socket at
195
+ # startup even when every client speaks TCP, and its default
196
+ # /var/run/postgresql belongs to the postgres system user, so an agent-owned
197
+ # cluster dies on the spot with "could not create lock file ... Permission
198
+ # denied" - the one privilege the design set out to avoid needing. /tmp is
199
+ # agent-writable, and nothing here uses the socket anyway: PGHOST is
200
+ # 127.0.0.1 and listen_addresses matches.
201
+ def postgres_block
202
+ <<~DOCKER
203
+
204
+ ENV PGDATA=/home/agent/pgdata \\
205
+ PGHOST=127.0.0.1 \\
206
+ PGPORT=5432 \\
207
+ PGUSER=agent
208
+ RUN set -eux; \\
209
+ PGBIN=$(dirname $(ls /usr/lib/postgresql/*/bin/initdb | head -1)); \\
210
+ echo "export PATH=$PGBIN:\\$PATH" >> /home/agent/.bashrc; \\
211
+ $PGBIN/initdb -D $PGDATA -U agent --auth=trust --encoding=UTF8; \\
212
+ echo "listen_addresses = '127.0.0.1'" >> $PGDATA/postgresql.conf; \\
213
+ echo "unix_socket_directories = '/tmp'" >> $PGDATA/postgresql.conf; \\
214
+ echo "fsync = off" >> $PGDATA/postgresql.conf; \\
215
+ $PGBIN/pg_ctl -D $PGDATA -o "-p 5432" -w start; \\
216
+ $PGBIN/createdb -U agent agent; \\
217
+ $PGBIN/pg_ctl -D $PGDATA -w stop
218
+ DOCKER
219
+ end
220
+
221
+ def bundler_block(app)
222
+ <<~DOCKER
223
+
224
+ # Gems install to BUNDLE_PATH from the base image, which is outside the
225
+ # checkout, so `git status` during a trial shows only the agent's own edits.
226
+ #
227
+ # Bundler rewrites Gemfile.lock when the lockfile has no entry for the
228
+ # container's platform: these images are aarch64-linux, and an app locked only
229
+ # on darwin and x86_64-linux gains an aarch64-linux line during install. That
230
+ # leaves the tree dirty before any agent has touched it, so every trial would
231
+ # start with a modified Gemfile.lock and carry it in the agent's own diff.
232
+ #
233
+ # The lockfile change is committed rather than reverted. Reverting invites
234
+ # bundler to redo it mid-trial, which would dirty the tree at a point that
235
+ # actually matters. It is amended onto each branch's single commit so the
236
+ # "one commit called Import application source" property survives, and the
237
+ # working tree carries the same change across every checkout because the
238
+ # planted bugs only ever touch implementation files.
239
+ RUN set -eux; \\
240
+ cd /workspace/app; \\
241
+ ruby --version; \\
242
+ gem install bundler --no-document #{app.bundler == "default" ? "" : "-v #{app.bundler}"}; \\
243
+ bundle install; \\
244
+ if ! git diff --quiet; then \\
245
+ git add -A; \\
246
+ git commit -q --amend --no-edit; \\
247
+ for b in $(git branch --format='%(refname:short)'); do \\
248
+ if [ "$b" != trial/base ]; then \\
249
+ git checkout -q "$b"; \\
250
+ git checkout -q trial/base -- Gemfile.lock; \\
251
+ git add -A; \\
252
+ git commit -q --amend --no-edit; \\
253
+ test -z "$(git status --porcelain)"; \\
254
+ fi; \\
255
+ done; \\
256
+ git checkout -q trial/base; \\
257
+ git reflog expire --expire=now --all; \\
258
+ git gc --prune=now --quiet; \\
259
+ fi; \\
260
+ git status --porcelain; \\
261
+ test -z "$(git status --porcelain)"; \\
262
+ test -z "$(git log --all --oneline --format='%s' | grep -viE '^Import application source$' || true)"
263
+ DOCKER
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ module ImageBuilder
5
+ # Builds and smoke-checks the base image. Compiles the rubies from source,
6
+ # so the first build is 15-30 minutes and every later one is cached.
7
+ class Base
8
+ def initialize(container: Container.new, shell: Shell)
9
+ @container = container
10
+ @shell = shell
11
+ end
12
+
13
+ # Compiling two rubies from source measured about 8 GB. Less than an app
14
+ # image, but it is the longest build in the tool, so losing it to a full
15
+ # disk costs the most.
16
+ NEED_GB = 10
17
+
18
+ def build(pins:, tag: LLMExperiment.base_image, no_cache: false)
19
+ @container.ensure_disk!(need_gb: NEED_GB)
20
+ @container.ensure_builder!
21
+ @shell.log "building #{tag} (compiles #{pins["ruby_versions"].join(" ")} from source; expect 15-30 min cold)"
22
+ @container.build(
23
+ tag: tag,
24
+ file: LLMExperiment.template_path("base.Containerfile"),
25
+ context: File.dirname(LLMExperiment.template_path("base.Containerfile")),
26
+ build_args: {
27
+ "RUBY_VERSIONS" => pins["ruby_versions"].join(" "),
28
+ "NODE_VERSION" => pins["node"],
29
+ "CLAUDE_CODE_VERSION" => pins["claude_code"],
30
+ "CODEX_VERSION" => pins["codex"],
31
+ "OPENCODE_VERSION" => pins["opencode"]
32
+ },
33
+ no_cache: no_cache
34
+ )
35
+ smoke_check(tag, pins)
36
+ @shell.log "base image ready: #{tag}"
37
+ end
38
+
39
+ private
40
+
41
+ def smoke_check(tag, pins)
42
+ @shell.log "smoke-checking the toolchain inside #{tag}"
43
+ script = <<~SH
44
+ set -e
45
+ echo "ruby: $(ruby --version)"
46
+ echo "node: $(node --version)"
47
+ echo "claude: $(claude --version)"
48
+ echo "codex: $(codex --version)"
49
+ echo "opencode: $(opencode --version)"
50
+ echo "vips: $(vips --version)"
51
+ echo "psql: $(psql --version)"
52
+ for v in #{pins["ruby_versions"].join(" ")}; do
53
+ echo "ruby $v: $(mise exec ruby@$v -- ruby -e 'print RUBY_VERSION')"
54
+ done
55
+ cat /etc/llmx-versions.json
56
+ SH
57
+ @container.run(tag, script,
58
+ memory: LLMExperiment.default_memory,
59
+ cpus: LLMExperiment.default_cpus,
60
+ mount_auth: false)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LLMExperiment
6
+ # Aggregates trial metrics and compares the conditions pairwise.
7
+ #
8
+ # Never pool across agents or apps. Claude and Codex do not count tool calls
9
+ # the same way, and two subject apps are different sizes of haystack. A number
10
+ # averaged over those is not a measurement of anything. So the rows are
11
+ # grouped by [app, agent] and each cell is reported on its own.
12
+ class MetricsReport
13
+ METRICS = %w[
14
+ wall_seconds
15
+ output_tokens
16
+ input_tokens
17
+ cache_creation_input_tokens
18
+ total_tool_calls
19
+ context_before_first_tool_call
20
+ tool_calls_to_first_defect_read
21
+ ].freeze
22
+
23
+ def initialize(experiment:)
24
+ @experiment = experiment
25
+ end
26
+
27
+ # One tree, never both. results-raw/ holds every trial; results/ holds the
28
+ # published subset, and for a public app those are the same files copied. A
29
+ # glob of "results*" matched both, so every published trial was counted
30
+ # twice -- which does not merely inflate n, it duplicates each observation,
31
+ # halves the apparent variance and pushes the exact p-values toward
32
+ # significance. Prefer the raw tree, which is always complete, and fall back
33
+ # to the published one so a fresh clone without results-raw/ still computes.
34
+ def files
35
+ @files ||= begin
36
+ raw = Dir.glob(File.join(@experiment.results_raw_dir, "**", "metrics.json"))
37
+ raw.empty? ? Dir.glob(File.join(@experiment.results_dir, "**", "metrics.json")) : raw
38
+ end
39
+ end
40
+
41
+ # A trial whose task never reproduced measured nothing: the agent was asked
42
+ # to fix something that was not broken.
43
+ def rows
44
+ @rows ||= files.sort.map { |f| JSON.parse(File.read(f)) }
45
+ .reject { |r| r["task_reproduces"] == false }
46
+ end
47
+
48
+ def conditions = @experiment.conditions
49
+
50
+ def condition_pairs = @experiment.conditions.combination(2).to_a
51
+
52
+ def render(io = $stdout)
53
+ raise Error, "no metrics.json found in #{@experiment.root}. Run trials, then `llmx parse --all`" if files.empty?
54
+
55
+ io.puts "#{rows.size} trial(s) from #{files.size} file(s)"
56
+ report_undeclared_conditions(io)
57
+ io.puts
58
+ cells.each { |(app, agent), cell| render_cell(io, app, agent, cell) }
59
+ render_fix_verified(io)
60
+ end
61
+
62
+ private
63
+
64
+ # Only declared conditions are tabulated, so results from a condition the
65
+ # experiment has since retired are read and then left out. Say so: a run
66
+ # that prints "102 trials" and reports on 90 otherwise reads as complete.
67
+ def report_undeclared_conditions(io)
68
+ found = rows.map { |r| r["condition"] }.compact.uniq
69
+ undeclared = found - conditions
70
+ return if undeclared.empty?
71
+
72
+ undeclared.sort.each do |cond|
73
+ n = rows.count { |r| r["condition"] == cond }
74
+ io.puts " excluded: #{n} trial(s) in condition #{cond.inspect}, " \
75
+ "which experiment.yml does not declare"
76
+ end
77
+ end
78
+
79
+ def cells
80
+ rows.group_by { |r| [r["app"], r["agent"]] }
81
+ .sort_by { |(app, agent), _| [app.to_s, agent.to_s] }
82
+ end
83
+
84
+ def render_cell(io, app, agent, cell)
85
+ by_condition = cell.group_by { |r| r["condition"] }
86
+ io.puts "=" * 78
87
+ io.puts "#{app} / #{agent} (n per condition: #{conditions.map do |c|
88
+ "#{c}=#{(by_condition[c] || []).size}"
89
+ end.join(" ")})"
90
+ io.puts "=" * 78
91
+
92
+ METRICS.each { |metric| render_metric(io, by_condition, metric) }
93
+ io.puts
94
+ end
95
+
96
+ def render_metric(io, by_condition, metric)
97
+ series = conditions.to_h do |cond|
98
+ [cond, (by_condition[cond] || []).filter_map { |r| r[metric] }.map(&:to_f)]
99
+ end
100
+ return if series.values.all?(&:empty?)
101
+
102
+ io.puts "\n #{metric}"
103
+ series.each do |cond, values|
104
+ next if values.empty?
105
+
106
+ io.puts format(" %-5s n=%-3d median=%-12s values=%s",
107
+ cond, values.size, Stats.median(values).round(2), values.map { |v| v.round(1) }.inspect)
108
+ end
109
+
110
+ condition_pairs.each { |(left, right)| render_pair(io, series, left, right) }
111
+ end
112
+
113
+ def render_pair(io, series, left, right)
114
+ a = series[left]
115
+ b = series[right]
116
+ return if a.empty? || b.empty?
117
+
118
+ result = Stats.exact_mann_whitney(a, b)
119
+ p_text = result[:p] ? format("p=%.4f", result[:p]) : "p=#{result[:note]}"
120
+ delta = Stats.median(b) - Stats.median(a)
121
+ io.puts format(" %-5s vs %-5s U=%-7s %-16s median change %+.2f",
122
+ left, right, result[:u], p_text, delta)
123
+ end
124
+
125
+ def render_fix_verified(io)
126
+ io.puts "\nfix_verified by condition (not pooled):"
127
+ cells.each do |(app, agent), cell|
128
+ parts = conditions.map do |cond|
129
+ subset = cell.select { |r| r["condition"] == cond }
130
+ next "#{cond}=-" if subset.empty?
131
+
132
+ "#{cond}=#{subset.count { |r| r["fix_verified"] }}/#{subset.size}"
133
+ end
134
+ io.puts format(" %-18s %-8s %s", app, agent, parts.join(" "))
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ # Default version pins. An experiment overrides them in experiment.yml under
5
+ # `pins:`; a result can always be traced back to a toolchain.
6
+ module Pins
7
+ DEFAULTS = {
8
+ "ruby_versions" => ["3.4.5", "4.0.1"],
9
+ "node" => "22",
10
+ "claude_code" => "2.1.233",
11
+ "codex" => "0.147.0",
12
+ "opencode" => "1.18.15"
13
+ }.freeze
14
+
15
+ def self.resolve(overrides = {})
16
+ unknown = overrides.keys - DEFAULTS.keys
17
+ raise ConfigError, "unknown pins: #{unknown.join(", ")}" if unknown.any?
18
+
19
+ DEFAULTS.merge(overrides)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module LLMExperiment
7
+ # The gate between results-raw/ (gitignored) and results/ (committed).
8
+ #
9
+ # A subject app may be private, so its raw transcripts cannot be published: a
10
+ # transcript contains whatever source the agent read. For those apps only
11
+ # derived measurements cross. An app marked `publish_transcripts: true` in
12
+ # experiment.yml crosses whole.
13
+ #
14
+ # The check is deliberately a hard failure rather than a redaction. Something
15
+ # unexpected in a transcript should stop the commit and be looked at, not be
16
+ # quietly rewritten into something that looks safe.
17
+ class Sanitizer
18
+ # Anything matching these must never reach the committed tree, whatever the
19
+ # app's privacy setting. Host paths identify the machine; the rest are secrets.
20
+ FORBIDDEN = {
21
+ "host home path" => %r{/Users/[a-z]},
22
+ "AWS-style key" => /AKIA[0-9A-Z]{16}/,
23
+ "private key block" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
24
+ "bearer token" => /\b(?:sk|pk)-[A-Za-z0-9_-]{20,}/,
25
+ "rails master key" => /\b[0-9a-f]{32}\b(?=.*master)/i,
26
+ "generic api secret" => /(?:api[_-]?key|secret|password|token)["'\s:=]+[A-Za-z0-9_-]{24,}/i
27
+ }.freeze
28
+
29
+ # Derived measurements always cross. Raw transcripts only for public apps.
30
+ DERIVED = %w[meta.json metrics.json prompt.txt].freeze
31
+ RAW = %w[transcript.jsonl events.jsonl agent.diff test_before.txt test_after.txt].freeze
32
+
33
+ PRIVATE_README = <<~MD
34
+ Private application. Raw transcripts and diffs stay in `results-raw/`,
35
+ which is gitignored; only the measurements derived from them are here.
36
+
37
+ - `meta.json` what the trial did and whether the fix held
38
+ - `metrics.json` the numbers `llmx metrics` reads
39
+ - `prompt.txt` the exact prompt, which contains no application source
40
+ MD
41
+
42
+ def initialize(experiment:)
43
+ @experiment = experiment
44
+ end
45
+
46
+ def public_apps
47
+ @public_apps ||= @experiment.apps.values.select(&:publish_transcripts).map(&:key)
48
+ end
49
+
50
+ # Report what would be refused, copy nothing.
51
+ def check = run(check_only: true)
52
+
53
+ # Copy what may cross, or refuse and leave nothing behind.
54
+ def publish = run(check_only: false)
55
+
56
+ private
57
+
58
+ def run(check_only:)
59
+ dirs = trial_dirs
60
+ raise Error, "nothing in #{@experiment.results_raw_dir} yet" if dirs.empty?
61
+
62
+ violations = []
63
+ copied = 0
64
+
65
+ dirs.each do |dir|
66
+ crossing = crossing_files(dir)
67
+ violations.concat(scan_all(dir, crossing))
68
+ next if check_only
69
+
70
+ copied += copy(dir, crossing)
71
+ end
72
+
73
+ refuse(violations, check_only: check_only) if violations.any?
74
+ report(dirs.size, copied, check_only: check_only)
75
+ end
76
+
77
+ def trial_dirs
78
+ Dir.glob(File.join(@experiment.results_raw_dir, "**", "meta.json")).map { |p| File.dirname(p) }.sort
79
+ end
80
+
81
+ def public?(dir)
82
+ meta = JSON.parse(File.read(File.join(dir, "meta.json")))
83
+ public_apps.include?(meta["app"])
84
+ end
85
+
86
+ def crossing_files(dir)
87
+ public?(dir) ? DERIVED + RAW : DERIVED
88
+ end
89
+
90
+ def relative(dir) = dir.sub("#{@experiment.results_raw_dir}/", "")
91
+
92
+ def scan_all(dir, crossing)
93
+ crossing.flat_map do |name|
94
+ src = File.join(dir, name)
95
+ next [] unless File.exist?(src)
96
+
97
+ scan(File.read(src), File.join(relative(dir), name))
98
+ end
99
+ end
100
+
101
+ def scan(text, label)
102
+ FORBIDDEN.filter_map do |name, pattern|
103
+ next unless (m = text[pattern])
104
+
105
+ { "file" => label, "kind" => name, "sample" => m[0, 40] }
106
+ end
107
+ end
108
+
109
+ def copy(dir, crossing)
110
+ target = File.join(@experiment.results_dir, relative(dir))
111
+ FileUtils.mkdir_p(target)
112
+ copied = 0
113
+ crossing.each do |name|
114
+ src = File.join(dir, name)
115
+ next unless File.exist?(src)
116
+
117
+ FileUtils.cp(src, File.join(target, name))
118
+ copied += 1
119
+ end
120
+ File.write(File.join(target, "README.md"), PRIVATE_README) unless public?(dir)
121
+ copied
122
+ end
123
+
124
+ def refuse(violations, check_only:)
125
+ warn "REFUSING to publish. #{violations.size} problem(s):\n"
126
+ violations.first(40).each { |v| warn format(" %-56s %-18s %s", v["file"], v["kind"], v["sample"].inspect) }
127
+ unless check_only
128
+ warn "\nNothing was copied."
129
+ FileUtils.rm_rf(@experiment.results_dir)
130
+ end
131
+
132
+ raise Error, "#{violations.size} problem(s) in what would be published; nothing was published"
133
+ end
134
+
135
+ def report(trials, copied, check_only:)
136
+ if check_only
137
+ puts "checked #{trials} trial(s): clean"
138
+ else
139
+ puts "published #{copied} file(s) from #{trials} trial(s) to #{@experiment.results_dir}"
140
+ puts "raw transcripts committed only for: #{public_apps.empty? ? "(no app)" : public_apps.join(", ")}"
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+
6
+ module LLMExperiment
7
+ # `llmx new NAME`. Scaffolds the directory convention every other command
8
+ # assumes. Prompts and task generation stay experiment-owned.
9
+ class Scaffold
10
+ def initialize(target)
11
+ @target = File.expand_path(target)
12
+ @name = File.basename(@target)
13
+ end
14
+
15
+ def create
16
+ raise Error, "#{@target} already exists" if File.exist?(@target)
17
+
18
+ FileUtils.mkdir_p(@target)
19
+ %w[prompts results-raw results].each { |d| FileUtils.mkdir_p(File.join(@target, d)) }
20
+ write_template("experiment.yml.erb", "experiment.yml")
21
+ write_template("README.md.erb", "README.md")
22
+ FileUtils.cp(LLMExperiment.template_path("gitignore"), File.join(@target, ".gitignore"))
23
+ File.write(File.join(@target, "results-raw", ".gitkeep"), "")
24
+
25
+ puts "created #{@name}/"
26
+ puts "next: edit #{@name}/experiment.yml, then run `llmx doctor`"
27
+ end
28
+
29
+ private
30
+
31
+ def write_template(template, out)
32
+ erb = ERB.new(File.read(LLMExperiment.template_path(template)), trim_mode: "-")
33
+ File.write(File.join(@target, out), erb.result(template_binding))
34
+ end
35
+
36
+ # The templates read `name` from this binding. It is a parameter rather
37
+ # than a local, because a local assigned only for ERB's benefit reads as
38
+ # dead code to the parser and warns on every test run.
39
+ def template_binding(name = @name)
40
+ binding
41
+ end
42
+ end
43
+ end