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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. 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