bitfab 0.61.0 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a1a8eab8b1b1b24bdb550f90e3d7cdfde97644a6fcd4301855a12664ac34870
4
- data.tar.gz: b1acc5424fa7900996bb20d403f1374d77efca40a9267dc9d986831992fe8439
3
+ metadata.gz: 49768c67671b66ea8066cfa078dbba13ac3548b83b9a0abaa628ab864a61b99f
4
+ data.tar.gz: 76e711a41258fa421a69cd6a0475a793698f3bdd001f60d0104ec47a5096e8da
5
5
  SHA512:
6
- metadata.gz: e0acbbd123458aff8ac1385c256da5a29bafef8e48a948a5e3de9916e97566d700ffa693695d572cb232e979d5b24b3f8664bd02fa1a3e1659ec01f2ddda80a4
7
- data.tar.gz: 8714de8dd405dcf831e1f229cb12e9262d21092eac2b9c320804b752f80348e4ba38a613512856ad1b9aa79db8f52d3368eab3777e425b678a3cc05e9c6d54ee
6
+ metadata.gz: bdef7ec071b9740bd8183056b68b84e9ab2c8004f0918678c604e9e35d6426144891bd5df0e476a59f8198e1f17459a4e0e48c08e3eab78061b119146e38b3f1
7
+ data.tar.gz: d441d62917aaa84b3dfbc88f6544e1630645fc5a595a7a3cf9b3e111bdbf62b43059b971ecb9f51001d45c8f28ecf7322afc07537307231fab71736f3852c80a
data/lib/bitfab/client.rb CHANGED
@@ -192,7 +192,7 @@ module Bitfab
192
192
  def replay(receiver, method_name = nil, trace_function_key:, limit: nil, trace_ids: nil, concurrency: nil, max_concurrency: ReplayConcurrency::UNSET,
193
193
  name: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
194
194
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
195
- on_experiment_start: nil)
195
+ on_experiment_start: nil, experimental_selective_replay: nil)
196
196
  Replay.run(
197
197
  self,
198
198
  receiver,
@@ -220,7 +220,8 @@ module Bitfab
220
220
  on_item_start:,
221
221
  on_item_finish:,
222
222
  on_progress:,
223
- on_experiment_start:
223
+ on_experiment_start:,
224
+ experimental_selective_replay:
224
225
  )
225
226
  end
226
227
 
@@ -337,7 +338,7 @@ module Bitfab
337
338
  # Configure an existing method only when it is discovered beneath a
338
339
  # bitfab_trace root owned by this client.
339
340
  def node(klass, method_name, name: nil, type: "custom", capture: true,
340
- experiment_id: nil, mock_on_replay: nil, finalize: nil, test_run_id: nil)
341
+ experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, test_run_id: nil)
341
342
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
342
343
  Bitfab::Traceable.node(
343
344
  klass,
@@ -347,6 +348,7 @@ module Bitfab
347
348
  capture:,
348
349
  experiment_id:,
349
350
  mock_on_replay:,
351
+ replay_reusable:,
350
352
  finalize:,
351
353
  client: self
352
354
  )
@@ -383,12 +385,14 @@ module Bitfab
383
385
  # Execute a block inside a span context, sending trace data on completion.
384
386
  # Called by Traceable, not intended for direct use.
385
387
  def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
386
- capture_when: "always", mock_on_replay: false, independent_root: false,
388
+ capture_when: "always", mock_on_replay: false, replay_reusable: false, independent_root: false,
387
389
  explicit_span_receiver: nil, explicit_span_method_name: nil, experiment_id: nil, finalize: nil,
388
390
  surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
389
- result_recorded_elsewhere: false, declared_node: false)
391
+ result_recorded_elsewhere: false, declared_node: false, input_block: false)
390
392
  return yield unless should_record?
391
393
 
394
+ selective_input = !input_block && (replay_reusable || mock_on_replay) && SelectiveReplay.inputs(args, kwargs)
395
+
392
396
  @http_client.refresh_simulation_plan
393
397
 
394
398
  resolved_capture_when = capture_when.to_s
@@ -465,7 +469,20 @@ module Bitfab
465
469
  # line up with `build_mock_tree`'s sequential numbering for the same
466
470
  # (key, name) pair. Different (key, name) pairs have independent
467
471
  # counters: they cannot shift each other.
