omniai-evals 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +124 -0
- data/lib/omniai/evals/adapters/call_record.rb +100 -0
- data/lib/omniai/evals/adapters/claude_cli.rb +270 -0
- data/lib/omniai/evals/adapters/omniai.rb +72 -0
- data/lib/omniai/evals/assertions/absence.rb +19 -0
- data/lib/omniai/evals/assertions/abstention.rb +43 -0
- data/lib/omniai/evals/assertions/circled.rb +180 -0
- data/lib/omniai/evals/assertions/identity.rb +48 -0
- data/lib/omniai/evals/assertions/presence.rb +33 -0
- data/lib/omniai/evals/assertions/structure.rb +85 -0
- data/lib/omniai/evals/assertions.rb +34 -0
- data/lib/omniai/evals/compare.rb +34 -0
- data/lib/omniai/evals/contract.rb +28 -0
- data/lib/omniai/evals/criteria.rb +51 -0
- data/lib/omniai/evals/criterion.rb +9 -0
- data/lib/omniai/evals/dataset.rb +65 -0
- data/lib/omniai/evals/dates.rb +105 -0
- data/lib/omniai/evals/errors.rb +19 -0
- data/lib/omniai/evals/example.rb +10 -0
- data/lib/omniai/evals/judge.rb +48 -0
- data/lib/omniai/evals/manifest.rb +73 -0
- data/lib/omniai/evals/normalize.rb +20 -0
- data/lib/omniai/evals/plan.rb +57 -0
- data/lib/omniai/evals/record.rb +37 -0
- data/lib/omniai/evals/reducer.rb +26 -0
- data/lib/omniai/evals/result.rb +22 -0
- data/lib/omniai/evals/results.rb +48 -0
- data/lib/omniai/evals/scorecard.rb +63 -0
- data/lib/omniai/evals/version.rb +7 -0
- data/lib/omniai/evals.rb +38 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: de97e262c2ca4119e0f158d0b4ed485ffeb9af5711edb529d099685fe277d615
|
|
4
|
+
data.tar.gz: ce16c93c7acab7e7f6c57680b65a14f04073ad21c7ad9f632efd746b60fb32a6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: df80b33102d5c4e691f143136de334d35a9e3755e6bde89d71147de1feba1c2a334f1091c69995fb3945fd6b8740caae68a3d1e5d9f2d52805ee5ba8fb6a39a0
|
|
7
|
+
data.tar.gz: 2cfaaab87db9222a769db038b8b6d3800f570d3b351fd625e522f23157c9f5b21ef24b1fa7f5894516aeff8459404397d63b471ede0652aeaf555861e60afcca
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Datasets, criteria, boolean results, the `all_epochs` reducer, run manifests, scorecards, comparability, and `Plan`.
|
|
8
|
+
- Assertions: `presence`, `absence`, `structure`, `identity`, `abstention`.
|
|
9
|
+
- Judge adapters: `Adapters::OmniAI` and `Adapters::ClaudeCLI`. Both accept a `calls:` collector that records each call's finish reason and latency.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
gem "irb"
|
|
8
|
+
gem "rake"
|
|
9
|
+
gem "rspec"
|
|
10
|
+
gem "rspec_junit_formatter"
|
|
11
|
+
gem "rubocop"
|
|
12
|
+
gem "rubocop-basic"
|
|
13
|
+
gem "rubocop-rake"
|
|
14
|
+
gem "rubocop-rspec"
|
|
15
|
+
gem "simplecov"
|
|
16
|
+
gem "yard"
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dean Singh
|
|
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,124 @@
|
|
|
1
|
+
# OmniAI::Evals
|
|
2
|
+
|
|
3
|
+
Structure and grading for LLM evals: datasets, boolean criteria, assertions, judges,
|
|
4
|
+
epoch reduction, comparability, and PHI-free scorecards. A companion to the
|
|
5
|
+
[omniai](https://github.com/ksylvest/omniai) gem family.
|
|
6
|
+
|
|
7
|
+
The gem holds the *structure*. It knows nothing about any dataset, model, or product —
|
|
8
|
+
consumers hold the datasets, the criteria keys, the prompts under test, and the runners
|
|
9
|
+
that wire a target to a dataset.
|
|
10
|
+
|
|
11
|
+
`docs/contract.md` is the authority. It fixes the vocabulary, the five file formats, the
|
|
12
|
+
Ruby interfaces, and six invariants the gem tests for itself. Every file the gem reads or
|
|
13
|
+
writes carries `"contract": 1`; changing a format without bumping that number fails the
|
|
14
|
+
suite.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "omniai-evals"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Load
|
|
23
|
+
|
|
24
|
+
The core loads with stdlib and `json` only. **Adapters reach a model, so none of them is
|
|
25
|
+
required for you** — require the one you want:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "omniai/evals" # core: no network, no gems
|
|
29
|
+
require "omniai/evals/adapters/omniai" # judge via the omniai gem (API-billed)
|
|
30
|
+
require "omniai/evals/adapters/claude_cli" # judge via `claude -p` (subscription or API)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Scores are boolean
|
|
34
|
+
|
|
35
|
+
There are no 0–1 scores, thresholds, or weights anywhere, by design. A criterion either
|
|
36
|
+
held or it did not, and `Result` raises on anything that is not `true` or `false`. Across
|
|
37
|
+
epochs, `Reducer.all_epochs` returns `:pass`, `:fail`, or `:flaky` — a criterion that
|
|
38
|
+
passes 3 runs in 5 is not "60% correct", it is unreliable, and the reader needs to see
|
|
39
|
+
that rather than a rounded number.
|
|
40
|
+
|
|
41
|
+
## The five deterministic criterion types
|
|
42
|
+
|
|
43
|
+
Each is free, offline, and returns `{key, score, comment}`.
|
|
44
|
+
|
|
45
|
+
| type | `target` | passes when |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `presence` | `{"date": "2019-03-14"}` / `{"text": "signed by"}` | the date (parsed, any format) or the text (case- and whitespace-normalized) appears |
|
|
48
|
+
| `absence` | `{"text": "DO NOT COPY"}` | the text does **not** appear |
|
|
49
|
+
| `structure` | `{"table_rows": 12}` | the markdown table has that many body rows (header and separator excluded) |
|
|
50
|
+
| `structure` | `{"circled": "Option B"}` | that option is marked circled / selected |
|
|
51
|
+
| `structure` | `{"path": ["exam","date"], "type": "string"}` | the output is a Hash, the path resolves, the value has that JSON type |
|
|
52
|
+
| `identity` | `{"text": "Patient: JANE Q."}` | the output contains it **exactly**, case-sensitive, after whitespace collapse |
|
|
53
|
+
| `identity` | `{"field": "claim_number", "value": "ABC-123"}` | that field of a Hash output equals that value |
|
|
54
|
+
| `abstention` | `{"region": "line 7", "forbidden_terms": ["approved"]}` | the output abstains on the region rather than asserting content it cannot read |
|
|
55
|
+
|
|
56
|
+
`structure` and `identity` each take a **text-mode** target and a **structured** target,
|
|
57
|
+
selected by which key is present — the shape a target emits (markdown vs JSON) is the
|
|
58
|
+
consumer's business, not the criterion's.
|
|
59
|
+
|
|
60
|
+
`identity` in text mode is the one assertion that is **case-sensitive** and normalizes
|
|
61
|
+
nothing but whitespace. That is deliberate: a banded table whose header names a different
|
|
62
|
+
patient must be transcribed as-is, and an OCR pass that quietly "fixes" the case has
|
|
63
|
+
produced exactly the failure the criterion exists to detect.
|
|
64
|
+
|
|
65
|
+
`abstention` recognises the non-lexical marker `~~~~` (any run of 3+ tildes) that
|
|
66
|
+
production models actually emit, plus lexical markers like `[illegible]`. Override the
|
|
67
|
+
list per criterion with `target["markers"]`.
|
|
68
|
+
|
|
69
|
+
`faithful` is a judge, not an assertion, and `Assertions.for("faithful")` raises rather
|
|
70
|
+
than quietly falling back to a string compare.
|
|
71
|
+
|
|
72
|
+
**Dates are compared parsed, never as strings.** `2019-03-14`, `03/14/2019`,
|
|
73
|
+
`03-14-2019`, `14 March 2019`, `Mar. 14, 2019`, `March 14 2019`, `2019/03/14` and
|
|
74
|
+
`3/14/19` are all the same day.
|
|
75
|
+
|
|
76
|
+
Numeric slash and dash dates are read **US month-first**. Ruby's `Date.parse` reads them
|
|
77
|
+
day-first, so it returns `nil` for `03/14/2019` and — worse — silently returns *February
|
|
78
|
+
4th* for `04/02/2019`, marking a correct transcription wrong with nothing raised anywhere.
|
|
79
|
+
A non-US dataset needs a per-dataset locale field; the format cannot be inferred from the
|
|
80
|
+
text. See `Dates::US_NUMERIC`.
|
|
81
|
+
|
|
82
|
+
## PHI
|
|
83
|
+
|
|
84
|
+
- **Result records (JSONL) are PHI-bearing** — a judge comment may quote model output.
|
|
85
|
+
Store them under your own protected path.
|
|
86
|
+
- **Scorecards and run manifests are not.** They carry counts and provenance, never text.
|
|
87
|
+
The scorecard is built as a fresh Hash of integers rather than by filtering fields out
|
|
88
|
+
of records, so there is no route by which a comment reaches it.
|
|
89
|
+
- **The judge receives the key text and the model output, never the example input.**
|
|
90
|
+
`Judge.packet` takes only those two, so there is no parameter to leak the input through.
|
|
91
|
+
|
|
92
|
+
## Runs, configs, and the results file
|
|
93
|
+
|
|
94
|
+
A **run is one target version**. `Plan` takes `configs:` so you can price "default vs
|
|
95
|
+
`effort: low`" in one go *before* committing the spend, but each config is its own run with
|
|
96
|
+
its own manifest and its own results file. `Results.write` refuses to append records from a
|
|
97
|
+
second `run_id` into a file that already holds one — otherwise a re-run silently doubles
|
|
98
|
+
every epoch and the reducer returns a PASS assembled from two runs nobody asked to combine.
|
|
99
|
+
|
|
100
|
+
`--plan` prices **target calls as well as judge calls**, from `prices["target"]` and
|
|
101
|
+
`prices["judge"]`. For a vision bench the target calls are the metered spend, and a plan
|
|
102
|
+
reporting `$0.00` for a paid run is worse than no plan.
|
|
103
|
+
|
|
104
|
+
## The demo bench
|
|
105
|
+
|
|
106
|
+
The gem ships one synthetic, PHI-free bench so it always has a consumer of its own:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ruby bench/demo/run.rb --plan # estimates calls and cost, makes zero model calls
|
|
110
|
+
ruby bench/demo/run.rb # grades a deliberately imperfect target, prints a scorecard
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bundle install
|
|
117
|
+
bundle exec rake # rspec + rubocop
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The six contract invariants have their own spec file, `spec/omniai/evals/invariants_spec.rb`.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OmniAI
|
|
6
|
+
module Evals
|
|
7
|
+
module Adapters
|
|
8
|
+
# One per-call record for an adapter's optional `calls:` collector: stop codes and numbers
|
|
9
|
+
# only, never result text or stderr (both can carry PHI).
|
|
10
|
+
#
|
|
11
|
+
# `attempts` is always 1: neither adapter retries, and retries inside `claude -p` or the
|
|
12
|
+
# provider client are not reported.
|
|
13
|
+
module CallRecord
|
|
14
|
+
DURATION_KEYS = %w[duration_ms duration_api_ms].freeze
|
|
15
|
+
# A call that finished but named no reason.
|
|
16
|
+
UNREPORTED = "unreported"
|
|
17
|
+
|
|
18
|
+
# For Adapters::ClaudeCLI. The block receives a sink to hand the subprocess stdout to
|
|
19
|
+
# (including on a non-zero exit, so an error envelope's subtype is still recorded) and
|
|
20
|
+
# returns that stdout. The stdout stays here; it is parsed only for stop codes and numbers.
|
|
21
|
+
def self.capture(calls, timeout_error:, &block)
|
|
22
|
+
return yield(nil) if calls.nil?
|
|
23
|
+
|
|
24
|
+
stdout = nil
|
|
25
|
+
sink = ->(out) { stdout = out }
|
|
26
|
+
measure(calls, timeout_error:, body: -> { block.call(sink) }) do |failure, latency_ms|
|
|
27
|
+
envelope_record(stdout, failure:, latency_ms:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# For Adapters::OmniAI. The block returns an OmniAI::Chat::Response; its verbatim provider
|
|
32
|
+
# finish reason is recorded.
|
|
33
|
+
def self.capture_response(calls, timeout_error:, &block)
|
|
34
|
+
return yield if calls.nil?
|
|
35
|
+
|
|
36
|
+
response = nil
|
|
37
|
+
measure(calls, timeout_error:, body: -> { response = block.call }) do |failure, latency_ms|
|
|
38
|
+
reason = failure || string(response.finish_reason&.value) || UNREPORTED
|
|
39
|
+
record(reason, latency_ms)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Runs +body+ and pushes exactly one record (built by the block from the failure word, or
|
|
44
|
+
# nil on success, and the latency) however it ends, before any error propagates.
|
|
45
|
+
def self.measure(calls, timeout_error:, body:)
|
|
46
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
47
|
+
failure = "error"
|
|
48
|
+
begin
|
|
49
|
+
result = body.call
|
|
50
|
+
failure = nil
|
|
51
|
+
result
|
|
52
|
+
rescue timeout_error
|
|
53
|
+
failure = "timeout"
|
|
54
|
+
raise
|
|
55
|
+
ensure
|
|
56
|
+
calls << yield(failure, ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The envelope's stop_reason, else its subtype (subtype first when is_error), else
|
|
61
|
+
# "unreported". A failed call keeps +failure+ unless its stdout is an is_error envelope, so
|
|
62
|
+
# a timeout, or a non-zero exit without one, records "timeout" / "error"; stdout that is
|
|
63
|
+
# not a JSON object records "error".
|
|
64
|
+
def self.envelope_record(stdout, failure:, latency_ms:)
|
|
65
|
+
envelope = envelope(stdout)
|
|
66
|
+
return record(failure || "error", latency_ms) if envelope.nil?
|
|
67
|
+
return record(failure, latency_ms) if failure && !envelope["is_error"]
|
|
68
|
+
|
|
69
|
+
result = record(finish_reason(envelope), latency_ms)
|
|
70
|
+
DURATION_KEYS.each { |key| result[key] = envelope[key] if envelope[key].is_a?(Numeric) }
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.record(finish_reason, latency_ms)
|
|
75
|
+
{ "finish_reason" => finish_reason, "latency_ms" => latency_ms, "attempts" => 1 }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Strings only, so an odd envelope can't put a nested object into the record.
|
|
79
|
+
def self.finish_reason(envelope)
|
|
80
|
+
reasons = envelope.values_at("stop_reason", "subtype")
|
|
81
|
+
reasons.reverse! if envelope["is_error"]
|
|
82
|
+
reasons.map { |reason| string(reason) }.compact.first || UNREPORTED
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.string(value)
|
|
86
|
+
value if value.is_a?(String)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.envelope(stdout)
|
|
90
|
+
return if stdout.nil?
|
|
91
|
+
|
|
92
|
+
parsed = JSON.parse(stdout)
|
|
93
|
+
parsed if parsed.is_a?(Hash)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
require_relative "../../evals"
|
|
7
|
+
require_relative "call_record"
|
|
8
|
+
|
|
9
|
+
module OmniAI
|
|
10
|
+
module Evals
|
|
11
|
+
module Adapters
|
|
12
|
+
# A judge that shells out to `claude -p`, ported from its first consumer's judge.
|
|
13
|
+
#
|
|
14
|
+
# One place for the "hand a packet to a `claude -p` judge and get back a parsed
|
|
15
|
+
# JSON verdict" mechanics that several checks need: completeness (rubric
|
|
16
|
+
# adherence), blind re-extraction, and dispute tie-break. Each caller supplies
|
|
17
|
+
# its OWN system prompt, packet shape, and model; this owns only the transport
|
|
18
|
+
# and the tolerant output parsing.
|
|
19
|
+
#
|
|
20
|
+
# Three disciplines are baked in and must never regress:
|
|
21
|
+
# - PHI-on-stdin: the packet (summary/source/transcript text) is PHI and goes
|
|
22
|
+
# on STDIN, never argv -- argv is visible in `ps`/process listings. Only
|
|
23
|
+
# static, PHI-free flags are passed as arguments.
|
|
24
|
+
# - PHI-free errors: `claude` stderr and the raw judge output can echo verbatim
|
|
25
|
+
# source quotes (PHI), so error messages surface only byte counts, never the
|
|
26
|
+
# content itself.
|
|
27
|
+
# - Isolation: the judge runs with only the model and the caller's system prompt
|
|
28
|
+
# (see ISOLATION_FLAGS) -- no tools, MCP servers, saved session, or settings.
|
|
29
|
+
#
|
|
30
|
+
# Changes from that original: it is a class whose billing is bound at
|
|
31
|
+
# construction (the old Judge.bound), so it satisfies the contract's
|
|
32
|
+
# `call(system:, packet:, model:, schema:, timeout:)` adapter interface; and it
|
|
33
|
+
# runs isolated, replacing the Claude Code system prompt rather than appending to it.
|
|
34
|
+
class ClaudeCLI
|
|
35
|
+
class JudgeError < OmniAI::Evals::Error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A judge that blew its wall-clock deadline, distinct from an unparseable result so
|
|
39
|
+
# the summary can count timeouts on their own line (it is still a JudgeError, so
|
|
40
|
+
# existing rescues keep treating it as a failed call).
|
|
41
|
+
class TimeoutError < JudgeError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The child's environment is built FROM SCRATCH, never inherited. ANTHROPIC_API_KEY is
|
|
45
|
+
# exported machine-wide on the operator's box, and a bare Open3.popen3(*args) hands it
|
|
46
|
+
# to `claude -p`, where it takes auth precedence over the claude.ai login -- so runs
|
|
47
|
+
# believed to be on the subscription silently billed the metered API (confirmed in the
|
|
48
|
+
# Anthropic Console, 2026-08-26). An ALLOWLIST is deliberate: deleting only the variable
|
|
49
|
+
# we found would leave ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL / the Bedrock+Vertex
|
|
50
|
+
# switches to bite next, via the same machine-wide-export mechanism.
|
|
51
|
+
#
|
|
52
|
+
# TRAP, so nobody re-derives it the expensive way: `~/.claude.json` ->
|
|
53
|
+
# `customApiKeyResponses` listing a key under "rejected" governs only the INTERACTIVE
|
|
54
|
+
# approval prompt. It does NOT govern `-p` subprocess auth precedence, and is not
|
|
55
|
+
# evidence of auth mode. Neither is the `-p` envelope's `total_cost_usd`, which is
|
|
56
|
+
# populated under both modes. Billing mode is verifiable only at the Console.
|
|
57
|
+
# Drawn by AUTHORITY, not by enumeration. STRIP anything that carries or redirects
|
|
58
|
+
# authority (ANTHROPIC_*, CLAUDE_CODE_USE_*, cloud credentials) or that injects code
|
|
59
|
+
# (NODE_OPTIONS, RUBYOPT -- an injected --require can re-add any stripped var, so code
|
|
60
|
+
# injection IS authority). PASS machine descriptors and LOCATORS.
|
|
61
|
+
#
|
|
62
|
+
# CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME are the decisive entries: they are the only
|
|
63
|
+
# omissions that would fail SILENTLY-WRONG instead of loudly. With them stripped, a
|
|
64
|
+
# child on a machine whose Claude config was relocated falls back to ~/.claude and, if
|
|
65
|
+
# a valid login lives there, authenticates as a DIFFERENT ACCOUNT -- the same
|
|
66
|
+
# silent-wrong-auth class this seam exists to prevent. The TLS/proxy entries only
|
|
67
|
+
# prevent pointless loud breakage on a corporate network.
|
|
68
|
+
#
|
|
69
|
+
# The boundary is self-enforcing: the spec fails if a future edit adds a name
|
|
70
|
+
# matching /KEY|TOKEN|SECRET|AUTH|ANTHROPIC|AWS|GOOGLE|CREDENTIAL/.
|
|
71
|
+
CHILD_ENV_ALLOWLIST = %w[
|
|
72
|
+
PATH HOME SHELL LANG LC_ALL LC_CTYPE TMPDIR USER LOGNAME
|
|
73
|
+
CLAUDE_CONFIG_DIR XDG_CONFIG_HOME
|
|
74
|
+
SSL_CERT_FILE NODE_EXTRA_CA_CERTS HTTP_PROXY HTTPS_PROXY NO_PROXY
|
|
75
|
+
].freeze
|
|
76
|
+
BILLING_MODES = %i[subscription api].freeze
|
|
77
|
+
|
|
78
|
+
# A page legitimately takes minutes (the model re-transcribes it), so the default
|
|
79
|
+
# deadline is generous; a caller's timeout: overrides it. Its job is only to cap a
|
|
80
|
+
# WEDGED judge that would otherwise hang a worker forever.
|
|
81
|
+
JUDGE_TIMEOUT = 900
|
|
82
|
+
TERM_GRACE = 5
|
|
83
|
+
|
|
84
|
+
# The judge receives PHI on stdin, so it runs with nothing but the model and the caller's
|
|
85
|
+
# system prompt (--system-prompt replaces Claude Code's): no tools, no MCP servers (which
|
|
86
|
+
# would also join it to peer networks), no saved session under ~/.claude/projects, and no
|
|
87
|
+
# settings or CLAUDE.md.
|
|
88
|
+
ISOLATION_FLAGS = ["--tools", "", "--strict-mcp-config", "--no-session-persistence",
|
|
89
|
+
"--setting-sources", "",].freeze
|
|
90
|
+
|
|
91
|
+
attr_reader :billing
|
|
92
|
+
|
|
93
|
+
# Billing is a RUN-level choice, so it is bound once at construction and the bound
|
|
94
|
+
# adapter is handed to the existing injection points -- no `billing:` parameter has
|
|
95
|
+
# to be threaded through every intermediate signature. The mode is validated at BIND
|
|
96
|
+
# time, so a bad value fails before any page is judged rather than on the first paid
|
|
97
|
+
# call, and the instance is frozen so a validated mode is the mode that runs.
|
|
98
|
+
#
|
|
99
|
+
# @param billing [Symbol] :subscription (local claude.ai login, no key reaches the
|
|
100
|
+
# child) or :api (METERED -- injects ANTHROPIC_API_KEY). Required on purpose:
|
|
101
|
+
# there is no default, so a caller cannot fall into metered billing by omission.
|
|
102
|
+
def initialize(billing:)
|
|
103
|
+
@billing = billing
|
|
104
|
+
child_env # validates the mode (and, for :api, the key) up front
|
|
105
|
+
freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs one `claude -p` judge under the billing mode bound at construction.
|
|
109
|
+
#
|
|
110
|
+
# @param system [String] the judge's system prompt -- static and PHI-free.
|
|
111
|
+
# @param packet [String] the PHI-bearing user content; sent on STDIN only.
|
|
112
|
+
# @param model [String] the model id (caller-chosen -- sonnet/haiku/etc.). Keyword
|
|
113
|
+
# with a nil default only so the signature matches Adapters::OmniAI; nil raises.
|
|
114
|
+
# @param schema [Hash, nil] an optional JSON Schema. When given, the CLI's
|
|
115
|
+
# `--json-schema` flag constrains the model's answer via constrained decoding
|
|
116
|
+
# and the schema-valid object comes back in the envelope's `structured_output`
|
|
117
|
+
# field -- the fix for a task prompt (e.g. "transcribe then extract") whose prose
|
|
118
|
+
# otherwise buries or replaces the JSON. Without it, behavior is unchanged: the
|
|
119
|
+
# free-form `result` is recovered by the tolerant parse_output/parse_verdict path.
|
|
120
|
+
# @param calls [#<<, nil] an optional collector. Each call that reaches the subprocess
|
|
121
|
+
# pushes exactly one CallRecord however it ends, before any error is raised.
|
|
122
|
+
# @return [Hash] the parsed verdict.
|
|
123
|
+
# @raise [JudgeError] when the claude call errors or returns unparseable output.
|
|
124
|
+
def call(system:, packet:, model: nil, schema: nil, timeout: JUDGE_TIMEOUT, calls: nil)
|
|
125
|
+
# model stays effectively required -- there is no sensible default for a judge
|
|
126
|
+
# model -- but it is a keyword with a nil default so this signature matches
|
|
127
|
+
# Adapters::OmniAI exactly and a runner can swap the two adapters.
|
|
128
|
+
raise ArgumentError, "model is required" if model.nil?
|
|
129
|
+
|
|
130
|
+
# stderr is captured but deliberately dropped from any error -- it can echo
|
|
131
|
+
# input (PHI). Only PHI-free flags ride argv; the packet rides stdin. The
|
|
132
|
+
# schema is a static, PHI-free JSON Schema, so it is safe on argv.
|
|
133
|
+
args = ["claude", "-p", "--system-prompt", system, *ISOLATION_FLAGS, "--model", model,
|
|
134
|
+
"--output-format", "json",]
|
|
135
|
+
args += ["--json-schema", JSON.generate(schema)] if schema
|
|
136
|
+
stdout = CallRecord.capture(calls, timeout_error: TimeoutError) do |on_stdout|
|
|
137
|
+
run_with_timeout(args, packet, timeout:, env: child_env, on_stdout:)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
schema ? parse_structured(stdout) : parse_output(stdout)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The child environment for the bound billing mode -- built from the allowlist, so no
|
|
144
|
+
# credential reaches the child unless this method puts it there. :subscription carries
|
|
145
|
+
# NO auth vars, leaving `claude -p` to use the local claude.ai login; :api injects the
|
|
146
|
+
# key deliberately and fails fast when it is absent, rather than silently falling back.
|
|
147
|
+
def child_env
|
|
148
|
+
raise ArgumentError, "unknown billing mode #{@billing.inspect}" unless BILLING_MODES.include?(@billing)
|
|
149
|
+
|
|
150
|
+
env = CHILD_ENV_ALLOWLIST.each_with_object({}) do |name, acc|
|
|
151
|
+
value = ENV.fetch(name, nil)
|
|
152
|
+
acc[name] = value unless value.nil?
|
|
153
|
+
end
|
|
154
|
+
return env unless @billing == :api
|
|
155
|
+
|
|
156
|
+
key = ENV.fetch("ANTHROPIC_API_KEY", "").to_s
|
|
157
|
+
raise JudgeError, "billing: :api requires ANTHROPIC_API_KEY to be set" if key.empty?
|
|
158
|
+
|
|
159
|
+
env.merge("ANTHROPIC_API_KEY" => key)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Runs the judge subprocess under a hard wall-clock deadline so a wedged `claude`
|
|
165
|
+
# can't hang a worker forever (Open3.capture3 has no timeout). stdout and stderr
|
|
166
|
+
# are drained in threads to avoid a pipe-buffer deadlock; stderr is dropped (it can
|
|
167
|
+
# echo PHI). On expiry: SIGTERM, a short grace, then SIGKILL, and a PHI-free
|
|
168
|
+
# JudgeError. The packet rides stdin only, never argv. +env+, when given, REPLACES
|
|
169
|
+
# the child's environment entirely (see the unsetenv_others note below). +on_stdout+,
|
|
170
|
+
# when given, receives stdout before the exit check, so a caller can read a failed
|
|
171
|
+
# call's envelope without it ever reaching an error.
|
|
172
|
+
def run_with_timeout(args, packet, timeout:, env: nil, on_stdout: nil)
|
|
173
|
+
# unsetenv_others is what makes the allowlist real: WITHOUT it Open3 MERGES the hash
|
|
174
|
+
# into the inherited environment, so every var we meant to exclude still reaches the
|
|
175
|
+
# child and the whole seam is a silent no-op.
|
|
176
|
+
spawn = env.nil? ? args : [env, *args, { unsetenv_others: true }]
|
|
177
|
+
Open3.popen3(*spawn) do |stdin, stdout, stderr, wait_thr|
|
|
178
|
+
writer = Thread.new do
|
|
179
|
+
stdin.write(packet)
|
|
180
|
+
rescue Errno::EPIPE
|
|
181
|
+
# the child exited before reading its input -- nothing left to send
|
|
182
|
+
ensure
|
|
183
|
+
stdin.close
|
|
184
|
+
end
|
|
185
|
+
out_reader = Thread.new { stdout.read }
|
|
186
|
+
err_reader = Thread.new { stderr.read } # drained to avoid deadlock; dropped (PHI)
|
|
187
|
+
|
|
188
|
+
if wait_thr.join(timeout).nil?
|
|
189
|
+
terminate(wait_thr)
|
|
190
|
+
[writer, out_reader, err_reader].each(&:kill)
|
|
191
|
+
raise TimeoutError, "judge timeout after #{timeout}s"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
writer.join
|
|
195
|
+
err_reader.join
|
|
196
|
+
status = wait_thr.value
|
|
197
|
+
stdout = out_reader.value.tap { |out| on_stdout&.call(out) }
|
|
198
|
+
raise JudgeError, "claude -p exited #{status.exitstatus}" unless status.success?
|
|
199
|
+
|
|
200
|
+
stdout
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# SIGTERM, wait a short grace, then SIGKILL -- never leave a wedged judge running.
|
|
205
|
+
def terminate(wait_thr)
|
|
206
|
+
Process.kill("TERM", wait_thr.pid)
|
|
207
|
+
return if wait_thr.join(TERM_GRACE)
|
|
208
|
+
|
|
209
|
+
Process.kill("KILL", wait_thr.pid)
|
|
210
|
+
wait_thr.join(1)
|
|
211
|
+
rescue Errno::ESRCH
|
|
212
|
+
# already exited between the timeout check and the signal
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Parses the `claude -p --output-format json` envelope ({ is_error, result })
|
|
216
|
+
# and returns the verdict Hash carried in `result`. The error message never
|
|
217
|
+
# includes the raw output -- it holds PHI (verbatim source quotes).
|
|
218
|
+
def parse_output(stdout)
|
|
219
|
+
envelope = JSON.parse(stdout)
|
|
220
|
+
raise JudgeError, "claude reported an error result" if envelope["is_error"]
|
|
221
|
+
|
|
222
|
+
parse_verdict(envelope.fetch("result", "").to_s)
|
|
223
|
+
rescue JSON::ParserError
|
|
224
|
+
raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The judge is told to emit bare JSON, but LLM output is nondeterministic -- the
|
|
228
|
+
# same input can come back ```-fenced one run and wrapped in prose the next.
|
|
229
|
+
# Strip fences first; if that still isn't valid JSON, fall back to the outermost
|
|
230
|
+
# { ... } span. Raises (PHI-free) only when no JSON object is recoverable.
|
|
231
|
+
def parse_verdict(result)
|
|
232
|
+
cleaned = result.strip.gsub(/\A```(?:json)?\s*/m, "").gsub(/\s*```\z/m, "").strip
|
|
233
|
+
JSON.parse(cleaned)
|
|
234
|
+
rescue JSON::ParserError
|
|
235
|
+
span = cleaned[/\{.*\}/m]
|
|
236
|
+
raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)" if span.nil?
|
|
237
|
+
|
|
238
|
+
# A non-nil span can still be invalid JSON (e.g. prose braces). Rescue here too
|
|
239
|
+
# so it surfaces as a PHI-free JudgeError, never a raw JSON::ParserError leaking
|
|
240
|
+
# the (possibly PHI-bearing) content.
|
|
241
|
+
begin
|
|
242
|
+
JSON.parse(span)
|
|
243
|
+
rescue JSON::ParserError
|
|
244
|
+
raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Reads the schema-constrained object from a `claude -p --json-schema` envelope's
|
|
249
|
+
# `structured_output` field (constrained decoding populates it). The free-form
|
|
250
|
+
# `result` still holds the model's prose transcription -- its chain-of-thought --
|
|
251
|
+
# but the deterministic artifact is `structured_output`. An absent field means the
|
|
252
|
+
# model transcribed but never emitted the constrained object (refusal / max_tokens):
|
|
253
|
+
# a COUNTED JudgeError, never a silent nil. Error messages stay PHI-free (byte counts
|
|
254
|
+
# only) -- the envelope can echo verbatim source quotes.
|
|
255
|
+
def parse_structured(stdout)
|
|
256
|
+
envelope = JSON.parse(stdout)
|
|
257
|
+
raise JudgeError, "claude reported an error result" if envelope["is_error"]
|
|
258
|
+
|
|
259
|
+
verdict = envelope["structured_output"]
|
|
260
|
+
raise JudgeError, "no structured_output in judge response (#{stdout.bytesize} bytes)" unless
|
|
261
|
+
verdict.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
verdict
|
|
264
|
+
rescue JSON::ParserError
|
|
265
|
+
raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require_relative "../../evals"
|
|
6
|
+
require_relative "call_record"
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require "omniai"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
raise OmniAI::Evals::AdapterNotLoaded,
|
|
12
|
+
'Adapters::OmniAI needs the omniai gem: add `gem "omniai"` to your Gemfile'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module OmniAI
|
|
16
|
+
module Evals
|
|
17
|
+
# Judge adapters. Each reaches a model, so none of them is required by the core --
|
|
18
|
+
# a consumer requires the one it wants.
|
|
19
|
+
module Adapters
|
|
20
|
+
# The default judge adapter: API-billed, structured output via OmniAI::Schema.format.
|
|
21
|
+
#
|
|
22
|
+
# "Default" is a documentation claim, not an implicit require. The core loads on
|
|
23
|
+
# stdlib alone (invariant 3), so requiring this file is the consumer's decision and
|
|
24
|
+
# AdapterNotLoaded names what is missing when the gem is absent.
|
|
25
|
+
#
|
|
26
|
+
# Behaviour-equivalent to Adapters::ClaudeCLI by design, and the signature is the
|
|
27
|
+
# contract's call(system:, packet:, model:, schema:, timeout:) so a runner can swap
|
|
28
|
+
# one for the other without touching the call site. Equivalence is not cosmetic:
|
|
29
|
+
# judge calibration measured on one adapter only transfers to the other if both put
|
|
30
|
+
# the same content in the same role.
|
|
31
|
+
class OmniAI
|
|
32
|
+
# @param client [OmniAI::Client]
|
|
33
|
+
# @param model [String] the default model, overridable per call
|
|
34
|
+
def initialize(client:, model:)
|
|
35
|
+
@client = client
|
|
36
|
+
@model = model
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param system [String] the judge's system prompt -- static and PHI-free
|
|
40
|
+
# @param packet [String] the PHI-bearing user content
|
|
41
|
+
# @param schema [Hash] a plain JSON Schema, per Judge.schema
|
|
42
|
+
# @param timeout [Integer, nil] wall-clock cap, matching ClaudeCLI's
|
|
43
|
+
# @param calls [#<<, nil] an optional collector; each call pushes exactly one CallRecord
|
|
44
|
+
# (the response's verbatim finish reason) however it ends, before any error is raised.
|
|
45
|
+
# @return [Hash] matching `schema`
|
|
46
|
+
def call(system:, packet:, model: nil, schema: nil, timeout: nil, calls: nil)
|
|
47
|
+
# A REAL system turn, not a system prompt glued onto the front of the user
|
|
48
|
+
# message. ClaudeCLI passes it via --system-prompt, and an adapter that
|
|
49
|
+
# instead prepends it to the user content is asking the model a different
|
|
50
|
+
# question -- which silently invalidates any calibration carried across.
|
|
51
|
+
response = CallRecord.capture_response(calls, timeout_error: Timeout::Error) do
|
|
52
|
+
Timeout.timeout(timeout) do
|
|
53
|
+
@client.chat(model: model || @model, format: format_for(schema)) do |prompt|
|
|
54
|
+
prompt.system(system)
|
|
55
|
+
prompt.user(packet)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
JSON.parse(response.text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Schema.format wants an OmniAI::Schema::Object and the core deals in plain JSON
|
|
63
|
+
# Schema Hashes, so the translation belongs here at the boundary.
|
|
64
|
+
def format_for(schema)
|
|
65
|
+
return nil if schema.nil?
|
|
66
|
+
|
|
67
|
+
::OmniAI::Schema.format(name: "verdict", schema: ::OmniAI::Schema.deserialize(schema))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# Passes when the forbidden text does NOT appear.
|
|
7
|
+
module Absence
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(example:, output:, criterion:)
|
|
11
|
+
needle = Normalize.phrase(criterion.target.fetch("text"))
|
|
12
|
+
present = Normalize.phrase(Normalize.text(output)).include?(needle)
|
|
13
|
+
Result.new(key: criterion.id, score: !present,
|
|
14
|
+
comment: present ? "forbidden text present" : "forbidden text absent")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|