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,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
|