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,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# Passes when the output declines to assert content for a region it cannot read.
|
|
7
|
+
# Two ways to pass: it carries an abstention marker for the region, or it names
|
|
8
|
+
# none of the forbidden terms. Hallucinated confidence is the failure this catches.
|
|
9
|
+
module Abstention
|
|
10
|
+
# "~~~~" is what the production model actually emits for an illegible region --
|
|
11
|
+
# a non-lexical marker, measured on a production baseline page. A run of 3 or
|
|
12
|
+
# more tildes counts, since the model is not consistent about the length.
|
|
13
|
+
#
|
|
14
|
+
# The lexical markers are the fallbacks. A criterion can replace this whole list
|
|
15
|
+
# via target["markers"] when a different target has its own convention.
|
|
16
|
+
MARKERS = ["[illegible]", "[unreadable]", "[not legible]", "cannot read", "unable to read"].freeze
|
|
17
|
+
NON_LEXICAL = /~{3,}/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def call(example:, output:, criterion:)
|
|
22
|
+
raw = Normalize.text(output)
|
|
23
|
+
text = Normalize.phrase(raw)
|
|
24
|
+
markers = Array(criterion.target["markers"]).then { |list| list.empty? ? MARKERS : list }
|
|
25
|
+
forbidden = Array(criterion.target["forbidden_terms"]).map { |term| Normalize.phrase(term) }
|
|
26
|
+
|
|
27
|
+
abstained = NON_LEXICAL.match?(raw) || markers.any? { |m| text.include?(Normalize.phrase(m)) }
|
|
28
|
+
asserted = forbidden.select { |term| text.include?(term) }
|
|
29
|
+
score = abstained || asserted.empty?
|
|
30
|
+
|
|
31
|
+
Result.new(key: criterion.id, score:, comment: comment(score, abstained, asserted, criterion))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def comment(score, abstained, asserted, criterion)
|
|
35
|
+
region = criterion.target["region"]
|
|
36
|
+
return "asserted #{asserted.join(', ')} for #{region}" unless score
|
|
37
|
+
|
|
38
|
+
abstained ? "abstained on #{region}" : "asserted no forbidden term for #{region}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# Which option on a form was circled -- the `structure` type's `{"circled": ...}` target.
|
|
7
|
+
#
|
|
8
|
+
# It lives apart from the rest of Structure because it is the only assertion whose job is
|
|
9
|
+
# to decide who a MARK belongs to, rather than to compare a value. Every rule here exists
|
|
10
|
+
# to stop a mark being credited to an option that did not carry it, in either direction:
|
|
11
|
+
# a correct answer scoring false, or an unmarked page scoring true.
|
|
12
|
+
module Circled
|
|
13
|
+
# A transcription marks a circled option one of two ways, and both count.
|
|
14
|
+
#
|
|
15
|
+
# LEXICAL: it says so -- "(circled)", "[x]", "circled", "selected".
|
|
16
|
+
MARKED = /\(circled\)|\[x\]|\bcircled\b|\bselected\b/i
|
|
17
|
+
# EMPHATIC: it renders the pen circle as emphasis, because production prompts tell it
|
|
18
|
+
# to "match any formatting as closely as possible" and a circle IS emphasis. Measured
|
|
19
|
+
# on a physician-completed exam form: four separate configs all transcribed the
|
|
20
|
+
# circled option in bold and not one of them used the word "circled".
|
|
21
|
+
#
|
|
22
|
+
# The single-delimiter forms require non-word boundaries. Without that, `_(.+?)_`
|
|
23
|
+
# matches the middle of a snake_case token and invents an emphasised span that was
|
|
24
|
+
# never emphasis -- "field_Normal_name" would mark "Normal" as circled.
|
|
25
|
+
EMPHASIS = /
|
|
26
|
+
\*\*(.+?)\*\*
|
|
27
|
+
| __(.+?)__
|
|
28
|
+
| (?<![\w*])\*(?!\*)([^*]+?)\*(?![\w*])
|
|
29
|
+
| (?<![\w_])_(?!_)([^_]+?)_(?![\w_])
|
|
30
|
+
/x
|
|
31
|
+
# A model transcribing a pen circle often leaves its ink as stray punctuation --
|
|
32
|
+
# "(2) Normal)" with an unbalanced paren appeared in three of those four configs --
|
|
33
|
+
# so text is compared with its punctuation edges trimmed.
|
|
34
|
+
EDGE_NOISE = /\A[^[:alnum:]]+|[^[:alnum:]]+\z/
|
|
35
|
+
# A leading option enumerator. Models bold the option text without it ("**Normal**"
|
|
36
|
+
# for "2) Normal"), so the enumerator is stripped from both sides before comparing.
|
|
37
|
+
ENUMERATOR = /\A(?:\(\d+\)|\d+[).]|[a-z][).])\s*/
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def call(output:, criterion:)
|
|
42
|
+
target = criterion.target
|
|
43
|
+
option = target.fetch("circled")
|
|
44
|
+
siblings = Array(target["among"]).reject { |s| same?(s, option) }
|
|
45
|
+
field = field_text(Normalize.text(output), option, target)
|
|
46
|
+
return failure(criterion, "option not found in output") if field.nil?
|
|
47
|
+
|
|
48
|
+
rivals = siblings.select { |sibling| marked?(field, sibling, target) }
|
|
49
|
+
unless rivals.empty?
|
|
50
|
+
return failure(criterion,
|
|
51
|
+
"#{rivals.size + 1} of the options are marked; the mark is not discriminating")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return pass(criterion, "marked circled") if lexical?(field, option, target)
|
|
55
|
+
return pass(criterion, "marked by emphasis") if emphasised?(field, option)
|
|
56
|
+
|
|
57
|
+
failure(criterion, "option present but not marked")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The lines that carry this field's options -- every line mentioning the target or any
|
|
61
|
+
# sibling -- rather than the whole page.
|
|
62
|
+
#
|
|
63
|
+
# Page scope is wrong in BOTH directions and a form makes both happen. Option strings
|
|
64
|
+
# repeat: Yes/No, Normal, None, Left/Right. A bolded "No" in an unrelated field three
|
|
65
|
+
# sections down refuses a correct mark (false negative), and a bolded target in some
|
|
66
|
+
# other field would accept an unmarked one (false positive). The enumeration is one
|
|
67
|
+
# line or a short run of adjacent ones, so that is the unit.
|
|
68
|
+
#
|
|
69
|
+
# @return [String, nil] nil when the option is not mentioned at all
|
|
70
|
+
def field_text(text, option, target)
|
|
71
|
+
lines = text.lines
|
|
72
|
+
index = lines.index { |line| mentions?(line, option) }
|
|
73
|
+
return nil if index.nil?
|
|
74
|
+
|
|
75
|
+
siblings = Array(target["among"]).reject { |sibling| same?(sibling, option) }
|
|
76
|
+
first = index
|
|
77
|
+
first -= 1 while first.positive? && mentions_any?(lines[first - 1], siblings)
|
|
78
|
+
last = index
|
|
79
|
+
last += 1 while last < lines.size - 1 && mentions_any?(lines[last + 1], siblings)
|
|
80
|
+
lines[first..last].join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The run must be CONTIGUOUS, not every line mentioning a sibling. Option words repeat
|
|
84
|
+
# across a form -- a Yes/No field elsewhere on the page also mentions "No" -- so
|
|
85
|
+
# collecting every such line drags an unrelated field back in and its emphasis becomes
|
|
86
|
+
# a phantom rival.
|
|
87
|
+
def mentions_any?(line, options)
|
|
88
|
+
options.any? { |option| mentions?(line, option) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Emphasis is checked across the field; a lexical marker must be ADJACENT to the
|
|
92
|
+
# option, so it is checked in that option's own window.
|
|
93
|
+
def marked?(field, option, target)
|
|
94
|
+
lexical?(field, option, target) || emphasised?(field, option)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A lexical marker counts only for the option it belongs to.
|
|
98
|
+
#
|
|
99
|
+
# Anywhere-on-the-line is not discriminating: "1) Slim (circled) 2) Normal 3) Muscular"
|
|
100
|
+
# would score "2) Normal" as circled, the same hole the emphasis branch already closed.
|
|
101
|
+
# Nor is a window bounded by the neighbours enough -- a marker sitting BETWEEN two
|
|
102
|
+
# options falls inside both windows, marks both, and refuses a correct page as
|
|
103
|
+
# "not discriminating".
|
|
104
|
+
#
|
|
105
|
+
# So each marker gets exactly one owner: the option it FOLLOWS, which is how these
|
|
106
|
+
# transcriptions read ("2) Normal (circled)"). A marker with nothing before it -- a
|
|
107
|
+
# checkbox opening the line -- belongs to the option it precedes instead.
|
|
108
|
+
def lexical?(field, option, target)
|
|
109
|
+
owners(field, target).any? { |owner| same?(owner, option) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Array<String>] the options that own a lexical marker
|
|
113
|
+
def owners(field, target)
|
|
114
|
+
text = Normalize.phrase(field)
|
|
115
|
+
options = [target.fetch("circled"), *Array(target["among"])].uniq
|
|
116
|
+
placed = options.filter_map { |option| locate(text, option)&.then { |at| [option, at] } }
|
|
117
|
+
|
|
118
|
+
marks(text, target).filter_map { |mark| owner_of(mark, placed) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Array<Integer>] where each lexical marker starts
|
|
122
|
+
def marks(text, target)
|
|
123
|
+
text.enum_for(:scan, markers(target)).map { Regexp.last_match.begin(0) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def owner_of(mark, placed)
|
|
127
|
+
before = placed.select { |(_, at)| at.last <= mark }.max_by { |(_, at)| at.last }
|
|
128
|
+
after = placed.select { |(_, at)| at.first >= mark }.min_by { |(_, at)| at.first }
|
|
129
|
+
(before || after)&.first
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Range, nil] where the option sits in the normalized text
|
|
133
|
+
def locate(text, option)
|
|
134
|
+
forms(option).filter_map do |form|
|
|
135
|
+
index = text.index(form)
|
|
136
|
+
index && (index..(index + form.length))
|
|
137
|
+
end.min_by(&:first)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def mentions?(line, option)
|
|
141
|
+
text = Normalize.phrase(line)
|
|
142
|
+
forms(option).any? { |form| text.include?(form) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# True when the option IS the emphasised span -- not merely when its field contains
|
|
146
|
+
# emphasis somewhere. That distinction is the whole assertion: on a form where every
|
|
147
|
+
# line carries a bold label, "this contains bold" passes vacuously for every option,
|
|
148
|
+
# including the ones that were not circled.
|
|
149
|
+
#
|
|
150
|
+
# Equality after trimming, deliberately, rather than containment: a span wrapping
|
|
151
|
+
# several options ("**1) Slim 2) Normal 3) Muscular**") contains the option but marks
|
|
152
|
+
# none of them, and scoring that as circled is the same vacuous pass one level down.
|
|
153
|
+
def emphasised?(field, option)
|
|
154
|
+
wanted = forms(option)
|
|
155
|
+
field.scan(EMPHASIS).flatten.compact.any? { |span| forms(span).intersect?(wanted) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The comparable spellings of one option: punctuation edges trimmed, and the same
|
|
159
|
+
# again without a leading enumerator, because a model may bold "Normal" for "2) Normal".
|
|
160
|
+
def forms(option)
|
|
161
|
+
canonical = Normalize.phrase(option).gsub(EDGE_NOISE, "")
|
|
162
|
+
[canonical, canonical.sub(ENUMERATOR, "").gsub(EDGE_NOISE, "")].uniq.reject(&:empty?)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def same?(left, right) = forms(left).intersect?(forms(right))
|
|
166
|
+
|
|
167
|
+
# A criterion may supply its own lexical markers for a target with its own convention.
|
|
168
|
+
def markers(target)
|
|
169
|
+
custom = Array(target["markers"])
|
|
170
|
+
return MARKED if custom.empty?
|
|
171
|
+
|
|
172
|
+
Regexp.union(custom.map { |marker| Regexp.new(Regexp.escape(marker), Regexp::IGNORECASE) })
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def pass(criterion, comment) = Result.new(key: criterion.id, score: true, comment:)
|
|
176
|
+
def failure(criterion, comment) = Result.new(key: criterion.id, score: false, comment:)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# "A header name is transcribed AS-IS" (docs/contract.md).
|
|
7
|
+
#
|
|
8
|
+
# Two target shapes, selected by which key is present:
|
|
9
|
+
#
|
|
10
|
+
# {"text": "Patient: JANE Q."} text mode -- the output must contain this
|
|
11
|
+
# string exactly, after whitespace collapse
|
|
12
|
+
# {"field": "claim_number", structured mode -- the output is a Hash and
|
|
13
|
+
# "value": "ABC-123"} that field equals this value
|
|
14
|
+
#
|
|
15
|
+
# Text mode is CASE-SENSITIVE and normalizes nothing but whitespace runs, unlike
|
|
16
|
+
# every other assertion here. That is the point of the type: a banded EHR table
|
|
17
|
+
# whose header names a DIFFERENT patient must be transcribed as-is, and an OCR pass
|
|
18
|
+
# that silently "fixes" the case or the spelling has produced the wrong-patient
|
|
19
|
+
# failure this criterion exists to detect. Case-folding here would hide it.
|
|
20
|
+
#
|
|
21
|
+
# Neither mode is fuzzy. A claim number that is nearly right is wrong.
|
|
22
|
+
module Identity
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def call(example:, output:, criterion:)
|
|
26
|
+
criterion.target.key?("text") ? text(output, criterion) : field(output, criterion)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def text(output, criterion)
|
|
30
|
+
expected = Normalize.squish(criterion.target.fetch("text"))
|
|
31
|
+
actual = Normalize.squish(Normalize.text(output))
|
|
32
|
+
ok = actual.include?(expected)
|
|
33
|
+
Result.new(key: criterion.id, score: ok,
|
|
34
|
+
comment: ok ? "transcribed as-is" : "not transcribed as-is (exact, case-sensitive)")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def field(output, criterion)
|
|
38
|
+
name = criterion.target.fetch("field")
|
|
39
|
+
expected = Normalize.squish(criterion.target.fetch("value").to_s)
|
|
40
|
+
actual = output.is_a?(Hash) ? Normalize.squish(output[name].to_s) : nil
|
|
41
|
+
|
|
42
|
+
ok = !actual.nil? && actual == expected
|
|
43
|
+
Result.new(key: criterion.id, score: ok, comment: ok ? "#{name} matches" : "#{name} mismatch")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# Passes when the key's date (compared parsed) or text (compared with case and
|
|
7
|
+
# whitespace runs normalized) appears in the output.
|
|
8
|
+
module Presence
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(example:, output:, criterion:)
|
|
12
|
+
text = Normalize.text(output)
|
|
13
|
+
key = criterion.target
|
|
14
|
+
|
|
15
|
+
score, comment = key.key?("date") ? date(text, key) : text_match(text, key)
|
|
16
|
+
Result.new(key: criterion.id, score:, comment:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def date(text, key)
|
|
20
|
+
wanted = Dates.parse(key.fetch("date"))
|
|
21
|
+
found = Dates.scan(text)
|
|
22
|
+
hit = found.include?(wanted)
|
|
23
|
+
[hit, hit ? "date #{wanted} found (parsed)" : "date #{wanted} not among #{found.size} parsed dates"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def text_match(text, key)
|
|
27
|
+
hit = Normalize.phrase(text).include?(Normalize.phrase(key.fetch("text")))
|
|
28
|
+
[hit, hit ? "text found" : "text not found"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
module Assertions
|
|
6
|
+
# "Row count of a banded table; which option is circled" (docs/contract.md) -- a
|
|
7
|
+
# different skill from transcription, so it gets its own type.
|
|
8
|
+
#
|
|
9
|
+
# Three target shapes, selected by which key is present:
|
|
10
|
+
#
|
|
11
|
+
# {"table_rows": 12} text mode -- the output's markdown table has
|
|
12
|
+
# this many BODY rows (header and the ---
|
|
13
|
+
# separator excluded)
|
|
14
|
+
# {"circled": "Option B"} text mode -- the output marks this option as
|
|
15
|
+
# circled/selected
|
|
16
|
+
# {"path": ["exam", "date"], structured mode -- the output is a Hash, the
|
|
17
|
+
# "type": "string"} path resolves, the value has that JSON type
|
|
18
|
+
#
|
|
19
|
+
# Text mode exists because the first consumer's target emits markdown, not JSON.
|
|
20
|
+
# A Hash-only assertion silently fails every example of a text bench, which reads
|
|
21
|
+
# identically to a model that got everything wrong.
|
|
22
|
+
module Structure
|
|
23
|
+
TYPES = {
|
|
24
|
+
"string" => [String],
|
|
25
|
+
"number" => [Numeric],
|
|
26
|
+
"boolean" => [TrueClass, FalseClass],
|
|
27
|
+
"array" => [Array],
|
|
28
|
+
"object" => [Hash],
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# A markdown separator row: | --- | :--: | and friends. Never a body row.
|
|
32
|
+
SEPARATOR = /\A\s*\|[\s:|-]+\|\s*\z/
|
|
33
|
+
ROW = /\A\s*\|/
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def call(example:, output:, criterion:)
|
|
38
|
+
target = criterion.target
|
|
39
|
+
return table_rows(output, criterion) if target.key?("table_rows")
|
|
40
|
+
return Circled.call(output:, criterion:) if target.key?("circled")
|
|
41
|
+
|
|
42
|
+
path(output, criterion)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Body rows only: the header row and the --- separator are table syntax, not data,
|
|
46
|
+
# and counting them would make every key off by two.
|
|
47
|
+
def table_rows(output, criterion)
|
|
48
|
+
rows = Normalize.text(output).lines.grep(ROW).grep_v(SEPARATOR)
|
|
49
|
+
body = [rows.size - 1, 0].max # the first remaining row is the header
|
|
50
|
+
expected = criterion.target.fetch("table_rows")
|
|
51
|
+
ok = body == expected
|
|
52
|
+
Result.new(key: criterion.id, score: ok, comment: "#{body} body rows, expected #{expected}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def path(output, criterion)
|
|
56
|
+
return failure(criterion, "output is not a structure (#{output.class})") unless output.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
steps = criterion.target.fetch("path")
|
|
59
|
+
expected = criterion.target.fetch("type")
|
|
60
|
+
value = dig(output, steps)
|
|
61
|
+
return failure(criterion, "path #{steps.join('.')} missing") if value.nil?
|
|
62
|
+
|
|
63
|
+
if matches?(value, expected)
|
|
64
|
+
pass(criterion, "#{steps.join('.')} is #{expected}")
|
|
65
|
+
else
|
|
66
|
+
failure(criterion, "#{steps.join('.')} is #{value.class}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Walks the path, giving up (nil) the moment a step lands on a non-Hash.
|
|
71
|
+
def dig(output, steps)
|
|
72
|
+
steps.reduce(output) { |node, step| node.is_a?(Hash) ? node[step] : nil }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def matches?(value, expected)
|
|
76
|
+
klasses = TYPES.fetch(expected) { raise ArgumentError, %(unknown structure type "#{expected}") }
|
|
77
|
+
klasses.any? { |klass| value.is_a?(klass) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pass(criterion, comment) = Result.new(key: criterion.id, score: true, comment:)
|
|
81
|
+
def failure(criterion, comment) = Result.new(key: criterion.id, score: false, comment:)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "assertions/presence"
|
|
4
|
+
require_relative "assertions/absence"
|
|
5
|
+
require_relative "assertions/circled"
|
|
6
|
+
require_relative "assertions/structure"
|
|
7
|
+
require_relative "assertions/abstention"
|
|
8
|
+
require_relative "assertions/identity"
|
|
9
|
+
|
|
10
|
+
module OmniAI
|
|
11
|
+
module Evals
|
|
12
|
+
# The deterministic, free scorers -- one per criterion type in docs/contract.md 3.2.
|
|
13
|
+
# "faithful" is deliberately absent: it is a judge, not an assertion, and asking for
|
|
14
|
+
# it here should fail loudly rather than fall back to a string compare.
|
|
15
|
+
module Assertions
|
|
16
|
+
REGISTRY = {
|
|
17
|
+
"presence" => Presence,
|
|
18
|
+
"absence" => Absence,
|
|
19
|
+
"structure" => Structure,
|
|
20
|
+
"abstention" => Abstention,
|
|
21
|
+
"identity" => Identity,
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @raise [ArgumentError]
|
|
27
|
+
def for(type)
|
|
28
|
+
REGISTRY.fetch(type.to_s) { raise ArgumentError, %(no assertion for "#{type}") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def registered = REGISTRY.keys
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# The comparability key -- (dataset sha, criteria sha, prompt_version) -- and the check
|
|
6
|
+
# that refuses to compare runs it does not match.
|
|
7
|
+
#
|
|
8
|
+
# Invariant 4: a mismatched key is NEVER silently compared. `force:` exists because
|
|
9
|
+
# sometimes you genuinely want to look anyway, but it returns a warning the caller
|
|
10
|
+
# must put in the report rather than quietly proceeding.
|
|
11
|
+
module Compare
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def key(dataset_sha:, criteria_sha:, prompt_version:)
|
|
15
|
+
[dataset_sha, criteria_sha, prompt_version].join("|")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @raise [ComparabilityError]
|
|
19
|
+
# @return [nil, String] nil when comparable; a warning when forced
|
|
20
|
+
def check!(left, right, force: false)
|
|
21
|
+
a = left["comparability_key"]
|
|
22
|
+
b = right["comparability_key"]
|
|
23
|
+
|
|
24
|
+
raise ComparabilityError, "missing comparability_key (#{a.inspect} vs #{b.inspect})" if a.nil? || b.nil?
|
|
25
|
+
return nil if a == b
|
|
26
|
+
|
|
27
|
+
message = "comparability keys differ:\n #{a}\n #{b}"
|
|
28
|
+
raise ComparabilityError, message unless force
|
|
29
|
+
|
|
30
|
+
"FORCED COMPARISON -- #{message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# The version stamped into every file the gem reads or writes. Bump it in the same
|
|
6
|
+
# commit as any change to a format in docs/contract.md section 3 -- the fixtures under
|
|
7
|
+
# spec/fixtures/contract/ are pinned to it, so a silent format drift fails the suite.
|
|
8
|
+
CONTRACT_VERSION = 1
|
|
9
|
+
|
|
10
|
+
# Guards the "contract" field on every file we load.
|
|
11
|
+
module Contract
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param payload [Hash] a parsed JSON document
|
|
15
|
+
# @param path [String] where it came from, for the error message
|
|
16
|
+
# @raise [ContractError]
|
|
17
|
+
# @return [Hash] the payload, unchanged
|
|
18
|
+
def check!(payload, path:)
|
|
19
|
+
version = payload["contract"]
|
|
20
|
+
raise ContractError, %(#{path}: missing "contract" version) if version.nil?
|
|
21
|
+
raise ContractError, "#{path}: contract #{version}, expected #{CONTRACT_VERSION}" unless
|
|
22
|
+
version == CONTRACT_VERSION
|
|
23
|
+
|
|
24
|
+
payload
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module OmniAI
|
|
6
|
+
module Evals
|
|
7
|
+
# Every criterion for a dataset, indexed by example id.
|
|
8
|
+
class Criteria
|
|
9
|
+
# @param dataset [Dataset]
|
|
10
|
+
# @return [Criteria]
|
|
11
|
+
def self.load(dataset:)
|
|
12
|
+
index = {}
|
|
13
|
+
digest = Digest::SHA256.new
|
|
14
|
+
|
|
15
|
+
# Manifest order, not directory order: the sha has to be reproducible, and
|
|
16
|
+
# Dir.glob's order is filesystem-dependent.
|
|
17
|
+
dataset.examples.each do |example|
|
|
18
|
+
path = File.join(dataset.dir, example.target)
|
|
19
|
+
digest << File.binread(path)
|
|
20
|
+
payload = Contract.check!(JSON.parse(File.read(path)), path:)
|
|
21
|
+
index[payload.fetch("example_id")] = payload.fetch("criteria").map do |row|
|
|
22
|
+
Criterion.new(
|
|
23
|
+
id: row.fetch("id"),
|
|
24
|
+
family: row.fetch("family"),
|
|
25
|
+
type: row.fetch("type"),
|
|
26
|
+
scorer: row.fetch("scorer"),
|
|
27
|
+
target: row.fetch("target", {}),
|
|
28
|
+
provenance: row.fetch("provenance", {})
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(index:, sha256: digest.hexdigest)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :sha256
|
|
37
|
+
|
|
38
|
+
def initialize(index:, sha256:)
|
|
39
|
+
@index = index.freeze
|
|
40
|
+
@sha256 = sha256
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Criterion>]
|
|
45
|
+
def for(example_id) = @index.fetch(example_id, [])
|
|
46
|
+
|
|
47
|
+
# @return [Array<Criterion>]
|
|
48
|
+
def all = @index.values.flatten
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Evals
|
|
5
|
+
# One atomic, binary check on one example. `type` selects the assertion (or the judge
|
|
6
|
+
# prompt, for "faithful"); `target` shape is per type and documented in docs/contract.md.
|
|
7
|
+
Criterion = Data.define(:id, :family, :type, :scorer, :target, :provenance)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module OmniAI
|
|
6
|
+
module Evals
|
|
7
|
+
# A frozen set of examples, identified by a content hash.
|
|
8
|
+
class Dataset
|
|
9
|
+
attr_reader :name, :examples, :dir
|
|
10
|
+
|
|
11
|
+
# @param path [String] the manifest.json to load
|
|
12
|
+
# @raise [ContractError]
|
|
13
|
+
# @return [Dataset]
|
|
14
|
+
def self.load(path)
|
|
15
|
+
payload = Contract.check!(JSON.parse(File.read(path)), path:)
|
|
16
|
+
examples = payload.fetch("examples", []).map do |row|
|
|
17
|
+
Example.new(
|
|
18
|
+
id: row.fetch("id"),
|
|
19
|
+
input: row.fetch("input"),
|
|
20
|
+
target: row.fetch("target"),
|
|
21
|
+
metadata: row.fetch("metadata", {})
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
new(name: payload.fetch("name"), examples:, dir: File.dirname(File.expand_path(path)),
|
|
25
|
+
manifest_bytes: File.binread(path))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(name:, examples:, dir:, manifest_bytes:)
|
|
29
|
+
@name = name
|
|
30
|
+
@examples = examples.freeze
|
|
31
|
+
@dir = dir
|
|
32
|
+
@manifest_bytes = manifest_bytes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Absolute path to an example's input file.
|
|
36
|
+
#
|
|
37
|
+
# @raise [ContractError] for an input with no file behind it. "messages" inputs are
|
|
38
|
+
# inline, so asking for their path is a caller bug, not a missing file -- saying so
|
|
39
|
+
# beats returning a path that does not exist.
|
|
40
|
+
def input_path_for(example)
|
|
41
|
+
path = example.input["path"]
|
|
42
|
+
raise ContractError, %(example #{example.id}: input kind "#{example.input['kind']}" has no path) if path.nil?
|
|
43
|
+
|
|
44
|
+
File.join(@dir, path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Every example's input file, in manifest order. Inline inputs contribute nothing.
|
|
48
|
+
def input_paths
|
|
49
|
+
@examples.filter_map { |example| example.input["path"] && input_path_for(example) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# SHA-256 over the manifest bytes plus every referenced input file, in manifest
|
|
53
|
+
# order. Hashing the file CONTENT rather than its path is what makes a re-render a
|
|
54
|
+
# different dataset -- the manifest is byte-identical when only the pixels changed.
|
|
55
|
+
def sha256
|
|
56
|
+
@sha256 ||= begin
|
|
57
|
+
digest = Digest::SHA256.new
|
|
58
|
+
digest << @manifest_bytes
|
|
59
|
+
input_paths.each { |path| digest << File.binread(path) }
|
|
60
|
+
digest.hexdigest
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|