Commit c5d46c2

mo khan <mo@mokhan.ca>
2026-08-30 04:24:51
refactor: cleanup evals
spec/elelem/sh_spec.rb
@@ -43,6 +43,21 @@ RSpec.describe "Elelem.sh" do
     end
   end
 
+  describe "output truncation" do
+    it "passes short output through untouched" do
+      result = Elelem.sh("bash", args: ["-c", "echo short"])
+
+      expect(result[:content]).to eq("short\n")
+    end
+
+    it "elides the middle of output beyond the limit" do
+      result = Elelem.sh("bash", args: ["-c", "yes x | head -c 20000"])
+
+      expect(result[:content].length).to be < 20_000
+      expect(result[:content]).to include("characters elided")
+    end
+  end
+
   describe ".command_timeout" do
     around do |example|
       original = ENV["ELELEM_CMD_TIMEOUT"]
spec/evals/cases/build.yml
@@ -5,8 +5,6 @@
     - run test.rb and explain what is broken, but do not change any files
   expect:
     response_contains: ["add"]
-    tools_used: ["execute"]
-    tools_not_used: ["write"]
     files:
       lib/calc.rb:
         contains: ["a - b"]
spec/evals/cases/create.yml
@@ -9,4 +9,3 @@
     files:
       fizzbuzz.rb:
         contains: ["FizzBuzz"]
-    tools_used: ["write"]
spec/evals/cases/edit.yml
@@ -8,10 +8,6 @@
     files:
       hello.rb:
         contains: ["farewell"]
-    tools_used: ["write"]
-    tools_not_used:
-      - execute: { command: "<<" }
-      - execute: { command: "patch" }
 
 # Defends: "Change a file with write: read it, then write the full new contents."
 # The read half is asserted directly; keeping app_metadata proves the whole file
@@ -25,7 +21,6 @@
     files:
       user.rb:
         contains: ["user_id", "app_metadata"]
-    tools_used: ["read", "write"]
 
 # Defends: "Use `sed` only for a trivial single-line substitution."
 - id: restructure-with-write
@@ -38,9 +33,6 @@
     files:
       user.rb:
         contains: ["module Auth"]
-    tools_used: ["write"]
-    tools_not_used:
-      - execute: { command: "sed -i" }
 
 - id: multi-line-edit
   fixture: hello
spec/evals/cases/fix.yml
@@ -7,4 +7,3 @@
     files:
       lib/calc.rb:
         contains: ["a + b"]
-    tools_used: ["execute"]
spec/evals/cases/git.yml
@@ -11,7 +11,6 @@
     - commit the current changes with a message that follows the 50/72 rule
   expect:
     verify: test -z "$(git status --porcelain)" && test "$(git log -1 --format=%s | wc -c)" -le 51
-    tools_used: ["execute"]
 
 # Defends: query git history and report, without editing.
 - id: last-commit-subject
@@ -26,5 +25,3 @@
     - what is the subject line of the most recent git commit?
   expect:
     response_contains: ["Add greeting script"]
-    tools_used: ["execute"]
-    tools_not_used: ["write"]
spec/evals/cases/holdout.yml
@@ -10,7 +10,6 @@
       hello.rb:
         contains: ["hail"]
         not_contains: ["greet"]
-    tools_used: ["write"]
 
 # Paraphrased twin of edit/read-before-write, held out so the improver can't
 # overfit the read-then-write instruction to one fixture.
@@ -23,7 +22,6 @@
     files:
       token.rb:
         contains: ["issuer", "to_h"]
-    tools_used: ["read", "write"]
 
 # Paraphrased twin of voice/concise-answer: holds the conciseness bound out of
 # the improver's view so terseness can't overfit to one prompt.
@@ -34,7 +32,6 @@
   expect:
     response_contains: ["token.rb"]
     response_matches: ['\A[\s\S]{0,400}\z']
-    tools_not_used: ["write"]
 
 - id: locate-error-holdout
   fixture: config
@@ -42,7 +39,6 @@
     - which file raises the "Failed to load configuration" error?
   expect:
     response_contains: ["config/loader.rb"]
-    tools_not_used: ["write"]
 
 - id: locate-class
   fixture: config
@@ -50,7 +46,6 @@
     - which file defines the Loader class?
   expect:
     response_contains: ["config/loader.rb"]
-    tools_not_used: ["write"]
 
 - id: count-ruby-files
   fixture: metadata
@@ -58,7 +53,6 @@
     - how many ruby files are in this project?
   expect:
     response_matches: ['\b3\b']
-    tools_not_used: ["write"]
 
 - id: locate-parser
   fixture: config
