bitfab 0.65.7 → 0.67.0

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: 84d81613e344f01a011f28ac437476d22e73867eb7ab2df04579075730b1c73b
4
- data.tar.gz: d3bb4d9f79d587dca9ee2ef2e1ee36a41fbeb59da7cb69960bf9fdbe75e8d663
3
+ metadata.gz: 455ac9a2dfa11b39c17550b3f86f6ffa87f3ea0a9684fb032722184a705da7e0
4
+ data.tar.gz: 11c0f2491a21515f3fc75f4f4f8eb98f544b7e6d5d3c9d1c7fc09e0728787b16
5
5
  SHA512:
6
- metadata.gz: 18ceec3ef05433309efff835c6bcf050789439e9d0424f9919972a70dd5d5b358eb07641fbed220d36b52e92f503d719dee044ba18003fb1371d77d0dba2e8e3
7
- data.tar.gz: a6743709c41b9b69597e43e5c1336af5038bf9e5c56d5e3b247051e12fb4527d9732d79bb98e3587f90aedfbc59f7e23ebeae6c0d02e1bfad712f37d6789dd00
6
+ metadata.gz: d1f41b10072dd2b9b52f2a91ef5265a91805d2aa8129a05a30a303f6f55831934c8206eca6fd215ac938a526f499e9bc2b38a2e74476c8aa06e6c675e14d9b7f
7
+ data.tar.gz: d1e5d1ac8216e5505b5d5da628d6914349fe7ee6018fa55616b20a5ba3ca66e647484f4db176ee85feac28c476f702e831bfcf9ae3fa5ff5084d9d0570584ade
data/lib/bitfab/client.rb CHANGED
@@ -37,7 +37,7 @@ module Bitfab
37
37
 
38
38
  attr_reader :service_url
39
39
 
40
- attr_reader :datasets, :categories, :organization_members, :traces, :spans, :labels, :graders, :experiments
40
+ attr_reader :datasets, :categories, :organization_members, :traces, :spans, :verdicts, :graders, :experiments
41
41
 
42
42
  # @deprecated Use {#categories}. Returns the same object.
43
43
  def assertion_categories
@@ -45,6 +45,12 @@ module Bitfab
45
45
  @categories
46
46
  end
47
47
 
48
+ # @deprecated Use {#verdicts}. Returns the same object.
49
+ def labels
50
+ Bitfab.warn_once("labels-deprecated", "labels is deprecated; use verdicts instead.")
51
+ @verdicts
52
+ end
53
+
48
54
  def initialize(api_key: nil, service_url: nil, capture_enabled: nil, enabled: nil, strict: false, simulation_plan: true, timeout: 120, trace_across_threads: nil, env_vars: nil, baml_client: nil, db_snapshot: nil)
49
55
  @db_snapshot = DbSnapshot.normalize_config(db_snapshot)
50
56
  @env_vars = env_vars || ENV.to_h
@@ -65,7 +71,7 @@ module Bitfab
65
71
  @organization_members = OrganizationMembers.new(@http_client)
66
72
  @traces = Traces.new(@http_client)
67
73
  @spans = Spans.new(@http_client)
68
- @labels = Labels.new(@http_client)
74
+ @verdicts = Verdicts.new(@http_client)
69
75
  @graders = Graders.new(@http_client)
70
76
  @experiments = Experiments.new(@http_client)
71
77
  # Mock overrides registered via register_mock_override, applied to every
@@ -2,15 +2,69 @@
2
2
 
3
3
  require "uri"
4
4
 
5
+ require_relative "verdicts"
6
+ require_relative "warn_once"
7
+
5
8
  module Bitfab
9
+ # Reads and writes grader verdicts: a grader's pass or fail on a whole trace, with its reason.
6
10
  class Graders
7
- GRADER_LABELS_PATH = "/api/sdk/graderLabels"
11
+ GRADER_VERDICTS_PATH = "/api/sdk/graderVerdicts"
12
+ # @deprecated Use {GRADER_VERDICTS_PATH}.
13
+ GRADER_LABELS_PATH = GRADER_VERDICTS_PATH
14
+ GRADER_VERDICT_VALUES = ["pass", "fail"].freeze
15
+ LABEL_UNSET = Object.new.freeze
16
+
17
+ private_constant :GRADER_VERDICT_VALUES, :LABEL_UNSET
8
18
 
9
19
  def initialize(http_client)
10
20
  @http_client = http_client
11
21
  end
12
22
 
23
+ # Reads grader verdicts for +trace_ids:+, for one +grader_id:+, or both. Each verdict carries
24
+ # +verdict+ ("pass", "fail", or nil), +reason+, +failureDiagnostic+, +confidence+, and +source+.
25
+ # Raises when neither selector is given.
26
+ def get_verdicts(trace_ids: nil, grader_id: nil, limit: nil)
27
+ read(trace_ids:, grader_id:, limit:)["verdicts"]
28
+ end
29
+
30
+ # Saves an external grader's verdict on one trace. +verdict:+ is "pass" or "fail". The deprecated
31
+ # +label:+ (true or false) is still accepted in place of +verdict:+.
32
+ def save_verdict(grader_id:, trace_id:, verdict: nil, reason: nil, failure_diagnostic: nil, confidence: nil,
33
+ label: LABEL_UNSET)
34
+ save_verdict_all([verdict_update(grader_id:, trace_id:, verdict:, reason:, failure_diagnostic:, confidence:,
35
+ label:)]).first
36
+ end
37
+
38
+ # Saves several external grader verdict entries (+graderId+, +traceId+, +verdict+, and optional
39
+ # +reason+, +failureDiagnostic+, +confidence+) in one request.
40
+ def save_verdict_all(verdicts)
41
+ write(verdicts)["verdicts"]
42
+ end
43
+
44
+ # @deprecated Use {#get_verdicts}. Returns the rows in their old shape, with +label+ as true or false.
13
45
  def get_labels(trace_ids: nil, grader_id: nil, limit: nil)