468
- call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
472
+ selective = replay_ctx&.dig(:selective_replay)
473
+ if selective
474
+ mocked, output = selective.enter(trace_function_key:, span_name:, span_id:, parent_span_id:,
475
+ args:, kwargs:, safety_mock: mock_on_replay, reuse_allowed: replay_reusable, can_reuse_output: finalize.nil? && !input_block)
476
+ if mocked
477
+ send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:, span_name:, span_type:,
478
+ function_name:, args:, kwargs:, mocked_output: output, started_at:,
479
+ experiment_id: resolved_experiment_id, input_source_span_id: resolved_input_source_span_id,
480
+ mock_source: "recorded", instrumentation: (surface == :trace) ? "trace" : "span", declared_node:)
481
+ on_complete&.call(result: output, error: nil, ended_at: Bitfab.now_iso_timestamp)
482
+ return output
483
+ end
484
+ end
485
+ call_index = selective ? nil : advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
469
486
  if call_index
470
487
  mock_result = check_mock_replay(
471
488
  replay_ctx,
@@ -561,6 +578,14 @@ module Bitfab
561
578
 
562
579
  on_complete&.call(result: recorded_result, error: recorded_error, ended_at:)
563
580
 
581
+ span_data = span_data.merge("replay_reusable" => replay_reusable)
582
+ output_json = finalize.nil? && !result.is_a?(Enumerator) && SelectiveReplay.fingerprint(final_result)
583
+ if selective_input && output_json && recorded_error.nil? && !content_off
584
+ recorded_args, recorded_kwargs = JSON.parse(selective_input)
585
+ span_data = span_data.merge("replay_json_safe" => true,
586
+ "replay_recording" => {"input" => recorded_args, "kwargs" => recorded_kwargs, "output" => JSON.parse(output_json)})
587
+ end
588
+
564
589
  send_span(
565
590
  span_data: content_off ? span_data.merge("content_off_by_simulation_plan" => true) : span_data,
566
591
  instrumentation: recorded_instrumentation,
@@ -1246,7 +1271,7 @@ module Bitfab
1246
1271
  # values warn once and default to "always"
1247
1272
  # @param mock_on_replay [Boolean] mark this span for the default "marked"
1248
1273
  # mock strategy. A missing selected occurrence fails closed.
1249
- def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, test_run_id: nil)
1274
+ def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, test_run_id: nil)
1250
1275
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
1251
1276
  Bitfab::Traceable.wrap(
1252
1277
  klass,
@@ -1256,6 +1281,7 @@ module Bitfab
1256
1281
  type:,
1257
1282
  capture_when:,
1258
1283
  mock_on_replay:,
1284
+ replay_reusable:,
1259
1285
  finalize:,
1260
1286
  experiment_id:,
1261
1287
  client: @client
@@ -262,7 +262,7 @@ module Bitfab
262
262
  # same org and trace function or the server rejects the replay
263
263
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
264
264
  code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_ids: nil,
265
- grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, judge_assertions: false, include_original_metadata: false)
265
+ grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, judge_assertions: false, include_original_metadata: false, experimental_selective_replay: nil)
266
266
  payload = {
267
267
  "traceFunctionKey" => trace_function_key
268
268
  }
@@ -288,6 +288,7 @@ module Bitfab
288
288
  payload["onlyWithAssertions"] = true if only_with_assertions
289
289
  payload["judgeAssertions"] = true if judge_assertions
290
290
  payload["includeOriginalMetadata"] = true if include_original_metadata
291
+ payload["experimentalSelectiveReplay"] = experimental_selective_replay if experimental_selective_replay
291
292
  payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
292
293
  git_state = GitState.resolved
293
294
  payload["git"] = git_state if git_state
data/lib/bitfab/replay.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "constants"
9
9
  require_relative "git_command"
10
10
  require_relative "mock_override"
11
11
  require_relative "serialize"
12
+ require_relative "selective_replay"
12
13
  require_relative "traceable"
13
14
  require_relative "transport"
14
15
 
@@ -85,10 +86,11 @@ module Bitfab
85
86
  # thread. Child threads and processes intentionally do not inherit it.
86
87
  def with_context(experiment_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
87
88
  mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
88
- db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil, replay_attempt: nil)
89
+ db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil, replay_attempt: nil, selective_replay: nil)
89
90
  previous = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
