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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class BuildCommand < Command
|
|
6
|
+
NAME = "build"
|
|
7
|
+
SUMMARY = "build the base image or a per-app image"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = "Usage: llmx build base [options]\n llmx build app KEY [options]"
|
|
11
|
+
parser.on("--no-cache", "rebuild every layer") { @no_cache = true }
|
|
12
|
+
parser.on("--tag TAG", "image tag (base only)") { |t| @tag = t }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(argv)
|
|
16
|
+
case argv.shift
|
|
17
|
+
when "base"
|
|
18
|
+
pins = begin
|
|
19
|
+
experiment.pins
|
|
20
|
+
rescue ConfigError
|
|
21
|
+
Pins.resolve
|
|
22
|
+
end
|
|
23
|
+
ImageBuilder::Base.new(container: container)
|
|
24
|
+
.build(pins: pins, tag: @tag || LLMExperiment.base_image, no_cache: !!@no_cache)
|
|
25
|
+
when "app"
|
|
26
|
+
key = argv.shift or raise Error, "usage: llmx build app KEY"
|
|
27
|
+
ImageBuilder::App.new(experiment: experiment, container: container)
|
|
28
|
+
.build(app: experiment.app(key), no_cache: !!@no_cache)
|
|
29
|
+
else
|
|
30
|
+
raise Error, "usage: llmx build base | llmx build app KEY"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
register BuildCommand::NAME, BuildCommand
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class CleanCommand < Command
|
|
6
|
+
NAME = "clean"
|
|
7
|
+
SUMMARY = "reclaim disk: app images and the BuildKit cache, verified by observation"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = <<~BANNER
|
|
11
|
+
Usage: llmx clean [--images] [--builder] [--base] [--dry-run]
|
|
12
|
+
|
|
13
|
+
With no flags this reports and deletes nothing.
|
|
14
|
+
|
|
15
|
+
The report measures the unpacked snapshot on disk, because that is what
|
|
16
|
+
an image costs: `container image inspect` sums layers and reports about
|
|
17
|
+
a third of it.
|
|
18
|
+
|
|
19
|
+
`--builder` verifies afterwards with `container ls -a` and prints the
|
|
20
|
+
kill-the-VM recovery if the builder is still there, because `container
|
|
21
|
+
builder stop` can exit 0 and do nothing.
|
|
22
|
+
|
|
23
|
+
BANNER
|
|
24
|
+
parser.on("--images", "delete every llmx-app-* image") { @images = true }
|
|
25
|
+
parser.on("--base", "also delete llmx-base (implies --images; 20-40 min to rebuild)") { @base = true }
|
|
26
|
+
parser.on("--builder", "stop the BuildKit builder and delete its cache") { @builder = true }
|
|
27
|
+
parser.on("--dry-run", "print what would go, delete nothing") { @dry_run = true }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(_argv)
|
|
31
|
+
cleaner = Cleaner.new(container: container)
|
|
32
|
+
images = @images || @base
|
|
33
|
+
return cleaner.report unless images || @builder
|
|
34
|
+
|
|
35
|
+
cleaner.clean_images(dry_run: !@dry_run.nil?, base: !@base.nil?) if images
|
|
36
|
+
cleaner.clean_builder(dry_run: !!@dry_run) if @builder
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
register CleanCommand::NAME, CleanCommand
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class DoctorCommand < Command
|
|
6
|
+
NAME = "doctor"
|
|
7
|
+
SUMMARY = "preflight checks: CLI, services, disk, auth, images, prompts"
|
|
8
|
+
|
|
9
|
+
MARKS = { ok: "ok", warn: "warn", fail: "FAIL" }.freeze
|
|
10
|
+
|
|
11
|
+
def run(_argv)
|
|
12
|
+
exp = begin
|
|
13
|
+
experiment
|
|
14
|
+
rescue ConfigError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
checks = Doctor.new(container: container).checks(exp)
|
|
18
|
+
checks.each do |check|
|
|
19
|
+
line = format(" %-5s %-22s %s", MARKS[check.status], check.name, check.detail)
|
|
20
|
+
line += " -> #{check.hint}" if check.hint && check.status != :ok
|
|
21
|
+
puts line
|
|
22
|
+
end
|
|
23
|
+
puts exp ? "\nexperiment: #{exp.name} (#{exp.root})" : "\n(no experiment.yml found here; machine checks only)"
|
|
24
|
+
raise Error, "doctor found failures" if checks.any? { |c| c.status == :fail }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
register DoctorCommand::NAME, DoctorCommand
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class LoginCommand < Command
|
|
6
|
+
NAME = "login"
|
|
7
|
+
SUMMARY = "log the agent CLIs in to your plan subscriptions"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = "Usage: llmx login [--agent NAME] [--verify]"
|
|
11
|
+
parser.on("--agent NAME", "log in one agent only (#{Auth::AGENTS.join(", ")})") { |a| @agent = a }
|
|
12
|
+
parser.on("--verify", "report what is logged in, log nothing in") { @verify = true }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(_argv)
|
|
16
|
+
auth = Auth.new(container: container)
|
|
17
|
+
return auth.verify if @verify
|
|
18
|
+
|
|
19
|
+
auth.login(agents: @agent ? [@agent] : Auth::AGENTS)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
register LoginCommand::NAME, LoginCommand
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class MetricsCommand < Command
|
|
6
|
+
NAME = "metrics"
|
|
7
|
+
SUMMARY = "medians and exact Mann-Whitney U, never pooled"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = <<~BANNER
|
|
11
|
+
Usage: llmx metrics [options]
|
|
12
|
+
|
|
13
|
+
Reads every metrics.json written by `llmx parse` and reports one cell
|
|
14
|
+
per app and agent. Nothing is pooled across those: two agents do not
|
|
15
|
+
count tool calls the same way, and two apps are different sizes of
|
|
16
|
+
haystack, so a number averaged over them measures nothing.
|
|
17
|
+
|
|
18
|
+
p-values are exact. With a handful of tasks per cell the normal
|
|
19
|
+
approximation to the Mann-Whitney U is not trustworthy, so every
|
|
20
|
+
split of the pooled values is enumerated instead.
|
|
21
|
+
|
|
22
|
+
BANNER
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(_argv)
|
|
26
|
+
MetricsReport.new(experiment: experiment).render
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
register MetricsCommand::NAME, MetricsCommand
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class NewCommand < Command
|
|
6
|
+
NAME = "new"
|
|
7
|
+
SUMMARY = "scaffold a new experiment directory"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = "Usage: llmx new NAME"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(argv)
|
|
14
|
+
target = argv.shift or raise Error, "usage: llmx new NAME"
|
|
15
|
+
Scaffold.new(target).create
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
register NewCommand::NAME, NewCommand
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class ParseCommand < Command
|
|
6
|
+
NAME = "parse"
|
|
7
|
+
SUMMARY = "transcripts to events.jsonl and metrics.json"
|
|
8
|
+
|
|
9
|
+
ROW = "%-18s %-8s %-6s %6s %8s %7s %7s %6s"
|
|
10
|
+
|
|
11
|
+
def add_options(parser)
|
|
12
|
+
parser.banner = <<~BANNER
|
|
13
|
+
Usage: llmx parse TRIAL_DIR [TRIAL_DIR...]
|
|
14
|
+
llmx parse --all
|
|
15
|
+
|
|
16
|
+
Reads each trial's transcript.jsonl and writes events.jsonl plus
|
|
17
|
+
metrics.json beside it. Re-running is safe: both files are derived.
|
|
18
|
+
|
|
19
|
+
ctx1st is the context the very first model request carried, before any
|
|
20
|
+
tool could have fetched anything. It is blank for Codex, which reports
|
|
21
|
+
usage once per turn rather than per request.
|
|
22
|
+
|
|
23
|
+
BANNER
|
|
24
|
+
parser.on("--all", "parse every trial under results-raw/") { @all = true }
|
|
25
|
+
parser.on("--lenient", "report contaminated trials without failing") { @lenient = true }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run(argv)
|
|
29
|
+
reader = Transcript::Parser.new(experiment: experiment)
|
|
30
|
+
dirs = @all ? reader.trial_dirs : argv
|
|
31
|
+
raise Error, "llmx parse needs a trial directory, or --all" if dirs.empty?
|
|
32
|
+
|
|
33
|
+
rows = dirs.filter_map { |dir| reader.parse(dir) }
|
|
34
|
+
print_table(rows)
|
|
35
|
+
flagged = report_contamination(rows)
|
|
36
|
+
return if flagged.empty? || @lenient
|
|
37
|
+
|
|
38
|
+
# Exit non-zero rather than warn and succeed. A contaminated trial that
|
|
39
|
+
# only prints a message gets pooled with clean ones by whatever runs
|
|
40
|
+
# next, and in a 90-cell batch nobody re-reads the scrollback. Parsing
|
|
41
|
+
# still wrote every metrics.json, so --lenient recovers the old
|
|
42
|
+
# behaviour once the trials have been looked at.
|
|
43
|
+
raise Error, "#{flagged.size} contaminated trial(s); re-run them, or pass --lenient"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def print_table(rows)
|
|
49
|
+
puts format(ROW, "task", "agent", "cond", "tools", "ctx1st", "out", "wall", "fixed")
|
|
50
|
+
rows.each do |r|
|
|
51
|
+
puts format(ROW, r["task_id"], r["agent"], r["condition"],
|
|
52
|
+
r["total_tool_calls"], r["context_before_first_tool_call"],
|
|
53
|
+
r["output_tokens"], r["wall_seconds"], r["fix_verified"])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only Claude reports what it was given at startup, so in practice only
|
|
58
|
+
# Claude rows can be checked. Every row is still offered to the check
|
|
59
|
+
# rather than filtered by agent, so a future CLI that starts reporting
|
|
60
|
+
# its startup state is covered without anyone remembering to come back.
|
|
61
|
+
def report_contamination(rows)
|
|
62
|
+
flagged = rows.map { |r| [r, Transcript::Hermeticity.contamination(r)] }
|
|
63
|
+
.reject { |(_, reasons)| reasons.empty? }
|
|
64
|
+
return flagged if flagged.empty?
|
|
65
|
+
|
|
66
|
+
warn "\nWARNING: #{flagged.size} trial(s) ran with state they should not have had:"
|
|
67
|
+
flagged.each do |(row, reasons)|
|
|
68
|
+
warn " #{row["trial_dir"]}"
|
|
69
|
+
reasons.each { |reason| warn " - #{reason}" }
|
|
70
|
+
end
|
|
71
|
+
flagged
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
register ParseCommand::NAME, ParseCommand
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class RunCommand < Command
|
|
6
|
+
NAME = "run"
|
|
7
|
+
SUMMARY = "run one trial, or the whole grid with --all"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = <<~BANNER
|
|
11
|
+
Usage: llmx run --task ID --agent NAME --condition NAME [options]
|
|
12
|
+
llmx run --all [--app KEY] [--task ID] [--agent NAME] [options]
|
|
13
|
+
|
|
14
|
+
One trial is one task, one agent, one condition, in a fresh container.
|
|
15
|
+
--all runs the grid: sequentially, blocked by task, and resumable —
|
|
16
|
+
a cell that already has results is skipped unless --redo is given.
|
|
17
|
+
|
|
18
|
+
BANNER
|
|
19
|
+
parser.on("--task ID", "task id from experiment.yml (a filter under --all)") { |v| @task = v }
|
|
20
|
+
parser.on("--agent NAME", "agent to run (a filter under --all)") { |v| @agent = v }
|
|
21
|
+
parser.on("--condition NAME", "condition to run") { |v| @condition = v }
|
|
22
|
+
parser.on("--all", "run every remaining cell of the grid") { @all = true }
|
|
23
|
+
parser.on("--app KEY", "with --all: only this app's tasks") { |v| @app = v }
|
|
24
|
+
parser.on("--redo", "with --all: re-run cells that already have results") { @redo = true }
|
|
25
|
+
parser.on("--timeout S", Integer, "wall-clock seconds for the agent") { |v| @timeout = v }
|
|
26
|
+
parser.on("--dry-run", "show what would run, run nothing") { @dry_run = true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(_argv)
|
|
30
|
+
return run_grid if @all
|
|
31
|
+
|
|
32
|
+
unless @task && @agent && @condition
|
|
33
|
+
raise Error, "llmx run needs --task, --agent and --condition (or --all for the grid)"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Trial.new(experiment: experiment, task_id: @task, agent: @agent, condition: @condition,
|
|
37
|
+
container: container, timeout: @timeout).run(dry_run: !!@dry_run)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def run_grid
|
|
43
|
+
grid = Grid.new(experiment: experiment, container: container)
|
|
44
|
+
filters = { app: @app, agent: @agent, task: @task }
|
|
45
|
+
planned = grid.plan(**filters).size
|
|
46
|
+
todo = @redo ? planned : grid.pending(**filters).size
|
|
47
|
+
puts "grid: #{planned} cell(s) - #{todo} to run, #{planned - todo} already have results"
|
|
48
|
+
puts
|
|
49
|
+
|
|
50
|
+
result = grid.run(**filters, redo_done: !@redo.nil?, dry_run: !@dry_run.nil?) do |index, total, cell|
|
|
51
|
+
puts format("[%d/%d] %s", index, total, grid.label(cell))
|
|
52
|
+
end
|
|
53
|
+
report(result)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def report(result)
|
|
57
|
+
if @dry_run
|
|
58
|
+
puts "\n(dry run: nothing was executed)"
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
puts
|
|
63
|
+
puts "ran #{result.ran}/#{result.total} cell(s)"
|
|
64
|
+
return if result.ok?
|
|
65
|
+
|
|
66
|
+
puts "\n#{result.failures.size} failed:"
|
|
67
|
+
result.failures.each { |cell| puts " #{cell[:task]} / #{cell[:agent]} / #{cell[:condition]}" }
|
|
68
|
+
raise Error, "#{result.failures.size} cell(s) failed; re-run `llmx run --all` to retry just those"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
register RunCommand::NAME, RunCommand
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class SanitizeCommand < Command
|
|
6
|
+
NAME = "sanitize"
|
|
7
|
+
SUMMARY = "gate results-raw/ into the committed results/"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = <<~BANNER
|
|
11
|
+
Usage: llmx sanitize [--check]
|
|
12
|
+
|
|
13
|
+
Copies each trial from results-raw/ into results/. Derived measurements
|
|
14
|
+
always cross; raw transcripts cross only for an app declared with
|
|
15
|
+
`publish_transcripts: true`, because a transcript contains whatever
|
|
16
|
+
source the agent read.
|
|
17
|
+
|
|
18
|
+
Anything that looks like a host path or a secret is a hard failure, not
|
|
19
|
+
a redaction: something unexpected in a transcript should stop the commit
|
|
20
|
+
and be looked at, not be quietly rewritten into something that looks safe.
|
|
21
|
+
|
|
22
|
+
BANNER
|
|
23
|
+
parser.on("--check", "report what would be refused, copy nothing") { @check = true }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(_argv)
|
|
27
|
+
sanitizer = Sanitizer.new(experiment: experiment)
|
|
28
|
+
@check ? sanitizer.check : sanitizer.publish
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register SanitizeCommand::NAME, SanitizeCommand
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class ShellCommand < Command
|
|
6
|
+
NAME = "shell"
|
|
7
|
+
SUMMARY = "interactive shell in a trial-shaped container, for debugging"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = "Usage: llmx shell [APP] [options]\n" \
|
|
11
|
+
" APP open the app's image at /workspace/app; " \
|
|
12
|
+
"omit for the base image"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(argv)
|
|
16
|
+
app_key = argv.shift
|
|
17
|
+
image = app_key ? experiment.app(app_key).image : LLMExperiment.base_image
|
|
18
|
+
container.ensure_system!
|
|
19
|
+
raise Error, "image #{image} not found; build it first" unless container.image?(image)
|
|
20
|
+
|
|
21
|
+
exp = app_key ? experiment : nil
|
|
22
|
+
argv_out = container.run_argv(
|
|
23
|
+
image, "bash",
|
|
24
|
+
memory: exp ? exp.memory : LLMExperiment.default_memory,
|
|
25
|
+
cpus: exp ? exp.cpus : LLMExperiment.default_cpus,
|
|
26
|
+
interactive: true,
|
|
27
|
+
workdir: app_key ? "/workspace/app" : nil
|
|
28
|
+
)
|
|
29
|
+
Shell.interactive(*argv_out)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
register ShellCommand::NAME, ShellCommand
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class StatusCommand < Command
|
|
6
|
+
NAME = "status"
|
|
7
|
+
SUMMARY = "grid completion: which cells have results"
|
|
8
|
+
|
|
9
|
+
def add_options(parser)
|
|
10
|
+
parser.banner = <<~BANNER
|
|
11
|
+
Usage: llmx status [options]
|
|
12
|
+
|
|
13
|
+
One row per task, one column per agent x condition. A cell reads `done`
|
|
14
|
+
once it has a run that got as far as writing meta.json.
|
|
15
|
+
|
|
16
|
+
Trials run one at a time, on purpose: two at once share the machine and
|
|
17
|
+
contaminate wall_seconds. Expect the grid to take as long as the sum of
|
|
18
|
+
its cells, and resume it with `llmx run --all` whenever it is interrupted.
|
|
19
|
+
|
|
20
|
+
BANNER
|
|
21
|
+
parser.on("--app KEY", "only this app's tasks") { |v| @app = v }
|
|
22
|
+
parser.on("--task ID", "only this task") { |v| @task = v }
|
|
23
|
+
parser.on("--agent NAME", "only this agent") { |v| @agent = v }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(_argv)
|
|
27
|
+
cells = Grid.new(experiment: experiment)
|
|
28
|
+
.status(app: @app, task: @task, agent: @agent)
|
|
29
|
+
print_table(cells)
|
|
30
|
+
print_totals(cells)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Columns come from experiment.yml, not from the plan: the plan rotates
|
|
36
|
+
# the conditions per task, and a header that rotated with it would be
|
|
37
|
+
# unreadable.
|
|
38
|
+
def print_table(cells)
|
|
39
|
+
agents = experiment.agents.keys & cells.map { |c| c[:agent] }
|
|
40
|
+
columns = agents.product(experiment.conditions)
|
|
41
|
+
tasks = cells.map { |c| c[:task] }.uniq
|
|
42
|
+
done = cells.to_h { |c| [[c[:task], c[:agent], c[:condition]], c[:done]] }
|
|
43
|
+
|
|
44
|
+
headers = columns.map { |agent, condition| "#{agent}/#{condition}" }
|
|
45
|
+
widths = headers.map { |header| [header.length, 4].max }
|
|
46
|
+
first = [tasks.map(&:length).max, 4].max
|
|
47
|
+
|
|
48
|
+
puts row(["task".ljust(first)], headers, widths)
|
|
49
|
+
tasks.each do |task|
|
|
50
|
+
marks = columns.map { |agent, condition| done[[task, agent, condition]] ? "done" : "-" }
|
|
51
|
+
puts row([task.ljust(first)], marks, widths)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def row(lead, values, widths)
|
|
56
|
+
(lead + values.each_with_index.map { |value, i| value.ljust(widths[i]) }).join(" ").rstrip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def print_totals(cells)
|
|
60
|
+
finished = cells.count { |c| c[:done] }
|
|
61
|
+
puts
|
|
62
|
+
puts "#{finished}/#{cells.size} cell(s) have results; #{cells.size - finished} still to run."
|
|
63
|
+
puts "Run them with: llmx run --all"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register StatusCommand::NAME, StatusCommand
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
class CLI
|
|
5
|
+
class VersionCommand < Command
|
|
6
|
+
NAME = "version"
|
|
7
|
+
SUMMARY = "gem version and default toolchain pins"
|
|
8
|
+
|
|
9
|
+
def run(_argv)
|
|
10
|
+
puts "llm-experiment #{VERSION}"
|
|
11
|
+
Pins::DEFAULTS.each do |tool, pin|
|
|
12
|
+
puts format(" %-14s %s", tool, Array(pin).join(" "))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
register VersionCommand::NAME, VersionCommand
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
class CLI
|
|
7
|
+
COMMANDS = {} # rubocop:disable Style/MutableConstant -- command files register themselves at load time
|
|
8
|
+
|
|
9
|
+
def self.register(name, klass)
|
|
10
|
+
COMMANDS[name] = klass
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.run(argv)
|
|
14
|
+
argv = argv.dup
|
|
15
|
+
name = argv.shift
|
|
16
|
+
name = "version" if %w[-v --version].include?(name)
|
|
17
|
+
|
|
18
|
+
if name.nil? || %w[-h --help help].include?(name)
|
|
19
|
+
print_help
|
|
20
|
+
return 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
klass = COMMANDS[name]
|
|
24
|
+
unless klass
|
|
25
|
+
warn "llmx: unknown command #{name.inspect}"
|
|
26
|
+
warn ""
|
|
27
|
+
capture_help_to_stderr
|
|
28
|
+
return 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
klass.new.call(argv)
|
|
32
|
+
0
|
|
33
|
+
rescue Error => e
|
|
34
|
+
warn "llmx: #{e.message}"
|
|
35
|
+
1
|
|
36
|
+
rescue Interrupt
|
|
37
|
+
130
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.print_help(io = $stdout)
|
|
41
|
+
io.puts "llmx — run controlled, reproducible experiments on coding agents"
|
|
42
|
+
io.puts
|
|
43
|
+
io.puts "Usage: llmx COMMAND [options]"
|
|
44
|
+
io.puts
|
|
45
|
+
width = COMMANDS.keys.map(&:length).max
|
|
46
|
+
COMMANDS.sort.each do |name, klass|
|
|
47
|
+
io.puts format(" %-#{width}s %s", name, klass.summary)
|
|
48
|
+
end
|
|
49
|
+
io.puts
|
|
50
|
+
io.puts "Run `llmx COMMAND --help` for details."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.capture_help_to_stderr
|
|
54
|
+
print_help($stderr)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Base class for commands. Subclasses set NAME and SUMMARY, implement
|
|
58
|
+
# #run(argv), and optionally #add_options(parser).
|
|
59
|
+
class Command
|
|
60
|
+
def self.summary = self::SUMMARY
|
|
61
|
+
|
|
62
|
+
def initialize(container: nil)
|
|
63
|
+
@container = container
|
|
64
|
+
@experiment_dir = nil
|
|
65
|
+
@help_shown = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call(argv)
|
|
69
|
+
parser.parse!(argv)
|
|
70
|
+
return if @help_shown
|
|
71
|
+
|
|
72
|
+
run(argv)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run(_argv)
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def container
|
|
82
|
+
@container ||= Container.new
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parser
|
|
86
|
+
@parser ||= OptionParser.new do |o|
|
|
87
|
+
o.banner = "Usage: llmx #{self.class::NAME} [options]"
|
|
88
|
+
add_options(o)
|
|
89
|
+
o.on("--experiment DIR", "experiment directory (default: walk up to experiment.yml)") do |d|
|
|
90
|
+
@experiment_dir = d
|
|
91
|
+
end
|
|
92
|
+
o.on("--verbose", "echo every container command") { LLMExperiment.verbose = true }
|
|
93
|
+
o.on("-h", "--help", "show this help") do
|
|
94
|
+
puts o
|
|
95
|
+
@help_shown = true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def add_options(_parser); end
|
|
101
|
+
|
|
102
|
+
def experiment
|
|
103
|
+
@experiment ||= @experiment_dir ? Experiment.load(@experiment_dir) : Experiment.find
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
require_relative "cli/version_command"
|
|
110
|
+
require_relative "cli/new_command"
|
|
111
|
+
require_relative "cli/doctor_command"
|
|
112
|
+
require_relative "cli/build_command"
|
|
113
|
+
require_relative "cli/login_command"
|
|
114
|
+
require_relative "cli/shell_command"
|
|
115
|
+
require_relative "cli/run_command"
|
|
116
|
+
require_relative "cli/status_command"
|
|
117
|
+
require_relative "cli/parse_command"
|
|
118
|
+
require_relative "cli/metrics_command"
|
|
119
|
+
require_relative "cli/sanitize_command"
|
|
120
|
+
require_relative "cli/clean_command"
|