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,472 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require_relative 'support/samples'
|
|
5
|
+
|
|
6
|
+
describe HaveAPI::Performance::Comparator do
|
|
7
|
+
def compare_cli(samples, budgets, sample_bytes: JSON.generate(samples))
|
|
8
|
+
Dir.mktmpdir('haveapi-comparator-') do |directory|
|
|
9
|
+
input = File.join(directory, 'samples.json')
|
|
10
|
+
policy = File.join(directory, 'budgets.json')
|
|
11
|
+
output = File.join(directory, 'result')
|
|
12
|
+
File.binwrite(input, sample_bytes)
|
|
13
|
+
File.write(policy, budgets)
|
|
14
|
+
_stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
|
|
15
|
+
'compare', '--input', input, '--budgets', policy, '--output', output)
|
|
16
|
+
[status.exitstatus, JSON.parse(File.read(File.join(output, 'report.json')))]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def replace_raw_body(sample, body)
|
|
21
|
+
output = sample['outputs'].first
|
|
22
|
+
raw = sample['bodies'].delete(output['body_sha256'])
|
|
23
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
24
|
+
sample['bodies'][digest] = raw.merge('body' => body)
|
|
25
|
+
output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def replace_response(sample, body, json: true)
|
|
29
|
+
replace_raw_body(sample, body)
|
|
30
|
+
canonical = json ? JSON.parse(body) : nil
|
|
31
|
+
sample['bodies'].values.first['canonical_body'] = canonical
|
|
32
|
+
sample['outputs'].first.merge!('envelope_status' => json ? canonical.fetch('status') : nil,
|
|
33
|
+
'canonical_sha256' => json ? HaveAPI::Performance::Identity.digest(canonical) : Digest::SHA256.hexdigest(body))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rebind_source(samples, role, sha)
|
|
37
|
+
samples["#{role}_sha"] = sha
|
|
38
|
+
samples['sources'][role]['sha'] = sha
|
|
39
|
+
samples['observations'].each_value do |observation|
|
|
40
|
+
observation['source']['sha'] = sha if observation['source']['root'] == samples['sources'][role]['root']
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'passes controlled AA traces through the actual CLI' do
|
|
45
|
+
samples, budgets = PerformanceSamples.fixture
|
|
46
|
+
code, report = compare_cli(samples, budgets)
|
|
47
|
+
expect(code).to eq(0)
|
|
48
|
+
expect(report['status']).to eq('pass')
|
|
49
|
+
expect(report['cases'].first['timing'].keys).to eq(%w[wall_seconds cpu_seconds])
|
|
50
|
+
expect(report['schema_version']).to eq(2)
|
|
51
|
+
expect(report['cases'].first['correctness']).to include('status' => 'pass', 'operations_per_sample' => 1)
|
|
52
|
+
expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'attributes replay success and loaded-input errors to exact input/comparator bytes and the original measurement harness' do
|
|
56
|
+
samples, budgets = PerformanceSamples.fixture
|
|
57
|
+
[false, true].each do |broken|
|
|
58
|
+
samples['schema_version'] = broken ? 1 : 2
|
|
59
|
+
bytes = "#{JSON.pretty_generate(samples)}\n"
|
|
60
|
+
code, report = compare_cli(samples, budgets, sample_bytes: bytes)
|
|
61
|
+
expect(code).to eq(broken ? 3 : 0)
|
|
62
|
+
expect(report['harness_sha256']).to eq('e' * 64)
|
|
63
|
+
expect(report.values_at('baseline_sha', 'candidate_sha', 'budget_sha256')).to eq(samples.values_at('baseline_sha', 'candidate_sha', 'budget_sha256'))
|
|
64
|
+
expect(report['comparison']).to eq('policy' => 'per-revision-response-stability-v1',
|
|
65
|
+
'comparator_sha256' => Digest::SHA256.file(File.expand_path('../../benchmarks/performance_compare.rb', __dir__)).hexdigest,
|
|
66
|
+
'samples_sha256' => Digest::SHA256.hexdigest(bytes))
|
|
67
|
+
end
|
|
68
|
+
code, report = compare_cli(samples, budgets, sample_bytes: '{')
|
|
69
|
+
expect(code).to eq(3)
|
|
70
|
+
expect(report).not_to have_key('harness_sha256')
|
|
71
|
+
expect(report.dig('comparison', 'samples_sha256')).to eq(Digest::SHA256.hexdigest('{'))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'classifies stable changed JSON and HTML normally without changing performance budgets' do
|
|
75
|
+
samples, budgets = PerformanceSamples.fixture
|
|
76
|
+
samples['cases'].each do |item|
|
|
77
|
+
html = item['id'].start_with?('docs.')
|
|
78
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
79
|
+
%w[baseline candidate].each do |role|
|
|
80
|
+
actual = samples['observations'][pair[role]['observation']]['source']['sha']
|
|
81
|
+
value = actual == samples['baseline_sha'] ? 'before' : 'after'
|
|
82
|
+
body = html ? "<!doctype html><html><body>#{value}</body></html>" : JSON.generate(status: true, response: value)
|
|
83
|
+
replace_response(pair[role], body, json: !html)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
code, report = compare_cli(samples, budgets)
|
|
88
|
+
expect(code).to eq(0)
|
|
89
|
+
expect(report['status']).to eq('pass')
|
|
90
|
+
expect(report['cases'].map { |item| item['responses'].slice('raw_changed', 'canonical_changed') }).to all(eq('raw_changed' => true, 'canonical_changed' => true))
|
|
91
|
+
%w[wall_seconds cpu_seconds allocated_objects].each do |metric|
|
|
92
|
+
changed = Marshal.load(Marshal.dump(samples))
|
|
93
|
+
changed['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = metric == 'allocated_objects' ? 140 : 3.0 }
|
|
94
|
+
regression_code, regression_report = compare_cli(changed, budgets)
|
|
95
|
+
expect(regression_code).to eq(1), metric
|
|
96
|
+
expect(regression_report['status']).to eq('regression')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'rejects canonical variation within a source including the second baseline/self column' do
|
|
101
|
+
%w[self_pairs pairs].each do |group|
|
|
102
|
+
samples, budgets = PerformanceSamples.fixture
|
|
103
|
+
replace_response(samples['cases'].first[group].last['candidate'], '{"status":true,"response":"changed"}')
|
|
104
|
+
code, report = compare_cli(samples, budgets)
|
|
105
|
+
expect(code).to eq(3)
|
|
106
|
+
expected_sha = samples[group == 'self_pairs' ? 'baseline_sha' : 'candidate_sha']
|
|
107
|
+
expect(report.dig('error', 'message')).to eq("Response identity differs within revision #{expected_sha}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'replays benchmark inputs with different unused contributor flakes and rejects tampered consumed or source inputs' do
|
|
112
|
+
%w[quick weekly].each do |profile|
|
|
113
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
114
|
+
tool = HaveAPI::Performance::BENCHMARK_INPUTS.to_h do |path|
|
|
115
|
+
content = "controlled #{path}\n"
|
|
116
|
+
[path, { 'content' => content, 'sha256' => Digest::SHA256.hexdigest(content) }]
|
|
117
|
+
end
|
|
118
|
+
samples.fetch('observations').each_value do |observation|
|
|
119
|
+
role = observation.fetch('source').fetch('sha') == samples['baseline_sha'] ? 'baseline' : 'candidate'
|
|
120
|
+
inputs = Marshal.load(Marshal.dump(observation.fetch('inputs')))
|
|
121
|
+
inputs['benchmark'] = tool
|
|
122
|
+
inputs['locks'] = { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
|
|
123
|
+
inputs['contributor_flakes'] = %w[flake.nix flake.lock].to_h { |path| [path, { 'content' => "#{role} #{path}" }] }
|
|
124
|
+
inputs['contributor_flakes'].each do |path, input|
|
|
125
|
+
samples['sources'][role]['files_sha256'][path] = Digest::SHA256.hexdigest(input.fetch('content'))
|
|
126
|
+
end
|
|
127
|
+
inputs['manifests']['.ruby-version'] = Digest::SHA256.hexdigest('3.2.0')
|
|
128
|
+
samples['sources'][role]['files_sha256']['.ruby-version'] = inputs['manifests']['.ruby-version']
|
|
129
|
+
observation['inputs'] = inputs
|
|
130
|
+
end
|
|
131
|
+
samples['inputs'] = HaveAPI::Performance::Release.graph(samples['observations']['baseline']['inputs'])
|
|
132
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
133
|
+
%w[tool lock raw-flake declaration ruby].each do |fault|
|
|
134
|
+
changed = Marshal.load(Marshal.dump(samples))
|
|
135
|
+
inputs = changed['observations']['candidate']['inputs']
|
|
136
|
+
case fault
|
|
137
|
+
when 'tool' then inputs['benchmark']['servers/ruby/benchmarks/shell.nix']['content'] << 'tampered'
|
|
138
|
+
when 'lock' then inputs['locks']['servers/ruby/benchmarks/Gemfile.lock'] = 'f' * 64
|
|
139
|
+
when 'raw-flake' then inputs['contributor_flakes']['flake.nix']['content'] << 'tampered'
|
|
140
|
+
when 'declaration' then inputs['manifests']['servers/ruby/Gemfile'] = 'f' * 64
|
|
141
|
+
else inputs['manifests']['.ruby-version'] = 'f' * 64
|
|
142
|
+
end
|
|
143
|
+
expect(compare_cli(changed, budgets).first).to eq(3), "#{profile}:#{fault}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'rejects unsupported sample formats without converting historical evidence' do
|
|
149
|
+
samples, budgets = PerformanceSamples.fixture
|
|
150
|
+
samples['schema_version'] = 1
|
|
151
|
+
code, report = compare_cli(samples, budgets)
|
|
152
|
+
expect(code).to eq(3)
|
|
153
|
+
expect(report.dig('error', 'message')).to include('Unsupported samples schema; expected 2')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'blocks an agreed meaningful slowdown for either clock independently' do
|
|
157
|
+
%w[wall_seconds cpu_seconds].each do |metric|
|
|
158
|
+
samples, budgets = PerformanceSamples.fixture
|
|
159
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = 3.0 }
|
|
160
|
+
code, report = compare_cli(samples, budgets)
|
|
161
|
+
expect(code).to eq(1)
|
|
162
|
+
expect(report['cases'].first['timing'][metric]['regression_agreement']).to eq(5)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'retains noisy inconclusive diagnostics without failing readiness' do
|
|
167
|
+
samples, budgets = PerformanceSamples.fixture
|
|
168
|
+
samples['cases'].first['self_pairs'].first['candidate']['wall_seconds'] = 6.0
|
|
169
|
+
code, report = compare_cli(samples, budgets)
|
|
170
|
+
expect(code).to eq(0)
|
|
171
|
+
expect(report['status']).to eq('inconclusive')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'requires four of five quick agreements and six of seven weekly agreements' do
|
|
175
|
+
%w[quick weekly].each do |profile|
|
|
176
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
177
|
+
agreement = HaveAPI::Performance::PROFILES.fetch(profile).fetch(:agreement)
|
|
178
|
+
samples['cases'].first['pairs'].first(agreement - 1).each { |pair| pair['candidate']['wall_seconds'] = 3.0 }
|
|
179
|
+
code, report = compare_cli(samples, budgets)
|
|
180
|
+
expect(code).to eq(0)
|
|
181
|
+
expect(report['status']).to eq('inconclusive')
|
|
182
|
+
samples['cases'].first['pairs'][agreement - 1]['candidate']['wall_seconds'] = 3.0
|
|
183
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'uses the stricter weekly budget on the same fifteen percent increase' do
|
|
188
|
+
%w[quick weekly].each do |profile|
|
|
189
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
190
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 2.30 }
|
|
191
|
+
expect(compare_cli(samples, budgets).first).to eq(profile == 'quick' ? 0 : 1)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'detects a cheap-request twenty-five percent regression using the batch floor' do
|
|
196
|
+
samples, budgets = PerformanceSamples.fixture
|
|
197
|
+
item = samples['cases'].first
|
|
198
|
+
item['iterations'] = 25_000
|
|
199
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
200
|
+
pair.each_value do |sample|
|
|
201
|
+
next unless sample.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
sample['operations'] = 25_000
|
|
204
|
+
sample['outputs'].first['count'] = 25_000
|
|
205
|
+
sample['counters']['operations'] = 25_000
|
|
206
|
+
sample['wall_seconds'] = sample['cpu_seconds'] = 0.00004
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
item['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 0.00005 }
|
|
210
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'validates distinct examples and rejects malformed compact counts, identities and counter summaries' do
|
|
214
|
+
samples, json = PerformanceSamples.fixture
|
|
215
|
+
budget = JSON.parse(json)
|
|
216
|
+
spec = samples['cases'].first
|
|
217
|
+
sample = Marshal.load(Marshal.dump(spec['pairs'].first['candidate']))
|
|
218
|
+
sample['operations'] = 4
|
|
219
|
+
sample['outputs'].first['count'] = 4
|
|
220
|
+
sample['counters']['operations'] = 4
|
|
221
|
+
comparator = described_class.new(samples, budget, budget_sha256: samples['budget_sha256'])
|
|
222
|
+
body = sample['bodies'].values.first['body']
|
|
223
|
+
allow(HaveAPI::Performance::Identity).to receive(:parse).and_call_original
|
|
224
|
+
comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
|
|
225
|
+
expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).once
|
|
226
|
+
%w[http_status envelope_status body_bytes body_sha256 canonical_sha256 comparison counter missing zero negative float total counter_count duplicate unreferenced].each do |fault|
|
|
227
|
+
broken = Marshal.load(Marshal.dump(sample))
|
|
228
|
+
output = broken['outputs'].last
|
|
229
|
+
case fault
|
|
230
|
+
when 'http_status' then output[fault] = 403
|
|
231
|
+
when 'envelope_status' then output[fault] = false
|
|
232
|
+
when 'body_bytes' then output[fault] += 1
|
|
233
|
+
when 'comparison' then output[fault] = { 'kind' => 'own-webui-href', 'own_version' => '0.30.2' }
|
|
234
|
+
when 'counter' then broken['counters']['record']['authorizations'] = 0
|
|
235
|
+
when 'missing' then broken['counters'].delete('record')
|
|
236
|
+
when 'zero' then output['count'] = 0
|
|
237
|
+
when 'negative' then output['count'] = -1
|
|
238
|
+
when 'float' then output['count'] = 4.0
|
|
239
|
+
when 'total' then output['count'] = 3
|
|
240
|
+
when 'counter_count' then broken['counters']['operations'] = 4.0
|
|
241
|
+
when 'duplicate'
|
|
242
|
+
output['count'] = 2
|
|
243
|
+
broken['outputs'] << output.dup
|
|
244
|
+
when 'unreferenced'
|
|
245
|
+
other_body = '{"status":true}'
|
|
246
|
+
broken['bodies'][Digest::SHA256.hexdigest(other_body)] = { 'body' => other_body, 'canonical_body' => JSON.parse(other_body) }
|
|
247
|
+
else output[fault] = '0' * 64
|
|
248
|
+
end
|
|
249
|
+
expect { comparator.send(:validate_sample, broken, 4, budget['profiles']['quick'].first, spec) }.to raise_error(StandardError)
|
|
250
|
+
end
|
|
251
|
+
# A second sample must independently prove its retained body again.
|
|
252
|
+
comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
|
|
253
|
+
expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).at_least(:twice)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'rejects raw/canonical disagreement and duplicate JSON on a reused body and rejects counts above the shared cap' do
|
|
257
|
+
%w[canonical duplicate cap].each do |fault|
|
|
258
|
+
samples, json = PerformanceSamples.fixture
|
|
259
|
+
spec = samples['cases'].first
|
|
260
|
+
sample = spec['pairs'].first['candidate']
|
|
261
|
+
if fault == 'canonical'
|
|
262
|
+
sample['bodies'].values.first['canonical_body']['response']['zero'] = 1
|
|
263
|
+
elsif fault == 'duplicate'
|
|
264
|
+
body = sample['bodies'].values.first['body'].sub('"status":true', '"status":false,"status":true')
|
|
265
|
+
replace_raw_body(sample, body)
|
|
266
|
+
else
|
|
267
|
+
spec['iterations'] = 100_001
|
|
268
|
+
end
|
|
269
|
+
comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
|
|
270
|
+
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it 'compares response occurrence totals within each revision without retaining request order' do
|
|
275
|
+
samples, json = PerformanceSamples.fixture
|
|
276
|
+
spec = samples['cases'].first
|
|
277
|
+
spec['iterations'] = 3
|
|
278
|
+
(spec['self_pairs'] + spec['pairs']).each do |pair|
|
|
279
|
+
%w[baseline candidate].each do |role|
|
|
280
|
+
sample = pair[role]
|
|
281
|
+
output = sample['outputs'].first
|
|
282
|
+
body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":100')
|
|
283
|
+
canonical = JSON.parse(body)
|
|
284
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
285
|
+
sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
|
|
286
|
+
sample['operations'] = 3
|
|
287
|
+
sample['outputs'] << output.merge('body_sha256' => digest, 'body_bytes' => body.bytesize,
|
|
288
|
+
'canonical_sha256' => HaveAPI::Performance::Identity.digest(canonical), 'count' => 2)
|
|
289
|
+
sample['counters']['operations'] = 3
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
|
|
293
|
+
report = comparator.compare
|
|
294
|
+
expect(report['status']).to eq('pass')
|
|
295
|
+
original_bytes = spec['pairs'].first['baseline']['outputs'].first['body_bytes']
|
|
296
|
+
expect(report['cases'].first.dig('responses', 'bytes_per_request', 'baseline')).to be_within(0.00001).of(original_bytes + (4.0 / 3))
|
|
297
|
+
spec['pairs'].last['candidate']['outputs'].reverse!
|
|
298
|
+
expect(comparator.compare['status']).to eq('pass')
|
|
299
|
+
spec['pairs'].each { |pair| pair['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] } }
|
|
300
|
+
report = comparator.compare
|
|
301
|
+
expect(report['status']).to eq('pass')
|
|
302
|
+
expect(report['cases'].first.dig('responses', 'canonical_changed')).to be(true)
|
|
303
|
+
spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
|
|
304
|
+
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'rejects retained runaway request batches even if both sources are equally slow' do
|
|
308
|
+
samples, budgets = PerformanceSamples.fixture
|
|
309
|
+
item = samples['cases'].first
|
|
310
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
311
|
+
%w[baseline candidate].each { |role| pair[role]['wall_seconds'] = 100.0 }
|
|
312
|
+
end
|
|
313
|
+
code, report = compare_cli(samples, budgets)
|
|
314
|
+
expect(code).to eq(3)
|
|
315
|
+
expect(report.dig('error', 'message')).to include('Request batch exceeds 30.0s')
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'rejects raw integer/float drift even when forged raw metadata is consistent' do
|
|
319
|
+
[[0, 0.0], [0.0, 0]].each do |original, changed|
|
|
320
|
+
samples, budgets = PerformanceSamples.fixture(zero: original)
|
|
321
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
322
|
+
body = JSON.parse(target['bodies'].values.first['body'])
|
|
323
|
+
body['response']['zero'] = changed
|
|
324
|
+
replace_raw_body(target, JSON.generate(body))
|
|
325
|
+
code, report = compare_cli(samples, budgets)
|
|
326
|
+
expect(code).to eq(3)
|
|
327
|
+
expect(report.dig('error', 'message')).to include('Raw/canonical output values differ')
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'allows raw map-order changes through the actual CLI' do
|
|
332
|
+
samples, budgets = PerformanceSamples.fixture
|
|
333
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
334
|
+
body = JSON.parse(target['bodies'].values.first['body'])
|
|
335
|
+
body['response'] = body['response'].to_a.reverse.to_h
|
|
336
|
+
replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
|
|
337
|
+
code, report = compare_cli(samples, budgets)
|
|
338
|
+
expect(code).to eq(0)
|
|
339
|
+
expect(report['cases'].first['responses']).to include('raw_changed' => true, 'canonical_changed' => false)
|
|
340
|
+
expect(report['cases'].first.dig('responses', 'bytes_per_request', 'delta')).to eq(0)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it 'reports weighted byte deltas from baseline/candidate pairs without self-pair bias or a raw determinism requirement' do
|
|
344
|
+
samples, budgets = PerformanceSamples.fixture
|
|
345
|
+
item = samples['cases'].first
|
|
346
|
+
baseline_bytes = item['pairs'].first['baseline']['outputs'].first['body_bytes']
|
|
347
|
+
item['self_pairs'].each do |pair|
|
|
348
|
+
%w[baseline candidate].each { |role| replace_raw_body(pair[role], " #{pair[role]['bodies'].values.first['body']}") }
|
|
349
|
+
end
|
|
350
|
+
item['pairs'].last(2).each do |pair|
|
|
351
|
+
replace_raw_body(pair['candidate'], " #{pair['candidate']['bodies'].values.first['body']}")
|
|
352
|
+
end
|
|
353
|
+
code, report = compare_cli(samples, budgets)
|
|
354
|
+
expect(code).to eq(0)
|
|
355
|
+
response = report['cases'].first['responses']
|
|
356
|
+
expect(response).to include('raw_changed' => true, 'canonical_changed' => false)
|
|
357
|
+
expect(response['bytes_per_request']).to include('baseline' => baseline_bytes, 'candidate' => baseline_bytes + 0.8)
|
|
358
|
+
expect(response['bytes_per_request']['delta']).to be_within(0.00001).of(0.8)
|
|
359
|
+
expect(response['bytes_per_request']['delta_percent']).to be_within(0.00001).of(80.0 / baseline_bytes)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it 'rejects identical source SHAs even with consistently rebound provenance' do
|
|
363
|
+
samples, budgets = PerformanceSamples.fixture
|
|
364
|
+
rebind_source(samples, 'candidate', samples['baseline_sha'])
|
|
365
|
+
code, report = compare_cli(samples, budgets)
|
|
366
|
+
expect(code).to eq(3)
|
|
367
|
+
expect(report.dig('error', 'message')).to include('Baseline and candidate must differ')
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'requires the fixed weekly baseline and accepts valid fixed-reference evidence' do
|
|
371
|
+
samples, budgets = PerformanceSamples.fixture(profile: 'weekly')
|
|
372
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
373
|
+
rebind_source(samples, 'baseline', 'a' * 40)
|
|
374
|
+
code, report = compare_cli(samples, budgets)
|
|
375
|
+
expect(code).to eq(3)
|
|
376
|
+
expect(report.dig('error', 'message')).to include('Weekly baseline must be the fixed reference')
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it 'requires both the percentage and absolute timing thresholds' do
|
|
380
|
+
samples, budgets = PerformanceSamples.fixture
|
|
381
|
+
samples['cases'].first['pairs'].each do |pair|
|
|
382
|
+
pair['baseline']['wall_seconds'] = 0.0001
|
|
383
|
+
pair['candidate']['wall_seconds'] = 0.00013
|
|
384
|
+
end
|
|
385
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
it 'blocks allocation growth and the independent fixed reference ceiling' do
|
|
389
|
+
samples, budgets = PerformanceSamples.fixture
|
|
390
|
+
samples['cases'].first['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 140 }
|
|
391
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
392
|
+
samples['cases'].first['pairs'].each do |pair|
|
|
393
|
+
pair['baseline']['allocated_objects'] = 180
|
|
394
|
+
pair['candidate']['allocated_objects'] = 180
|
|
395
|
+
end
|
|
396
|
+
expect(compare_cli(samples, budgets).first).to eq(1)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it 'requires full Rust cases and their relative allocation and authorization contracts' do
|
|
400
|
+
%w[missing allocations authorization].each do |fault|
|
|
401
|
+
samples, budgets = PerformanceSamples.fixture
|
|
402
|
+
case fault
|
|
403
|
+
when 'missing'
|
|
404
|
+
samples['inventory'].pop
|
|
405
|
+
samples['cases'].pop
|
|
406
|
+
when 'allocations'
|
|
407
|
+
samples['cases'].last['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 200 }
|
|
408
|
+
else
|
|
409
|
+
samples['cases'].last['pairs'].first['candidate']['counters']['record']['authorizations'] -= 1
|
|
410
|
+
end
|
|
411
|
+
expect(compare_cli(samples, budgets).first).to eq(fault == 'allocations' ? 1 : 3)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
it 'blocks uncalibrated reference budgets' do
|
|
416
|
+
samples, json = PerformanceSamples.fixture
|
|
417
|
+
budgets = JSON.parse(json)
|
|
418
|
+
budgets.delete('calibration')
|
|
419
|
+
json = JSON.generate(budgets)
|
|
420
|
+
samples['budget_sha256'] = Digest::SHA256.hexdigest(json)
|
|
421
|
+
expect(compare_cli(samples, json).first).to eq(3)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it 'blocks missing cases, operations, dependency graphs and source provenance' do
|
|
425
|
+
%w[cases outputs inputs sources].each do |missing|
|
|
426
|
+
samples, budgets = PerformanceSamples.fixture
|
|
427
|
+
case missing
|
|
428
|
+
when 'cases' then samples['cases'].clear
|
|
429
|
+
when 'outputs' then samples['cases'].first['pairs'].first['candidate']['outputs'].clear
|
|
430
|
+
else samples.delete(missing)
|
|
431
|
+
end
|
|
432
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it 'rejects missing or changed workload order and runtime policy' do
|
|
437
|
+
%w[workload_order worker_order runtime_policy].each do |fault|
|
|
438
|
+
samples, budgets = PerformanceSamples.fixture
|
|
439
|
+
case fault
|
|
440
|
+
when 'workload_order' then samples['cases'].first['pairs'][1]['workload_order'] = 'forward'
|
|
441
|
+
when 'worker_order' then samples['observations']['candidate-reverse']['workload_order'] = 'forward'
|
|
442
|
+
else samples['observations']['baseline']['runtime_policy']['timezone'] = 'local'
|
|
443
|
+
end
|
|
444
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it 'blocks extra queries, lost authorizations, wrong output and changed dependency graphs' do
|
|
449
|
+
%w[queries authorizations output environment].each do |fault|
|
|
450
|
+
samples, budgets = PerformanceSamples.fixture
|
|
451
|
+
target = samples['cases'].first['pairs'].first['candidate']
|
|
452
|
+
case fault
|
|
453
|
+
when 'queries' then target['counters']['record']['queries'] = 1
|
|
454
|
+
when 'authorizations' then target['counters']['record']['authorizations'] = 0
|
|
455
|
+
when 'output' then target['outputs'].first['canonical_sha256'] = '0' * 64
|
|
456
|
+
else samples['observations']['candidate']['environment'] = {}
|
|
457
|
+
end
|
|
458
|
+
expect(compare_cli(samples, budgets).first).to eq(3)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
it 'preserves scalar types and array order while ignoring map order only' do
|
|
463
|
+
identity = HaveAPI::Performance::Identity
|
|
464
|
+
expect(identity.digest(a: 0, b: [false, '', nil])).to eq(identity.digest('b' => [false, '', nil], 'a' => 0))
|
|
465
|
+
expect(identity.digest(a: 0)).not_to eq(identity.digest(a: 0.0))
|
|
466
|
+
expect(identity.digest(a: [false, nil])).not_to eq(identity.digest(a: [nil, false]))
|
|
467
|
+
expect { identity.digest(a: 0, 'a' => 1) }.to raise_error(HaveAPI::Performance::ContractError)
|
|
468
|
+
['{"status":false,"status":true}', '{"a":[{"b":1,"b":2}]}'].each do |json|
|
|
469
|
+
expect { identity.parse(json) }.to raise_error(HaveAPI::Performance::ContractError, /Duplicate/)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|