ast-merge-git 7.1.7 → 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 +32 -3
- data/lib/ast/merge/git/benchmark_adapter.rb +172 -0
- data/lib/ast/merge/git/local_benchmark.rb +733 -37
- 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
|
|
@@ -39,6 +40,12 @@ module Ast
|
|
|
39
40
|
'metamorphic' => %w[source transformed],
|
|
40
41
|
'diff' => %w[before after]
|
|
41
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
|
|
42
49
|
|
|
43
50
|
attr_reader :document, :corpus_digest, :path
|
|
44
51
|
|
|
@@ -88,14 +95,22 @@ module Ast
|
|
|
88
95
|
capabilities = inferred.values.flatten.uniq.sort
|
|
89
96
|
direct = cases.select { |item| direct_case?(item, capabilities) }.map { |item| item.fetch('id') }
|
|
90
97
|
sentinels = definition.fetch('mandatory_sentinels')
|
|
91
|
-
|
|
92
|
-
population = cases.map { |item| item.fetch('id') } -
|
|
93
|
-
neighbors =
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
96
109
|
|
|
97
110
|
{
|
|
98
111
|
'profile' => profile,
|
|
112
|
+
'selection_mode' => definition.fetch('selection_mode'),
|
|
113
|
+
'competitor_policy' => definition.fetch('competitor_policy'),
|
|
99
114
|
'seed' => document.dig('selection', 'seed'),
|
|
100
115
|
'selected_case_ids' => selected,
|
|
101
116
|
'excluded_case_ids' => cases.map { |item| item.fetch('id') } - selected,
|
|
@@ -120,7 +135,7 @@ module Ast
|
|
|
120
135
|
end,
|
|
121
136
|
'budgets' => definition.fetch('budgets'),
|
|
122
137
|
'explanation' => explanation(
|
|
123
|
-
|
|
138
|
+
definition.fetch('selection_mode'),
|
|
124
139
|
inferred: inferred,
|
|
125
140
|
direct: direct,
|
|
126
141
|
sentinels: sentinels,
|
|
@@ -137,10 +152,18 @@ module Ast
|
|
|
137
152
|
private
|
|
138
153
|
|
|
139
154
|
def validate_profiles!
|
|
140
|
-
|
|
155
|
+
unless document['profiles'].keys.sort == PROFILE_POLICIES.keys.sort
|
|
156
|
+
error!('profiles must be exactly micro, dev, nightly, and competitive')
|
|
157
|
+
end
|
|
141
158
|
document['profiles'].each do |name, profile|
|
|
142
|
-
require_keys(
|
|
159
|
+
require_keys(
|
|
160
|
+
profile,
|
|
161
|
+
%w[selection_mode competitor_policy mandatory_sentinels neighbor_count budgets],
|
|
162
|
+
"profile #{name}"
|
|
163
|
+
)
|
|
143
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)
|
|
144
167
|
end
|
|
145
168
|
end
|
|
146
169
|
|
|
@@ -332,9 +355,22 @@ module Ast
|
|
|
332
355
|
ids.sort_by { |id| [digest("#{seed}\0#{id}"), id] }
|
|
333
356
|
end
|
|
334
357
|
|
|
335
|
-
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)
|
|
336
368
|
{
|
|
337
|
-
'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),
|
|
338
374
|
'changed_paths' => details[:inferred].map { |path, caps| "#{path} => #{caps.join(',')}" },
|
|
339
375
|
'direct_cases' => details[:direct],
|
|
340
376
|
'sentinels' => details[:sentinels],
|
|
@@ -374,9 +410,102 @@ module Ast
|
|
|
374
410
|
end
|
|
375
411
|
end
|
|
376
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
|
+
|
|
377
504
|
# Executes the same authored merge bytes through Git and the installed driver.
|
|
378
505
|
class LocalBenchmarkRunner
|
|
379
506
|
DEFAULT_TIMEOUT = 30
|
|
507
|
+
MAX_WORKERS = 32
|
|
508
|
+
ADAPTER_DESCRIPTOR_SCHEMA = 'structuredmerge.benchmark.adapter-descriptor/v1'
|
|
380
509
|
MERGIRAF_EXTENSIONS = {
|
|
381
510
|
'bash' => 'sh',
|
|
382
511
|
'html' => 'html',
|
|
@@ -388,22 +517,37 @@ module Ast
|
|
|
388
517
|
'yaml' => 'yaml'
|
|
389
518
|
}.freeze
|
|
390
519
|
|
|
391
|
-
|
|
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)
|
|
392
523
|
@benchmark = benchmark
|
|
393
524
|
@driver_path = Pathname(driver_path).expand_path
|
|
394
525
|
@tmp_root = Pathname(tmp_root).expand_path
|
|
395
526
|
@timeout = Integer(timeout)
|
|
396
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)
|
|
397
531
|
end
|
|
532
|
+
# rubocop:enable Metrics/ParameterLists
|
|
398
533
|
|
|
399
534
|
def run(profile:, changed_paths: [])
|
|
400
535
|
verify_environment!
|
|
401
536
|
selection = @benchmark.select(profile: profile, changed_paths: changed_paths)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
item = @benchmark.case_by_id(id)
|
|
405
|
-
execute_case(item) + execute_competitors(item)
|
|
537
|
+
if @competitor_paths.any? && selection.fetch('competitor_policy') != 'configured'
|
|
538
|
+
error!('competitor adapters require the competitive profile')
|
|
406
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
|
+
)
|
|
407
551
|
raise LocalBenchmark::Error, 'runner changed its working directory' unless Dir.pwd == original_cwd
|
|
408
552
|
|
|
409
553
|
{
|
|
@@ -412,16 +556,143 @@ module Ast
|
|
|
412
556
|
'corpus_id' => @benchmark.document['id'],
|
|
413
557
|
'corpus_digest' => @benchmark.corpus_digest,
|
|
414
558
|
'selection' => selection,
|
|
415
|
-
'
|
|
559
|
+
'run_manifest' => manifest,
|
|
560
|
+
'cache_identity' => cache_identity(selection, manifest),
|
|
416
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
|
+
},
|
|
417
567
|
'results' => results
|
|
418
568
|
}
|
|
419
569
|
ensure
|
|
420
570
|
Dir.chdir(original_cwd) if original_cwd && Dir.pwd != original_cwd
|
|
421
571
|
end
|
|
422
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
|
+
|
|
423
618
|
private
|
|
424
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
|
+
|
|
425
696
|
def verify_environment!
|
|
426
697
|
error!("missing installed driver: #{@driver_path}") unless @driver_path.file? && @driver_path.executable?
|
|
427
698
|
@competitor_paths.each do |id, path|
|
|
@@ -434,6 +705,7 @@ module Ast
|
|
|
434
705
|
end
|
|
435
706
|
|
|
436
707
|
def execute_case(item)
|
|
708
|
+
return unsupported_candidate(item) unless candidate_supports?(item)
|
|
437
709
|
return execute_merge2_case(item) if item['operation'] == 'merge2'
|
|
438
710
|
return execute_merge3_case(item) if item['operation'] == 'merge3'
|
|
439
711
|
return execute_metamorphic_case(item) if item['operation'] == 'metamorphic'
|
|
@@ -441,6 +713,54 @@ module Ast
|
|
|
441
713
|
unsupported_pair(item)
|
|
442
714
|
end
|
|
443
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
|
+
|
|
444
764
|
def execute_competitors(item)
|
|
445
765
|
@competitor_paths.map do |id, path|
|
|
446
766
|
metadata = @benchmark.document.dig('competitors', id)
|
|
@@ -454,10 +774,37 @@ module Ast
|
|
|
454
774
|
)
|
|
455
775
|
end
|
|
456
776
|
|
|
457
|
-
|
|
777
|
+
execute_competitor(item, id, path)
|
|
458
778
|
end
|
|
459
779
|
end
|
|
460
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
|
+
|
|
461
808
|
def execute_mergiraf(item, path)
|
|
462
809
|
workspace = @tmp_root.join("#{safe_id(item['id'])}-mergiraf-#{Process.pid}")
|
|
463
810
|
FileUtils.rm_rf(workspace)
|
|
@@ -589,6 +936,7 @@ module Ast
|
|
|
589
936
|
chdir: workspace
|
|
590
937
|
)
|
|
591
938
|
result = capture[:stdout].empty? ? {} : JSON.parse(capture[:stdout])
|
|
939
|
+
capture[:stderr] = provider_diagnostics(result, capture[:stderr])
|
|
592
940
|
capture[:output] = capture[:status].zero? ? result.fetch('output') : ''
|
|
593
941
|
raw_result(item, 'ast-merge-provider.merge2', capture)
|
|
594
942
|
rescue JSON::ParserError, KeyError => e
|
|
@@ -599,6 +947,14 @@ module Ast
|
|
|
599
947
|
raw_result(item, 'ast-merge-provider.merge2', capture)
|
|
600
948
|
end
|
|
601
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
|
+
|
|
602
958
|
def write_metamorphic_roles(item, workspace)
|
|
603
959
|
%w[source transformed].each do |role|
|
|
604
960
|
workspace.join(role).binwrite(item.dig('inputs', role, 'bytes'))
|
|
@@ -864,9 +1220,12 @@ module Ast
|
|
|
864
1220
|
def classify(item, status, checks, stderr: '')
|
|
865
1221
|
expected = item.dig('expected', 'outcome')
|
|
866
1222
|
return 'excluded_ambiguous' if expected == 'excluded_ambiguous'
|
|
1223
|
+
|
|
867
1224
|
if expected == 'error'
|
|
868
1225
|
return 'error' if status.nil?
|
|
1226
|
+
|
|
869
1227
|
return expected_error_diagnostic?(status, stderr) ? 'correct_clean' : 'error' if status >= 2
|
|
1228
|
+
|
|
870
1229
|
return status == 1 ? 'false_conflict' : 'false_auto_merge'
|
|
871
1230
|
end
|
|
872
1231
|
return 'error' if status.nil? || status >= 2
|
|
@@ -878,7 +1237,7 @@ module Ast
|
|
|
878
1237
|
end
|
|
879
1238
|
|
|
880
1239
|
def expected_error_diagnostic?(status, stderr)
|
|
881
|
-
status >= 2 && /\
|
|
1240
|
+
status >= 2 && /\A[^:\r\n]+: (?:destination_)?parse_error:/i.match?(stderr.to_s)
|
|
882
1241
|
end
|
|
883
1242
|
|
|
884
1243
|
def equivalence_checks(item, output)
|
|
@@ -908,6 +1267,7 @@ module Ast
|
|
|
908
1267
|
equivalence_acceptable = !selected.nil?
|
|
909
1268
|
preservation_acceptable = violations.empty? && unverified.empty?
|
|
910
1269
|
checks.merge(
|
|
1270
|
+
'agreement' => agreement(expected, output),
|
|
911
1271
|
'preservation_evaluations' => preservation,
|
|
912
1272
|
'preservation_violations' => violations,
|
|
913
1273
|
'preservation_unverified' => unverified,
|
|
@@ -944,6 +1304,34 @@ module Ast
|
|
|
944
1304
|
policy['provider'].to_s == item.dig('selector', 'provider_id').to_s
|
|
945
1305
|
end
|
|
946
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
|
+
|
|
947
1335
|
def dimensions(item, outcome, checks)
|
|
948
1336
|
eligible = item.dig('oracle', 'score_eligible')
|
|
949
1337
|
{
|
|
@@ -960,6 +1348,7 @@ module Ast
|
|
|
960
1348
|
'preservation' => {
|
|
961
1349
|
'eligible' => eligible,
|
|
962
1350
|
'requirements' => item['preservation_policy'],
|
|
1351
|
+
'agreement' => checks['agreement'],
|
|
963
1352
|
'violations' => checks.fetch('preservation_violations', []),
|
|
964
1353
|
'unverified' => checks.fetch('preservation_unverified', [])
|
|
965
1354
|
},
|
|
@@ -1060,7 +1449,7 @@ module Ast
|
|
|
1060
1449
|
lines = stderr.lines.map(&:strip).reject(&:empty?)
|
|
1061
1450
|
lines << unsupported_reason if unsupported_reason
|
|
1062
1451
|
lines.map do |line|
|
|
1063
|
-
match = /\
|
|
1452
|
+
match = /\A(?:[^:\r\n]+:\s+)?([a-z_]+):/i.match(line)
|
|
1064
1453
|
category = match&.[](1) || (unsupported_reason == line ? 'unsupported' : 'process')
|
|
1065
1454
|
{ 'severity' => 'error', 'category' => category, 'message' => line }
|
|
1066
1455
|
end
|
|
@@ -1085,26 +1474,36 @@ module Ast
|
|
|
1085
1474
|
ENV.keys.grep(/(?:ORACLE|EXPECTED)/i).to_h { |name| [name, nil] }
|
|
1086
1475
|
end
|
|
1087
1476
|
|
|
1088
|
-
def
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
'
|
|
1093
|
-
'
|
|
1094
|
-
'
|
|
1095
|
-
'
|
|
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
|
+
}
|
|
1096
1488
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1489
|
+
manifest.merge('sha256' => canonical_digest(manifest))
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
def cache_identity(selection, manifest = run_manifest(selection))
|
|
1493
|
+
configuration = manifest.fetch('configuration')
|
|
1100
1494
|
competitors = competitor_provenance
|
|
1101
1495
|
identity = {
|
|
1102
|
-
'
|
|
1103
|
-
'
|
|
1104
|
-
'
|
|
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'),
|
|
1105
1504
|
'competitors' => competitors,
|
|
1106
1505
|
'corpus_sha256' => @benchmark.corpus_digest,
|
|
1107
|
-
'environment' => environment,
|
|
1506
|
+
'environment' => manifest.fetch('environment'),
|
|
1108
1507
|
'profile' => selection['profile'],
|
|
1109
1508
|
'changed_paths' => selection['changed_paths'],
|
|
1110
1509
|
'inferred_capabilities' => selection['inferred_capabilities'],
|
|
@@ -1116,12 +1515,279 @@ module Ast
|
|
|
1116
1515
|
identity.merge('sha256' => Digest::SHA256.hexdigest(JSON.generate(identity)))
|
|
1117
1516
|
end
|
|
1118
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
|
+
|
|
1119
1781
|
def competitor_provenance
|
|
1120
1782
|
@competitor_provenance ||= @competitor_paths.to_h do |id, path|
|
|
1121
1783
|
metadata = @benchmark.document.fetch('competitors').fetch(id)
|
|
1122
1784
|
capture = timed_capture(oracle_free_env, path.to_s, '--version', chdir: path.dirname)
|
|
1123
1785
|
error!("#{id} version probe failed: #{capture[:stderr].strip}") unless capture[:status].zero?
|
|
1124
|
-
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
|
|
1125
1791
|
error!("#{id} version differs: #{capture[:stdout].strip}") unless capture[:stdout].strip == expected
|
|
1126
1792
|
|
|
1127
1793
|
[id, metadata.merge(
|
|
@@ -1184,6 +1850,7 @@ module Ast
|
|
|
1184
1850
|
'corpus_id' => @run['corpus_id'],
|
|
1185
1851
|
'corpus_digest' => @run['corpus_digest'],
|
|
1186
1852
|
'selection' => @run['selection'],
|
|
1853
|
+
'run_manifest' => @run.fetch('run_manifest'),
|
|
1187
1854
|
'cache_identity' => @run['cache_identity'],
|
|
1188
1855
|
'dimensions' => {
|
|
1189
1856
|
'safety' => {
|
|
@@ -1251,8 +1918,10 @@ module Ast
|
|
|
1251
1918
|
'candidate_result_id' => candidate['id'],
|
|
1252
1919
|
'from' => baseline['outcome'],
|
|
1253
1920
|
'to' => candidate['outcome'],
|
|
1254
|
-
'newly_passing' => !
|
|
1255
|
-
|
|
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']),
|
|
1256
1925
|
'changed_conflict' => conflict?(baseline) && conflict?(candidate) &&
|
|
1257
1926
|
baseline.dig('raw', 'output', 'sha256') != candidate.dig('raw', 'output', 'sha256')
|
|
1258
1927
|
}
|
|
@@ -1262,6 +1931,10 @@ module Ast
|
|
|
1262
1931
|
%w[false_conflict true_conflict].include?(result['outcome'])
|
|
1263
1932
|
end
|
|
1264
1933
|
|
|
1934
|
+
def unsupported?(result)
|
|
1935
|
+
result['outcome'] == 'unsupported'
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1265
1938
|
def performance
|
|
1266
1939
|
@results.group_by { |item| item['adapter_id'] }.transform_values do |items|
|
|
1267
1940
|
values = items.filter_map { |item| item.dig('runtime', 'duration_ns') }
|
|
@@ -1281,9 +1954,24 @@ module Ast
|
|
|
1281
1954
|
|
|
1282
1955
|
def competitive
|
|
1283
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
|
|
1284
1971
|
{
|
|
1285
1972
|
'configured' => @run.fetch('competitors'),
|
|
1286
1973
|
'outcomes' => results.group_by { |item| item['outcome'] }.transform_values(&:length),
|
|
1974
|
+
'adapters' => adapters,
|
|
1287
1975
|
'unsupported_case_ids' => results.filter_map do |item|
|
|
1288
1976
|
item['case_id'] if item['outcome'] == 'unsupported'
|
|
1289
1977
|
end,
|
|
@@ -1294,6 +1982,14 @@ module Ast
|
|
|
1294
1982
|
}
|
|
1295
1983
|
end
|
|
1296
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
|
+
|
|
1297
1993
|
def strata
|
|
1298
1994
|
case_results = @results.select { |item| item['adapter_role'] == 'candidate' }
|
|
1299
1995
|
tag_fields = %w[operation partition family provider dialect false_auto_merge_severity]
|