90
91
  ctx = {
91
92
  replay_attempt:,
93
+ selective_replay:,
92
94
  experiment_id:,
93
95
  input_source_span_id:,
94
96
  input_source_trace_id:,
@@ -276,7 +278,7 @@ module Bitfab
276
278
  concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
277
279
  dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
278
280
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
279
- on_experiment_start: nil)
281
+ on_experiment_start: nil, experimental_selective_replay: nil)
280
282
  concurrency_options = ReplayConcurrency.resolve(concurrency, attempts, max_concurrency)
281
283
  attempts = concurrency_options.attempts
282
284
  max_concurrency = concurrency_options.max_concurrency
@@ -337,6 +339,12 @@ module Bitfab
337
339
  # both take precedence over the base mock strategy.
338
340
  registered_overrides = client.instance_variable_get(:@mock_overrides) || []
339
341
  resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides
342
+ if experimental_selective_replay
343
+ SelectiveReplay.wire_options(experimental_selective_replay)
344
+ unless mock.to_s == "marked" && resolved_overrides.empty? && concurrency_options.primitive != "process"
345
+ raise ArgumentError, "experimental_selective_replay requires marked mocks, no overrides, and in-process replay"
346
+ end
347
+ end
340
348
 
341
349
  # limit is meaningless with explicit trace_ids (the ID list determines
342
350
  # the count), so it's omitted from the request entirely.
@@ -373,7 +381,8 @@ module Bitfab
373
381
  only_with_assertions:,
374
382
  judge_assertions:,
375
383
  include_original_metadata: !adapt_inputs.nil?,
376
- db_branch_settings: resolved_db_branch_settings
384
+ db_branch_settings: resolved_db_branch_settings,
385
+ **(experimental_selective_replay ? {experimental_selective_replay: SelectiveReplay.wire_options(experimental_selective_replay)} : {})
377
386
  )
378
387
  experiment_id = replay_data["experimentId"] || replay_data["testRunId"]
379
388
  experiment_url = replay_data["experimentUrl"] || replay_data["testRunUrl"]
@@ -400,7 +409,7 @@ module Bitfab
400
409
  process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock.to_s,
401
410
  adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
402
411
  on_item_finish: item_finish_callback,
403
- mock_overrides: resolved_overrides, dry_run:, process_executor:)
412
+ mock_overrides: resolved_overrides, dry_run:, process_executor:, experimental_selective_replay:)
404
413
  else
405
414
  []
406
415
  end
@@ -776,7 +785,7 @@ module Bitfab
776
785
  # Process all replay items, optionally in parallel using threads.
777
786
  def process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock_strategy,
778
787
  adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil,
779
- mock_overrides: [], dry_run: false, process_executor: nil)
788
+ mock_overrides: [], dry_run: false, process_executor: nil, experimental_selective_replay: nil)
780
789
  concurrency = max_concurrency || server_items.length
781
790
 
782
791
  # Lifecycle callbacks run from worker threads in the parallel path, so the
@@ -887,7 +896,7 @@ module Bitfab
887
896
  process_executor.call(item, experiment_id, mock_strategy, include_db_branch_lease, db_branch_settings, dry_run:)
888
897
  else
889
898
  process_single_item(http_client, item, receiver, method_name, experiment_id, mock_strategy,
890
- adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:)
899
+ adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:, experimental_selective_replay:)
891
900
  end
892
901
  end
893
902
 
@@ -930,7 +939,7 @@ module Bitfab
930
939
  # than propagated, so one bad trace never aborts the whole replay run
931
940
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
932
941
  def process_single_item(http_client, server_item, receiver, method_name, experiment_id, mock_strategy,
933
- adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false)
942
+ adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false, experimental_selective_replay: nil)
934
943
  metrics = extract_server_item_metrics(server_item)
935
944
  attempt = server_item["attempt"] || 0
936
945
  # The ORIGINAL (historical) trace/span this item replays. Canonical
@@ -945,6 +954,7 @@ module Bitfab
945
954
  # snapshot ref arrive without a lease, so the app uses its normal DB
946
955
  # path. Unsafe calls on that path still require replay mocking.
947
956
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
957
+ selective_replay = experimental_selective_replay && SelectiveReplay.new(server_item["selectiveReplayPlan"], experimental_selective_replay)
948
958
  # A resolve that was ATTEMPTED and failed is different: the caller asked