46
+ Bitfab.warn_once("grader-get-labels-deprecated", "graders.get_labels is deprecated; use graders.get_verdicts instead.")
47
+ read(trace_ids:, grader_id:, limit:)["labels"]
48
+ end
49
+
50
+ # @deprecated Use {#save_verdict}.
51
+ def save_label(grader_id:, trace_id:, label:, reason: nil, failure_diagnostic: nil, confidence: nil)
52
+ Bitfab.warn_once("grader-save-label-deprecated", "graders.save_label is deprecated; use graders.save_verdict instead.")
53
+ update = verdict_update(grader_id:, trace_id:, verdict: VerdictInput.legacy_verdict(label), reason:,
54
+ failure_diagnostic:, confidence:, label: LABEL_UNSET)
55
+ write([update])["labels"].first
56
+ end
57
+
58
+ # @deprecated Use {#save_verdict_all}.
59
+ def save_label_all(labels)
60
+ Bitfab.warn_once("grader-save-label-all-deprecated",
61
+ "graders.save_label_all is deprecated; use graders.save_verdict_all instead.")
62
+ write(labels.map { |entry| legacy_entry(entry) })["labels"]
63
+ end
64
+
65
+ private
66
+
67
+ def read(trace_ids:, grader_id:, limit:)
14
68
  if (trace_ids.nil? || trace_ids.empty?) && grader_id.nil?
15
69
  raise ArgumentError, "Pass trace_ids, grader_id, or both: there is nothing to read otherwise."
16
70
  end
@@ -19,19 +73,29 @@ module Bitfab
19
73
  params["traceIds"] = trace_ids.join(",") unless trace_ids.nil? || trace_ids.empty?
20
74
  params["graderId"] = grader_id unless grader_id.nil?
21
75
  params["limit"] = limit unless limit.nil?
22
- @http_client.get("#{GRADER_LABELS_PATH}?#{URI.encode_www_form(params)}")["labels"]
76
+ @http_client.get("#{GRADER_VERDICTS_PATH}?#{URI.encode_www_form(params)}")
23
77
  end
24
78
 
25
- def save_label(grader_id:, trace_id:, label:, reason: nil, failure_diagnostic: nil, confidence: nil)
26
- update = {"graderId" => grader_id, "traceId" => trace_id, "label" => label}
79
+ def write(verdicts)
80
+ verdicts = verdicts.map { |entry| VerdictInput.normalize_entry(entry, GRADER_VERDICT_VALUES) }
81
+ @http_client.request(GRADER_VERDICTS_PATH, {"verdicts" => verdicts})
82
+ end
83
+
84
+ def legacy_entry(entry)
85
+ label_key = [:label, "label"].find { |key| entry.key?(key) }
86
+ verdict_present = entry.key?(:verdict) || entry.key?("verdict")
87
+ return entry if label_key.nil? || verdict_present
88
+
89
+ entry.except(label_key).merge("verdict" => VerdictInput.legacy_verdict(entry[label_key]))
90
+ end
91
+
92
+ def verdict_update(grader_id:, trace_id:, verdict:, reason:, failure_diagnostic:, confidence:, label:)
93
+ verdict = VerdictInput.resolve_verdict(verdict, label, GRADER_VERDICT_VALUES, LABEL_UNSET)
94
+ update = {"graderId" => grader_id, "traceId" => trace_id, "verdict" => verdict}
27
95
  update["reason"] = reason unless reason.nil?
28
96
  update["failureDiagnostic"] = failure_diagnostic unless failure_diagnostic.nil?
29
97
  update["confidence"] = confidence unless confidence.nil?
30
- save_label_all([update]).first
31
- end
32
-
33
- def save_label_all(labels)
34
- @http_client.request(GRADER_LABELS_PATH, {"labels" => labels})["labels"]
98
+ update
35
99
  end
36
100
  end
37
101
  end
