ast-merge-git 7.1.7 → 7.1.9

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
@@ -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
- selected = ordered(sentinels + direct)
92
- population = cases.map { |item| item.fetch('id') } - selected
93
- neighbors = neighbor_order(population).first(definition.fetch('neighbor_count'))
94
- selected = ordered(selected + neighbors)
95
- 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
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
- profile,
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
- 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
141
158
  document['profiles'].each do |name, profile|
142
- 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
+ )
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 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)
336
368
  {
337
- '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),
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
- 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)
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
- original_cwd = Dir.pwd
403
- results = selection['selected_case_ids'].flat_map do |id|
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
- 'cache_identity' => cache_identity(selection),
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
- execute_mergiraf(item, path)
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 && /\Aast-merge-git: parse_error:/i.match?(stderr.to_s)
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 = /\Aast-merge-git: ([a-z_]+):/.match(line)
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 cache_identity(selection)
1089
- source_sha, = Open3.capture2('git', '-C', Pathname(__dir__).join('..', '..', '..', '..').to_s,
1090
- 'rev-parse', 'HEAD')
1091
- environment = {
1092
- 'ruby' => RUBY_DESCRIPTION,
1093
- 'platform' => RUBY_PLATFORM,
1094
- 'host_os' => RbConfig::CONFIG['host_os'],
1095
- '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
+ }
1096
1488
  }
1097
- configuration = selection['selected_case_ids'].map do |id|
1098
- @benchmark.case_by_id(id).slice('id', 'selector')
1099
- 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')
1100
1494
  competitors = competitor_provenance
1101
1495
  identity = {
1102
- 'adapter_source_sha' => source_sha.strip,
1103
- 'adapter_artifact_sha256' => Digest::SHA256.file(@driver_path).hexdigest,
1104
- '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'),
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 = "#{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
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' => !SUCCESS.include?(baseline['outcome']) && SUCCESS.include?(candidate['outcome']),
1255
- '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']),
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]