lemans 1.1.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +14 -4
  4. data/exe/lemans-remote +22 -10
  5. data/lib/lemans/agents/miniswen.rb +2 -2
  6. data/lib/lemans/agents/oracle.rb +13 -7
  7. data/lib/lemans/cli/progress_reporter.rb +6 -1
  8. data/lib/lemans/cli/regrade.rb +193 -0
  9. data/lib/lemans/cli/report/aggregate.rb +18 -11
  10. data/lib/lemans/cli/report.rb +25 -8
  11. data/lib/lemans/cli/templates/bench/README.md +7 -2
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  13. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  14. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  15. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  16. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  17. data/lib/lemans/cli.rb +38 -1
  18. data/lib/lemans/config/agent.rb +12 -0
  19. data/lib/lemans/config/environment.rb +31 -2
  20. data/lib/lemans/config/network_policy.rb +2 -0
  21. data/lib/lemans/config/setup.rb +6 -1
  22. data/lib/lemans/config/verifier.rb +18 -7
  23. data/lib/lemans/config.rb +45 -10
  24. data/lib/lemans/environment.rb +6 -3
  25. data/lib/lemans/environments/daytona/retries.rb +5 -3
  26. data/lib/lemans/environments/daytona/shell.rb +11 -1
  27. data/lib/lemans/environments/daytona.rb +16 -6
  28. data/lib/lemans/environments/docker.rb +3 -3
  29. data/lib/lemans/ext/deep_merge.rb +13 -0
  30. data/lib/lemans/result.rb +57 -7
  31. data/lib/lemans/runner.rb +1 -1
  32. data/lib/lemans/store.rb +7 -1
  33. data/lib/lemans/stores/fs.rb +9 -3
  34. data/lib/lemans/task_definition.rb +158 -10
  35. data/lib/lemans/trial/patch.rb +46 -8
  36. data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
  37. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
  38. data/lib/lemans/trial/verifier.rb +30 -3
  39. data/lib/lemans/trial.rb +89 -33
  40. data/lib/lemans/version.rb +1 -1
  41. data/lib/miniswen/agent.rb +21 -2
  42. data/lib/miniswen/version.rb +1 -1
  43. metadata +6 -2
  44. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44398d66bdcf062d7d5d41ad70e400e76a0031afdcbbdf2df83883ec60aefd33
4
- data.tar.gz: 79dcdeff359498787b2535c5155ae00806c17572cb8359b51a27454f67a1c5d6
3
+ metadata.gz: 4dbdb69526dbf7556f8c6f5840151fbeaaef985f732d868a1dd50b3e6f78d374
4
+ data.tar.gz: f47b34d05a3396feebed35354e4e8986105fba78d721a3be5cf98ead3a7ebd56
5
5
  SHA512:
6
- metadata.gz: f6cb208dfd63c0c63631586e84db8e7cb3e1b9c1e9092d59c6c41d4d58d8057793ca15facf7b9ab15a56723606b878b8817ce97024ad852e3e4c1199429382cb
7
- data.tar.gz: 251f9a0142b9464499c3c5219ddcc4c95684609048200f5d48a4c55e4e022370941746a3f8635594be3a53ef5f72fcf84d006caeb2b94af618bc38f4ec40c3f7
6
+ metadata.gz: 249aacbba88024ca2ba14c440899e721305f746a1f7bb1213fa1dae2d4dc3f28b747eb99d3a42fa684451497438f5fa22263d853cbefe1ac8627ae7a272d023e
7
+ data.tar.gz: ec188a43c63f5e4cbf56686ec1117757d29d3083956d374a82b27bf4fd20aa5939a1a63b5cc4b8d395b051dca8b9850db9531e973bcdbebe5e1dfc66d26852fa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
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
+
11
+ ## [1.2.0] - 2026-09-02
12
+
13
+ - `allow_failure { ... }` (`LemansReport::Assertions`) for verification checks that are recorded in `checks.json` but do not grade the run.
14
+ - Add `inherit_from: ../bench.yml` and support per-task overrides via `bench.yml` in a task directory.
15
+ - Multistep tasks support.
16
+ - Support named Docker envs via `environment.profiles` and `environment: <name>` in a task's frontmatter.
17
+
3
18
  ## [1.1.0] - 2026-08-28
4
19
 
5
20
  - A `#<effort>` model suffix (`openrouter/openai/gpt-5.6-luna#xhigh`) pins the reasoning effort; results land in `<model>-<effort>/`.
data/README.md CHANGED
@@ -46,7 +46,7 @@ my-bench/
46
46
  ├── hello-world/
47
47
  │ ├── instruction.md # what the agent is asked to do; YAML frontmatter carries