949
959
  # for a branch, so running their method against live data would produce a
950
960
  # result that looks valid and is not. Fail the item instead, loudly.
@@ -994,7 +1004,7 @@ module Bitfab
994
1004
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
995
1005
  include_outputs = mock_strategy == "all"
996
1006
  mock_tree = nil
997
- if !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
1007
+ if !selective_replay && !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
998
1008
  begin
999
1009
  tree = http_client.get_span_tree(
1000
1010
  original_span_id,
@@ -1035,6 +1045,7 @@ module Bitfab
1035
1045
  span["id"],
1036
1046
  metrics,
1037
1047
  input_source_trace_id: span["externalTraceId"],
1048
+ selective_replay:,
1038
1049
  mock_strategy:,
1039
1050
  mock_tree:,
1040
1051
  mock_overrides:,
@@ -1282,7 +1293,7 @@ module Bitfab
1282
1293
  def execute_item(item, receiver, method_name, experiment_id, input_source_span_id = nil, metrics = {},
1283
1294
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1284
1295
  fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, db_branch_timings: nil,
1285
- source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false)
1296
+ source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false, selective_replay: nil)
1286
1297
  args, kwargs = Serialize.deserialize_inputs(item)
1287
1298
 
1288
1299
  fn_result = nil
@@ -1303,6 +1314,7 @@ module Bitfab
1303
1314
  begin
