bitfab 0.61.0 → 0.61.2

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: c3ee5133d6797e0d931936274c5d05bea38532e5879f8a55567a5417cabdd04d
4
+ data.tar.gz: 57666b69b7277a81a3715a3f386d8660d47d6568ef027adc7a4a10fafc02f6c2
5
5
  SHA512:
6
- metadata.gz: e0acbbd123458aff8ac1385c256da5a29bafef8e48a948a5e3de9916e97566d700ffa693695d572cb232e979d5b24b3f8664bd02fa1a3e1659ec01f2ddda80a4
7
- data.tar.gz: 8714de8dd405dcf831e1f229cb12e9262d21092eac2b9c320804b752f80348e4ba38a613512856ad1b9aa79db8f52d3368eab3777e425b678a3cc05e9c6d54ee
6
+ metadata.gz: 8a7d3b6f1f05402242f92004139803d1246be8407c3e71a8246331e8cb2d49ed811f0e54005843549490ae385e2bec32be681392241c2a866d13d502339e2e51
7
+ data.tar.gz: 3e2a653d2de7a0d4e64424f3a40ada97e5922968e7862701241efb41a2e3a679e715feb1899ac0e7ace5144bbb4161269b52f2bf5f66e53eef0e9d854397bcf0
data/lib/bitfab/client.rb CHANGED
@@ -120,6 +120,13 @@ module Bitfab
120
120
  # Passed alongside dataset_id or dataset_ids it pins which members of that
121
121
  # selection replay, and the server rejects any ID none of those datasets
122
122
  # contains
123
+ # @param name [String, nil] What this run is testing, in a few words, such
124
+ # as 'baseline' or 'shorter system prompt'. Bitfab records the commit,
125
+ # branch, tree state, datasets, and who ran it with every experiment, so
126
+ # do not repeat them here.
127
+ # @param notes [String, nil] Run conditions Bitfab cannot see on its own,
128
+ # such as an environment override or a forced feature flag. Kept on the
129
+ # experiment next to its name (max 10,000 characters).
123
130
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
124
131
  # @param code_change_description [String, nil] optional rationale for the
125
132
  # code change being tested in this replay (stored on the experiment).
@@ -190,9 +197,9 @@ module Bitfab
190
197
  # @return [Hash] with :items, :experiment_id, :experiment_url, and the
191
198
  # deprecated aliases :test_run_id and :test_run_url carrying the same values
192
199
  def replay(receiver, method_name = nil, trace_function_key:, limit: nil, trace_ids: nil, concurrency: nil, max_concurrency: ReplayConcurrency::UNSET,
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,
200
+ name: nil, notes: 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
201
  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)
202
+ on_experiment_start: nil, experimental_selective_replay: nil)
196
203
  Replay.run(
197
204
  self,
198
205
  receiver,
@@ -201,6 +208,7 @@ module Bitfab
201
208
  limit:,
202
209
  trace_ids:,
203
210
  name:,
211
+ notes:,
204
212
  concurrency:,
205
213
  max_concurrency:,
206
214
  attempts:,
@@ -220,7 +228,8 @@ module Bitfab
220
228
  on_item_start:,
221
229
  on_item_finish:,
222
230
  on_progress:,
223
- on_experiment_start:
231
+ on_experiment_start:,
232
+ experimental_selective_replay:
224
233
  )
225
234
  end
226
235
 
@@ -337,7 +346,7 @@ module Bitfab
337
346
  # Configure an existing method only when it is discovered beneath a
338
347
  # bitfab_trace root owned by this client.
339
348
  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)
349
+ experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, test_run_id: nil)
341
350
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
342
351
  Bitfab::Traceable.node(
343
352
  klass,
@@ -347,6 +356,7 @@ module Bitfab
347
356
  capture:,
348
357
  experiment_id:,
349
358
  mock_on_replay:,
359
+ replay_reusable:,
350
360
  finalize:,
351
361
  client: self
352
362
  )
@@ -383,12 +393,14 @@ module Bitfab
383
393
  # Execute a block inside a span context, sending trace data on completion.
384
394
  # Called by Traceable, not intended for direct use.
385
395
  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,
396
+ capture_when: "always", mock_on_replay: false, replay_reusable: false, independent_root: false,
387
397
  explicit_span_receiver: nil, explicit_span_method_name: nil, experiment_id: nil, finalize: nil,
388
398
  surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
389
- result_recorded_elsewhere: false, declared_node: false)
399
+ result_recorded_elsewhere: false, declared_node: false, input_block: false)
390
400
  return yield unless should_record?
391
401
 
402
+ selective_input = !input_block && (replay_reusable || mock_on_replay) && SelectiveReplay.inputs(args, kwargs)
403
+
392
404
  @http_client.refresh_simulation_plan