data/lib/bitfab/jev.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ module Jev
5
+ PROVIDER = "typesafe"
6
+ SYSTEM_ONE = :system_one
7
+ WRAPPER_SCOPE_KEY = :bitfab_jev_wrapper_scope
8
+
9
+ module_function
10
+
11
+ def jev_shaped?(reply)
12
+ return false if reply.nil? || reply.is_a?(String) || reply.is_a?(Numeric) || reply.is_a?(Symbol)
13
+
14
+ usage = field(reply, :usage)
15
+ field(reply, :model).is_a?(String) && !usage.nil? && !usage.is_a?(String) && !usage.is_a?(Numeric)
16
+ rescue
17
+ false
18
+ end
19
+
20
+ def auto_context(reply, duration_ms)
21
+ jev_shaped?(reply) ? context(reply, duration_ms) : nil
22
+ rescue
23
+ nil
24
+ end
25
+
26
+ def mark_recorded(reply)
27
+ Thread.current[WRAPPER_SCOPE_KEY]&.push(reply)
28
+ end
29
+
30
+ def watch_recorded
31
+ previous = Thread.current[WRAPPER_SCOPE_KEY]
32
+ scope = []
33
+ Thread.current[WRAPPER_SCOPE_KEY] = scope
34
+ reply = yield
35
+ [reply, !scope.empty?]
36
+ ensure
37
+ Thread.current[WRAPPER_SCOPE_KEY] = previous
38
+ previous&.concat(scope) if scope
39
+ end
40
+
41
+ def record_node_call(method_name)
42
+ return yield unless method_name.to_sym == SYSTEM_ONE
43
+
44
+ span = Bitfab.current_span
45
+ started_at = monotonic_ms
46
+ reply, recorded_inside = watch_recorded { yield }
47
+ begin
48
+ entry = recorded_inside ? nil : auto_context(reply, monotonic_ms - started_at)
49
+ if entry
50
+ span.add_context(entry)
51
+ mark_recorded(reply)
52
+ Subtree.active_sessions.each(&:jev_recorded_below!)
53
+ end
54
+ rescue
55
+ nil
56
+ end
57
+ reply
58
+ end
59
+
60
+ def field(value, name)
61
+ if value.is_a?(Hash)
62
+ value.key?(name) ? value[name] : value[name.to_s]
63
+ elsif value.respond_to?(name)
64
+ value.public_send(name)
65
+ end
66
+ end
67
+
68
+ def context(reply, duration_ms)
69
+ return nil if reply.nil?
70
+
71
+ usage = field(reply, :usage)
72
+ model = field(reply, :model)
73
+ input_tokens = usage.nil? ? nil : field(usage, :input_tokens)
74
+ output_tokens = usage.nil? ? nil : field(usage, :output_tokens)
75
+ entry = {provider: PROVIDER}
76
+ entry[:model] = model if model.is_a?(String)
77
+ entry[:inputTokens] = input_tokens if input_tokens.is_a?(Numeric)
78
+ entry[:outputTokens] = output_tokens if output_tokens.is_a?(Numeric)
79
+ entry[:durationMs] = duration_ms
80
+ entry
81
+ end
82
+
83
+ def record(span, reply, duration_ms)
84
+ entry = context(reply, duration_ms)
85
+ span.add_context(entry) if entry
86
+ rescue
87
+ nil
88
+ end
89
+
90
+ def monotonic_ms
91
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
92
+ end
93
+
94
+ class Wrapper
95
+ def initialize(callable, on_reply)
96
+ @callable = callable
97
+ @on_reply = on_reply
98
+ end
99
+
100
+ # Call the wrapped Jev method with the given arguments and return its
101
+ # reply unchanged. Errors from the call are raised as-is and record nothing.
102
+ def call(*args, **kwargs, &block)
103
+ span = Bitfab.current_span
104
+ started_at = Jev.monotonic_ms
105
+ reply, recorded = Jev.watch_recorded { @callable.call(*args, **kwargs, &block) }
106
+ Jev.record(span, reply, Jev.monotonic_ms - started_at) unless recorded
107
+ begin
108
+ @on_reply&.call(reply)
109
+ rescue
110
+ nil
111
+ end
112
+ reply
113
+ end
114
+
115
+ # A Proc that calls this wrapper, so it can be passed with `&`.
116
+ def to_proc
117
+ method(:call).to_proc
118
+ end
119
+ end
120
+ end
121
+
122
+ class Client
123
+ # Record the model, token usage, and duration of a Jev (TypeSafe System One)
124
+ # call on the span that is current when the call starts. Opens no span of
125
+ # its own and never raises from recording.
126
+ #
127
+ # @example
128
+ # ask_jev = Bitfab.client.wrap_jev(jev.method(:system_one))
129
+ # reply = ask_jev.call(state:, questions:)
130
+ #
131
+ # @param callable [#call, nil] calls Jev and returns its reply; pass a block instead to wrap the block
132
+ # @param on_reply [#call, nil] called with each reply after it is recorded; its errors are ignored
133
+ # @return [Bitfab::Jev::Wrapper] responds to +call+ with the same arguments as the wrapped callable
134
+ def wrap_jev(callable = nil, on_reply: nil, &block)
135
+ raise ArgumentError, "wrap_jev takes a callable or a block, not both." if callable && block
136
+ target = callable || block
137
+ raise ArgumentError, "wrap_jev requires a callable that calls Jev (e.g., jev.method(:system_one))." unless target.respond_to?(:call)
138
+
139
+ Jev::Wrapper.new(target, on_reply)
140
+ end
141
+ end
142
+
143
+ class BitfabFunction
144
+ # Jev wrappers record onto the currently active span under any key.
145
+ def wrap_jev(*args, **kwargs, &block)
146
+ @client.wrap_jev(*args, **kwargs, &block)
147
+ end
148
+ end
149
+ end
data/lib/bitfab/labels.rb CHANGED
@@ -1,143 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
- module Bitfab
6
- class Labels
7
- LABELS_PATH = "/api/sdk/traces/labels"
8
- HUMAN_LABELS_PATH = "#{LABELS_PATH}/human"
9
- EVIDENCE_UNSET = Object.new.freeze
10
-
11
- private_constant :EVIDENCE_UNSET
12
-
13
- def initialize(http_client)
14
- @http_client = http_client
15
- end
16
-
17
- def save(label:, annotation:, assertion_id:, trace_id: nil, original_trace_id: nil, attempt: nil,
18
- confidence: nil, experiment_id: nil, evidence: EVIDENCE_UNSET, test_run_id: nil)
19
- experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
20
- update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
21
- update["label"] = label
22
- update["annotation"] = annotation
23
- update["confidence"] = confidence unless confidence.nil?
24
- update["evidence"] = evidence unless evidence.equal?(EVIDENCE_UNSET)
25
- save_all([update], experiment_id:).first
26
- end
27
-
28
- def save_all(labels, experiment_id: nil, test_run_id: nil)
29
- experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
30
- require_assertion_ids(labels)
31
- payload = {"labels" => labels}
32
- unless experiment_id.nil?
33
- payload["experimentId"] = experiment_id
34
- payload["testRunId"] = experiment_id
35
- end
36
- @http_client.request(LABELS_PATH, payload)["labels"]
37
- end
38
-
39
- def skip(assertion_id:, trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil,
40
- test_run_id: nil, annotation: nil)
41
- experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
42
- update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:).merge("skip" => true)
43
- update["annotation"] = annotation unless annotation.nil?
44
- save_all([update], experiment_id:).first
45
- end
46
-
47
- # Records that the evidence does not show the part of the run this assertion checks, so there was
48
- # nothing to judge. Counted as a fail in every pass rate, but stored and read back as an abstention,
49
- # apart from a fail. +annotation:+ says what the evidence would have had to show.
50
- def abstain(assertion_id:, trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil,
51
- test_run_id: nil, annotation: nil)
52
- experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
53
- update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:).merge("abstain" => true)
54
- update["annotation"] = annotation unless annotation.nil?
55
- save_all([update], experiment_id:).first
56
- end
57
-
58
- def archive(trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil, assertion_id: nil, test_run_id: nil)
59
- experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
60
- update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
61
- save_all([update.merge("archive" => true)], experiment_id:).first
62
- end
63
-
64
- def save_human(label:, annotation:, trace_id:, assertion_id:, confidence: nil, evidence: EVIDENCE_UNSET)
65
- update = {"traceId" => trace_id, "assertionId" => assertion_id, "label" => label, "annotation" => annotation}
66
- update["confidence"] = confidence unless confidence.nil?
67
- update["evidence"] = evidence unless evidence.equal?(EVIDENCE_UNSET)
68
- save_human_all([update]).first
69
- end
70
-
71
- def save_human_all(labels)
72
- require_human_assertion_ids(labels)
73
- @http_client.request(HUMAN_LABELS_PATH, {"labels" => labels})["labels"]
74
- end
75
-
76
- def get(trace_id)
77
- get_all([trace_id]).first
78
- end
79
-
80
- def get_all(trace_ids)
81
- return [] if trace_ids.empty?
82
-
83
- query = URI.encode_www_form("traceIds" => trace_ids.join(","))
84
- @http_client.get("#{LABELS_PATH}?#{query}")["labels"]
85
- end
86
-
87
- def generate_label_evidence(trace_id, assertion_id)
88
- encoded_trace_id = URI.encode_www_form_component(trace_id).gsub("+", "%20")
89
- encoded_assertion_id = URI.encode_www_form_component(assertion_id).gsub("+", "%20")
90
- @http_client.get(
91
- "/api/sdk/traces/#{encoded_trace_id}/assertions/#{encoded_assertion_id}/evidence"
92
- )["evidence"]
93
- end
94
-
95
- private
96
-
97
- def require_assertion_ids(labels)
98
- unscoped = labels.reject do |entry|
99
- entry_value(entry, "archive") == true || !blank?(entry_value(entry, "assertionId"))
100
- end
101
- return if unscoped.empty?
102
-
103
- targets = unscoped.map { |entry| entry_value(entry, "traceId") || entry_value(entry, "originalTraceId") }
104
- raise ArgumentError, "Agent verdicts must name an assertion: #{targets.join(", ")}. " \
105
- "Pass the assertion_id of the assertion each verdict scores to labels.save, save_all, skip, and abstain. " \
106
- "Only archive may omit it, to clear an old whole-trace verdict."
107
- end
108
-
109
- def require_human_assertion_ids(labels)
110
- unscoped = labels.select { |entry| blank?(entry_value(entry, "assertionId")) }
111
- return if unscoped.empty?
112
-
113
- targets = unscoped.map { |entry| entry_value(entry, "traceId") }
114
- raise ArgumentError, "Human verdicts must name an assertion: #{targets.join(", ")}. " \
115
- "Pass the assertion_id of the assertion each verdict scores to labels.save_human and save_human_all."
116
- end
117
-
118
- def entry_value(entry, key)
119
- entry.key?(key) ? entry[key] : entry[key.to_sym]
120
- end
121
-
122
- def blank?(value)
123
- value.nil? || value.to_s.empty?
124
- end
125
-
126
- def label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
127
- if trace_id.nil? == original_trace_id.nil?
128
- raise ArgumentError, "Pass exactly one of trace_id or original_trace_id. " \
129
- "Use original_trace_id for a replay verdict, with the experiment_id it ran under."
130
- end
131
-
132
- update = {}
133
- update["assertionId"] = assertion_id unless assertion_id.nil?
134
- if trace_id.nil?
135
- update["originalTraceId"] = original_trace_id
136
- update["attempt"] = attempt unless attempt.nil?
137
- else
138
- update["traceId"] = trace_id
139
- end
140
- update
141
- end
142
- end
143
- end
3
+ require_relative "verdicts"
data/lib/bitfab/replay.rb CHANGED
@@ -549,7 +549,7 @@ module Bitfab
549
549
  warn "Bitfab: server has no persisted trace for #{missing.length} of " \
