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.
- checksums.yaml +4 -4
- data/README.md +30 -10
- data/exe/lemans-remote +161 -46
- data/lib/lemans/agent.rb +39 -0
- data/lib/lemans/agents/miniswen.rb +28 -25
- data/lib/lemans/agents/miniswen_installed.rb +13 -9
- data/lib/lemans/agents/nop.rb +3 -3
- data/lib/lemans/agents/oracle.rb +8 -8
- data/lib/lemans/cli/board_reporter.rb +16 -16
- data/lib/lemans/cli/progress_reporter.rb +19 -19
- data/lib/lemans/cli/report/aggregate.rb +117 -0
- data/lib/lemans/cli/report.rb +164 -0
- data/lib/lemans/cli.rb +58 -63
- data/lib/lemans/clobber.rb +17 -55
- data/lib/lemans/config/agent.rb +51 -0
- data/lib/lemans/config/conversion.rb +62 -0
- data/lib/lemans/config/environment.rb +39 -0
- data/lib/lemans/config/image_spec.rb +42 -0
- data/lib/lemans/config/network_policy.rb +55 -0
- data/lib/lemans/config/revision.rb +42 -0
- data/lib/lemans/config/setup.rb +57 -0
- data/lib/lemans/config/tree_digest.rb +26 -0
- data/lib/lemans/config/verifier.rb +72 -0
- data/lib/lemans/config.rb +107 -0
- data/lib/lemans/environment.rb +54 -0
- data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
- data/lib/lemans/environments/daytona.rb +21 -35
- data/lib/lemans/result.rb +270 -0
- data/lib/lemans/runner/executor.rb +64 -0
- data/lib/lemans/runner/task.rb +62 -0
- data/lib/lemans/runner.rb +82 -0
- data/lib/lemans/store.rb +44 -0
- data/lib/lemans/stores/fs.rb +122 -0
- data/lib/lemans/task_definition.rb +194 -0
- data/lib/lemans/trial/patch.rb +76 -0
- data/lib/lemans/trial/setup.rb +66 -0
- data/lib/lemans/trial/snapshot.rb +57 -0
- data/lib/lemans/trial/verifier.rb +190 -0
- data/lib/lemans/trial.rb +113 -147
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +3 -2
- data/lib/miniswen/trajectory.rb +2 -0
- metadata +58 -25
- data/lib/lemans/agents/base.rb +0 -30
- data/lib/lemans/bench.rb +0 -280
- data/lib/lemans/environments/base.rb +0 -55
- data/lib/lemans/network_policy.rb +0 -66
- data/lib/lemans/patch.rb +0 -70
- data/lib/lemans/restore_paths.rb +0 -21
- data/lib/lemans/results/aggregate.rb +0 -114
- data/lib/lemans/results/cost_source.rb +0 -13
- data/lib/lemans/results/outcome.rb +0 -36
- data/lib/lemans/results/report.rb +0 -149
- data/lib/lemans/results/sorting.rb +0 -24
- data/lib/lemans/results/tally.rb +0 -19
- data/lib/lemans/results/usage.rb +0 -24
- data/lib/lemans/run.rb +0 -152
- data/lib/lemans/setup.rb +0 -59
- data/lib/lemans/setup_files.rb +0 -36
- data/lib/lemans/snapshot.rb +0 -55
- data/lib/lemans/task.rb +0 -207
- data/lib/lemans/tree_digest.rb +0 -24
- data/lib/lemans/units.rb +0 -44
- data/lib/lemans/verifier.rb +0 -199
- /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
- /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# A single task definition: an instruction, an environment, tests, a solution —
|
|
8
|
+
# plus the task's projection of the bench config (setup and verifier sections
|
|
9
|
+
# with per-task overrides applied). Anything lemans does not understand
|
|
10
|
+
# belongs under `metadata`, copied untouched.
|
|
11
|
+
class TaskDefinition
|
|
12
|
+
INSTRUCTION = "instruction.md"
|
|
13
|
+
ENVIRONMENT_DIR = "environment"
|
|
14
|
+
TESTS_DIR = "tests"
|
|
15
|
+
SOLUTION_DIR = "solution"
|
|
16
|
+
|
|
17
|
+
FLAT_TEST = "verification_test.rb"
|
|
18
|
+
FLAT_SOLUTION = "solution.patch"
|
|
19
|
+
FLAT_SEED = "environment.patch"
|
|
20
|
+
|
|
21
|
+
FRONTMATTER = /\A---\n(.*?)\n---\n/m
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def load_from_directory(config, dir)
|
|
25
|
+
dir = Pathname(dir)
|
|
26
|
+
data = frontmatter(dir)
|
|
27
|
+
|
|
28
|
+
task = new(config, data["name"] || dir.basename.to_s, dir:)
|
|
29
|
+
task.description = data["description"].to_s if data["description"]
|
|
30
|
+
task.difficulty = data["difficulty"].to_sym if data["difficulty"]
|
|
31
|
+
task.tags = Array(data["tags"]).map(&:to_s) if data["tags"]
|
|
32
|
+
task.metadata = data["metadata"] if data["metadata"]
|
|
33
|
+
|
|
34
|
+
declared_setup = Config::Setup.from_config(data["setup"], root: dir)
|
|
35
|
+
refuse_config_collisions!(task, declared_setup, config.setup)
|
|
36
|
+
task.setup = declared_setup
|
|
37
|
+
|
|
38
|
+
task.verifier.restore_paths = Config::Verifier.restore_paths!(data["restore"]) if data.key?("restore")
|
|
39
|
+
if (declared = Config::Setup.from_config(data.dig("verifier", "setup"), root: dir))
|
|
40
|
+
refuse_config_collisions!(task, declared, config.verifier.setup)
|
|
41
|
+
task.verifier.setup = config.verifier.setup.merge(declared)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
validate!(task, data)
|
|
45
|
+
task
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def frontmatter(dir)
|
|
51
|
+
path = dir.join(INSTRUCTION)
|
|
52
|
+
raise ConfigError, "#{dir}: #{INSTRUCTION} is required" unless path.file?
|
|
53
|
+
|
|
54
|
+
contents = path.read
|
|
55
|
+
match = contents.match(FRONTMATTER)
|
|
56
|
+
|
|
57
|
+
unless match
|
|
58
|
+
raise ConfigError, "#{path}: frontmatter opens with --- but never closes" if contents.start_with?("---")
|
|
59
|
+
|
|
60
|
+
return {}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
data = YAML.safe_load(match[1], aliases: true) || {}
|
|
64
|
+
raise ConfigError, "#{path}: frontmatter must be a mapping" unless data.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
data
|
|
67
|
+
rescue Psych::Exception => e
|
|
68
|
+
raise ConfigError, "#{path}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate!(task, data)
|
|
72
|
+
if data.key?("overrides")
|
|
73
|
+
named = data["overrides"].is_a?(Hash) && data["overrides"].any? ? " (#{data["overrides"].keys.join(", ")})" : ""
|
|
74
|
+
raise ConfigError, "#{task.dir}: a task cannot override the frozen profile#{named} — " \
|
|
75
|
+
"what has to vary belongs in bench.yml, where it varies for every trial"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if (extras = data["verifier"].is_a?(Hash) ? data["verifier"].keys - ["setup"] : nil) && extras.any?
|
|
79
|
+
raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench.yml names no shared image" unless
|
|
83
|
+
task.config.environment.image || task.environment_dockerfile.file?
|
|
84
|
+
|
|
85
|
+
return unless task.test_files.empty?
|
|
86
|
+
|
|
87
|
+
raise ConfigError, "#{task.dir}: #{TESTS_DIR}/ or a flat #{FLAT_TEST} is required — " \
|
|
88
|
+
"the verifier uploads it at verification time"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Collisions would be resolved by upload order, so a task never gets to
|
|
92
|
+
# shadow the bench-wide copy.
|
|
93
|
+
def refuse_config_collisions!(task, declared, base)
|
|
94
|
+
return unless declared
|
|
95
|
+
|
|
96
|
+
shadowed = declared.files.map(&:last) & base.files.map(&:last)
|
|
97
|
+
return if shadowed.empty?
|
|
98
|
+
|
|
99
|
+
raise ConfigError, "#{task.dir}: setup.files names #{shadowed.first}, which the bench " \
|
|
100
|
+
"already ships to every task"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
attr_reader :config, :name, :dir
|
|
105
|
+
|
|
106
|
+
attr_accessor :difficulty, :tags, :description, :metadata
|
|
107
|
+
|
|
108
|
+
def initialize(config, name, dir: nil)
|
|
109
|
+
@config = config
|
|
110
|
+
@name = name
|
|
111
|
+
|
|
112
|
+
@difficulty = :easy
|
|
113
|
+
@tags = []
|
|
114
|
+
@description = ""
|
|
115
|
+
@metadata = {}
|
|
116
|
+
|
|
117
|
+
@dir = dir || config.tasks_dir.join(name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The story alone: frontmatter is for the harness, never for the agent.
|
|
121
|
+
def instruction
|
|
122
|
+
@instruction ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def digest
|
|
126
|
+
@digest ||= Config::TreeDigest.call(dir)[0, 16]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The task's projection of the config sections: bench-wide values with the
|
|
130
|
+
# task's own overrides applied. Phase machinery reads these, never the
|
|
131
|
+
# global config.
|
|
132
|
+
def setup
|
|
133
|
+
@setup ||= config.setup.merge(own_setup_with_seed)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def setup=(declared)
|
|
137
|
+
@declared_setup = declared
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def verifier
|
|
141
|
+
@verifier ||= config.verifier.dup
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def environment = config.environment
|
|
145
|
+
|
|
146
|
+
def seed? = dir.join(FLAT_SEED).file?
|
|
147
|
+
|
|
148
|
+
# [absolute, remote-relative] pairs. Tests stay on the harness side while
|
|
149
|
+
# the agent works; uploaded into the sandbox only at verification.
|
|
150
|
+
def test_files
|
|
151
|
+
tests_dir.directory? ? expand(tests_dir) : flat(FLAT_TEST)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def solution_files
|
|
155
|
+
solution_dir.directory? ? expand(solution_dir) : flat(FLAT_SOLUTION)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def solution? = solution_files.any?
|
|
159
|
+
|
|
160
|
+
def tests_dir = dir.join(TESTS_DIR)
|
|
161
|
+
|
|
162
|
+
def solution_dir = dir.join(SOLUTION_DIR)
|
|
163
|
+
|
|
164
|
+
def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
|
|
165
|
+
|
|
166
|
+
def environment_image
|
|
167
|
+
if environment.image
|
|
168
|
+
Config::ImageSpec.registry(environment.image)
|
|
169
|
+
else
|
|
170
|
+
Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def own_setup_with_seed
|
|
177
|
+
declared = @declared_setup || Config::Setup.new
|
|
178
|
+
return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
|
|
179
|
+
|
|
180
|
+
declared.merge(Config::Setup.new.tap { it.files = [[dir.join(FLAT_SEED), FLAT_SEED]] })
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Dotfiles included, matching TreeDigest: the files a digest records are
|
|
184
|
+
# exactly the files that ship.
|
|
185
|
+
def expand(root)
|
|
186
|
+
root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [it, it.relative_path_from(root).to_s] }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def flat(filename)
|
|
190
|
+
path = dir.join(filename)
|
|
191
|
+
path.file? ? [[path, filename]] : []
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
class Trial
|
|
9
|
+
# The agent's work as one git patch, diffed against a baseline sealed before
|
|
10
|
+
# its first turn
|
|
11
|
+
class Patch
|
|
12
|
+
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
13
|
+
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
14
|
+
|
|
15
|
+
private attr_reader :task, :environment, :path, :workdir, :baseline, :timeout
|
|
16
|
+
|
|
17
|
+
def initialize(task, environment, timeout: 300, path: "agent.patch")
|
|
18
|
+
@task = task
|
|
19
|
+
@environment = environment
|
|
20
|
+
@path = path
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
|
|
23
|
+
@workdir = task.environment.workdir
|
|
24
|
+
@baseline = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def seal!
|
|
28
|
+
@baseline = write_tree
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
32
|
+
# after would not show what the agent did to them
|
|
33
|
+
def collect!(result, store)
|
|
34
|
+
return unless baseline
|
|
35
|
+
|
|
36
|
+
after = write_tree
|
|
37
|
+
return unless after
|
|
38
|
+
|
|
39
|
+
diffed = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout:)
|
|
40
|
+
return unless diffed.success?
|
|
41
|
+
|
|
42
|
+
Tempfile.create(%w[agent .patch]) do |file|
|
|
43
|
+
environment.download(REMOTE_PATCH, file.path)
|
|
44
|
+
store.save_artifact(result, Pathname(file.path), path:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout:)
|
|
48
|
+
path
|
|
49
|
+
rescue InfrastructureError => e
|
|
50
|
+
warn "lemans: could not collect the agent patch for #{result.id}: #{e.message}"
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# `safe.directory` because the sandbox may run the tree as a different user
|
|
57
|
+
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
58
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
59
|
+
|
|
60
|
+
# Untracked files only reach a diff through an index, so both sides are
|
|
61
|
+
# staged into a scratch one and hashed. Rebuilt from empty each time, so a
|
|
62
|
+
# stale entry cannot survive into the second tree.
|
|
63
|
+
def write_tree
|
|
64
|
+
result = environment.exec(
|
|
65
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
66
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
|
|
67
|
+
timeout:
|
|
68
|
+
)
|
|
69
|
+
return nil unless result.success?
|
|
70
|
+
|
|
71
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
72
|
+
tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
class Trial
|
|
7
|
+
# Makes one shared image into this task's sandbox at start-up: uploads the
|
|
8
|
+
# declared files and runs the setup commands
|
|
9
|
+
class Setup
|
|
10
|
+
# Uploads land under one harness-owned directory, wiped once the steps have
|
|
11
|
+
# run: an agent whose first `ls /` finds the seed patch is reading the harness.
|
|
12
|
+
ROOT = "/lemans"
|
|
13
|
+
DIR = "#{ROOT}/setup".freeze
|
|
14
|
+
|
|
15
|
+
private attr_reader :task, :files, :commands, :needs_seed, :timeout
|
|
16
|
+
|
|
17
|
+
def initialize(task, files: [], commands: [], seed: false, exec_timeout: nil)
|
|
18
|
+
@task = task
|
|
19
|
+
@files = files
|
|
20
|
+
@commands = commands
|
|
21
|
+
@needs_seed = seed
|
|
22
|
+
@timeout = exec_timeout || task.environment.build_timeout
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute!(environment)
|
|
26
|
+
return if commands.empty? && files.empty?
|
|
27
|
+
|
|
28
|
+
upload_files!(environment)
|
|
29
|
+
apply_seed!(environment)
|
|
30
|
+
run_commands!(environment)
|
|
31
|
+
|
|
32
|
+
# cleanup: no trace of the setup files is left for the agent to read
|
|
33
|
+
environment.exec!("rm -rf #{Shellwords.escape(ROOT)}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def upload_files!(environment)
|
|
39
|
+
files.each do |local, remote|
|
|
40
|
+
environment.upload(local, File.join(DIR, remote))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Folds a task's flat environment.patch into the workdir, then reseals the
|
|
45
|
+
# tree as a one-commit repo so `git log` does not point at the defect.
|
|
46
|
+
def apply_seed!(environment)
|
|
47
|
+
return unless needs_seed
|
|
48
|
+
|
|
49
|
+
# NOTE: the seed rides the files list, appended there by TaskDefinition
|
|
50
|
+
seed = File.join(DIR, TaskDefinition::FLAT_SEED)
|
|
51
|
+
|
|
52
|
+
workdir = Shellwords.escape(task.environment.workdir)
|
|
53
|
+
environment.exec!(
|
|
54
|
+
"cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
|
|
55
|
+
"rm -rf .git && git init -q && git add -A && " \
|
|
56
|
+
"git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
|
|
57
|
+
timeout:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_commands!(environment)
|
|
62
|
+
commands.each { environment.exec!(it, timeout:) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
class Trial
|
|
7
|
+
# The sealed baseline of a task's graded surfaces: a git tree written before
|
|
8
|
+
# the agent's first turn, checked out over the live tree at verification.
|
|
9
|
+
class Snapshot
|
|
10
|
+
REMOTE_INDEX = "/tmp/lemans-baseline.idx"
|
|
11
|
+
|
|
12
|
+
private attr_reader :task, :environment, :workdir, :paths, :timeout, :baseline
|
|
13
|
+
|
|
14
|
+
def initialize(task, environment, timeout: 300)
|
|
15
|
+
@task = task
|
|
16
|
+
@environment = environment
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
|
|
19
|
+
@workdir = task.environment.workdir
|
|
20
|
+
@paths = task.verifier.restore_paths
|
|
21
|
+
@baseline = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sealing failure is an environment error: the agent has not run yet.
|
|
25
|
+
def capture!
|
|
26
|
+
return if paths.empty?
|
|
27
|
+
|
|
28
|
+
result = environment.exec(
|
|
29
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
30
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
|
|
31
|
+
timeout:
|
|
32
|
+
)
|
|
33
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
34
|
+
raise InfrastructureError, "could not seal the graded surfaces: #{result.output.to_s[0, 500]}" unless result.success? && /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
35
|
+
|
|
36
|
+
@baseline = tree
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def restore! # rubocop:disable Naming/PredicateMethod
|
|
40
|
+
return true if paths.empty?
|
|
41
|
+
raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
|
|
42
|
+
|
|
43
|
+
escaped_paths = Shellwords.join(paths)
|
|
44
|
+
|
|
45
|
+
environment.exec(
|
|
46
|
+
"cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
|
|
47
|
+
"rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
|
|
48
|
+
timeout:
|
|
49
|
+
).success?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
class Trial
|
|
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
|
+
Verification = Data.define(:reward, :logs)
|
|
13
|
+
|
|
14
|
+
REWARD_RANGE = (0.0..1.0)
|
|
15
|
+
|
|
16
|
+
# Where the task's tests land at verification time
|
|
17
|
+
TESTS_DIR = "/tests"
|
|
18
|
+
|
|
19
|
+
# Harness-owned files used in verification tests
|
|
20
|
+
ASSETS = Pathname(File.expand_path("verifier/assets", __dir__))
|
|
21
|
+
|
|
22
|
+
VERIFY_BIN = "verify"
|
|
23
|
+
|
|
24
|
+
# The message a person finds where the suite output would have been.
|
|
25
|
+
TAMPERED = "The graded surfaces could not be restored from the sealed baseline: the sandbox no " \
|
|
26
|
+
"longer holds the tree sealed before the agent's first turn. Removing or rewriting " \
|
|
27
|
+
"it is a failed check, so this run scores 0.\n"
|
|
28
|
+
|
|
29
|
+
private attr_reader :task, :environment, :snapshot, :timeout
|
|
30
|
+
|
|
31
|
+
def initialize(task, environment, snapshot)
|
|
32
|
+
@task = task
|
|
33
|
+
@environment = environment
|
|
34
|
+
@snapshot = snapshot
|
|
35
|
+
@timeout = task.verifier.timeout
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify!(&evidence_collector)
|
|
39
|
+
upload_tests!
|
|
40
|
+
prepare_env!
|
|
41
|
+
|
|
42
|
+
# A baseline the agent made unrestorable is a verdict, not an error.
|
|
43
|
+
return Verification.new(reward: 0.0, logs: TAMPERED) unless snapshot.restore!
|
|
44
|
+
|
|
45
|
+
verification = run_tests!
|
|
46
|
+
|
|
47
|
+
download_evidence(evidence_collector)
|
|
48
|
+
|
|
49
|
+
verification
|
|
50
|
+
rescue InfrastructureError => e
|
|
51
|
+
salvage_evidence(evidence_collector)
|
|
52
|
+
# Everything under verification is the verifier's failure, never the
|
|
53
|
+
# model's
|
|
54
|
+
raise if e.is_a?(VerifierError)
|
|
55
|
+
|
|
56
|
+
raise VerifierError, e.message
|
|
57
|
+
rescue StandardError
|
|
58
|
+
salvage_evidence(evidence_collector)
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def upload_tests!
|
|
65
|
+
environment.exec!("rm -rf #{TESTS_DIR} && mkdir -p #{TESTS_DIR}")
|
|
66
|
+
|
|
67
|
+
uploads = task.verifier.files.to_h { |local, remote| [remote, local] }
|
|
68
|
+
.merge(task.test_files.to_h { |local, remote| [remote, local] })
|
|
69
|
+
|
|
70
|
+
uploads.each { |remote, local| environment.upload(local, "#{TESTS_DIR}/#{remote}") }
|
|
71
|
+
ASSETS.glob("*.rb").each { |asset| environment.upload(asset, "#{TESTS_DIR}/#{asset.basename}") }
|
|
72
|
+
# An upload promises no mode bit, so the executable gets its own.
|
|
73
|
+
environment.exec!("chmod +x #{TESTS_DIR}/#{VERIFY_BIN}") if uploads.key?(VERIFY_BIN)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def prepare_env!
|
|
77
|
+
Setup.new(
|
|
78
|
+
task,
|
|
79
|
+
files: task.verifier.setup.files,
|
|
80
|
+
commands: task.verifier.setup.commands,
|
|
81
|
+
exec_timeout: timeout
|
|
82
|
+
).execute!(environment)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run_tests!
|
|
86
|
+
# Ensure $LOGS exists
|
|
87
|
+
environment.exec!("mkdir -p #{Shellwords.escape(task.verifier.logs_dir)}")
|
|
88
|
+
# Ensure the agent hasn't pre-written reward.txt or checks.json
|
|
89
|
+
environment.exec!("rm -f #{Shellwords.escape(task.verifier.reward_path)} " \
|
|
90
|
+
"#{Shellwords.escape(File.join(task.verifier.logs_dir, "checks.json"))}")
|
|
91
|
+
|
|
92
|
+
env = { "WORKDIR" => task.environment.workdir,
|
|
93
|
+
"TESTS" => TESTS_DIR,
|
|
94
|
+
"LOGS" => task.verifier.logs_dir }
|
|
95
|
+
command = "cd #{Shellwords.escape(task.environment.workdir)} && " \
|
|
96
|
+
"export RUBYOPT=\"${RUBYOPT:+$RUBYOPT }-I#{TESTS_DIR}\" && " \
|
|
97
|
+
"#{verifier_script}"
|
|
98
|
+
|
|
99
|
+
result = environment.exec(command, timeout:, env:)
|
|
100
|
+
|
|
101
|
+
Verification.new(reward: read_reward(result), logs: result.output.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def verifier_script
|
|
105
|
+
[task.verifier.preverify, task.verifier.command].compact.map { "( #{it} )" }.join(" && ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_reward(command_result)
|
|
109
|
+
reward_path = task.verifier.reward_path
|
|
110
|
+
present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
|
|
111
|
+
return reward_from_exit(command_result) unless present.success?
|
|
112
|
+
|
|
113
|
+
result = environment.exec("cat #{Shellwords.escape(reward_path)}")
|
|
114
|
+
raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
|
|
115
|
+
|
|
116
|
+
value = begin
|
|
117
|
+
Float(result.output.to_s.strip)
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
raise VerifierError, "verifier wrote #{result.output.to_s.strip.inspect}, which is not a reward"
|
|
120
|
+
end
|
|
121
|
+
raise VerifierError, "verifier wrote a non-finite reward" unless value.finite?
|
|
122
|
+
raise VerifierError, "reward #{value} is outside #{REWARD_RANGE}" unless REWARD_RANGE.cover?(value)
|
|
123
|
+
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def reward_from_exit(command_result)
|
|
128
|
+
case command_result.exit_code
|
|
129
|
+
when 0 then 1.0
|
|
130
|
+
when 1 then 0.0
|
|
131
|
+
else
|
|
132
|
+
raise VerifierError,
|
|
133
|
+
"verifier exited #{command_result.exit_code} and wrote no reward to #{task.verifier.reward_path}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def download_evidence(collector)
|
|
138
|
+
return unless collector
|
|
139
|
+
|
|
140
|
+
@evidence_attempted = true
|
|
141
|
+
root = task.verifier.logs_dir
|
|
142
|
+
paths = list_files(root)
|
|
143
|
+
return if paths.empty?
|
|
144
|
+
|
|
145
|
+
relative_to = Pathname(root).cleanpath
|
|
146
|
+
|
|
147
|
+
# Use a temp dir to download evidence to check for collisions
|
|
148
|
+
Dir.mktmpdir("lemans-evidence") do |staging|
|
|
149
|
+
paths.each do |remote|
|
|
150
|
+
relative = checked_remote_path(remote, root).relative_path_from(relative_to)
|
|
151
|
+
staged = Pathname(staging).join(relative)
|
|
152
|
+
staged.dirname.mkpath
|
|
153
|
+
environment.download(remote, staged)
|
|
154
|
+
|
|
155
|
+
collector.call(staged, relative.to_s)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def list_files(declared)
|
|
161
|
+
return [] unless environment.exec("test -d #{Shellwords.escape(declared)}").success?
|
|
162
|
+
|
|
163
|
+
listing = environment.exec("find #{Shellwords.escape(declared)} -type f -print0")
|
|
164
|
+
raise VerifierError, "could not list #{declared}: #{listing.output.to_s[0, 500]}" unless listing.success?
|
|
165
|
+
|
|
166
|
+
listing.output.to_s.split("\0").reject(&:empty?)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def checked_remote_path(remote, declared)
|
|
170
|
+
root = Pathname(declared).cleanpath
|
|
171
|
+
candidate = Pathname(remote).cleanpath
|
|
172
|
+
|
|
173
|
+
raise VerifierError, "evidence file #{remote.inspect} escapes #{declared}" unless candidate.absolute? && candidate.to_s.start_with?("#{root}/")
|
|
174
|
+
|
|
175
|
+
raise VerifierError, "evidence file #{remote.inspect} contains control characters" if remote.match?(/[[:cntrl:]]/)
|
|
176
|
+
|
|
177
|
+
candidate
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def salvage_evidence(collector)
|
|
181
|
+
return unless collector
|
|
182
|
+
return if @evidence_attempted
|
|
183
|
+
|
|
184
|
+
download_evidence(collector)
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
warn "lemans: could not save the verifier's evidence: #{e.class}: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|