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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module OmniAI
|
|
6
|
+
module Evals
|
|
7
|
+
# Dates are compared PARSED, never as strings: "2019-03-14", "03/14/2019" and
|
|
8
|
+
# "March 14, 2019" are the same day, and a string compare scores three different
|
|
9
|
+
# transcriptions of one correct answer as two failures.
|
|
10
|
+
module Dates
|
|
11
|
+
# Numeric slash AND dash dates are read as US month-first (MM/DD/YYYY, MM-DD-YYYY),
|
|
12
|
+
# NOT by Date.parse.
|
|
13
|
+
#
|
|
14
|
+
# This is not a style preference, it is a correctness fix. Ruby's Date.parse reads
|
|
15
|
+
# a slash date as DD/MM/YYYY, so it returns nil for "03/14/2019" (there is no month
|
|
16
|
+
# 14) and -- far worse -- silently returns February 4th for "04/02/2019". A judge
|
|
17
|
+
# would then mark a correct April 2nd transcription as wrong, with no error anywhere
|
|
18
|
+
# to notice. Wrong-but-plausible is the failure mode this whole gem exists to catch,
|
|
19
|
+
# so it cannot live in the date comparison itself.
|
|
20
|
+
#
|
|
21
|
+
# STATED DEFAULT (docs/contract.md): every dataset this grades is US, so month-first
|
|
22
|
+
# is the rule. A non-US dataset needs a per-dataset locale field in the contract --
|
|
23
|
+
# it is not something to infer from the text, because "04/02/2019" is valid in both
|
|
24
|
+
# readings and guessing reintroduces exactly the silent error above. An unambiguous
|
|
25
|
+
# key should be written ISO in the criteria file regardless.
|
|
26
|
+
US_NUMERIC = %r{\A(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\z}
|
|
27
|
+
|
|
28
|
+
# ISO stays ISO: "2019-03-14" is unambiguous and must NOT hit the US rule.
|
|
29
|
+
ISO = /\A\d{4}-\d{2}-\d{2}\z/
|
|
30
|
+
# "2019/03/14" is year-first by shape, so it is unambiguous too.
|
|
31
|
+
ISO_SLASH = %r{\A(\d{4})/(\d{1,2})/(\d{1,2})\z}
|
|
32
|
+
|
|
33
|
+
MONTH = "January|February|March|April|May|June|July|August|September|October|November|December|" \
|
|
34
|
+
"Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec"
|
|
35
|
+
|
|
36
|
+
# Every date shape seen in the faxed medical records this grades. Order matters:
|
|
37
|
+
# the alternation is tried left to right, so longer textual forms come before the
|
|
38
|
+
# numeric ones that could match a prefix of them.
|
|
39
|
+
TOKEN = Regexp.union(
|
|
40
|
+
/(?:#{MONTH})\.?\s+\d{1,2},?\s+\d{4}/, # March 14, 2019 / Mar. 14 2019 / Mar 14 2019
|
|
41
|
+
/\d{1,2}\s+(?:#{MONTH})\.?,?\s+\d{4}/, # 14 March 2019
|
|
42
|
+
%r{\d{4}[-/]\d{1,2}[-/]\d{1,2}}, # 2019-03-14 / 2019/03/14
|
|
43
|
+
%r{\d{1,2}[/-]\d{1,2}[/-]\d{2,4}} # 03/14/2019 / 03-14-2019 / 3/14/19
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# @return [Date, nil] nil when the value is not a date
|
|
49
|
+
def parse(value)
|
|
50
|
+
return nil if value.nil?
|
|
51
|
+
|
|
52
|
+
text = value.to_s.strip
|
|
53
|
+
return iso(text) if ISO.match?(text)
|
|
54
|
+
|
|
55
|
+
slash = ISO_SLASH.match(text)
|
|
56
|
+
return ymd(slash[1], slash[2], slash[3]) if slash
|
|
57
|
+
|
|
58
|
+
us = US_NUMERIC.match(text)
|
|
59
|
+
return us_numeric(us) if us
|
|
60
|
+
|
|
61
|
+
textual(text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Array<Date>] every date-shaped token in a blob of text, parsed. Used by
|
|
65
|
+
# the presence assertion so a date key matches whatever format the model wrote.
|
|
66
|
+
def scan(text)
|
|
67
|
+
text.to_s.scan(TOKEN).filter_map { |token| parse(token) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A textual month ("Mar. 14, 2019", "14 March 2019") is unambiguous, so Date.parse
|
|
71
|
+
# is safe here -- the day-first trap only applies to all-numeric forms.
|
|
72
|
+
def textual(text)
|
|
73
|
+
Date.parse(text)
|
|
74
|
+
rescue Date::Error
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def iso(text)
|
|
79
|
+
Date.iso8601(text)
|
|
80
|
+
rescue Date::Error
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def us_numeric(match)
|
|
85
|
+
month, day, year = match.captures
|
|
86
|
+
year = expand_year(year)
|
|
87
|
+
ymd(year, month, day)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A two-digit year in a medical record is this century unless that would be absurd;
|
|
91
|
+
# 70 is the conventional pivot and matches Ruby's own strptime behaviour.
|
|
92
|
+
def expand_year(year)
|
|
93
|
+
return year if year.length == 4
|
|
94
|
+
|
|
95
|
+
year.to_i < 70 ? "20#{year}" : "19#{year}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ymd(year, month, day)
|
|
99
|
+
Date.new(year.to_i, month.to_i, day.to_i)
|
|
100
|
+
rescue Date::Error
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# Base for every error the gem raises.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# A file is not the contract version this gem speaks, or is missing a required field.
|
|
9
|
+
class ContractError < Error; end
|
|
10
|
+
|
|
11
|
+
# Two runs were compared whose comparability keys do not match.
|
|
12
|
+
class ComparabilityError < Error; end
|
|
13
|
+
|
|
14
|
+
# An adapter was used without being required. The core loads on stdlib alone, so
|
|
15
|
+
# "default adapter" is a documentation claim, not an implicit require: naming the
|
|
16
|
+
# missing require is the whole point of this error.
|
|
17
|
+
class AdapterNotLoaded < Error; end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# One unit under test. `input` is the section 3.1 input object ("kind" is
|
|
6
|
+
# image/text/messages); `target` is the path to this example's criteria file,
|
|
7
|
+
# relative to the manifest.
|
|
8
|
+
Example = Data.define(:id, :input, :target, :metadata)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# The shape of a judge criterion: one boolean field plus a short reason.
|
|
6
|
+
#
|
|
7
|
+
# `packet` takes key_text and output and NOTHING else. That is the enforcement of
|
|
8
|
+
# "the judge receives the key text and the output, never the example input" -- there
|
|
9
|
+
# is no parameter through which the input could arrive, so no caller can leak it.
|
|
10
|
+
#
|
|
11
|
+
# The schema is a plain JSON Schema Hash, not an omniai type: the core loads on
|
|
12
|
+
# stdlib alone (invariant 3), and both adapters want raw JSON Schema anyway --
|
|
13
|
+
# ClaudeCLI passes it to --json-schema, and Adapters::OmniAI deserializes it.
|
|
14
|
+
module Judge
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def schema
|
|
18
|
+
{
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
passed: { type: "boolean", description: "true when the output is faithful to the key" },
|
|
22
|
+
reason: { type: "string", description: "one sentence, under 200 characters" },
|
|
23
|
+
},
|
|
24
|
+
required: %i[passed reason],
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def packet(key_text:, output:)
|
|
29
|
+
<<~PACKET
|
|
30
|
+
KEY (human-authored ground truth):
|
|
31
|
+
#{key_text}
|
|
32
|
+
|
|
33
|
+
OUTPUT (model under test):
|
|
34
|
+
#{output}
|
|
35
|
+
PACKET
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @raise [Error] when the verdict's score is not a boolean. A lax provider can
|
|
39
|
+
# return 0.8 or "true"; the raw ArgumentError from Result names the class but not
|
|
40
|
+
# the criterion, which is useless in a run over hundreds of them.
|
|
41
|
+
def result(verdict, criterion:)
|
|
42
|
+
Result.new(key: criterion.id, score: verdict["passed"], comment: verdict["reason"].to_s)
|
|
43
|
+
rescue ArgumentError => e
|
|
44
|
+
raise Error, "judge verdict for #{criterion.id}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module OmniAI
|
|
6
|
+
module Evals
|
|
7
|
+
# The run manifest written beside the results.
|
|
8
|
+
#
|
|
9
|
+
# The first five fields match the first consumer's existing run manifest, in the
|
|
10
|
+
# same shape, so its port is a rename rather than a rewrite. Invariant 2 applies:
|
|
11
|
+
# nothing here may carry input or output text -- argv is sanitized on the way in.
|
|
12
|
+
module Manifest
|
|
13
|
+
REDACTED = "<redacted>"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def build(tool:, repo_root:, run_id:, dataset:, criteria:, target:, model:, epochs:,
|
|
18
|
+
argv: ARGV, now: Time.now.utc, params: {}, seed: "none", reducer: "all_epochs",
|
|
19
|
+
gems: {}, judge: nil)
|
|
20
|
+
{
|
|
21
|
+
"contract" => CONTRACT_VERSION,
|
|
22
|
+
"run_id" => run_id,
|
|
23
|
+
"tool" => tool,
|
|
24
|
+
"argv_sanitized" => sanitize_argv(argv),
|
|
25
|
+
"toolkit_sha" => toolkit_sha(repo_root),
|
|
26
|
+
"ruby_version" => RUBY_VERSION,
|
|
27
|
+
"ts" => now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
28
|
+
"dataset" => dataset,
|
|
29
|
+
"criteria" => criteria,
|
|
30
|
+
"target" => target,
|
|
31
|
+
"model" => model,
|
|
32
|
+
"params" => params,
|
|
33
|
+
"seed" => seed,
|
|
34
|
+
"epochs" => epochs,
|
|
35
|
+
"reducer" => reducer,
|
|
36
|
+
"gems" => gems,
|
|
37
|
+
"judge" => judge,
|
|
38
|
+
"comparability_key" => Compare.key(dataset_sha: dataset.fetch("sha256"),
|
|
39
|
+
criteria_sha: criteria.fetch("sha256"),
|
|
40
|
+
prompt_version: target.fetch("prompt_version")),
|
|
41
|
+
}.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Redacts the value following --claimant (both "--claimant X" and "--claimant=X"
|
|
45
|
+
# forms) so the manifest never records a claimant name; all other args pass through.
|
|
46
|
+
def sanitize_argv(argv)
|
|
47
|
+
redact_next = false
|
|
48
|
+
argv.map do |arg|
|
|
49
|
+
if redact_next
|
|
50
|
+
redact_next = false
|
|
51
|
+
REDACTED
|
|
52
|
+
elsif arg == "--claimant"
|
|
53
|
+
redact_next = true
|
|
54
|
+
arg
|
|
55
|
+
elsif arg.start_with?("--claimant=")
|
|
56
|
+
"--claimant=#{REDACTED}"
|
|
57
|
+
else
|
|
58
|
+
arg
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The toolkit's short git SHA for provenance; "unknown" when repo_root isn't a repo
|
|
64
|
+
# or git is unavailable (never fails the run over provenance).
|
|
65
|
+
def toolkit_sha(repo_root)
|
|
66
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", repo_root, "rev-parse", "--short", "HEAD")
|
|
67
|
+
status.success? ? stdout.strip : "unknown"
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
"unknown"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# Text tidying shared by the assertions. Kept in one place so "does the output
|
|
6
|
+
# contain this" means the same thing in every assertion.
|
|
7
|
+
module Normalize
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Output may be a String or the Hash a structured target returned.
|
|
11
|
+
def text(output) = output.is_a?(String) ? output : JSON.generate(output)
|
|
12
|
+
|
|
13
|
+
# Collapse whitespace runs; keep the case.
|
|
14
|
+
def squish(value) = value.to_s.gsub(/\s+/, " ").strip
|
|
15
|
+
|
|
16
|
+
# Collapse whitespace runs AND case -- the comparison form for contains-checks.
|
|
17
|
+
def phrase(value) = squish(value).downcase
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# What a run WOULD do: examples x configs x epochs, estimated calls and cost, and
|
|
6
|
+
# zero model calls.
|
|
7
|
+
#
|
|
8
|
+
# `adapter:` is accepted and deliberately never invoked -- the spec passes one that
|
|
9
|
+
# raises, so invariant 5 is enforced by the signature rather than by convention.
|
|
10
|
+
# Prices come from the consumer: the gem knows nothing about any provider's rates.
|
|
11
|
+
#
|
|
12
|
+
# `configs:` is a PLANNING dimension only. A run is one target version, so each
|
|
13
|
+
# config is its own run with its own manifest and its own results file -- this
|
|
14
|
+
# exists so you can price "default vs effort:low" in one go BEFORE committing the
|
|
15
|
+
# spend, not so two configs share a results file. Results.write refuses to mix
|
|
16
|
+
# run_ids in one file, which is what actually prevents the merge.
|
|
17
|
+
module Plan
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def build(dataset:, criteria:, configs:, epochs:, prices:, adapter: nil)
|
|
21
|
+
examples = dataset.examples.size
|
|
22
|
+
judge_criteria = criteria.all.count { |criterion| criterion.scorer == "judge" }
|
|
23
|
+
target_calls = examples * configs.size * epochs
|
|
24
|
+
judge_calls = judge_criteria * configs.size * epochs
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"examples" => examples,
|
|
28
|
+
"configs" => configs.size,
|
|
29
|
+
"epochs" => epochs,
|
|
30
|
+
"judge_criteria" => judge_criteria,
|
|
31
|
+
"calls" => target_calls + judge_calls,
|
|
32
|
+
"target_calls" => target_calls,
|
|
33
|
+
"judge_calls" => judge_calls,
|
|
34
|
+
"target_cost_usd" => cost(target_calls, prices["target"]),
|
|
35
|
+
"judge_cost_usd" => cost(judge_calls, prices["judge"]),
|
|
36
|
+
"cost_usd" => cost(target_calls, prices["target"]) + cost(judge_calls, prices["judge"]),
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The consumer supplies the price table; an absent price is 0.0 rather than an
|
|
41
|
+
# error, because a bench of pure assertions has no metered call to price.
|
|
42
|
+
def cost(calls, price) = (calls * (price || 0.0)).round(4)
|
|
43
|
+
|
|
44
|
+
def render(plan)
|
|
45
|
+
format("%<examples>d examples x %<configs>d configs x %<epochs>d epochs\n" \
|
|
46
|
+
"%<target_calls>d target calls, estimated $%<target_cost>.2f\n" \
|
|
47
|
+
"%<judge_calls>d judge calls, estimated $%<judge_cost>.2f\n" \
|
|
48
|
+
"%<calls>d calls total, estimated $%<cost>.2f\n",
|
|
49
|
+
examples: plan.fetch("examples"), configs: plan.fetch("configs"),
|
|
50
|
+
epochs: plan.fetch("epochs"), calls: plan.fetch("calls"),
|
|
51
|
+
target_calls: plan.fetch("target_calls"), target_cost: plan.fetch("target_cost_usd"),
|
|
52
|
+
judge_calls: plan.fetch("judge_calls"), judge_cost: plan.fetch("judge_cost_usd"),
|
|
53
|
+
cost: plan.fetch("cost_usd"))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# One line of the results file: (example, criterion, epoch).
|
|
6
|
+
#
|
|
7
|
+
# `key`/`score`/`comment` are the LangSmith / Braintrust / openevals record and the
|
|
8
|
+
# only required fields; the rest are ours. THIS FILE IS PHI-BEARING -- a judge comment
|
|
9
|
+
# may quote output -- so the consumer stores it under its own protected path. The
|
|
10
|
+
# scorecard never carries comments.
|
|
11
|
+
module Record
|
|
12
|
+
# docs/contract.md section 2: a scorer is an assertion, a judge, or a label. A typo
|
|
13
|
+
# here would otherwise reach the scorecard as its own silent family.
|
|
14
|
+
SCORERS = %w[assertion judge label].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def build(result:, run_id:, example_id:, family:, scorer:, epoch:, cost_usd: 0.0, latency_ms: 0)
|
|
19
|
+
raise ArgumentError, "unknown scorer #{scorer.inspect}" unless SCORERS.include?(scorer.to_s)
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
"contract" => CONTRACT_VERSION,
|
|
23
|
+
"run_id" => run_id,
|
|
24
|
+
"example_id" => example_id,
|
|
25
|
+
"key" => result.key,
|
|
26
|
+
"score" => result.score,
|
|
27
|
+
"comment" => result.comment,
|
|
28
|
+
"family" => family,
|
|
29
|
+
"scorer" => scorer,
|
|
30
|
+
"epoch" => epoch,
|
|
31
|
+
"cost_usd" => cost_usd,
|
|
32
|
+
"latency_ms" => latency_ms,
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# How the epochs of one criterion combine into one verdict.
|
|
6
|
+
#
|
|
7
|
+
# `all_epochs` is the default and the only reducer in v1. FLAKY is a first-class
|
|
8
|
+
# verdict rather than a rounding decision: a criterion that passes 3 of 5 times is
|
|
9
|
+
# not "60% correct", it is unreliable, and the reader needs to see that.
|
|
10
|
+
module Reducer
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param results [Array<Result>] one per epoch, for a single criterion
|
|
14
|
+
# @return [Symbol] :pass, :fail, or :flaky
|
|
15
|
+
def all_epochs(results)
|
|
16
|
+
raise ArgumentError, "no epochs to reduce" if results.empty?
|
|
17
|
+
|
|
18
|
+
scores = results.map(&:score)
|
|
19
|
+
return :pass if scores.all?
|
|
20
|
+
return :fail if scores.none?
|
|
21
|
+
|
|
22
|
+
:flaky
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# One graded criterion. `key`/`score`/`comment` are the LangSmith / Braintrust /
|
|
6
|
+
# openevals record and the only required fields in a result line.
|
|
7
|
+
#
|
|
8
|
+
# The boolean check is here, at the single point every scorer funnels through, so
|
|
9
|
+
# invariant 1 ("no float ever reaches a result record") cannot be violated by a
|
|
10
|
+
# future scorer that returns a confidence. Data is frozen and setter-free, so a
|
|
11
|
+
# validated score is the score that gets written.
|
|
12
|
+
Result = Data.define(:key, :score, :comment) do
|
|
13
|
+
def initialize(key:, score:, comment:)
|
|
14
|
+
unless score.is_a?(TrueClass) || score.is_a?(FalseClass)
|
|
15
|
+
raise ArgumentError, "score must be true or false, got #{score.class}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
super(key: key.to_s, score:, comment: comment.to_s)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# The JSONL results file.
|
|
6
|
+
module Results
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Appends rather than truncates: a run writes epoch by epoch, and losing the
|
|
10
|
+
# earlier epochs to a reopen would silently change every reduced verdict.
|
|
11
|
+
#
|
|
12
|
+
# Appending is therefore also the hazard. Re-running a runner into a path that
|
|
13
|
+
# already holds another run doubles every epoch, and the reducer sees 2n epochs
|
|
14
|
+
# without anything raising -- a PASS built from two runs it was never asked to
|
|
15
|
+
# combine. So a file holds exactly one run_id, and a second one is refused.
|
|
16
|
+
#
|
|
17
|
+
# @param records [Array<Hash>]
|
|
18
|
+
# @raise [Error] when the records do not belong to the file's existing run
|
|
19
|
+
def write(path, records)
|
|
20
|
+
return if records.empty?
|
|
21
|
+
|
|
22
|
+
guard_run_id!(path, records)
|
|
23
|
+
File.open(path, "a") do |file|
|
|
24
|
+
records.each { |record| file.puts(JSON.generate(record)) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def guard_run_id!(path, records)
|
|
29
|
+
incoming = records.map { |record| record.fetch("run_id") }.uniq
|
|
30
|
+
raise Error, "records span #{incoming.size} run_ids: #{incoming.join(', ')}" unless incoming.size == 1
|
|
31
|
+
return unless File.exist?(path) && !File.empty?(path)
|
|
32
|
+
|
|
33
|
+
existing = JSON.parse(File.foreach(path).first).fetch("run_id")
|
|
34
|
+
return if existing == incoming.first
|
|
35
|
+
|
|
36
|
+
raise Error, "#{path} holds run #{existing}; refusing to append run #{incoming.first}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @raise [ContractError]
|
|
40
|
+
# @return [Array<Hash>]
|
|
41
|
+
def read(path)
|
|
42
|
+
File.readlines(path).reject { |line| line.strip.empty? }.map.with_index(1) do |line, number|
|
|
43
|
+
Contract.check!(JSON.parse(line), path: "#{path}:#{number}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# The PHI-free aggregate: counts, all-pass on top, family rates beneath.
|
|
6
|
+
#
|
|
7
|
+
# Counts, not percentages -- small datasets make rates misleading and the reader can
|
|
8
|
+
# divide. Invariant 2 is enforced by construction: this builds a fresh Hash of
|
|
9
|
+
# integers from the records rather than filtering fields out of them, so there is no
|
|
10
|
+
# path by which a comment reaches the scorecard.
|
|
11
|
+
module Scorecard
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(records:, run_id:, comparability_key:, cost_usd: nil, calls: nil)
|
|
15
|
+
by_key = records.group_by { |record| record.fetch("key") }
|
|
16
|
+
verdicts = by_key.transform_values { |rows| reduce(rows) }
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
"contract" => CONTRACT_VERSION,
|
|
20
|
+
"run_id" => run_id,
|
|
21
|
+
"comparability_key" => comparability_key,
|
|
22
|
+
"examples" => examples_for(by_key, verdicts),
|
|
23
|
+
"families" => families_for(by_key, verdicts),
|
|
24
|
+
"cost_usd" => cost_usd,
|
|
25
|
+
"calls" => calls,
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Comments are dropped here, not carried and filtered later: the Result rebuilt for
|
|
30
|
+
# the reducer takes an empty comment, so PHI has no route into this method's output.
|
|
31
|
+
def reduce(rows)
|
|
32
|
+
Reducer.all_epochs(rows.map { |row| Result.new(key: row.fetch("key"), score: row.fetch("score"), comment: "") })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# An example is only all_pass when every one of its criteria passed; a single FAIL
|
|
36
|
+
# makes it failed, otherwise a FLAKY makes it flaky.
|
|
37
|
+
def examples_for(by_key, verdicts)
|
|
38
|
+
by_example = Hash.new { |hash, key| hash[key] = [] }
|
|
39
|
+
by_key.each { |key, rows| by_example[rows.first.fetch("example_id")] << verdicts.fetch(key) }
|
|
40
|
+
|
|
41
|
+
counts = { "total" => by_example.size, "all_pass" => 0, "flaky" => 0, "failed" => 0 }
|
|
42
|
+
by_example.each_value { |list| counts[bucket(list)] += 1 }
|
|
43
|
+
counts
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def bucket(verdicts)
|
|
47
|
+
return "failed" if verdicts.include?(:fail)
|
|
48
|
+
return "flaky" if verdicts.include?(:flaky)
|
|
49
|
+
|
|
50
|
+
"all_pass"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def families_for(by_key, verdicts)
|
|
54
|
+
by_key.each_with_object({}) do |(key, rows), acc|
|
|
55
|
+
family = rows.first.fetch("family")
|
|
56
|
+
acc[family] ||= { "criteria" => 0, "pass" => 0, "flaky" => 0, "fail" => 0 }
|
|
57
|
+
acc[family]["criteria"] += 1
|
|
58
|
+
acc[family][verdicts.fetch(key).to_s] += 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/omniai/evals.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "evals/version"
|
|
6
|
+
require_relative "evals/errors"
|
|
7
|
+
require_relative "evals/contract"
|
|
8
|
+
require_relative "evals/result"
|
|
9
|
+
require_relative "evals/example"
|
|
10
|
+
require_relative "evals/dataset"
|
|
11
|
+
require_relative "evals/criterion"
|
|
12
|
+
require_relative "evals/criteria"
|
|
13
|
+
require_relative "evals/normalize"
|
|
14
|
+
require_relative "evals/dates"
|
|
15
|
+
require_relative "evals/assertions"
|
|
16
|
+
require_relative "evals/reducer"
|
|
17
|
+
require_relative "evals/record"
|
|
18
|
+
require_relative "evals/results"
|
|
19
|
+
require_relative "evals/compare"
|
|
20
|
+
require_relative "evals/manifest"
|
|
21
|
+
require_relative "evals/scorecard"
|
|
22
|
+
require_relative "evals/plan"
|
|
23
|
+
require_relative "evals/judge"
|
|
24
|
+
|
|
25
|
+
# Structure and grading for LLM evals.
|
|
26
|
+
#
|
|
27
|
+
# The core loads with stdlib and `json` only. Judge adapters reach a model and are
|
|
28
|
+
# therefore NOT required here -- a consumer requires the one it wants:
|
|
29
|
+
#
|
|
30
|
+
# require "omniai/evals"
|
|
31
|
+
# require "omniai/evals/adapters/omniai"
|
|
32
|
+
#
|
|
33
|
+
module OmniAI
|
|
34
|
+
# Structure and grading for LLM evals: datasets, boolean criteria, assertions,
|
|
35
|
+
# judges, reducers, and PHI-free scorecards.
|
|
36
|
+
module Evals
|
|
37
|
+
end
|
|
38
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omniai-evals
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dean Singh
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: omniai
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.8'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.8'
|
|
26
|
+
description: Datasets, boolean criteria, assertions, judges, reducers, and PHI-free
|
|
27
|
+
scorecards. A companion to the omniai gem family.
|
|
28
|
+
email:
|
|
29
|
+
- dean.singh@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- Gemfile
|
|
36
|
+
- LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/omniai/evals.rb
|
|
39
|
+
- lib/omniai/evals/adapters/call_record.rb
|
|
40
|
+
- lib/omniai/evals/adapters/claude_cli.rb
|
|
41
|
+
- lib/omniai/evals/adapters/omniai.rb
|
|
42
|
+
- lib/omniai/evals/assertions.rb
|
|
43
|
+
- lib/omniai/evals/assertions/absence.rb
|
|
44
|
+
- lib/omniai/evals/assertions/abstention.rb
|
|
45
|
+
- lib/omniai/evals/assertions/circled.rb
|
|
46
|
+
- lib/omniai/evals/assertions/identity.rb
|
|
47
|
+
- lib/omniai/evals/assertions/presence.rb
|
|
48
|
+
- lib/omniai/evals/assertions/structure.rb
|
|
49
|
+
- lib/omniai/evals/compare.rb
|
|
50
|
+
- lib/omniai/evals/contract.rb
|
|
51
|
+
- lib/omniai/evals/criteria.rb
|
|
52
|
+
- lib/omniai/evals/criterion.rb
|
|
53
|
+
- lib/omniai/evals/dataset.rb
|
|
54
|
+
- lib/omniai/evals/dates.rb
|
|
55
|
+
- lib/omniai/evals/errors.rb
|
|
56
|
+
- lib/omniai/evals/example.rb
|
|
57
|
+
- lib/omniai/evals/judge.rb
|
|
58
|
+
- lib/omniai/evals/manifest.rb
|
|
59
|
+
- lib/omniai/evals/normalize.rb
|
|
60
|
+
- lib/omniai/evals/plan.rb
|
|
61
|
+
- lib/omniai/evals/record.rb
|
|
62
|
+
- lib/omniai/evals/reducer.rb
|
|
63
|
+
- lib/omniai/evals/result.rb
|
|
64
|
+
- lib/omniai/evals/results.rb
|
|
65
|
+
- lib/omniai/evals/scorecard.rb
|
|
66
|
+
- lib/omniai/evals/version.rb
|
|
67
|
+
homepage: https://github.com/DeanSingh/omniai-evals
|
|
68
|
+
licenses:
|
|
69
|
+
- MIT
|
|
70
|
+
metadata:
|
|
71
|
+
homepage_uri: https://github.com/DeanSingh/omniai-evals
|
|
72
|
+
changelog_uri: https://github.com/DeanSingh/omniai-evals/blob/main/CHANGELOG.md
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 3.2.0
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 3.6.9
|
|
89
|
+
specification_version: 4
|
|
90
|
+
summary: Structure and grading for LLM evals
|
|
91
|
+
test_files: []
|