48
48
  │ │ # name, description, difficulty, tags, metadata — plus the
49
- │ │ # per-task overrides: setup, restore, verifier.setup
49
+ │ │ # per-task overrides: setup, restore, verifier.setup, environment
50
50
  │ ├── environment/Dockerfile # [optional] a task-specific sandbox image, instead of the shared one
51
51
  │ ├── environment.patch # [optional] task setup patch: applied and resealed as a fresh git repo at setup
52
52
  │ ├── verification_test.rb # grades the result
@@ -59,6 +59,9 @@ The `bench.yml` looks like this:
59
59
  ```yaml
60
60
  version: 1
61
61
 
62
+ # inherit_from: ../bench.yml # [optional] start from another bench.yml; the sections below are
63
+ # # deep-merged over it (lists replace; set a key to ~ to drop it)
64
+
62
65
  # setup: # [optional] sandbox preparation, run before the agent starts
63
66
  # files: [fixtures/seed.sql] # uploaded for the commands to consume, then wiped
64
67
  # commands: [bin/sandbox-setup]
@@ -68,6 +71,11 @@ environment:
68
71
  # backend: "daytona" # or "docker"
69
72
  # dockerfile: environment/Dockerfile # the default — or pin a published image instead:
70
73
  # image: ghcr.io/acme/my-bench@sha256:...
74
+ # profiles: # [optional] named alternatives to the shared image; a task
75
+ # campfire: # picks one with `environment: campfire` in its frontmatter
76
+ # dockerfile: docker/campfire/Dockerfile
77
+ # fizzy:
78
+ # image: ghcr.io/acme/fizzy@sha256:...
71
79
  resources: { cpus: 2, memory: 2GB, storage: 5GB }
72
80
  build_timeout: 10m
73
81
  network:
@@ -134,7 +142,7 @@ A minimal task example—checking whether an agent can write "Hello, world" into
134
142
  +Hello, world
135
143
  ```
136
144
 
137
- - `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). 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:
138
146
 
139
147
  ```ruby
140
148
  require "minitest/autorun"
@@ -162,6 +170,8 @@ verifier:
162
170
 
163
171
  An `environment.patch` next to `instruction.md` is always applied, declared or not.
164
172
 
173
+ A heavy task may need more than the bench-wide budgets: a `bench.yml` in the task directory is deep-merged over the bench's (as if it had `inherit_from` pointing at it) and applies to that task's trials only — say, `agent: { timeout: 2h, step_limit: 300 }`.
174
+
165
175
  ### 3. Set credentials
166
176
 
167
177
  ```bash
@@ -212,7 +222,7 @@ ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0
212
222
 
213
223
  Each trial writes a flat `runs/<model>/<task>__<id>/` directory:
214
224
 
215
- - `result.json`: reward, outcome (completed, error, etc.), usage, timings, tags, digests
225
+ - `result.json`: reward, credit, outcome (completed, error, etc.), usage, timings, tags, digests
216
226
  - `trajectory.json`: [ATIF](https://www.harborframework.com/docs/agents/trajectory-format) trajectory of the agent's session
217
227
  - `agent.patch`: the agent's work as one diff against the sealed baseline
218
228
  - `verifier.log`, `checks.json`, etc.: additional logs captured during the verification phase.
@@ -244,7 +254,7 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
244
254
  | `lemans init` | Scaffold a new bench directory: an annotated `bench.yml` and two example tasks |
245
255
  | `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
246
256
  | `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `-k`, `-c`, `--resume`) |
247
- | `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 |
248
258
  | `lemans clobber` | Delete run results (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
249
259
 
250
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 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
@@ -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.new(**result.cost_source.to_h)
95
+ cost_source: Result::CostSource.build(**result.cost_source.to_h)
96
96
  )
97
97
  end
98
98
 
@@ -14,10 +14,16 @@ module Lemans
14
14
  PATCH = "solution.patch"
15
15
 
16
16
  def run(task, environment)
17
- raise ConfigError, "#{task.name}: no solution/ to run — the oracle has nothing to prove" unless task.solution?
17
+ files = task.solution_files
18
+ if files.empty?
19
+ raise ConfigError, "#{task.name}: no solution/ to run — the oracle has nothing to prove" if
20
+ task.verifiable? && !task.solution_applied_earlier?
18
21
 
19
- upload_solution(environment, task)
20
- outcome = environment.exec(command_for(task), timeout:)
22
+ return Response.new(outcome: Result::Outcome.new(:completed), usage: Result::Usage.zero)
23
+ end
24
+
25
+ upload_solution(environment, files)
26
+ outcome = environment.exec(command_for(task, files), timeout:)
21
27
 