1304
1315
  ReplayContext.with_context(
1305
1316
  replay_attempt: attempt,
1317
+ selective_replay:,
1306
1318
  experiment_id:,
1307
1319
  input_source_span_id:,
1308
1320
  input_source_trace_id:,
@@ -1338,6 +1350,7 @@ module Bitfab
1338
1350
  receiver.send(method_name, *args, **kwargs)
1339
1351
  end
1340
1352
  fn_result = fn_result.to_a if fn_result.is_a?(Enumerator)
1353
+ selective_replay&.assert_no_safety_conflict!
1341
1354
  replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1342
1355
  rescue => e
1343
1356
  # The method ran and raised, so it still has a duration.
@@ -1357,6 +1370,7 @@ module Bitfab
1357
1370
 
1358
1371
  item_error = fn_error || replay_error
1359
1372
  {
1373
+ **(selective_replay ? {selective_replay: selective_replay.report} : {}),
1360
1374
  attempt:,
1361
1375
  ingestion_type: metrics[:ingestion_type],
1362
1376
  input: kwargs.empty? ? args : [*args, kwargs],
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bitfab
6
+ class SelectiveReplay
7
+ def self.wire_options(options)
8
+ valid = options.is_a?(Hash) && options.keys == [:must_run] && options[:must_run].is_a?(Array) &&
9
+ options[:must_run].length.between?(1, 100) && options[:must_run].all? do |node|
10
+ node.is_a?(Hash) && node.keys.sort == [:span_name, :trace_function_key] &&
11
+ node.values.all? { |value| value.is_a?(String) && value.length.between?(1, 500) }
12
+ end
13
+ raise ArgumentError, "experimental_selective_replay accepts only 1–100 must_run identities" unless valid
14
+
15
+ {"mustRun" => options[:must_run].map { |node| {"traceFunctionKey" => node[:trace_function_key], "spanName" => node[:span_name]} }}
16
+ end
17
+
18
+ # Strict JSON avoids silently changing Ruby classes, symbol keys, numeric
19
+ # types, aliases, or insertion order when a recorded output is returned.
20
+ def self.fingerprint(value)
21
+ seen = Set.new
22
+ validate = lambda do |item|
23
+ case item
24
+ when NilClass, TrueClass, FalseClass
25
+ true
26
+ when String
27
+ item.instance_of?(String) && seen.add?(item.object_id) && !item.start_with?("<unserializable")
28
+ when Integer
29
+ item.abs <= (2**53 - 1)
30
+ when Float
31
+ item.finite? && item != item.to_i
32
+ when Array, Hash
33
+ next false unless item.instance_of?(Array) || item.instance_of?(Hash)
34
+ next false unless seen.add?(item.object_id)
35
+
36
+ if item.is_a?(Array)
37
+ item.all? { |child| validate.call(child) }
38
+ else
39
+ item.default.nil? && item.default_proc.nil? && !item.compare_by_identity? && item.all? do |key, child|
40
+ key.instance_of?(String) && !key.match?(/\A(?:0|[1-9][0-9]*)\z/) && validate.call(child)
41
+ end
42
+ end
43
+ else
44
+ false
45
+ end
46
+ end
47
+ validate.call(value) ? JSON.generate(value) : nil
48
+ rescue StandardError, SystemStackError
49
+ nil
50
+ end
51
+
52
+ def self.inputs(args, kwargs)
53
+ return nil unless kwargs.keys.all? { |key| key.is_a?(Symbol) }
54
+
55
+ fingerprint([args, kwargs.transform_keys(&:to_s)])
56
+ end
57
+
58
+ def initialize(plan, options)
59
+ expected = self.class.wire_options(options)
60
+ unless plan.is_a?(Hash) && plan["version"] == 4 && plan["nodes"].is_a?(Array) &&
61
+ plan["nodes"].length.between?(1, 500) && plan["assertions"].is_a?(Array) &&
62
+ plan["unresolvedMustRun"].is_a?(Array)
63
+ raise ArgumentError, "Server did not return a compatible selective replay plan"
64
+ end
65
+ raise ArgumentError, "Selective replay plan does not match must_run" unless plan["options"] == expected
66
+
67
+ @plan = JSON.parse(JSON.generate(plan))
68
+ @required = expected["mustRun"].map { |node| key(node) }.to_set
69
+ @nodes = {}
70
+ @children = Hash.new { |hash, id| hash[id] = [] }
71
+ @recordings = {}
72
+ @parents = {}
73
+ @consumed = Hash.new { |hash, id| hash[id] = Set.new }
74
+ @required_scopes = Set.new
75
+ @assertion_scopes = Set.new
76
+ @assertion_names = Set.new
77
+ @unresolved_assertions = []
78
+ @observed = Hash.new(0)
79
+ @reached = Set.new
80
+ @decisions = []
81
+ @mutex = Mutex.new
82
+ @plan["nodes"].each do |node|
83
+ valid_node = node.is_a?(Hash) && node["id"].is_a?(String) && !@nodes.key?(node["id"]) &&
84
+ node["spanName"].is_a?(String) && node["traceFunctionKey"].is_a?(String) &&
85
+ [true, false].include?(node["mustRun"]) && [true, false].include?(node["reusable"]) &&
86
+ node.key?("parentId") && (node["parentId"].nil? || node["parentId"].is_a?(String))
87
+ raise ArgumentError, "Invalid selective replay node" unless valid_node
88
+ @nodes[node["id"]] = node
89
+ @children[node["parentId"]] << node unless node["parentId"].nil?
90
+ recording = node["recording"]
91
+ if recording.is_a?(Hash) && recording.key?("output")
92
+ @recordings[node["id"]] = [self.class.fingerprint([recording["input"], recording.fetch("kwargs", {})]), self.class.fingerprint(recording["output"])]
93
+ end
94
+ end
95
+ root = @nodes[@plan["rootId"]]
96
+ raise ArgumentError, "Selective replay requires a live recorded root" unless root && root["parentId"].nil? && root["mustRun"]
97
+
98
+ @full_trace = false
99
+ ids = Set.new
100
+ @plan["assertions"].each do |assertion|
101
+ unless assertion.is_a?(Hash) && assertion["id"].is_a?(String) && !assertion["id"].empty? && ids.add?(assertion["id"]) && assertion.key?("target")
102
+ raise ArgumentError, "Invalid selective replay assertion"
103
+ end
104
+ target = assertion["target"]
105
+ if target.nil?
106
+ @full_trace = true
107
+ next
108
+ end
109
+ raise ArgumentError, "Invalid selective replay assertion target" unless target.is_a?(Hash) && %w[output span].include?(target["kind"])
110
+ next if target["kind"] == "output"
111
+
112
+ occurrence = target.fetch("occurrence", "first")
113
+ valid_target = target["name"].is_a?(String) && !target["name"].empty? &&
114
+ (%w[first last].include?(occurrence) || (occurrence.is_a?(Integer) && occurrence >= 0))
115
+ raise ArgumentError, "Invalid selective replay assertion target" unless valid_target
116
+ @assertion_names.add(target["name"])
117
+ count = @nodes.values.count { |node| node["spanName"] == target["name"] }
118
+ @unresolved_assertions << assertion["id"] if count <= (occurrence.is_a?(Integer) ? occurrence : 0)
119
+ end
120
+ @full_trace ||= !@unresolved_assertions.empty?
121
+ unresolved = @required - @nodes.values.map { |node| key(node) }.to_set
122
+ unless unresolved == @plan["unresolvedMustRun"].map { |node| key(node) }.to_set
123
+ raise ArgumentError, "Selective replay plan has incomplete must_run coverage"
124
+ end
125
+ @nodes.each_value do |node|
126
+ ancestors = Set.new([node["id"]])
127
+ required = @required.include?(key(node)) || !unresolved.empty?
128
+ asserted = @full_trace || @assertion_names.include?(node["spanName"])
129
+ parent_id = node["parentId"]
130
+ until parent_id.nil?
131
+ parent = @nodes[parent_id]
132
+ unless parent && ancestors.add?(parent_id)
133
+ raise ArgumentError, "Invalid selective replay ancestry"
134
+ end
135
+ if node["mustRun"] && !parent["mustRun"]
136
+ raise ArgumentError, "Invalid selective replay ancestry"
137
+ end
138
+ required ||= @required.include?(key(parent))
139
+ asserted ||= @assertion_names.include?(parent["spanName"])
140
+ parent_id = parent["parentId"]
141
+ end
142
+ if !ancestors.include?(@plan["rootId"]) || ((required || asserted) && !node["mustRun"])
143
+ raise ArgumentError, "Selective replay plan could hide required code or assertion evidence"
144
+ end
145
+ end
146
+ end
147
+
148
+ def enter(trace_function_key:, span_name:, span_id:, parent_span_id:, args:, kwargs:, safety_mock:, reuse_allowed:, can_reuse_output: true)
149
+ @mutex.synchronize do
150
+ identity = [trace_function_key, span_name]
151
+ input = self.class.inputs(args, kwargs)
152
+ node = if parent_span_id.nil?
153
+ @nodes[@plan["rootId"]]
154
+ else
155
+ candidates = @children[@parents[parent_span_id]].select { |candidate| key(candidate) == identity && !@consumed[parent_span_id].include?(candidate["id"]) }
156
+ exact = candidates.select { |candidate| input && @recordings.dig(candidate["id"], 0) == input }
157
+ match = if exact.length == 1
158
+ exact.first
159
+ else
160
+ ((exact.empty? && candidates.length == 1) ? candidates.first : nil)
161
+ end
162
+ (match ? [match] : candidates).each { |candidate| @consumed[parent_span_id].add(candidate["id"]) }
163
+ match
164
+ end
165
+ @parents[span_id] = node&.dig("id")
166
+ required = @required.include?(identity) || @required_scopes.include?(parent_span_id)
167
+ @required_scopes.add(span_id) if required
168
+ asserted = @full_trace || @assertion_names.include?(span_name) || @assertion_scopes.include?(parent_span_id)
169
+ @assertion_scopes.add(span_id) if asserted
170
+ must_run = parent_span_id.nil? || required || asserted || node&.dig("mustRun") || !@plan["unresolvedMustRun"].empty?
171
+ recorded_input, recorded_output = @recordings[node&.dig("id")]
172
+ reusable = input && input == recorded_input && recorded_output && can_reuse_output
173
+ reason = if required
174
+ "must-run"
175
+ elsif asserted
176
+ "assertion"
177
+ elsif !@plan["unresolvedMustRun"].empty?
178
+ "unresolved-must-run"
179
+ elsif !node
180
+ "unmatched-or-ambiguous-call"
181
+ elsif !reusable
182
+ "input-drift-or-missing-recording"
183
+ else
184
+ node["reason"]
185
+ end
186
+ decision = {trace_function_key:, span_name:, original_span_id: node&.dig("id"), reason:}
187
+ if safety_mock && (must_run || !reusable)
188
+ @decisions << decision.merge(action: "blocked", reason: "safety-conflict:#{reason}")
189
+ @failure = RuntimeError.new("Selective replay cannot execute or safely reuse safety-mocked node #{span_name} (#{reason})")
190
+ raise @failure
191
+ end
192
+ if !must_run && reusable && (safety_mock || (node["reusable"] && reuse_allowed))
193
+ @decisions << decision.merge(action: "mock", reason: safety_mock ? "safety-mock" : "unchanged-inputs")
194
+ return [true, JSON.parse(recorded_output)]
195
+ end
196
+ @decisions << decision.merge(action: "run")
197
+ @observed[span_name] += 1
198
+ @reached.add(identity) if @required.include?(identity)
199
+ [false, nil]
200
+ end
201
+ end
202
+
203
+ def assert_no_safety_conflict!
204
+ raise @failure if @failure
205
+ end
206
+
207
+ def report
208
+ @mutex.synchronize do
209
+ {
210
+ decisions: @decisions.map(&:dup),
211
+ must_run_not_reached: @plan["options"]["mustRun"].reject { |node| @reached.include?(key(node)) }.map { |node| identity(node) },
212
+ unresolved_must_run: @plan["unresolvedMustRun"].map { |node| identity(node) },
213
+ assertion_ids: @plan["assertions"].map { |assertion| assertion["id"] },
214
+ unresolved_assertions: @unresolved_assertions.dup,
215
+ assertion_targets_not_reached: @plan["assertions"].filter_map do |assertion|
216
+ target = assertion["target"]
217
+ if target && target["kind"] == "span" && @observed[target["name"]] <= (target["occurrence"].is_a?(Integer) ? target["occurrence"] : 0)
218
+ assertion["id"]
219
+ end
220
+ end
221
+ }
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ def key(node)
228
+ [node.fetch("traceFunctionKey"), node.fetch("spanName")]
229
+ end
230
+
231
+ def identity(node)
232
+ {trace_function_key: node["traceFunctionKey"], span_name: node["spanName"]}
233
+ end
234
+ end
235
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Bitfab
4
4
  class SimulationPlan
5
- CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized prompt].freeze
5
+ CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized replay_recording prompt].freeze
6
6
  FRAMEWORKS = %w[openai-agents langgraph claude-agent-sdk vercel-ai].freeze