550
550
  "#{completed_count} completed replay item(s) " \
551
551
  "(experiment #{experiment_id}). Their replay token usage is " \
552
- "unavailable and they cannot be labeled."
552
+ "unavailable and they cannot be given a verdict."
553
553
  end
554
554
  end
555
555
 
@@ -37,6 +37,7 @@ module Bitfab
37
37
  :name,
38
38
  :excluded,
39
39
  :passthrough,
40
+ :jev,
40
41
  keyword_init: true
41
42
  )
42
43
 
@@ -55,6 +56,8 @@ module Bitfab
55
56
  :declared_node,
56
57
  :contexts,
57
58
  :prompt,
59
+ :jev_started_at,
60
+ :jev_recorded_below,
58
61
  keyword_init: true
59
62
  )
60
63
 
@@ -506,7 +509,7 @@ module Bitfab
506
509
 
507
510
  return if event.event == :call && skip_boundary_call?(event)
508
511
 
509
- resolved_path = owned_path(event.path)
512
+ resolved_path = traced_path(event)
510
513
  return unless resolved_path
511
514
 
512
515
  case event.event
@@ -523,6 +526,12 @@ module Bitfab
523
526
  @exclude[method_name.to_s] == true
524
527
  end
525
528
 
529
+ def jev_recorded_below!
530
+ @stacks[Fiber.current.object_id]&.each do |entry|
531
+ entry.jev_recorded_below = true if entry.is_a?(OpenSpan)
532
+ end
533
+ end
534
+
526
535
  def reserve_node
