Commit 3f682f8

2026-09-07 03:51:37
feat: scaffold elelem-evals gem
Ports the eval harness out of elelem core's spec suite into its own gem with a real exe/elelem-evals CLI (-p provider, -c case glob, -l list, -P prompt), targeting any registered Elelem::Providers provider instead of only running under rspec. Claude-Session: https://claude.ai/code/session_01DUuj4amvRrPxDnHPHBkvz5
bin/console
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "bundler/setup"
+require "elelem/evals"
+require "irb"
+
+IRB.start(__FILE__)
bin/setup
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+IFS=$'\n\t'
+set -vx
+
+bundle install
+
+# Do any other automated setup that you need to do here
cases/build.yml
@@ -0,0 +1,10 @@
+# Defends: run the tests, interpret the failure, and DON'T edit when only asked to diagnose.
+- id: diagnose-failing-test
+  fixture: broken
+  turns:
+    - run test.rb and explain what is broken, but do not change any files
+  expect:
+    response_contains: ["add"]
+    files:
+      lib/calc.rb:
+        contains: ["a - b"]
cases/create.yml
@@ -0,0 +1,11 @@
+# Defends: scaffold a new program from scratch and run it.
+- id: fizzbuzz-from-scratch
+  fixture: blank
+  turns:
+    - create fizzbuzz.rb that prints the numbers 1 to 15 one per line, but Fizz for
+      multiples of 3, Buzz for multiples of 5, and FizzBuzz for multiples of 15; then run it
+  expect:
+    verify: '[ "$(ruby fizzbuzz.rb | sed -n 3p)" = "Fizz" ] && [ "$(ruby fizzbuzz.rb | sed -n 5p)" = "Buzz" ] && [ "$(ruby fizzbuzz.rb | sed -n 15p)" = "FizzBuzz" ]'
+    files:
+      fizzbuzz.rb:
+        contains: ["FizzBuzz"]
cases/edit.yml
@@ -0,0 +1,36 @@
+# 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
+# was rewritten, not just the new method appended blindly.
+- id: read-before-write
+  fixture: metadata
+  turns:
+    - add a user_id method to the User class in user.rb that returns the row's "id" value
+  expect:
+    verify: ruby -e 'require "./user"; u = User.new({"id" => 7, "app_metadata" => {"a" => 1}}); raise unless u.user_id == 7 && u.app_metadata == {"a" => 1}'
+    files:
+      user.rb:
+        contains: ["user_id", "app_metadata"]
+
+# Defends: "Use `sed` only for a trivial single-line substitution."
+- id: restructure-with-write
+  fixture: metadata
+  turns:
+    - wrap the User class in user.rb in a module named Auth, keeping the class body
+      unchanged
+  expect:
+    verify: ruby -e 'require "./user"; Auth::User'
+    files:
+      user.rb:
+        contains: ["module Auth"]
+
+- id: multi-line-edit
+  fixture: hello
+  turns:
+    - 'change greet so it prints "Goodbye, #{name}." with a period instead of an
+      exclamation mark, and call it with "friend" instead of "world"'
+  expect:
+    verify: ruby hello.rb | grep -qxF "Goodbye, friend."
+    files:
+      hello.rb:
+        contains: ["Goodbye"]
+        not_contains: ["Hello"]
cases/fix.yml
@@ -0,0 +1,9 @@
+- id: fix-failing-test
+  fixture: broken
+  turns:
+    - run test.rb and fix whatever is broken
+  expect:
+    verify: ruby test.rb
+    files:
+      lib/calc.rb:
+        contains: ["a + b"]
cases/git.yml
@@ -0,0 +1,27 @@
+- id: commit-50-72
+  fixture: hello
+  setup:
+    - git init -q
+    - git config user.email [email protected]
+    - git config user.name evals
+    - git add -A
+    - git commit -q -m "initial commit"
+    - printf 'greet("again")\n' >> hello.rb
+  turns:
+    - 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
+
+# Defends: query git history and report, without editing.
+- id: last-commit-subject
+  fixture: hello
+  setup:
+    - git init -q
+    - git config user.email [email protected]
+    - git config user.name evals
+    - git add -A
+    - git commit -q -m "Add greeting script"
+  turns:
+    - what is the subject line of the most recent git commit?
+  expect:
+    response_contains: ["Add greeting script"]
cases/holdout.yml
@@ -0,0 +1,62 @@
+# Held out from the improver: it never sees these failures, but a regression here
+# still blocks a promotion -- a guard against prompts that overfit the visible set.
+- id: rename-method-holdout
+  fixture: hello
+  turns:
+    - rename the greet method to hail, including the call at the bottom
+  expect:
+    verify: ruby hello.rb
+    files:
+      hello.rb:
+        contains: ["hail"]
+        not_contains: ["greet"]
+
+# Paraphrased twin of edit/read-before-write, held out so the improver can't
+# overfit the read-then-write instruction to one fixture.
+- id: read-before-write-holdout
+  fixture: metadata
+  turns:
+    - add an issuer method to the Token class in token.rb that returns the "iss" claim
+  expect:
+    verify: ruby -e 'require "./token"; t = Token.new({"iss" => "acme", "app_metadata" => {"a" => 1}}); raise unless t.issuer == "acme" && t.to_h["app_metadata"] == {"a" => 1}'
+    files:
+      token.rb:
+        contains: ["issuer", "to_h"]
+
+# Paraphrased twin of voice/concise-answer: holds the conciseness bound out of
+# the improver's view so terseness can't overfit to one prompt.
+- id: concise-answer-holdout
+  fixture: metadata
+  turns:
+    - which file defines the Token class?
+  expect:
+    response_contains: ["token.rb"]
+    response_matches: ['\A[\s\S]{0,400}\z']
+
+- id: locate-error-holdout
+  fixture: config
+  turns:
+    - which file raises the "Failed to load configuration" error?
+  expect:
+    response_contains: ["config/loader.rb"]
+
+- id: locate-class
+  fixture: config
+  turns:
+    - which file defines the Loader class?
+  expect:
+    response_contains: ["config/loader.rb"]
+
+- id: count-ruby-files
+  fixture: metadata
+  turns:
+    - how many ruby files are in this project?
+  expect:
+    response_matches: ['\b3\b']
+
+- id: locate-parser
+  fixture: config
+  turns:
+    - which file defines the Parser class?
+  expect:
+    response_contains: ["config/parser.rb"]
cases/implement.yml
@@ -0,0 +1,10 @@
+# Defends: implement a function from a stub until its test passes (Exercism/HumanEval style).
+- id: roman-numerals
+  fixture: roman
+  turns:
+    - implement to_roman in roman.rb so that `ruby roman_test.rb` passes
+  expect:
+    verify: ruby roman_test.rb
+    files:
+      roman.rb:
+        not_contains: ["NotImplementedError"]
cases/json.yml
@@ -0,0 +1,7 @@
+- id: summarize-json-log
+  fixture: logs
+  turns:
+    - which path returned the most 5xx responses in requests.jsonl, and how many?
+  expect:
+    response_contains: ["/scim/v2/Users"]
+    response_matches: ['/scim/v2/Users[\s\S]{0,120}3']
cases/ops.yml
@@ -0,0 +1,11 @@
+# Shell-verified ops tasks. No privileged or destructive commands yet -- these
+# run in the same tmpdir workspace as every other case.
+- id: count-500s
+  fixture: logs
+  turns:
+    - count how many lines in requests.jsonl have response_code 500 and write
+      just that number to count.txt
+  expect:
+    verify: 'test "$(cat count.txt)" = "$(grep -c ''"response_code":500'' requests.jsonl)"'
+    files:
+      count.txt: {}
cases/refactor.yml
@@ -0,0 +1,14 @@
+# Defends: a multi-file edit that renames a symbol and its caller, behavior intact.
+- id: rename-across-files
+  fixture: greeter
+  turns:
+    - rename the greeting method to salutation everywhere, including its caller
+  expect:
+    verify: ruby main.rb
+    files:
+      lib.rb:
+        contains: ["salutation"]
+        not_contains: ["def greeting"]
+      main.rb:
+        contains: ["salutation"]
+        not_contains: ["greeting"]
cases/search.yml
@@ -0,0 +1,30 @@
+# Defends: locate where an error string originates by searching, then answer.
+- id: locate-error-string
+  fixture: config
+  turns:
+    - this app raises 'Failed to load configuration'. which file in this project raises it?
+  expect:
+    response_contains: ["config/loader.rb"]
+
+# Defends: "- files: `fd -e rb .`"
+- id: count-ruby-files
+  fixture: metadata
+  turns:
+    - how many ruby files are in this project?
+  expect:
+    response_matches: ['\b3\b']
+
+# Defends: "- text: `rg -n "pattern" .`"
+- id: grep-with-ripgrep
+  fixture: config
+  turns:
+    - search the codebase for the text "Failed to load configuration" and tell me which file contains it
+  expect:
+    response_contains: ["config/loader.rb"]
+
+- id: list-references
+  fixture: metadata
+  turns:
+    - which files reference app_metadata?
+  expect:
+    response_contains: ["user.rb", "token.rb"]
cases/shell.yml
@@ -0,0 +1,17 @@
+- id: largest-tracked-file
+  fixture: sizes
+  setup:
+    - for i in $(seq 1 400); do printf '%d,widget-%d,%d\n' "$i" "$i" $((i * 7)) >> data/report.csv; done
+    - git init -q
+    - git config user.email [email protected]
+    - git config user.name evals
+    - git add -A
+    - git commit -q -m "initial commit"
+    # .git/description is never read by git, so padding it is a safe stand-in
+    # for the pack files that dominate a real repository's byte counts.
+    - for i in $(seq 1 1200); do printf 'padding line %d\n' "$i" >> .git/description; done
+  turns:
+    - what is the biggest tracked file in this repository?
+  expect:
+    response_contains: ["report.csv"]
+    response_not_contains: [".git/"]
cases/unix.yml
@@ -0,0 +1,23 @@
+# Defends: answer with a shell pipeline over tracked files, not by editing.
+- id: count-files-with-string
+  fixture: metadata
+  turns:
+    - write the number of ruby files that contain the text app_metadata to count.txt
+  expect:
+    verify: '[ "$(cat count.txt)" = "2" ]'
+
+# Defends: portable byte size (BSD `stat -f%z` vs GNU `stat -c%s`).
+- id: byte-size-portable
+  fixture: sizes
+  turns:
+    - write the size of README.md in bytes to size.txt
+  expect:
+    verify: '[ "$(cat size.txt)" = "$(wc -c < README.md | tr -d " ")" ]'
+
+# Defends: aggregate a value across many files with a pipeline.
+- id: total-ruby-lines
+  fixture: metadata
+  turns:
+    - 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 " ")" ]'
cases/voice.yml
@@ -0,0 +1,11 @@
+# Defends: "Answer in as few words as the task allows -- no preamble, no filler."
+# The right answer is a single filename. The length bound passes a concise reply
+# that names the file and stops, but fails a padded, preamble-laden one -- so the
+# voice line earns its place and the ablator won't strip it.
+- id: concise-answer
+  fixture: config
+  turns:
+    - which file defines the Parser class?
+  expect:
+    response_contains: ["config/parser.rb"]
+    response_matches: ['\A[\s\S]{0,400}\z']
exe/elelem-evals
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "elelem/evals"
+
+Elelem::Evals::CLI.run(ARGV)
fixtures/blank/README.md
@@ -0,0 +1,3 @@
+# Scratch
+
+An empty workspace for building something new.
fixtures/broken/lib/calc.rb
@@ -0,0 +1,3 @@
+def add(a, b)
+  a - b
+end
fixtures/broken/test.rb
@@ -0,0 +1,5 @@
+require_relative "lib/calc"
+
+raise "add(2, 3) should be 5, got #{add(2, 3)}" unless add(2, 3) == 5
+
+puts "ok"
fixtures/config/config/loader.rb
@@ -0,0 +1,11 @@
+class Loader
+  def initialize(path)
+    @path = path
+  end
+
+  def call
+    raise "Failed to load configuration: #{@path}" unless File.exist?(@path)
+
+    Parser.new(File.read(@path)).call
+  end
+end
fixtures/config/config/parser.rb
@@ -0,0 +1,9 @@
+class Parser
+  def initialize(text)
+    @text = text
+  end
+
+  def call
+    @text.lines.map { |line| line.split(":", 2) }.to_h
+  end
+end
fixtures/config/app.rb
@@ -0,0 +1,3 @@
+require_relative "config/loader"
+
+Loader.new("app.yml").call
fixtures/greeter/lib.rb
@@ -0,0 +1,3 @@
+def greeting(name)
+  "Hi, #{name}"
+end
fixtures/greeter/main.rb
@@ -0,0 +1,3 @@
+require_relative "lib"
+
+puts greeting("world")
fixtures/hello/hello.rb
@@ -0,0 +1,5 @@
+def greet(name)
+  puts "Hello, #{name}!"
+end
+
+greet("world")
fixtures/logs/requests.jsonl
@@ -0,0 +1,12 @@
+{"path":"/scim/v2/Users","response_code":200}
+{"path":"/scim/v2/Users","response_code":500}
+{"path":"/scim/v2/Users","response_code":503}
+{"path":"/scim/v2/Users","response_code":500}
+{"path":"/scim/v2/Groups","response_code":200}
+{"path":"/scim/v2/Groups","response_code":404}
+{"path":"/scim/v2/Groups","response_code":200}
+{"path":"/health","response_code":200}
+{"path":"/health","response_code":500}
+{"path":"/health","response_code":200}
+{"path":"/scim/v2/Schemas","response_code":200}
+{"path":"/scim/v2/Schemas","response_code":304}
fixtures/metadata/session.rb
@@ -0,0 +1,10 @@
+class Session
+  def initialize(id, user_id)
+    @id = id
+    @user_id = user_id
+  end
+
+  def to_h
+    { "id" => @id, "user_id" => @user_id }
+  end
+end
fixtures/metadata/token.rb
@@ -0,0 +1,9 @@
+class Token
+  def initialize(claims)
+    @claims = claims
+  end
+
+  def to_h
+    @claims.merge("app_metadata" => @claims["app_metadata"] || {})
+  end
+end
fixtures/metadata/user.rb
@@ -0,0 +1,9 @@
+class User
+  def initialize(row)
+    @row = row
+  end
+
+  def app_metadata
+    @row.fetch("app_metadata", {})
+  end
+end
fixtures/roman/roman.rb
@@ -0,0 +1,3 @@
+def to_roman(number)
+  raise NotImplementedError
+end
fixtures/roman/roman_test.rb
@@ -0,0 +1,8 @@
+require_relative "roman"
+
+{ 1 => "I", 4 => "IV", 9 => "IX", 40 => "XL", 90 => "XC", 2024 => "MMXXIV" }.each do |number, want|
+  got = to_roman(number)
+  raise "to_roman(#{number}) = #{got.inspect}, want #{want.inspect}" unless got == want
+end
+
+puts "ok"
fixtures/sizes/data/report.csv
@@ -0,0 +1,1 @@
+id,widget,cents
fixtures/sizes/lib/app.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "csv"
+
+class App
+  def initialize(path = "data/report.csv")
+    @path = path
+  end
+
+  def rows
+    CSV.read(@path, headers: true)
+  end
+
+  def total
+    rows.sum { |row| row["cents"].to_i }
+  end
+end
fixtures/sizes/README.md
@@ -0,0 +1,3 @@
+# Widgets
+
+Inventory reports live in `data/`.
lib/elelem/evals/assertions.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    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
+  end
+end
lib/elelem/evals/bounded_client.rb
@@ -0,0 +1,24 @@
+# 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
lib/elelem/evals/case.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "yaml"
+
+module Elelem
+  module Evals
+    class Case
+      DEFAULT_MAX_TURNS = 30
+
+      def self.load_all(dir)
+        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
lib/elelem/evals/cli.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "optparse"
+
+module Elelem
+  module Evals
+    class CLI
+      ROOT = File.expand_path("../../..", __dir__)
+
+      def self.run(argv)
+        new.run(argv)
+      end
+
+      def run(argv)
+        options = parse(argv)
+        cases = Case.load_all(options[:cases]).select { |kase| match?(kase, options[:filter]) }
+
+        return list(cases) if options[:list]
+
+        client = -> { Elelem::Providers.build(options[:provider]) }
+        runner = Runner.new(fixtures: options[:fixtures], client: client, prompt: options[:prompt])
+        reporter = Reporter.new
+
+        cases.each do |kase|
+          reporter.record(runner.run(kase))
+        rescue StandardError => e
+          reporter.error(kase, e)
+        end
+
+        exit(reporter.summarize ? 0 : 1)
+      end
+
+      private
+
+      def list(cases)
+        cases.each { |kase| puts kase.to_s }
+      end
+
+      def match?(kase, filter)
+        filter.nil? || File.fnmatch(filter, kase.to_s)
+      end
+
+      def parse(argv)
+        options = {
+          provider: ENV["ELELEM_PROVIDER"] || "llama",
+          cases: File.join(ROOT, "cases"),
+          fixtures: File.join(ROOT, "fixtures"),
+          prompt: nil,
+          filter: nil,
+          list: false
+        }
+
+        OptionParser.new do |parser|
+          parser.banner = "Usage: elelem-evals [options]"
+
+          parser.on("-p", "--provider NAME", "Provider to run against (default: llama)") { |v| options[:provider] = v }
+          parser.on("-c", "--cases GLOB", "Filter cases by group/id glob, e.g. build/*") { |v| options[:filter] = v }
+          parser.on("--cases-dir DIR", "Directory of case yaml files") { |v| options[:cases] = v }
+          parser.on("--fixtures-dir DIR", "Directory of fixture directories") { |v| options[:fixtures] = v }
+          parser.on("-P", "--prompt FILE", "System prompt template file") { |v| options[:prompt] = File.read(v) }
+          parser.on("-l", "--list", "List matching cases without running them") { options[:list] = true }
+        end.parse!(argv)
+
+        options
+      end
+    end
+  end
+end
lib/elelem/evals/null_terminal.rb
@@ -0,0 +1,15 @@
+# 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
lib/elelem/evals/reporter.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    class Reporter
+      def initialize(out: $stdout)
+        @out = out
+        @results = []
+        @errors = 0
+      end
+
+      def record(result)
+        @results << result
+        @out.puts "#{result.passed? ? "PASS" : "FAIL"} #{result.group}/#{result.id}"
+        return if result.passed?
+
+        result.failures.each { |failure| @out.puts "  #{failure}" }
+      end
+
+      def error(kase, error)
+        @errors += 1
+        @out.puts "ERROR #{kase.group}/#{kase.id}: #{error.class}: #{error.message}"
+      end
+
+      def summarize
+        passed = @results.count(&:passed?)
+        total = @results.size + @errors
+        @out.puts
+        @out.puts "#{passed}/#{total} passed"
+        passed == total && @errors.zero?
+      end
+    end
+  end
+end
lib/elelem/evals/result.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    Result = Data.define(:id, :group, :failures, :response) do
+      def passed?
+        failures.empty?
+      end
+    end
+  end
+end
lib/elelem/evals/runner.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    class Runner
+      def initialize(fixtures:, client:, prompt: nil)
+        @fixtures = fixtures
+        @client = client
+        @prompt = prompt
+      end
+
+      def run(kase)
+        response = nil
+        bounded = BoundedClient.new(@client.call, max_turns: kase.max_turns)
+
+        Workspace.open(fixtures: @fixtures, 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
+  end
+end
lib/elelem/evals/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    VERSION = "0.1.0"
+  end
+end
lib/elelem/evals/workspace.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "tmpdir"
+
+module Elelem
+  module Evals
+    class Workspace
+      SetupFailed = Class.new(StandardError)
+
+      def self.open(fixtures:, fixture:, setup: [])
+        dir = Dir.mktmpdir("elelem-evals-")
+        workspace = new(dir, fixtures, fixture)
+        workspace.prepare(setup)
+        yield workspace
+      ensure
+        FileUtils.remove_entry(dir) if dir && File.directory?(dir)
+      end
+
+      attr_reader :path
+
+      def initialize(path, fixtures, fixture)
+        @path = path
+        @fixtures = fixtures
+        @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
lib/elelem/evals.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require "elelem"
+
+require_relative "evals/version"
+require_relative "evals/case"
+require_relative "evals/bounded_client"
+require_relative "evals/assertions"
+require_relative "evals/workspace"
+require_relative "evals/null_terminal"
+require_relative "evals/result"
+require_relative "evals/runner"
+require_relative "evals/reporter"
+require_relative "evals/cli"
.gitignore
@@ -0,0 +1,8 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
elelem-evals.gemspec
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/evals/version"
+
+Gem::Specification.new do |spec|
+  spec.name = "elelem-evals"
+  spec.version = Elelem::Evals::VERSION
+  spec.authors = ["mo khan"]
+  spec.email = ["[email protected]"]
+
+  spec.summary = "Eval harness for elelem providers."
+  spec.description = "Runs scenario-based evals against any elelem provider."
+  spec.homepage = "https://src.mokhan.ca/elelem/evals"
+  spec.license = "MIT"
+  spec.required_ruby_version = ">= 4.0.0"
+  spec.metadata["allowed_push_host"] = "https://rubygems.org"
+  spec.metadata["homepage_uri"] = spec.homepage
+  spec.metadata["source_code_uri"] = spec.homepage
+
+  gemspec = File.basename(__FILE__)
+  spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
+    ls.readlines("\x0", chomp: true).reject do |f|
+      (f == gemspec) || f.start_with?(*%w[bin/ Gemfile .gitignore])
+    end
+  end
+  spec.bindir = "exe"
+  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "elelem", "~> 0.10"
+  spec.add_dependency "yaml", "~> 0.4"
+end
Gemfile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec name: "elelem-evals"
+
+gem "irb"
+gem "rake", "~> 13.0"
Gemfile.lock
@@ -0,0 +1,149 @@
+PATH
+  remote: .
+  specs:
+    elelem-evals (0.1.0)
+      elelem (~> 0.10)
+      yaml (~> 0.4)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    base64 (0.3.0)
+    bigdecimal (4.1.2)
+    date (3.5.1)
+    digest (3.2.1)
+    elelem (0.10.0)
+      base64 (~> 0.1)
+      date (~> 3.0)
+      digest (~> 3.0)
+      erb (~> 6.0)
+      fileutils (~> 1.0)
+      json (~> 2.0)
+      json_schemer (~> 2.0)
+      logger (~> 1.0)
+      net-hippie (~> 1.0)
+      open3 (~> 0.1)
+      optparse (~> 0.1)
+      pathname (~> 0.1)
+      reline (~> 0.6)
+      securerandom (~> 0.1)
+      shellwords (~> 0.2)
+      stringio (~> 3.0)
+      tempfile (~> 0.3)
+      uri (~> 1.0)
+      webrick (~> 1.9)
+    erb (6.0.7)
+    fileutils (1.8.0)
+    hana (1.3.7)
+    io-console (0.9.2)
+    irb (1.18.0)
+      pp (>= 0.6.0)
+      prism (>= 1.3.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (2.21.2)
+    json_schemer (2.5.0)
+      bigdecimal
+      hana (~> 1.3)
+      regexp_parser (~> 2.0)
+      simpleidn (~> 0.2)
+    logger (1.7.0)
+    monitor (0.2.0)
+    net-hippie (1.5.1)
+      base64 (~> 0.1)
+      json (~> 2.0)
+      logger (~> 1.0)
+      monitor (~> 0.1)
+      net-http (~> 0.1)
+      openssl (~> 4.0)
+      resolv (~> 0.1)
+      timeout (~> 0.1)
+    net-http (0.9.1)
+      uri (>= 0.11.1)
+    open3 (0.2.1)
+    openssl (4.0.2)
+    optparse (0.8.1)
+    pathname (0.5.0)
+    pp (0.6.4)
+      prettyprint
+    prettyprint (0.2.0)
+    prism (1.9.0)
+    rake (13.4.2)
+    rbs (4.2.0)
+      logger
+      prism (>= 1.6.0)
+      tsort
+    rdoc (8.0.0)
+      erb
+      prism (>= 1.6.0)
+      rbs (>= 4.0.0)
+      tsort
+    regexp_parser (2.12.0)
+    reline (0.7.0)
+      io-console (~> 0.5)
+    resolv (0.7.2)
+    securerandom (0.4.1)
+    shellwords (0.2.2)
+    simpleidn (0.3.0)
+    stringio (3.2.0)
+    tempfile (0.3.1)
+    timeout (0.6.1)
+    tsort (0.2.0)
+    uri (1.1.1)
+    webrick (1.9.2)
+    yaml (0.4.0)
+
+PLATFORMS
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  elelem-evals!
+  irb
+  rake (~> 13.0)
+
+CHECKSUMS
+  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+  bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+  digest (3.2.1) sha256=ab3312b4e272d7d5dc41c564c86a25861a1f34ac5153374199a0b74861395947
+  elelem (0.10.0) sha256=de67f3a28351640da471e6e80e7a1d79e83e293cbc502a9e98c3c609cad2e237
+  elelem-evals (0.1.0)
+  erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+  fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+  hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
+  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+  json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  monitor (0.2.0) sha256=18698584f161ca6611d73130663990ca7dc439d9d88095226e683035d66f1f0f
+  net-hippie (1.5.1) sha256=76ecfc7d8df7866c3ed4f2e25fe963d87537451a1fb0d318b58eca161127fbcc
+  net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
+  open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
+  openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
+  optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+  pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
+  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+  rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
+  reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  resolv (0.7.2) sha256=626d044d975ab2daac759bf898416f1b51e2cb8dcd6727c2b5b5b28b97ead2e1
+  securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+  shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
+  simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
+  stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+  tempfile (0.3.1) sha256=0bb53ab646744e505eb3102147e22ae130a626a15563e882428c1ec973fed76a
+  timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
+  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+  yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942
+
+BUNDLED WITH
+  4.0.20
LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2026 mo khan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
Rakefile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require "bundler/gem_tasks"
+
+task default: %i[]