7
7
  READ_TIMEOUT = 5.0
8
8
  REFRESH_SECONDS = 60.0
@@ -667,10 +667,15 @@ module Bitfab
667
667
  active_parent[:span_id]
668
668
  end
669
669
  subtree_parent_span_id = stack.reverse_each.find { |entry| entry.span_id }&.span_id
670
+ parent_span_id = subtree_parent_span_id || @fiber_parents.dig(fiber_id, :span_id) || active_parent_span_id || @root_span_id
671
+ ReplayContext.current&.dig(:selective_replay)&.enter(
672
+ trace_function_key: @trace_function_key, span_name: metadata.name, span_id:, parent_span_id:,
673
+ args:, kwargs:, safety_mock: false, reuse_allowed: false, can_reuse_output: false
674
+ )
670
675
  stack << OpenSpan.new(
671
676
  identity: metadata.identity,
672
677
  span_id:,
673
- parent_span_id: subtree_parent_span_id || @fiber_parents.dig(fiber_id, :span_id) || active_parent_span_id || @root_span_id,
678
+ parent_span_id:,
674
679
  name: metadata.name,
675
680
  args:,
676
681
  kwargs:,
@@ -53,7 +53,7 @@ module Bitfab
53
53
  # preserve the bound client through the fluent wrapper, matching Python's
54
54
  # `BitfabFunction.span()` and TypeScript's `BitfabFunction.withSpan()`.
55
55
  def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom",
56
- capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, client: nil, test_run_id: nil)
56
+ capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, client: nil, test_run_id: nil)
57
57
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
58
58
  method_name_str = method_name.to_s
