lemans 0.2.2 → 1.0.0.pre.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
data/lib/lemans/task.rb DELETED
@@ -1,207 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "yaml"
5
- require "pathname"
6
-
7
- module Lemans
8
- # One task: an instruction, an environment, a verifier, a solution.
9
- # Anything lemans does not understand belongs under `metadata`, copied untouched.
10
- class Task
11
- INSTRUCTION = "instruction.md"
12
- ENVIRONMENT_DIR = "environment"
13
- TESTS_DIR = "tests"
14
- SOLUTION_DIR = "solution"
15
-
16
- FLAT_TEST = "verification_test.rb"
17
- FLAT_SOLUTION = "solution.patch"
18
- FLAT_SEED = "environment.patch"
19
-
20
- FRONTMATTER = /\A---\n(.*?)\n---\n/m
21
-
22
- # An image, either already published or built from a task's Dockerfile.
23
- # Built images are named by content digest, so reuse is only ever of the identical thing.
24
- class ImageSpec
25
- attr_reader :reference, :dockerfile_path, :slug, :digest
26
-
27
- def self.registry(reference) = new(reference: reference)
28
-
29
- def self.dockerfile(path, slug:)
30
- path = Pathname(path)
31
- raise ConfigError, "no Dockerfile at #{path}" unless path.file?
32
-
33
- new(dockerfile_path: path, slug: slug)
34
- end
35
-
36
- def initialize(reference: nil, dockerfile_path: nil, slug: nil)
37
- @reference = reference
38
- @dockerfile_path = dockerfile_path
39
- @slug = slug
40
- # Hashing the reference gives backends one answer to "is this the same image" either way.
41
- @digest = built? ? TreeDigest.call(context_dir) : Digest::SHA256.hexdigest(reference.to_s)
42
- freeze
43
- end
44
-
45
- def built? = !dockerfile_path.nil?
46
-
47
- def context_dir = dockerfile_path&.dirname
48
-
49
- def name
50
- built? ? "lemans-#{digest[0, 32]}" : reference
51
- end
52
-
53
- def to_s = name
54
- end
55
-
56
- attr_reader :dir, :name, :description, :difficulty, :tags, :metadata, :bench, :digest
57
-
58
- def self.load(dir, bench:)
59
- dir = Pathname(dir)
60
- new(frontmatter(dir), dir: dir, bench: bench)
61
- end
62
-
63
- def self.frontmatter(dir)
64
- content = dir.join(INSTRUCTION).read
65
- match = content.match(FRONTMATTER)
66
- unless match
67
- # Opens like frontmatter but never matches: silently dropping every
68
- # declared key (and leaking the raw block to the agent) is worse than
69
- # refusing. CRLF endings and a missing final newline are the usual causes.
70
- raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter opens with --- but never closes" if
71
- content.start_with?("---")
72
-
73
- return {}
74
- end
75
- config = YAML.safe_load(match[1], aliases: true) || {}
76
- raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter must be a mapping" unless config.is_a?(Hash)
77
-
78
- config
79
- rescue Errno::ENOENT
80
- {} # fallback to validate!
81
- rescue Psych::Exception => e
82
- raise ConfigError, "#{dir.join(INSTRUCTION)}: #{e.message}"
83
- end
84
-
85
- def initialize(config, dir:, bench:)
86
- @dir = Pathname(dir)
87
- @bench = bench
88
- @name = config["name"] || @dir.basename.to_s
89
- @description = config["description"]
90
- @difficulty = config["difficulty"]
91
- @tags = Array(config["tags"]).freeze
92
- @metadata = (config["metadata"] || {}).freeze
93
- @files = SetupFiles.call(config["files"], root: @dir, label: @dir)
94
- @restore = config.key?("restore") ? RestorePaths.call(config["restore"], label: "#{@dir}: restore") : nil
95
-
96
- validate!(config)
97
- # Recorded on every result: without it a reward cannot say which task version it measured.
98
- @digest = TreeDigest.call(@dir)[0, 16]
99
- freeze
100
- end
101
-
102
- # The story alone: frontmatter is for the harness, never for the agent.
103
- def instruction = instruction_path.read.sub(FRONTMATTER, "")
104
-
105
- def instruction_path = dir.join(INSTRUCTION)
106
-
107
- def environment_context = dir.join(ENVIRONMENT_DIR)
108
-
109
- def environment_dockerfile = environment_context.join("Dockerfile")
110
-
111
- # Stays on the harness side while the agent works; uploaded into the sandbox only at verification.
112
- def tests_dir = dir.join(TESTS_DIR)
113
-
114
- # [absolute, remote-relative] pairs.
115
- def test_files
116
- if tests_dir.directory?
117
- expand(tests_dir)
118
- else
119
- flat(FLAT_TEST)
120
- end
121
- end
122
-
123
- def solution_files
124
- if solution_context.directory?
125
- expand(solution_context)
126
- else
127
- flat(FLAT_SOLUTION)
128
- end
129
- end
130
-
131
- def environment_image
132
- if bench.environment.image
133
- ImageSpec.registry(bench.environment.image)
134
- else
135
- ImageSpec.dockerfile(environment_dockerfile, slug: name)
136
- end
137
- end
138
-
139
- def setup_files(phase)
140
- declared = @files.fetch(phase.to_sym, [])
141
- seed = Pathname(FLAT_SEED)
142
- return declared unless phase.to_sym == :environment && dir.join(seed).file? && !declared.include?(seed)
143
-
144
- declared + [seed]
145
- end
146
-
147
- def solution_context = dir.join(SOLUTION_DIR)
148
-
149
- def solution? = solution_files.any?
150
-
151
- def restore_paths = @restore || bench.verifier.restore_paths
152
-
153
- def to_h
154
- {
155
- name: name,
156
- description: description,
157
- difficulty: difficulty,
158
- tags: tags,
159
- metadata: metadata
160
- }.compact
161
- end
162
-
163
- private
164
-
165
- # Dotfiles included, matching TreeDigest: the files a digest records are
166
- # exactly the files that ship.
167
- def expand(root)
168
- root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [_1, _1.relative_path_from(root).to_s] }
169
- end
170
-
171
- def flat(filename)
172
- path = dir.join(filename)
173
- path.file? ? [[path, filename]] : []
174
- end
175
-
176
- def validate!(config)
177
- raise ConfigError, "#{dir}: #{INSTRUCTION} is required" unless instruction_path.file?
178
-
179
- refuse_bench_collisions!
180
-
181
- raise ConfigError, "#{dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench.yml names no shared image" unless bench.environment.image || environment_dockerfile.file?
182
-
183
- if test_files.empty?
184
- raise ConfigError, "#{dir}: #{TESTS_DIR}/ or a flat #{FLAT_TEST} is required — " \
185
- "the verifier uploads it at verification time"
186
- end
187
-
188
- return unless config.key?("overrides")
189
-
190
- declared = config["overrides"]
191
- named = declared.is_a?(Hash) && declared.any? ? " (#{declared.keys.join(", ")})" : ""
192
- raise ConfigError, "#{dir}: a task cannot override the frozen profile#{named} — " \
193
- "what has to vary belongs in bench.yml, where it varies for every trial"
194
- end
195
-
196
- # Collisions would be resolved by upload order, so a task never gets to shadow the bench-wide copy.
197
- def refuse_bench_collisions!
198
- SetupFiles::PHASES.each do |phase|
199
- shadowed = @files.fetch(phase) & bench.setup_files(phase)
200
- next if shadowed.empty?
201
-
202
- raise ConfigError, "#{dir}: files.#{phase} names #{shadowed.first}, which #{bench.path.basename} " \
203
- "already ships to every task"
204
- end
205
- end
206
- end
207
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module Lemans
6
- # A directory's contents reduced to one number. Paths hash alongside
7
- # contents, and dotfiles are included — glob skips them by default.
8
- module TreeDigest
9
- def self.call(dir)
10
- dir = Pathname(dir)
11
- sha = Digest::SHA256.new
12
- dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |entry|
13
- next unless entry.file?
14
-
15
- path = entry.relative_path_from(dir).to_s
16
- contents = entry.binread
17
- sha << [path.bytesize, contents.bytesize].pack("Q>Q>")
18
- sha << path
19
- sha << contents
20
- end
21
- sha.hexdigest
22
- end
23
- end
24
- end
data/lib/lemans/units.rb DELETED
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Lemans
4
- # Durations and sizes as a human writes them ("30m", "2GB"), stored as
5
- # seconds and megabytes. A bare number gets the obvious reading.
6
- module Units
7
- DURATION = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\z/
8
- DURATION_FACTORS = { "ms" => 0.001, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
9
-
10
- SIZE = /\A(\d+(?:\.\d+)?)\s*(MB|GB|TB)?\z/i
11
- SIZE_FACTORS = { "mb" => 1, "gb" => 1024, "tb" => 1024 * 1024 }.freeze
12
-
13
- class << self
14
- def seconds(value, field:)
15
- return nil if value.nil?
16
- return finite_nonnegative(value, field, "a duration") if value.is_a?(Numeric)
17
-
18
- match = DURATION.match(value.to_s.strip)
19
- raise ConfigError, "#{field}: cannot read #{value.inspect} as a duration (try 30m, 300s, 1h)" unless match
20
-
21
- match[1].to_f * DURATION_FACTORS.fetch(match[2] || "s")
22
- end
23
-
24
- def megabytes(value, field:)
25
- return nil if value.nil?
26
- return finite_nonnegative(value, field, "a size").round if value.is_a?(Numeric)
27
-
28
- match = SIZE.match(value.to_s.strip)
29
- raise ConfigError, "#{field}: cannot read #{value.inspect} as a size (try 2GB, 512MB)" unless match
30
-
31
- (match[1].to_f * SIZE_FACTORS.fetch((match[2] || "mb").downcase)).round
32
- end
33
-
34
- private
35
-
36
- def finite_nonnegative(value, field, noun)
37
- number = value.to_f
38
- raise ConfigError, "#{field}: cannot read #{value.inspect} as #{noun}" if number.negative? || !number.finite?
39
-
40
- number
41
- end
42
- end
43
- end
44
- end
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "pathname"
5
- require "shellwords"
6
- require "tmpdir"
7
-
8
- module Lemans
9
- # Verifies a trial in the sandbox the agent worked in, after Trial has closed
10
- # its network. The tests are uploaded fresh at verification time, never before.
11
- class Verifier
12
- REWARD_RANGE = (0.0..1.0)
13
-
14
- # Where the task's tests land at verification time
15
- TESTS_DIR = "/tests"
16
-
17
- # Harness-owned files used in verification tests
18
- ASSETS = Pathname(File.expand_path("verifier/assets", __dir__))
19
-
20
- VERIFY_BIN = "verify"
21
-
22
- # The message a person finds where the suite output would have been.
23
- TAMPERED = "The graded surfaces could not be restored from the sealed baseline: the sandbox no " \
24
- "longer holds the tree sealed before the agent's first turn. Removing or rewriting " \
25
- "it is a failed check, so this run scores 0.\n"
26
-
27
- def initialize(bench:, task:, dir:, snapshot: nil)
28
- @bench = bench
29
- @task = task
30
- @dir = dir
31
- @snapshot = snapshot
32
- end
33
-
34
- def call(environment)
35
- upload_tests(environment)
36
- prepare(environment)
37
-
38
- # A baseline the agent made unrestorable is a verdict, not an error.
39
- unless restore_baseline(environment)
40
- dir.mkpath
41
- dir.join("verifier.log").write(TAMPERED)
42
- return 0.0
43
- end
44
-
45
- reward = verify(environment)
46
- download_evidence(environment)
47
- reward
48
- rescue InfrastructureError => e
49
- salvage_evidence(environment)
50
- # Everything under verification is the verifier's failure, never the
51
- # model's
52
- raise if e.is_a?(VerifierError)
53
-
54
- raise VerifierError, e.message
55
- rescue StandardError
56
- salvage_evidence(environment)
57
- raise
58
- end
59
-
60
- private
61
-
62
- attr_reader :bench, :task, :dir
63
-
64
- def restore_baseline(environment)
65
- snapshot = @snapshot || Snapshot.new(environment, bench: bench, task: task,
66
- timeout: bench.verifier.timeout_sec)
67
- snapshot.restore!
68
- end
69
-
70
- def upload_tests(environment)
71
- environment.exec!("rm -rf #{TESTS_DIR} && mkdir -p #{TESTS_DIR}")
72
-
73
- uploads = bench.verification_files.to_h { |local, remote| [remote, local] }
74
- .merge(task.test_files.to_h { |local, remote| [remote, local] })
75
-
76
- uploads.each { |remote, local| environment.upload(local, "#{TESTS_DIR}/#{remote}") }
77
- ASSETS.glob("*.rb").each { |asset| environment.upload(asset, "#{TESTS_DIR}/#{asset.basename}") }
78
- environment.exec!("chmod +x #{TESTS_DIR}/#{VERIFY_BIN}") if uploads.key?(VERIFY_BIN)
79
- end
80
-
81
- def prepare(environment)
82
- Setup.new(
83
- commands: bench.verifier.setup,
84
- task: task,
85
- phase: :verifier,
86
- timeout_sec: bench.verifier.timeout_sec
87
- ).call(environment)
88
- end
89
-
90
- def verify(environment)
91
- # Ensure $LOGS exists
92
- environment.exec!("mkdir -p #{Shellwords.escape(bench.verifier.logs_dir)}")
93
- # Ensure the agent hasn't pre-written reward.txt or checks.json
94
- environment.exec!("rm -f #{Shellwords.escape(bench.verifier.reward_path)} " \
95
- "#{Shellwords.escape(File.join(bench.verifier.logs_dir, "checks.json"))}")
96
-
97
- env = { "WORKDIR" => bench.environment.workdir,
98
- "TESTS" => TESTS_DIR,
99
- "LOGS" => bench.verifier.logs_dir }
100
- command = "cd #{Shellwords.escape(bench.environment.workdir)} && " \
101
- "export RUBYOPT=\"${RUBYOPT:+$RUBYOPT }-I#{TESTS_DIR}\" && " \
102
- "#{verifier_script}"
103
- result = environment.exec(command, timeout: bench.verifier.timeout_sec, env: env)
104
- dir.mkpath
105
- dir.join("verifier.log").write(result.output.to_s)
106
-
107
- read_reward(environment, result)
108
- end
109
-
110
- def verifier_script
111
- [bench.verifier.preverify, bench.verifier.command].compact.map { "( #{_1} )" }.join(" && ")
112
- end
113
-
114
- def read_reward(environment, command_result)
115
- reward_path = bench.verifier.reward_path
116
- present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
117
- return reward_from_exit(command_result) unless present.success?
118
-
119
- result = environment.exec("cat #{Shellwords.escape(reward_path)}")
120
- raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
121
-
122
- value = begin
123
- Float(result.output.to_s.strip)
124
- rescue ArgumentError
125
- raise VerifierError, "verifier wrote #{result.output.to_s.strip.inspect}, which is not a reward"
126
- end
127
- raise VerifierError, "verifier wrote a non-finite reward" unless value.finite?
128
- raise VerifierError, "reward #{value} is outside #{REWARD_RANGE}" unless REWARD_RANGE.cover?(value)
129
-
130
- value
131
- end
132
-
133
- def reward_from_exit(command_result)
134
- case command_result.exit_code
135
- when 0 then 1.0
136
- when 1 then 0.0
137
- else
138
- raise VerifierError,
139
- "verifier exited #{command_result.exit_code} and wrote no reward to #{bench.verifier.reward_path}"
140
- end
141
- end
142
-
143
- def download_evidence(environment)
144
- @evidence_attempted = true
145
- root = bench.verifier.logs_dir
146
- paths = list_files(environment, root)
147
- return if paths.empty?
148
-
149
- relative_to = Pathname(root).cleanpath
150
-
151
- # Use a temp dir to download evidence to check for collisions
152
- Dir.mktmpdir("lemans-evidence") do |staging|
153
- paths.each do |remote|
154
- relative = checked_remote_path(remote, root).relative_path_from(relative_to)
155
- staged = Pathname(staging).join(relative)
156
- staged.dirname.mkpath
157
- environment.download(remote, staged)
158
-
159
- destination = dir.join(relative)
160
- if destination.exist?
161
- warn "lemans: evidence file #{relative} collides with a harness file and was dropped"
162
- next
163
- end
164
-
165
- destination.dirname.mkpath
166
- FileUtils.cp(staged, destination)
167
- end
168
- end
169
- end
170
-
171
- def list_files(environment, declared)
172
- return [] unless environment.exec("test -d #{Shellwords.escape(declared)}").success?
173
-
174
- listing = environment.exec("find #{Shellwords.escape(declared)} -type f -print0")
175
- raise VerifierError, "could not list #{declared}: #{listing.output.to_s[0, 500]}" unless listing.success?
176
-
177
- listing.output.to_s.split("\0").reject(&:empty?)
178
- end
179
-
180
- def checked_remote_path(remote, declared)
181
- root = Pathname(declared).cleanpath
182
- candidate = Pathname(remote).cleanpath
183
-
184
- raise VerifierError, "evidence file #{remote.inspect} escapes #{declared}" unless candidate.absolute? && candidate.to_s.start_with?("#{root}/")
185
-
186
- raise VerifierError, "evidence file #{remote.inspect} contains control characters" if remote.match?(/[[:cntrl:]]/)
187
-
188
- candidate
189
- end
190
-
191
- def salvage_evidence(environment)
192
- return if @evidence_attempted
193
-
194
- download_evidence(environment)
195
- rescue StandardError => e
196
- warn "lemans: could not save the verifier's evidence: #{e.class}: #{e.message}"
197
- end
198
- end
199
- end