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.
@@ -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
- selected = ordered(sentinels + direct)
91
- population = cases.map { |item| item.fetch('id') } - selected
92
- neighbors = neighbor_order(population).first(definition.fetch('neighbor_count'))
93
- selected = ordered(selected + neighbors)
94
- selected = sentinels if profile == 'micro'
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
- profile,
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
- error!('profiles must be exactly micro and dev') unless document['profiles'].keys.sort == %w[dev micro]
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(profile, %w[mandatory_sentinels neighbor_count budgets], "profile #{name}")
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'] == 'merge3'
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 explanation(profile, details)
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' => profile == 'micro' ? 'mandatory sentinels only' : 'sentinels + direct + neighbors',
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
- def initialize(benchmark:, driver_path:, tmp_root:, timeout: DEFAULT_TIMEOUT, competitor_paths: {})
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
- original_cwd = Dir.pwd
402
- results = selection['selected_case_ids'].flat_map do |id|
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
- 'cache_identity' => cache_identity(selection),
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
- execute_mergiraf(item, path)
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(item, capture.fetch(:status), checks)
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
- return 'candidate' if %w[ast-merge-git ast-merge-provider.diff2 structuredmerge.unsupported].include?(adapter)
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['acceptable']
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
- violations = preservation_violations(item, checks, output)
829
- checks.merge('preservation_violations' => violations, 'acceptable' => !selected.nil? && violations.empty?)
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
- 'violations' => checks.fetch('preservation_violations')
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 preservation_violations(item, checks, output)
882
- return [] if checks['exact']
883
-
1359
+ def preservation_evaluations(item, checks, output)
884
1360
  expected = item.dig('expected', 'output', 'bytes')
885
- return [] unless expected
1361
+ return {} unless expected
886
1362
 
887
- item['preservation_policy'].filter_map do |name, requirement|
888
- next unless requirement == 'required'
889
- next unless preservation_violation?(name, output, expected, checks)
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 preservation_violation?(name, output, expected, checks)
1371
+ def preservation_evaluation(name, output, expected, checks)
896
1372
  case name
897
- when 'encoding' then output.encoding != expected.encoding
898
- when 'line_endings' then line_ending_style(output) != line_ending_style(expected)
899
- when 'unknown_fields' then checks['structural'] != true
900
- else output != expected
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 = /\Aast-merge-git: ([a-z_]+):/.match(line)
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 cache_identity(selection)
982
- source_sha, = Open3.capture2('git', '-C', Pathname(__dir__).join('..', '..', '..', '..').to_s,
983
- 'rev-parse', 'HEAD')
984
- environment = {
985
- 'ruby' => RUBY_DESCRIPTION,
986
- 'platform' => RUBY_PLATFORM,
987
- 'host_os' => RbConfig::CONFIG['host_os'],
988
- 'host_cpu' => RbConfig::CONFIG['host_cpu']
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
- configuration = selection['selected_case_ids'].map do |id|
991
- @benchmark.case_by_id(id).slice('id', 'selector')
992
- end
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
- 'adapter_source_sha' => source_sha.strip,
996
- 'adapter_artifact_sha256' => Digest::SHA256.file(@driver_path).hexdigest,
997
- 'configuration_sha256' => Digest::SHA256.hexdigest(JSON.generate(configuration)),
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 = "#{metadata.fetch('adapter_id')} #{metadata.fetch('version')}"
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' => eligible.length,
1086
- 'violation_result_ids' => eligible.filter_map do |item|
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' => { 'error_result_ids' => @results.filter_map do |item|
1092
- item['id'] if item['outcome'] == 'error'
1093
- end },
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' => !SUCCESS.include?(baseline['outcome']) && SUCCESS.include?(candidate['outcome']),
1134
- 'newly_failing' => SUCCESS.include?(baseline['outcome']) && !SUCCESS.include?(candidate['outcome']),
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]