527
536
  stack = current_stack(Fiber.current.object_id)
528
537
  return true if captured_depth(stack) < @max_depth
@@ -684,7 +693,9 @@ module Bitfab
684
693
  args:,
685
694
  kwargs:,
686
695
  started_at: now,
687
- span_data: without_content ? {"content_off_by_simulation_plan" => true} : nil
696
+ span_data: without_content ? {"content_off_by_simulation_plan" => true} : nil,
697
+ span_type: metadata.jev ? "llm" : nil,
698
+ jev_started_at: metadata.jev ? Jev.monotonic_ms : nil
688
699
  )
689
700
  end
690
701
 
@@ -708,6 +719,7 @@ module Bitfab
708
719
  return unless entry.is_a?(OpenSpan) && matching_identity?(entry.identity, event, resolved_path)
709
720
 
710
721
  stack.pop
722
+ record_jev_reply(entry, event.return_value) if entry.jev_started_at
711
723
  emit(entry, result: event.return_value)
712
724
  parent = stack.last
713
725
  parent.error ||= entry.error if entry.error && parent.is_a?(OpenSpan)
@@ -715,6 +727,17 @@ module Bitfab
715
727
  @drained = true if @exhausted && captured_calls_drained?
716
728
  end
717
729
 
730
+ def record_jev_reply(entry, reply)
731
+ return if entry.error || entry.jev_recorded_below
732
+
733
+ context = Jev.auto_context(reply, Jev.monotonic_ms - entry.jev_started_at)
734
+ return unless context
735
+
736
+ entry.contexts = [context]
737
+ Jev.mark_recorded(reply)
738
+ jev_recorded_below!
739
+ end
740
+
718
741
  def record_error(event)
719
742
  entry = @stacks[Fiber.current.object_id]&.last
720
743
  return unless entry.is_a?(OpenSpan)
@@ -849,7 +872,8 @@ module Bitfab
849
872
  lineno: event.lineno,
850
873
  name: span_name(event, method_id),
851
874
  excluded: @exclude[method_id.to_s],
852
- passthrough: passthrough_wrapper?(event.parameters)
875
+ passthrough: passthrough_wrapper?(event.parameters),
876
+ jev: method_id == Jev::SYSTEM_ONE
853
877
  ).freeze
854
878
  end
855
879
 
@@ -862,6 +886,16 @@ module Bitfab
862
886
  event.callee_id || event.method_id
863
887
  end
864
888
 
889
+ def traced_path(event)
890
+ owned_path(event.path) || jev_path(event)
891
+ end
892
+
893
+ def jev_path(event)
894
+ return nil unless called_method_id(event) == Jev::SYSTEM_ONE && !sdk_frame?(event.path)
895
+
896
+ Subtree.realpath(event.path)
897
+ end
898
+
865
899
  def owned_path(path)
866
900
  @owned_paths.fetch(path) do
867
901
  resolved = Subtree.realpath(path)
@@ -945,7 +979,7 @@ module Bitfab
945
979
  return unless event.event == :call
946
980
  return if skip_boundary_call?(event)
947
981
 
948
- resolved_path = owned_path(event.path)
982
+ resolved_path = traced_path(event)
949
983
  count_dropped_call(event, resolved_path) if resolved_path
950
984
  rescue Exception # rubocop:disable Lint/RescueException
951
985
  nil
@@ -173,7 +173,9 @@ module Bitfab
173
173
  explicit_span_receiver: self, explicit_span_method_name: method_name,
174
174
  experiment_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?, declared_node: true,
175
175
  on_start: call.method(:start), on_complete: call.method(:complete),
176
- around_body: call.method(:run)) { super(*args, **kwargs, &block) }
176
+ around_body: call.method(:run)) do
177
+ Jev.record_node_call(method_name) { super(*args, **kwargs, &block) }
178
+ end
177
179
  end
