lemans 1.2.0 → 1.3.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 +8 -0
- data/README.md +3 -3
- data/exe/lemans-remote +22 -10
- data/lib/lemans/agents/miniswen.rb +2 -2
- data/lib/lemans/cli/progress_reporter.rb +6 -1
- data/lib/lemans/cli/regrade.rb +193 -0
- data/lib/lemans/cli/report/aggregate.rb +18 -11
- data/lib/lemans/cli/report.rb +25 -8
- data/lib/lemans/cli.rb +38 -1
- data/lib/lemans/config/environment.rb +4 -1
- data/lib/lemans/config.rb +1 -1
- data/lib/lemans/environment.rb +6 -3
- data/lib/lemans/environments/daytona/retries.rb +5 -3
- data/lib/lemans/environments/daytona/shell.rb +11 -1
- data/lib/lemans/environments/daytona.rb +16 -6
- data/lib/lemans/environments/docker.rb +2 -2
- data/lib/lemans/result.rb +16 -6
- data/lib/lemans/store.rb +7 -1
- data/lib/lemans/stores/fs.rb +9 -3
- data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +20 -4
- data/lib/lemans/trial/verifier.rb +30 -3
- data/lib/lemans/trial.rb +7 -1
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +21 -2
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4dbdb69526dbf7556f8c6f5840151fbeaaef985f732d868a1dd50b3e6f78d374
|
|
4
|
+
data.tar.gz: f47b34d05a3396feebed35354e4e8986105fba78d721a3be5cf98ead3a7ebd56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 249aacbba88024ca2ba14c440899e721305f746a1f7bb1213fa1dae2d4dc3f28b747eb99d3a42fa684451497438f5fa22263d853cbefe1ac8627ae7a272d023e
|
|
7
|
+
data.tar.gz: ec188a43c63f5e4cbf56686ec1117757d29d3083956d374a82b27bf4fd20aa5939a1a63b5cc4b8d395b051dca8b9850db9531e973bcdbebe5e1dfc66d26852fa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-09-04
|
|
4
|
+
|
|
5
|
+
- Miniswen: send an explicit `max_tokens` on every request (otherwise defaults could eat a lot of context, e.g., for `qwen3.8-27b`).
|
|
6
|
+
- `lemans report --metadata category:full-features` filters runs by task metadata.
|
|
7
|
+
- Fractional credit support (in addition to reward).
|
|
8
|
+
- Make Daytona TTL inferred from the task timeout settings.
|
|
9
|
+
- Fix Dockerfile resolution when profiles are used and per-task `bench.yml` exists.
|
|
10
|
+
|
|
3
11
|
## [1.2.0] - 2026-09-02
|
|
4
12
|
|
|
5
13
|
- `allow_failure { ... }` (`LemansReport::Assertions`) for verification checks that are recorded in `checks.json` but do not grade the run.
|
data/README.md
CHANGED
|
@@ -142,7 +142,7 @@ A minimal task example—checking whether an agent can write "Hello, world" into
|
|
|
142
142
|
+Hello, world
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
- `verification_test.rb`: a Ruby test that grades the solution (pass → 1, fail → 0; for partial credit, write a float in 0.0..1.0 to `$LOGS/reward.txt` instead). A check worth recording but not grading goes into `allow_failure { ... }` (`include LemansReport::Assertions`): a failed assertion inside lands in `checks.json` as `fail (allowed)` with its message and leaves the reward alone, while errors and skips stay hard failures. We use Minitest:
|
|
145
|
+
- `verification_test.rb`: a Ruby test that grades the solution (pass → 1, fail → 0; for partial credit, write a float in 0.0..1.0 to `$LOGS/reward.txt` instead). A check worth recording but not grading goes into `allow_failure { ... }` (`include LemansReport::Assertions`): a failed assertion inside lands in `checks.json` as `fail (allowed)` with its message and leaves the reward alone, while errors and skips stay hard failures (a test may call `allow_failure` once). For fractional credit next to the reward, say what passing the required checks alone is worth (`LemansReport.base_credit = 0.7`) and weight the allowed failures (`allow_failure(points: 2) { ... }`, 1 by default): the trial's `credit` is the base credit plus the remainder scaled by the share of extra points passed, rounded to two digits — 0 whenever the suite fails, and simply the reward when no `base_credit` is set. We use Minitest:
|
|
146
146
|
|
|
147
147
|
```ruby
|
|
148
148
|
require "minitest/autorun"
|
|
@@ -222,7 +222,7 @@ ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0
|
|
|
222
222
|
|
|
223
223
|
Each trial writes a flat `runs/<model>/<task>__<id>/` directory:
|
|
224
224
|
|
|
225
|
-
- `result.json`: reward, outcome (completed, error, etc.), usage, timings, tags, digests
|
|
225
|
+
- `result.json`: reward, credit, outcome (completed, error, etc.), usage, timings, tags, digests
|
|
226
226
|
- `trajectory.json`: [ATIF](https://www.harborframework.com/docs/agents/trajectory-format) trajectory of the agent's session
|
|
227
227
|
- `agent.patch`: the agent's work as one diff against the sealed baseline
|
|
228
228
|
- `verifier.log`, `checks.json`, etc.: additional logs captured during the verification phase.
|
|
@@ -254,7 +254,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
254
254
|
| `lemans init` | Scaffold a new bench directory: an annotated `bench.yml` and two example tasks |
|
|
255
255
|
| `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
|
|
256
256
|
| `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `-k`, `-c`, `--resume`) |
|
|
257
|
-
| `lemans report` | Summarize `runs/` as a table or CSV (`--tag`, `-A [task-agent-model]` to aggregate, `-S <column>` to sort); repeated attempts add pass@k per model × task |
|
|
257
|
+
| `lemans report` | Summarize `runs/` as a table or CSV (`--task`, `--tag`, `--metadata key:value` to filter, `-A [task-agent-model]` to aggregate, `-S <column>` to sort); repeated attempts add pass@k per model × task, fractional grading a `credit` column |
|
|
258
258
|
| `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
|
|
259
259
|
|
|
260
260
|
## miniswen
|
data/exe/lemans-remote
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
# exe/lemans-remote provision
|
|
14
14
|
#
|
|
15
15
|
# Fire-and-forget a bench run on remote Daytona sandboxes — one sandbox
|
|
16
|
-
# per task (--run-in-band for a single sandbox, --attempts N to
|
|
17
|
-
# each task in N sandboxes); results are archived to the
|
|
16
|
+
# per task × model (--run-in-band for a single sandbox, --attempts N to
|
|
17
|
+
# repeat each task in N sandboxes); results are archived to the
|
|
18
18
|
# lemans-remote-runs volume. Add --sync to wait for a single sandbox
|
|
19
19
|
# and download into ./runs directly instead:
|
|
20
20
|
#
|
|
21
21
|
# exe/lemans-remote run --bench ../ai-evals --task hello-world
|
|
22
|
-
# exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --args="-k 2 -c 8"
|
|
22
|
+
# exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --model openrouter/qwen/qwen3.5-coder --args="-k 2 -c 8"
|
|
23
23
|
#
|
|
24
24
|
# Retry invalid local results (agent/infra errors, not negative rewards):
|
|
25
25
|
# launches one sandbox per invalid (task, model) group — a single retried
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
#
|
|
31
31
|
# Then watch, fetch, and clean up:
|
|
32
32
|
#
|
|
33
|
-
# exe/lemans-remote status [--history] [--running | --complete]
|
|
33
|
+
# exe/lemans-remote status [--history] [--running | --complete] [-W [INTERVAL]]
|
|
34
34
|
# exe/lemans-remote pull-runs [RUN_ID ...] [--all] [--dry-run]
|
|
35
35
|
# exe/lemans-remote drop-orphans [--min-age 10m]
|
|
36
36
|
# exe/lemans-remote clobber RUN_ID ... | --all
|
|
@@ -834,7 +834,8 @@ module LemansRemote # :nodoc: all
|
|
|
834
834
|
retry_groups.each { say_status :retry, "#{it.task} (#{it.model}) — #{it.dirs.size} invalid attempt(s), retrying one" }
|
|
835
835
|
retry_groups.map { [ [ it.task ], models.any? ? models : [ it.model ].compact, nil, it ] }
|
|
836
836
|
else
|
|
837
|
-
plan_batches(bench, tasks).
|
|
837
|
+
plan_batches(bench, tasks).product(plan_models(bench, models), (1..attempts).to_a)
|
|
838
|
+
.map { |batch, batch_models, attempt| [ batch, batch_models, attempts > 1 ? attempt : nil, nil ] }
|
|
838
839
|
end
|
|
839
840
|
raise Thor::Error, "lemans-remote: --sync runs a single sandbox — #{jobs.size} invalid group(s) to retry" if options[:sync] && jobs.size > 1
|
|
840
841
|
|
|
@@ -851,8 +852,8 @@ module LemansRemote # :nodoc: all
|
|
|
851
852
|
|
|
852
853
|
fanout =
|
|
853
854
|
if retry_groups then "one per invalid (task, model) group"
|
|
854
|
-
elsif attempts > 1 then "#{jobs.size / attempts} task batch(es) × #{attempts} attempts"
|
|
855
|
-
else "one per task"
|
|
855
|
+
elsif attempts > 1 then "#{jobs.size / attempts} task × model batch(es) × #{attempts} attempts"
|
|
856
|
+
else "one per task × model"
|
|
856
857
|
end
|
|
857
858
|
say_status :fanout, "#{jobs.size} sandboxes, #{fanout}, #{options[:concurrency].to_i} at a time"
|
|
858
859
|
failures, retried = launch_batches(bench, jobs, provisioner)
|
|
@@ -867,15 +868,19 @@ module LemansRemote # :nodoc: all
|
|
|
867
868
|
option :history, type: :boolean, default: false, desc: "Also read the vault manifests (spins a short-lived helper sandbox)"
|
|
868
869
|
option :running, type: :boolean, default: false, desc: "Only show runs still in flight"
|
|
869
870
|
option :complete, type: :boolean, default: false, desc: "Only show finished runs (success, failed, or stale)"
|
|
870
|
-
option :watch,
|
|
871
|
+
option :watch, aliases: "-W", banner: "INTERVAL", lazy_default: "5s",
|
|
872
|
+
desc: "Redraw the table every INTERVAL (default 5s) until interrupted"
|
|
871
873
|
def status
|
|
872
874
|
no_watch_history = "lemans-remote: --watch cannot be combined with --history (each refresh would spin a helper sandbox)"
|
|
873
875
|
raise Thor::Error, no_watch_history if options[:watch] && options[:history]
|
|
874
876
|
return print_status unless options[:watch]
|
|
875
877
|
|
|
878
|
+
interval = seconds!(options[:watch])
|
|
879
|
+
raise Thor::Error, "lemans-remote: --watch interval must be positive" unless interval.positive?
|
|
880
|
+
|
|
876
881
|
loop do
|
|
877
882
|
print "\e[H\e[2J"
|
|
878
|
-
say "lemans-remote status — #{Time.now.strftime("%H:%M:%S")}, refreshing every
|
|
883
|
+
say "lemans-remote status — #{Time.now.strftime("%H:%M:%S")}, refreshing every #{interval.to_i}s (^C to stop)"
|
|
879
884
|
begin
|
|
880
885
|
unless print_status
|
|
881
886
|
say_status :done, "no running sandboxes — stopping watch", :green
|
|
@@ -884,7 +889,7 @@ module LemansRemote # :nodoc: all
|
|
|
884
889
|
rescue StandardError => e
|
|
885
890
|
say_status :error, e.message, :red
|
|
886
891
|
end
|
|
887
|
-
sleep
|
|
892
|
+
sleep interval
|
|
888
893
|
end
|
|
889
894
|
rescue Interrupt
|
|
890
895
|
say ""
|
|
@@ -1098,6 +1103,13 @@ module LemansRemote # :nodoc: all
|
|
|
1098
1103
|
expanded.map { [ it ] }
|
|
1099
1104
|
end
|
|
1100
1105
|
|
|
1106
|
+
def plan_models(bench, models)
|
|
1107
|
+
return [ models ] if options[:sync] || options[:run_in_band]
|
|
1108
|
+
|
|
1109
|
+
expanded = models.any? ? models : bench.models
|
|
1110
|
+
expanded.size > 1 ? expanded.map { [ it ] } : [ models ]
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1101
1113
|
def launch_batch(bench, tasks, models, provisioner, attempt: nil)
|
|
1102
1114
|
Runner.new(
|
|
1103
1115
|
bench: bench,
|
|
@@ -43,7 +43,7 @@ module Lemans
|
|
|
43
43
|
agent = agent_for(environment)
|
|
44
44
|
begin
|
|
45
45
|
agent.run(task.instruction)
|
|
46
|
-
rescue ::Miniswen::InfrastructureError => e
|
|
46
|
+
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
47
47
|
agent.partial_result(e.message)
|
|
48
48
|
end
|
|
49
49
|
end
|
|
@@ -92,7 +92,7 @@ module Lemans
|
|
|
92
92
|
**totals,
|
|
93
93
|
cost_usd: result.cost_usd,
|
|
94
94
|
# FIXME: need a better way to map Miniswen's cost source to Lemans'
|
|
95
|
-
cost_source: Result::CostSource.
|
|
95
|
+
cost_source: Result::CostSource.build(**result.cost_source.to_h)
|
|
96
96
|
)
|
|
97
97
|
end
|
|
98
98
|
|
|
@@ -47,7 +47,7 @@ module Lemans
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def finished(result)
|
|
50
|
-
status = result.scored? ?
|
|
50
|
+
status = result.scored? ? grade(result) : result.status.to_s
|
|
51
51
|
@shell.say_status STATUS_VERBS.fetch(result.status, result.status),
|
|
52
52
|
"#{result.task.ljust(@task_width)} #{status.ljust(12)} #{result.duration}s",
|
|
53
53
|
color(result)
|
|
@@ -55,6 +55,11 @@ module Lemans
|
|
|
55
55
|
@shell.say_status :error, first_line(result.detail), :red unless result.scored? || result.detail.nil?
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
def grade(result)
|
|
59
|
+
grade = "reward=#{result.reward.inspect}"
|
|
60
|
+
result.credit == result.reward ? grade : "#{grade} credit=#{result.credit.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
58
63
|
def first_line(detail) = detail.to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]
|
|
59
64
|
|
|
60
65
|
def color(result)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
# Re-grades stored results from the checks.json each trial left behind,
|
|
9
|
+
# against a mapping: every check of the task as `fail` (required) or
|
|
10
|
+
# `fail (allowed)` (extra), plus the grading section. Nothing runs.
|
|
11
|
+
class Regrade
|
|
12
|
+
ALLOWED = "fail (allowed)"
|
|
13
|
+
CHECKS = "checks.json"
|
|
14
|
+
|
|
15
|
+
Mapping = Struct.new(:checks, :base_credit, :points, keyword_init: true) do
|
|
16
|
+
def self.from_json(data)
|
|
17
|
+
checks = data["checks"] or raise ConfigError, "a mapping needs a `checks` section"
|
|
18
|
+
grading = data["grading"] || {}
|
|
19
|
+
declared = grading["points"] || {}
|
|
20
|
+
allowed = checks.select { |_, status| status == ALLOWED }.keys
|
|
21
|
+
new(checks:, base_credit: grading["base_credit"], points: allowed.to_h { [ it, declared.fetch(it, 1) ] })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def names = checks.keys.sort
|
|
25
|
+
|
|
26
|
+
def allowed?(check) = points.key?(check)
|
|
27
|
+
|
|
28
|
+
def grading = { base_credit:, points: }.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Change = Struct.new(:result, :reward, :credit, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Reads the grading schema off the test file without running it: every
|
|
34
|
+
# `def test_*` and ActiveSupport `test "..."` is a check, an
|
|
35
|
+
# `allow_failure` call inside makes it an extra worth its `points:`.
|
|
36
|
+
class TestScanner < Prism::Visitor
|
|
37
|
+
attr_reader :tests, :points, :base_credit
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
super
|
|
41
|
+
@scope = []
|
|
42
|
+
@tests = []
|
|
43
|
+
@points = {}
|
|
44
|
+
@current = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visit_module_node(node) = scoped(node) { super }
|
|
48
|
+
|
|
49
|
+
def visit_class_node(node) = scoped(node) { super }
|
|
50
|
+
|
|
51
|
+
def visit_def_node(node)
|
|
52
|
+
return super unless node.name.start_with?("test_")
|
|
53
|
+
|
|
54
|
+
within("#{@scope.join("::")}##{node.name}") { super }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_call_node(node)
|
|
58
|
+
case node.name
|
|
59
|
+
when :test
|
|
60
|
+
title = node.arguments&.arguments&.first
|
|
61
|
+
if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
|
|
62
|
+
return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}") { super }
|
|
63
|
+
end
|
|
64
|
+
when :allow_failure
|
|
65
|
+
@points[@current] ||= points_of(node) if @current
|
|
66
|
+
when :base_credit=
|
|
67
|
+
@base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
|
|
68
|
+
end
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def scoped(node)
|
|
75
|
+
@scope.push(node.constant_path.full_name)
|
|
76
|
+
yield
|
|
77
|
+
ensure
|
|
78
|
+
@scope.pop
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def within(check)
|
|
82
|
+
@tests << check
|
|
83
|
+
@current = check
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
@current = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def points_of(node)
|
|
90
|
+
keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
|
|
91
|
+
pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
|
|
92
|
+
pair ? pair.value.value : 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
def mapping_from_file(path) = Mapping.from_json(JSON.parse(File.read(path)))
|
|
98
|
+
|
|
99
|
+
def mapping_for(task)
|
|
100
|
+
local, = task.test_files.find { |_, remote| remote == "verification_test.rb" }
|
|
101
|
+
raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless local
|
|
102
|
+
|
|
103
|
+
scanner = TestScanner.new
|
|
104
|
+
Prism.parse_file(local.to_s).value.accept(scanner)
|
|
105
|
+
checks = scanner.tests.to_h { [ it, scanner.points.key?(it) ? ALLOWED : "fail" ] }
|
|
106
|
+
Mapping.new(checks:, base_credit: scanner.base_credit, points: scanner.points)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
attr_reader :store, :task, :mapping
|
|
111
|
+
|
|
112
|
+
def initialize(store, task, mapping:)
|
|
113
|
+
@store = store
|
|
114
|
+
@task = task
|
|
115
|
+
@mapping = mapping
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def results
|
|
119
|
+
@results ||= store.query(task:).select(&:scored?).sort_by { it.id.to_s }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A statically read mapping is only trusted once a stored checks.json
|
|
123
|
+
# names exactly its checks.
|
|
124
|
+
def verify_mapping!
|
|
125
|
+
stored = results.lazy.filter_map { checks_of(it) }.first
|
|
126
|
+
return unless stored
|
|
127
|
+
|
|
128
|
+
names = stored.fetch("checks", {}).keys.sort
|
|
129
|
+
return if names == mapping.names
|
|
130
|
+
|
|
131
|
+
raise ConfigError, "the checks read from verification_test.rb do not match the stored #{CHECKS} " \
|
|
132
|
+
"(missing: #{(names - mapping.names).inspect}, unexpected: #{(mapping.names - names).inspect}); " \
|
|
133
|
+
"pass --mapping with a #{CHECKS}-shaped file"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def execute!
|
|
137
|
+
changes = []
|
|
138
|
+
skipped = []
|
|
139
|
+
results.each do |result|
|
|
140
|
+
checks = checks_of(result)
|
|
141
|
+
next skipped << [ result, "no #{CHECKS}" ] unless checks
|
|
142
|
+
next skipped << [ result, "#{CHECKS} names other checks than the mapping" ] unless checks.fetch("checks", {}).keys.sort == mapping.names
|
|
143
|
+
|
|
144
|
+
change = regrade!(result, checks)
|
|
145
|
+
change ? changes << change : skipped << [ result, "unchanged" ]
|
|
146
|
+
end
|
|
147
|
+
[ changes, skipped ]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def checks_of(result)
|
|
153
|
+
raw = store.read_artifact(result, CHECKS)
|
|
154
|
+
raw && JSON.parse(raw)
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def regrade!(result, checks)
|
|
160
|
+
statuses = checks["checks"].to_h { |check, status| [ check, status_of(check, status) ] }
|
|
161
|
+
failures = statuses.reject { |_, status| status == "pass" || status == ALLOWED }.keys
|
|
162
|
+
allowed = checks.fetch("allowed_failures", {}).slice(*statuses.select { |_, status| status == ALLOWED }.keys)
|
|
163
|
+
updated = { checks: statuses, failures:, allowed_failures: allowed }
|
|
164
|
+
updated[:grading] = mapping.grading unless mapping.grading.empty?
|
|
165
|
+
reward = failures.empty? ? 1.0 : 0.0
|
|
166
|
+
credit = credit_of(statuses, reward)
|
|
167
|
+
return if reward == result.reward && credit == result.credit && JSON.parse(JSON.generate(updated)) == checks
|
|
168
|
+
|
|
169
|
+
store.save_artifact(result, "#{JSON.pretty_generate(updated)}\n", path: CHECKS, force: true)
|
|
170
|
+
change = Change.new(result:, reward: [ result.reward, reward ], credit: [ result.credit, credit ])
|
|
171
|
+
store.save(result.graded!(reward, credit:))
|
|
172
|
+
change
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def status_of(check, status)
|
|
176
|
+
return status unless status == "fail" || status == ALLOWED
|
|
177
|
+
|
|
178
|
+
mapping.allowed?(check) ? ALLOWED : "fail"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def credit_of(statuses, reward)
|
|
182
|
+
return reward if mapping.base_credit.nil?
|
|
183
|
+
return 0.0 if reward.zero?
|
|
184
|
+
|
|
185
|
+
total = mapping.points.values.sum
|
|
186
|
+
return reward if total.zero?
|
|
187
|
+
|
|
188
|
+
passed = mapping.points.sum { |check, value| statuses[check] == "pass" ? value : 0 }
|
|
189
|
+
(mapping.base_credit + (1 - mapping.base_credit) * (passed.to_f / total)).round(2)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -10,8 +10,8 @@ module Lemans
|
|
|
10
10
|
# task, agent, model — "task-model" reads as two columns.
|
|
11
11
|
class Aggregate
|
|
12
12
|
KEYS = %i[task agent model].freeze
|
|
13
|
-
METRICS = %i[score time cost steps tokens].freeze
|
|
14
|
-
METRIC_SOURCES = { time: :duration, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
13
|
+
METRICS = %i[score credit time cost steps tokens].freeze
|
|
14
|
+
METRIC_SOURCES = { credit: :credit, time: :duration, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
15
15
|
|
|
16
16
|
attr_reader :report, :keys
|
|
17
17
|
|
|
@@ -46,20 +46,15 @@ module Lemans
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def to_rows
|
|
49
|
-
|
|
49
|
+
metrics = report.fractional? ? METRICS : METRICS - [ :credit ]
|
|
50
|
+
[ keys.map(&:to_s) + metrics.map(&:to_s) ] +
|
|
50
51
|
@groups.map do |group|
|
|
51
|
-
keys.map { |key| display_key(key, group[key]) } +
|
|
52
|
-
"#{group[:solved]}/#{group[:attempts]}",
|
|
53
|
-
time(group[:duration]),
|
|
54
|
-
cost(group[:cost_usd]),
|
|
55
|
-
mean_display(group[:steps], 1),
|
|
56
|
-
mean_display(group[:tokens], 0)
|
|
57
|
-
]
|
|
52
|
+
keys.map { |key| display_key(key, group[key]) } + metrics.map { cell(it, group) }
|
|
58
53
|
end
|
|
59
54
|
end
|
|
60
55
|
|
|
61
56
|
def to_csv
|
|
62
|
-
columns = keys + %i[solved attempts duration cost_usd steps tokens]
|
|
57
|
+
columns = keys + %i[solved attempts credit duration cost_usd steps tokens]
|
|
63
58
|
CSV.generate do |csv|
|
|
64
59
|
csv << columns
|
|
65
60
|
@groups.each { |group| csv << columns.map { group[it] } }
|
|
@@ -78,6 +73,7 @@ module Lemans
|
|
|
78
73
|
keys.zip(values).to_h.merge(
|
|
79
74
|
solved: Report.tally(group)[:solved],
|
|
80
75
|
attempts: group.size,
|
|
76
|
+
credit: mean(group.filter_map { it[:credit] }),
|
|
81
77
|
duration: median(group.filter_map { it[:duration] }),
|
|
82
78
|
cost_usd: mean(group.filter_map { it[:cost_usd] }),
|
|
83
79
|
steps: mean(group.filter_map { it[:steps] }),
|
|
@@ -85,6 +81,17 @@ module Lemans
|
|
|
85
81
|
)
|
|
86
82
|
end
|
|
87
83
|
|
|
84
|
+
def cell(metric, group)
|
|
85
|
+
case metric
|
|
86
|
+
when :score then "#{group[:solved]}/#{group[:attempts]}"
|
|
87
|
+
when :credit then mean_display(group[:credit], 2)
|
|
88
|
+
when :time then time(group[:duration])
|
|
89
|
+
when :cost then cost(group[:cost_usd])
|
|
90
|
+
when :steps then mean_display(group[:steps], 1)
|
|
91
|
+
when :tokens then mean_display(group[:tokens], 0)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
88
95
|
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
89
96
|
|
|
90
97
|
def median(values)
|
data/lib/lemans/cli/report.rb
CHANGED
|
@@ -7,16 +7,16 @@ module Lemans
|
|
|
7
7
|
# Renders stored results as a table or CSV. The store is the source of
|
|
8
8
|
# truth; rows are plain hashes derived from Result records.
|
|
9
9
|
class Report
|
|
10
|
-
COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration started_at trial
|
|
11
|
-
detail].freeze
|
|
12
|
-
TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration trial].freeze
|
|
13
|
-
NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration].freeze
|
|
10
|
+
COLUMNS = %i[task agent model reward credit outcome scored cost_usd steps tokens duration started_at trial
|
|
11
|
+
tags detail].freeze
|
|
12
|
+
TABLE_COLUMNS = %i[task agent model reward credit outcome cost_usd steps tokens duration trial].freeze
|
|
13
|
+
NUMERIC_COLUMNS = %i[reward credit cost_usd steps tokens duration].freeze
|
|
14
14
|
|
|
15
15
|
attr_reader :rows, :unreadable
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
|
-
def load(store, tags: nil, names: nil)
|
|
19
|
-
rows = store.query(task: names, tags:).map { row_from(it) }
|
|
18
|
+
def load(store, tags: nil, names: nil, metadata: nil)
|
|
19
|
+
rows = store.query(task: names, tags:, metadata:).map { row_from(it) }
|
|
20
20
|
new(rows.sort_by { [ it[:task].to_s, it[:started_at].to_s, it[:trial].to_s ] },
|
|
21
21
|
unreadable: store.unreadable.size)
|
|
22
22
|
end
|
|
@@ -28,6 +28,7 @@ module Lemans
|
|
|
28
28
|
agent: result.agent,
|
|
29
29
|
model: result.model,
|
|
30
30
|
reward: result.reward,
|
|
31
|
+
credit: result.credit,
|
|
31
32
|
outcome: result.status,
|
|
32
33
|
scored: result.scored?,
|
|
33
34
|
detail: result.detail,
|
|
@@ -59,6 +60,18 @@ module Lemans
|
|
|
59
60
|
}
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# `--metadata category:full-features`, repeated, means every pair must match.
|
|
64
|
+
def metadata_filter(specs)
|
|
65
|
+
return if specs.nil? || specs.empty?
|
|
66
|
+
|
|
67
|
+
specs.to_h do |spec|
|
|
68
|
+
key, value = spec.split(":", 2)
|
|
69
|
+
raise ConfigError, "--metadata: expected key:value (got #{spec.inspect})" if value.nil? || key.empty?
|
|
70
|
+
|
|
71
|
+
[ key, value ]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
62
75
|
# One sorting rule for every view: validate the column name and keep
|
|
63
76
|
# rows that never measured the value at the bottom.
|
|
64
77
|
def sort_column(name, allowed:)
|
|
@@ -98,10 +111,14 @@ module Lemans
|
|
|
98
111
|
self.class.tally(rows).merge(cost_usd: rows.sum { it[:cost_usd].to_f })
|
|
99
112
|
end
|
|
100
113
|
|
|
114
|
+
def fractional? = rows.any? { it[:credit] && it[:credit] != it[:reward] }
|
|
115
|
+
|
|
116
|
+
def table_columns = fractional? ? TABLE_COLUMNS : TABLE_COLUMNS - [ :credit ]
|
|
117
|
+
|
|
101
118
|
def to_rows
|
|
102
|
-
[
|
|
119
|
+
[ table_columns.map(&:to_s) ] +
|
|
103
120
|
rows.map do |row|
|
|
104
|
-
|
|
121
|
+
table_columns.map do |column|
|
|
105
122
|
display(column == :model ? short_model(row[:model]) : row[column])
|
|
106
123
|
end
|
|
107
124
|
end
|
data/lib/lemans/cli.rb
CHANGED
|
@@ -127,17 +127,50 @@ module Lemans
|
|
|
127
127
|
raise Thor::Error, "lemans: #{e.message}"
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
|
|
131
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
132
|
+
option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
|
|
133
|
+
option :runs_dir, default: "./runs", desc: "Directory holding run directories"
|
|
134
|
+
option :mapping, banner: "PATH",
|
|
135
|
+
desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
|
|
136
|
+
"instead of reading verification_test.rb"
|
|
137
|
+
def regrade
|
|
138
|
+
store = Stores::FS.new(options[:runs_dir])
|
|
139
|
+
tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
|
|
140
|
+
raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
|
|
141
|
+
|
|
142
|
+
tasks.each do |task|
|
|
143
|
+
mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
|
|
144
|
+
regrade = Regrade.new(store, task.name, mapping:)
|
|
145
|
+
regrade.verify_mapping! unless options[:mapping]
|
|
146
|
+
|
|
147
|
+
changes, skipped = regrade.execute!
|
|
148
|
+
changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
|
|
149
|
+
skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
|
|
150
|
+
say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
say ""
|
|
154
|
+
say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
|
|
155
|
+
print_report Report.load(store, names: tasks.map(&:name))
|
|
156
|
+
rescue ConfigError => e
|
|
157
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
130
160
|
desc "report", "Summarize run results as a table or CSV"
|
|
131
161
|
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
132
162
|
option :tag, desc: "Only runs whose result carries this tag", repeatable: true
|
|
133
163
|
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
164
|
+
option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
|
|
165
|
+
repeatable: true
|
|
134
166
|
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
135
167
|
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
136
168
|
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
137
169
|
option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
|
|
138
170
|
def report
|
|
139
171
|
store = Stores::FS.new(options[:runs_dir])
|
|
140
|
-
results = Report.load(store, tags: options[:tag], names: options[:task]
|
|
172
|
+
results = Report.load(store, tags: options[:tag], names: options[:task],
|
|
173
|
+
metadata: Report.metadata_filter(options[:metadata]))
|
|
141
174
|
raise Thor::Error, "lemans: no matching results found" if results.empty?
|
|
142
175
|
|
|
143
176
|
results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
@@ -163,6 +196,10 @@ module Lemans
|
|
|
163
196
|
raise Thor::Error, "lemans: no matching tasks"
|
|
164
197
|
end
|
|
165
198
|
|
|
199
|
+
def grade_change(change)
|
|
200
|
+
%i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }.join(" ")
|
|
201
|
+
end
|
|
202
|
+
|
|
166
203
|
def print_report(report)
|
|
167
204
|
print_table report.to_rows
|
|
168
205
|
color = report.summary[:invalid].positive? ? :red : nil
|
|
@@ -21,6 +21,7 @@ module Lemans
|
|
|
21
21
|
|
|
22
22
|
conf.workdir = absolute_path!(data["workdir"]) if data["workdir"]
|
|
23
23
|
conf.build_timeout = seconds!(data["build_timeout"]) if data["build_timeout"]
|
|
24
|
+
conf.sandbox_ttl = seconds!(data["sandbox_ttl"]) if data["sandbox_ttl"]
|
|
24
25
|
conf.network = NetworkPolicy.from_config(data["network"]) if data["network"]
|
|
25
26
|
|
|
26
27
|
conf.resources.cpus = integer!(data.dig("resources", "cpus")) if data.dig("resources", "cpus")
|
|
@@ -42,7 +43,7 @@ module Lemans
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
attr_accessor :image, :dockerfile, :workdir, :backend,
|
|
45
|
-
:resources, :build_timeout, :network, :profiles
|
|
46
|
+
:resources, :build_timeout, :sandbox_ttl, :network, :profiles
|
|
46
47
|
|
|
47
48
|
def initialize
|
|
48
49
|
@image = nil
|
|
@@ -52,6 +53,7 @@ module Lemans
|
|
|
52
53
|
@workdir = "/app"
|
|
53
54
|
@resources = Resources.new(cpus: 2, memory: 2048, storage: 5120)
|
|
54
55
|
@build_timeout = 10 * 60
|
|
56
|
+
@sandbox_ttl = nil
|
|
55
57
|
@network = NetworkPolicy.new
|
|
56
58
|
end
|
|
57
59
|
|
|
@@ -63,6 +65,7 @@ module Lemans
|
|
|
63
65
|
"profiles" => profiles.transform_values { { "image" => it.image, "dockerfile" => it.dockerfile&.to_s }.compact },
|
|
64
66
|
"workdir" => workdir,
|
|
65
67
|
"build_timeout" => build_timeout,
|
|
68
|
+
"sandbox_ttl" => sandbox_ttl,
|
|
66
69
|
"network" => network.to_h,
|
|
67
70
|
"resources" => resources.to_h.transform_keys(&:to_s)
|
|
68
71
|
}.compact
|
data/lib/lemans/config.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Lemans
|
|
|
33
33
|
contents = YAML.safe_load_file(config_path.to_s, aliases: true) || {}
|
|
34
34
|
raise ConfigError, "#{path}: #{config_name} must be a mapping of sections" unless contents.is_a?(Hash)
|
|
35
35
|
|
|
36
|
-
root = Pathname(path)
|
|
36
|
+
root = Pathname(path).expand_path
|
|
37
37
|
|
|
38
38
|
parent = load_file(root.join(contents["inherit_from"])) if contents["inherit_from"]
|
|
39
39
|
|
data/lib/lemans/environment.rb
CHANGED
|
@@ -13,17 +13,20 @@ module Lemans
|
|
|
13
13
|
|
|
14
14
|
DEFAULT_TIMEOUT = 60
|
|
15
15
|
|
|
16
|
-
attr_reader :image, :resources, :network, :env, :labels, :build_timeout
|
|
16
|
+
attr_reader :image, :resources, :network, :env, :labels, :build_timeout, :ttl
|
|
17
17
|
|
|
18
18
|
# `labels` is backend-agnostic trial metadata (task, trial id, phase);
|
|
19
|
-
# every backend receives it even if it has nowhere to put it.
|
|
20
|
-
|
|
19
|
+
# every backend receives it even if it has nowhere to put it. `ttl` is
|
|
20
|
+
# the longest the sandbox is expected to live: a backend that reaps
|
|
21
|
+
# sandboxes on a clock must not reap this one sooner.
|
|
22
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout: nil, ttl: nil)
|
|
21
23
|
@image = image
|
|
22
24
|
@resources = resources
|
|
23
25
|
@network = network
|
|
24
26
|
@env = env
|
|
25
27
|
@labels = labels
|
|
26
28
|
@build_timeout = build_timeout
|
|
29
|
+
@ttl = ttl
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# Build the image and bring the sandbox up under the network policy it was
|
|
@@ -12,7 +12,7 @@ module Lemans
|
|
|
12
12
|
# instead of wrapping them, so both dialects have to be caught.
|
|
13
13
|
SDK_ERRORS = [ ::Daytona::Sdk::Error, *::Daytona::Sdk::API_ERROR_CLASSES ].freeze
|
|
14
14
|
|
|
15
|
-
READ_ATTEMPTS =
|
|
15
|
+
READ_ATTEMPTS = 5
|
|
16
16
|
RETRY_DELAY_SEC = 2
|
|
17
17
|
|
|
18
18
|
private
|
|
@@ -25,7 +25,7 @@ module Lemans
|
|
|
25
25
|
attempts += 1
|
|
26
26
|
raise if attempts >= READ_ATTEMPTS || !retryable?(e)
|
|
27
27
|
|
|
28
|
-
sleep RETRY_DELAY_SEC
|
|
28
|
+
sleep RETRY_DELAY_SEC * 2**(attempts - 1)
|
|
29
29
|
retry
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -33,9 +33,11 @@ module Lemans
|
|
|
33
33
|
# Transport failures surface as status 0 (libcurl stamps refused/reset/
|
|
34
34
|
# DNS with code 0) or none, and throttling and server errors heal on
|
|
35
35
|
# their own; any other 4xx would fail the same way again.
|
|
36
|
+
# A 408 is the daemon giving up on a read that outran its own exec
|
|
37
|
+
# timeout; a sandbox that wedged and recovered serves it fine next time.
|
|
36
38
|
def retryable?(error)
|
|
37
39
|
status = status_code(error)
|
|
38
|
-
status.nil? || status.zero? || status == 429 || status >= 500
|
|
40
|
+
status.nil? || status.zero? || status == 408 || status == 429 || status >= 500
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def status_code(error)
|
|
@@ -30,7 +30,7 @@ module Lemans
|
|
|
30
30
|
if timeout && timeout > SHORT_COMMAND_SEC
|
|
31
31
|
exec_in_session(command, timeout: timeout, env: env)
|
|
32
32
|
else
|
|
33
|
-
|
|
33
|
+
exec_short(command, timeout: timeout, env: env)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
Environment::ExecResult.new(command: command, duration: (now - started).round(3), **response)
|
|
@@ -51,6 +51,16 @@ module Lemans
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A 408 means the daemon already killed the command at its budget, so
|
|
55
|
+
# the model reads a plain timeout, the same as it would on docker.
|
|
56
|
+
def exec_short(command, timeout:, env:)
|
|
57
|
+
exec_directly(command, timeout: timeout, env: env)
|
|
58
|
+
rescue *SDK_ERRORS => e
|
|
59
|
+
raise unless status_code(e) == 408
|
|
60
|
+
|
|
61
|
+
{ exit_code: 124, output: "<command timed out after #{timeout} seconds>" }
|
|
62
|
+
end
|
|
63
|
+
|
|
54
64
|
def exec_directly(command, timeout:, env:)
|
|
55
65
|
response = sandbox.process.exec(
|
|
56
66
|
command: command,
|
|
@@ -9,8 +9,6 @@ module Lemans
|
|
|
9
9
|
# Daytona sandboxes. Daytona builds images server-side into reusable content-named
|
|
10
10
|
# snapshots and enforces the network policy itself.
|
|
11
11
|
class Daytona < Environment
|
|
12
|
-
TTL_MINUTES = 120
|
|
13
|
-
|
|
14
12
|
DEFAULT_BUILD_TIMEOUT = 600
|
|
15
13
|
|
|
16
14
|
# Workspace tarballs ride uploads/downloads, so transfers get their own
|
|
@@ -41,14 +39,16 @@ module Lemans
|
|
|
41
39
|
config
|
|
42
40
|
end
|
|
43
41
|
|
|
44
|
-
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
|
|
45
|
-
super(image:, resources:, network:, env:, labels:,
|
|
42
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: 3600)
|
|
43
|
+
super(image:, resources:, network:, env:, labels:, ttl:,
|
|
46
44
|
build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
|
|
47
45
|
@logger = logger
|
|
46
|
+
@started_at = nil
|
|
48
47
|
end
|
|
49
48
|
|
|
50
49
|
def start
|
|
51
50
|
@sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
|
|
51
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
52
|
@shell = Shell.new(sandbox)
|
|
53
53
|
self
|
|
54
54
|
rescue *Retries::SDK_ERRORS => e
|
|
@@ -61,7 +61,7 @@ module Lemans
|
|
|
61
61
|
def exec(command, timeout: nil, env: {})
|
|
62
62
|
@shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
|
|
63
63
|
rescue *Retries::SDK_ERRORS => e
|
|
64
|
-
raise InfrastructureError, "daytona: exec failed: #{e.message}"
|
|
64
|
+
raise InfrastructureError, "daytona: exec failed#{ttl_note}: #{e.message}"
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def upload(local_path, remote_path)
|
|
@@ -127,11 +127,21 @@ module Lemans
|
|
|
127
127
|
auto_delete_interval: 60,
|
|
128
128
|
# A real ceiling: without it a harness that dies mid-run leaves a
|
|
129
129
|
# running sandbox billing forever.
|
|
130
|
-
ttl_minutes:
|
|
130
|
+
ttl_minutes: ttl_minutes,
|
|
131
131
|
**network_kwargs(network)
|
|
132
132
|
)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
def ttl_minutes = (ttl / 60.0).ceil
|
|
136
|
+
|
|
137
|
+
# Daytona destroys a sandbox at its TTL whatever it is doing; the next
|
|
138
|
+
# exec then fails with a vague "is the Sandbox started?".
|
|
139
|
+
def ttl_note
|
|
140
|
+
return "" unless @started_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at > ttl
|
|
141
|
+
|
|
142
|
+
" after the sandbox's #{ttl_minutes}m TTL expired (raise environment.sandbox_ttl)"
|
|
143
|
+
end
|
|
144
|
+
|
|
135
145
|
def snapshot_store
|
|
136
146
|
SnapshotStore.new(client:, image:, resources:, build_timeout:, logger: @logger)
|
|
137
147
|
end
|
|
@@ -15,8 +15,8 @@ module Lemans
|
|
|
15
15
|
|
|
16
16
|
attr_reader :container
|
|
17
17
|
|
|
18
|
-
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
|
|
19
|
-
super(image:, resources:, network:, env:, labels:,
|
|
18
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: nil)
|
|
19
|
+
super(image:, resources:, network:, env:, labels:, ttl:,
|
|
20
20
|
build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
|
|
21
21
|
@logger = logger
|
|
22
22
|
@name = "lemans-#{SecureRandom.hex(6)}"
|
data/lib/lemans/result.rb
CHANGED
|
@@ -51,7 +51,12 @@ module Lemans
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
CostSource = Data.define(:name, :model, :priced_as, :registry)
|
|
54
|
+
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
55
|
+
# Build a cost source record from a possibly partial Hash
|
|
56
|
+
def self.build(**source)
|
|
57
|
+
new(**self.members.to_h { [ it, nil ] }, **source)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
55
60
|
|
|
56
61
|
Usage = Data.define(
|
|
57
62
|
:input_tokens, :output_tokens,
|
|
@@ -80,7 +85,7 @@ module Lemans
|
|
|
80
85
|
def Usage.from_json(data)
|
|
81
86
|
# Older files carry a partial cost_source (just the name).
|
|
82
87
|
if (source = data[:cost_source])
|
|
83
|
-
cost_source = CostSource.
|
|
88
|
+
cost_source = CostSource.build(**source)
|
|
84
89
|
end
|
|
85
90
|
new(
|
|
86
91
|
input_tokens: data[:input_tokens],
|
|
@@ -150,7 +155,7 @@ module Lemans
|
|
|
150
155
|
attr_reader :phases, :steps
|
|
151
156
|
|
|
152
157
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
153
|
-
attr_reader :reward, :outcome, :usage
|
|
158
|
+
attr_reader :reward, :credit, :outcome, :usage
|
|
154
159
|
|
|
155
160
|
def initialize(task:, agent:, model:, id: nil, index: nil,
|
|
156
161
|
profile_digest: nil, task_digest: nil, revision: nil)
|
|
@@ -217,8 +222,9 @@ module Lemans
|
|
|
217
222
|
completed!(outcome, aggregate_usage)
|
|
218
223
|
end
|
|
219
224
|
|
|
220
|
-
def graded!(reward)
|
|
225
|
+
def graded!(reward, credit: reward)
|
|
221
226
|
@reward = reward
|
|
227
|
+
@credit = credit
|
|
222
228
|
self
|
|
223
229
|
end
|
|
224
230
|
|
|
@@ -229,6 +235,7 @@ module Lemans
|
|
|
229
235
|
|
|
230
236
|
@outcome = Outcome.new(reason, detail)
|
|
231
237
|
@reward = nil
|
|
238
|
+
@credit = nil
|
|
232
239
|
self
|
|
233
240
|
end
|
|
234
241
|
|
|
@@ -241,7 +248,7 @@ module Lemans
|
|
|
241
248
|
lemans_version: VERSION,
|
|
242
249
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
243
250
|
steps: steps&.map(&:as_json),
|
|
244
|
-
reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
251
|
+
reward:, credit:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
245
252
|
started_at: started_at&.iso8601,
|
|
246
253
|
finished_at: finished_at&.iso8601
|
|
247
254
|
}.compact
|
|
@@ -258,10 +265,12 @@ module Lemans
|
|
|
258
265
|
result.tags = data[:tags] || []
|
|
259
266
|
result.metadata = data[:metadata] || {}
|
|
260
267
|
phases_from(data).each { result.phases << it }
|
|
268
|
+
|
|
261
269
|
# Steps first: the stored outcome/usage below override the aggregates.
|
|
262
270
|
data[:steps]&.map { Step.from_json(it) }&.each do |step|
|
|
263
271
|
result.step_completed!(step.outcome, step.usage, duration: step.duration)
|
|
264
272
|
end
|
|
273
|
+
|
|
265
274
|
if data[:outcome]
|
|
266
275
|
result.completed!(
|
|
267
276
|
Outcome.from_json(data[:outcome]),
|
|
@@ -271,7 +280,8 @@ module Lemans
|
|
|
271
280
|
duration: data[:duration] || data[:duration_sec]
|
|
272
281
|
)
|
|
273
282
|
end
|
|
274
|
-
|
|
283
|
+
|
|
284
|
+
result.graded!(data[:reward], credit: data[:credit] || data[:reward]) unless data[:reward].nil?
|
|
275
285
|
result
|
|
276
286
|
end
|
|
277
287
|
|
data/lib/lemans/store.rb
CHANGED
|
@@ -37,7 +37,13 @@ module Lemans
|
|
|
37
37
|
|
|
38
38
|
# Persist the result's file artifact
|
|
39
39
|
# (contents could be eiher IO (file) or text).
|
|
40
|
-
|
|
40
|
+
# An existing artifact is kept unless force is set.
|
|
41
|
+
def save_artifact(result, contents, path:, force: false)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the artifact's text, nil when the result never stored it
|
|
46
|
+
def read_artifact(result, path)
|
|
41
47
|
raise NotImplementedError
|
|
42
48
|
end
|
|
43
49
|
end
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -30,12 +30,13 @@ module Lemans
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# The file system keeps no index, so filtering happens in memory.
|
|
33
|
-
def query(task: nil, agent: nil, model: nil, tags: nil)
|
|
33
|
+
def query(task: nil, agent: nil, model: nil, tags: nil, metadata: nil)
|
|
34
34
|
results = fetch
|
|
35
35
|
results.select! { Array(task).include?(it.task) } if task
|
|
36
36
|
results.select! { it.agent == agent } if agent
|
|
37
37
|
results.select! { it.model == model } if model
|
|
38
38
|
results.select! { Array(tags).intersect?(it.tags) } if tags
|
|
39
|
+
results.select! { |result| metadata.all? { |key, value| result.metadata.transform_keys(&:to_s)[key].to_s == value } } if metadata
|
|
39
40
|
results
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -69,9 +70,9 @@ module Lemans
|
|
|
69
70
|
raise ConfigError, "cannot record trial #{result.id}: #{e.message}"
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
def save_artifact(result, contents, path:)
|
|
73
|
+
def save_artifact(result, contents, path:, force: false)
|
|
73
74
|
destination = result_dir(result).join(path)
|
|
74
|
-
if destination.exist?
|
|
75
|
+
if destination.exist? && !force
|
|
75
76
|
warn "lemans: artifact #{path} collides with an existing file and was dropped"
|
|
76
77
|
return
|
|
77
78
|
end
|
|
@@ -84,6 +85,11 @@ module Lemans
|
|
|
84
85
|
nil
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
def read_artifact(result, path)
|
|
89
|
+
file = result_dir(result).join(path)
|
|
90
|
+
file.read if file.file?
|
|
91
|
+
end
|
|
92
|
+
|
|
87
93
|
private
|
|
88
94
|
|
|
89
95
|
def filtered(text) = filterer ? filterer.filter(text) : text
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
# Loaded when a verifier command opts in with `ruby -report-lemans …`
|
|
4
4
|
# (that is `-r eport-lemans`, resolved from /tests on the LOAD_PATH).
|
|
5
5
|
module LemansReport
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :base_credit
|
|
8
|
+
|
|
9
|
+
def points = @points ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
def self.registered? = @registered
|
|
7
13
|
|
|
8
14
|
def self.register
|
|
@@ -9,7 +9,11 @@ module LemansReport
|
|
|
9
9
|
|
|
10
10
|
module Assertions
|
|
11
11
|
# Allow failing minitest assertions inside the block (but halt and record them as allowed failures not affected the grade)
|
|
12
|
-
def allow_failure
|
|
12
|
+
def allow_failure(points: 1)
|
|
13
|
+
check = "#{self.class}##{name}"
|
|
14
|
+
raise ArgumentError, "#{check} calls allow_failure twice: one allowed failure per test" if LemansReport.points.key?(check)
|
|
15
|
+
|
|
16
|
+
LemansReport.points[check] = points
|
|
13
17
|
yield
|
|
14
18
|
rescue Minitest::Skip
|
|
15
19
|
raise
|
|
@@ -41,9 +45,12 @@ module LemansReport
|
|
|
41
45
|
File.write(
|
|
42
46
|
File.join(@dir, "checks.json"),
|
|
43
47
|
JSON.pretty_generate(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
{
|
|
49
|
+
checks: checks,
|
|
50
|
+
failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
|
|
51
|
+
allowed_failures: allowed,
|
|
52
|
+
grading:
|
|
53
|
+
}.compact
|
|
47
54
|
)
|
|
48
55
|
)
|
|
49
56
|
end
|
|
@@ -62,6 +69,15 @@ module LemansReport
|
|
|
62
69
|
dir && result.source_location.first.to_s.start_with?("#{dir.chomp("/")}/")
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
def grading
|
|
73
|
+
prior = existing.fetch("grading", {})
|
|
74
|
+
base_credit = LemansReport.base_credit || prior["base_credit"]
|
|
75
|
+
points = prior.fetch("points", {}).merge(LemansReport.points).sort.to_h
|
|
76
|
+
return if base_credit.nil? && points.empty?
|
|
77
|
+
|
|
78
|
+
{ base_credit:, points: }.compact
|
|
79
|
+
end
|
|
80
|
+
|
|
65
81
|
def existing
|
|
66
82
|
JSON.parse(File.read(File.join(@dir, "checks.json")))
|
|
67
83
|
rescue StandardError
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "pathname"
|
|
4
5
|
require "shellwords"
|
|
5
6
|
require "tmpdir"
|
|
@@ -9,7 +10,7 @@ module Lemans
|
|
|
9
10
|
# Verifies a trial in the sandbox the agent worked in, after Trial has closed
|
|
10
11
|
# its network. The tests are uploaded fresh at verification time, never before.
|
|
11
12
|
class Verifier
|
|
12
|
-
Verification = Data.define(:reward, :logs)
|
|
13
|
+
Verification = Data.define(:reward, :credit, :logs)
|
|
13
14
|
|
|
14
15
|
REWARD_RANGE = (0.0..1.0)
|
|
15
16
|
|
|
@@ -40,7 +41,7 @@ module Lemans
|
|
|
40
41
|
prepare_env!
|
|
41
42
|
|
|
42
43
|
# 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
|
+
return Verification.new(reward: 0.0, credit: 0.0, logs: TAMPERED) unless snapshot.restore!
|
|
44
45
|
|
|
45
46
|
verification = run_tests!
|
|
46
47
|
|
|
@@ -98,7 +99,8 @@ module Lemans
|
|
|
98
99
|
|
|
99
100
|
result = environment.exec(command, timeout:, env:)
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
reward = read_reward(result)
|
|
103
|
+
Verification.new(reward:, credit: read_credit(reward), logs: result.output.to_s)
|
|
102
104
|
end
|
|
103
105
|
|
|
104
106
|
def verifier_script
|
|
@@ -124,6 +126,31 @@ module Lemans
|
|
|
124
126
|
value
|
|
125
127
|
end
|
|
126
128
|
|
|
129
|
+
def read_credit(reward)
|
|
130
|
+
path = File.join(task.verifier.logs_dir, "checks.json")
|
|
131
|
+
return reward unless environment.exec("test -e #{Shellwords.escape(path)}").success?
|
|
132
|
+
|
|
133
|
+
result = environment.exec("cat #{Shellwords.escape(path)}")
|
|
134
|
+
raise VerifierError, "could not read #{path}: #{result.output.to_s[0, 500]}" unless result.success?
|
|
135
|
+
|
|
136
|
+
checks = begin
|
|
137
|
+
JSON.parse(result.output.to_s)
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
raise VerifierError, "#{path} is not JSON: #{e.message[0, 500]}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
grading = checks["grading"]
|
|
143
|
+
return reward unless grading && (base_credit = grading["base_credit"])
|
|
144
|
+
return 0.0 if reward.zero?
|
|
145
|
+
|
|
146
|
+
points = grading.fetch("points", {})
|
|
147
|
+
total = points.values.sum
|
|
148
|
+
return reward if total.zero?
|
|
149
|
+
|
|
150
|
+
passed = points.sum { |check, value| checks.dig("checks", check) == "pass" ? value : 0 }
|
|
151
|
+
(base_credit + (1 - base_credit) * (passed.to_f / total)).round(2)
|
|
152
|
+
end
|
|
153
|
+
|
|
127
154
|
def reward_from_exit(command_result)
|
|
128
155
|
case command_result.exit_code
|
|
129
156
|
when 0 then 1.0
|
data/lib/lemans/trial.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Lemans
|
|
|
35
35
|
resources: task.environment.resources,
|
|
36
36
|
network: task.environment.network,
|
|
37
37
|
build_timeout: task.environment.build_timeout,
|
|
38
|
+
ttl: sandbox_ttl,
|
|
38
39
|
labels: {
|
|
39
40
|
"lemans.task" => task.name,
|
|
40
41
|
"lemans.trial" => self.result.id,
|
|
@@ -117,7 +118,7 @@ module Lemans
|
|
|
117
118
|
store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
|
|
118
119
|
|
|
119
120
|
if step_task.final_step?
|
|
120
|
-
result.graded!(verification.reward)
|
|
121
|
+
result.graded!(verification.reward, credit: verification.credit)
|
|
121
122
|
elsif verification.reward.zero?
|
|
122
123
|
result.graded!(0.0)
|
|
123
124
|
throw :halt
|
|
@@ -182,6 +183,11 @@ module Lemans
|
|
|
182
183
|
[ *pre, current_step_index, last ].join(".")
|
|
183
184
|
end
|
|
184
185
|
|
|
186
|
+
def sandbox_ttl
|
|
187
|
+
task.environment.sandbox_ttl ||
|
|
188
|
+
[ 3600, task.environment.build_timeout + task.steps * (config.agent.timeout + task.verifier.timeout) + 600 ].max
|
|
189
|
+
end
|
|
190
|
+
|
|
185
191
|
def check_cost_limit!
|
|
186
192
|
limit = config.agent.cost_limit
|
|
187
193
|
cost = result.usage&.cost_usd
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Miniswen
|
|
|
27
27
|
# The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
|
|
28
28
|
CACHE_CONTROL = { type: "ephemeral" }.freeze
|
|
29
29
|
|
|
30
|
+
# Left unset, the provider reserves the model's advertised maximum output
|
|
31
|
+
# ahead of the prompt (qwen3.8-27b: 128K of a 256K window), halving the
|
|
32
|
+
# history an agent turn of a few hundred tokens can build on.
|
|
33
|
+
MAX_OUTPUT_TOKENS = 32_768
|
|
34
|
+
|
|
30
35
|
EXEC_ENV = {
|
|
31
36
|
"PAGER" => "cat",
|
|
32
37
|
"MANPAGER" => "cat",
|
|
@@ -224,7 +229,7 @@ module Miniswen
|
|
|
224
229
|
end
|
|
225
230
|
|
|
226
231
|
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
227
|
-
def to_h = { name
|
|
232
|
+
def to_h = { name:, model:, priced_as:, registry: }.compact
|
|
228
233
|
end
|
|
229
234
|
|
|
230
235
|
attr_reader :messages, :environment
|
|
@@ -503,7 +508,7 @@ module Miniswen
|
|
|
503
508
|
tools: { bash: @bash_tool },
|
|
504
509
|
temperature: nil,
|
|
505
510
|
model: model_info,
|
|
506
|
-
params: routing_params,
|
|
511
|
+
params: routing_params.merge(output_cap_params(model_info)),
|
|
507
512
|
thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
|
|
508
513
|
)
|
|
509
514
|
payload(response)
|
|
@@ -543,6 +548,20 @@ module Miniswen
|
|
|
543
548
|
|
|
544
549
|
def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
|
|
545
550
|
|
|
551
|
+
# OpenAI itself retired `max_tokens` for its reasoning models; the
|
|
552
|
+
# OpenAI-compatible providers and Anthropic still read it.
|
|
553
|
+
def output_cap_params(model_info)
|
|
554
|
+
cap = [ info&.max_tokens, MAX_OUTPUT_TOKENS ].compact.min
|
|
555
|
+
provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
|
|
556
|
+
if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
|
|
557
|
+
{ max_completion_tokens: cap }
|
|
558
|
+
elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
|
|
559
|
+
{ max_tokens: cap }
|
|
560
|
+
else
|
|
561
|
+
{}
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
546
565
|
def cost_source
|
|
547
566
|
if local?
|
|
548
567
|
return CostSource.new(name: :local_provider, model: @model,
|
data/lib/miniswen/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lemans
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -163,6 +163,7 @@ files:
|
|
|
163
163
|
- lib/lemans/cli.rb
|
|
164
164
|
- lib/lemans/cli/board_reporter.rb
|
|
165
165
|
- lib/lemans/cli/progress_reporter.rb
|
|
166
|
+
- lib/lemans/cli/regrade.rb
|
|
166
167
|
- lib/lemans/cli/report.rb
|
|
167
168
|
- lib/lemans/cli/report/aggregate.rb
|
|
168
169
|
- lib/lemans/cli/templates/bench/README.md
|