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,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
require "daytona"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
module Environments
|
|
9
|
+
# Daytona sandboxes. Daytona builds images server-side into reusable content-named
|
|
10
|
+
# snapshots and enforces the network policy itself.
|
|
11
|
+
class Daytona < Base
|
|
12
|
+
TTL_MINUTES = 120
|
|
13
|
+
|
|
14
|
+
DEFAULT_BUILD_TIMEOUT_SEC = 600
|
|
15
|
+
|
|
16
|
+
# Workspace tarballs ride uploads/downloads, so transfers get their own
|
|
17
|
+
# budget through the SDK's streaming API instead of the global HTTP cap.
|
|
18
|
+
TRANSFER_TIMEOUT = 900
|
|
19
|
+
|
|
20
|
+
# File transfers ride the SDK's typhoeus/libcurl stack, which segfaults
|
|
21
|
+
# the VM under enough concurrent easy_perform calls (a GC race on string
|
|
22
|
+
# options libcurl is still copying). Transfers are seconds each, so
|
|
23
|
+
# capping them costs little; execs and lifecycle stay fully parallel.
|
|
24
|
+
TRANSFER_SLOTS = Concurrent::Semaphore.new(6)
|
|
25
|
+
|
|
26
|
+
SdkTweaks.apply!
|
|
27
|
+
|
|
28
|
+
attr_reader :sandbox
|
|
29
|
+
|
|
30
|
+
# Trials run concurrently, and an unsynchronised memo would build several
|
|
31
|
+
# SDK clients and leak all but one.
|
|
32
|
+
CLIENT = Concurrent::Delay.new { ::Daytona::Daytona.new(credentials) }
|
|
33
|
+
|
|
34
|
+
def self.client = CLIENT.value!
|
|
35
|
+
|
|
36
|
+
# The CLI stores its key as DAYTONA_TOKEN, the SDK only reads
|
|
37
|
+
# DAYTONA_API_KEY; Config also resolves .env files, so go through it.
|
|
38
|
+
def self.credentials
|
|
39
|
+
config = ::Daytona::Config.new
|
|
40
|
+
config.api_key ||= config.read_env("DAYTONA_TOKEN")
|
|
41
|
+
raise ConfigError, "no Daytona credentials: set DAYTONA_API_KEY or DAYTONA_TOKEN" unless config.api_key || config.jwt_token
|
|
42
|
+
|
|
43
|
+
config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout_sec: nil)
|
|
47
|
+
super(image: image, resources: resources, network: network, env: env, labels: labels,
|
|
48
|
+
build_timeout_sec: build_timeout_sec || DEFAULT_BUILD_TIMEOUT_SEC)
|
|
49
|
+
@logger = logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
@sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
|
|
54
|
+
@shell = Shell.new(sandbox)
|
|
55
|
+
self
|
|
56
|
+
rescue *Retries::SDK_ERRORS => e
|
|
57
|
+
# A sandbox created but never handed over would bill until its TTL:
|
|
58
|
+
# the caller's ensure can only stop an environment it received.
|
|
59
|
+
stop
|
|
60
|
+
raise InfrastructureError, "daytona: could not start sandbox: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exec(command, timeout: nil, env: {})
|
|
64
|
+
@shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
|
|
65
|
+
rescue *Retries::SDK_ERRORS => e
|
|
66
|
+
raise InfrastructureError, "daytona: exec failed: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def upload(local_path, remote_path)
|
|
70
|
+
transfer do
|
|
71
|
+
# An open handle, not a path string: the SDK uploads a non-existent
|
|
72
|
+
# path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
|
|
73
|
+
Pathname(local_path).open("rb") do |file|
|
|
74
|
+
sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rescue *Retries::SDK_ERRORS => e
|
|
78
|
+
raise InfrastructureError, "daytona: could not upload #{local_path}: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def download(remote_path, local_path)
|
|
82
|
+
local_path = Pathname(local_path)
|
|
83
|
+
local_path.dirname.mkpath
|
|
84
|
+
transfer do
|
|
85
|
+
local_path.open("wb") do |file|
|
|
86
|
+
sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
rescue *Retries::SDK_ERRORS, SystemCallError => e
|
|
90
|
+
raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def network_policy=(policy)
|
|
94
|
+
sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
|
|
95
|
+
@network = policy
|
|
96
|
+
rescue *Retries::SDK_ERRORS => e
|
|
97
|
+
raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Deleting is the only cleanup that stops the meter. Never raises:
|
|
101
|
+
# cleanup must not replace an otherwise valid result with an exception.
|
|
102
|
+
def stop
|
|
103
|
+
return if @sandbox.nil?
|
|
104
|
+
|
|
105
|
+
id = @sandbox.id
|
|
106
|
+
wait = true
|
|
107
|
+
begin
|
|
108
|
+
@sandbox.delete(wait:)
|
|
109
|
+
@sandbox = nil
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
# VM shutdown: confirming destruction needs threads Ruby no longer
|
|
112
|
+
# grants, but the bare DELETE needs none — the meter still stops.
|
|
113
|
+
if e.is_a?(ThreadError) && wait
|
|
114
|
+
wait = false
|
|
115
|
+
retry
|
|
116
|
+
end
|
|
117
|
+
warn "lemans: sandbox #{id} may still be running — delete failed: #{e.class}: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def transfer
|
|
124
|
+
TRANSFER_SLOTS.acquire
|
|
125
|
+
yield
|
|
126
|
+
ensure
|
|
127
|
+
TRANSFER_SLOTS.release
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def client = self.class.client
|
|
131
|
+
|
|
132
|
+
# A sandbox inherits the snapshot's resources, so the profile's are
|
|
133
|
+
# stamped into the snapshot at build time.
|
|
134
|
+
def create_params
|
|
135
|
+
::Daytona::CreateSandboxFromSnapshotParams.new(
|
|
136
|
+
snapshot: snapshot_store.call,
|
|
137
|
+
env_vars: env,
|
|
138
|
+
labels: labels,
|
|
139
|
+
auto_stop_interval: 0, # a 30-minute agent must not be stopped under it
|
|
140
|
+
auto_delete_interval: 60,
|
|
141
|
+
# A real ceiling: without it a harness that dies mid-run leaves a
|
|
142
|
+
# running sandbox billing forever.
|
|
143
|
+
ttl_minutes: TTL_MINUTES,
|
|
144
|
+
**network_kwargs(network)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def snapshot_store
|
|
149
|
+
SnapshotStore.new(client: client, image: image, resources: resources,
|
|
150
|
+
build_timeout_sec: build_timeout_sec, logger: @logger)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def network_kwargs(policy, for_update: false)
|
|
154
|
+
case policy.mode
|
|
155
|
+
when :none
|
|
156
|
+
{ network_block_all: true }
|
|
157
|
+
when :public
|
|
158
|
+
for_update ? { network_block_all: false } : {}
|
|
159
|
+
when :allowlist
|
|
160
|
+
raise ConfigError, "daytona: an allowlist cannot mix domains and IP targets (#{policy.hosts.join(", ")})" if policy.domains.any? && policy.ip_targets.any?
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
network_block_all: false,
|
|
164
|
+
domain_allow_list: policy.domains.join(","),
|
|
165
|
+
network_allow_list: policy.ip_targets.join(",")
|
|
166
|
+
}.reject { |_, value| value == "" }
|
|
167
|
+
else
|
|
168
|
+
# Silently returning nil would launch under Daytona's default
|
|
169
|
+
# network — the opposite of what this method exists to prevent.
|
|
170
|
+
raise ConfigError, "daytona: unsupported network mode #{policy.mode.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
# The backends `lemans run --backend` can name, and the one place a name
|
|
5
|
+
# becomes a class. An unknown backend fails as a config mistake.
|
|
6
|
+
module Environments
|
|
7
|
+
BACKENDS = { "daytona" => "Daytona" }.freeze
|
|
8
|
+
|
|
9
|
+
def self.build(backend, **)
|
|
10
|
+
constant = BACKENDS[backend.to_s] or
|
|
11
|
+
raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
|
|
12
|
+
|
|
13
|
+
const_get(constant).new(**)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# What a phase is allowed to reach. Every phase names its policy explicitly:
|
|
7
|
+
# network: { mode: allowlist, hosts: [openrouter.ai, "*.example.com", 10.0.0.0/8] }
|
|
8
|
+
class NetworkPolicy
|
|
9
|
+
MODES = %i[none allowlist public].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :mode, :hosts, :domains, :ip_targets
|
|
12
|
+
|
|
13
|
+
def self.from_config(config, field:)
|
|
14
|
+
raise ConfigError, "#{field}: network policy is required" if config.nil?
|
|
15
|
+
|
|
16
|
+
mode = config["mode"] or raise ConfigError, "#{field}.mode is required (#{MODES.join(", ")})"
|
|
17
|
+
new(mode: mode.to_s.to_sym, hosts: config["hosts"] || [], field: field)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.none = new(mode: :none)
|
|
21
|
+
|
|
22
|
+
def initialize(mode:, hosts: [], field: "network")
|
|
23
|
+
unless MODES.include?(mode)
|
|
24
|
+
raise ConfigError,
|
|
25
|
+
"#{field}.mode: #{mode.inspect} is not one of #{MODES.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise ConfigError, "#{field}.hosts must be a list" unless hosts.is_a?(Array)
|
|
29
|
+
if mode != :allowlist && !hosts.empty?
|
|
30
|
+
raise ConfigError,
|
|
31
|
+
"#{field}.hosts is only meaningful with mode: allowlist"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise ConfigError, "#{field}.hosts cannot be empty with mode: allowlist" if mode == :allowlist && hosts.empty?
|
|
35
|
+
|
|
36
|
+
hosts = validated_hosts(hosts, field)
|
|
37
|
+
|
|
38
|
+
@mode = mode
|
|
39
|
+
@hosts = hosts.freeze
|
|
40
|
+
# Split once, at construction: backends allowlist domains and IP ranges
|
|
41
|
+
# through separate APIs, and a bad entry must fail here, loudly — a
|
|
42
|
+
# malformed allowlist must never launch a sandbox open.
|
|
43
|
+
@ip_targets, @domains = hosts.partition { ip_target?(_1) }.map(&:freeze)
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h = { mode: mode, hosts: hosts }
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validated_hosts(hosts, field)
|
|
52
|
+
hosts.map do |entry|
|
|
53
|
+
raise ConfigError, "#{field}.hosts entry #{entry.inspect} is not a host name, pattern, or IP range" unless entry.is_a?(String) && !entry.strip.empty?
|
|
54
|
+
|
|
55
|
+
entry.strip
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ip_target?(entry)
|
|
60
|
+
IPAddr.new(entry)
|
|
61
|
+
true
|
|
62
|
+
rescue IPAddr::Error
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/lemans/patch.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# The agent's work as one git patch, diffed against a baseline sealed before
|
|
8
|
+
# its first turn
|
|
9
|
+
class Patch
|
|
10
|
+
LOCAL_PATH = "agent.patch"
|
|
11
|
+
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
12
|
+
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
13
|
+
|
|
14
|
+
TIMEOUT = 300
|
|
15
|
+
|
|
16
|
+
def initialize(environment, bench:, dir:)
|
|
17
|
+
@environment = environment
|
|
18
|
+
@workdir = bench.environment.workdir
|
|
19
|
+
@path = Pathname(dir).join(LOCAL_PATH)
|
|
20
|
+
@baseline = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def seal!
|
|
24
|
+
@baseline = write_tree
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
28
|
+
# after would not show what the agent did to them
|
|
29
|
+
def collect!
|
|
30
|
+
return unless baseline
|
|
31
|
+
|
|
32
|
+
after = write_tree
|
|
33
|
+
return unless after
|
|
34
|
+
|
|
35
|
+
result = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout: TIMEOUT)
|
|
36
|
+
return unless result.success?
|
|
37
|
+
|
|
38
|
+
path.dirname.mkpath
|
|
39
|
+
environment.download(REMOTE_PATCH, path)
|
|
40
|
+
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout: TIMEOUT)
|
|
41
|
+
path
|
|
42
|
+
rescue InfrastructureError => e
|
|
43
|
+
warn "lemans: could not collect the agent patch for #{path.dirname.basename}: #{e.message}"
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :environment, :workdir, :path, :baseline
|
|
50
|
+
|
|
51
|
+
# `safe.directory` because the sandbox may run the tree as a different user
|
|
52
|
+
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
53
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
54
|
+
|
|
55
|
+
# Untracked files only reach a diff through an index, so both sides are
|
|
56
|
+
# staged into a scratch one and hashed. Rebuilt from empty each time, so a
|
|
57
|
+
# stale entry cannot survive into the second tree.
|
|
58
|
+
def write_tree
|
|
59
|
+
result = environment.exec(
|
|
60
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
61
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
|
|
62
|
+
timeout: TIMEOUT
|
|
63
|
+
)
|
|
64
|
+
return nil unless result.success?
|
|
65
|
+
|
|
66
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
67
|
+
tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module RestorePaths # :nodoc:
|
|
7
|
+
def self.call(declared, label:)
|
|
8
|
+
Array(declared).each_with_index.map do |path, index|
|
|
9
|
+
entry = "#{label}[#{index}]"
|
|
10
|
+
raise ConfigError, "#{entry} must be a path string, got #{path.inspect}" unless path.is_a?(String)
|
|
11
|
+
raise ConfigError, "#{entry} must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
|
|
12
|
+
raise ConfigError, "#{entry} must not escape the workdir: #{path.inspect}" if
|
|
13
|
+
path.split("/").include?("..")
|
|
14
|
+
raise ConfigError, "#{entry} must name something inside the workdir, got #{path.inspect}" if
|
|
15
|
+
Pathname(path).cleanpath.to_s == "."
|
|
16
|
+
|
|
17
|
+
path
|
|
18
|
+
end.freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module Results
|
|
7
|
+
# Rolls trials up the way a leaderboard quotes them: solved out of
|
|
8
|
+
# attempts, median time, mean spend per run. Groups by any 1-3 of
|
|
9
|
+
# task, agent, model — "task-model" reads as two columns.
|
|
10
|
+
class Aggregate
|
|
11
|
+
KEYS = %i[task agent model].freeze
|
|
12
|
+
METRICS = %i[score time cost steps tokens].freeze
|
|
13
|
+
METRIC_SOURCES = { time: :duration_sec, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :report, :keys
|
|
16
|
+
|
|
17
|
+
def self.keys(spec)
|
|
18
|
+
keys = spec.to_s.split("-").map(&:to_sym)
|
|
19
|
+
return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
|
|
20
|
+
|
|
21
|
+
raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(report, keys:)
|
|
25
|
+
@report = report
|
|
26
|
+
@keys = keys
|
|
27
|
+
@groups = report.rows
|
|
28
|
+
.group_by { |row| keys.map { row[_1] } }
|
|
29
|
+
.map { |values, group| build(values, group) }
|
|
30
|
+
.sort_by { |group| keys.map { group[_1].to_s } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def order_by!(column)
|
|
34
|
+
column = Sorting.column(column, allowed: keys + METRICS)
|
|
35
|
+
@groups =
|
|
36
|
+
if keys.include?(column)
|
|
37
|
+
Sorting.call(@groups) { _1[column].to_s }
|
|
38
|
+
elsif column == :score
|
|
39
|
+
Sorting.call(@groups, descending: true) { [Rational(_1[:solved], _1[:attempts]), _1[:attempts]] }
|
|
40
|
+
else
|
|
41
|
+
Sorting.call(@groups, descending: true) { _1[METRIC_SOURCES.fetch(column)] }
|
|
42
|
+
end
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_rows
|
|
47
|
+
[keys.map(&:to_s) + METRICS.map(&:to_s)] +
|
|
48
|
+
@groups.map do |group|
|
|
49
|
+
keys.map { |key| display_key(key, group[key]) } + [
|
|
50
|
+
"#{group[:solved]}/#{group[:attempts]}",
|
|
51
|
+
time(group[:duration_sec]),
|
|
52
|
+
cost(group[:cost_usd]),
|
|
53
|
+
mean_display(group[:steps], 1),
|
|
54
|
+
mean_display(group[:tokens], 0)
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_csv
|
|
60
|
+
columns = keys + %i[solved attempts duration_sec cost_usd steps tokens]
|
|
61
|
+
CSV.generate do |csv|
|
|
62
|
+
csv << columns
|
|
63
|
+
@groups.each { |group| csv << columns.map { group[_1] } }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def summary = report.summary
|
|
68
|
+
|
|
69
|
+
def summary_lines = report.summary_lines
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Attempts count every run; means and the median skip runs that never
|
|
74
|
+
# measured the value, so one invalid trial cannot zero out a cell.
|
|
75
|
+
def build(values, group)
|
|
76
|
+
keys.zip(values).to_h.merge(
|
|
77
|
+
solved: Tally.call(group)[:solved],
|
|
78
|
+
attempts: group.size,
|
|
79
|
+
duration_sec: median(group.filter_map { _1[:duration_sec] }),
|
|
80
|
+
cost_usd: mean(group.filter_map { _1[:cost_usd] }),
|
|
81
|
+
steps: mean(group.filter_map { _1[:steps] }),
|
|
82
|
+
tokens: mean(group.filter_map { _1[:tokens] })
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
87
|
+
|
|
88
|
+
def median(values)
|
|
89
|
+
return nil if values.empty?
|
|
90
|
+
|
|
91
|
+
sorted = values.sort
|
|
92
|
+
mid = sorted.size / 2
|
|
93
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def display_key(key, value)
|
|
97
|
+
return "-" if value.nil?
|
|
98
|
+
|
|
99
|
+
key == :model ? Report.short_model(value) : value.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def time(sec)
|
|
103
|
+
return "-" if sec.nil?
|
|
104
|
+
|
|
105
|
+
minutes, seconds = sec.round.divmod(60)
|
|
106
|
+
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
110
|
+
|
|
111
|
+
def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# Where a trial's dollar figure came from: a published $0.00 is only worth
|
|
6
|
+
# reading if it can be told apart from "nobody could price this model".
|
|
7
|
+
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
8
|
+
def self.none = new(name: :none, model: nil, priced_as: nil, registry: nil)
|
|
9
|
+
|
|
10
|
+
def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# Why a trial ended, and whether its reward means anything: out-of-budget
|
|
6
|
+
# is a scored failure, a sandbox that never started measured nothing.
|
|
7
|
+
class Outcome
|
|
8
|
+
SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
|
|
9
|
+
INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled harness_crash].freeze
|
|
10
|
+
|
|
11
|
+
ALL = (SCORED + INVALID).freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :detail
|
|
14
|
+
|
|
15
|
+
def initialize(name, detail: nil)
|
|
16
|
+
raise ArgumentError, "unknown outcome #{name.inspect}" unless ALL.include?(name)
|
|
17
|
+
|
|
18
|
+
@name = name
|
|
19
|
+
@detail = detail
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ALL.each do |outcome|
|
|
24
|
+
define_method(:"#{outcome}?") { name == outcome }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def scored? = SCORED.include?(name)
|
|
28
|
+
|
|
29
|
+
def invalid? = !scored?
|
|
30
|
+
|
|
31
|
+
def to_h = { name: name, scored: scored?, detail: detail }.compact
|
|
32
|
+
|
|
33
|
+
def to_s = detail ? "#{name}: #{detail}" : name.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
module Results
|
|
9
|
+
# Reads a runs directory back as a table or CSV. The result files stay the
|
|
10
|
+
# source of truth; unreadable ones are counted and said out loud.
|
|
11
|
+
class Report
|
|
12
|
+
COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration_sec started_at trial tags
|
|
13
|
+
detail].freeze
|
|
14
|
+
TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration_sec trial].freeze
|
|
15
|
+
NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration_sec].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :rows, :unreadable
|
|
18
|
+
|
|
19
|
+
def self.load(runs_dir, tag: nil)
|
|
20
|
+
paths = Pathname(runs_dir).glob("**/result.json").sort
|
|
21
|
+
rows = []
|
|
22
|
+
unreadable = 0
|
|
23
|
+
|
|
24
|
+
paths.each do |path|
|
|
25
|
+
result = JSON.parse(path.read)
|
|
26
|
+
rows << row_from(result)
|
|
27
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
28
|
+
unreadable += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rows = rows.select { _1[:tags].include?(tag) } if tag
|
|
32
|
+
new(rows: rows.sort_by { [_1[:task].to_s, _1[:started_at].to_s] }, unreadable: unreadable)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.row_from(result)
|
|
36
|
+
{
|
|
37
|
+
task: result["task"],
|
|
38
|
+
agent: result["agent"],
|
|
39
|
+
model: result["model"],
|
|
40
|
+
reward: result["reward"],
|
|
41
|
+
outcome: result.dig("outcome", "name"),
|
|
42
|
+
scored: result.dig("outcome", "scored") == true,
|
|
43
|
+
detail: result.dig("outcome", "detail"),
|
|
44
|
+
cost_usd: result.dig("usage", "cost_usd"),
|
|
45
|
+
steps: result.dig("usage", "steps"),
|
|
46
|
+
tokens: tokens_from(result),
|
|
47
|
+
duration_sec: result["duration_sec"],
|
|
48
|
+
started_at: result["started_at"],
|
|
49
|
+
trial: result["trial"],
|
|
50
|
+
tags: Array(result["tags"]).map(&:to_s)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Tokens the model actually consumed and produced; cache reads stay out,
|
|
55
|
+
# matching how providers meter a run.
|
|
56
|
+
def self.tokens_from(result)
|
|
57
|
+
input = result.dig("usage", "input_tokens")
|
|
58
|
+
output = result.dig("usage", "output_tokens")
|
|
59
|
+
input.nil? && output.nil? ? nil : input.to_i + output.to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A bench may name no model at all (nop, oracle); the summary needs a
|
|
63
|
+
# label, not a nil for ljust to crash on.
|
|
64
|
+
def self.short_model(model) = model.to_s.split("/").last || "(default)"
|
|
65
|
+
|
|
66
|
+
def initialize(rows:, unreadable: 0)
|
|
67
|
+
@rows = rows
|
|
68
|
+
@unreadable = unreadable
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def empty? = rows.empty? && unreadable.zero?
|
|
72
|
+
|
|
73
|
+
# Numbers rank best-first the way a leaderboard reads; names sort A-Z.
|
|
74
|
+
# Trials that never measured the column sink to the bottom either way.
|
|
75
|
+
def order_by!(column)
|
|
76
|
+
column = Sorting.column(column, allowed: TABLE_COLUMNS)
|
|
77
|
+
descending = NUMERIC_COLUMNS.include?(column)
|
|
78
|
+
@rows = Sorting.call(rows, descending: descending) { _1[column] }
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def summary
|
|
83
|
+
Tally.call(rows).merge(cost_usd: rows.sum { _1[:cost_usd].to_f })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_rows
|
|
87
|
+
[TABLE_COLUMNS.map(&:to_s)] +
|
|
88
|
+
rows.map do |row|
|
|
89
|
+
TABLE_COLUMNS.map do |column|
|
|
90
|
+
display(column == :model ? short_model(row[:model]) : row[column])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def summary_lines
|
|
96
|
+
per_model = rows.group_by { short_model(_1[:model]) }
|
|
97
|
+
lines =
|
|
98
|
+
if per_model.size > 1
|
|
99
|
+
width = per_model.keys.map(&:length).max
|
|
100
|
+
per_model.map { |model, group| "#{model.ljust(width)} #{stats(group)}" } +
|
|
101
|
+
["#{"total".ljust(width)} #{stats(rows)}"]
|
|
102
|
+
else
|
|
103
|
+
[stats(rows)]
|
|
104
|
+
end
|
|
105
|
+
lines[-1] = "#{lines[-1]} · #{unreadable} unreadable result(s) skipped" if unreadable.positive?
|
|
106
|
+
lines
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_csv
|
|
110
|
+
CSV.generate do |csv|
|
|
111
|
+
csv << COLUMNS
|
|
112
|
+
rows.each do |row|
|
|
113
|
+
csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# The rank divides solved by scored, not total: invalid trials measured nothing.
|
|
121
|
+
def stats(group)
|
|
122
|
+
totals = Tally.call(group).merge(cost_usd: group.sum { _1[:cost_usd].to_f })
|
|
123
|
+
rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
|
|
124
|
+
"#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
|
|
125
|
+
"#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pass_at_k(group)
|
|
129
|
+
cells = group.select { _1[:scored] }.group_by { [_1[:model], _1[:task]] }.values
|
|
130
|
+
sizes = cells.map(&:size).uniq
|
|
131
|
+
return "" unless sizes.any? { _1 > 1 }
|
|
132
|
+
|
|
133
|
+
solved = cells.count { |trials| trials.any? { _1[:reward].to_f >= 1.0 } }
|
|
134
|
+
label = sizes.size == 1 ? "pass@#{sizes.first}" : "pass@k"
|
|
135
|
+
" · #{label} #{solved}/#{cells.size} tasks (#{(100.0 * solved / cells.size).round}%)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def short_model(model) = self.class.short_model(model)
|
|
139
|
+
|
|
140
|
+
def display(value)
|
|
141
|
+
case value
|
|
142
|
+
when nil then "-"
|
|
143
|
+
when Float then format("%g", value.round(4))
|
|
144
|
+
else value.to_s
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# One sorting rule for every results view: validate the column name and
|
|
6
|
+
# keep rows that never measured the value at the bottom.
|
|
7
|
+
module Sorting
|
|
8
|
+
def self.column(name, allowed:)
|
|
9
|
+
column = name.to_s.to_sym
|
|
10
|
+
return column if allowed.include?(column)
|
|
11
|
+
|
|
12
|
+
raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.call(rows, descending: false)
|
|
16
|
+
keyed = rows.map { [yield(_1), _1] }
|
|
17
|
+
present, missing = keyed.partition { |value, _| value }
|
|
18
|
+
sorted = present.sort_by { |value, _| value }
|
|
19
|
+
sorted.reverse! if descending
|
|
20
|
+
(sorted + missing).map(&:last)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|