22
28
  unless outcome.success?
23
29
  raise InfrastructureError,
@@ -32,8 +38,8 @@ module Lemans
32
38
 
33
39
  # An entrypoint ships only when applying the golden patch is not enough: an executable
34
40
  # `solve` (its shebang picks the language) or solve.sh; otherwise the bare patch is applied.
35
- def command_for(task)
36
- shipped = task.solution_files.map(&:last)
41
+ def command_for(task, files)
42
+ shipped = files.map(&:last)
37
43
  # An upload promises no mode bit, so the executable gets its own.
38
44
  return "chmod +x #{REMOTE_DIR}/#{SOLVE} && #{REMOTE_DIR}/#{SOLVE}" if shipped.include?(SOLVE)
39
45
  return "bash #{REMOTE_DIR}/#{ENTRYPOINT}" if shipped.include?(ENTRYPOINT)
@@ -43,8 +49,8 @@ module Lemans
43
49
  "cd #{Shellwords.escape(task.environment.workdir)} && git apply --binary --whitespace=nowarn #{REMOTE_DIR}/#{PATCH}"
44
50
  end
45
51
 
46
- def upload_solution(environment, task)
47
- task.solution_files.each do |local, remote|
52
+ def upload_solution(environment, files)
53
+ files.each do |local, remote|
48
54
  environment.upload(local, "#{REMOTE_DIR}/#{remote}")
49
55
  end
50
56
  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
@@ -37,5 +37,10 @@ tasks/<name>/
37
37
  ```
38
38
 
39
39
  - `hello-world` — proves the image and the grading pipeline end to end
40
- - `example-task` — a FizzBuzz-style challenge showing the per-task `setup` and
41
- `restore` overrides and tamper-proof grading
40
+ - `example-task` — a two-step FizzBuzz challenge (`multistep: true`: implement,
41
+ then golf it under a size budget) showing the per-task `setup` and `restore`
42
+ overrides, tamper-proof grading, and indexed step files
43
+ (`verification_test.1.rb`, `solution.1.patch`, `solution.2.patch`). Steps that
44
+ share helpers keep them in one `tests/` directory: everything unindexed in it
45
+ ships with every verified step, `tests/verification_test.N.rb` grades step N,
46
+ and `tests/verification_test.rb` the final one
@@ -3,6 +3,12 @@
3
3
  name: example-task
4
4
  description: Classic FizzBuzz under a source-size budget
5
5
  difficulty: medium
6
+ # A multistep task: the instruction sections below are separated by `---`;
7
+ # the first is a shared preamble, each following one is a step. Every step
8
+ # runs a fresh agent session. Step files carry the step index: verification_test.1.rb
9
+ # grades step 1 on the spot, solution.N.patch is step N's solution applied on top of
10
+ # the previous one (a lone solution.patch would be the whole task's solution, applied before step 1).
11
+ multistep: true
6
12
  tags: [ruby, golf]
7
13
  metadata:
8
14
  category: example
@@ -19,14 +25,19 @@ setup:
19
25
  # before grading: rewriting expected.txt buys the agent nothing.
20
26
  restore: [expected.txt]
21
27
  ---
22
- `/app/fizzbuzz.rb` is supposed to print the classic FizzBuzz sequence, but
23
- today it only prints the numbers. Make `ruby fizzbuzz.rb` print, one per
24
- line, the numbers 1 through 100 with every multiple of 3 replaced by `Fizz`,
25
- every multiple of 5 by `Buzz`, and every multiple of both by `FizzBuzz` —
26
- exactly the contents of `expected.txt`.
28
+ You are working on `/app/fizzbuzz.rb`, a tiny Ruby program graded against
29
+ `/app/expected.txt`. The program must compute its output itself: it is graded
30
+ with `expected.txt` out of reach, so reading the file back is not an answer.
27
31
 
28
- Two constraints:
32
+ ---
33
+
34
+ `fizzbuzz.rb` is supposed to print the classic FizzBuzz sequence, but today
35
+ it only prints the numbers. Make `ruby fizzbuzz.rb` print, one per line, the
36
+ numbers 1 through 100 with every multiple of 3 replaced by `Fizz`, every
37
+ multiple of 5 by `Buzz`, and every multiple of both by `FizzBuzz` — exactly
38
+ the contents of `expected.txt`.
39
+
40
+ ---
29
41
 
30
- - `fizzbuzz.rb` must stay at most 150 bytes keep it tight;
31
- - the program must compute the sequence itself: it is graded with
32
- `expected.txt` out of reach, so reading the file back is not an answer.
42
+ Now make it tight: shrink `fizzbuzz.rb` to at most 150 bytes without changing
43
+ what it prints.