haveapi 0.30.1 → 0.31.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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +786 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +678 -0
- data/benchmarks/shell.nix +21 -0
- data/doc/client-examples.md +179 -26
- data/doc/performance.md +310 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
- data/lib/haveapi/documentation_clients.rb +49 -6
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/client_examples/profile_mount_spec.rb +45 -0
- data/spec/client_examples/ts_generated_client_spec.rb +53 -0
- data/spec/client_examples/typescript_profiles_spec.rb +113 -0
- data/spec/documentation/client_examples_spec.rb +2 -2
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +472 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +407 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +470 -0
- data/spec/performance/runner_spec.rb +610 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- metadata +29 -3
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
# All entry points share the calibrated native JSON version. Unbundled
|
|
2
|
+
# workers must activate it before require selects a newer gem in GEM_PATH.
|
|
3
|
+
module HaveAPI
|
|
4
|
+
module Performance
|
|
5
|
+
JSON_VERSION = '2.21.2'.freeze
|
|
6
|
+
gem 'json', "= #{JSON_VERSION}"
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
require 'digest'
|
|
10
|
+
require 'json'
|
|
11
|
+
require 'open3'
|
|
12
|
+
require 'rbconfig'
|
|
13
|
+
|
|
14
|
+
module HaveAPI
|
|
15
|
+
module Performance
|
|
16
|
+
REFERENCE = 'b85ec364057f536940d689b09e612512ffffaf82'.freeze
|
|
17
|
+
SAMPLE_SCHEMA = 2
|
|
18
|
+
PROFILES = { 'quick' => { self_pairs: 3, pairs: 5, agreement: 4, target: 1.0, adequate: 0.75, relative: 0.20 },
|
|
19
|
+
'weekly' => { self_pairs: 3, pairs: 7, agreement: 6, target: 2.0, adequate: 1.5, relative: 0.10 } }.freeze
|
|
20
|
+
MAX_ITERATIONS = 100_000
|
|
21
|
+
REQUEST_LIMIT_SECONDS = 30.0
|
|
22
|
+
SETUP_LIMIT_SECONDS = 120.0
|
|
23
|
+
BENCHMARK_INPUTS = %w[servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix].freeze
|
|
24
|
+
|
|
25
|
+
def self.complex_cases(profile)
|
|
26
|
+
sizes = profile == 'weekly' ? [32] : [16]
|
|
27
|
+
sizes.product(%w[admin member]).map do |resources, user|
|
|
28
|
+
{ 'id' => 'docs.html_rust', 'dimensions' => { 'resources' => resources, 'actions' => 4, 'samples' => 2, 'parameters' => 4, 'user' => user } }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
METRICS = %w[wall_seconds cpu_seconds allocated_objects].freeze
|
|
32
|
+
COUNTERS = %w[queries authorizations executions].freeze
|
|
33
|
+
RUNTIME_POLICY = { 'rack_env' => 'test', 'locale' => 'C.UTF-8', 'timezone' => 'UTC', 'yjit' => false, 'rjit' => false }.freeze
|
|
34
|
+
class ContractError < StandardError; end
|
|
35
|
+
|
|
36
|
+
def self.gemspec(root, path)
|
|
37
|
+
script = <<~RUBY
|
|
38
|
+
require 'json'
|
|
39
|
+
spec = Gem::Specification.load(File.basename(ARGV.fetch(0)))
|
|
40
|
+
abort 'Cannot load gemspec' unless spec
|
|
41
|
+
puts JSON.generate(ruby: spec.required_ruby_version.to_s,
|
|
42
|
+
dependencies: spec.dependencies.map { |d| [d.name, d.type.to_s, d.requirement.to_s] }.sort)
|
|
43
|
+
RUBY
|
|
44
|
+
environment = (ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]).to_h { |key| [key, nil] }
|
|
45
|
+
output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', script, path, chdir: File.dirname(File.join(root, path)))
|
|
46
|
+
raise ContractError, "Cannot load source gemspec #{path}: #{errors.strip}" unless status.success?
|
|
47
|
+
|
|
48
|
+
specification = JSON.parse(output)
|
|
49
|
+
unless Gem::Requirement.new(specification.fetch('ruby')).satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
|
50
|
+
raise ContractError, "Benchmark Ruby does not satisfy #{path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
specification.fetch('dependencies').each do |name, type, requirement|
|
|
54
|
+
next unless type == 'runtime'
|
|
55
|
+
next if %w[haveapi haveapi-client haveapi-rust-client].include?(name)
|
|
56
|
+
|
|
57
|
+
unless Gem::Requirement.new(requirement.split(',').map(&:strip)).satisfied_by?(Gem::Specification.find_by_name(name).version)
|
|
58
|
+
raise ContractError, "Benchmark dependencies do not satisfy #{name} #{requirement}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
specification
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One reviewed maintenance mapping, not a general cherry-pick certificate.
|
|
65
|
+
# Both live entry points verify Git; replay verifies the retained raw delta.
|
|
66
|
+
module Release
|
|
67
|
+
MAPPED = '8b7abc097db49893da9cc8fa4395648901fec71d'.freeze
|
|
68
|
+
METADATA = '1c3c98f0d77e0b23c8da1a8937f25c5799864a3b'.freeze
|
|
69
|
+
DELTA = '0a008acbb46434a0914421064149ce154ea41a6e82a29022c53e666eb2066fd5'.freeze
|
|
70
|
+
DIFF = %w[-c diff.suppressBlankEmpty=false -c core.quotePath=true diff --binary --full-index
|
|
71
|
+
--no-ext-diff --no-textconv --no-color --no-renames --diff-algorithm=myers
|
|
72
|
+
--no-indent-heuristic --unified=3 --inter-hunk-context=0 --src-prefix=a/ --dst-prefix=b/
|
|
73
|
+
--line-prefix= --no-relative -O/dev/null].freeze
|
|
74
|
+
TREES = %w[aaa082cae86f0a24559333213ab959729fb37494 e5a3896cad997648eaa08ff1c1865deca5de9a43
|
|
75
|
+
32a09190e05a89f6312e07f20a9e4585335e468c d42a0638dc93a40ef08718ae2bf6897df966d980].freeze
|
|
76
|
+
JSON_GEMFILES = %w[Gemfile servers/ruby/Gemfile clients/rust/Gemfile].freeze
|
|
77
|
+
VERSIONS = { 'servers/ruby/lib/haveapi/version.rb' => 'haveapi',
|
|
78
|
+
'clients/ruby/lib/haveapi/client/version.rb' => 'haveapi-client',
|
|
79
|
+
'clients/rust/lib/haveapi/rust_client/version.rb' => 'haveapi-rust-client' }.freeze
|
|
80
|
+
|
|
81
|
+
def self.lineage(git:, ancestor:, candidate:)
|
|
82
|
+
# Ordinary master ancestry needs no maintenance objects, including in a
|
|
83
|
+
# single-branch clone. Probe the exceptional mapping only when needed.
|
|
84
|
+
return if ancestor.call(REFERENCE, candidate)
|
|
85
|
+
return unless ancestor.call(MAPPED, candidate)
|
|
86
|
+
|
|
87
|
+
proof = { 'candidate_sha' => candidate, 'reference_sha' => REFERENCE, 'mapped_sha' => MAPPED,
|
|
88
|
+
'metadata_sha' => METADATA,
|
|
89
|
+
'trees' => [REFERENCE, MAPPED, "#{METADATA}^", METADATA].map { |sha| git.call('rev-parse', "#{sha}^{tree}").strip },
|
|
90
|
+
'reference_delta' => git.call(*DIFF, REFERENCE, MAPPED),
|
|
91
|
+
'metadata_delta' => git.call(*DIFF, "#{METADATA}^", METADATA) }
|
|
92
|
+
validate_lineage(proof, candidate)
|
|
93
|
+
proof
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.validate_lineage(proof, candidate)
|
|
97
|
+
unless proof.fetch('candidate_sha') == candidate && proof.fetch('reference_sha') == REFERENCE &&
|
|
98
|
+
proof.fetch('mapped_sha') == MAPPED && proof.fetch('metadata_sha') == METADATA && proof.fetch('trees') == TREES &&
|
|
99
|
+
proof.fetch('reference_delta') == proof.fetch('metadata_delta') &&
|
|
100
|
+
Digest::SHA256.hexdigest(proof.fetch('reference_delta')) == DELTA
|
|
101
|
+
raise ContractError, 'Invalid published release lineage proof'
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.version(contents)
|
|
106
|
+
value = contents.fetch('VERSION').strip
|
|
107
|
+
unless value.match?(/\A0\.30\.(?:0|[1-9][0-9]*)\z/) && contents.fetch('VERSION') == "#{value}\n"
|
|
108
|
+
raise ContractError, 'Expected own 0.30 patch version'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.paths(profile)
|
|
115
|
+
PROFILES.fetch(profile)
|
|
116
|
+
versions = VERSIONS.keys
|
|
117
|
+
%w[VERSION .ruby-version flake.nix flake.lock Gemfile servers/ruby/Gemfile clients/ruby/Gemfile] +
|
|
118
|
+
versions + versions.map { |path| "#{path.split('/lib/').first}/#{VERSIONS.fetch(path)}.gemspec" } +
|
|
119
|
+
['clients/rust/Gemfile']
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Normalize only the exact JSON preparation declaration and validated own
|
|
123
|
+
# VERSION/internal dependency identities. Keep raw and normalized hashes.
|
|
124
|
+
def self.normalize(contents)
|
|
125
|
+
own = version(contents)
|
|
126
|
+
normalized = contents.merge(json_preparation(contents).fetch('normalized_contents'))
|
|
127
|
+
normalized['VERSION'] = "0.30.PATCH\n"
|
|
128
|
+
VERSIONS.each do |path, name|
|
|
129
|
+
next unless contents.has_key?(path)
|
|
130
|
+
|
|
131
|
+
source = contents.fetch(path)
|
|
132
|
+
raise ContractError, "Mismatched local #{name} version" unless source.scan(/^\s*VERSION = '([^']+)'/).flatten == [own]
|
|
133
|
+
|
|
134
|
+
normalized[path] = source.sub(/^(\s*VERSION = )'#{Regexp.escape(own)}'/, "\\1'0.30.PATCH'")
|
|
135
|
+
spec_path = "#{path.split('/lib/').first}/#{name}.gemspec"
|
|
136
|
+
spec = contents.fetch(spec_path)
|
|
137
|
+
raise ContractError, 'Mismatched local package identity' unless spec.match?(/\.name\s*= '#{Regexp.escape(name)}'/)
|
|
138
|
+
next if name == 'haveapi-client'
|
|
139
|
+
|
|
140
|
+
requirement = /(?<=\.add_dependency )'haveapi-client', '~> ([^']+)'/
|
|
141
|
+
raise ContractError, 'Unsynchronized internal client constraint' unless spec.scan(requirement).flatten == [own]
|
|
142
|
+
|
|
143
|
+
normalized[spec_path] = spec.sub(requirement, "'haveapi-client', '~> 0.30.PATCH'")
|
|
144
|
+
end
|
|
145
|
+
normalized
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.capture(contents)
|
|
149
|
+
{ 'contents' => contents, 'own_version' => version(contents),
|
|
150
|
+
'raw_sha256' => contents.transform_values { |value| Digest::SHA256.hexdigest(value) },
|
|
151
|
+
'normalized_sha256' => normalize(contents).transform_values { |value| Digest::SHA256.hexdigest(value) },
|
|
152
|
+
'json_preparation' => json_preparation(contents).fetch('declarations') }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A reviewed preparation-only delta: no declaration, the old unversioned
|
|
156
|
+
# declaration, or this exact development pin. Every other byte is retained.
|
|
157
|
+
def self.json_preparation(contents)
|
|
158
|
+
selected = contents.slice(*JSON_GEMFILES)
|
|
159
|
+
raise ContractError, 'Missing JSON preparation Gemfile' unless selected.keys.sort == JSON_GEMFILES.sort
|
|
160
|
+
|
|
161
|
+
declarations = {}
|
|
162
|
+
normalized = selected.to_h do |path, text|
|
|
163
|
+
lines = text.lines.grep(/^\s*gem ['"]json['"]/)
|
|
164
|
+
pin = " gem 'json', '= #{JSON_VERSION}'\n"
|
|
165
|
+
old = " gem 'json'\n"
|
|
166
|
+
unless lines.empty? || lines == [pin] || lines == [old]
|
|
167
|
+
raise ContractError, "Unexpected JSON preparation declaration in #{path}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
declarations[path] = if lines.empty?
|
|
171
|
+
'absent'
|
|
172
|
+
elsif lines == [pin]
|
|
173
|
+
'exact-development-pin'
|
|
174
|
+
else
|
|
175
|
+
'old-unversioned'
|
|
176
|
+
end
|
|
177
|
+
[path, lines.empty? ? text : text.sub(lines.first, '')]
|
|
178
|
+
end
|
|
179
|
+
{ 'contents' => selected, 'normalized_contents' => normalized,
|
|
180
|
+
'raw_sha256' => selected.transform_values { |text| Digest::SHA256.hexdigest(text) },
|
|
181
|
+
'normalized_sha256' => normalized.transform_values { |text| Digest::SHA256.hexdigest(text) },
|
|
182
|
+
'declarations' => declarations, 'version' => JSON_VERSION }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.validate_json_preparation(inputs, source)
|
|
186
|
+
receipt = inputs.fetch('json_preparation')
|
|
187
|
+
raise ContractError, 'JSON preparation normalization mismatch' unless receipt == json_preparation(receipt.fetch('contents'))
|
|
188
|
+
|
|
189
|
+
receipt.fetch('raw_sha256').each do |path, digest|
|
|
190
|
+
unless source.fetch('files_sha256').fetch(path) == digest && inputs.fetch('manifests').fetch(path) == receipt.fetch('normalized_sha256').fetch(path)
|
|
191
|
+
raise ContractError, 'JSON preparation escaped verified source'
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.validate_inputs(inputs, source, profile)
|
|
197
|
+
receipt = inputs.fetch('release_versions')
|
|
198
|
+
raise ContractError, 'Incomplete release input graph' unless receipt.fetch('contents').keys.sort == paths(profile).sort
|
|
199
|
+
raise ContractError, 'Release input normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
|
|
200
|
+
|
|
201
|
+
expected = receipt.fetch('normalized_sha256')
|
|
202
|
+
expected = expected.except('flake.nix', 'flake.lock') if inputs.has_key?('benchmark')
|
|
203
|
+
raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == expected
|
|
204
|
+
|
|
205
|
+
receipt.fetch('raw_sha256').each do |path, digest|
|
|
206
|
+
raise ContractError, 'Release input escaped verified source' unless source.fetch('files_sha256').fetch(path) == digest
|
|
207
|
+
end
|
|
208
|
+
receipt.fetch('own_version')
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.graph(inputs)
|
|
212
|
+
inputs.except('release_versions', 'json_preparation', 'contributor_flakes')
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.html(body, own)
|
|
216
|
+
raise ContractError, 'Expected own 0.30 patch version' unless own.match?(/\A0\.30\.(?:0|[1-9][0-9]*)\z/)
|
|
217
|
+
|
|
218
|
+
# Only the exact framework footer and emitted WebUI hrefs may change.
|
|
219
|
+
# Opaque content and complete quoted tag attributes remain byte-exact.
|
|
220
|
+
footer = '<p class="text-right">Generated by <a href="https://github.com/vpsfreecz/haveapi">HaveAPI</a> v'
|
|
221
|
+
pattern = %r{<!--.*?-->|(?i:<(?<opaque>script|style|pre|code|textarea)\b[^>]*>.*?</\k<opaque>\s*>)|(?<footer>#{Regexp.escape(footer)})(?<footer_version>[^<]*)</p>|(?<link><a\s+href=")https://webui\.haveapi\.org/v#{Regexp.escape(own)}/(?=#[^"<>]*")|<(?:"[^"]*"|'[^']*'|[^'">])*>}m
|
|
222
|
+
footers = 0
|
|
223
|
+
normalized = body.gsub(pattern) do |match|
|
|
224
|
+
if Regexp.last_match[:footer]
|
|
225
|
+
footers += 1
|
|
226
|
+
raise ContractError, 'Documentation footer differs from own source VERSION' unless Regexp.last_match[:footer_version] == own
|
|
227
|
+
|
|
228
|
+
"#{Regexp.last_match[:footer]}0.30.PATCH</p>"
|
|
229
|
+
elsif Regexp.last_match[:link]
|
|
230
|
+
"#{Regexp.last_match[:link]}https://webui.haveapi.org/v0.30.PATCH/"
|
|
231
|
+
else
|
|
232
|
+
match
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
raise ContractError, 'Expected one framework documentation footer' unless footers == 1
|
|
236
|
+
|
|
237
|
+
normalized
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.validate_source_pair(profile:, baseline_sha:, candidate_sha:)
|
|
242
|
+
raise ContractError, 'Baseline and candidate must differ' if baseline_sha == candidate_sha
|
|
243
|
+
return unless profile == 'weekly' && baseline_sha != REFERENCE
|
|
244
|
+
|
|
245
|
+
raise ContractError, 'Weekly baseline must be the fixed reference'
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def self.push_pair!(selection, baseline:, candidate:)
|
|
249
|
+
return false unless selection['push_pair']
|
|
250
|
+
|
|
251
|
+
expected = { 'event_name' => 'push', 'before' => baseline, 'after' => candidate }
|
|
252
|
+
unless selection['event_name'] == 'push' && selection['profile'] == 'quick' && selection['push_pair'] == expected &&
|
|
253
|
+
selection['baseline_sha'] == baseline && selection['candidate_sha'] == candidate && baseline != candidate &&
|
|
254
|
+
[baseline, candidate].all? { |sha| sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40 }
|
|
255
|
+
raise ContractError, 'Invalid quick push event pair'
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
true
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Canonicalization ignores map order only. Arrays and scalar types retain
|
|
262
|
+
# their meaning; duplicate JSON or symbol/string-normalized keys fail closed.
|
|
263
|
+
module Identity
|
|
264
|
+
# A sample owns this snapshot; later response/fixture mutations cannot
|
|
265
|
+
# change the body proof retained for another operation in that sample.
|
|
266
|
+
def self.snapshot(value)
|
|
267
|
+
case value
|
|
268
|
+
when Hash then value.to_h { |key, item| [snapshot(key), snapshot(item)] }.freeze
|
|
269
|
+
when Array then value.map { |item| snapshot(item) }.freeze
|
|
270
|
+
when String then value.dup.freeze
|
|
271
|
+
else value.freeze
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def self.canonical(value)
|
|
276
|
+
case value
|
|
277
|
+
when Hash
|
|
278
|
+
normalized = {}
|
|
279
|
+
value.each do |key, item|
|
|
280
|
+
raise ContractError, 'Non-string JSON map key' unless key.is_a?(String) || key.is_a?(Symbol)
|
|
281
|
+
raise ContractError, "Duplicate normalized key #{key}" if normalized.has_key?(key.to_s)
|
|
282
|
+
|
|
283
|
+
normalized[key.to_s] = canonical(item)
|
|
284
|
+
end
|
|
285
|
+
normalized.sort.to_h
|
|
286
|
+
when Array then value.map { |item| canonical(item) }
|
|
287
|
+
when String, Integer, TrueClass, FalseClass, NilClass then value
|
|
288
|
+
when Float
|
|
289
|
+
raise ContractError, 'Non-finite JSON number' unless value.finite?
|
|
290
|
+
|
|
291
|
+
value
|
|
292
|
+
else raise ContractError, "Non-JSON value #{value.class}"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def self.digest(value)
|
|
297
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(value)))
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def self.json(response)
|
|
301
|
+
body = parse(response.body)
|
|
302
|
+
canonical = canonical(body)
|
|
303
|
+
{ http_status: response.status, envelope_status: body.fetch('status'),
|
|
304
|
+
body_bytes: response.body.bytesize, body_sha256: Digest::SHA256.hexdigest(response.body),
|
|
305
|
+
canonical_sha256: Digest::SHA256.hexdigest(JSON.generate(canonical)), canonical_body: canonical }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def self.parse(string)
|
|
309
|
+
JSON.parse(string, allow_duplicate_key: false)
|
|
310
|
+
rescue JSON::ParserError => e
|
|
311
|
+
raise ContractError, "Duplicate key or invalid JSON: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.key(spec)
|
|
315
|
+
JSON.generate(canonical('id' => spec.fetch('id'), 'dimensions' => spec.fetch('dimensions')))
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
class Comparator
|
|
320
|
+
POLICY = 'per-revision-response-stability-v1'.freeze
|
|
321
|
+
SOURCE_SHA256 = Digest::SHA256.file(__FILE__).hexdigest.freeze
|
|
322
|
+
|
|
323
|
+
def initialize(samples, budgets, budget_sha256:)
|
|
324
|
+
@samples = samples
|
|
325
|
+
@budgets = budgets
|
|
326
|
+
@budget_sha256 = budget_sha256
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def compare
|
|
330
|
+
validate_header
|
|
331
|
+
inventory = @samples.fetch('inventory').map { |spec| Identity.key(spec) }
|
|
332
|
+
cases = @samples.fetch('cases')
|
|
333
|
+
budget_cases = @budgets.fetch('profiles').fetch(@samples.fetch('profile'))
|
|
334
|
+
unless inventory.uniq == inventory && inventory.sort == cases.map { |item| Identity.key(item) }.sort &&
|
|
335
|
+
inventory.sort == (budget_cases + Performance.complex_cases(@samples.fetch('profile'))).map { |item| Identity.key(item) }.sort
|
|
336
|
+
raise ContractError, 'Missing, duplicate or unexpected workload'
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
results = cases.map do |item|
|
|
340
|
+
budget = budget_cases.find { |entry| Identity.key(entry) == Identity.key(item) }
|
|
341
|
+
compare_case(item, budget)
|
|
342
|
+
end
|
|
343
|
+
status = if results.any? { |item| item['classification'] == 'regression' }
|
|
344
|
+
'regression'
|
|
345
|
+
elsif results.any? { |item| item['classification'] == 'inconclusive' }
|
|
346
|
+
'inconclusive'
|
|
347
|
+
else
|
|
348
|
+
'pass'
|
|
349
|
+
end
|
|
350
|
+
@samples.slice('schema_version', 'profile', 'baseline_sha', 'candidate_sha', 'harness_sha256', 'budget_sha256')
|
|
351
|
+
.merge('status' => status, 'cases' => results, 'calibration_environment' => 'weekly')
|
|
352
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError => e
|
|
353
|
+
raise ContractError, "Invalid comparison input: #{e.message}"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
def validate_header
|
|
359
|
+
raise ContractError, "Unsupported samples schema; expected #{SAMPLE_SCHEMA}" unless @samples.fetch('schema_version').eql?(SAMPLE_SCHEMA)
|
|
360
|
+
raise ContractError, 'Unsupported budget schema' unless @budgets.fetch('schema_version') == 1
|
|
361
|
+
raise ContractError, 'Wrong fixed reference' unless @budgets.fetch('reference_sha') == REFERENCE
|
|
362
|
+
raise ContractError, 'Budget digest mismatch' unless @samples.fetch('budget_sha256') == @budget_sha256
|
|
363
|
+
|
|
364
|
+
@policy = PROFILES.fetch(@samples.fetch('profile'))
|
|
365
|
+
calibration = @budgets.fetch('calibration').fetch(@samples.fetch('profile'))
|
|
366
|
+
unless calibration.fetch('source_sha') == REFERENCE && calibration.fetch('raw_sha256').match?(/\A[0-9a-f]{64}\z/) &&
|
|
367
|
+
calibration.fetch('warmup_batches') == 2 && calibration.fetch('iterations').is_a?(Integer) && calibration['iterations'] > 0 &&
|
|
368
|
+
!calibration.fetch('fixture_sha256').empty? && !calibration.fetch('inputs').empty?
|
|
369
|
+
raise ContractError, 'Missing calibrated reference evidence'
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
%w[baseline_sha candidate_sha harness_sha256].each do |field|
|
|
373
|
+
length = field.end_with?('_sha256') ? 64 : 40
|
|
374
|
+
raise ContractError, "Missing #{field}" unless @samples.fetch(field).match?(/\A[0-9a-f]{#{length}}\z/)
|
|
375
|
+
end
|
|
376
|
+
Performance.validate_source_pair(profile: @samples.fetch('profile'), baseline_sha: @samples.fetch('baseline_sha'),
|
|
377
|
+
candidate_sha: @samples.fetch('candidate_sha'))
|
|
378
|
+
raise ContractError, 'Missing fixture provenance' if @samples.fetch('fixture_sha256').empty?
|
|
379
|
+
raise ContractError, 'Missing source provenance' unless @samples.fetch('sources').keys.sort == %w[baseline candidate]
|
|
380
|
+
raise ContractError, 'Missing environment' if @samples.fetch('environment').empty?
|
|
381
|
+
unless @samples.fetch('environment') == @budgets.fetch('environment').fetch('weekly')
|
|
382
|
+
raise ContractError, 'Calibration environment mismatch'
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
sources = @samples.fetch('sources')
|
|
386
|
+
lineage = @samples['release_lineage']
|
|
387
|
+
Release.validate_lineage(lineage, @samples.fetch('candidate_sha')) if lineage
|
|
388
|
+
%w[baseline candidate].each do |role|
|
|
389
|
+
source = sources.fetch(role)
|
|
390
|
+
unless source.fetch('sha') == @samples.fetch("#{role}_sha") && source.fetch('root').start_with?('/') &&
|
|
391
|
+
source.fetch('tree').match?(/\A[0-9a-f]{40}\z/) && source.fetch('tracked_files').is_a?(Integer) && source['tracked_files'] > 0
|
|
392
|
+
raise ContractError, 'Invalid source proof'
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
observations = @samples.fetch('observations')
|
|
396
|
+
raise ContractError, 'Missing worker provenance' if observations.empty?
|
|
397
|
+
|
|
398
|
+
observations.each_value do |observation|
|
|
399
|
+
source = observation.fetch('source')
|
|
400
|
+
raise ContractError, 'Worker root/SHA absent from source proof' unless sources.values.any? { |entry| entry['root'] == source['root'] && entry['sha'] == source['sha'] }
|
|
401
|
+
raise ContractError, 'Missing loaded sources' if source.fetch('loaded_sources').empty?
|
|
402
|
+
raise ContractError, 'Runtime policy mismatch' unless observation.fetch('runtime_policy') == RUNTIME_POLICY
|
|
403
|
+
raise ContractError, 'Missing host diagnostics' unless %w[hostname os cpu].all? { |key| observation.fetch('host').fetch(key).is_a?(String) }
|
|
404
|
+
|
|
405
|
+
selected = sources.values.find { |entry| entry['root'] == source['root'] && entry['sha'] == source['sha'] }
|
|
406
|
+
source.fetch('loaded_sources').each do |path, sha256|
|
|
407
|
+
raise ContractError, 'Loaded runtime not in verified source tree' unless selected.fetch('files_sha256').fetch(path) == sha256
|
|
408
|
+
end
|
|
409
|
+
inputs = observation.fetch('inputs')
|
|
410
|
+
if inputs.has_key?('benchmark')
|
|
411
|
+
tool = inputs.fetch('benchmark')
|
|
412
|
+
raise ContractError, 'Incomplete benchmark inputs' unless tool.keys.sort == BENCHMARK_INPUTS.sort
|
|
413
|
+
|
|
414
|
+
tool.each do |path, input|
|
|
415
|
+
unless Digest::SHA256.hexdigest(input.fetch('content')) == input.fetch('sha256') &&
|
|
416
|
+
@samples.fetch('inputs').fetch('benchmark').fetch(path) == input
|
|
417
|
+
raise ContractError, 'Benchmark input digest mismatch'
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
unless inputs.fetch('locks') == { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
|
|
421
|
+
raise ContractError, 'Benchmark lock identity mismatch'
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
inputs.fetch('contributor_flakes').each do |path, input|
|
|
425
|
+
unless %w[flake.nix flake.lock].include?(path) && Digest::SHA256.hexdigest(input.fetch('content')) == selected.fetch('files_sha256').fetch(path)
|
|
426
|
+
raise ContractError, 'Contributor flake escaped source'
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
unless inputs.fetch('contributor_flakes').keys.sort == %w[flake.lock flake.nix]
|
|
430
|
+
raise ContractError, 'Missing contributor flake provenance'
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
inputs.fetch('manifests').each do |path, digest|
|
|
434
|
+
next if Release::JSON_GEMFILES.include?(path) || lineage
|
|
435
|
+
|
|
436
|
+
raise ContractError, 'Dependency manifest escaped source' unless selected.fetch('files_sha256').fetch(path) == digest
|
|
437
|
+
end
|
|
438
|
+
elsif inputs.has_key?('contributor_flakes')
|
|
439
|
+
raise ContractError, 'Contributor flake separation without benchmark inputs'
|
|
440
|
+
end
|
|
441
|
+
if lineage
|
|
442
|
+
Release.validate_inputs(inputs, selected, @samples.fetch('profile'))
|
|
443
|
+
elsif inputs.has_key?('release_versions')
|
|
444
|
+
raise ContractError, 'Unproven own-version normalization'
|
|
445
|
+
else
|
|
446
|
+
Release.validate_json_preparation(inputs, selected)
|
|
447
|
+
end
|
|
448
|
+
unless observation.fetch('environment') == @samples['environment'] && Release.graph(inputs) == @samples.fetch('inputs') &&
|
|
449
|
+
observation.fetch('fixture_sha256') == @samples['fixture_sha256']
|
|
450
|
+
raise ContractError, 'Worker provenance mismatch'
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
return if @samples.fetch('inputs').fetch('locks').any? && @samples['inputs'].fetch('manifests').any?
|
|
454
|
+
|
|
455
|
+
raise ContractError, 'Missing dependency graph'
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def number(value)
|
|
459
|
+
raise ContractError, 'Invalid nonnegative finite metric' unless value.is_a?(Numeric) && value.finite? && value >= 0
|
|
460
|
+
|
|
461
|
+
value
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def median(values)
|
|
465
|
+
sorted = values.sort
|
|
466
|
+
middle = sorted.length / 2
|
|
467
|
+
sorted.length.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def validate_sample(sample, iterations, budget, spec)
|
|
471
|
+
workload = spec.fetch('id')
|
|
472
|
+
METRICS.each { |metric| number(sample.fetch(metric)) }
|
|
473
|
+
if sample.fetch('wall_seconds') * iterations > REQUEST_LIMIT_SECONDS
|
|
474
|
+
raise ContractError, "Request batch exceeds #{REQUEST_LIMIT_SECONDS}s: #{workload}"
|
|
475
|
+
end
|
|
476
|
+
raise ContractError, 'Operation count mismatch' unless sample.fetch('operations').is_a?(Integer) && sample.fetch('operations') == iterations
|
|
477
|
+
|
|
478
|
+
outputs = sample.fetch('outputs')
|
|
479
|
+
counts = outputs.map do |output|
|
|
480
|
+
count = output.fetch('count')
|
|
481
|
+
raise ContractError, 'Invalid response occurrence count' unless count.is_a?(Integer) && count.between?(1, iterations)
|
|
482
|
+
|
|
483
|
+
count
|
|
484
|
+
end
|
|
485
|
+
raise ContractError, 'Response occurrence count mismatch' unless counts.sum == iterations
|
|
486
|
+
|
|
487
|
+
counters = sample.fetch('counters')
|
|
488
|
+
unless counters.fetch('operations').is_a?(Integer) && counters.fetch('operations') == iterations
|
|
489
|
+
raise ContractError, 'Validated counter operation count mismatch'
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
proof = @samples.fetch('observations').fetch(sample.fetch('observation'))
|
|
493
|
+
raise ContractError, 'Sample source provenance missing' unless proof
|
|
494
|
+
|
|
495
|
+
bodies = sample.fetch('bodies')
|
|
496
|
+
unless bodies.keys.sort == outputs.map { |output| output.fetch('body_sha256') }.sort
|
|
497
|
+
raise ContractError, 'Missing, duplicate or unreferenced response example'
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
body_proofs = bodies.to_h do |digest, value|
|
|
501
|
+
raise ContractError, 'Invalid raw output digest' unless digest.match?(/\A[0-9a-f]{64}\z/)
|
|
502
|
+
raise ContractError, 'Raw output digest mismatch' unless Digest::SHA256.hexdigest(value.fetch('body')) == digest
|
|
503
|
+
|
|
504
|
+
canonical = value['canonical_body']
|
|
505
|
+
canonical_digest = canonical.nil? ? digest : Identity.digest(canonical)
|
|
506
|
+
if !canonical.nil? && Identity.digest(Identity.parse(value.fetch('body'))) != canonical_digest
|
|
507
|
+
raise ContractError, 'Raw/canonical output values differ'
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
envelope = canonical.nil? || workload.start_with?('rust.') ? nil : canonical.fetch('status')
|
|
511
|
+
[digest, { body: value.fetch('body').dup.freeze, bytes: value.fetch('body').bytesize, canonical_sha256: canonical_digest.dup.freeze,
|
|
512
|
+
canonical_nil: canonical.nil?, envelope: Identity.snapshot(envelope) }.freeze]
|
|
513
|
+
end
|
|
514
|
+
outputs.each do |output|
|
|
515
|
+
raise ContractError, 'Invalid output status' unless output.fetch('http_status') == 200
|
|
516
|
+
raise ContractError, 'Missing envelope status' unless output.has_key?('envelope_status')
|
|
517
|
+
raise ContractError, 'Empty output' unless number(output.fetch('body_bytes')) > 0
|
|
518
|
+
|
|
519
|
+
%w[body_sha256 canonical_sha256].each do |field|
|
|
520
|
+
raise ContractError, "Missing #{field}" unless output.fetch(field).match?(/\A[0-9a-f]{64}\z/)
|
|
521
|
+
end
|
|
522
|
+
body_digest = output.fetch('body_sha256')
|
|
523
|
+
body_proof = body_proofs.fetch(body_digest)
|
|
524
|
+
raise ContractError, 'Raw body byte count mismatch' unless body_proof.fetch(:bytes) == output['body_bytes']
|
|
525
|
+
raise ContractError, 'Raw body envelope status mismatch' unless body_proof.fetch(:envelope) == output.fetch('envelope_status')
|
|
526
|
+
|
|
527
|
+
comparison = output['comparison']
|
|
528
|
+
if output.has_key?('comparison') && !(@samples['release_lineage'] && %w[docs.html_resources docs.html_actions docs.html_rust].include?(workload) && body_proof.fetch(:canonical_nil) &&
|
|
529
|
+
comparison == { 'kind' => 'own-webui-href', 'own_version' => proof.fetch('inputs').fetch('release_versions').fetch('own_version') })
|
|
530
|
+
raise ContractError, 'Invalid own-version HTML comparison'
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
digest = if comparison
|
|
534
|
+
Digest::SHA256.hexdigest(Release.html(body_proof.fetch(:body), comparison.fetch('own_version')))
|
|
535
|
+
else
|
|
536
|
+
body_proof.fetch(:canonical_sha256)
|
|
537
|
+
end
|
|
538
|
+
if digest != output['canonical_sha256']
|
|
539
|
+
raise ContractError, 'Canonical output digest mismatch'
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
expected = budget ? budget.fetch('counters') : { 'queries' => 0, 'authorizations' => (spec.fetch('dimensions').fetch('resources') * spec.fetch('dimensions').fetch('actions')) + 1, 'executions' => 0 }
|
|
543
|
+
raise ContractError, 'Missing complex documentation authorization' if !budget && expected['authorizations'] <= 0
|
|
544
|
+
raise ContractError, 'Uncalibrated counter budget' unless (COUNTERS - expected.keys).empty?
|
|
545
|
+
unless expected.values.all? { |value| value.is_a?(Integer) && value >= 0 }
|
|
546
|
+
raise ContractError, 'Invalid calibrated counter budget'
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
record = counters.fetch('record')
|
|
550
|
+
raise ContractError, 'Counter keys mismatch' unless record.keys.sort == expected.keys.sort
|
|
551
|
+
|
|
552
|
+
record.each do |key, value|
|
|
553
|
+
raise ContractError, 'Invalid counter' unless value.is_a?(Integer) && value >= 0
|
|
554
|
+
raise ContractError, "Counter contract #{key}: #{value}, expected #{expected[key]}" unless value == expected[key]
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def output_counts(outputs, digest = 'canonical_sha256')
|
|
559
|
+
outputs.each_with_object(Hash.new(0)) do |output, counts|
|
|
560
|
+
identity = JSON.generate(output.values_at('http_status', 'envelope_status', digest))
|
|
561
|
+
counts[identity] += output.fetch('count')
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def same_outputs(left, right)
|
|
566
|
+
output_counts(left) == output_counts(right)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def response_changes(pairs)
|
|
570
|
+
outputs = %w[baseline candidate].to_h do |role|
|
|
571
|
+
[role, pairs.flat_map { |pair| pair.fetch(role).fetch('outputs') }]
|
|
572
|
+
end
|
|
573
|
+
bytes = outputs.transform_values do |examples|
|
|
574
|
+
examples.sum { |output| output.fetch('body_bytes') * output.fetch('count') }.to_f / examples.sum { |output| output.fetch('count') }
|
|
575
|
+
end
|
|
576
|
+
delta = bytes.fetch('candidate') - bytes.fetch('baseline')
|
|
577
|
+
{ 'raw_changed' => output_counts(outputs.fetch('baseline'), 'body_sha256') != output_counts(outputs.fetch('candidate'), 'body_sha256'),
|
|
578
|
+
'canonical_changed' => !same_outputs(outputs.fetch('baseline'), outputs.fetch('candidate')),
|
|
579
|
+
'bytes_per_request' => bytes.merge('delta' => delta, 'delta_percent' => 100.0 * delta / bytes.fetch('baseline')) }
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def compare_case(item, budget)
|
|
583
|
+
iterations = item.fetch('iterations')
|
|
584
|
+
raise ContractError, 'Invalid batch size' unless iterations.is_a?(Integer) && iterations.between?(1, MAX_ITERATIONS)
|
|
585
|
+
|
|
586
|
+
self_pairs = item.fetch('self_pairs')
|
|
587
|
+
pairs = item.fetch('pairs')
|
|
588
|
+
raise ContractError, 'Incomplete pair inventory' unless self_pairs.length == @policy[:self_pairs] && pairs.length == @policy[:pairs]
|
|
589
|
+
|
|
590
|
+
ceiling = nil
|
|
591
|
+
if budget
|
|
592
|
+
reference = number(budget.fetch('reference_allocations'))
|
|
593
|
+
ceiling = number(budget.fetch('allocation_ceiling'))
|
|
594
|
+
raise ContractError, 'Uncalibrated allocation ceiling' unless reference > 0 && ceiling == (reference * 1.25).ceil + 32
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
outputs_by_source = {}
|
|
598
|
+
canonical_counters = nil
|
|
599
|
+
(self_pairs + pairs).each_with_index do |pair, index|
|
|
600
|
+
expected_order = if index < self_pairs.length
|
|
601
|
+
%w[baseline baseline]
|
|
602
|
+
elsif (index - self_pairs.length).even?
|
|
603
|
+
%w[baseline candidate]
|
|
604
|
+
else
|
|
605
|
+
%w[candidate baseline]
|
|
606
|
+
end
|
|
607
|
+
raise ContractError, 'Wrong comparison order' unless pair.fetch('order') == expected_order
|
|
608
|
+
|
|
609
|
+
pair_index = index < self_pairs.length ? index : index - self_pairs.length
|
|
610
|
+
expected_workload_order = pair_index.even? ? 'forward' : 'reverse'
|
|
611
|
+
raise ContractError, 'Wrong workload order' unless pair.fetch('workload_order') == expected_workload_order
|
|
612
|
+
|
|
613
|
+
%w[baseline candidate].each do |role|
|
|
614
|
+
sample = pair.fetch(role)
|
|
615
|
+
validate_sample(sample, iterations, budget, item)
|
|
616
|
+
unless budget
|
|
617
|
+
canonical_counters ||= sample.fetch('counters')
|
|
618
|
+
unless sample.fetch('counters') == canonical_counters
|
|
619
|
+
raise ContractError, 'Complex documentation authorization counts differ'
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
expected_sha = index < self_pairs.length ? @samples['baseline_sha'] : @samples["#{role}_sha"]
|
|
623
|
+
actual_sha = @samples['observations'].fetch(sample['observation']).fetch('source').fetch('sha')
|
|
624
|
+
raise ContractError, 'Sample belongs to wrong revision' unless actual_sha == expected_sha
|
|
625
|
+
if @samples['observations'].fetch(sample['observation']).fetch('workload_order') != expected_workload_order
|
|
626
|
+
raise ContractError, 'Worker workload order mismatch'
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
outputs_by_source[actual_sha] ||= sample.fetch('outputs')
|
|
630
|
+
unless same_outputs(outputs_by_source.fetch(actual_sha), sample.fetch('outputs'))
|
|
631
|
+
raise ContractError, "Response identity differs within revision #{actual_sha}"
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
classifications = %w[wall_seconds cpu_seconds].to_h do |metric|
|
|
636
|
+
baseline = pairs.map { |pair| pair['baseline'][metric] }
|
|
637
|
+
noise = [self_pairs.map { |pair| (pair['baseline'][metric] - pair['candidate'][metric]).abs }.max,
|
|
638
|
+
0.00005 / iterations].max
|
|
639
|
+
excess = pairs.map do |pair|
|
|
640
|
+
b = pair['baseline'][metric]
|
|
641
|
+
pair['candidate'][metric] - b - [@policy[:relative] * b, 0.050 / iterations].max
|
|
642
|
+
end
|
|
643
|
+
failed = excess.count { |value| value > noise }
|
|
644
|
+
passed = excess.count { |value| value < -noise }
|
|
645
|
+
signal = median(baseline) * iterations >= @policy[:adequate]
|
|
646
|
+
classification = 'inconclusive'
|
|
647
|
+
if signal
|
|
648
|
+
classification = 'regression' if failed >= @policy[:agreement]
|
|
649
|
+
classification = 'pass' if passed >= @policy[:agreement]
|
|
650
|
+
end
|
|
651
|
+
[metric, { 'classification' => classification, 'noise' => noise,
|
|
652
|
+
'baseline_batch_seconds' => median(baseline) * iterations, 'adequate_signal' => signal,
|
|
653
|
+
'relative_budget' => @policy[:relative], 'minimum_batch_increase_seconds' => 0.050,
|
|
654
|
+
'excess' => excess, 'regression_agreement' => failed, 'pass_agreement' => passed }]
|
|
655
|
+
end
|
|
656
|
+
base_allocations = median(pairs.map { |pair| pair['baseline']['allocated_objects'] })
|
|
657
|
+
candidate_allocations = median(pairs.map { |pair| pair['candidate']['allocated_objects'] })
|
|
658
|
+
allocations_failed = candidate_allocations > base_allocations + [0.10 * base_allocations, 32].max || (ceiling && candidate_allocations > ceiling)
|
|
659
|
+
classification = if allocations_failed || classifications.values.any? { |result| result['classification'] == 'regression' }
|
|
660
|
+
'regression'
|
|
661
|
+
elsif classifications.values.any? { |result| result['classification'] == 'inconclusive' }
|
|
662
|
+
'inconclusive'
|
|
663
|
+
else
|
|
664
|
+
'pass'
|
|
665
|
+
end
|
|
666
|
+
item.slice('id', 'dimensions', 'iterations')
|
|
667
|
+
.merge('classification' => classification, 'timing' => classifications, 'responses' => response_changes(pairs),
|
|
668
|
+
'correctness' => { 'status' => 'pass', 'validated_samples' => 2 * (self_pairs.length + pairs.length),
|
|
669
|
+
'operations_per_sample' => iterations, 'distinct_responses' => outputs_by_source.fetch(@samples.fetch('baseline_sha')).length,
|
|
670
|
+
'distinct_responses_by_source' => outputs_by_source.transform_values(&:length) },
|
|
671
|
+
'pilot' => item.fetch('pilot', {}).slice('selected_iterations', 'warmup_iterations', 'target_seconds',
|
|
672
|
+
'baseline_batch_seconds', 'adequate_signal', 'capped', 'calibration_adjusted'),
|
|
673
|
+
'allocations' => { 'baseline_median' => base_allocations, 'candidate_median' => candidate_allocations,
|
|
674
|
+
'reference_ceiling' => ceiling, 'calibrated' => !budget.nil?, 'classification' => allocations_failed ? 'regression' : 'pass' })
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
end
|