393
405
 
394
406
  resolved_capture_when = capture_when.to_s
@@ -465,7 +477,20 @@ module Bitfab
465
477
  # line up with `build_mock_tree`'s sequential numbering for the same
466
478
  # (key, name) pair. Different (key, name) pairs have independent
467
479
  # counters: they cannot shift each other.
468
- call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
480
+ selective = replay_ctx&.dig(:selective_replay)
481
+ if selective
482
+ mocked, output = selective.enter(trace_function_key:, span_name:, span_id:, parent_span_id:,
483
+ args:, kwargs:, safety_mock: mock_on_replay, reuse_allowed: replay_reusable, can_reuse_output: finalize.nil? && !input_block)
484
+ if mocked
485
+ send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:, span_name:, span_type:,
486
+ function_name:, args:, kwargs:, mocked_output: output, started_at:,
487
+ experiment_id: resolved_experiment_id, input_source_span_id: resolved_input_source_span_id,
488
+ mock_source: "recorded", instrumentation: (surface == :trace) ? "trace" : "span", declared_node:)
489
+ on_complete&.call(result: output, error: nil, ended_at: Bitfab.now_iso_timestamp)
490
+ return output
491
+ end
492
+ end
493
+ call_index = selective ? nil : advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
469
494
  if call_index
470
495
  mock_result = check_mock_replay(
471
496
  replay_ctx,
@@ -561,6 +586,14 @@ module Bitfab
561
586
 
562
587
  on_complete&.call(result: recorded_result, error: recorded_error, ended_at:)
563
588
 
589
+ span_data = span_data.merge("replay_reusable" => replay_reusable)
590
+ output_json = finalize.nil? && !result.is_a?(Enumerator) && SelectiveReplay.fingerprint(final_result)
591
+ if selective_input && output_json && recorded_error.nil? && !content_off
592
+ recorded_args, recorded_kwargs = JSON.parse(selective_input)
593
+ span_data = span_data.merge("replay_json_safe" => true,
594
+ "replay_recording" => {"input" => recorded_args, "kwargs" => recorded_kwargs, "output" => JSON.parse(output_json)})
595
+ end
596
+
564
597
  send_span(
565
598
  span_data: content_off ? span_data.merge("content_off_by_simulation_plan" => true) : span_data,
566
599
  instrumentation: recorded_instrumentation,
@@ -1246,7 +1279,7 @@ module Bitfab
1246
1279
  # values warn once and default to "always"
1247
1280
  # @param mock_on_replay [Boolean] mark this span for the default "marked"
1248
1281
  # 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)
1282
+ 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
1283
  experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
1251
1284
  Bitfab::Traceable.wrap(
1252
1285
  klass,
@@ -1256,6 +1289,7 @@ module Bitfab
1256
1289
  type:,
1257
1290
  capture_when:,
1258
1291
  mock_on_replay:,
1292
+ replay_reusable:,
1259
1293
  finalize:,
1260
1294
  experiment_id:,
1261
1295
  client: @client
@@ -261,8 +261,8 @@ module Bitfab
261
261
  # runnable graders at completion; each must be an active/live grader in the
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
- 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)
264
+ code_change_files: nil, experiment_group_id: nil, name: nil, notes: 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, experimental_selective_replay: nil)
266
266
  payload = {
267
267
  "traceFunctionKey" => trace_function_key
268
268
  }
@@ -271,6 +271,7 @@ module Bitfab
271
271
  payload["limit"] = limit unless limit.nil?
272
272
  payload["traceIds"] = trace_ids if trace_ids
273
273
  payload["name"] = name unless name.nil?
274
+ payload["notes"] = notes unless notes.nil?
274
275
  payload["codeChangeDescription"] = code_change_description unless code_change_description.nil?
275
276
  payload["codeChangeFiles"] = normalize_code_change_files(code_change_files) unless code_change_files.nil?
276
277
  payload["experimentGroupId"] = experiment_group_id unless experiment_group_id.nil?
@@ -288,6 +289,7 @@ module Bitfab
288
289
  payload["onlyWithAssertions"] = true if only_with_assertions
289
290
  payload["judgeAssertions"] = true if judge_assertions
290
291
  payload["includeOriginalMetadata"] = true if include_original_metadata
292
+ payload["experimentalSelectiveReplay"] = experimental_selective_replay if experimental_selective_replay
291
293
  payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
292
294
  git_state = GitState.resolved
293
295
  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:,
@@ -203,7 +205,13 @@ module Bitfab
203
205
  # already determines how many traces replay. Supplying trace_ids also
204
206
  # emits a warning.
205
207
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
206
- # @param name [String, nil] optional display name for the resulting experiment
208
+ # @param name [String, nil] What this run is testing, in a few words, such
209
+ # as 'baseline' or 'shorter system prompt'. Bitfab records the commit,
210
+ # branch, tree state, datasets, and who ran it with every experiment, so
211
+ # do not repeat them here.
212
+ # @param notes [String, nil] Run conditions Bitfab cannot see on its own,
213
+ # such as an environment override or a forced feature flag. Kept on the
214
+ # experiment next to its name (max 10,000 characters).
207
215
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
208
216
  # @param code_change_description [String, nil] optional rationale for the
209
217
  # code change being tested in this replay (stored on the experiment).
@@ -272,11 +280,11 @@ module Bitfab
272
280
  # crashes the run.
273
281
  # @return [Hash] with :items, :experiment_id, :experiment_url, and the
274
282
  # deprecated aliases :test_run_id and :test_run_url carrying the same values
275
- def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
283
+ def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, notes: nil,
276
284
  concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
277
285
  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
286
  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)