59
59
  bound_client = client
@@ -67,7 +67,7 @@ module Bitfab
67
67
  trace_function_key:,
68
68
  span_name: name || Subtree.qualified_name(self, method_name),
69
69
  span_type: type, function_name: method_name_str, args:, kwargs:,
70
- capture_when:, mock_on_replay:, finalize:, experiment_id:, surface: :span,
70
+ capture_when:, mock_on_replay:, replay_reusable:, finalize:, experiment_id:, surface: :span, input_block: !block.nil?,
71
71
  explicit_span_receiver: self, explicit_span_method_name: method_name) do
72
72
  super(*args, **kwargs, &block)
73
73
  end
@@ -135,7 +135,7 @@ module Bitfab
135
135
  # Configure an existing method only when it is discovered beneath a
136
136
  # bitfab_trace root. Outside subtree capture the method runs unchanged.
137
137
  def self.node(klass, method_name, name: nil, type: "custom", capture: true,
138
- experiment_id: nil, mock_on_replay: nil, finalize: nil, client: nil, test_run_id: nil)
138
+ experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, client: nil, test_run_id: nil)
139
139
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
140
140
  validate_node_options!(capture:, mock_on_replay:)
141
141
  method_name_str = method_name.to_s
@@ -164,7 +164,7 @@ module Bitfab
164
164
  call.owner.client.send(:execute_span,
165
165
  trace_function_key: call.owner.trace_function_key,
166
166
  span_name:, span_type: type, function_name: method_name_str,
167
- args:, kwargs:, capture_when: "nested", mock_on_replay: policy,
167
+ args:, kwargs:, capture_when: "nested", mock_on_replay: policy, replay_reusable:, input_block: !block.nil?,
168
168
  explicit_span_receiver: self, explicit_span_method_name: method_name,
169
169
  experiment_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?, declared_node: true,
170
170
  on_start: call.method(:start), on_complete: call.method(:complete),
@@ -267,7 +267,7 @@ module Bitfab
267
267
  # When true, `client.replay(...)` returns this span's
268
268
  # historical output instead of executing the wrapped method.
269
269
  def bitfab_span(method_name, trace_function_key: nil, name: nil, type: "custom",
270
- capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, test_run_id: nil)
270
+ capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, test_run_id: nil)
271
271
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
272
272
  trace_function_key ||= @bitfab_function_key
