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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# One definition of the numbers everyone quotes — total, scored, invalid, solved — so the
|
|
6
|
+
# streaming summary and the report can never drift apart.
|
|
7
|
+
module Tally
|
|
8
|
+
def self.call(entries)
|
|
9
|
+
scored = entries.count { _1[:scored] }
|
|
10
|
+
{
|
|
11
|
+
total: entries.size,
|
|
12
|
+
scored: scored,
|
|
13
|
+
invalid: entries.size - scored,
|
|
14
|
+
solved: entries.count { _1[:reward].to_f >= 1.0 }
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# What a trial spent. Cache tokens count separately, and the cost carries
|
|
6
|
+
# its own provenance — $0.00 is the figure most in need of auditing.
|
|
7
|
+
Usage = Data.define(:input_tokens, :output_tokens, :cached_tokens, :cost_usd, :steps, :cost_source) do
|
|
8
|
+
def self.zero
|
|
9
|
+
new(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cost_usd: 0.0, steps: 0, cost_source: CostSource.none)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_h
|
|
13
|
+
{
|
|
14
|
+
input_tokens: input_tokens,
|
|
15
|
+
output_tokens: output_tokens,
|
|
16
|
+
cached_tokens: cached_tokens,
|
|
17
|
+
cost_usd: cost_usd,
|
|
18
|
+
steps: steps,
|
|
19
|
+
cost_source: cost_source&.to_h
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/lemans/run.rb
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# A whole run: every task, k attempts each, several trials in flight.
|
|
8
|
+
# Concurrency and resume exist because a sequential run is a day of wall clock.
|
|
9
|
+
class Run
|
|
10
|
+
Attempt = Data.define(:task, :model, :index)
|
|
11
|
+
|
|
12
|
+
# What ^C injects into workers instead of Interrupt, so the join loop can
|
|
13
|
+
# swallow its own stop signal while a user's second ^C stays distinguishable
|
|
14
|
+
class Shutdown < Exception; end # rubocop:disable Lint/InheritException
|
|
15
|
+
|
|
16
|
+
def initialize(bench:, tasks:, agent_name:, runs_dir:, attempts: 1, concurrency: 4,
|
|
17
|
+
resume: false, model: nil, backend: "daytona")
|
|
18
|
+
@bench = bench
|
|
19
|
+
@tasks = tasks
|
|
20
|
+
@agent_name = agent_name
|
|
21
|
+
@runs_dir = Pathname(runs_dir)
|
|
22
|
+
@attempts = attempts
|
|
23
|
+
@concurrency = concurrency
|
|
24
|
+
@resume = resume
|
|
25
|
+
@model = Array(model)
|
|
26
|
+
@backend = backend
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# How many attempts this run will actually schedule — resume already
|
|
30
|
+
# subtracted the retired ones.
|
|
31
|
+
def total = pending.size
|
|
32
|
+
|
|
33
|
+
def call(&report)
|
|
34
|
+
begin
|
|
35
|
+
@runs_dir.mkpath
|
|
36
|
+
rescue SystemCallError => e
|
|
37
|
+
raise ConfigError, "cannot use runs directory #{@runs_dir}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
queue = Queue.new
|
|
40
|
+
pending.shuffle.each { queue << _1 }
|
|
41
|
+
queue.close
|
|
42
|
+
|
|
43
|
+
results = Concurrent::Array.new
|
|
44
|
+
workers = Array.new(@concurrency) { Thread.new { drain(queue, results, &report) } }
|
|
45
|
+
workers.each(&:join)
|
|
46
|
+
raise @abort if @abort
|
|
47
|
+
|
|
48
|
+
summarize(results)
|
|
49
|
+
rescue Interrupt
|
|
50
|
+
queue&.clear
|
|
51
|
+
queue&.close
|
|
52
|
+
workers = Array(workers).select(&:alive?)
|
|
53
|
+
report&.call(:interrupted, { in_flight: workers.size })
|
|
54
|
+
workers.each { _1.raise(Shutdown) }
|
|
55
|
+
|
|
56
|
+
workers.each do |worker| # rubocop:disable Style/CombinableLoops
|
|
57
|
+
worker.join
|
|
58
|
+
rescue Shutdown
|
|
59
|
+
# ignore: our own stop signal coming back
|
|
60
|
+
end
|
|
61
|
+
summarize(results || []).merge(interrupted: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def pending
|
|
67
|
+
@pending ||= models.flat_map do |model|
|
|
68
|
+
@tasks.flat_map do |task|
|
|
69
|
+
completed = @resume ? completed_attempts(task, model) : 0
|
|
70
|
+
((completed + 1)..@attempts).map { Attempt.new(task: task, model: model, index: _1) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def models
|
|
76
|
+
return @model if @model.any?
|
|
77
|
+
return [nil] if @bench.agent.models.empty?
|
|
78
|
+
|
|
79
|
+
@bench.agent.models
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Counts only attempts that measured the same thing (agent, model, digests, scored) — otherwise
|
|
83
|
+
# editing bench.yml and resuming would let old-profile trials satisfy the new run.
|
|
84
|
+
def completed_attempts(task, model)
|
|
85
|
+
@runs_dir.glob("**/result.json").count { same_run?(_1, task, model) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def same_run?(path, task, model)
|
|
89
|
+
result = JSON.parse(path.read, symbolize_names: true)
|
|
90
|
+
|
|
91
|
+
result[:task] == task.name &&
|
|
92
|
+
result[:agent] == @agent_name &&
|
|
93
|
+
result[:model] == (model || @bench.agent.model) &&
|
|
94
|
+
result[:profile_digest] == @bench.digest &&
|
|
95
|
+
result[:task_digest] == task.digest &&
|
|
96
|
+
result.dig(:outcome, :scored) == true
|
|
97
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Any failure that escapes a trial poisons the run: stop scheduling, let
|
|
102
|
+
# in-flight trials finish, surface after the join.
|
|
103
|
+
def drain(queue, results, &)
|
|
104
|
+
# The TUI owns failure output; a dying worker must not spray stderr.
|
|
105
|
+
Thread.current.report_on_exception = false
|
|
106
|
+
while (attempt = queue.pop)
|
|
107
|
+
begin
|
|
108
|
+
results << run_attempt(attempt, &)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
@abort ||= e
|
|
111
|
+
queue.clear
|
|
112
|
+
queue.close
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_attempt(attempt)
|
|
118
|
+
trial = Trial.new(
|
|
119
|
+
task: attempt.task,
|
|
120
|
+
bench: @bench,
|
|
121
|
+
agent_name: @agent_name,
|
|
122
|
+
model: attempt.model,
|
|
123
|
+
backend: @backend,
|
|
124
|
+
runs_dir: @runs_dir
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if block_given?
|
|
128
|
+
yield :started, { task: attempt.task.name, model: attempt.model || @bench.agent.model,
|
|
129
|
+
index: attempt.index, attempts: @attempts, trial: trial.id }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result = trial.run
|
|
133
|
+
if block_given?
|
|
134
|
+
yield :finished, {
|
|
135
|
+
task: attempt.task.name,
|
|
136
|
+
model: attempt.model || @bench.agent.model,
|
|
137
|
+
index: attempt.index,
|
|
138
|
+
outcome: result[:outcome][:name],
|
|
139
|
+
scored: result[:outcome][:scored],
|
|
140
|
+
reward: result[:reward],
|
|
141
|
+
duration_sec: result[:duration_sec],
|
|
142
|
+
detail: result[:outcome][:detail]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def summarize(results)
|
|
149
|
+
Results::Tally.call(results.map { { scored: _1.dig(:outcome, :scored) == true, reward: _1[:reward] } })
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/lemans/setup.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# Makes one shared image into this task's sandbox at start-up: uploads the
|
|
7
|
+
# declared files and runs the setup commands. A failure here is never the model's.
|
|
8
|
+
class Setup
|
|
9
|
+
# Uploads land under one harness-owned directory, wiped once the steps have
|
|
10
|
+
# run: an agent whose first `ls /` finds the seed patch is reading the harness.
|
|
11
|
+
ROOT = "/lemans"
|
|
12
|
+
DIR = "#{ROOT}/setup".freeze
|
|
13
|
+
|
|
14
|
+
def initialize(commands:, task:, phase:, timeout_sec:)
|
|
15
|
+
@commands = commands
|
|
16
|
+
@task = task
|
|
17
|
+
@phase = phase
|
|
18
|
+
@timeout_sec = timeout_sec
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(environment)
|
|
22
|
+
return if commands.empty? && files.empty?
|
|
23
|
+
|
|
24
|
+
files.each { |local, remote| environment.upload(local, remote) }
|
|
25
|
+
apply_seed(environment)
|
|
26
|
+
commands.each { environment.exec!(_1, timeout: timeout_sec) }
|
|
27
|
+
environment.exec!("rm -rf #{Shellwords.escape(ROOT)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :commands, :task, :phase, :timeout_sec
|
|
33
|
+
|
|
34
|
+
# Folds a task's flat environment.patch into the workdir, then reseals the
|
|
35
|
+
# tree as a one-commit repo so `git log` does not point at the defect.
|
|
36
|
+
def apply_seed(environment)
|
|
37
|
+
return unless phase == :environment
|
|
38
|
+
|
|
39
|
+
seed = "#{DIR}/#{Task::FLAT_SEED}"
|
|
40
|
+
return unless files.any? { |_, remote| remote == seed }
|
|
41
|
+
|
|
42
|
+
workdir = Shellwords.escape(task.bench.environment.workdir)
|
|
43
|
+
environment.exec!(
|
|
44
|
+
"cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
|
|
45
|
+
"rm -rf .git && git init -q && git add -A && " \
|
|
46
|
+
"git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
|
|
47
|
+
timeout: timeout_sec
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def files
|
|
52
|
+
@files ||= from(bench.root, bench.setup_files(phase)) + from(task.dir, task.setup_files(phase))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def from(root, paths) = paths.map { [root.join(_1), "#{DIR}/#{_1}"] }
|
|
56
|
+
|
|
57
|
+
def bench = task.bench
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# The files a phase's setup steps consume. Paths are confined to the
|
|
7
|
+
# directory that declared them and must exist at load time.
|
|
8
|
+
module SetupFiles
|
|
9
|
+
PHASES = %i[environment verifier].freeze
|
|
10
|
+
|
|
11
|
+
def self.call(declared, root:, label:)
|
|
12
|
+
declared ||= {}
|
|
13
|
+
raise ConfigError, "#{label}: files must name a phase (#{PHASES.join(", ")})" unless declared.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
unknown = declared.keys.map(&:to_s) - PHASES.map(&:to_s)
|
|
16
|
+
raise ConfigError, "#{label}: files.#{unknown.first} is not a phase (#{PHASES.join(", ")})" if unknown.any?
|
|
17
|
+
|
|
18
|
+
root = Pathname(root)
|
|
19
|
+
PHASES.to_h do |phase|
|
|
20
|
+
[phase, Array(declared[phase.to_s]).map { checked(_1, phase, root: root, label: label) }.freeze]
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.checked(path, phase, root:, label:)
|
|
25
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} is not a path" unless path.is_a?(String) && !path.empty?
|
|
26
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must be relative to #{root}" if path.start_with?("/")
|
|
27
|
+
|
|
28
|
+
relative = Pathname(path).cleanpath
|
|
29
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must name a file inside #{root}" if relative.each_filename.include?("..") || relative.to_s == "."
|
|
30
|
+
raise ConfigError, "#{label}: files.#{phase} names #{path}, which is not a file" unless root.join(relative).file?
|
|
31
|
+
|
|
32
|
+
relative
|
|
33
|
+
end
|
|
34
|
+
private_class_method :checked
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# The sealed baseline of a task's graded surfaces: a git tree written before
|
|
7
|
+
# the agent's first turn, checked out over the live tree at verification.
|
|
8
|
+
class Snapshot
|
|
9
|
+
REMOTE_INDEX = "/tmp/lemans-baseline.idx"
|
|
10
|
+
|
|
11
|
+
TIMEOUT = 300
|
|
12
|
+
|
|
13
|
+
def initialize(environment, bench:, task:, timeout: TIMEOUT)
|
|
14
|
+
@environment = environment
|
|
15
|
+
@workdir = bench.environment.workdir
|
|
16
|
+
@paths = task.restore_paths
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@baseline = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sealing failure is an environment error: the agent has not run yet.
|
|
22
|
+
def capture!
|
|
23
|
+
return if paths.empty?
|
|
24
|
+
|
|
25
|
+
result = environment.exec(
|
|
26
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
27
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
|
|
28
|
+
timeout: timeout
|
|
29
|
+
)
|
|
30
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
31
|
+
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)
|
|
32
|
+
|
|
33
|
+
@baseline = tree
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def restore! # rubocop:disable Naming/PredicateMethod
|
|
37
|
+
return true if paths.empty?
|
|
38
|
+
raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
|
|
39
|
+
|
|
40
|
+
environment.exec(
|
|
41
|
+
"cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
|
|
42
|
+
"rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
|
|
43
|
+
timeout: timeout
|
|
44
|
+
).success?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :environment, :workdir, :paths, :timeout, :baseline
|
|
50
|
+
|
|
51
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
52
|
+
|
|
53
|
+
def escaped_paths = Shellwords.join(paths)
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/lemans/task.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|