lemans 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f74cd8839b49ac3d579c9e8999e398d867b4b4d6510bae6f28546b00e5e427cd
4
- data.tar.gz: bac93bb29cb5d9c6ef31c99aa13dd845ced0b022088876390e8bda5f075857ee
3
+ metadata.gz: 94b9194a7931b789ba1cb675cfc8d81e0367f8b73754ce1fa11cbff33fff7174
4
+ data.tar.gz: 4b61d46c323ddd60cb016402d757cb53db56c0adb372709d73ebeec369789e41
5
5
  SHA512:
6
- metadata.gz: 65772ed04c753b4109fef2f57d0351506c84cff8b5724f097d634c2f95e2a240b37013a3e86b2cdb6af3893ff630a36a1feb9418305267e37394f7fffbe4e273
7
- data.tar.gz: 5adfd42c14059d7ca8863617cb1c13ae504cf00c24f82f1dba348f1f89a02c8c7d422de987d5cd714abe14f869883bbd4f3828a8e397083f3464b3c34ee215c7
6
+ metadata.gz: db72821e26aa89d9bb9922134042e38c3041a2a8df32669c7e3d69d9ad5f06ebd2fddbfcee89b0f38bc4a4a0f263190858fc24ee86aac5e51984ee1b62a7a6b1
7
+ data.tar.gz: 1b144ff16c02f963031bf078f03f465508edad3f0ed061e0879905ca189143ad14e6a008a0f9fbaf2d0bea28e2124f82f84eea3f5596bb771251c868e675cd50
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.1] - 2026-09-04
4
+
5
+ - `agent.max_output_tokens` and `lemans run --max-output-tokens`
6
+
7
+ ## [1.3.0] - 2026-09-04
8
+
9
+ - Miniswen: send an explicit `max_tokens` on every request (otherwise defaults could eat a lot of context, e.g., for `qwen3.8-27b`).
10
+ - `lemans report --metadata category:full-features` filters runs by task metadata.
11
+ - Fractional credit support (in addition to reward).
12
+ - Make Daytona TTL inferred from the task timeout settings.
13
+ - Fix Dockerfile resolution when profiles are used and per-task `bench.yml` exists.
14
+
3
15
  ## [1.2.0] - 2026-09-02
4
16
 
5
17
  - `allow_failure { ... }` (`LemansReport::Assertions`) for verification checks that are recorded in `checks.json` but do not grade the run.
data/README.md CHANGED
@@ -88,6 +88,7 @@ agent:
88
88
  timeout: 30m
89
89
  step_limit: 100
90
90
  cost_limit: 5.0
91
+ # max_output_tokens: 32768 # [optional] output cap per model call, for hosts that reserve the model's full max output out of the context window (default: the provider's)
91
92
  environment:
92
93
  network: # the sandbox network while the agent works: just enough to reach the model
93
94
  mode: allowlist
@@ -142,7 +143,7 @@ A minimal task example—checking whether an agent can write "Hello, world" into
142
143
  +Hello, world
143
144
  ```
144
145
 
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:
146
+ - `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
147
 
147
148
  ```ruby
148
149
  require "minitest/autorun"
@@ -218,11 +219,11 @@ ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0
218
219
  6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
219
220
  ```
220
221
 
221
- `lemans run` runs all the tasks for the model defined in `bench.yml` and prints the report at the end. You can override the model(s) to use (`--model`), the number of attempts (`--attempts`), or select specific tasks by name (`--task=ac-throttle-search`, may be repeated).
222
+ `lemans run` runs all the tasks for the model defined in `bench.yml` and prints the report at the end. You can override the model(s) to use (`--model`), the agent's output cap per model call (`--max-output-tokens`), the number of attempts (`--attempts`), or select specific tasks by name (`--task=ac-throttle-search`, may be repeated).
222
223
 
223
224
  Each trial writes a flat `runs/<model>/<task>__<id>/` directory:
224
225
 
