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 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