tinyeval 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b7c6ac99dd71dd4c4be5357ed72256fab49e2515a40192e78ebc95b4c8897fa6
4
+ data.tar.gz: 2c657612bb89482b501e563df4a2f7f44279dee5db09cd234ecca607c187ac78
5
+ SHA512:
6
+ metadata.gz: 64940ca3bbee3d08b992aba4ae828d33d945ade046494a23b836d6f92860fe71bc172c5dd01eb7046767245ad8a6df90b215f1f31a1991e07ff84e4f657128fd
7
+ data.tar.gz: 99c679395ab5c523bf5f3e0f2eefaf1791db80e88657253df69038dc9d48d0e1c5bd4ac261b95b363c9b7d594bad9f0e97bbfceb0db6c45fe484f4be99c3a972
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 evalship
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # tinyeval
2
+
3
+ Test the AI calls in your Ruby or Rails app with evals, and run them in CI next to your other tests.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bundle add tinyeval --group development
9
+ ```
10
+
11
+ ## Write an eval
12
+
13
+ `evals/support.sh` lists the test cases and how to grade each reply:
14
+
15
+ ```bash
16
+ worker bundle exec ruby evals/worker.rb
17
+
18
+ test_case "refund after the window" "Can I still return my shoes? They arrived 40 days ago."
19
+ matches "30[- ]day"
20
+ judge "Says a refund isn't possible anymore, politely"
21
+ ```
22
+
23
+ `evals/worker.rb` loads your app once and answers each test case:
24
+
25
+ ```ruby
26
+ require "tinyeval/worker" # before your app, so its output stays off the replies
27
+ require_relative "../lib/support"
28
+
29
+ support = Support.new
30
+ Tinyeval.worker do |input|
31
+ raise Tinyeval::CouldntRun, "ANTHROPIC_API_KEY isn't set" unless ENV["ANTHROPIC_API_KEY"]
32
+ support.answer(input)
33
+ end
34
+ ```
35
+
36
+ In a Rails app, Rails boots once per worker with this line, and the worker calls your own class:
37
+
38
+ ```bash
39
+ worker bin/rails runner evals/worker.rb
40
+ ```
41
+
42
+ ## Run it
43
+
44
+ ```sh
45
+ bundle exec tinyeval
46
+ ```
47
+
48
+ ## When the AI call fails
49
+
50
+ Raise `Tinyeval::CouldntRun` when the call can't run at all. tinyeval then counts the run as couldn't run, apart from the failures. Errors from the `anthropic` and `openai` gems count too when they are a 401, a 429, a 5xx, a timeout or a lost connection.
51
+
52
+ Any other error becomes the reply, so the eval fails. Its stack trace goes to stderr.
53
+
54
+ ## Needs bash
55
+
56
+ tinyeval is a bash script. It runs on bash 3.2 or later: macOS and Linux have it, and on Windows it uses Git for Windows' bash.
57
+
58
+ ## Updating
59
+
60
+ ```sh
61
+ bundle update tinyeval
62
+ ```
63
+
64
+ Dependabot and Renovate see new versions in your `Gemfile.lock`. A repo that commits its own `./tinyeval` file keeps running that file.
65
+
66
+ ## More
67
+
68
+ - [tinyeval.com](https://tinyeval.com)
69
+ - [Writing evals](https://github.com/evalship-hq/tinyeval#writing-evals): every check line, the judge and the options
70
+ - [evalship-hq/ruby-llm-evals](https://github.com/evalship-hq/ruby-llm-evals): a Ruby support bot with its evals in CI
data/exe/tinyeval ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs the bundled bash tinyeval. It loads nothing from lib: the worker's stdout redirect would hide tinyeval's output.
5
+ runner = File.expand_path("../tinyeval", __dir__)
6
+ ENV["TINYEVAL_PACKAGE_UPDATE"] = ENV["BUNDLE_GEMFILE"] ? "bundle update tinyeval" : "gem update tinyeval"
7
+
8
+ bash = "bash"
9
+ if Gem.win_platform?
10
+ # Git for Windows' bash, next to the cmd folder that has git.exe, before a bash on PATH, which may be WSL's.
11
+ git_dirs = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).select { |dir| File.file?(File.join(dir, "git.exe")) }
12
+ candidates = git_dirs.map { |dir| File.expand_path("../bin/bash.exe", dir) } << "C:/Program Files/Git/bin/bash.exe"
13
+ bash = candidates.find { |path| File.file?(path) } || bash
14
+ end
15
+
16
+ begin
17
+ exec(bash, runner, *ARGV)
18
+ rescue SystemCallError
19
+ warn "tinyeval needs bash 3.2 or later. On Windows, install Git for Windows: https://git-scm.com/download/win"
20
+ exit 2
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tinyeval"
4
+
5
+ # Require this before your app. From here on, all output to stdout goes to stderr, at the file descriptor, so
6
+ # puts, child processes and C extensions stay off the replies. The replies go to the real stdout.
7
+ module Tinyeval
8
+ REPLIES = STDOUT.dup.binmode
9
+ STDOUT.reopen(STDERR)
10
+
11
+ # Answers each input tinyeval sends, up to a NUL byte, with the block's return value and a NUL byte.
12
+ def self.worker
13
+ $stdin.binmode
14
+ while (input = $stdin.gets("\0"))
15
+ break unless input.chomp!("\0") # stdin closed partway through an input
16
+ reply =
17
+ begin
18
+ out = yield(input.force_encoding(Encoding::UTF_8)).to_s
19
+ out.encoding == Encoding::BINARY ? out : out.encode(Encoding::UTF_8) # bytes, as Net::HTTP gives, go as is
20
+ rescue StandardError => e
21
+ if (reason = couldnt_run(e))
22
+ "#tinyeval couldnt_run #{reason.split.join(" ")}\n"
23
+ else
24
+ warn e.full_message
25
+ "#{e.class}: #{e.message}" # the reply, so the eval fails
26
+ end
27
+ end
28
+ REPLIES.write(reply.b.delete("\0"), "\0") # .b: delete raises on invalid UTF-8
29
+ REPLIES.flush
30
+ end
31
+ end
32
+
33
+ # Why the AI call couldn't run, or nil when the error is the app's. The anthropic and openai gems' errors are
34
+ # matched by class name, so neither gem has to be installed: 401, 429, 5xx, connection errors and timeouts.
35
+ def self.couldnt_run(error)
36
+ return error.message if error.is_a?(CouldntRun)
37
+
38
+ names = error.class.ancestors.map(&:name).compact
39
+ sdk = names.grep(/\A(Anthropic|OpenAI)::Errors::APIError\z/).first or return
40
+ status = error.status if error.respond_to?(:status)
41
+ connection = names.include?("#{sdk[/\A\w+/]}::Errors::APIConnectionError") # timeouts are one too
42
+ return unless connection || [401, 429].include?(status) || status.to_i >= 500
43
+
44
+ "#{sdk[/\A\w+/].downcase}: #{[status, error.class.name.to_s[/\w+\z/]].compact.join(" ")}"
45
+ end
46
+ end
data/lib/tinyeval.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Leaves stdout alone, so Bundler.require can load it. A worker requires "tinyeval/worker" instead.
4
+ module Tinyeval
5
+ # Raise it when the AI call couldn't run, such as without an API key. tinyeval then counts the run apart from
6
+ # the failures, since it says nothing about the reply.
7
+ class CouldntRun < StandardError; end
8
+ end
data/tinyeval ADDED
@@ -0,0 +1,784 @@
1
+ #!/usr/bin/env bash
2
+ # tinyeval: evals for AI features, in one bash file. https://github.com/evalship-hq/tinyeval
3
+ # Runs on bash 3.2 (macOS) and up with the tools every bash install has. judge also needs curl.
4
+
5
+ _te_version=0.11.0
6
+ _te_usage="Usage: tinyeval [-n runs] [-j jobs] [-t seconds] [-k text] [-o file] [-x file] [eval files...]
7
+ tinyeval update
8
+ tinyeval share [file]
9
+
10
+ Runs every test case in the eval files (default: evals/*.sh) and writes the
11
+ results to eval-results.json. Exits 1 when an eval fails or couldn't run
12
+ (its AI call failed or timed out, or the judge couldn't answer).
13
+
14
+ -n runs run each test case this many times; it passes when at least half pass
15
+ -j jobs run this many test cases at once (default: 4)
16
+ -t seconds stop an AI call that takes longer (default: 300)
17
+ -k text run only the test cases whose name contains text
18
+ -o file where to write the results (default: eval-results.json)
19
+ -x file also write them as JUnit XML, which most CI systems show
20
+ -v print the version
21
+ update replace this file with the latest release
22
+ share put the results (default: eval-results.json) behind a private
23
+ link on evalship.com for 30 days, with their inputs and replies
24
+
25
+ An eval file is bash. It defines ask, then lists test cases, each followed
26
+ by the lines that grade its reply:
27
+
28
+ ask() { ruby bin/answer.rb; } the AI call: input as \$1 and on stdin, reply on stdout
29
+ worker COMMAND... or keep COMMAND running, one per -j: it reads each
30
+ input up to a NUL byte and prints the reply and a NUL
31
+ A reply may start with #tinyeval couldnt_run REASON
32
+ or #tinyeval cost_usd DOLLARS lines, and the app may
33
+ add each AI call it made to the file \$TINYEVAL_CALLS
34
+ as a line of JSON: {\"call\": NAME, \"model\", \"input\", ...}
35
+ test_case NAME [INPUT] starts a test case; without INPUT it reads a heredoc
36
+ contains TEXT the reply contains TEXT, ignoring case
37
+ not_contains TEXT the reply doesn't contain TEXT, ignoring case
38
+ matches REGEX the reply matches the extended regex
39
+ equals TEXT the reply is exactly TEXT
40
+ expect COMMAND... COMMAND exits 0 with the reply on stdin
41
+ json FILTER the reply is JSON and jq's FILTER is true for it
42
+ problems FILTER jq's FILTER lists what's wrong with the JSON reply;
43
+ an empty list passes, and each item is a failure
44
+ target FILE#NAME the code the test case covers, kept in the results file
45
+ check NAME the lines after it grade the same reply as another
46
+ eval, named \"TEST CASE / NAME\"
47
+ judge CRITERION a model says the reply meets CRITERION; it needs
48
+ OPENROUTER_API_KEY, ANTHROPIC_API_KEY or OPENAI_API_KEY
49
+
50
+ More: https://github.com/evalship-hq/tinyeval"
51
+ _te_download=${TINYEVAL_DOWNLOAD_URL:-https://github.com/evalship-hq/tinyeval/releases/latest/download/tinyeval}
52
+
53
+ # A package manager's copy (gem, pip, npm...) updates through it, so the package's wrapper sets the command that does.
54
+ # Not passed on: a repo's own ./tinyeval, run below, updates itself.
55
+ _te_pkg=${TINYEVAL_PACKAGE_UPDATE-}
56
+ unset TINYEVAL_PACKAGE_UPDATE
57
+
58
+ # The tinyeval on PATH runs the repo's own ./tinyeval, so each repo keeps the version its evals were written for.
59
+ if [ -f ./tinyeval ] && [ -x ./tinyeval ] && ! [ ./tinyeval -ef "$0" ]; then exec ./tinyeval "$@"; fi
60
+
61
+ # Whether version $1 is newer than version $2.
62
+ _te_newer() {
63
+ awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, ".")
64
+ for (i = 1; i <= 3; i++) if (x[i] + 0 != y[i] + 0) exit !(x[i] + 0 > y[i] + 0); exit 1 }'
65
+ }
66
+
67
+ if [ "${1-}" = update ]; then
68
+ [ -z "$_te_pkg" ] || { echo "tinyeval: a package manager installed this tinyeval. Update it with: $_te_pkg" >&2; exit 2; }
69
+ # Next to this file, so the rename below replaces it in one step: bash may still be reading the old one.
70
+ _te_new="$0.download"
71
+ if ! curl -fsSL "$_te_download" -o "$_te_new" || ! _te_latest=$(sed -n 's/^_te_version=//p' "$_te_new") ||
72
+ [ -z "$_te_latest" ] || ! bash -n "$_te_new"; then
73
+ rm -f "$_te_new"
74
+ echo "tinyeval: couldn't download the latest tinyeval from $_te_download" >&2
75
+ exit 2
76
+ fi
77
+ if ! _te_newer "$_te_latest" "$_te_version"; then
78
+ rm -f "$_te_new"
79
+ echo "tinyeval $_te_version is the latest version"
80
+ exit 0
81
+ fi
82
+ chmod +x "$_te_new" && mv -f "$_te_new" "$0" || exit 2
83
+ if [ -d .git ] && [ "$0" -ef ./tinyeval ]; then _te_commit=" Commit it with your code."; fi
84
+ echo "Updated $0 from $_te_version to $_te_latest.${_te_commit-}"
85
+ exit 0
86
+ fi
87
+
88
+ # A private link to the results on evalship.com, like a secret gist, for whoever you send it to.
89
+ if [ "${1-}" = share ]; then
90
+ _te_file=${2:-eval-results.json}
91
+ [ -f "$_te_file" ] || { echo "tinyeval: no $_te_file to share. Run tinyeval first, or name a results file" >&2; exit 2; }
92
+ _te_said=$(curl -sS -m 60 -w '\n%{http_code}' -A "tinyeval/$_te_version" -H 'Accept: text/plain' \
93
+ -H 'Content-Type: application/octet-stream' --data-binary @"$_te_file" \
94
+ "${TINYEVAL_SHARE_URL:-https://evalship.com/api/v1/shares}" 2>&1)
95
+ _te_code=${_te_said##*$'\n'} _te_said=${_te_said%$'\n'*}
96
+ _te_said=${_te_said%$'\n'}
97
+ [ "$_te_code" = 201 ] || { echo "tinyeval: couldn't share $_te_file: $_te_said" >&2; exit 2; }
98
+ printf '%s\n' "$_te_said"
99
+ # The first line ends with the link.
100
+ _te_url=${_te_said%%$'\n'*}
101
+ [ -z "${GITHUB_STEP_SUMMARY-}" ] || printf '\nShared results: %s\n' "${_te_url##* }" >>"$GITHUB_STEP_SUMMARY"
102
+ exit 0
103
+ fi
104
+
105
+ _te_runs=1 _te_jobs=4 _te_timeout=300 _te_only='' _te_junit=''
106
+ _te_results=eval-results.json
107
+ while getopts n:j:t:k:o:x:vh _te_opt; do
108
+ case $_te_opt in
109
+ n) _te_runs=$OPTARG ;;
110
+ j) _te_jobs=$OPTARG ;;
111
+ t) _te_timeout=$OPTARG ;;
112
+ k) _te_only=$OPTARG ;;
113
+ o) _te_results=$OPTARG ;;
114
+ x) _te_junit=$OPTARG ;;
115
+ v) echo "tinyeval $_te_version"; exit 0 ;;
116
+ h) echo "$_te_usage"; exit 0 ;;
117
+ *) echo "$_te_usage" >&2; exit 2 ;;
118
+ esac
119
+ done
120
+ shift $((OPTIND - 1))
121
+ for _te_opt in "-n $_te_runs" "-j $_te_jobs" "-t $_te_timeout"; do
122
+ case ${_te_opt#-? } in '' | *[!0-9]* | 0) echo "tinyeval: ${_te_opt%% *} takes a whole number, 1 or more" >&2; exit 2 ;; esac
123
+ done
124
+ [ $# -gt 0 ] || set -- evals/*.sh
125
+ for _te_f in "$@"; do
126
+ [ -f "$_te_f" ] || { echo "tinyeval: no eval file at $_te_f. Create one: https://github.com/evalship-hq/tinyeval#quickstart" >&2; exit 2; }
127
+ done
128
+
129
+ # In a terminal, once a day, look up the latest release in the background. The summary names it. GitHub Actions
130
+ # gets its own check per run, below, for the job summary; other CI never checks.
131
+ _te_cache=${XDG_CACHE_HOME:-$HOME/.cache}/tinyeval
132
+ if [ -t 1 ] && [ -z "${CI-}" ]; then
133
+ _te_check=1
134
+ if [ -z "$(find "$_te_cache/latest" -mmin -1440 2>/dev/null)" ]; then
135
+ (
136
+ mkdir -p "$_te_cache" && touch "$_te_cache/latest" # also when offline, so the next try is a day later
137
+ curl -fsSL -m 20 "$_te_download" | sed -n 's/^_te_version=//p' >"$_te_cache/latest.new"
138
+ [ -s "$_te_cache/latest.new" ] && mv -f "$_te_cache/latest.new" "$_te_cache/latest"
139
+ ) >/dev/null 2>&1 &
140
+ fi
141
+ fi
142
+
143
+ # A coding agent's sandbox may not let it write to $TMPDIR, so the repo holds it then, until tinyeval exits.
144
+ _te_tmp=$(mktemp -d 2>/dev/null || mktemp -d "$PWD/.tinyeval.XXXXXX") || exit 2
145
+ # Git Bash: native Windows programs, like its curl.exe, need C:/ paths, not /tmp ones.
146
+ _te_win=''
147
+ if command -v cygpath >/dev/null; then _te_tmp=$(cygpath -m "$_te_tmp") _te_win=1; fi
148
+ trap 'rm -rf "$_te_tmp"' EXIT
149
+ if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
150
+ curl -fsSL -m 5 "$_te_download" 2>/dev/null | sed -n 's/^_te_version=//p' >"$_te_tmp/latest" &
151
+ _te_check_pid=$!
152
+ fi
153
+ # With an OTLP endpoint set, each run of ask tags its OpenTelemetry spans with its eval's otel id (_te_case), this
154
+ # tinyeval run's part being the temp dir's random suffix.
155
+ _te_otel='' _te_otel_attrs=${OTEL_RESOURCE_ATTRIBUTES-}
156
+ [ -z "${OTEL_EXPORTER_OTLP_ENDPOINT-}${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT-}" ] || _te_otel=${_te_tmp##*.}
157
+ # Background test cases ignore Ctrl-C, as bash's background jobs do, so stop the whole process group.
158
+ _te_stop() {
159
+ trap '' TERM
160
+ # Git Bash: native programs like python.exe ignore signals, so end each child's Windows process tree.
161
+ if [ -r /proc/$$/winpid ]; then
162
+ for _te_p in $(ps | awk -v me=$$ '$2 == me { print $4 }'); do taskkill //F //T //PID "$_te_p" >/dev/null 2>&1; done
163
+ fi
164
+ kill -TERM 0 2>/dev/null
165
+ exit 130
166
+ }
167
+ trap _te_stop INT TERM
168
+ if [ -t 1 ]; then _te_g=$'\033[32m' _te_r=$'\033[31m' _te_y=$'\033[33m' _te_d=$'\033[2m' _te_0=$'\033[0m'; fi
169
+ _te_tab=$'\t'
170
+
171
+ # --- what an eval file uses ---------------------------------------------------
172
+
173
+ # test_case NAME [INPUT]: starts a test case. Without INPUT it reads one from stdin (a heredoc).
174
+ test_case() {
175
+ local next_input
176
+ if [ $# -ge 2 ]; then next_input=$2; else next_input=$(cat); fi
177
+ _te_run_case
178
+ _te_name=$1 _te_file=${BASH_SOURCE[1]} _te_asserts=() _te_targets=() _te_input=$next_input
179
+ # Test cases with one name would merge into one eval, so a later one gets a number and fails.
180
+ local n=1
181
+ while [[ $_te_names == *$'\n'"$_te_name"$'\n'* ]]; do n=$((n + 1)); _te_name="$1 #$n"; done
182
+ _te_names+="$_te_name"$'\n'
183
+ [ $n -eq 1 ] || _te_add _te_fail "another test case in this file is named \"$1\". Give each its own name"
184
+ }
185
+
186
+ contains() { _te_add _te_contains "$@"; } # the reply contains TEXT, ignoring case
187
+ not_contains() { _te_add _te_not_contains "$@"; } # the reply doesn't contain TEXT, ignoring case
188
+ matches() { _te_add _te_matches "$@"; } # the reply matches an extended regex
189
+ equals() { _te_add _te_equals "$@"; } # the reply is exactly TEXT
190
+ expect() { _te_add _te_expect "$@"; } # COMMAND exits 0 with the reply on its stdin
191
+ json() { _te_add _te_json_is "$@"; } # the reply is JSON and jq's FILTER is true for it
192
+ problems() { _te_add _te_problems "$@"; } # jq's FILTER lists what's wrong with the reply; none passes
193
+ # check NAME: the grading lines after it are another eval of the same reply, named "TEST CASE / NAME". One AI call can
194
+ # then show as several evals, each passing or failing on its own.
195
+ check() {
196
+ [ -n "$_te_name" ] || { echo "tinyeval: check comes before any test_case in ${BASH_SOURCE[1]}" >&2; return 1; }
197
+ _te_asserts+=("#check $1")
198
+ }
199
+ # target FILE#NAME...: the code this test case covers, for whatever reads the results file. It grades nothing.
200
+ target() {
201
+ [ -n "$_te_name" ] || { echo "tinyeval: target comes before any test_case in ${BASH_SOURCE[1]}" >&2; return 1; }
202
+ _te_targets+=("$@")
203
+ }
204
+ judge() { _te_add _te_judge "$@"; } # a model says the reply meets CRITERION
205
+
206
+ # worker COMMAND...: the AI call stays running instead of starting for each input, as an app that boots slowly
207
+ # needs. One runs per -j. It reads an input up to a NUL byte on stdin, then prints the reply and a NUL byte.
208
+ worker() {
209
+ [ "$_te_jobs" -le 100 ] || { echo "tinyeval: worker runs at most 100 at once, so lower -j" >&2; exit 2; }
210
+ _te_worker=("$@")
211
+ }
212
+
213
+ # --- internals ----------------------------------------------------------------
214
+
215
+ _te_add() {
216
+ [ -n "$_te_name" ] || { echo "tinyeval: ${1#_te_} comes before any test_case in ${BASH_SOURCE[2]}" >&2; return 1; }
217
+ local a
218
+ printf -v a '%q ' "$@"
219
+ _te_asserts+=("$a")
220
+ }
221
+
222
+ _te_fail() { _te_failures="${_te_failures:+$_te_failures$'\n'}$*"; }
223
+ # The AI call or the judge broke. A run with only these says nothing about the reply: it couldn't run.
224
+ _te_error() { _te_errors="${_te_errors:+$_te_errors$'\n'}$*"; _te_fail "$@"; }
225
+
226
+ # A line the eval file runs that isn't a command, like a misspelled contains, fails its test case instead of grading nothing.
227
+ _te_unknown() { [ -z "$_te_name" ] || _te_add _te_fail "not a tinyeval line: $1"; }
228
+
229
+ # Whether $1 contains $2, ignoring case.
230
+ _te_has() {
231
+ shopt -s nocasematch
232
+ [[ $1 == *"$2"* ]]
233
+ local found=$?
234
+ shopt -u nocasematch
235
+ return $found
236
+ }
237
+ _te_contains() { _te_has "$output" "$1" || _te_fail "expected to contain \"$1\""; }
238
+ _te_not_contains() { ! _te_has "$output" "$1" || _te_fail "expected not to contain \"$1\""; }
239
+ _te_matches() { [[ $output =~ $1 ]] || _te_fail "expected to match /$1/"; }
240
+ _te_equals() { [ "$output" = "$1" ] || _te_fail "expected exactly \"$1\""; }
241
+ _te_expect() {
242
+ local said code
243
+ said=$(printf '%s' "$output" | "$@" 2>&1) && return
244
+ code=$?
245
+ _te_fail "$*: $(printf '%s' "${said:-exited $code}" | tail -n 1)"
246
+ }
247
+ # The app's own checks, run where the reply is made (a worker, say), return their problems in the reply. A filter
248
+ # that gives null fails too: a misspelled field would otherwise pass every reply.
249
+ _te_problems() {
250
+ local said
251
+ command -v jq >/dev/null || { _te_error "problems needs jq: https://jqlang.org/download"; return; }
252
+ if ! said=$(printf '%s' "$output" | jq -r --arg f "$1" "($1) | if type == \"array\" then .[] | (strings // tojson)
253
+ elif type == \"string\" then . else error(\"\\(\$f) is \\(tojson), not a list\") end" 2>&1); then
254
+ _te_fail "problems: $(printf '%s' "$said" | tail -n 1 | sed 's/^jq: error (at [^)]*): //')"
255
+ return
256
+ fi
257
+ said=${said//$'\r'/} # jq.exe on Git Bash ends its lines with CRLF
258
+ [ -z "$said" ] || _te_fail "$said"
259
+ }
260
+
261
+ _te_json_is() {
262
+ local said
263
+ command -v jq >/dev/null || { _te_error "json needs jq: https://jqlang.org/download"; return; }
264
+ said=$(printf '%s' "$output" | jq -e "$1" 2>&1) && return
265
+ # 1: the filter gave false or null. Anything else: the reply isn't JSON, or the filter has an error.
266
+ if [ $? -eq 1 ]; then _te_fail "json: $1 is ${said:-false}"; else _te_fail "json: $(printf '%s' "$said" | tail -n 1)"; fi
267
+ }
268
+
269
+ _te_judge() {
270
+ local reply verdict
271
+ reply=$(_te_ask_judge "You grade one reply from an AI feature against one criterion.
272
+
273
+ Criterion: $1
274
+
275
+ The input it got:
276
+ <input>
277
+ $_te_input
278
+ </input>
279
+
280
+ Its reply:
281
+ <reply>
282
+ $output
283
+ </reply>
284
+
285
+ Does the reply meet the criterion? Answer PASS or FAIL on the first line, then one sentence on why.") ||
286
+ { _te_error "judge couldn't run: $reply"; return; }
287
+ # The first word of the first line, after markdown or a label like "Verdict:", so "does not PASS" isn't a pass.
288
+ verdict=$(printf '%s\n' "$reply" | awk 'NF { sub(/^[^[:alnum:]]+/, ""); sub(/^[[:alpha:]]+:[^[:alnum:]]*/, "")
289
+ if ($0 ~ /^(PASS|FAIL)([^[:alnum:]]|$)/) print substr($0, 1, 4); exit }')
290
+ reply=$(printf '%s' "$reply" | tr '\n' ' ' | sed -E 's/^[^A-Z]*(PASS|FAIL)[^[:alnum:]]*//' | cut -c 1-300)
291
+ case $verdict in
292
+ PASS) ;;
293
+ FAIL) _te_fail "judge: $reply" ;;
294
+ *) _te_error "judge gave no PASS or FAIL: $reply" ;;
295
+ esac
296
+ }
297
+
298
+ # Sets the variable $1 to a JSON string holding $2, without starting a process unless $2 has control characters.
299
+ # Drops those but newline and tab, so ANSI colors lose their ESC.
300
+ _te_json() {
301
+ # bs is two backslashes, not an escaped quote.
302
+ # shellcheck disable=SC1003
303
+ local _te_s=$2 bs='\\' q='\"' n='\n' t='\t' tab=$'\t'
304
+ case $_te_s in *[[:cntrl:]]*) _te_s=$(printf '%s' "$_te_s" | tr -d '\000-\010\013-\037') ;; esac
305
+ # bash 3.2 takes seconds to substitute in tens of KB, so sed and awk escape a long text.
306
+ if [ ${#_te_s} -gt 2000 ]; then
307
+ _te_s=$(printf '%s\n' "$_te_s" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e "s/$tab/\\\\t/g" |
308
+ LC_ALL=C awk 'NR > 1 { printf "%s", "\\n" } { printf "%s", $0 }')
309
+ else
310
+ _te_s=${_te_s//\\/"$bs"} _te_s=${_te_s//\"/"$q"} _te_s=${_te_s//$'\n'/"$n"} _te_s=${_te_s//$'\t'/"$t"}
311
+ fi
312
+ # shellcheck disable=SC2089 # it sets the variable named in $1, which is always quoted where it's used
313
+ printf -v "$1" '"%s"' "$_te_s"
314
+ }
315
+
316
+ # Sets the variable $1 to $2 for XML text or an attribute, keeping newline and tab.
317
+ _te_xml() {
318
+ # Quoted, as bash 5.2 reads an unquoted & in a replacement as the text it matched.
319
+ local _te_s=$2 amp='&amp;' lt='&lt;' gt='&gt;' quot='&quot;'
320
+ case $_te_s in *[[:cntrl:]]*) _te_s=$(printf '%s' "$_te_s" | tr -d '\000-\010\013-\037') ;; esac
321
+ if [ ${#_te_s} -gt 2000 ]; then # as in _te_json
322
+ _te_s=$(printf '%s\n' "$_te_s" | LC_ALL=C sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g')
323
+ else
324
+ _te_s=${_te_s//&/"$amp"} _te_s=${_te_s//</"$lt"} _te_s=${_te_s//>/"$gt"} _te_s=${_te_s//\"/"$quot"}
325
+ fi
326
+ printf -v "$1" '%s' "$_te_s"
327
+ }
328
+
329
+ # The string value of the last "KEY": "..." in JSON on stdin, still escaped.
330
+ # shortcut: a regex, not a parser; fine for one chat completion, use jq if this ever reads more.
331
+ _te_json_get() { tr -d '\n' | sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p'; }
332
+
333
+ # Reads the AI calls the app wrote to the file $1, one JSON object a line, as evalship's /api/v1/calls takes them:
334
+ # _te_calls gets them joined by commas for the results file, _te_call_lines by newlines, and cost their summed "cost"
335
+ # unless the reply said it. A line that isn't an object fails the test case and is left out, so the results stay JSON.
336
+ # shortcut: only a line's shape is checked, as tinyeval doesn't need jq; an app's JSON encoder writes valid lines.
337
+ _te_read_calls() {
338
+ local l n=0 bad=''
339
+ _te_calls='' _te_call_lines=''
340
+ [ -s "$1" ] || return 0
341
+ while IFS= read -r l || [ -n "$l" ]; do
342
+ l=${l%$'\r'} n=$((n + 1))
343
+ [ -n "$l" ] || continue
344
+ if [[ $l != "{"*"}" ]]; then
345
+ [ -n "$bad" ] || _te_fail "line $n of \$TINYEVAL_CALLS isn't a JSON object on one line (write each call unindented): ${l:0:80}"
346
+ bad=1
347
+ continue
348
+ fi
349
+ _te_calls=${_te_calls:+$_te_calls,}$l _te_call_lines=$_te_call_lines$l$'\n'
350
+ done <"$1"
351
+ [ -z "$_te_calls" ] || [ -n "$cost" ] ||
352
+ cost=$(printf '%s' "$_te_call_lines" | LC_ALL=C awk '
353
+ match($0, /"cost"[ \t]*:[ \t]*[0-9][0-9.eE+-]*/) { v = substr($0, RSTART, RLENGTH); sub(/^"cost"[ \t]*:[ \t]*/, "", v); s += v; n++ }
354
+ END { if (n) printf "%.10g", s }')
355
+ }
356
+
357
+ # "call (model) → call (model)" for the AI calls in $1, one JSON object a line.
358
+ _te_call_names() {
359
+ printf '%s' "$1" | LC_ALL=C awk '
360
+ function str(k, v) {
361
+ if (!match($0, "\"" k "\"[ \t]*:[ \t]*\"([^\"\\\\]|\\\\.)*\"")) return ""
362
+ v = substr($0, RSTART, RLENGTH); sub(/^"[^"]*"[ \t]*:[ \t]*"/, "", v); return substr(v, 1, length(v) - 1)
363
+ }
364
+ { c = str("call"); m = str("model"); out = out (NR > 1 ? " → " : "") (c == "" ? "?" : c) (m == "" ? "" : " (" m ")") }
365
+ END { print out }'
366
+ }
367
+
368
+ # Prints the judge model's answer to $1, or why there is none and returns 1.
369
+ _te_ask_judge() {
370
+ if [ -n "${TINYEVAL_JUDGE:-}" ]; then printf '%s' "$1" | sh -c "$TINYEVAL_JUDGE"; return; fi
371
+ local url=${TINYEVAL_JUDGE_URL:-} key=${TINYEVAL_JUDGE_KEY:-} model=${TINYEVAL_JUDGE_MODEL:-} res content jmodel jprompt
372
+ if [ -z "$url" ]; then
373
+ if [ -n "${OPENROUTER_API_KEY:-}" ]; then
374
+ url=https://openrouter.ai/api/v1 key=${key:-$OPENROUTER_API_KEY} model=${model:-anthropic/claude-haiku-5.5}
375
+ elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
376
+ url=https://api.anthropic.com/v1 key=${key:-$ANTHROPIC_API_KEY} model=${model:-claude-haiku-5-5}
377
+ elif [ -n "${OPENAI_API_KEY:-}" ]; then
378
+ url=https://api.openai.com/v1 key=${key:-$OPENAI_API_KEY}
379
+ else
380
+ echo "set OPENROUTER_API_KEY, ANTHROPIC_API_KEY or OPENAI_API_KEY, or TINYEVAL_JUDGE to a command"; return 1
381
+ fi
382
+ fi
383
+ [ -n "$model" ] || { echo "set TINYEVAL_JUDGE_MODEL to the model the judge should use"; return 1; }
384
+ command -v curl >/dev/null || { echo "judge needs curl, or TINYEVAL_JUDGE set to a command"; return 1; }
385
+ # The key goes in a header file, not the command line, so it doesn't show in ps.
386
+ (umask 077; printf 'Authorization: Bearer %s\n' "$key" >"$_te_dir/auth")
387
+ # --retry waits out 429 and 5xx replies. -o, because on stdout each retry's reply would add to the last one.
388
+ _te_json jmodel "$model"
389
+ _te_json jprompt "$1"
390
+ if ! res=$(printf '{"model":%s,"messages":[{"role":"user","content":%s}]}' "$jmodel" "$jprompt" |
391
+ curl -sS --retry 3 --max-time 120 -o "$_te_dir/judge.json" "${url%/}/chat/completions" -H "@$_te_dir/auth" \
392
+ -H 'Content-Type: application/json' --data-binary @- 2>&1) || ! res=$(cat "$_te_dir/judge.json"); then
393
+ echo "$res"; return 1
394
+ fi
395
+ content=$(printf '%s' "$res" | _te_json_get content)
396
+ if [ -z "$content" ]; then
397
+ content=$(printf '%s' "$res" | _te_json_get message)
398
+ printf '%s' "${content:-$res}" | cut -c 1-300
399
+ return 1
400
+ fi
401
+ printf '%s' "$content" | sed -e 's/\\[nrt]/ /g' -e 's/\\"/"/g' -e 's/\\\//\//g' -e 's/\\\\/\\/g'
402
+ }
403
+
404
+ # Starts the test case test_case set up, in the background. Up to -j run at once; they print in file order.
405
+ _te_run_case() {
406
+ [ -n "$_te_name" ] || return 0
407
+ if [ -z "$_te_only" ] || _te_has "$_te_name" "$_te_only"; then
408
+ _te_n=$((_te_n + 1))
409
+ local dir="$_te_tmp/$_te_i.$_te_n"
410
+ # The slot the test case -j back had, as the oldest one is collected first: its worker is free.
411
+ _te_slot=$(((_te_n - 1) % _te_jobs))
412
+ [ ${#_te_worker[@]} -eq 0 ] || _te_worker_up "$_te_slot"
413
+ { mkdir "$dir" && _te_case "$dir" >"$dir/printed"; } &
414
+ _te_pids+=("$!")
415
+ _te_dirs+=("$dir")
416
+ while [ ${#_te_pids[@]} -ge "$_te_jobs" ]; do _te_collect; done
417
+ fi
418
+ _te_name=
419
+ }
420
+
421
+ # cat without starting a process, as tinyeval's main loop needs two per test case.
422
+ _te_cat() { local l; while IFS= read -r l || [ -n "$l" ]; do printf '%s\n' "$l"; done <"$1"; }
423
+
424
+ # Waits for the oldest running test case, then prints it and keeps its results.
425
+ _te_collect() {
426
+ wait "${_te_pids[0]}"
427
+ [ -n "$_te_header" ] || { echo "$_te_suite"; _te_header=1; }
428
+ _te_cat "${_te_dirs[0]}/printed"
429
+ _te_cat "${_te_dirs[0]}/results.jsonl" >>"$_te_tmp/results.jsonl"
430
+ [ ! -f "${_te_dirs[0]}/junit.xml" ] || _te_cat "${_te_dirs[0]}/junit.xml" >>"$_te_tmp/$_te_i.junit"
431
+ [ ! -f "${_te_dirs[0]}/summary.md" ] || _te_cat "${_te_dirs[0]}/summary.md" >>"$_te_tmp/summary.md"
432
+ _te_pids=("${_te_pids[@]:1}")
433
+ _te_dirs=("${_te_dirs[@]:1}")
434
+ }
435
+
436
+ # Starts the worker for slot $1, or a new one when its last call broke it. Its fifos are fds 20+2*slot and 21+2*slot.
437
+ _te_worker_up() {
438
+ local s=$1 w="$_te_tmp/$_te_i.w$1" f
439
+ if [ -n "${_te_wpids[$s]-}" ]; then
440
+ [ -f "$w.broken" ] || return 0
441
+ _te_worker_down "$s"
442
+ rm "$w.broken"
443
+ fi
444
+ _te_wn=$((_te_wn + 1))
445
+ f="$w.$_te_wn"
446
+ mkfifo "$f.in" "$f.out" || exit 2
447
+ # One calls file per slot: a slot's worker answers one test case at a time, which clears it first.
448
+ if [ -n "$_te_win" ]; then
449
+ # Git Bash: native programs, like python.exe, can't open its FIFOs, so cat carries the bytes over plain pipes.
450
+ { cat <"$f.in" | TINYEVAL_CALLS="$w.calls" "${_te_worker[@]}" | cat >"$f.out"; } 2>"$f.err" &
451
+ else
452
+ TINYEVAL_CALLS="$w.calls" "${_te_worker[@]}" <"$f.in" >"$f.out" 2>"$f.err" &
453
+ fi
454
+ _te_wpids[s]=$! _te_werrs[s]=$f.err
455
+ eval "exec $((20 + 2 * s))>\"\$f.in\" $((21 + 2 * s))<\"\$f.out\""
456
+ }
457
+
458
+ _te_worker_down() {
459
+ local s=$1
460
+ eval "exec $((20 + 2 * s))>&- $((21 + 2 * s))<&-"
461
+ pkill -TERM -P "${_te_wpids[$s]}" 2>/dev/null
462
+ kill "${_te_wpids[$s]}" 2>/dev/null
463
+ wait "${_te_wpids[$s]}" 2>/dev/null
464
+ }
465
+
466
+ # Sends the input to this test case's worker; its reply goes to output. A worker that stops or takes longer than -t
467
+ # is started again for the next test case.
468
+ # shortcut: this test case's later runs couldn't run then; restart here if -n runs need them.
469
+ # shortcut: an input larger than a pipe holds (16 KB or more) waits on the worker without -t.
470
+ _te_worker_ask() {
471
+ local s=$_te_slot w="$_te_tmp/$_te_i.w$_te_slot" started=$SECONDS
472
+ local to=$((20 + 2 * _te_slot)) from=$((21 + 2 * _te_slot))
473
+ if [ -f "$w.broken" ]; then _te_error "the worker stopped on an earlier run"; return; fi
474
+ trap '' PIPE # a worker that stopped is an error here, not the end of the test case
475
+ if printf '%s\0' "$_te_input" 1>&"$to" 2>/dev/null && IFS= read -r -d '' -t "$_te_timeout" -u "$from" output; then
476
+ trap - PIPE
477
+ return
478
+ fi
479
+ trap - PIPE
480
+ : >"$w.broken"
481
+ # By the time, as read fails the same way for both, and Git Bash shows a stopped worker as running for a while.
482
+ if [ $((SECONDS - started)) -ge "$_te_timeout" ]; then
483
+ local why="took longer than ${_te_timeout}s"
484
+ [ -z "$output" ] || why="didn't end its reply with a NUL byte within ${_te_timeout}s"
485
+ _te_error "the worker $why, so tinyeval stopped it (-t sets the limit)"
486
+ else
487
+ _te_error "the worker stopped: $(tail -n 3 "${_te_werrs[$s]}" | tr '\n' ' ')"
488
+ fi
489
+ }
490
+
491
+ # Runs ask with the input, its reply going to $1/out. Writes $1/timed-out when -t stops it.
492
+ # shortcut: a timeout stops ask and its direct children; deeper ones run on, but no longer hold up tinyeval.
493
+ _te_call_ask() {
494
+ local dir=$1 pid watchdog code
495
+ # 3>&-: a child left running after a timeout would hold the time keyword's stderr open, and so the caller's.
496
+ ask "$_te_input" <<<"$_te_input" >"$dir/out" 2>"$dir/err" 3>&- &
497
+ pid=$!
498
+ (
499
+ # A second at a time, with no trap to stop a long sleep: a stop that came before the trap was set waited out -t.
500
+ s=0
501
+ while [ $s -lt "$_te_timeout" ]; do sleep 1; s=$((s + 1)); done
502
+ : >"$dir/timed-out"
503
+ # Git Bash: end the Windows process tree, which takes native programs like python.exe with it.
504
+ if [ -r "/proc/$pid/winpid" ]; then taskkill //F //T //PID "$(cat "/proc/$pid/winpid")"; fi
505
+ pkill -TERM -P "$pid" 2>/dev/null
506
+ kill -TERM "$pid" 2>/dev/null
507
+ ) >/dev/null 2>&1 3>&- &
508
+ watchdog=$!
509
+ wait "$pid" 2>/dev/null # no "Terminated" line when the watchdog stopped it
510
+ code=$?
511
+ kill "$watchdog" 2>/dev/null
512
+ wait "$watchdog" 2>/dev/null
513
+ return $code
514
+ }
515
+
516
+ # Runs one test case: ask, then every grading line, once per run. Prints its lines; results go to $1/results.jsonl.
517
+ # A check line starts another eval on the same reply, keyed "TEST CASE / NAME".
518
+ _te_case() {
519
+ _te_dir=$1
520
+ local i=1 g first=0 ms total_ms=0 took='' code a asked passed failure couldnt cost h jcost TIMEFORMAT=%3R
521
+ local suite key file jinput joutput targets='' t jt name=$_te_name
522
+ local keys=("$_te_name") lines=(0) passes=() errors=() fails=() errs=() shown=() shown_output=() shown_calls=() jcalls otel jotel=''
523
+ # Where the app may write its AI calls: this test case's own file, or its worker slot's (_te_worker_up).
524
+ local calls_file="$_te_dir/calls"
525
+ [ ${#_te_worker[@]} -eq 0 ] || calls_file="$_te_tmp/$_te_i.w$_te_slot.calls"
526
+ export TINYEVAL_CALLS=$calls_file
527
+ for a in ${_te_asserts[@]+"${_te_asserts[@]}"}; do
528
+ if [[ $a == "#check "* ]]; then keys+=("$_te_name / ${a#\#check }") lines+=(0)
529
+ else lines[${#lines[@]} - 1]=$((lines[${#lines[@]} - 1] + 1)); fi
530
+ done
531
+ # Grading lines that all come after a check: the test case is only its checks.
532
+ [ "${lines[0]}" -gt 0 ] || [ ${#keys[@]} -eq 1 ] || first=1
533
+ _te_json suite "$_te_suite"
534
+ _te_json file "$_te_file"
535
+ for t in ${_te_targets[@]+"${_te_targets[@]}"}; do _te_json jt "$t"; targets="${targets:+$targets,}$jt"; done
536
+ [ -z "$targets" ] || targets=",\"targets\":[$targets]"
537
+ : >"$_te_dir/results.jsonl"
538
+ while [ $i -le "$_te_runs" ]; do
539
+ _te_failures='' _te_errors='' output=''
540
+ # shortcut: a worker reads OTEL_RESOURCE_ATTRIBUTES once, so only ask's spans are tagged; tag a worker's by slot and time if asked.
541
+ if [ -n "$_te_otel" ] && [ ${#_te_worker[@]} -eq 0 ]; then
542
+ otel="$_te_otel.${_te_dir##*/}.$i" && jotel=",\"otel\":\"$otel\""
543
+ export OTEL_RESOURCE_ATTRIBUTES="${_te_otel_attrs:+$_te_otel_attrs,}evalship.eval=$otel"
544
+ fi
545
+ # bash's time keyword, not a clock command: two per run (perl on macOS's bash 3.2) were most of tinyeval's own time.
546
+ { time {
547
+ [ ! -f "$_te_dir/timed-out" ] || rm "$_te_dir/timed-out"
548
+ : >"$calls_file"
549
+ if [ ${#_te_worker[@]} -gt 0 ]; then
550
+ _te_worker_ask
551
+ elif ! type ask >/dev/null 2>&1; then
552
+ _te_error "define ask or worker in $_te_file: ask gets the input as \$1 and on stdin, and prints the reply"
553
+ else
554
+ _te_call_ask "$_te_dir"
555
+ code=$?
556
+ # read, not $(<file), which starts a process; $() drops the trailing newlines, so this does too.
557
+ IFS= read -r -d '' output <"$_te_dir/out"
558
+ if [ -f "$_te_dir/timed-out" ]; then
559
+ _te_error "ask took longer than ${_te_timeout}s, so tinyeval stopped it (-t sets the limit)"
560
+ elif [ $code -ne 0 ]; then
561
+ _te_error "ask failed (exit $code): $(tail -n 3 "$_te_dir/err" | tr '\n' ' ')"
562
+ fi
563
+ fi
564
+ while [[ $output == *$'\n' ]]; do output=${output%$'\n'}; done
565
+ # Lines at the reply's start for tinyeval, not graded: "#tinyeval couldnt_run REASON" when the AI call broke (a
566
+ # 429 the app caught), "#tinyeval cost_usd DOLLARS" for what the call cost.
567
+ cost=''
568
+ while [[ $output == "#tinyeval "* ]]; do
569
+ h=${output%%$'\n'*} h=${h#\#tinyeval }
570
+ if [[ $output == *$'\n'* ]]; then output=${output#*$'\n'}; else output=''; fi
571
+ case $h in
572
+ "couldnt_run "*) _te_error "${h#couldnt_run }" ;;
573
+ "cost_usd "*) cost=${h#cost_usd }
574
+ [[ $cost =~ ^[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$ ]] || { _te_fail "#tinyeval cost_usd is a number of dollars, not \"$cost\""; cost=''; } ;;
575
+ *) _te_fail "not a #tinyeval line: $h (couldnt_run REASON or cost_usd DOLLARS)" ;;
576
+ esac
577
+ done
578
+ _te_read_calls "$calls_file"
579
+ # The AI call's errors are every eval's; a grading line's are its own eval's.
580
+ asked=$_te_errors g=0
581
+ for a in ${_te_asserts[@]+"${_te_asserts[@]}"} "#check "; do
582
+ if [[ $a != "#check "* ]]; then
583
+ [ -n "$asked" ] || eval "$a"
584
+ continue
585
+ fi
586
+ if [ -z "$asked" ] && [ "${lines[g]}" -eq 0 ] && [ $g -ge $first ]; then
587
+ _te_fail "nothing grades this $([ $g -eq 0 ] && echo test case || echo check): add contains, matches, json, problems, expect or judge under it"
588
+ fi
589
+ fails[g]=$_te_failures errs[g]=$_te_errors
590
+ g=$((g + 1)) _te_failures=$asked _te_errors=$asked
591
+ done
592
+ } 2>&3; } 3>&2 2>"$_te_dir/took"
593
+ read -r ms <"$_te_dir/took"
594
+ ms=$((10#${ms/[.,]/})) total_ms=$((total_ms + ms))
595
+ _te_json jinput "$_te_input"
596
+ _te_json joutput "$output"
597
+ jcalls='' && [ -z "$_te_calls" ] || jcalls=",\"calls\":[$_te_calls]"
598
+ g=$first
599
+ while [ $g -lt ${#keys[@]} ]; do
600
+ passed=true failure='' couldnt=''
601
+ if [ -z "${fails[g]}" ]; then passes[g]=$((${passes[g]:-0} + 1)); else
602
+ _te_json failure "${fails[g]}"
603
+ passed=false failure=",\"failure\":$failure" shown[g]=${fails[g]} shown_output[g]=$output shown_calls[g]=$_te_call_lines
604
+ # evalship reads couldnt_run too, and leaves such a run out of a test case's repeats.
605
+ if [ "${fails[g]}" = "${errs[g]}" ]; then
606
+ _te_json couldnt "${errs[g]}"
607
+ errors[g]=$((${errors[g]:-0} + 1)) couldnt=",\"metadata\":{\"couldnt_run\":$couldnt}"
608
+ fi
609
+ fi
610
+ _te_json key "${keys[g]}"
611
+ # The call's cost goes on one eval only, so a test case's checks don't count it again.
612
+ jcost='' && [ -z "$cost" ] || [ $g -ne $first ] || jcost=",\"cost_usd\":$cost"
613
+ # One eval per line with suite, key and passed first: the summary and the next run read them back with sed.
614
+ printf '{"suite":%s,"key":%s,"passed":%s,"file":%s%s,"duration_ms":%s%s,"input":%s,"output":%s%s%s%s%s}\n' \
615
+ "$suite" "$key" "$passed" "$file" "$targets" "$ms" "$jcost" "$jinput" "$joutput" "$failure" "$couldnt" "$jcalls" "$jotel" \
616
+ >>"$_te_dir/results.jsonl"
617
+ g=$((g + 1))
618
+ done
619
+ i=$((i + 1))
620
+ done
621
+
622
+ [ "$_te_runs" -gt 1 ] || printf -v took ' %d.%ds' $((ms / 1000)) $((ms % 1000 / 100))
623
+ [ $first -eq 0 ] || printf ' %s%s%s\n' "$name" "$_te_d" "$took$_te_0"
624
+ local mark count ran p e indent label reply last_reply='' verdict
625
+ g=$first
626
+ while [ $g -lt ${#keys[@]} ]; do
627
+ # Runs that couldn't run don't count toward the majority.
628
+ p=${passes[g]:-0} e=${errors[g]:-0} ran=$((_te_runs - ${errors[g]:-0})) mark="${_te_g}✓" count=''
629
+ [ "$_te_runs" -eq 1 ] || count=" $p/$ran"
630
+ [ $g -gt 0 ] || count="$count$took"
631
+ [ "$e" -eq 0 ] || count="$count, $e couldn't run"
632
+ if [ "$ran" -eq 0 ]; then mark="${_te_y}!" count=" couldn't run"
633
+ elif [ $((p * 2)) -lt "$ran" ]; then mark="${_te_r}✗"
634
+ elif [ "$p" -lt "$ran" ]; then mark="${_te_y}~"; fi
635
+ indent=' ' label=$name
636
+ [ $g -eq 0 ] || indent=' ' label=${keys[g]#"$name / "}
637
+ printf '%s%s%s %s%s%s\n' "$indent" "$mark" "$_te_0" "$label" "$_te_d" "$count$_te_0"
638
+ reply=''
639
+ if [ -n "${shown[g]}" ]; then
640
+ reply=$(printf '%s' "${shown_output[g]:-(empty)}" | tr '\n' ' ' | cut -c 1-200)
641
+ printf '%s\n' "${shown[g]}" | sed "s/^/$indent /"
642
+ # A check that failed on the reply another one showed doesn't show it again.
643
+ if [ "$reply" != "$last_reply" ]; then
644
+ printf '%s %sreply: %s%s\n' "$indent" "$_te_d" "$reply" "$_te_0"
645
+ [ -z "${shown_calls[g]}" ] || printf '%s %scalls: %s%s\n' "$indent" "$_te_d" "$(_te_call_names "${shown_calls[g]}")" "$_te_0"
646
+ fi
647
+ last_reply=$reply
648
+ fi
649
+ verdict=pass
650
+ if [ "$ran" -eq 0 ]; then verdict=error; elif [ $((p * 2)) -lt "$ran" ]; then verdict=fail; fi
651
+ _te_name=${keys[g]} shown_failures=${shown[g]}
652
+ [ -z "$_te_junit" ] || _te_junit_case "$verdict" "$reply" >>"$_te_dir/junit.xml"
653
+ if [ -n "${GITHUB_STEP_SUMMARY-}" ] && [ $verdict != pass ]; then _te_summary_row "$verdict" "$reply" >>"$_te_dir/summary.md"; fi
654
+ g=$((g + 1))
655
+ done
656
+ }
657
+
658
+ # A <testcase> for this test case, with a <failure> or <error> unless $1 is pass. $2 is the reply shown.
659
+ _te_junit_case() {
660
+ local suite name file why reply tag=failure
661
+ _te_xml suite "$_te_suite"
662
+ _te_xml name "$_te_name"
663
+ _te_xml file "$_te_file"
664
+ printf ' <testcase classname="%s" name="%s" file="%s" time="%d.%03d"' "$suite" "$name" "$file" \
665
+ $((total_ms / 1000)) $((total_ms % 1000))
666
+ if [ "$1" = pass ]; then echo '/>'; return; fi
667
+ [ "$1" = fail ] || tag=error
668
+ _te_xml why "${shown_failures%%$'\n'*}"
669
+ _te_xml reply "$shown_failures"$'\n'"reply: $2"
670
+ printf '>\n <%s message="%s">%s</%s>\n </testcase>\n' "$tag" "$why" "$reply" "$tag"
671
+ }
672
+
673
+ # A row of the GitHub job summary's table for a test case that failed ($1 fail) or couldn't run ($1 error).
674
+ _te_summary_row() {
675
+ local why=${shown_failures//$'\n'/; }
676
+ why=${why:0:300}
677
+ printf '| %s | %s › %s ' "$([ "$1" = fail ] && echo ✗ || echo !)" "$(_te_cell "$_te_suite")" "$(_te_cell "$_te_name")"
678
+ printf '| %s | %s |\n' "$(_te_cell "$why")" "$(_te_cell "$2")"
679
+ }
680
+ # Text that can stand in a Markdown table cell.
681
+ _te_cell() { local s=${1//|/'\|'} lt='&lt;'; s=${s//</"$lt"}; printf '%s' "${s//$'\n'/ }"; }
682
+
683
+ # suite<TAB>key<TAB>true|false|error per eval line of a results file (or results.jsonl) on stdin.
684
+ # A raw "couldnt_run": can only be the key: inside a JSON string its quotes are escaped.
685
+ _te_statuses() {
686
+ local line='^\{"suite":("([^"\\]|\\.)*"),"key":("([^"\\]|\\.)*"),"passed":(true|false)'
687
+ sed -nE -e "s/$line.*\"couldnt_run\":.*/\\1$_te_tab\\3${_te_tab}error/p" -e t -e "s/$line.*/\\1$_te_tab\\3$_te_tab\\5/p"
688
+ }
689
+
690
+ # suite<TAB>key<TAB>pass|fail|error per eval, in first-run order: the majority of the runs that could run, as evalship
691
+ # counts repeats. error when none could.
692
+ _te_majority() {
693
+ awk -F '\t' '{ k = $1 FS $2; if (!(k in seen)) { seen[k] = 1; order[++m] = k }
694
+ if ($3 != "error") { n[k]++; if ($3 == "true") p[k]++ } }
695
+ END { for (i = 1; i <= m; i++) { k = order[i]; print k FS (!(k in n) ? "error" : p[k] * 2 >= n[k] ? "pass" : "fail") } }'
696
+ }
697
+
698
+ # --- run ------------------------------------------------------------------------
699
+
700
+ : >"$_te_tmp/results.jsonl"
701
+ _te_i=0
702
+ for _te_f in "$@"; do
703
+ _te_i=$((_te_i + 1))
704
+ _te_suite=$(basename "$_te_f" .sh)
705
+ # A subshell per file, so one file's ask doesn't leak into the next. In the background, because bash runs
706
+ # the INT/TERM trap during wait but not while a foreground command runs.
707
+ (
708
+ _te_name='' _te_n=0 _te_header='' _te_names=$'\n'
709
+ _te_pids=()
710
+ _te_dirs=()
711
+ # bash 3.2 (macOS) brace-expands inside "$(echo '...')" once an apostrophe written '"'"' flips its quote
712
+ # count: JSON rows like {"a": 1, "b": 2} multiplied into billions of inputs and filled all memory.
713
+ set +B
714
+ trap '[ $? -ne 127 ] || _te_unknown "$BASH_COMMAND"' ERR
715
+ # shellcheck source=/dev/null
716
+ . "$_te_f"
717
+ trap - ERR
718
+ _te_run_case
719
+ while [ ${#_te_pids[@]} -gt 0 ]; do _te_collect; done
720
+ for _te_s in "${!_te_wpids[@]}"; do _te_worker_down "$_te_s"; done
721
+ if [ -n "$_te_junit" ] && [ -f "$_te_tmp/$_te_i.junit" ]; then
722
+ _te_xml _te_sx "$_te_suite"
723
+ # shellcheck disable=SC2154 # _te_xml sets it
724
+ printf ' <testsuite name="%s" tests="%s" failures="%s" errors="%s">\n' "$_te_sx" \
725
+ "$(grep -c '<testcase ' "$_te_tmp/$_te_i.junit")" "$(grep -c '<failure ' "$_te_tmp/$_te_i.junit")" \
726
+ "$(grep -c '<error ' "$_te_tmp/$_te_i.junit")"
727
+ _te_cat "$_te_tmp/$_te_i.junit"
728
+ echo ' </testsuite>'
729
+ fi >>"$_te_tmp/junit.xml"
730
+ ) </dev/null &
731
+ wait "$!"
732
+ done
733
+
734
+ _te_statuses <"$_te_tmp/results.jsonl" | _te_majority >"$_te_tmp/now"
735
+ _te_total=$(wc -l <"$_te_tmp/now" | tr -d ' ')
736
+ if [ "$_te_total" -eq 0 ]; then
737
+ if [ -n "$_te_only" ]; then echo "tinyeval: no test case name contains \"$_te_only\"" >&2; else echo "tinyeval: no test cases in $*" >&2; fi
738
+ exit 2
739
+ fi
740
+
741
+ if [ -f "$_te_results" ]; then
742
+ _te_statuses <"$_te_results" | _te_majority >"$_te_tmp/was"
743
+ awk -F '\t' -v g="$_te_g" -v r="$_te_r" -v z="$_te_0" '
744
+ function show(s) { s = substr(s, 2, length(s) - 2); gsub(/\\"/, "\"", s); return s }
745
+ NR == FNR { was[$1 FS $2] = $3; next }
746
+ ($1 FS $2) in was && was[$1 FS $2] != $3 && was[$1 FS $2] != "error" && $3 != "error" {
747
+ if (!shown++) print "\nSince the last run"
748
+ print " " ($3 == "pass" ? g "✓" z : r "✗" z) " " show($1) " › " show($2) ($3 == "pass" ? " passes now" : " fails now")
749
+ }' "$_te_tmp/was" "$_te_tmp/now"
750
+ fi
751
+ { echo '{"evals": ['; sed '$!s/$/,/' "$_te_tmp/results.jsonl"; echo ']}'; } >"$_te_results"
752
+
753
+ _te_failed=$(grep -c "${_te_tab}fail\$" "$_te_tmp/now")
754
+ _te_broke=$(grep -c "${_te_tab}error\$" "$_te_tmp/now")
755
+ if [ "$_te_failed" -gt 0 ]; then
756
+ _te_said="$_te_failed of $_te_total evals failed" _te_color=$_te_r
757
+ [ "$_te_broke" -eq 0 ] || _te_said="$_te_said, $_te_broke couldn't run"
758
+ elif [ "$_te_broke" -gt 0 ]; then _te_said="$_te_broke of $_te_total evals couldn't run" _te_color=$_te_y
759
+ else _te_said="All $_te_total evals passed" _te_color=$_te_g; fi
760
+ printf '\n%s%s%s %s(%s)%s\n' "$_te_color" "$_te_said" "$_te_0" "$_te_d" "$_te_results" "$_te_0"
761
+
762
+ if [ -n "$_te_junit" ]; then
763
+ { echo '<?xml version="1.0" encoding="UTF-8"?>'
764
+ printf '<testsuites tests="%s" failures="%s" errors="%s">\n' "$_te_total" "$_te_failed" "$_te_broke"
765
+ cat "$_te_tmp/junit.xml"
766
+ echo '</testsuites>'; } >"$_te_junit"
767
+ fi
768
+ # What the update notices below say to run.
769
+ if [ -n "$_te_pkg" ]; then _te_up=$_te_pkg _te_commit=''
770
+ else case $0 in ./*) _te_up="$0 update" ;; *) _te_up='tinyeval update' ;; esac; _te_commit=' and commit it'; fi
771
+ # GitHub Actions shows this on the run's page.
772
+ if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
773
+ { echo "### tinyeval: $_te_said"
774
+ if [ -s "$_te_tmp/summary.md" ]; then printf '\n| | Test case | Why | Reply |\n|---|---|---|---|\n'; cat "$_te_tmp/summary.md"; fi
775
+ wait "$_te_check_pid" 2>/dev/null # at most curl's 5 seconds, and the run has usually outlasted it
776
+ if _te_latest=$(cat "$_te_tmp/latest" 2>/dev/null) && _te_newer "$_te_latest" "$_te_version"; then
777
+ printf '\ntinyeval %s is out. Run %s%s.\n' "$_te_latest" "$_te_up" "$_te_commit"
778
+ fi
779
+ } >>"$GITHUB_STEP_SUMMARY"
780
+ fi
781
+ if [ -n "${_te_check-}" ] && _te_latest=$(cat "$_te_cache/latest" 2>/dev/null) && _te_newer "$_te_latest" "$_te_version"; then
782
+ printf '%stinyeval %s is out. Run %s%s\n' "$_te_d" "$_te_latest" "$_te_up" "$_te_0"
783
+ fi
784
+ [ $((_te_failed + _te_broke)) -eq 0 ] || exit 1
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tinyeval
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.11.0
5
+ platform: ruby
6
+ authors:
7
+ - evalship
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: tinyeval runs your evals with bundle exec tinyeval. Tinyeval.worker answers
13
+ them from your app, booted once.
14
+ executables:
15
+ - tinyeval
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - exe/tinyeval
22
+ - lib/tinyeval.rb
23
+ - lib/tinyeval/worker.rb
24
+ - tinyeval
25
+ homepage: https://tinyeval.com
26
+ licenses:
27
+ - MIT
28
+ metadata:
29
+ source_code_uri: https://github.com/evalship-hq/tinyeval/tree/main/packages/ruby
30
+ changelog_uri: https://github.com/evalship-hq/tinyeval/releases
31
+ bug_tracker_uri: https://github.com/evalship-hq/tinyeval/issues
32
+ rubygems_mfa_required: 'true'
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 4.0.20
48
+ specification_version: 4
49
+ summary: Evals for the AI calls in your Ruby or Rails app, run by one bash file
50
+ test_files: []