225
- - `result.json`: reward, outcome (completed, error, etc.), usage, timings, tags, digests
226
+ - `result.json`: reward, credit, outcome (completed, error, etc.), usage, timings, tags, digests
226
227
  - `trajectory.json`: [ATIF](https://www.harborframework.com/docs/agents/trajectory-format) trajectory of the agent's session
227
228
  - `agent.patch`: the agent's work as one diff against the sealed baseline
228
229
  - `verifier.log`, `checks.json`, etc.: additional logs captured during the verification phase.
@@ -253,8 +254,8 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
253
254
  | --- | --- |
254
255
  | `lemans init` | Scaffold a new bench directory: an annotated `bench.yml` and two example tasks |
255
256
  | `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
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 run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
258
+ | `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
259
  | `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
259
260
 
260
261
  ## 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 repeat
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).flat_map { |batch| (1..attempts).map { [ batch, models, attempts > 1 ? it : nil, nil ] } }
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, type: :boolean, default: false, aliases: "-W", desc: "Redraw the table every 5 seconds until interrupted"
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 5s (^C to stop)"
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 5
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
@@ -59,7 +59,8 @@ module Lemans
59
59
  max_steps: profile.step_limit,
60
60
  max_time: profile.timeout,
61
61
  max_cost: profile.cost_limit,
62
- exec_timeout: profile.exec_timeout
62
+ exec_timeout: profile.exec_timeout,
63
+ max_output_tokens: profile.max_output_tokens
63
64
  )
64
65
  end
65
66
 
@@ -92,7 +93,7 @@ module Lemans
92
93
  **totals,
93
94
  cost_usd: result.cost_usd,
94
95
  # FIXME: need a better way to map Miniswen's cost source to Lemans'
95
- cost_source: Result::CostSource.new(**result.cost_source.to_h)
96
+ cost_source: Result::CostSource.build(**result.cost_source.to_h)
96
97
  )
97
98
  end
98
99
 
@@ -114,6 +115,7 @@ module Lemans
114
115
  cost_limit: profile.cost_limit,
115
116
  wall_time_limit_seconds: profile.timeout,
116
117
  exec_timeout_seconds: profile.exec_timeout,
118
+ max_output_tokens: profile.max_output_tokens,
117
119
  max_consecutive_format_errors: ::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS
118
120
  }.compact }
119
121
  end
@@ -62,7 +62,8 @@ module Lemans
62
62
  "-m", model.to_s, "-p", task.instruction,
63
63
  "--results-path", RESULTS_PATH,
64
64
  "--max-steps", profile.step_limit, "--max-time", profile.timeout.to_i,
65
- "--exec-timeout", profile.exec_timeout.to_i ]
65
+ "--exec-timeout", profile.exec_timeout.to_i,
66
+ "--max-output-tokens", profile.max_output_tokens ]
66
67
  argv += [ "--max-cost", profile.cost_limit.to_i ] if profile.cost_limit
67
68
  argv.map { Shellwords.escape(it.to_s) }.join(" ")
68
69
  end
@@ -47,7 +47,7 @@ module Lemans
47
47
  end
48
48
 
49
49
  def finished(result)
50
- status = result.scored? ? "reward=#{result.reward.inspect}" : result.status.to_s
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
- [ keys.map(&:to_s) + METRICS.map(&:to_s) ] +
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)
@@ -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 tags
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
- [ TABLE_COLUMNS.map(&:to_s) ] +
119
+ [ table_columns.map(&:to_s) ] +
103
120
  rows.map do |row|
104
- TABLE_COLUMNS.map do |column|
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
@@ -50,6 +50,8 @@ module Lemans
50
50
  option :tag, desc: "Run every task carrying this tag(s)", repeatable: true
51
51
  option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop)"
52
52
  option :model, desc: "Override the model(s) from bench.yml", repeatable: true
53
+ option :max_output_tokens, type: :numeric, banner: "TOKENS",
54
+ desc: "Cap the agent's output per model call (default: the provider's)"
53
55
  option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
54
56
  option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
55
57
  option :runs_dir, default: "./runs", desc: "Where to write run directories"
@@ -127,17 +129,50 @@ module Lemans
127
129
  raise Thor::Error, "lemans: #{e.message}"
128
130
  end
129
131
 
132
+ desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
133
+ option :bench, default: ".", desc: "Directory holding bench.yml"
134
+ option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
135
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories"
136
+ option :mapping, banner: "PATH",
137
+ desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
138
+ "instead of reading verification_test.rb"
139
+ def regrade
140
+ store = Stores::FS.new(options[:runs_dir])
141
+ tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
142
+ raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
143
+
144
+ tasks.each do |task|
145
+ mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
146
+ regrade = Regrade.new(store, task.name, mapping:)
147
+ regrade.verify_mapping! unless options[:mapping]
148
+
149
+ changes, skipped = regrade.execute!
150
+ changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
151
+ skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
152
+ say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
153
+ end
154
+
155
+ say ""
156
+ say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
157
+ print_report Report.load(store, names: tasks.map(&:name))
158
+ rescue ConfigError => e
159
+ raise Thor::Error, "lemans: #{e.message}"
160
+ end
161
+
130
162
  desc "report", "Summarize run results as a table or CSV"
131
163
  option :runs_dir, default: "runs", desc: "Directory holding run directories"
132
164
  option :tag, desc: "Only runs whose result carries this tag", repeatable: true
133
165
  option :task, desc: "Only these tasks' runs", repeatable: true
166
+ option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
167
+ repeatable: true
134
168
  option :format, default: "table", enum: %w[table csv], desc: "Output format"
135
169
  option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
136
170
  desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
137
171
  option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
138
172
  def report
139
173
  store = Stores::FS.new(options[:runs_dir])
140
- results = Report.load(store, tags: options[:tag], names: options[:task])
174
+ results = Report.load(store, tags: options[:tag], names: options[:task],
175
+ metadata: Report.metadata_filter(options[:metadata]))
141
176
  raise Thor::Error, "lemans: no matching results found" if results.empty?
142
177
 
143
178
  results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
@@ -163,6 +198,10 @@ module Lemans
163
198
  raise Thor::Error, "lemans: no matching tasks"
164
199
  end
165
200
 
201
+ def grade_change(change)
202
+ %i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }.join(" ")
203
+ end
204
+
166
205
  def print_report(report)
167
206
  print_table report.to_rows
168
207
  color = report.summary[:invalid].positive? ? :red : nil
@@ -18,6 +18,7 @@ module Lemans
18
18
  conf.cost_limit = float!(data["cost_limit"]) if data["cost_limit"]
19
19
  conf.timeout = seconds!(data["timeout"]) if data["timeout"]
20
20
  conf.exec_timeout = seconds!(data["exec_timeout"]) if data["exec_timeout"]
21
+ conf.max_output_tokens = integer!(data["max_output_tokens"]) if data.key?("max_output_tokens")
21
22
 
22
23
  if (network_data = data.dig("environment", "network"))
23
24
  conf.environment = Environment.new(network: NetworkPolicy.from_config(network_data))
@@ -30,7 +31,7 @@ module Lemans
30
31
  end
31
32
 
32
33
  attr_accessor :name, :models, :timeout,
33
- :step_limit, :cost_limit, :exec_timeout,
34
+ :step_limit, :cost_limit, :exec_timeout, :max_output_tokens,
34
35
  :environment
35
36
 
36
37
  def model = models.first
@@ -43,6 +44,7 @@ module Lemans
43
44
  "step_limit" => step_limit,
44
45
  "cost_limit" => cost_limit,
45
46
  "exec_timeout" => exec_timeout,
47
+ "max_output_tokens" => max_output_tokens,
46
48
  "environment" => { "network" => environment.network.to_h }
47
49
  }.compact
48
50
  end
@@ -55,6 +57,7 @@ module Lemans
55
57
  @step_limit = 100
56
58
  @cost_limit = nil
57
59
  @exec_timeout = 300
60
+ @max_output_tokens = 0
58
61
  @timeout = 30 * 60
59
62
  @environment = Environment.new(network: NetworkPolicy.new)
60
63
  end
@@ -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
 
@@ -90,13 +90,18 @@ module Lemans
90
90
 
91
91
  def tasks = @tasks ||= parse_tasks
92
92
 
93
- def load_options(agent: nil, model: nil, attempts: nil, concurrency: nil, backend: nil, **)
93
+ def load_options(agent: nil, model: nil, max_output_tokens: nil, attempts: nil, concurrency: nil, backend: nil, **)
94
94
  @agent.name = agent if agent
95
95
  @agent.models = Array(model) if model
96
+ @agent.max_output_tokens = max_output_tokens if max_output_tokens
96
97
  @attempts = attempts if attempts
97
98
  @concurrency = concurrency if concurrency
98
99
  @backend = environment.backend = backend if backend
99
- tasks.each { it.config.load_options(agent:, model:, attempts:, concurrency:, backend:) unless it.config.equal?(self) }
100
+ tasks.each do |task|
101
+ next if task.config.equal?(self)
102
+
103
+ task.config.load_options(agent:, model:, max_output_tokens:, attempts:, concurrency:, backend:)
104
+ end
100
105
  end
101
106
 
102
107
  def agent_name = agent.name
@@ -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
- def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout: nil)
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 = 3
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
- exec_directly(command, timeout: timeout, env: env)
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: 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.new(**CostSource.members.to_h { [ it, nil ] }, **source)
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
- result.graded!(data[:reward]) unless data[:reward].nil?
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
- def save_artifact(result, contents, path:)
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
@@ -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
- checks: checks,
45
- failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
46
- allowed_failures: allowed
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
- Verification.new(reward: read_reward(result), logs: result.output.to_s)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  end
@@ -14,7 +14,7 @@ module Miniswen
14
14
 
15
15
  # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
16
16
  # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
17
- TRUNCATION_FINISH_REASONS = %w[length max_tokens].freeze
17
+ TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
18
18
  CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
19
19
  # A safety stop, which arrives looking exactly like a model that forgot
20
20
  # to call the tool: no content, no tool call, and — since the provider
@@ -224,19 +224,20 @@ module Miniswen
224
224
  end
225
225
 
226
226
  CostSource = Data.define(:name, :model, :priced_as, :registry) do
227
- def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
227
+ def to_h = { name:, model:, priced_as:, registry: }.compact
228
228
  end
229
229
 
230
230
  attr_reader :messages, :environment
231
231
 
232
- private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
232
+ private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
233
233
  :clock, :reporter
234
234
 
235
235
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
236
236
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
237
237
  # Limits of 0 or nil are disabled.
238
238
  def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
239
- exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
239
+ exec_timeout: 30, max_output_tokens: 0,
240
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
240
241
  reporter: nil)
241
242
  name, @effort = model.split("#", 2)
242
243
  @provider, @id = name.split("/", 2)
@@ -254,6 +255,7 @@ module Miniswen
254
255
  @max_time = max_time.to_f
255
256
  @max_cost = max_cost
256
257
  @exec_timeout = exec_timeout
258
+ @max_output_tokens = max_output_tokens.to_i
257
259
 
258
260
  @clock = clock
259
261
  @reporter = reporter
@@ -503,7 +505,7 @@ module Miniswen
503
505
  tools: { bash: @bash_tool },
504
506
  temperature: nil,
505
507
  model: model_info,
506
- params: routing_params,
508
+ params: routing_params.merge(output_cap_params(model_info)),
507
509
  thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
508
510
  )
509
511
  payload(response)
@@ -543,6 +545,22 @@ module Miniswen
543
545
 
544
546
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
545
547
 
548
+ # OpenAI itself retired `max_tokens` for its reasoning models; the
549
+ # OpenAI-compatible providers and Anthropic still read it.
550
+ def output_cap_params(model_info)
551
+ return {} if max_output_tokens.zero?
552
+
553
+ cap = [ info&.max_tokens, max_output_tokens ].compact.min
554
+ provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
555
+ if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
556
+ { max_completion_tokens: cap }
557
+ elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
558
+ { max_tokens: cap }
559
+ else
560
+ {}
561
+ end
562
+ end
563
+
546
564
  def cost_source
547
565
  if local?
548
566
  return CostSource.new(name: :local_provider, model: @model,
data/lib/miniswen/cli.rb CHANGED
@@ -123,6 +123,10 @@ module Miniswen
123
123
  options[:exec_timeout] = v
124
124
  end
125
125
 
126
+ opts.on("--max-output-tokens=TOKENS", Integer, "Output cap per model call (default: the provider's)") do |v|
127
+ options[:max_output_tokens] = v
128
+ end
129
+
126
130
  opts.on("-q", "--quiet", "Disable progress output") do
127
131
  @quiet = true
128
132
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  end
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.2.0
4
+ version: 1.3.1
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