273
273
  unless trace_function_key
@@ -278,7 +278,7 @@ module Bitfab
278
278
  # If the method already exists (inline or after-method style), wrap it immediately
279
279
  if method_defined?(method_name) || private_method_defined?(method_name)
280
280
  _bitfab_wrap_method(
281
- method_name, trace_function_key:, name:, type:, capture_when:, mock_on_replay:, finalize:, experiment_id:
281
+ method_name, trace_function_key:, name:, type:, capture_when:, mock_on_replay:, replay_reusable:, finalize:, experiment_id:
282
282
  )
283
283
  else
284
284
  # Method doesn't exist yet (before-method style) - register for method_added hook
@@ -289,6 +289,7 @@ module Bitfab
289
289
  type:,
290
290
  capture_when:,
291
291
  mock_on_replay:,
292
+ replay_reusable:,
292
293
  finalize:,
293
294
  experiment_id:
294
295
  }
@@ -328,10 +329,10 @@ module Bitfab
328
329
  # EXPERIMENTAL. Configure one method discovered beneath bitfab_trace.
329
330
  # The node inherits the active trace key and never traces by itself.
330
331
  def bitfab_node(method_name, name: nil, type: "custom", capture: true,
331
- experiment_id: nil, mock_on_replay: nil, finalize: nil, test_run_id: nil)
332
+ experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, test_run_id: nil)
332
333
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
333
334
  Traceable.send(:validate_node_options!, capture:, mock_on_replay:)
334
- config = {name:, type:, capture:, experiment_id:, mock_on_replay:, finalize:}
335
+ config = {name:, type:, capture:, experiment_id:, mock_on_replay:, replay_reusable:, finalize:}
335
336
  if method_defined?(method_name) || private_method_defined?(method_name)
336
337
  _bitfab_node_method(method_name, **config)
337
338
  else
@@ -365,7 +366,7 @@ module Bitfab
365
366
  end
366
367
 
367
368
  def _bitfab_node_method(method_name, name: nil, type: "custom", capture: true,
368
- experiment_id: nil, mock_on_replay: nil, finalize: nil)
369
+ experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil)
369
370
  Traceable.node(
370
371
  self,
371
372
  method_name,
@@ -374,6 +375,7 @@ module Bitfab
374
375
  capture:,
375
376
  experiment_id:,
376
377
  mock_on_replay:,
378
+ replay_reusable:,
377
379
  finalize:,
378
380
  client: nil
379
381
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.61.0"
4
+ VERSION = "0.61.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.61.0
4
+ version: 0.61.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -188,6 +188,7 @@ files:
188
188
  - lib/bitfab/seed.rb
189
189
  - lib/bitfab/seed_cli.rb
190
190
  - lib/bitfab/seed_context.rb
191
+ - lib/bitfab/selective_replay.rb
191
192
  - lib/bitfab/serialize.rb
192
193
  - lib/bitfab/simulation_plan.rb
193
194
  - lib/bitfab/span_context.rb