Commit 3f682f8
2026-09-07 03:51:37
Changed files (52)
cases
exe
fixtures
blank
hello
logs
metadata
roman
sizes
lib
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[]