287
+ on_experiment_start: nil, experimental_selective_replay: nil)
280
288
  concurrency_options = ReplayConcurrency.resolve(concurrency, attempts, max_concurrency)
281
289
  attempts = concurrency_options.attempts
282
290
  max_concurrency = concurrency_options.max_concurrency
@@ -337,6 +345,12 @@ module Bitfab
337
345
  # both take precedence over the base mock strategy.
338
346
  registered_overrides = client.instance_variable_get(:@mock_overrides) || []
339
347
  resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides
348
+ if experimental_selective_replay
349
+ SelectiveReplay.wire_options(experimental_selective_replay)
350
+ unless mock.to_s == "marked" && resolved_overrides.empty? && concurrency_options.primitive != "process"
351
+ raise ArgumentError, "experimental_selective_replay requires marked mocks, no overrides, and in-process replay"
352
+ end
353
+ end
340
354
 
341
355
  # limit is meaningless with explicit trace_ids (the ID list determines
342
356
  # the count), so it's omitted from the request entirely.
@@ -363,6 +377,7 @@ module Bitfab
363
377
  effective_limit,
364
378
  trace_ids:,
365
379
  name:,
380
+ notes:,
366
381
  code_change_description:,
367
382
  code_change_files:,
368
383
  experiment_group_id:,
@@ -373,7 +388,8 @@ module Bitfab
373
388
  only_with_assertions:,
374
389
  judge_assertions:,
375
390
  include_original_metadata: !adapt_inputs.nil?,
376
- db_branch_settings: resolved_db_branch_settings
391
+ db_branch_settings: resolved_db_branch_settings,
392
+ **(experimental_selective_replay ? {experimental_selective_replay: SelectiveReplay.wire_options(experimental_selective_replay)} : {})
377
393
  )
378
394
  experiment_id = replay_data["experimentId"] || replay_data["testRunId"]
379
395
  experiment_url = replay_data["experimentUrl"] || replay_data["testRunUrl"]
@@ -400,7 +416,7 @@ module Bitfab
400
416
  process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock.to_s,
401
417
  adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
402
418
  on_item_finish: item_finish_callback,
403
- mock_overrides: resolved_overrides, dry_run:, process_executor:)
419
+ mock_overrides: resolved_overrides, dry_run:, process_executor:, experimental_selective_replay:)
404
420
  else
405
421
  []
406
422
  end
@@ -776,7 +792,7 @@ module Bitfab
776
792
  # Process all replay items, optionally in parallel using threads.
777
793
  def process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock_strategy,
778
794
  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)
795
+ mock_overrides: [], dry_run: false, process_executor: nil, experimental_selective_replay: nil)
780
796
  concurrency = max_concurrency || server_items.length
781
797
 
782
798
  # Lifecycle callbacks run from worker threads in the parallel path, so the
@@ -887,7 +903,7 @@ module Bitfab
887
903
  process_executor.call(item, experiment_id, mock_strategy, include_db_branch_lease, db_branch_settings, dry_run:)
888
904
  else
889
905
  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:)
906
+ adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:, experimental_selective_replay:)
891
907
  end
892
908
  end
893
909
 
@@ -930,7 +946,7 @@ module Bitfab
930
946
  # than propagated, so one bad trace never aborts the whole replay run
931
947
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
932
948
  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)
949
+ adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false, experimental_selective_replay: nil)
934
950
  metrics = extract_server_item_metrics(server_item)
935
951
  attempt = server_item["attempt"] || 0
936
952
  # The ORIGINAL (historical) trace/span this item replays. Canonical
@@ -945,6 +961,7 @@ module Bitfab
945
961
  # snapshot ref arrive without a lease, so the app uses its normal DB
946
962
  # path. Unsafe calls on that path still require replay mocking.