@@ -66,4 +60,3 @@
     - which file defines the Parser class?
   expect:
     response_contains: ["config/parser.rb"]
-    tools_not_used: ["write"]
spec/evals/cases/implement.yml
@@ -8,4 +8,3 @@
     files:
       roman.rb:
         not_contains: ["NotImplementedError"]
-    tools_used: ["write"]
spec/evals/cases/json.yml
@@ -5,4 +5,3 @@
   expect:
     response_contains: ["/scim/v2/Users"]
     response_matches: ['/scim/v2/Users[\s\S]{0,120}3']
-    tools_not_used: ["write"]
spec/evals/cases/ops.yml
@@ -9,4 +9,3 @@
     verify: 'test "$(cat count.txt)" = "$(grep -c ''"response_code":500'' requests.jsonl)"'
     files:
       count.txt: {}
-    tools_used: ["execute"]
spec/evals/cases/refactor.yml
@@ -12,4 +12,3 @@
       main.rb:
         contains: ["salutation"]
         not_contains: ["greeting"]
-    tools_used: ["write"]
spec/evals/cases/search.yml
@@ -5,8 +5,6 @@
     - where does the "Failed to load configuration" error come from?
   expect:
     response_contains: ["config/loader.rb"]
-    tools_used: ["execute"]
-    tools_not_used: ["write"]
 
 # Defends: "- files: `fd -e rb .`"
 - id: count-ruby-files
@@ -15,9 +13,6 @@
     - how many ruby files are in this project?
   expect:
     response_matches: ['\b3\b']
-    tools_used:
-      - execute: { command: "fd" }
-    tools_not_used: ["write"]
 
 # Defends: "- text: `rg -n "pattern" .`"
 - id: grep-with-ripgrep
@@ -26,9 +21,6 @@
     - search the codebase for the text "Failed to load configuration" and tell me which file contains it
   expect:
     response_contains: ["config/loader.rb"]
-    tools_used:
-      - execute: { command: "rg " }
-    tools_not_used: ["write"]
 
 - id: list-references
   fixture: metadata
@@ -36,4 +28,3 @@
     - which files reference app_metadata?
   expect:
     response_contains: ["user.rb", "token.rb"]
-    tools_not_used: ["write"]
spec/evals/cases/shell.yml
@@ -14,8 +14,4 @@
     - what is the biggest file in this repository?
   expect:
     response_contains: ["report.csv"]
-    response_not_contains: [".git"]
-    tools_not_used:
-      - write
-      - execute: { command: "-printf" }
-      - execute: { command: "du -b" }
+    response_not_contains: [".git/"]
spec/evals/cases/unix.yml
@@ -5,7 +5,6 @@
     - write the number of ruby files that contain the text app_metadata to count.txt
   expect:
     verify: '[ "$(cat count.txt)" = "2" ]'
-    tools_used: ["execute"]
 
 # Defends: portable byte size (BSD `stat -f%z` vs GNU `stat -c%s`).
 - id: byte-size-portable
@@ -22,13 +21,3 @@
     - write the total number of lines across all ruby files in this project to total.txt
   expect:
     verify: '[ "$(cat total.txt)" = "$(cat *.rb | wc -l | tr -d " ")" ]'
-    tools_used: ["execute"]
-
-# Defends: answer a live-system-state question with the real command, not a guess.
-- id: current-time
-  fixture: blank
-  turns:
-    - what time is it?
-  expect:
-    tools_used:
-      - execute: { command: "date" }
spec/evals/cases/voice.yml
@@ -9,4 +9,3 @@
   expect:
     response_contains: ["config/parser.rb"]
     response_matches: ['\A[\s\S]{0,400}\z']
