lemans 0.0.0.pre → 0.2.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +669 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +160 -7
data/lib/lemans/trial.rb
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
# One task, one agent, one reward. Only what happens inside the agent phase
|
|
9
|
+
# is a statement about the model; everything else is the harness's fault.
|
|
10
|
+
class Trial
|
|
11
|
+
attr_reader :task, :bench, :agent_name, :model, :backend, :dir, :id
|
|
12
|
+
|
|
13
|
+
def initialize(task:, bench:, agent_name:, runs_dir:, model: nil, backend: "daytona")
|
|
14
|
+
@task = task
|
|
15
|
+
@bench = bench
|
|
16
|
+
@agent_name = agent_name
|
|
17
|
+
@model = model
|
|
18
|
+
@backend = backend
|
|
19
|
+
@id = "#{task.name}__#{SecureRandom.alphanumeric(7)}"
|
|
20
|
+
@dir = Pathname(runs_dir).join(model_dir, @id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
logs_dir.mkpath
|
|
25
|
+
started_at = Time.now.utc
|
|
26
|
+
reward = nil
|
|
27
|
+
usage = nil
|
|
28
|
+
outcome = Results::Outcome.new(:completed)
|
|
29
|
+
@phases = {}
|
|
30
|
+
# Declared outside the phase blocks they are assigned in, or `ensure`
|
|
31
|
+
# could not stop a sandbox whose setup raised.
|
|
32
|
+
environment = nil
|
|
33
|
+
snapshot = nil
|
|
34
|
+
patch = nil
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
agent = Agents.build(agent_name, profile: bench.agent, model: model)
|
|
38
|
+
|
|
39
|
+
phase(:environment_setup) do
|
|
40
|
+
environment = start_environment
|
|
41
|
+
prepare(environment)
|
|
42
|
+
|
|
43
|
+
snapshot = Snapshot.new(environment, bench: bench, task: task,
|
|
44
|
+
timeout: bench.environment.build_timeout_sec)
|
|
45
|
+
snapshot.capture!
|
|
46
|
+
|
|
47
|
+
patch = Patch.new(environment, bench: bench, dir: dir)
|
|
48
|
+
patch.seal!
|
|
49
|
+
|
|
50
|
+
agent.install(environment, task: task)
|
|
51
|
+
environment.network_policy = bench.agent.network
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
agent_result = phase(:agent) do
|
|
55
|
+
in_agent_phase { agent.call(environment, task: task, logs_dir: logs_dir) }
|
|
56
|
+
end
|
|
57
|
+
usage = agent_result.usage
|
|
58
|
+
outcome = over_ceiling(agent_result) || agent_result.outcome
|
|
59
|
+
|
|
60
|
+
patch.collect!
|
|
61
|
+
|
|
62
|
+
if outcome.scored?
|
|
63
|
+
phase(:verifier) do
|
|
64
|
+
# The sandbox is sealed before the tests arrive
|
|
65
|
+
environment.network_policy = NetworkPolicy.none
|
|
66
|
+
reward = Verifier.new(bench: bench, task: task, dir: dir, snapshot: snapshot).call(environment)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rescue VerifierError => e
|
|
70
|
+
outcome = Results::Outcome.new(:verifier_error, detail: e.message)
|
|
71
|
+
rescue ::Miniswen::AccountingError => e
|
|
72
|
+
outcome = Results::Outcome.new(:accounting_error, detail: e.message)
|
|
73
|
+
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
74
|
+
outcome = Results::Outcome.new(@agent_phase ? :agent_error : :environment_error, detail: e.message)
|
|
75
|
+
rescue ConfigError
|
|
76
|
+
# A malformed bench is the author's bug to fix - raise!
|
|
77
|
+
raise
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
# A harness bug must leave evidence.
|
|
80
|
+
outcome = Results::Outcome.new(:harness_crash, detail: crash_detail(e))
|
|
81
|
+
ensure
|
|
82
|
+
environment&.stop
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
write_result(started_at: started_at, reward: reward, outcome: outcome, usage: usage)
|
|
86
|
+
rescue SystemCallError, JSON::GeneratorError => e
|
|
87
|
+
# runs_dir unwritable, disk full
|
|
88
|
+
raise ConfigError, "cannot record trial #{id}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def model_dir
|
|
94
|
+
(model || bench.agent.model || agent_name).to_s.split("/").last
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def logs_dir = dir
|
|
98
|
+
|
|
99
|
+
def over_ceiling(result)
|
|
100
|
+
limit = bench.agent.cost_limit
|
|
101
|
+
cost = result.usage&.cost_usd
|
|
102
|
+
return nil unless limit && cost && result.outcome.scored? && cost > limit
|
|
103
|
+
|
|
104
|
+
Results::Outcome.new(:cost_ceiling_reached,
|
|
105
|
+
detail: format("spent $%<cost>.4f against a $%<limit>.4f limit", cost: cost, limit: limit))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def crash_detail(error)
|
|
109
|
+
["#{error.class}: #{error.message}", *Array(error.backtrace).first(5)].join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def phase(name)
|
|
113
|
+
@phases[name] = { started_at: Time.now.utc.iso8601(6) }
|
|
114
|
+
yield
|
|
115
|
+
ensure
|
|
116
|
+
@phases[name][:finished_at] = Time.now.utc.iso8601(6)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def in_agent_phase
|
|
120
|
+
@agent_phase = true
|
|
121
|
+
result = yield
|
|
122
|
+
@agent_phase = false
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def start_environment
|
|
127
|
+
Environments.build(
|
|
128
|
+
backend,
|
|
129
|
+
image: task.environment_image,
|
|
130
|
+
resources: bench.environment.resources,
|
|
131
|
+
network: bench.environment.network,
|
|
132
|
+
build_timeout_sec: bench.environment.build_timeout_sec,
|
|
133
|
+
labels: { "lemans.task" => task.name, "lemans.trial" => id, "lemans.phase" => "agent" }
|
|
134
|
+
).start
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def prepare(environment)
|
|
138
|
+
Setup.new(
|
|
139
|
+
commands: bench.environment.setup,
|
|
140
|
+
task: task,
|
|
141
|
+
phase: :environment,
|
|
142
|
+
timeout_sec: bench.environment.build_timeout_sec
|
|
143
|
+
).call(environment)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_result(started_at:, reward:, outcome:, usage:)
|
|
147
|
+
finished_at = Time.now.utc
|
|
148
|
+
result = {
|
|
149
|
+
trial: id,
|
|
150
|
+
task: task.name,
|
|
151
|
+
agent: agent_name,
|
|
152
|
+
model: model || bench.agent.model,
|
|
153
|
+
reward: outcome.scored? ? reward : nil,
|
|
154
|
+
outcome: outcome.to_h,
|
|
155
|
+
usage: usage&.to_h,
|
|
156
|
+
lemans_version: VERSION,
|
|
157
|
+
# The digest already hashes the bench's shipped files along with its
|
|
158
|
+
# config, so the per-file listing added bulk, not pinning.
|
|
159
|
+
profile_digest: bench.digest,
|
|
160
|
+
task_digest: task.digest,
|
|
161
|
+
bench: bench.revision.to_h,
|
|
162
|
+
started_at: started_at.iso8601,
|
|
163
|
+
finished_at: finished_at.iso8601,
|
|
164
|
+
duration_sec: (finished_at - started_at).round(1),
|
|
165
|
+
# Where the wall clock went: without this, a slow sandbox morning
|
|
166
|
+
# reads as a slow model.
|
|
167
|
+
phases: @phases,
|
|
168
|
+
tags: task.tags,
|
|
169
|
+
metadata: task.metadata
|
|
170
|
+
}
|
|
171
|
+
atomic_write(result_path, "#{JSON.pretty_generate(result)}\n")
|
|
172
|
+
result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def result_path = dir.join("result.json")
|
|
176
|
+
|
|
177
|
+
# --resume treats any result.json as a finished attempt, so the write must
|
|
178
|
+
# be atomic: a rename is either all there or not there at all.
|
|
179
|
+
def atomic_write(path, content)
|
|
180
|
+
tmp = path.dirname.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(4)}")
|
|
181
|
+
tmp.write(content)
|
|
182
|
+
tmp.rename(path)
|
|
183
|
+
ensure
|
|
184
|
+
tmp&.delete if tmp&.exist?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/lemans/units.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded when a verifier command opts in with `ruby -report-lemans …`
|
|
4
|
+
# (that is `-r eport-lemans`, resolved from /tests on the LOAD_PATH).
|
|
5
|
+
module LemansReport
|
|
6
|
+
def self.registered? = @registered
|
|
7
|
+
|
|
8
|
+
def self.register
|
|
9
|
+
return if @registered
|
|
10
|
+
return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions)
|
|
11
|
+
|
|
12
|
+
@registered = true
|
|
13
|
+
require_relative "lemans_minitest_reporter"
|
|
14
|
+
::Minitest.singleton_class.define_method(:plugin_lemans_report_init) do |_options|
|
|
15
|
+
dir = ENV["LOGS"]
|
|
16
|
+
reporter << Reporter.new(dir) if dir && File.directory?(dir)
|
|
17
|
+
end
|
|
18
|
+
(::Minitest.extensions ||= []) << "lemans_report"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if defined?(Minitest)
|
|
23
|
+
LemansReport.register
|
|
24
|
+
else
|
|
25
|
+
# `-r` runs before bundler picks the app's minitest, so requiring minitest
|
|
26
|
+
# here would activate the wrong version. Instead watch class definitions and
|
|
27
|
+
# register the moment minitest's own module body closes; the probe disarms
|
|
28
|
+
# itself and nothing foreign is patched.
|
|
29
|
+
trace = TracePoint.new(:end) do |event|
|
|
30
|
+
next unless event.self.is_a?(Module) && event.self.name == "Minitest"
|
|
31
|
+
|
|
32
|
+
LemansReport.register
|
|
33
|
+
trace.disable if LemansReport.registered?
|
|
34
|
+
end
|
|
35
|
+
trace.enable
|
|
36
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LemansReport
|
|
6
|
+
# Appends every Minitest result to $LOGS/checks.json. Required by
|
|
7
|
+
# eport-lemans once Minitest is loaded; never load this file directly.
|
|
8
|
+
class Reporter < Minitest::AbstractReporter
|
|
9
|
+
def initialize(dir)
|
|
10
|
+
super()
|
|
11
|
+
@dir = dir
|
|
12
|
+
@results = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(result)
|
|
16
|
+
@results << result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def report
|
|
20
|
+
graded = @results.select { graded?(_1) }
|
|
21
|
+
prior = existing.fetch("checks", {})
|
|
22
|
+
return if graded.empty? && prior.empty?
|
|
23
|
+
|
|
24
|
+
checks = prior.merge(graded.to_h { [name(_1), status(_1)] }).sort.to_h
|
|
25
|
+
File.write(
|
|
26
|
+
File.join(@dir, "checks.json"),
|
|
27
|
+
JSON.pretty_generate(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A skip inside the harness-shipped tests is an unverified requirement and
|
|
32
|
+
# fails the run. The app's own suite keeps vanilla skip semantics.
|
|
33
|
+
def passed?
|
|
34
|
+
@results.none? { |result| graded?(result) && result.skipped? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def graded?(result)
|
|
40
|
+
dir = ENV["TESTS"]
|
|
41
|
+
# The trailing slash matters: /testsuite must not count as /tests.
|
|
42
|
+
dir && result.source_location.first.to_s.start_with?("#{dir.chomp("/")}/")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def existing
|
|
46
|
+
JSON.parse(File.read(File.join(@dir, "checks.json")))
|
|
47
|
+
rescue StandardError
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def name(result) = "#{result.klass}##{result.name}"
|
|
52
|
+
|
|
53
|
+
def status(result)
|
|
54
|
+
if result.skipped? then "skip"
|
|
55
|
+
elsif result.error? then "error"
|
|
56
|
+
elsif result.passed? then "pass"
|
|
57
|
+
else "fail"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
data/lib/lemans.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
|
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
|
6
|
+
loader.inflector.inflect("cli" => "CLI", "atif" => "ATIF")
|
|
7
|
+
# miniswen lives in this repo but is a plain-require gem of its own.
|
|
8
|
+
loader.ignore("#{__dir__}/miniswen.rb", "#{__dir__}/miniswen")
|
|
9
|
+
# assets/ holds files uploaded into sandboxes, not Ruby the harness loads.
|
|
10
|
+
loader.ignore("#{__dir__}/lemans/verifier/assets")
|
|
11
|
+
loader.setup
|
|
12
|
+
|
|
13
|
+
require "miniswen"
|
|
14
|
+
|
|
15
|
+
module Lemans
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Anything the caller can fix: a malformed bench.yml, a task directory
|
|
19
|
+
# missing a file, an unknown network mode.
|
|
20
|
+
class ConfigError < Error; end
|
|
21
|
+
|
|
22
|
+
# A backend, agent, or verifier failing in a way that is the harness's fault
|
|
23
|
+
# rather than the model's. These become an Outcome, never a zero reward.
|
|
24
|
+
class InfrastructureError < Error; end
|
|
25
|
+
|
|
26
|
+
# The verifier itself failed, or wrote something that is not a reward. Retrying
|
|
27
|
+
# would verify the same patch the same way, so it is terminal.
|
|
28
|
+
class VerifierError < InfrastructureError; end
|
|
29
|
+
end
|