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