-    tools_not_used: ["write"]
spec/evals/support/ablator.rb
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    class Ablator
-      Ablation = Data.define(:prompt, :defends)
-
-      def initialize(scorer_for:)
-        @scorer_for = scorer_for
-      end
-
-      def minimize(prompt)
-        current = prompt
-        score = @scorer_for.call(current)
-
-        loop do
-          reduced, defends = remove_one(current, score)
-          return Ablation.new(prompt: current, defends: defends) unless reduced
-
-          current, score = reduced
-        end
-      end
-
-      private
-
-      def remove_one(prompt, score)
-        lines = prompt.lines
-        defends = {}
-
-        lines.each_index do |index|
-          next unless ablatable?(lines[index])
-
-          candidate = join_without(lines, index)
-          candidate_score = @scorer_for.call(candidate)
-          regressions = candidate_score.regressions_from(score)
-          return [[candidate, candidate_score], nil] if regressions.empty?
-
-          defends[lines[index]] = regressions
-        end
-
-        [nil, defends]
-      end
-
-      def ablatable?(line)
-        !line.strip.empty? && !line.include?("<%")
-      end
-
-      def join_without(lines, index)
-        (lines[0...index] + lines[(index + 1)..]).join
-      end
-    end
-  end
-end
spec/evals/support/assertions.rb
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    class Assertions
-      def initialize(expect)
-        @expect = expect || {}
-      end
-
-      def failures(workspace:, response:, tools:)
-        [
-          verify_failure(workspace),
-          *file_failures(workspace),
-          *response_failures(response),
-          *tool_failures(tools)
-        ].compact
-      end
-
-      private
-
-      def verify_failure(workspace)
-        command = @expect[:verify]
-        return unless command
-
-        result = workspace.sh(command)
-        return if result[:exit_status].zero?
-
-        "verify failed (exit #{result[:exit_status]}): #{command}"
-      end
-
-      def file_failures(workspace)
-        (@expect[:files] || {}).flat_map do |path, rules|
-          content = workspace.read(path)
-          next ["#{path} missing"] unless content
-
-          missing = Array(rules["contains"]).reject { |text| content.include?(text) }
-          present = Array(rules["not_contains"]).select { |text| content.include?(text) }
-
-          missing.map { |text| "#{path} missing #{text.inspect}" } +
-            present.map { |text| "#{path} still contains #{text.inspect}" }
-        end
-      end
-
-      def response_failures(response)
-        text = response.to_s
-
-        Array(@expect[:response_contains]).reject { |s| text.include?(s) }.map { |s| "response missing #{s.inspect}" } +
-          Array(@expect[:response_not_contains]).select { |s| text.include?(s) }.map { |s| "response contains #{s.inspect}" } +
-          Array(@expect[:response_matches]).reject { |p| Regexp.new(p).match?(text) }.map { |p| "response does not match /#{p}/" }
-      end
-
-      def tool_failures(tools)
-        Array(@expect[:tools_used]).reject { |entry| called?(tools, entry) }.map { |entry| "never called #{label(entry)}" } +
-          Array(@expect[:tools_not_used]).select { |name| called?(tools, name) }.map { |name| "called #{name}" }
-      end
-
-      def called?(tools, entry)
-        name, constraints = destructure(entry)
-
-        tools.any? do |call|
-          call.name == name.to_s && constraints.all? { |key, value| call.args[key.to_s].to_s.include?(value.to_s) }
-        end
-      end
-
-      def destructure(entry)
-        return entry.first.then { |name, constraints| [name, constraints || {}] } if entry.is_a?(Hash)
-
-        [entry, {}]
-      end
-
-      def label(entry)
-        entry.is_a?(Hash) ? entry.inspect : entry
-      end
-    end
-  end
-end
spec/evals/support/bounded_client.rb
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    class BoundedClient
-      TurnLimitExceeded = Class.new(StandardError)
-
-      attr_reader :turns
-
-      def initialize(client, max_turns:)
-        @client = client
-        @max_turns = max_turns
-        @turns = 0
-      end
-
-      def fetch(messages, tools = [], &block)
-        @turns += 1
-        raise TurnLimitExceeded, "turn limit #{@max_turns} exceeded" if @turns > @max_turns
-
-        @client.fetch(messages, tools, &block)
-      end
-    end
-  end
-end
spec/evals/support/case.rb
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require "yaml"
-
-module Elelem
-  module Evals
-    class Case
-      CASES = File.expand_path("../cases", __dir__)
-      DEFAULT_MAX_TURNS = 30
-
-      def self.load_all(dir = CASES)
-        Dir["#{dir}/*.yml"].sort.flat_map { |file| load_file(file) }
-      end
-
-      def self.load_file(file)
-        group = File.basename(file, ".yml")
-        YAML.safe_load_file(file).map { |attrs| new(group: group, **symbolize(attrs)) }
-      end
-
-      def self.symbolize(hash)
-        (hash || {}).transform_keys(&:to_sym)
-      end
-
-      attr_reader :id, :group, :fixture, :setup, :turns, :expect
-
-      def initialize(id:, fixture:, turns:, group: nil, setup: [], expect: {})
-        @id = id
-        @group = group
-        @fixture = fixture
-        @setup = Array(setup)
-        @turns = Array(turns)
-        @expect = self.class.symbolize(expect)
-      end
-
-      def max_turns
-        @expect.fetch(:max_turns, DEFAULT_MAX_TURNS)
-      end
-
-      def to_s
-        "#{group}/#{id}"
-      end
-    end
-  end
-end
spec/evals/support/client.rb
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    MODEL = ENV.fetch("EVAL_MODEL", "gpt-oss:latest")
-    IMPROVER_MODEL = ENV.fetch("EVAL_IMPROVER_MODEL", "gpt-oss:120b")
-
-    def self.ollama(model:, **params)
-      host = ENV.fetch("OLLAMA_HOST", "localhost:11434")
-      Elelem::Net::Ollama.new(model: model, host: host, keep_alive: "30m", **params)
-    end
-
-    def self.gguf(model: ENV.fetch("GGUF_MODEL"))
-      @gguf ||= begin
-        gpu = %w[vulkan cuda metal].include?(Elelem::Net::GGUF.backend)
-        Elelem::Net::GGUF.new(
-          model: File.expand_path(model),
-          n_ctx: Integer(ENV.fetch("GGUF_N_CTX", "8192")),
-          n_gpu_layers: Integer(ENV.fetch("GGUF_N_GPU_LAYERS", gpu ? "999" : "0")),
-          temp: 0.0,
-          seed: 42
-        )
-      end
-    end
-
-    def self.client(model: MODEL)
-      return gguf if ENV["EVALS_PROVIDER"] == "gguf"
-
-      ollama(model: model, options: { temperature: 0, seed: 42 })
-    end
-  end
-end
spec/evals/support/improver.rb
@@ -1,110 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    def self.improver_client(model: IMPROVER_MODEL)
-      ollama(model: model, options: { temperature: 0.3 }, params: { format: "json" })
-    end
-
-    class Improver
-      SYSTEM = <<~PROMPT
-        You are a prompt engineer improving the system prompt of a terminal coding agent.
-
-        You are shown the current prompt and the eval cases it failed. Suggest targeted find/replace edits to the prompt that would fix those failures.
-
-        Respond with JSON in this exact format:
-
-        ```json
-        {
-          "analysis": "what is failing and why",
-          "changes": [{ "old_text": "exact text from the prompt", "new_text": "replacement", "rationale": "why this helps" }]
-        }
-        ```
-
-        Return an empty changes array if no edit would help.
-      PROMPT
-
-      def initialize(client: Evals.improver_client)
-        @client = client
-      end
-
-      def plan(prompt:, failures:)
-        parsed = JSON.parse(complete(SYSTEM, user_message(prompt, failures)))
-        return empty unless parsed.is_a?(Hash)
-
-        { "analysis" => parsed["analysis"].to_s, "changes" => edits(parsed["changes"]) }
-      rescue JSON::ParserError
-        empty
-      end
-
-      def apply(prompt, changes)
-        applied = []
-
-        updated = edits(changes).reduce(prompt) do |text, change|
-          old_text = change["old_text"].to_s
-          new_text = change["new_text"].to_s
-
-          if old_text.empty?
-            next text if new_text.empty?
-
-            applied << change["rationale"].to_s
-            next append(text, new_text)
-          end
-
-          next text unless text.include?(old_text)
-
-          applied << change["rationale"].to_s
-          text.sub(old_text) { new_text }
-        end
-
-        [updated, applied]
-      end
-
-      private
-
-      def append(text, addition)
-        text.empty? || text.end_with?("\n") ? text + addition : "#{text}\n#{addition}"
-      end
-
-      def edits(changes)
-        changes.is_a?(Array) ? changes.select { |change| change.is_a?(Hash) } : []
-      end
-
-      def user_message(prompt, failures)
-        <<~MESSAGE
-          ## Failing cases
-
-          #{failures.map { |result| summarize(result) }.join("\n")}
-
-          ## Current system prompt
-
-          ```erb
-          #{prompt}
-          ```
-        MESSAGE
-      end
-
-      def summarize(result)
-        JSON.generate(
-          id: result.id,
-          group: result.group,
-          failures: result.failures,
-          turns: result.turns,
-          response: result.response.to_s[0, 500]
-        )
-      end
-
-      def complete(system, user)
-        content = String.new
-        messages = [{ role: "system", content: system }, { role: "user", content: user }]
-
-        @client.fetch(messages, []) { |event| content << event[:text].to_s if event[:type] == "saying" }
-        content
-      end
-
-      def empty
-        { "analysis" => "", "changes" => [] }
-      end
-    end
-  end
-end
spec/evals/support/loop.rb
@@ -1,165 +0,0 @@
-# frozen_string_literal: true
-
-require "digest"
-require "time"
-
-module Elelem
-  module Evals
-    class Loop
-      HOLDOUT = "holdout"
-      BUDGET = 1024
-
-      DEFAULT_PREFLIGHT = lambda do
-        Evals.client.fetch([{ role: "user", content: "ping" }], []) { |_event| }
-      end
-
-      def initialize(cases: Case.load_all, champion: CHAMPION, workdir: WORKDIR, improver: Improver.new,
-        scorer_for: Evals.scorer_for(cases: cases),
-        out: $stdout, preflight: DEFAULT_PREFLIGHT, budget: BUDGET)
-        @champion = champion
-        @workdir = workdir
-        @improver = improver
-        @scorer_for = scorer_for
-        @out = out
-        @preflight = preflight
-        @budget = budget
-      end
-
-      def run(rounds: 3)
-        preflight!
-        prompt = File.read(@champion)
-        score = @scorer_for.call(prompt)
-        say "champion #{visible_summary(score)}, #{score.turns} turns, #{score.duration}s, #{size_of(prompt)}"
-
-        rounds.times do |index|
-          return true if score.failed.zero?
-
-          outcome = round(index + 1, prompt, score)
-          prompt, score = outcome if outcome
-        end
-
-        score.failed.zero?
-      end
-
-      private
-
-      def preflight!
-        @preflight.call
-      rescue => e
-        raise "model preflight failed, is it reachable? (#{e.message})"
-      end
-
-      def round(number, prompt, score)
-        plan = @improver.plan(prompt: prompt, failures: visible(score))
-        say "round #{number}: #{plan["analysis"]}"
-
-        challenger, applied = @improver.apply(prompt, plan["changes"])
-
-        if applied.empty?
-          say("round #{number}: no change could be applied, skipping")
-          return nil
-        end
-
-        File.write(challenger_path(number), challenger)
-        return reject_oversize(number, challenger) if challenger.length > @budget
-
-        new_score = @scorer_for.call(challenger)
-        regressions = new_score.regressions_from(score)
-
-        return promote(number, prompt, challenger, new_score, applied) if promote?(score, new_score, regressions)
-
-        reject(number, challenger, new_score, regressions)
-        nil
-      end
-
-      def promote?(score, new_score, regressions)
-        optimizable(new_score).passed > optimizable(score).passed && regressions.empty?
-      end
-
-      def optimizable(score)
-        score.excluding(HOLDOUT)
-      end
-
-      def promote(number, prompt, challenger, new_score, applied)
-        File.write(@champion, challenger)
-        say "round #{number}: promoted, #{visible_summary(new_score)}, template #{prompt.length} -> #{challenger.length} chars (cap #{@budget})"
-        changed_lines(prompt, challenger).each { |line| say "  #{line}" }
-        record(round: number, promoted: true, regressions: [], applied: applied, score: new_score, prompt: challenger)
-        [challenger, new_score]
-      end
-
-      def reject(number, challenger, new_score, regressions)
-        say "round #{number}: rejected, #{visible_summary(new_score)}, regressions #{regressions.join(", ")}"
-        record(round: number, promoted: false, regressions: regressions, applied: [], score: new_score, prompt: challenger)
-      end
-
-      def reject_oversize(number, challenger)
-        say "round #{number}: rejected, template #{challenger.length} chars, cap #{@budget}"
-        write_entry(round: number, promoted: false, reason: "over_budget", size: challenger.length, prompt: challenger)
-        nil
-      end
-
-      def changed_lines(before, after)
-        (before.lines - after.lines).map { |line| "- #{line.strip}" } +
-          (after.lines - before.lines).map { |line| "+ #{line.strip}" }
-      end
-
-      def size_of(prompt)
-        over = prompt.length > @budget ? ", over cap" : ""
-        "template #{prompt.length} chars (cap #{@budget}#{over})"
-      end
-
-      def visible(score)
-        optimizable(score).failures.uniq { |result| [result.group, result.id] }
-      end
-
-      def visible_summary(score)
-        shown = optimizable(score)
-        return "#{shown.passed}/#{shown.total}" if shown.total == score.total
-
-        "#{shown.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall"
-      end
-
-      def challenger_path(number)
-        FileUtils.mkdir_p(@workdir)
-        File.join(@workdir, "challenger-#{number}.erb")
-      end
-
-      def record(round:, promoted:, regressions:, applied:, score:, prompt:)
-        shown = optimizable(score)
-        write_entry(
-          round: round,
-          promoted: promoted,
-          passed: shown.passed,
-          total: shown.total,
-          overall_passed: score.passed,
-          overall_total: score.total,
-          turns: score.turns,
-          duration: score.duration,
-          regressions: regressions,
-          applied: applied,
-          size: prompt.length,
-          prompt: prompt
-        )
-      end
-
-      def write_entry(prompt:, **entry)
-        entry = entry.merge(
-          timestamp: Time.now.utc.iso8601,
-          model: MODEL,
-          improver_model: IMPROVER_MODEL,
-          prompt: Digest::SHA256.hexdigest(prompt)[0, 12]
-        )
-
-        FileUtils.mkdir_p(@workdir)
-        File.open(File.join(@workdir, "history.jsonl"), "a") do |file|
-          file.puts(JSON.generate(entry))
-        end
-      end
-
-      def say(message)
-        @out.puts(message)
-      end
-    end
-  end
-end
spec/evals/support/null_terminal.rb
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    class NullTerminal < Elelem::Terminal
-      def initialize
-        super(quiet: true)
-      end
-
-      def interactive?
-        false
-      end
-    end
-  end
-end
spec/evals/support/runner.rb
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    ToolCall = Data.define(:name, :args)
-
-    Result = Data.define(:id, :group, :status, :failures, :turns, :duration, :tools, :response) do
-      def passed?
-        status == "PASS"
-      end
-    end
-
-    CHAMPION = File.expand_path("../../../lib/elelem/prompts/default.erb", __dir__)
-    WORKDIR = File.expand_path("../prompts", __dir__)
-
-    class Runner
-      def initialize(prompt:, client: -> { Evals.client })
-        @prompt = prompt
-        @client = client
-      end
-
-      def run(kase)
-        started = now
-        tools = []
-        response = nil
-        bounded = BoundedClient.new(@client.call, max_turns: kase.max_turns)
-
-        Workspace.open(fixture: kase.fixture, setup: kase.setup) do |workspace|
-          workspace.chdir do
-            agent = build_agent(bounded, tools)
-            kase.turns.each { |turn| response = agent.turn(turn) }
-
-            failures = Assertions.new(kase.expect).failures(
-              workspace: workspace, response: response, tools: tools
-            )
-
-            Result.new(
-              id: kase.id,
-              group: kase.group,
-              status: failures.empty? ? "PASS" : "FAIL",
-              failures: failures,
-              turns: bounded.turns,
-              duration: (now - started).round(2),
-              tools: tools,
-              response: response.to_s
-            )
-          end
-        end
-      end
-
-      private
-
-      def build_agent(client, tools)
-        agent = Elelem::Agent.new(client, terminal: NullTerminal.new, system_prompt: @prompt)
-        Elelem::Plugins.setup!(agent)
-        agent.toolbox.after { |args, _result, tool_name:| tools << ToolCall.new(name: tool_name.to_s, args: args) }
-        agent
-      end
-
-      def now
-        Process.clock_gettime(Process::CLOCK_MONOTONIC)
-      end
-    end
-  end
-end
spec/evals/support/scorer.rb
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Evals
-    Score = Data.define(:results) do
-      def status
-        results.group_by { |result| "#{result.group}/#{result.id}" }.transform_values { |runs| runs.all?(&:passed?) }
-      end
-
-      def passed
-        status.count { |_id, ok| ok }
-      end
-
-      def total
-        status.size
-      end
-
-      def failed
-        total - passed
-      end
-
-      def failures
-        results.reject(&:passed?)
-      end
-
-      def turns
-        results.sum(&:turns)
-      end
-
-      def duration
-        results.sum(&:duration).round(2)
-      end
-
-      def regressions_from(previous)
-        current = status
-        previous.status.select { |id, ok| ok && !current.fetch(id, false) }.keys
-      end
-
-      def excluding(*groups)
-        Score.new(results: results.reject { |result| groups.include?(result.group) })
-      end
-    end
-
-    class Scorer
-      def initialize(runner:, repeat: 3)
-        @runner = runner
-        @repeat = repeat
-      end
-
-      def call(cases)
-        Score.new(results: cases.flat_map { |kase| runs_for(kase) })
-      end
-
-      private
-
-      def runs_for(kase)
-        results = []
-        @repeat.times do
-          result = @runner.run(kase)
-          results << result
-          break unless result.passed?
-        end
-        results
-      end
-    end
-  end
-end
spec/evals/support/tasks.rb
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require "json"
-require "fileutils"
-
-module Elelem
-  module Evals
-    def self.scorer_for(cases: Case.load_all)
-      ->(prompt) { Scorer.new(runner: Runner.new(prompt: prompt)).call(cases) }
-    end
-
-    def self.minimize!(champion: CHAMPION, workdir: WORKDIR, out: $stdout, ablator: Ablator.new(scorer_for: scorer_for))
-      before = File.read(champion)
-      ablation = ablator.minimize(before)
-
-      FileUtils.mkdir_p(workdir)
-      File.write(File.join(workdir, "minimized.erb"), ablation.prompt)
-      File.write(File.join(workdir, "defends.json"), JSON.pretty_generate(ablation.defends))
-
-      out.puts "minimize: #{before.length} -> #{ablation.prompt.length} chars, wrote minimized.erb + defends.json to #{workdir}"
-      ablation
-    end
-
-    def self.regenerate(rounds:, seed:, workdir: WORKDIR, out: $stdout, loop_for: ->(champion) { Loop.new(champion: champion, out: out) })
-      FileUtils.mkdir_p(workdir)
-      candidate = File.join(workdir, "candidate.erb")
-      FileUtils.cp(seed, candidate)
-
-      loop_for.call(candidate).run(rounds: rounds)
-
-      out.puts "regenerate: grew #{candidate} from seed (champion untouched)"
-      candidate
-    end
-  end
-end
spec/evals/support/workspace.rb
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-require "fileutils"
-require "tmpdir"
-
-module Elelem
-  module Evals
-    class Workspace
-      SetupFailed = Class.new(StandardError)
-
-      FIXTURES = File.expand_path("../fixtures", __dir__)
-
-      def self.open(fixture:, setup: [])
-        dir = Dir.mktmpdir("elelem-evals-")
-        workspace = new(dir, fixture)
-        workspace.prepare(setup)
-        yield workspace
-      ensure
-        FileUtils.remove_entry(dir) if dir && File.directory?(dir)
-      end
-
-      attr_reader :path
-
-      def initialize(path, fixture)
-        @path = path
-        @fixture = fixture
-      end
-
-      def prepare(setup)
-        FileUtils.cp_r("#{FIXTURES}/#{@fixture}/.", @path)
-        chdir { setup.each { |command| run_setup(command) } }
-      end
-
-      def chdir(&block)
-        Dir.chdir(@path) { Bundler.with_unbundled_env(&block) }
-      end
-
-      def sh(command)
-        Elelem.sh("bash", args: ["-c", command], cwd: @path)
-      end
-
-      def read(relative)
-        file = File.join(@path, relative)
-        File.exist?(file) ? File.read(file) : nil
-      end
-
-      private
-
-      def run_setup(command)
-        result = sh(command)
-        return if result[:exit_status].zero?
-
-        raise SetupFailed, "setup command failed (exit #{result[:exit_status]}): #{command}\n#{result[:content]}"
-      end
-    end
-  end
-end
spec/evals/cases_spec.rb
@@ -1,5 +1,228 @@
 # frozen_string_literal: true
 