178
180
  end
179
181
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "warn_once"
6
+
7
+ module Bitfab
8
+ module VerdictInput
9
+ JUDGED_VERDICTS = ["pass", "fail"].freeze
10
+
11
+ module_function
12
+
13
+ def resolve_verdict(verdict, label, allowed, unset)
14
+ unless label.equal?(unset)
15
+ raise ArgumentError, "Pass verdict: or the deprecated label:, not both." unless verdict.nil?
16
+
17
+ Bitfab.warn_once("label-keyword-deprecated", "label: is deprecated; pass verdict: \"pass\" or \"fail\" instead.")
18
+ verdict = legacy_verdict(label)
19
+ end
20
+ check_verdict(verdict, allowed)
21
+ end
22
+
23
+ def require_annotation(verdict, annotation)
24
+ return unless JUDGED_VERDICTS.include?(verdict)
25
+ return unless annotation.nil? || annotation.to_s.empty?
26
+
27
+ raise ArgumentError, "annotation: is required for a #{verdict.inspect} verdict: say why the assertion " \
28
+ "#{(verdict == "pass") ? "passed" : "failed"}. Only an \"abstained\" verdict may omit it."
29
+ end
30
+
31
+ def normalize_entry(entry, allowed)
32
+ label_key = [:label, "label"].find { |key| entry.key?(key) }
33
+ abstain_key = [:abstain, "abstain"].find { |key| entry.key?(key) }
34
+ return entry if label_key.nil? && abstain_key.nil?
35
+
36
+ verdict_key = [:verdict, "verdict"].find { |key| entry.key?(key) }
37
+ unless verdict_key.nil?
38
+ raise ArgumentError, "A verdict entry carries verdict and the deprecated #{label_key || abstain_key}, not both."
39
+ end
40
+
41
+ if !label_key.nil? && entry[abstain_key] == true
42
+ raise ArgumentError, "A verdict entry carries both the deprecated label and abstain. " \
43
+ "Send verdict: \"pass\", \"fail\", or \"abstained\" instead."
44
+ end
45
+
46
+ normalized = entry.except(label_key, abstain_key)
47
+ if label_key.nil?
48
+ Bitfab.warn_once("abstain-entry-deprecated", "abstain: true in a verdict entry is deprecated; " \
49
+ "send verdict: \"abstained\" instead.")
50
+ return normalized if entry[abstain_key] != true && action_entry?(entry)
51
+
52
+ if entry[abstain_key] != true
53
+ raise ArgumentError, "A verdict entry with abstain: #{entry[abstain_key].inspect} has no verdict. " \
54
+ "Send verdict: \"pass\", \"fail\", or \"abstained\" instead of the deprecated abstain key."
55
+ end
56
+
57
+ normalized["verdict"] = check_verdict("abstained", allowed)
58
+ else
59
+ Bitfab.warn_once("label-entry-deprecated", "label in a verdict entry is deprecated; " \
60
+ "send verdict: \"pass\" or \"fail\" instead.")
61
+ normalized["verdict"] = check_verdict(legacy_verdict(entry[label_key]), allowed)
62
+ end
63
+ normalized
64
+ end
65
+
66
+ def action_entry?(entry)
67
+ ["skip", "archive"].any? { |key| entry[key] == true || entry[key.to_sym] == true }
68
+ end
69
+
70
+ def legacy_verdict(label)
71
+ return "pass" if label == true
72
+ return "fail" if label == false
73
+
74
+ raise ArgumentError, "The deprecated label must be true or false, got #{label.inspect}."
75
+ end
76
+
77
+ def refuse_abstained_extras(entry)
78
+ return unless [entry["verdict"], entry[:verdict]].include?("abstained")
79
+
80
+ extra = ["confidence", "evidence"].find { |key| entry.key?(key) || entry.key?(key.to_sym) }
81
+ return if extra.nil?
82
+
83
+ raise ArgumentError, "An \"abstained\" verdict carries no #{extra}, since nothing was found to judge."
84
+ end
85
+
86
+ def check_verdict(verdict, allowed)
87
+ return verdict if allowed.include?(verdict)
88
+
89
+ raise ArgumentError, "verdict must be #{allowed.map(&:inspect).join(" or ")}, got #{verdict.inspect}."
90
+ end
91
+ end
92
+
93
+ private_constant :VerdictInput
94
+
95
+ # Reads and writes assertion verdicts on traces: an agent's or a person's pass, fail, or abstention
96
+ # on one assertion, with the reason behind it.
97
+ class Verdicts
98
+ VERDICTS_PATH = "/api/sdk/traces/verdicts"
99
+ HUMAN_VERDICTS_PATH = "#{VERDICTS_PATH}/human"
100
+ # @deprecated Use {VERDICTS_PATH}.
101
+ LABELS_PATH = VERDICTS_PATH
102
+ # @deprecated Use {HUMAN_VERDICTS_PATH}.
103
+ HUMAN_LABELS_PATH = HUMAN_VERDICTS_PATH
104
+ AGENT_VERDICTS = ["pass", "fail", "abstained"].freeze
105
+ HUMAN_VERDICTS = ["pass", "fail"].freeze
106
+ EVIDENCE_UNSET = Object.new.freeze
107
+ LABEL_UNSET = Object.new.freeze
108
+
109
+ private_constant :EVIDENCE_UNSET, :LABEL_UNSET
110
+
111
+ def initialize(http_client)
112
+ @http_client = http_client
113
+ end
114
+
115
+ # Saves an agent's verdict on one assertion of a trace. +verdict:+ is "pass", "fail", or "abstained",
116
+ # and +annotation:+ is the reason (required for pass and fail). Target a trace by +trace_id:+, or a
117
+ # replay by +original_trace_id:+ and +attempt:+ with the +experiment_id:+ it ran under. The deprecated
118
+ # +label:+ (true or false) is still accepted in place of +verdict:+.
119
+ def save(assertion_id:, annotation: nil, verdict: nil, trace_id: nil, original_trace_id: nil, attempt: nil,
120
+ confidence: nil, experiment_id: nil, evidence: EVIDENCE_UNSET, test_run_id: nil, label: LABEL_UNSET)
121
+ verdict = VerdictInput.resolve_verdict(verdict, label, AGENT_VERDICTS, LABEL_UNSET)
122
+ VerdictInput.require_annotation(verdict, annotation)
123
+ experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
124
+ update = verdict_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
125
+ update["verdict"] = verdict
126
+ update["annotation"] = annotation unless annotation.nil?
127
+ update["confidence"] = confidence unless confidence.nil?
128
+ update["evidence"] = evidence unless evidence.equal?(EVIDENCE_UNSET)
129
+ save_all([update], experiment_id:).first
130
+ end
131
+
132
+ # Saves several agent verdict entries in one request. Each entry is a hash with the wire keys
133
+ # (+traceId+ or +originalTraceId+ and +attempt+, +assertionId+, and one of +verdict+, +skip+, or +archive+).
134
+ def save_all(verdicts, experiment_id: nil, test_run_id: nil)
135
+ experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
136
+ verdicts = verdicts.map { |entry| VerdictInput.normalize_entry(entry, AGENT_VERDICTS) }
137
+ verdicts.each { |entry| VerdictInput.refuse_abstained_extras(entry) }
138
+ require_assertion_ids(verdicts)
139
+ payload = {"verdicts" => verdicts}
140
+ unless experiment_id.nil?
141
+ payload["experimentId"] = experiment_id
142
+ payload["testRunId"] = experiment_id
143
+ end
144
+ @http_client.request(VERDICTS_PATH, payload)["verdicts"]
145
+ end
146
+
147
+ # Withholds a verdict on one assertion without recording a pass or a fail, with an optional reason.
148
+ def skip(assertion_id:, trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil,
149
+ test_run_id: nil, annotation: nil)
150
+ experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
151
+ update = verdict_target(trace_id:, original_trace_id:, attempt:, assertion_id:).merge("skip" => true)
152
+ update["annotation"] = annotation unless annotation.nil?
153
+ save_all([update], experiment_id:).first
154
+ end
155
+
156
+ # Records that the evidence does not show the part of the run this assertion checks, so there was
157
+ # nothing to judge. Counted as a fail in every pass rate, but stored and read back as an abstention,
158
+ # apart from a fail. +annotation:+ says what the evidence would have had to show. Shorthand for
159
+ # +save(verdict: "abstained")+.
160
+ def abstain(assertion_id:, trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil,
161
+ test_run_id: nil, annotation: nil)
162
+ experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
163
+ update = verdict_target(trace_id:, original_trace_id:, attempt:, assertion_id:).merge("verdict" => "abstained")
164
+ update["annotation"] = annotation unless annotation.nil?
165
+ save_all([update], experiment_id:).first
166
+ end
167
+
168
+ # Archives the active verdict on one assertion, or an old whole-trace verdict when +assertion_id:+ is omitted.
169
+ def archive(trace_id: nil, original_trace_id: nil, attempt: nil, experiment_id: nil, assertion_id: nil, test_run_id: nil)
170
+ experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
171
+ update = verdict_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
172
+ save_all([update.merge("archive" => true)], experiment_id:).first
173
+ end
174
+
175
+ # Saves a person's verdict on one assertion of a trace. +verdict:+ is "pass" or "fail", since a person
176
+ # never abstains, and +annotation:+ is the reason, which is required. The deprecated +label:+ (true or
177
+ # false) is still accepted in place of +verdict:+.
178
+ def save_human(trace_id:, assertion_id:, annotation: nil, verdict: nil, confidence: nil, evidence: EVIDENCE_UNSET,
179
+ label: LABEL_UNSET)
180
+ verdict = VerdictInput.resolve_verdict(verdict, label, HUMAN_VERDICTS, LABEL_UNSET)
181
+ VerdictInput.require_annotation(verdict, annotation)
182
+ update = {"traceId" => trace_id, "assertionId" => assertion_id, "verdict" => verdict, "annotation" => annotation}
183
+ update["confidence"] = confidence unless confidence.nil?
184
+ update["evidence"] = evidence unless evidence.equal?(EVIDENCE_UNSET)
185
+ save_human_all([update]).first
186
+ end
187
+
188
+ # Saves several of a person's verdict entries in one request.
189
+ def save_human_all(verdicts)
190
+ verdicts = verdicts.map { |entry| VerdictInput.normalize_entry(entry, HUMAN_VERDICTS) }
191
+ require_human_assertion_ids(verdicts)
192
+ @http_client.request(HUMAN_VERDICTS_PATH, {"verdicts" => verdicts})["verdicts"].map do |row|
193
+ row.key?("label") ? row : row.merge("label" => row["verdict"] == "pass")
194
+ end
195
+ end
196
+
197
+ # Reads one trace's verdicts, or nil when the trace has none.
198
+ def get(trace_id)
199
+ get_all([trace_id]).first
200
+ end
201
+
202
+ # Reads the verdicts of several traces.
203
+ def get_all(trace_ids)
204
+ return [] if trace_ids.empty?
205
+
206
+ query = URI.encode_www_form("traceIds" => trace_ids.join(","))
207
+ @http_client.get("#{VERDICTS_PATH}?#{query}")["verdicts"]
208
+ end
209
+
210
+ # Generates span-by-span evidence for one assertion on a trace, to send with its verdict.
211
+ def generate_evidence(trace_id, assertion_id)
212
+ encoded_trace_id = URI.encode_www_form_component(trace_id).gsub("+", "%20")
213
+ encoded_assertion_id = URI.encode_www_form_component(assertion_id).gsub("+", "%20")
214
+ @http_client.get(
215
+ "/api/sdk/traces/#{encoded_trace_id}/assertions/#{encoded_assertion_id}/evidence"
216
+ )["evidence"]
217
+ end
218
+
219
+ # @deprecated Use {#generate_evidence}.
220
+ def generate_label_evidence(trace_id, assertion_id)
221
+ Bitfab.warn_once("generate-label-evidence-deprecated",
222
+ "generate_label_evidence is deprecated; use verdicts.generate_evidence instead.")
223
+ generate_evidence(trace_id, assertion_id)
224
+ end
225
+
226
+ private
227
+
228
+ def require_assertion_ids(verdicts)
229
+ unscoped = verdicts.reject do |entry|
230
+ entry_value(entry, "archive") == true || !blank?(entry_value(entry, "assertionId"))
231
+ end
232
+ return if unscoped.empty?
233
+
234
+ targets = unscoped.map { |entry| entry_value(entry, "traceId") || entry_value(entry, "originalTraceId") }
235
+ raise ArgumentError, "Agent verdicts must name an assertion: #{targets.join(", ")}. " \
236
+ "Pass the assertion_id of the assertion each verdict scores to verdicts.save, save_all, skip, and abstain. " \
237
+ "Only archive may omit it, to clear an old whole-trace verdict."
238
+ end
239
+
240
+ def require_human_assertion_ids(verdicts)
241
+ unscoped = verdicts.select { |entry| blank?(entry_value(entry, "assertionId")) }
242
+ return if unscoped.empty?
243
+
244
+ targets = unscoped.map { |entry| entry_value(entry, "traceId") }
245
+ raise ArgumentError, "Human verdicts must name an assertion: #{targets.join(", ")}. " \
246
+ "Pass the assertion_id of the assertion each verdict scores to verdicts.save_human and save_human_all."
247
+ end
248
+
249
+ def entry_value(entry, key)
250
+ entry.key?(key) ? entry[key] : entry[key.to_sym]
251
+ end
252
+
253
+ def blank?(value)
254
+ value.nil? || value.to_s.empty?
255
+ end
256
+
257
+ def verdict_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
258
+ if trace_id.nil? == original_trace_id.nil?
259
+ raise ArgumentError, "Pass exactly one of trace_id or original_trace_id. " \
260
+ "Use original_trace_id for a replay verdict, with the experiment_id it ran under."
261
+ end
262
+
263
+ update = {}
264
+ update["assertionId"] = assertion_id unless assertion_id.nil?
265
+ if trace_id.nil?
266
+ update["originalTraceId"] = original_trace_id
267
+ update["attempt"] = attempt unless attempt.nil?
268
+ else
269
+ update["traceId"] = trace_id
270
+ end
271
+ update
272
+ end
273
+ end
274
+
275
+ Labels = Verdicts
276
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.65.7"
4
+ VERSION = "0.67.0"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -21,7 +21,7 @@ require_relative "bitfab/traces"
21
21
  require_relative "bitfab/spans"
