ast-merge-git 7.1.6 → 7.1.8
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
- checksums.yaml.gz.sig +0 -0
- data/exe/ast-merge-git +51 -11
- data/lib/ast/merge/git/benchmark_adapter.rb +172 -0
- data/lib/ast/merge/git/local_benchmark.rb +885 -68
- data/lib/ast/merge/git/version.rb +1 -1
- data/lib/ast/merge/git.rb +2 -0
- data/sig/ast/merge/git.rbs +36 -1
- data.tar.gz.sig +0 -0
- metadata +27 -12
- metadata.gz.sig +0 -0
|
@@ -5,6 +5,7 @@ require 'fileutils'
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'open3'
|
|
7
7
|
require 'rbconfig'
|
|
8
|
+
require 'tree_haver'
|
|
8
9
|
|
|
9
10
|
module Ast
|
|
10
11
|
module Merge
|
|
@@ -15,8 +16,8 @@ module Ast
|
|
|
15
16
|
Error = Class.new(StandardError)
|
|
16
17
|
SCHEMA = 'structuredmerge.benchmark.corpus/v1'
|
|
17
18
|
CASE_SCHEMA = 'structuredmerge.benchmark/v1'
|
|
18
|
-
OPERATIONS = %w[merge3 metamorphic diff].freeze
|
|
19
|
-
EXECUTABLE_OPERATIONS = %w[merge3 metamorphic].freeze
|
|
19
|
+
OPERATIONS = %w[merge2 merge3 metamorphic diff].freeze
|
|
20
|
+
EXECUTABLE_OPERATIONS = %w[merge2 merge3 metamorphic].freeze
|
|
20
21
|
PARTITIONS = %w[sentinel gold metamorphic].freeze
|
|
21
22
|
EXPECTATIONS = %w[clean conflict error excluded_ambiguous].freeze
|
|
22
23
|
SEVERITIES = %w[none low high critical].freeze
|
|
@@ -34,10 +35,17 @@ module Ast
|
|
|
34
35
|
].freeze
|
|
35
36
|
SELECTOR_FIELDS = %w[provider_id family dialect backend profile require].freeze
|
|
36
37
|
INPUT_ROLES = {
|
|
38
|
+
'merge2' => %w[incoming current],
|
|
37
39
|
'merge3' => %w[base ours theirs],
|
|
38
40
|
'metamorphic' => %w[source transformed],
|
|
39
41
|
'diff' => %w[before after]
|
|
40
42
|
}.freeze
|
|
43
|
+
PROFILE_POLICIES = {
|
|
44
|
+
'micro' => %w[sentinels none],
|
|
45
|
+
'dev' => %w[affected none],
|
|
46
|
+
'nightly' => %w[all none],
|
|
47
|
+
'competitive' => %w[all configured]
|
|
48
|
+
}.freeze
|
|
41
49
|
|
|
42
50
|
attr_reader :document, :corpus_digest, :path
|
|
43
51
|
|
|
@@ -87,14 +95,22 @@ module Ast
|
|
|
87
95
|
capabilities = inferred.values.flatten.uniq.sort
|
|
88
96
|
direct = cases.select { |item| direct_case?(item, capabilities) }.map { |item| item.fetch('id') }
|
|
89
97
|
sentinels = definition.fetch('mandatory_sentinels')
|
|
90
|
-
|
|
91
|
-
population = cases.map { |item| item.fetch('id') } -
|
|
92
|
-
neighbors =
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
base = ordered(sentinels + direct)
|
|
99
|
+
population = cases.map { |item| item.fetch('id') } - base
|
|
100
|
+
neighbors = if definition.fetch('selection_mode') == 'affected'
|
|
101
|
+
neighbor_order(population).first(definition.fetch('neighbor_count'))
|
|
102
|
+
else
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
selected = selected_ids_for(definition.fetch('selection_mode'), sentinels, base, neighbors)
|
|
106
|
+
if selected.length > definition.dig('budgets', 'case_count')
|
|
107
|
+
error!("profile #{profile} case budget does not fit selection")
|
|
108
|
+
end
|
|
95
109
|
|
|
96
110
|
{
|
|
97
111
|
'profile' => profile,
|
|
112
|
+
'selection_mode' => definition.fetch('selection_mode'),
|
|
113
|
+
'competitor_policy' => definition.fetch('competitor_policy'),
|
|
98
114
|
'seed' => document.dig('selection', 'seed'),
|
|
99
115
|
'selected_case_ids' => selected,
|
|
100
116
|
'excluded_case_ids' => cases.map { |item| item.fetch('id') } - selected,
|
|
@@ -119,7 +135,7 @@ module Ast
|
|
|
119
135
|
end,
|
|
120
136
|
'budgets' => definition.fetch('budgets'),
|
|
121
137
|
'explanation' => explanation(
|
|
122
|
-
|
|
138
|
+
definition.fetch('selection_mode'),
|
|
123
139
|
inferred: inferred,
|
|
124
140
|
direct: direct,
|
|
125
141
|
sentinels: sentinels,
|
|
@@ -136,10 +152,18 @@ module Ast
|
|
|
136
152
|
private
|
|
137
153
|
|
|
138
154
|
def validate_profiles!
|
|
139
|
-
|
|
155
|
+
unless document['profiles'].keys.sort == PROFILE_POLICIES.keys.sort
|
|
156
|
+
error!('profiles must be exactly micro, dev, nightly, and competitive')
|
|
157
|
+
end
|
|
140
158
|
document['profiles'].each do |name, profile|
|
|
141
|
-
require_keys(
|
|
159
|
+
require_keys(
|
|
160
|
+
profile,
|
|
161
|
+
%w[selection_mode competitor_policy mandatory_sentinels neighbor_count budgets],
|
|
162
|
+
"profile #{name}"
|
|
163
|
+
)
|
|
142
164
|
require_keys(profile['budgets'], %w[wall_seconds case_count output_bytes], "profile #{name} budgets")
|
|
165
|
+
actual = [profile['selection_mode'], profile['competitor_policy']]
|
|
166
|
+
error!("profile #{name} policy differs") unless actual == PROFILE_POLICIES.fetch(name)
|
|
143
167
|
end
|
|
144
168
|
end
|
|
145
169
|
|
|
@@ -267,7 +291,7 @@ module Ast
|
|
|
267
291
|
end
|
|
268
292
|
|
|
269
293
|
def validate_operation!(item, id)
|
|
270
|
-
if item['operation']
|
|
294
|
+
if %w[merge2 merge3].include?(item['operation'])
|
|
271
295
|
require_keys(item, %w[expected expected_conflicts], id)
|
|
272
296
|
expectation = item.dig('expected', 'outcome')
|
|
273
297
|
error!("#{id}: unsupported expected outcome") unless EXPECTATIONS.include?(expectation)
|
|
@@ -331,9 +355,22 @@ module Ast
|
|
|
331
355
|
ids.sort_by { |id| [digest("#{seed}\0#{id}"), id] }
|
|
332
356
|
end
|
|
333
357
|
|
|
334
|
-
def
|
|
358
|
+
def selected_ids_for(mode, sentinels, base, neighbors)
|
|
359
|
+
case mode
|
|
360
|
+
when 'sentinels' then sentinels
|
|
361
|
+
when 'affected' then ordered(base + neighbors)
|
|
362
|
+
when 'all' then cases.map { |item| item.fetch('id') }
|
|
363
|
+
else error!("unknown selection mode: #{mode}")
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def explanation(selection_mode, details)
|
|
335
368
|
{
|
|
336
|
-
'profile_rule' =>
|
|
369
|
+
'profile_rule' => {
|
|
370
|
+
'sentinels' => 'mandatory sentinels only',
|
|
371
|
+
'affected' => 'sentinels + direct + neighbors',
|
|
372
|
+
'all' => 'all admitted cases in canonical corpus order'
|
|
373
|
+
}.fetch(selection_mode),
|
|
337
374
|
'changed_paths' => details[:inferred].map { |path, caps| "#{path} => #{caps.join(',')}" },
|
|
338
375
|
'direct_cases' => details[:direct],
|
|
339
376
|
'sentinels' => details[:sentinels],
|
|
@@ -373,9 +410,102 @@ module Ast
|
|
|
373
410
|
end
|
|
374
411
|
end
|
|
375
412
|
|
|
413
|
+
# Owns one long-lived benchmark adapter subprocess and its JSONL exchange.
|
|
414
|
+
class BenchmarkAdapterSession
|
|
415
|
+
attr_reader :spawn_duration_ns
|
|
416
|
+
|
|
417
|
+
def initialize(driver_path:, chdir:, timeout:, env: {})
|
|
418
|
+
@driver_path = Pathname(driver_path).expand_path
|
|
419
|
+
@chdir = Pathname(chdir).expand_path
|
|
420
|
+
@timeout = timeout
|
|
421
|
+
@env = env
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def start
|
|
425
|
+
return self if @process
|
|
426
|
+
|
|
427
|
+
started = monotonic_nanoseconds
|
|
428
|
+
@stdin, @stdout, @stderr, @process = Open3.popen3(
|
|
429
|
+
@env,
|
|
430
|
+
@driver_path.to_s,
|
|
431
|
+
'benchmark-provider-session',
|
|
432
|
+
chdir: @chdir.to_s
|
|
433
|
+
)
|
|
434
|
+
[@stdin, @stdout, @stderr].each(&:binmode)
|
|
435
|
+
@stderr_reader = Thread.new { @stderr.read }
|
|
436
|
+
@spawn_duration_ns = monotonic_nanoseconds - started
|
|
437
|
+
@request_count = 0
|
|
438
|
+
self
|
|
439
|
+
rescue Errno::ENOENT => e
|
|
440
|
+
raise LocalBenchmark::Error, "cannot start benchmark adapter session: #{e.message}"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def request(payload)
|
|
444
|
+
start
|
|
445
|
+
started = monotonic_nanoseconds
|
|
446
|
+
@stdin.puts(JSON.generate(payload))
|
|
447
|
+
@stdin.flush
|
|
448
|
+
line = read_response_line
|
|
449
|
+
response = JSON.parse(line)
|
|
450
|
+
@request_count += 1
|
|
451
|
+
response.merge(
|
|
452
|
+
'round_trip_duration_ns' => monotonic_nanoseconds - started,
|
|
453
|
+
'session_request_index' => @request_count
|
|
454
|
+
)
|
|
455
|
+
rescue Errno::EPIPE, IOError, JSON::ParserError => e
|
|
456
|
+
raise LocalBenchmark::Error, "benchmark adapter session failed: #{e.message}#{stderr_suffix}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def close
|
|
460
|
+
return unless @process
|
|
461
|
+
|
|
462
|
+
@stdin.close unless @stdin.closed?
|
|
463
|
+
terminate unless @process.join(1)
|
|
464
|
+
@stderr_reader.join(1)
|
|
465
|
+
ensure
|
|
466
|
+
[@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
|
|
467
|
+
@process = nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
private
|
|
471
|
+
|
|
472
|
+
def read_response_line
|
|
473
|
+
reader = Thread.new { @stdout.gets }
|
|
474
|
+
unless reader.join(@timeout)
|
|
475
|
+
terminate
|
|
476
|
+
raise LocalBenchmark::Error, "benchmark adapter session timed out after #{@timeout}s#{stderr_suffix}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
reader.value || raise(LocalBenchmark::Error, "benchmark adapter session closed#{stderr_suffix}")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def terminate
|
|
483
|
+
Process.kill('TERM', @process.pid)
|
|
484
|
+
return if @process.join(1)
|
|
485
|
+
|
|
486
|
+
Process.kill('KILL', @process.pid)
|
|
487
|
+
@process.join
|
|
488
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
489
|
+
@process.join
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def stderr_suffix
|
|
493
|
+
return '' unless @stderr_reader&.join(0)
|
|
494
|
+
|
|
495
|
+
stderr = @stderr_reader.value
|
|
496
|
+
stderr.empty? ? '' : ": #{stderr}"
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def monotonic_nanoseconds
|
|
500
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
376
504
|
# Executes the same authored merge bytes through Git and the installed driver.
|
|
377
505
|
class LocalBenchmarkRunner
|
|
378
506
|
DEFAULT_TIMEOUT = 30
|
|
507
|
+
MAX_WORKERS = 32
|
|
508
|
+
ADAPTER_DESCRIPTOR_SCHEMA = 'structuredmerge.benchmark.adapter-descriptor/v1'
|
|
379
509
|
MERGIRAF_EXTENSIONS = {
|
|
380
510
|
'bash' => 'sh',
|
|
381
511
|
'html' => 'html',
|
|
@@ -387,22 +517,37 @@ module Ast
|
|
|
387
517
|
'yaml' => 'yaml'
|
|
388
518
|
}.freeze
|
|
389
519
|
|
|
390
|
-
|
|
520
|
+
# rubocop:disable Metrics/ParameterLists -- runner dependencies and resource limits remain explicit
|
|
521
|
+
def initialize(benchmark:, driver_path:, tmp_root:, timeout: DEFAULT_TIMEOUT, competitor_paths: {}, workers: 1,
|
|
522
|
+
adapter_descriptor: nil)
|
|
391
523
|
@benchmark = benchmark
|
|
392
524
|
@driver_path = Pathname(driver_path).expand_path
|
|
393
525
|
@tmp_root = Pathname(tmp_root).expand_path
|
|
394
526
|
@timeout = Integer(timeout)
|
|
395
527
|
@competitor_paths = competitor_paths.transform_values { |path| Pathname(path).expand_path }
|
|
528
|
+
@workers = Integer(workers)
|
|
529
|
+
error!("workers must be between 1 and #{MAX_WORKERS}") unless @workers.between?(1, MAX_WORKERS)
|
|
530
|
+
@adapter_descriptor = load_adapter_descriptor(adapter_descriptor)
|
|
396
531
|
end
|
|
532
|
+
# rubocop:enable Metrics/ParameterLists
|
|
397
533
|
|
|
398
534
|
def run(profile:, changed_paths: [])
|
|
399
535
|
verify_environment!
|
|
400
536
|
selection = @benchmark.select(profile: profile, changed_paths: changed_paths)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
item = @benchmark.case_by_id(id)
|
|
404
|
-
execute_case(item) + execute_competitors(item)
|
|
537
|
+
if @competitor_paths.any? && selection.fetch('competitor_policy') != 'configured'
|
|
538
|
+
error!('competitor adapters require the competitive profile')
|
|
405
539
|
end
|
|
540
|
+
execution = {
|
|
541
|
+
'kind' => 'correctness',
|
|
542
|
+
'adapter_mode' => 'cold-process',
|
|
543
|
+
'workers' => @workers
|
|
544
|
+
}
|
|
545
|
+
manifest = run_manifest(selection, execution: execution)
|
|
546
|
+
original_cwd = Dir.pwd
|
|
547
|
+
results, worker_process_ids = execute_selected_cases(
|
|
548
|
+
selection.fetch('selected_case_ids'),
|
|
549
|
+
competitors: selection.fetch('competitor_policy') == 'configured'
|
|
550
|
+
)
|
|
406
551
|
raise LocalBenchmark::Error, 'runner changed its working directory' unless Dir.pwd == original_cwd
|
|
407
552
|
|
|
408
553
|
{
|
|
@@ -411,16 +556,143 @@ module Ast
|
|
|
411
556
|
'corpus_id' => @benchmark.document['id'],
|
|
412
557
|
'corpus_digest' => @benchmark.corpus_digest,
|
|
413
558
|
'selection' => selection,
|
|
414
|
-
'
|
|
559
|
+
'run_manifest' => manifest,
|
|
560
|
+
'cache_identity' => cache_identity(selection, manifest),
|
|
415
561
|
'competitors' => competitor_provenance,
|
|
562
|
+
'execution' => {
|
|
563
|
+
'workers_requested' => @workers,
|
|
564
|
+
'workers_used' => worker_process_ids.length,
|
|
565
|
+
'worker_process_ids' => worker_process_ids
|
|
566
|
+
},
|
|
416
567
|
'results' => results
|
|
417
568
|
}
|
|
418
569
|
ensure
|
|
419
570
|
Dir.chdir(original_cwd) if original_cwd && Dir.pwd != original_cwd
|
|
420
571
|
end
|
|
421
572
|
|
|
573
|
+
def performance(profile:, changed_paths: [], iterations: 1)
|
|
574
|
+
verify_environment!
|
|
575
|
+
count = Integer(iterations)
|
|
576
|
+
error!('iterations must be positive') unless count.positive?
|
|
577
|
+
|
|
578
|
+
selection = @benchmark.select(profile: profile, changed_paths: changed_paths)
|
|
579
|
+
execution = {
|
|
580
|
+
'kind' => 'performance',
|
|
581
|
+
'adapter_mode' => 'persistent-jsonl',
|
|
582
|
+
'iterations' => count,
|
|
583
|
+
'timing_components' => %w[spawn adapter_execution harness_overhead round_trip]
|
|
584
|
+
}
|
|
585
|
+
manifest = run_manifest(selection, execution: execution)
|
|
586
|
+
FileUtils.mkdir_p(@tmp_root)
|
|
587
|
+
session = BenchmarkAdapterSession.new(
|
|
588
|
+
driver_path: @driver_path,
|
|
589
|
+
chdir: @tmp_root,
|
|
590
|
+
timeout: @timeout,
|
|
591
|
+
env: oracle_free_env
|
|
592
|
+
).start
|
|
593
|
+
samples = selection.fetch('selected_case_ids').flat_map do |id|
|
|
594
|
+
item = @benchmark.case_by_id(id)
|
|
595
|
+
Array.new(count) do |index|
|
|
596
|
+
performance_sample(session, item, index + 1)
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
{
|
|
600
|
+
'schema_version' => 'structuredmerge.benchmark.performance-run/v1',
|
|
601
|
+
'kind' => 'performance_only',
|
|
602
|
+
'quality_classification_performed' => false,
|
|
603
|
+
'selection' => selection,
|
|
604
|
+
'run_manifest' => manifest,
|
|
605
|
+
'cache_identity' => cache_identity(selection, manifest),
|
|
606
|
+
'session' => {
|
|
607
|
+
'adapter_mode' => 'persistent-jsonl',
|
|
608
|
+
'spawn_duration_ns' => session.spawn_duration_ns,
|
|
609
|
+
'process_ids' => samples.map { |sample| sample['process_id'] }.uniq
|
|
610
|
+
},
|
|
611
|
+
'timing' => performance_timing(samples, session.spawn_duration_ns),
|
|
612
|
+
'samples' => samples
|
|
613
|
+
}
|
|
614
|
+
ensure
|
|
615
|
+
session&.close
|
|
616
|
+
end
|
|
617
|
+
|
|
422
618
|
private
|
|
423
619
|
|
|
620
|
+
def execute_selected_cases(case_ids, competitors:)
|
|
621
|
+
return [[], []] if case_ids.empty?
|
|
622
|
+
if @workers == 1
|
|
623
|
+
return [execute_case_group(case_ids.each_with_index.to_a, competitors: competitors), [Process.pid]]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
error!('parallel benchmark workers require Process.fork') unless Process.respond_to?(:fork)
|
|
627
|
+
|
|
628
|
+
groups = Array.new([@workers, case_ids.length].min) { [] }
|
|
629
|
+
case_ids.each_with_index { |id, index| groups[index % groups.length] << [id, index] }
|
|
630
|
+
execute_case_groups(groups, competitors: competitors)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def execute_case_groups(groups, competitors:)
|
|
634
|
+
root = @tmp_root.join("workers-#{Process.pid}-#{monotonic_nanoseconds}")
|
|
635
|
+
FileUtils.mkdir_p(root)
|
|
636
|
+
workers = groups.each_with_index.map do |group, index|
|
|
637
|
+
result_path = root.join("worker-#{index}.marshal")
|
|
638
|
+
pid = fork_case_group(group, result_path, competitors: competitors)
|
|
639
|
+
{ pid: pid, result_path: result_path }
|
|
640
|
+
end
|
|
641
|
+
statuses = workers.to_h do |worker|
|
|
642
|
+
pid, status = Process.wait2(worker.fetch(:pid))
|
|
643
|
+
[pid, status]
|
|
644
|
+
end
|
|
645
|
+
payloads = workers.map do |worker|
|
|
646
|
+
result_path = worker.fetch(:result_path)
|
|
647
|
+
error!("benchmark worker #{worker.fetch(:pid)} produced no result") unless result_path.file?
|
|
648
|
+
|
|
649
|
+
# The parent reads only a result written by its own forked child in a private run directory.
|
|
650
|
+
Marshal.load(result_path.binread) # rubocop:disable Security/MarshalLoad
|
|
651
|
+
end
|
|
652
|
+
failed = workers.find { |worker| !statuses.fetch(worker.fetch(:pid)).success? }
|
|
653
|
+
if failed
|
|
654
|
+
payload = payloads.fetch(workers.index(failed))
|
|
655
|
+
details = payload[:error]&.first(2)&.join(': ') || statuses.fetch(failed.fetch(:pid)).inspect
|
|
656
|
+
error!("benchmark worker #{failed.fetch(:pid)} failed: #{details}")
|
|
657
|
+
end
|
|
658
|
+
entries = payloads.flat_map { |payload| payload.fetch(:entries) }
|
|
659
|
+
[entries.sort_by(&:first).flat_map(&:last), payloads.map { |payload| payload.fetch(:process_id) }]
|
|
660
|
+
ensure
|
|
661
|
+
FileUtils.rm_rf(root) if root
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def fork_case_group(group, result_path, competitors:)
|
|
665
|
+
fork do
|
|
666
|
+
payload = {
|
|
667
|
+
process_id: Process.pid,
|
|
668
|
+
entries: execute_case_group_entries(group, competitors: competitors)
|
|
669
|
+
}
|
|
670
|
+
result_path.binwrite(Marshal.dump(payload))
|
|
671
|
+
exit! 0
|
|
672
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- worker must serialize all failures
|
|
673
|
+
result_path.binwrite(
|
|
674
|
+
Marshal.dump(process_id: Process.pid, entries: [], error: [e.class.name, e.message, e.backtrace])
|
|
675
|
+
)
|
|
676
|
+
exit! 1
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def execute_case_group(indexed_case_ids, competitors:)
|
|
681
|
+
execute_case_group_entries(indexed_case_ids, competitors: competitors).sort_by(&:first).flat_map(&:last)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def execute_case_group_entries(indexed_case_ids, competitors:)
|
|
685
|
+
indexed_case_ids.map do |id, index|
|
|
686
|
+
item = @benchmark.case_by_id(id)
|
|
687
|
+
competitor_results = competitors ? execute_competitors(item) : []
|
|
688
|
+
[index, execute_case(item) + competitor_results]
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def monotonic_nanoseconds
|
|
693
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
694
|
+
end
|
|
695
|
+
|
|
424
696
|
def verify_environment!
|
|
425
697
|
error!("missing installed driver: #{@driver_path}") unless @driver_path.file? && @driver_path.executable?
|
|
426
698
|
@competitor_paths.each do |id, path|
|
|
@@ -433,12 +705,62 @@ module Ast
|
|
|
433
705
|
end
|
|
434
706
|
|
|
435
707
|
def execute_case(item)
|
|
708
|
+
return unsupported_candidate(item) unless candidate_supports?(item)
|
|
709
|
+
return execute_merge2_case(item) if item['operation'] == 'merge2'
|
|
436
710
|
return execute_merge3_case(item) if item['operation'] == 'merge3'
|
|
437
711
|
return execute_metamorphic_case(item) if item['operation'] == 'metamorphic'
|
|
438
712
|
|
|
439
713
|
unsupported_pair(item)
|
|
440
714
|
end
|
|
441
715
|
|
|
716
|
+
def unsupported_candidate(item)
|
|
717
|
+
[execute_baseline_for(item), raw_result(
|
|
718
|
+
item,
|
|
719
|
+
'structuredmerge.unsupported',
|
|
720
|
+
nil,
|
|
721
|
+
outcome: 'unsupported',
|
|
722
|
+
unsupported_reason: "candidate does not support #{item['operation']}/#{item['family']}/#{item['dialect']}"
|
|
723
|
+
)]
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def execute_baseline_for(item)
|
|
727
|
+
workspace = @tmp_root.join("#{safe_id(item['id'])}-unsupported-#{Process.pid}")
|
|
728
|
+
FileUtils.rm_rf(workspace)
|
|
729
|
+
FileUtils.mkdir_p(workspace)
|
|
730
|
+
if item['operation'] == 'merge2'
|
|
731
|
+
write_merge2_roles(item, workspace)
|
|
732
|
+
execute_merge2_baseline(item, workspace)
|
|
733
|
+
elsif item['operation'] == 'metamorphic'
|
|
734
|
+
write_metamorphic_roles(item, workspace)
|
|
735
|
+
execute_metamorphic_baseline(item, workspace)
|
|
736
|
+
else
|
|
737
|
+
write_roles(item, workspace)
|
|
738
|
+
execute_baseline(item, workspace)
|
|
739
|
+
end
|
|
740
|
+
ensure
|
|
741
|
+
FileUtils.rm_rf(workspace) if workspace
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def candidate_supports?(item)
|
|
745
|
+
# The default candidate is the Ruby golden master. Cross-runtime
|
|
746
|
+
# corpus cases must opt in through an adapter descriptor rather than
|
|
747
|
+
# being executed and reported as Ruby failures.
|
|
748
|
+
return item.fetch('selector').fetch('provider_id').start_with?('ruby.') unless @adapter_descriptor
|
|
749
|
+
|
|
750
|
+
support = @adapter_descriptor.fetch('supports')
|
|
751
|
+
if support['combinations']
|
|
752
|
+
return support.fetch('combinations').any? do |combination|
|
|
753
|
+
combination.fetch('family') == item.fetch('family') &&
|
|
754
|
+
combination.fetch('dialects').include?(item.fetch('dialect')) &&
|
|
755
|
+
combination.fetch('operations').include?(item.fetch('operation'))
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
support.fetch('operations').include?(item.fetch('operation')) &&
|
|
760
|
+
support.fetch('families').include?(item.fetch('family')) &&
|
|
761
|
+
support.fetch('dialects').include?(item.fetch('dialect'))
|
|
762
|
+
end
|
|
763
|
+
|
|
442
764
|
def execute_competitors(item)
|
|
443
765
|
@competitor_paths.map do |id, path|
|
|
444
766
|
metadata = @benchmark.document.dig('competitors', id)
|
|
@@ -452,10 +774,37 @@ module Ast
|
|
|
452
774
|
)
|
|
453
775
|
end
|
|
454
776
|
|
|
455
|
-
|
|
777
|
+
execute_competitor(item, id, path)
|
|
456
778
|
end
|
|
457
779
|
end
|
|
458
780
|
|
|
781
|
+
def execute_competitor(item, id, path)
|
|
782
|
+
return execute_mergiraf(item, path) if id == 'mergiraf'
|
|
783
|
+
return execute_git_competitor(item, path) if id == 'git'
|
|
784
|
+
|
|
785
|
+
error!("unsupported competitor adapter: #{id}")
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def execute_git_competitor(item, path)
|
|
789
|
+
workspace = @tmp_root.join("#{safe_id(item['id'])}-git-#{Process.pid}")
|
|
790
|
+
FileUtils.rm_rf(workspace)
|
|
791
|
+
FileUtils.mkdir_p(workspace)
|
|
792
|
+
write_roles(item, workspace)
|
|
793
|
+
capture = timed_capture(
|
|
794
|
+
oracle_free_env,
|
|
795
|
+
path.to_s,
|
|
796
|
+
'merge-file',
|
|
797
|
+
'-p',
|
|
798
|
+
'ours',
|
|
799
|
+
'base',
|
|
800
|
+
'theirs',
|
|
801
|
+
chdir: workspace
|
|
802
|
+
)
|
|
803
|
+
raw_result(item, 'git', capture)
|
|
804
|
+
ensure
|
|
805
|
+
FileUtils.rm_rf(workspace) if workspace
|
|
806
|
+
end
|
|
807
|
+
|
|
459
808
|
def execute_mergiraf(item, path)
|
|
460
809
|
workspace = @tmp_root.join("#{safe_id(item['id'])}-mergiraf-#{Process.pid}")
|
|
461
810
|
FileUtils.rm_rf(workspace)
|
|
@@ -496,6 +845,20 @@ module Ast
|
|
|
496
845
|
FileUtils.rm_rf(workspace) if workspace
|
|
497
846
|
end
|
|
498
847
|
|
|
848
|
+
def execute_merge2_case(item)
|
|
849
|
+
workspace = @tmp_root.join("#{safe_id(item['id'])}-#{Process.pid}")
|
|
850
|
+
FileUtils.rm_rf(workspace)
|
|
851
|
+
FileUtils.mkdir_p(workspace)
|
|
852
|
+
write_merge2_roles(item, workspace)
|
|
853
|
+
baseline = execute_merge2_baseline(item, workspace)
|
|
854
|
+
candidate = execute_merge2_candidate(item, workspace)
|
|
855
|
+
rerun = execute_merge2_candidate(item, workspace)
|
|
856
|
+
candidate['deterministic_correctness_rerun'] = correctness_record(candidate) == correctness_record(rerun)
|
|
857
|
+
[baseline, candidate]
|
|
858
|
+
ensure
|
|
859
|
+
FileUtils.rm_rf(workspace) if workspace
|
|
860
|
+
end
|
|
861
|
+
|
|
499
862
|
def execute_metamorphic_case(item)
|
|
500
863
|
workspace = @tmp_root.join("#{safe_id(item['id'])}-#{Process.pid}")
|
|
501
864
|
FileUtils.rm_rf(workspace)
|
|
@@ -542,6 +905,56 @@ module Ast
|
|
|
542
905
|
end
|
|
543
906
|
end
|
|
544
907
|
|
|
908
|
+
def write_merge2_roles(item, workspace)
|
|
909
|
+
%w[incoming current].each do |role|
|
|
910
|
+
workspace.join(role).binwrite(item.dig('inputs', role, 'bytes'))
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def execute_merge2_baseline(item, workspace)
|
|
915
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
916
|
+
output = workspace.join('incoming').binread
|
|
917
|
+
capture = {
|
|
918
|
+
stdout: '',
|
|
919
|
+
stderr: '',
|
|
920
|
+
status: 0,
|
|
921
|
+
output: output,
|
|
922
|
+
duration_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started
|
|
923
|
+
}
|
|
924
|
+
raw_result(item, 'template.overwrite', capture)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def execute_merge2_candidate(item, workspace)
|
|
928
|
+
selector = item.fetch('selector')
|
|
929
|
+
capture = timed_capture(
|
|
930
|
+
candidate_env(selector),
|
|
931
|
+
@driver_path.to_s,
|
|
932
|
+
'benchmark-provider-merge2',
|
|
933
|
+
'incoming',
|
|
934
|
+
'current',
|
|
935
|
+
"#{item['id']}.#{item['dialect']}",
|
|
936
|
+
chdir: workspace
|
|
937
|
+
)
|
|
938
|
+
result = capture[:stdout].empty? ? {} : JSON.parse(capture[:stdout])
|
|
939
|
+
capture[:stderr] = provider_diagnostics(result, capture[:stderr])
|
|
940
|
+
capture[:output] = capture[:status].zero? ? result.fetch('output') : ''
|
|
941
|
+
raw_result(item, 'ast-merge-provider.merge2', capture)
|
|
942
|
+
rescue JSON::ParserError, KeyError => e
|
|
943
|
+
capture ||= { stdout: '', stderr: '', status: 2, duration_ns: 0 }
|
|
944
|
+
capture[:stderr] = [capture[:stderr], e.message].reject(&:empty?).join("\n")
|
|
945
|
+
capture[:status] = 2
|
|
946
|
+
capture[:output] = ''
|
|
947
|
+
raw_result(item, 'ast-merge-provider.merge2', capture)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def provider_diagnostics(result, stderr)
|
|
951
|
+
diagnostics = Array(result['diagnostics']).filter_map do |diagnostic|
|
|
952
|
+
message = diagnostic['message'] || diagnostic[:message]
|
|
953
|
+
message && "#{diagnostic['category'] || diagnostic[:category]}: #{message}"
|
|
954
|
+
end
|
|
955
|
+
[stderr, *diagnostics].reject(&:empty?).join("\n")
|
|
956
|
+
end
|
|
957
|
+
|
|
545
958
|
def write_metamorphic_roles(item, workspace)
|
|
546
959
|
%w[source transformed].each do |role|
|
|
547
960
|
workspace.join(role).binwrite(item.dig('inputs', role, 'bytes'))
|
|
@@ -632,7 +1045,12 @@ module Ast
|
|
|
632
1045
|
output = capture&.fetch(:output, '') || ''
|
|
633
1046
|
checks = equivalence_checks(item, output)
|
|
634
1047
|
markers = conflict_regions(output)
|
|
635
|
-
classified = outcome || classify(
|
|
1048
|
+
classified = outcome || classify(
|
|
1049
|
+
item,
|
|
1050
|
+
capture.fetch(:status),
|
|
1051
|
+
checks,
|
|
1052
|
+
stderr: capture.fetch(:stderr, '')
|
|
1053
|
+
)
|
|
636
1054
|
eligible = item.dig('oracle', 'score_eligible') && !%w[unsupported excluded_ambiguous].include?(classified)
|
|
637
1055
|
{
|
|
638
1056
|
'schema_version' => 'structuredmerge.benchmark.result/v1',
|
|
@@ -664,8 +1082,15 @@ module Ast
|
|
|
664
1082
|
end
|
|
665
1083
|
|
|
666
1084
|
def adapter_role(adapter)
|
|
667
|
-
return 'baseline' if adapter.start_with?('git.')
|
|
668
|
-
|
|
1085
|
+
return 'baseline' if adapter.start_with?('git.') || adapter == 'template.overwrite'
|
|
1086
|
+
|
|
1087
|
+
candidates = %w[
|
|
1088
|
+
ast-merge-git
|
|
1089
|
+
ast-merge-provider.diff2
|
|
1090
|
+
ast-merge-provider.merge2
|
|
1091
|
+
structuredmerge.unsupported
|
|
1092
|
+
]
|
|
1093
|
+
return 'candidate' if candidates.include?(adapter)
|
|
669
1094
|
|
|
670
1095
|
'competitor'
|
|
671
1096
|
end
|
|
@@ -792,18 +1217,29 @@ module Ast
|
|
|
792
1217
|
'analyzed'
|
|
793
1218
|
end
|
|
794
1219
|
|
|
795
|
-
def classify(item, status, checks)
|
|
1220
|
+
def classify(item, status, checks, stderr: '')
|
|
796
1221
|
expected = item.dig('expected', 'outcome')
|
|
797
1222
|
return 'excluded_ambiguous' if expected == 'excluded_ambiguous'
|
|
1223
|
+
|
|
1224
|
+
if expected == 'error'
|
|
1225
|
+
return 'error' if status.nil?
|
|
1226
|
+
|
|
1227
|
+
return expected_error_diagnostic?(status, stderr) ? 'correct_clean' : 'error' if status >= 2
|
|
1228
|
+
|
|
1229
|
+
return status == 1 ? 'false_conflict' : 'false_auto_merge'
|
|
1230
|
+
end
|
|
798
1231
|
return 'error' if status.nil? || status >= 2
|
|
799
|
-
return status == 1 ? 'false_conflict' : 'false_auto_merge' if expected == 'error'
|
|
800
1232
|
return status == 1 ? 'true_conflict' : 'false_auto_merge' if expected == 'conflict'
|
|
801
1233
|
return 'false_conflict' if status == 1
|
|
802
|
-
return 'correct_clean' if checks['
|
|
1234
|
+
return 'correct_clean' if checks['equivalence_acceptable']
|
|
803
1235
|
|
|
804
1236
|
'false_auto_merge'
|
|
805
1237
|
end
|
|
806
1238
|
|
|
1239
|
+
def expected_error_diagnostic?(status, stderr)
|
|
1240
|
+
status >= 2 && /\A[^:\r\n]+: (?:destination_)?parse_error:/i.match?(stderr.to_s)
|
|
1241
|
+
end
|
|
1242
|
+
|
|
807
1243
|
def equivalence_checks(item, output)
|
|
808
1244
|
expected = item.dig('expected', 'output', 'bytes')
|
|
809
1245
|
exact = !expected.nil? && output == expected
|
|
@@ -825,8 +1261,20 @@ module Ast
|
|
|
825
1261
|
'accepted_equivalence' => selected&.fetch('class'),
|
|
826
1262
|
'parse_validity_only_accepted' => false
|
|
827
1263
|
}
|
|
828
|
-
|
|
829
|
-
|
|
1264
|
+
preservation = preservation_evaluations(item, checks, output)
|
|
1265
|
+
violations = preservation.filter_map { |name, status| name if status == 'fail' }
|
|
1266
|
+
unverified = preservation.filter_map { |name, status| name if status == 'unverified' }
|
|
1267
|
+
equivalence_acceptable = !selected.nil?
|
|
1268
|
+
preservation_acceptable = violations.empty? && unverified.empty?
|
|
1269
|
+
checks.merge(
|
|
1270
|
+
'agreement' => agreement(expected, output),
|
|
1271
|
+
'preservation_evaluations' => preservation,
|
|
1272
|
+
'preservation_violations' => violations,
|
|
1273
|
+
'preservation_unverified' => unverified,
|
|
1274
|
+
'equivalence_acceptable' => equivalence_acceptable,
|
|
1275
|
+
'preservation_acceptable' => preservation_acceptable,
|
|
1276
|
+
'acceptable' => equivalence_acceptable && preservation_acceptable
|
|
1277
|
+
)
|
|
830
1278
|
end
|
|
831
1279
|
|
|
832
1280
|
def structural_equivalence(item, output, expected)
|
|
@@ -856,6 +1304,34 @@ module Ast
|
|
|
856
1304
|
policy['provider'].to_s == item.dig('selector', 'provider_id').to_s
|
|
857
1305
|
end
|
|
858
1306
|
|
|
1307
|
+
def agreement(expected, output)
|
|
1308
|
+
return nil unless expected
|
|
1309
|
+
|
|
1310
|
+
{
|
|
1311
|
+
'line' => sequence_agreement(expected.lines, output.lines),
|
|
1312
|
+
'token' => sequence_agreement(lexemes(expected), lexemes(output))
|
|
1313
|
+
}
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def lexemes(content)
|
|
1317
|
+
content.split(/(\s+)/).reject(&:empty?)
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
def sequence_agreement(expected, output)
|
|
1321
|
+
expected_counts = expected.tally
|
|
1322
|
+
output_counts = output.tally
|
|
1323
|
+
matched = expected_counts.sum do |value, count|
|
|
1324
|
+
[count, output_counts.fetch(value, 0)].min
|
|
1325
|
+
end
|
|
1326
|
+
denominator = [expected.length, output.length].max
|
|
1327
|
+
{
|
|
1328
|
+
'matched' => matched,
|
|
1329
|
+
'expected' => expected.length,
|
|
1330
|
+
'observed' => output.length,
|
|
1331
|
+
'percent' => denominator.zero? ? nil : (matched * 100.0 / denominator).round(2)
|
|
1332
|
+
}
|
|
1333
|
+
end
|
|
1334
|
+
|
|
859
1335
|
def dimensions(item, outcome, checks)
|
|
860
1336
|
eligible = item.dig('oracle', 'score_eligible')
|
|
861
1337
|
{
|
|
@@ -872,35 +1348,55 @@ module Ast
|
|
|
872
1348
|
'preservation' => {
|
|
873
1349
|
'eligible' => eligible,
|
|
874
1350
|
'requirements' => item['preservation_policy'],
|
|
875
|
-
'
|
|
1351
|
+
'agreement' => checks['agreement'],
|
|
1352
|
+
'violations' => checks.fetch('preservation_violations', []),
|
|
1353
|
+
'unverified' => checks.fetch('preservation_unverified', [])
|
|
876
1354
|
},
|
|
877
1355
|
'performance' => { 'quality_offset_allowed' => false }
|
|
878
1356
|
}
|
|
879
1357
|
end
|
|
880
1358
|
|
|
881
|
-
def
|
|
882
|
-
return [] if checks['exact']
|
|
883
|
-
|
|
1359
|
+
def preservation_evaluations(item, checks, output)
|
|
884
1360
|
expected = item.dig('expected', 'output', 'bytes')
|
|
885
|
-
return
|
|
1361
|
+
return {} unless expected
|
|
886
1362
|
|
|
887
|
-
item['preservation_policy'].
|
|
888
|
-
next unless requirement == 'required'
|
|
889
|
-
next
|
|
1363
|
+
item['preservation_policy'].to_h do |name, requirement|
|
|
1364
|
+
next [name, 'not_required'] unless requirement == 'required'
|
|
1365
|
+
next [name, 'pass'] if checks['exact']
|
|
890
1366
|
|
|
891
|
-
name
|
|
1367
|
+
[name, preservation_evaluation(name, output, expected, checks)]
|
|
892
1368
|
end
|
|
893
1369
|
end
|
|
894
1370
|
|
|
895
|
-
def
|
|
1371
|
+
def preservation_evaluation(name, output, expected, checks)
|
|
896
1372
|
case name
|
|
897
|
-
when 'encoding'
|
|
898
|
-
|
|
899
|
-
when '
|
|
900
|
-
|
|
1373
|
+
when 'encoding'
|
|
1374
|
+
encoding_signature(output) == encoding_signature(expected) ? 'pass' : 'fail'
|
|
1375
|
+
when 'line_endings'
|
|
1376
|
+
line_ending_style(output) == line_ending_style(expected) ? 'pass' : 'fail'
|
|
1377
|
+
when 'unknown_fields'
|
|
1378
|
+
checks['structural'] == true ? 'pass' : 'unverified'
|
|
1379
|
+
when 'formatting', 'source_regions'
|
|
1380
|
+
output == expected ? 'pass' : 'fail'
|
|
1381
|
+
else
|
|
1382
|
+
'unverified'
|
|
901
1383
|
end
|
|
902
1384
|
end
|
|
903
1385
|
|
|
1386
|
+
def encoding_signature(content)
|
|
1387
|
+
bytes = content.b
|
|
1388
|
+
bom = if bytes.start_with?("\xEF\xBB\xBF".b)
|
|
1389
|
+
'utf-8-bom'
|
|
1390
|
+
elsif bytes.start_with?("\xFF\xFE".b)
|
|
1391
|
+
'utf-16le-bom'
|
|
1392
|
+
elsif bytes.start_with?("\xFE\xFF".b)
|
|
1393
|
+
'utf-16be-bom'
|
|
1394
|
+
end
|
|
1395
|
+
return bom if bom
|
|
1396
|
+
|
|
1397
|
+
bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? ? 'utf-8' : 'binary'
|
|
1398
|
+
end
|
|
1399
|
+
|
|
904
1400
|
def line_ending_style(content)
|
|
905
1401
|
return 'crlf' if content.include?("\r\n")
|
|
906
1402
|
|
|
@@ -953,7 +1449,7 @@ module Ast
|
|
|
953
1449
|
lines = stderr.lines.map(&:strip).reject(&:empty?)
|
|
954
1450
|
lines << unsupported_reason if unsupported_reason
|
|
955
1451
|
lines.map do |line|
|
|
956
|
-
match = /\
|
|
1452
|
+
match = /\A(?:[^:\r\n]+:\s+)?([a-z_]+):/i.match(line)
|
|
957
1453
|
category = match&.[](1) || (unsupported_reason == line ? 'unsupported' : 'process')
|
|
958
1454
|
{ 'severity' => 'error', 'category' => category, 'message' => line }
|
|
959
1455
|
end
|
|
@@ -978,26 +1474,36 @@ module Ast
|
|
|
978
1474
|
ENV.keys.grep(/(?:ORACLE|EXPECTED)/i).to_h { |name| [name, nil] }
|
|
979
1475
|
end
|
|
980
1476
|
|
|
981
|
-
def
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
'
|
|
986
|
-
'
|
|
987
|
-
'
|
|
988
|
-
'
|
|
1477
|
+
def run_manifest(selection, execution: { 'kind' => 'correctness', 'adapter_mode' => 'cold-process' })
|
|
1478
|
+
configuration = benchmark_configuration(selection, execution: execution)
|
|
1479
|
+
manifest = {
|
|
1480
|
+
'schema_version' => 'structuredmerge.benchmark.run-manifest/v1',
|
|
1481
|
+
'adapter' => adapter_identity,
|
|
1482
|
+
'configuration' => configuration,
|
|
1483
|
+
'environment' => run_environment,
|
|
1484
|
+
'corpus' => {
|
|
1485
|
+
'id' => @benchmark.document.fetch('id'),
|
|
1486
|
+
'sha256' => @benchmark.corpus_digest
|
|
1487
|
+
}
|
|
989
1488
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1489
|
+
manifest.merge('sha256' => canonical_digest(manifest))
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
def cache_identity(selection, manifest = run_manifest(selection))
|
|
1493
|
+
configuration = manifest.fetch('configuration')
|
|
993
1494
|
competitors = competitor_provenance
|
|
994
1495
|
identity = {
|
|
995
|
-
'
|
|
996
|
-
'
|
|
997
|
-
'
|
|
1496
|
+
'adapter_id' => manifest.dig('adapter', 'adapter_id'),
|
|
1497
|
+
'adapter_source_sha' => manifest.dig('adapter', 'source', 'revision'),
|
|
1498
|
+
'adapter_artifact_sha256' => manifest.dig('adapter', 'artifact', 'sha256'),
|
|
1499
|
+
'configuration_sha256' => canonical_digest(configuration),
|
|
1500
|
+
'parser_providers' => configuration.fetch('cases').map do |item|
|
|
1501
|
+
item.fetch('parser_provider')
|
|
1502
|
+
end.uniq,
|
|
1503
|
+
'run_manifest_sha256' => manifest.fetch('sha256'),
|
|
998
1504
|
'competitors' => competitors,
|
|
999
1505
|
'corpus_sha256' => @benchmark.corpus_digest,
|
|
1000
|
-
'environment' => environment,
|
|
1506
|
+
'environment' => manifest.fetch('environment'),
|
|
1001
1507
|
'profile' => selection['profile'],
|
|
1002
1508
|
'changed_paths' => selection['changed_paths'],
|
|
1003
1509
|
'inferred_capabilities' => selection['inferred_capabilities'],
|
|
@@ -1009,12 +1515,279 @@ module Ast
|
|
|
1009
1515
|
identity.merge('sha256' => Digest::SHA256.hexdigest(JSON.generate(identity)))
|
|
1010
1516
|
end
|
|
1011
1517
|
|
|
1518
|
+
def benchmark_configuration(selection, execution:)
|
|
1519
|
+
provider = @adapter_descriptor&.fetch('provider')
|
|
1520
|
+
{
|
|
1521
|
+
'profile' => selection.fetch('profile'),
|
|
1522
|
+
'changed_paths' => selection.fetch('changed_paths'),
|
|
1523
|
+
'inferred_capabilities' => selection.fetch('inferred_capabilities'),
|
|
1524
|
+
'selected_case_ids' => selection.fetch('selected_case_ids'),
|
|
1525
|
+
'timeout_seconds' => @timeout,
|
|
1526
|
+
'network_policy' => @benchmark.document.fetch('network_policy'),
|
|
1527
|
+
'competitor_ids' => @competitor_paths.keys.sort,
|
|
1528
|
+
'execution' => execution,
|
|
1529
|
+
'cases' => selection.fetch('selected_case_ids').map do |id|
|
|
1530
|
+
item = @benchmark.case_by_id(id)
|
|
1531
|
+
selector = item.fetch('selector')
|
|
1532
|
+
backend_ref = TreeHaver::BackendRegistry.fetch(selector.fetch('backend')) unless provider
|
|
1533
|
+
case_configuration = {
|
|
1534
|
+
'case_id' => id,
|
|
1535
|
+
'operation' => item.fetch('operation'),
|
|
1536
|
+
'merge_provider' => adapter_merge_provider(item) || {
|
|
1537
|
+
'provider_id' => selector.fetch('provider_id'),
|
|
1538
|
+
'require_path' => selector.fetch('require')
|
|
1539
|
+
},
|
|
1540
|
+
'parser_provider' => provider&.fetch('parser_provider') || {
|
|
1541
|
+
'requested_backend_id' => selector.fetch('backend'),
|
|
1542
|
+
'backend_family' => backend_ref&.family,
|
|
1543
|
+
'selection_mode' => 'explicit'
|
|
1544
|
+
},
|
|
1545
|
+
'selector' => selector
|
|
1546
|
+
}
|
|
1547
|
+
case_configuration['selector_role'] = 'oracle_context' if provider
|
|
1548
|
+
case_configuration
|
|
1549
|
+
end
|
|
1550
|
+
}
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
def adapter_merge_provider(item)
|
|
1554
|
+
return unless @adapter_descriptor
|
|
1555
|
+
|
|
1556
|
+
provider = @adapter_descriptor.fetch('provider')
|
|
1557
|
+
provider.dig('merge_providers', item.fetch('family')) || provider['merge_provider']
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
def performance_sample(session, item, iteration)
|
|
1561
|
+
request_id = "#{item.fetch('id')}.#{iteration}"
|
|
1562
|
+
response = session.request(benchmark_adapter_request(item, request_id))
|
|
1563
|
+
output = BenchmarkAdapter.decode_source(response.fetch('output_base64'))
|
|
1564
|
+
adapter_duration = response.fetch('duration_ns')
|
|
1565
|
+
round_trip_duration = response.fetch('round_trip_duration_ns')
|
|
1566
|
+
harness_overhead = [round_trip_duration - adapter_duration, 0].max
|
|
1567
|
+
measurement_class = if response.fetch('session_request_index') == 1
|
|
1568
|
+
'session_startup_and_first_request'
|
|
1569
|
+
else
|
|
1570
|
+
'warm_persistent_process'
|
|
1571
|
+
end
|
|
1572
|
+
{
|
|
1573
|
+
'case_id' => item.fetch('id'),
|
|
1574
|
+
'request_id' => request_id,
|
|
1575
|
+
'iteration' => iteration,
|
|
1576
|
+
'operation' => response['operation'],
|
|
1577
|
+
'process_id' => response.fetch('process_id'),
|
|
1578
|
+
'status' => response.fetch('status'),
|
|
1579
|
+
'result' => response.fetch('result'),
|
|
1580
|
+
'diagnostics' => diagnostics(response.fetch('stderr'), nil),
|
|
1581
|
+
'output' => raw_record(output),
|
|
1582
|
+
'runtime' => {
|
|
1583
|
+
'adapter_duration_ns' => adapter_duration,
|
|
1584
|
+
'harness_overhead_ns' => harness_overhead,
|
|
1585
|
+
'round_trip_duration_ns' => round_trip_duration,
|
|
1586
|
+
'measurement_class' => measurement_class
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
rescue ArgumentError => e
|
|
1590
|
+
error!("invalid adapter response for #{request_id}: #{e.message}")
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
def performance_timing(samples, spawn_duration)
|
|
1594
|
+
runtimes = samples.map { |sample| sample.fetch('runtime') }
|
|
1595
|
+
{
|
|
1596
|
+
'spawn_duration_ns' => spawn_duration,
|
|
1597
|
+
'adapter_execution_total_ns' => runtimes.sum { |runtime| runtime.fetch('adapter_duration_ns') },
|
|
1598
|
+
'harness_overhead_total_ns' => runtimes.sum { |runtime| runtime.fetch('harness_overhead_ns') },
|
|
1599
|
+
'round_trip_total_ns' => runtimes.sum { |runtime| runtime.fetch('round_trip_duration_ns') },
|
|
1600
|
+
'first_request_includes_runtime_startup' => true,
|
|
1601
|
+
'quality_classification_uses_these_values' => false
|
|
1602
|
+
}
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
def benchmark_adapter_request(item, request_id)
|
|
1606
|
+
operation, roles = benchmark_adapter_operation(item)
|
|
1607
|
+
{
|
|
1608
|
+
'schema_version' => BenchmarkAdapter::REQUEST_SCHEMA,
|
|
1609
|
+
'request_id' => request_id,
|
|
1610
|
+
'operation' => operation,
|
|
1611
|
+
'selector' => item.fetch('selector'),
|
|
1612
|
+
'path_name' => "#{item.fetch('id')}.#{item.fetch('dialect')}",
|
|
1613
|
+
'sources' => roles.to_h do |adapter_role, corpus_role|
|
|
1614
|
+
[adapter_role, BenchmarkAdapter.encode_source(item.dig('inputs', corpus_role, 'bytes'))]
|
|
1615
|
+
end
|
|
1616
|
+
}
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1619
|
+
def benchmark_adapter_operation(item)
|
|
1620
|
+
case item.fetch('operation')
|
|
1621
|
+
when 'merge2' then ['merge2', { 'incoming' => 'incoming', 'current' => 'current' }]
|
|
1622
|
+
when 'merge3' then ['merge3', { 'base' => 'base', 'ours' => 'ours', 'theirs' => 'theirs' }]
|
|
1623
|
+
when 'metamorphic' then ['diff2', { 'before' => 'source', 'after' => 'transformed' }]
|
|
1624
|
+
when 'diff' then ['diff2', { 'before' => 'before', 'after' => 'after' }]
|
|
1625
|
+
else error!("unsupported benchmark adapter operation: #{item.fetch('operation')}")
|
|
1626
|
+
end
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
def source_provenance(root: Pathname(__dir__).join('..', '..', '..', '..').expand_path,
|
|
1630
|
+
repository: 'structuredmerge/structuredmerge-ruby', label: 'Ruby golden-master')
|
|
1631
|
+
revision, revision_status = Open3.capture2('git', '-C', root.to_s, 'rev-parse', 'HEAD')
|
|
1632
|
+
error!("cannot determine #{label} source revision") unless revision_status.success?
|
|
1633
|
+
|
|
1634
|
+
status, status_result = Open3.capture2('git', '-C', root.to_s, 'status', '--porcelain',
|
|
1635
|
+
'--untracked-files=no')
|
|
1636
|
+
error!("cannot determine #{label} source state") unless status_result.success?
|
|
1637
|
+
|
|
1638
|
+
{
|
|
1639
|
+
'repository' => repository,
|
|
1640
|
+
'revision' => revision.strip,
|
|
1641
|
+
'dirty' => !status.empty?
|
|
1642
|
+
}
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
def adapter_identity
|
|
1646
|
+
artifact = {
|
|
1647
|
+
'path' => @driver_path.to_s,
|
|
1648
|
+
'sha256' => Digest::SHA256.file(@driver_path).hexdigest
|
|
1649
|
+
}
|
|
1650
|
+
unless @adapter_descriptor
|
|
1651
|
+
return {
|
|
1652
|
+
'adapter_id' => 'ruby-gm.ast-merge-git',
|
|
1653
|
+
'implementation' => 'ruby-golden-master',
|
|
1654
|
+
'package' => Ast::Merge::Git::PACKAGE_NAME,
|
|
1655
|
+
'package_version' => Ast::Merge::Git::VERSION,
|
|
1656
|
+
'source' => source_provenance,
|
|
1657
|
+
'artifact' => artifact
|
|
1658
|
+
}
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
declared = @adapter_descriptor.fetch('adapter')
|
|
1662
|
+
source = declared.fetch('source')
|
|
1663
|
+
declared.slice('adapter_id', 'implementation', 'package', 'package_version').merge(
|
|
1664
|
+
'source' => source_provenance(
|
|
1665
|
+
root: @adapter_descriptor.fetch('source_root'),
|
|
1666
|
+
repository: source.fetch('repository'),
|
|
1667
|
+
label: declared.fetch('adapter_id')
|
|
1668
|
+
),
|
|
1669
|
+
'artifact' => artifact
|
|
1670
|
+
)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def run_environment
|
|
1674
|
+
return benchmark_environment unless @adapter_descriptor
|
|
1675
|
+
|
|
1676
|
+
{
|
|
1677
|
+
'harness' => benchmark_environment,
|
|
1678
|
+
'adapter' => @adapter_descriptor.fetch('environment')
|
|
1679
|
+
}
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
def load_adapter_descriptor(path)
|
|
1683
|
+
return unless path
|
|
1684
|
+
|
|
1685
|
+
descriptor_path = Pathname(path).expand_path
|
|
1686
|
+
descriptor = JSON.parse(descriptor_path.binread)
|
|
1687
|
+
unless descriptor['schema_version'] == ADAPTER_DESCRIPTOR_SCHEMA
|
|
1688
|
+
error!('unsupported adapter descriptor schema')
|
|
1689
|
+
end
|
|
1690
|
+
%w[adapter source_root provider supports environment].each do |key|
|
|
1691
|
+
error!("adapter descriptor missing #{key}") unless descriptor.key?(key)
|
|
1692
|
+
end
|
|
1693
|
+
%w[adapter_id implementation package package_version source].each do |key|
|
|
1694
|
+
error!("adapter descriptor missing adapter.#{key}") unless descriptor.fetch('adapter').key?(key)
|
|
1695
|
+
end
|
|
1696
|
+
unless descriptor.dig('adapter', 'source', 'repository')
|
|
1697
|
+
error!('adapter descriptor missing adapter.source.repository')
|
|
1698
|
+
end
|
|
1699
|
+
provider = descriptor.fetch('provider')
|
|
1700
|
+
error!('adapter descriptor missing provider.parser_provider') unless provider.key?('parser_provider')
|
|
1701
|
+
unless provider.key?('merge_provider') || provider['merge_providers'].is_a?(Hash)
|
|
1702
|
+
error!('adapter descriptor requires provider.merge_provider or provider.merge_providers')
|
|
1703
|
+
end
|
|
1704
|
+
%w[operations families dialects].each do |key|
|
|
1705
|
+
values = descriptor.fetch('supports')[key]
|
|
1706
|
+
unless values.is_a?(Array) && values.any?
|
|
1707
|
+
error!("adapter descriptor supports.#{key} must be a non-empty array")
|
|
1708
|
+
end
|
|
1709
|
+
end
|
|
1710
|
+
validate_support_combinations!(descriptor.fetch('supports'))
|
|
1711
|
+
source_root = Pathname(descriptor.fetch('source_root'))
|
|
1712
|
+
source_root = descriptor_path.dirname.join(source_root) unless source_root.absolute?
|
|
1713
|
+
descriptor.merge('source_root' => source_root.expand_path)
|
|
1714
|
+
rescue JSON::ParserError => e
|
|
1715
|
+
error!("invalid adapter descriptor JSON: #{e.message}")
|
|
1716
|
+
rescue SystemCallError => e
|
|
1717
|
+
error!("cannot read adapter descriptor: #{e.message}")
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
def validate_support_combinations!(support)
|
|
1721
|
+
return unless support.key?('combinations')
|
|
1722
|
+
|
|
1723
|
+
combinations = support.fetch('combinations')
|
|
1724
|
+
unless combinations.is_a?(Array) && combinations.any?
|
|
1725
|
+
error!('adapter descriptor supports.combinations must be a non-empty array')
|
|
1726
|
+
end
|
|
1727
|
+
combinations.each do |combination|
|
|
1728
|
+
error!('adapter support combination requires family') unless combination['family'].is_a?(String)
|
|
1729
|
+
%w[operations dialects].each do |key|
|
|
1730
|
+
values = combination[key]
|
|
1731
|
+
unless values.is_a?(Array) && values.any?
|
|
1732
|
+
error!("adapter support combination #{key} must be a non-empty array")
|
|
1733
|
+
end
|
|
1734
|
+
end
|
|
1735
|
+
end
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
def benchmark_environment
|
|
1739
|
+
allowlisted = %w[
|
|
1740
|
+
BUNDLE_GEMFILE
|
|
1741
|
+
STRUCTUREDMERGE_DEV
|
|
1742
|
+
TREE_HAVER_BACKEND
|
|
1743
|
+
TREE_HAVER_NATIVE_BACKEND
|
|
1744
|
+
TREE_HAVER_RUBY_BACKEND
|
|
1745
|
+
TSLP_DEV
|
|
1746
|
+
].to_h { |name| [name, ENV[name]] }
|
|
1747
|
+
{
|
|
1748
|
+
'git' => git_environment,
|
|
1749
|
+
'ruby' => RUBY_DESCRIPTION,
|
|
1750
|
+
'ruby_engine' => defined?(RUBY_ENGINE) ? RUBY_ENGINE : 'ruby',
|
|
1751
|
+
'ruby_version' => RUBY_VERSION,
|
|
1752
|
+
'rubygems' => Gem::VERSION,
|
|
1753
|
+
'bundler' => Gem.loaded_specs['bundler']&.version&.to_s,
|
|
1754
|
+
'platform' => RUBY_PLATFORM,
|
|
1755
|
+
'host_os' => RbConfig::CONFIG['host_os'],
|
|
1756
|
+
'host_cpu' => RbConfig::CONFIG['host_cpu'],
|
|
1757
|
+
'allowlisted_env' => allowlisted
|
|
1758
|
+
}
|
|
1759
|
+
end
|
|
1760
|
+
|
|
1761
|
+
def git_environment
|
|
1762
|
+
executable, = Open3.capture2('sh', '-c', 'command -v git')
|
|
1763
|
+
version, status = Open3.capture2('git', '--version')
|
|
1764
|
+
return { 'available' => false } unless status.success?
|
|
1765
|
+
|
|
1766
|
+
path = Pathname(executable.strip)
|
|
1767
|
+
{
|
|
1768
|
+
'available' => true,
|
|
1769
|
+
'path' => path.to_s,
|
|
1770
|
+
'version' => version.strip,
|
|
1771
|
+
'sha256' => path.file? ? Digest::SHA256.file(path).hexdigest : nil
|
|
1772
|
+
}
|
|
1773
|
+
rescue SystemCallError
|
|
1774
|
+
{ 'available' => false }
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
def canonical_digest(value)
|
|
1778
|
+
Digest::SHA256.hexdigest(JSON.generate(deep_sort(value)))
|
|
1779
|
+
end
|
|
1780
|
+
|
|
1012
1781
|
def competitor_provenance
|
|
1013
1782
|
@competitor_provenance ||= @competitor_paths.to_h do |id, path|
|
|
1014
1783
|
metadata = @benchmark.document.fetch('competitors').fetch(id)
|
|
1015
1784
|
capture = timed_capture(oracle_free_env, path.to_s, '--version', chdir: path.dirname)
|
|
1016
1785
|
error!("#{id} version probe failed: #{capture[:stderr].strip}") unless capture[:status].zero?
|
|
1017
|
-
expected =
|
|
1786
|
+
expected = if id == 'git'
|
|
1787
|
+
"#{metadata.fetch('adapter_id')} version #{metadata.fetch('version')}"
|
|
1788
|
+
else
|
|
1789
|
+
"#{metadata.fetch('adapter_id')} #{metadata.fetch('version')}"
|
|
1790
|
+
end
|
|
1018
1791
|
error!("#{id} version differs: #{capture[:stdout].strip}") unless capture[:stdout].strip == expected
|
|
1019
1792
|
|
|
1020
1793
|
[id, metadata.merge(
|
|
@@ -1066,12 +1839,18 @@ module Ast
|
|
|
1066
1839
|
def build
|
|
1067
1840
|
pairs = @results.group_by { |result| result['case_id'] }.values.map { |items| transition(items) }
|
|
1068
1841
|
false_auto_merges = candidate_eligible.select { |item| item['outcome'] == 'false_auto_merge' }
|
|
1842
|
+
preservation_failures = candidate_eligible.select do |item|
|
|
1843
|
+
item.dig('dimensions', 'preservation', 'violations').any? ||
|
|
1844
|
+
item.dig('dimensions', 'preservation', 'unverified').any?
|
|
1845
|
+
end
|
|
1846
|
+
unexpected_errors = candidate_results.select { |item| item['outcome'] == 'error' }
|
|
1069
1847
|
{
|
|
1070
1848
|
'schema_version' => 'structuredmerge.benchmark.report/v1',
|
|
1071
1849
|
'kind' => 'paired_aggregate_report',
|
|
1072
1850
|
'corpus_id' => @run['corpus_id'],
|
|
1073
1851
|
'corpus_digest' => @run['corpus_digest'],
|
|
1074
1852
|
'selection' => @run['selection'],
|
|
1853
|
+
'run_manifest' => @run.fetch('run_manifest'),
|
|
1075
1854
|
'cache_identity' => @run['cache_identity'],
|
|
1076
1855
|
'dimensions' => {
|
|
1077
1856
|
'safety' => {
|
|
@@ -1082,15 +1861,20 @@ module Ast
|
|
|
1082
1861
|
},
|
|
1083
1862
|
'effectiveness' => outcome_counts,
|
|
1084
1863
|
'preservation' => {
|
|
1085
|
-
'eligible' =>
|
|
1086
|
-
'violation_result_ids' =>
|
|
1864
|
+
'eligible' => candidate_eligible.length,
|
|
1865
|
+
'violation_result_ids' => candidate_eligible.filter_map do |item|
|
|
1087
1866
|
item['id'] if item.dig('dimensions', 'preservation', 'violations').any?
|
|
1088
|
-
end
|
|
1867
|
+
end,
|
|
1868
|
+
'unverified_result_ids' => candidate_eligible.filter_map do |item|
|
|
1869
|
+
item['id'] if item.dig('dimensions', 'preservation', 'unverified').any?
|
|
1870
|
+
end,
|
|
1871
|
+
'gate' => preservation_failures.empty? ? 'pass' : 'fail'
|
|
1089
1872
|
},
|
|
1090
1873
|
'performance' => performance,
|
|
1091
|
-
'reliability' => {
|
|
1092
|
-
|
|
1093
|
-
|
|
1874
|
+
'reliability' => {
|
|
1875
|
+
'error_result_ids' => unexpected_errors.map { |item| item['id'] },
|
|
1876
|
+
'gate' => unexpected_errors.empty? ? 'pass' : 'fail'
|
|
1877
|
+
},
|
|
1094
1878
|
'coverage' => coverage,
|
|
1095
1879
|
'competitive' => competitive
|
|
1096
1880
|
},
|
|
@@ -1099,7 +1883,7 @@ module Ast
|
|
|
1099
1883
|
'newly_passing_case_ids' => pairs.filter_map { |pair| pair['case_id'] if pair['newly_passing'] },
|
|
1100
1884
|
'newly_failing_case_ids' => pairs.filter_map { |pair| pair['case_id'] if pair['newly_failing'] },
|
|
1101
1885
|
'changed_conflict_case_ids' => pairs.filter_map { |pair| pair['case_id'] if pair['changed_conflict'] },
|
|
1102
|
-
'hard_gate_failed' => false_auto_merges.any?,
|
|
1886
|
+
'hard_gate_failed' => false_auto_merges.any? || preservation_failures.any? || unexpected_errors.any?,
|
|
1103
1887
|
'scalar_score' => nil
|
|
1104
1888
|
}
|
|
1105
1889
|
end
|
|
@@ -1114,6 +1898,10 @@ module Ast
|
|
|
1114
1898
|
eligible.select { |item| item['adapter_role'] == 'candidate' }
|
|
1115
1899
|
end
|
|
1116
1900
|
|
|
1901
|
+
def candidate_results
|
|
1902
|
+
@results.select { |item| item['adapter_role'] == 'candidate' }
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1117
1905
|
def outcome_counts
|
|
1118
1906
|
%w[correct_clean false_conflict true_conflict false_auto_merge error unsupported
|
|
1119
1907
|
excluded_ambiguous].to_h do |outcome|
|
|
@@ -1130,8 +1918,10 @@ module Ast
|
|
|
1130
1918
|
'candidate_result_id' => candidate['id'],
|
|
1131
1919
|
'from' => baseline['outcome'],
|
|
1132
1920
|
'to' => candidate['outcome'],
|
|
1133
|
-
'newly_passing' => !
|
|
1134
|
-
|
|
1921
|
+
'newly_passing' => !unsupported?(candidate) &&
|
|
1922
|
+
!SUCCESS.include?(baseline['outcome']) && SUCCESS.include?(candidate['outcome']),
|
|
1923
|
+
'newly_failing' => !unsupported?(candidate) &&
|
|
1924
|
+
SUCCESS.include?(baseline['outcome']) && !SUCCESS.include?(candidate['outcome']),
|
|
1135
1925
|
'changed_conflict' => conflict?(baseline) && conflict?(candidate) &&
|
|
1136
1926
|
baseline.dig('raw', 'output', 'sha256') != candidate.dig('raw', 'output', 'sha256')
|
|
1137
1927
|
}
|
|
@@ -1141,6 +1931,10 @@ module Ast
|
|
|
1141
1931
|
%w[false_conflict true_conflict].include?(result['outcome'])
|
|
1142
1932
|
end
|
|
1143
1933
|
|
|
1934
|
+
def unsupported?(result)
|
|
1935
|
+
result['outcome'] == 'unsupported'
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1144
1938
|
def performance
|
|
1145
1939
|
@results.group_by { |item| item['adapter_id'] }.transform_values do |items|
|
|
1146
1940
|
values = items.filter_map { |item| item.dig('runtime', 'duration_ns') }
|
|
@@ -1160,9 +1954,24 @@ module Ast
|
|
|
1160
1954
|
|
|
1161
1955
|
def competitive
|
|
1162
1956
|
results = @results.select { |item| item['adapter_role'] == 'competitor' }
|
|
1957
|
+
adapters = results.group_by { |item| item['adapter_id'] }.transform_values do |items|
|
|
1958
|
+
unsupported = items.select { |item| item['outcome'] == 'unsupported' }
|
|
1959
|
+
supported = items - unsupported
|
|
1960
|
+
eligible = supported.count { |item| item['score_eligible'] }
|
|
1961
|
+
selected = items.map { |item| item['case_id'] }.uniq.length
|
|
1962
|
+
{
|
|
1963
|
+
'selected_cases' => selected,
|
|
1964
|
+
'supported_cases' => supported.length,
|
|
1965
|
+
'eligible_cases' => eligible,
|
|
1966
|
+
'coverage' => coverage_ratio(eligible, selected),
|
|
1967
|
+
'unsupported_case_ids' => unsupported.map { |item| item['case_id'] },
|
|
1968
|
+
'outcomes' => items.group_by { |item| item['outcome'] }.transform_values(&:length)
|
|
1969
|
+
}
|
|
1970
|
+
end
|
|
1163
1971
|
{
|
|
1164
1972
|
'configured' => @run.fetch('competitors'),
|
|
1165
1973
|
'outcomes' => results.group_by { |item| item['outcome'] }.transform_values(&:length),
|
|
1974
|
+
'adapters' => adapters,
|
|
1166
1975
|
'unsupported_case_ids' => results.filter_map do |item|
|
|
1167
1976
|
item['case_id'] if item['outcome'] == 'unsupported'
|
|
1168
1977
|
end,
|
|
@@ -1173,6 +1982,14 @@ module Ast
|
|
|
1173
1982
|
}
|
|
1174
1983
|
end
|
|
1175
1984
|
|
|
1985
|
+
def coverage_ratio(numerator, denominator)
|
|
1986
|
+
{
|
|
1987
|
+
'numerator' => numerator,
|
|
1988
|
+
'denominator' => denominator,
|
|
1989
|
+
'percent' => denominator.zero? ? nil : (numerator * 100.0 / denominator).round(2)
|
|
1990
|
+
}
|
|
1991
|
+
end
|
|
1992
|
+
|
|
1176
1993
|
def strata
|
|
1177
1994
|
case_results = @results.select { |item| item['adapter_role'] == 'candidate' }
|
|
1178
1995
|
tag_fields = %w[operation partition family provider dialect false_auto_merge_severity]
|