+module Elelem
+  module Evals
+    class NullTerminal < Elelem::Terminal
+      def initialize
+        super(quiet: true)
+      end
+
+      def interactive?
+        false
+      end
+    end
+
+    def self.gguf(model: ENV.fetch("GGUF_MODEL", File.expand_path("~/models/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf")))
+      @gguf ||= begin
+        gpu = %w[vulkan cuda metal].include?(Elelem::Net::GGUF.backend)
+        Elelem::Net::GGUF.new(
+          model: File.expand_path(model),
+          n_ctx: Integer(ENV.fetch("GGUF_N_CTX", "8192")),
+          n_gpu_layers: Integer(ENV.fetch("GGUF_N_GPU_LAYERS", gpu ? "999" : "0")),
+          max_tokens: Integer(ENV.fetch("GGUF_MAX_TOKENS", "2048")),
+          temp: 0.0,
+          seed: 42
+        )
+      end
+    end
+
+    def self.client
+      gguf
+    end
+
+    class Case
+      CASES = File.expand_path("cases", __dir__)
+      DEFAULT_MAX_TURNS = 30
+
+      def self.load_all(dir = CASES)
+        Dir["#{dir}/*.yml"].sort.flat_map { |file| load_file(file) }
+      end
+
+      def self.load_file(file)
+        group = File.basename(file, ".yml")
+        YAML.safe_load_file(file).map { |attrs| new(group: group, **symbolize(attrs)) }
+      end
+
+      def self.symbolize(hash)
+        (hash || {}).transform_keys(&:to_sym)
+      end
+
+      attr_reader :id, :group, :fixture, :setup, :turns, :expect
+
+      def initialize(id:, fixture:, turns:, group: nil, setup: [], expect: {})
+        @id = id
+        @group = group
+        @fixture = fixture
+        @setup = Array(setup)
+        @turns = Array(turns)
+        @expect = self.class.symbolize(expect)
+      end
+
+      def max_turns
+        @expect.fetch(:max_turns, DEFAULT_MAX_TURNS)
+      end
+
+      def to_s
+        "#{group}/#{id}"
+      end
+    end
+
+    class BoundedClient
+      TurnLimitExceeded = Class.new(StandardError)
+
+      attr_reader :turns
+
+      def initialize(client, max_turns:)
+        @client = client
+        @max_turns = max_turns
+        @turns = 0
+      end
+
+      def fetch(messages, tools = [], &block)
+        @turns += 1
+        raise TurnLimitExceeded, "turn limit #{@max_turns} exceeded" if @turns > @max_turns
+
+        @client.fetch(messages, tools, &block)
+      end
+    end
+
+    class Assertions
+      def initialize(expect)
+        @expect = expect || {}
+      end
+
+      def failures(workspace:, response:)
+        [
+          verify_failure(workspace),
+          *file_failures(workspace),
+          *response_failures(response)
+        ].compact
+      end
+
+      private
+
+      def verify_failure(workspace)
+        command = @expect[:verify]
+        return unless command
+
+        result = workspace.sh(command)
+        return if result[:exit_status].zero?
+
+        "verify failed (exit #{result[:exit_status]}): #{command}"
+      end
+
+      def file_failures(workspace)
+        (@expect[:files] || {}).flat_map do |path, rules|
+          content = workspace.read(path)
+          next ["#{path} missing"] unless content
+
+          missing = Array(rules["contains"]).reject { |text| content.include?(text) }
+          present = Array(rules["not_contains"]).select { |text| content.include?(text) }
+
+          missing.map { |text| "#{path} missing #{text.inspect}" } +
+            present.map { |text| "#{path} still contains #{text.inspect}" }
+        end
+      end
+
+      def response_failures(response)
+        text = response.to_s
+
+        Array(@expect[:response_contains]).reject { |s| text.include?(s) }.map { |s| "response missing #{s.inspect}" } +
+          Array(@expect[:response_not_contains]).select { |s| text.include?(s) }.map { |s| "response contains #{s.inspect}" } +
+          Array(@expect[:response_matches]).reject { |p| Regexp.new(p).match?(text) }.map { |p| "response does not match /#{p}/" }
+      end
+    end
+
+    Result = Data.define(:id, :group, :failures, :response) do
+      def passed?
+        failures.empty?
+      end
+    end
+
+    CHAMPION = File.expand_path("../../lib/elelem/prompts/default.erb", __dir__)
+
+    class Runner
+      def initialize(prompt:, client: -> { Evals.client })
+        @prompt = prompt
+        @client = client
+      end
+
+      def run(kase)
+        response = nil
+        bounded = BoundedClient.new(@client.call, max_turns: kase.max_turns)
+
+        Workspace.open(fixture: kase.fixture, setup: kase.setup) do |workspace|
+          workspace.chdir do
+            agent = build_agent(bounded)
+            kase.turns.each { |turn| response = agent.turn(turn) }
+
+            failures = Assertions.new(kase.expect).failures(workspace: workspace, response: response)
+
+            Result.new(id: kase.id, group: kase.group, failures: failures, response: response.to_s)
+          end
+        end
+      end
+
+      private
+
+      def build_agent(client)
+        agent = Elelem::Agent.new(client, terminal: NullTerminal.new, system_prompt: @prompt)
+        Elelem::Plugins.setup!(agent)
+        agent
+      end
+    end
+
+    class Workspace
+      SetupFailed = Class.new(StandardError)
+
+      FIXTURES = File.expand_path("fixtures", __dir__)
+
+      def self.open(fixture:, setup: [])
+        dir = Dir.mktmpdir("elelem-evals-")
+        workspace = new(dir, fixture)
+        workspace.prepare(setup)
+        yield workspace
+      ensure
+        FileUtils.remove_entry(dir) if dir && File.directory?(dir)
+      end
+
+      attr_reader :path
+
+      def initialize(path, fixture)
+        @path = path
+        @fixture = fixture
+      end
+
+      def prepare(setup)
+        FileUtils.cp_r("#{FIXTURES}/#{@fixture}/.", @path)
+        chdir { setup.each { |command| run_setup(command) } }
+      end
+
+      def chdir(&block)
+        Dir.chdir(@path) { Bundler.with_unbundled_env(&block) }
+      end
+
+      def sh(command)
+        Elelem.sh("bash", args: ["-c", command], cwd: @path)
+      end
+
+      def read(relative)
+        file = File.join(@path, relative)
+        File.exist?(file) ? File.read(file) : nil
+      end
+
+      private
+
+      def run_setup(command)
+        result = sh(command)
+        return if result[:exit_status].zero?
+
+        raise SetupFailed, "setup command failed (exit #{result[:exit_status]}): #{command}\n#{result[:content]}"
+      end
+    end
+  end
+end
+
 RSpec.describe "eval cases" do
   runner = Elelem::Evals::Runner.new(prompt: File.read(Elelem::Evals::CHAMPION))
 
spec/spec_helper.rb
@@ -2,8 +2,10 @@
 
 require_relative "../lib/elelem"
 
+require "fileutils"
 require "shellwords"
 require "tmpdir"
+require "yaml"
 
 Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f }