22
22
  require_relative "bitfab/assertions"
23
23
  require_relative "bitfab/scenarios"
24
- require_relative "bitfab/labels"
24
+ require_relative "bitfab/verdicts"
25
25
  require_relative "bitfab/graders"
26
26
  require_relative "bitfab/experiments"
27
27
  require_relative "bitfab/mock_override"
@@ -30,6 +30,7 @@ require_relative "bitfab/replay_branch"
30
30
  require_relative "bitfab/client"
31
31
  require_relative "bitfab/seed"
32
32
  require_relative "bitfab/baml"
33
+ require_relative "bitfab/jev"
33
34
  require_relative "bitfab/traceable"
34
35
  require_relative "bitfab/replay_registry"
35
36
  require_relative "bitfab/seed_cli"
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.65.7
4
+ version: 0.67.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -175,6 +175,7 @@ files:
175
175
  - lib/bitfab/git_state.rb
176
176
  - lib/bitfab/graders.rb
177
177
  - lib/bitfab/http_client.rb
178
+ - lib/bitfab/jev.rb
178
179
  - lib/bitfab/labels.rb
179
180
  - lib/bitfab/mock_override.rb
180
181
  - lib/bitfab/organization_members.rb
@@ -205,6 +206,7 @@ files:
205
206
  - lib/bitfab/traceable.rb
206
207
  - lib/bitfab/traces.rb
207
208
  - lib/bitfab/transport.rb
209
+ - lib/bitfab/verdicts.rb
208
210
  - lib/bitfab/version.rb
209
211
  - lib/bitfab/warn_once.rb
210
212
  homepage: https://bitfab.ai