947
963
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
964
+ selective_replay = experimental_selective_replay && SelectiveReplay.new(server_item["selectiveReplayPlan"], experimental_selective_replay)
948
965
  # A resolve that was ATTEMPTED and failed is different: the caller asked
949
966
  # for a branch, so running their method against live data would produce a
950
967
  # result that looks valid and is not. Fail the item instead, loudly.
@@ -994,7 +1011,7 @@ module Bitfab
994
1011
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
995
1012
  include_outputs = mock_strategy == "all"
996
1013
  mock_tree = nil
997
- if !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
1014
+ if !selective_replay && !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
998
1015
  begin
999
1016
  tree = http_client.get_span_tree(
1000
1017
  original_span_id,
@@ -1035,6 +1052,7 @@ module Bitfab
1035
1052
  span["id"],
1036
1053
  metrics,
1037
1054
  input_source_trace_id: span["externalTraceId"],
1055
+ selective_replay:,
1038
1056
  mock_strategy:,
1039
1057
  mock_tree:,
1040
1058
  mock_overrides:,
@@ -1282,7 +1300,7 @@ module Bitfab
1282
1300
  def execute_item(item, receiver, method_name, experiment_id, input_source_span_id = nil, metrics = {},
1283
1301
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1284
1302
  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)
1303
+ source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false, selective_replay: nil)
1286
1304
  args, kwargs = Serialize.deserialize_inputs(item)
1287
1305
 
1288
1306
  fn_result = nil
@@ -1303,6 +1321,7 @@ module Bitfab
1303
1321
  begin
1304
1322
  ReplayContext.with_context(
1305
1323
  replay_attempt: attempt,
1324
+ selective_replay:,
1306
1325
  experiment_id:,
1307
1326
  input_source_span_id:,
1308
1327
  input_source_trace_id:,
@@ -1338,6 +1357,7 @@ module Bitfab
1338
1357
  receiver.send(method_name, *args, **kwargs)
1339
1358
  end
1340
1359
  fn_result = fn_result.to_a if fn_result.is_a?(Enumerator)
1360
+ selective_replay&.assert_no_safety_conflict!
1341
1361
  replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1342
1362
  rescue => e
1343
1363
  # The method ran and raised, so it still has a duration.
@@ -1357,6 +1377,7 @@ module Bitfab
1357
1377
 
1358
1378
  item_error = fn_error || replay_error
1359
1379
  {
1380
+ **(selective_replay ? {selective_replay: selective_replay.report} : {}),
1360
1381
  attempt:,
1361
1382
  ingestion_type: metrics[:ingestion_type],
1362
1383
  input: kwargs.empty? ? args : [*args, kwargs],
@@ -19,7 +19,7 @@ module Bitfab
19
19
  # Project-owned registry of production replay roots.
20
20
  class ReplayRegistry
21
21
  OPTION_NAMES = %i[
22
- limit trace_ids name concurrency max_concurrency code_change_description
22
+ limit trace_ids name notes concurrency max_concurrency code_change_description
23
23
  code_change_files experiment_group_id dataset_id dataset_ids grader_ids mock
24
24
  mock_override adapt_inputs db_branch attempts only_with_assertions judge_assertions dry_run on_item_finish
25
25
  ].freeze
@@ -186,7 +186,7 @@ module Bitfab
186
186
  options[:limit] = bound || 10
187
187
  end
188
188
 
189
- %i[name max_concurrency experiment_group_id dataset_ids grader_ids mock attempts only_with_assertions dry_run].each do |key|
189
+ %i[name notes max_concurrency experiment_group_id dataset_ids grader_ids mock attempts only_with_assertions dry_run].each do |key|
190
190
  options[key] = args[key] unless args[key].nil?
191
191
  end
192
192
 
@@ -284,7 +284,13 @@ module Bitfab
284
284
  options.on("--seed PATH", "--cases PATH") { |value| args[:seed] = value }
285
285
  options.on("--run") { args[:run] = true }
286
286
  options.on("--from-trace IDS") { |value| args[:from_trace] = comma_separated("--from-trace", value) }
287
- options.on("--name NAME") { |value| args[:name] = value }
287
+ options.on("--name NAME",
288
+ "What this run is testing, in a few words, such as 'baseline' or 'shorter system prompt'. " \
289
+ "Bitfab records the commit, branch, tree state, datasets, and who ran it with every experiment, " \
290
+ "so do not repeat them here.") { |value| args[:name] = value }
291
+ options.on("--notes TEXT",
292
+ "Run conditions Bitfab cannot see on its own, such as an environment override or a forced feature flag. " \
293
+ "Kept on the experiment next to its name.") { |value| args[:notes] = value }
288
294
  options.on("--concurrency N", Integer) do |value|
289
295
  args[:max_concurrency] = positive_integer("--concurrency", value)
290
296
  end
@@ -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.2"
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.2
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