squishling 0.4.0 → 0.5.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +25 -9
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +53 -18
  6. data/docs/failures.md +123 -18
  7. data/docs/harnesses.md +24 -14
  8. data/docs/measuring-tokens.md +8 -6
  9. data/docs/routing.md +5 -0
  10. data/docs/schemas.md +11 -2
  11. data/docs/testing.md +183 -0
  12. data/docs/verifying.md +84 -0
  13. data/lib/squishling/adapter/error_body.rb +47 -0
  14. data/lib/squishling/adapter.rb +275 -0
  15. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  16. data/lib/squishling/adapters/openai_options.rb +72 -0
  17. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  18. data/lib/squishling/budget.rb +71 -0
  19. data/lib/squishling/class_methods.rb +43 -7
  20. data/lib/squishling/class_registry.rb +63 -0
  21. data/lib/squishling/configuration.rb +57 -6
  22. data/lib/squishling/definition.rb +90 -16
  23. data/lib/squishling/errors.rb +92 -7
  24. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  25. data/lib/squishling/harness/option_types.rb +2 -1
  26. data/lib/squishling/harness/registry.rb +8 -36
  27. data/lib/squishling/invoker.rb +21 -9
  28. data/lib/squishling/judge.rb +16 -3
  29. data/lib/squishling/llm_client.rb +66 -132
  30. data/lib/squishling/model_path.rb +60 -13
  31. data/lib/squishling/named_registry.rb +64 -0
  32. data/lib/squishling/observer.rb +11 -2
  33. data/lib/squishling/output_check.rb +13 -9
  34. data/lib/squishling/params.rb +6 -23
  35. data/lib/squishling/railtie.rb +10 -0
  36. data/lib/squishling/result.rb +37 -16
  37. data/lib/squishling/rotation.rb +5 -0
  38. data/lib/squishling/rspec.rb +18 -0
  39. data/lib/squishling/run/messages.rb +40 -3
  40. data/lib/squishling/run/rejections.rb +43 -0
  41. data/lib/squishling/run.rb +66 -42
  42. data/lib/squishling/schema.rb +10 -9
  43. data/lib/squishling/squawk.rb +33 -3
  44. data/lib/squishling/tasks/squishling.rake +18 -0
  45. data/lib/squishling/testing/adapter.rb +194 -0
  46. data/lib/squishling/testing/checks.rb +39 -0
  47. data/lib/squishling/testing/direct.rb +41 -0
  48. data/lib/squishling/testing/errors.rb +18 -0
  49. data/lib/squishling/testing/fixtures.rb +206 -0
  50. data/lib/squishling/testing/helpers.rb +71 -0
  51. data/lib/squishling/testing/minitest.rb +32 -0
  52. data/lib/squishling/testing/observer.rb +31 -0
  53. data/lib/squishling/testing/reply.rb +60 -0
  54. data/lib/squishling/testing/request.rb +75 -0
  55. data/lib/squishling/testing/rspec.rb +30 -0
  56. data/lib/squishling/testing/script.rb +189 -0
  57. data/lib/squishling/testing/state.rb +73 -0
  58. data/lib/squishling/testing/stubs.rb +112 -0
  59. data/lib/squishling/testing.rb +126 -0
  60. data/lib/squishling/verification.rb +39 -0
  61. data/lib/squishling/verifier.rb +179 -0
  62. data/lib/squishling/version.rb +1 -1
  63. data/lib/squishling.rb +57 -4
  64. metadata +40 -21
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Tells the test adapter which request it is answering. An Adapter::Request carries no role, sample, or attempt,
6
+ # but the Observer::Call that wraps it does, on the thread that makes the request: this observer keeps it where
7
+ # Adapter#complete can read it, and passes the request on to any observer the app configured.
8
+ class Observer
9
+ KEY = :squishling_testing_call
10
+
11
+ # The call being made on this thread, or nil outside a request.
12
+ def self.current
13
+ Thread.current[KEY]
14
+ end
15
+
16
+ attr_reader :previous
17
+
18
+ def initialize(previous = nil)
19
+ @previous = previous
20
+ end
21
+
22
+ def around(call, &)
23
+ outer = Thread.current[KEY]
24
+ Thread.current[KEY] = call
25
+ @previous ? @previous.around(call, &) : yield
26
+ ensure
27
+ Thread.current[KEY] = outer
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # A scripted answer with more than content: the model id the provider reports and its token counts, so the
6
+ # squawk and observer usage paths are covered. usage is a Hash such as { input_tokens: 12, output_tokens: 30 }.
7
+ class Reply
8
+ attr_reader :content, :model, :usage
9
+
10
+ def initialize(content = nil, model: nil, usage: nil)
11
+ @content = content
12
+ @model = model
13
+ @usage = usage
14
+ end
15
+ end
16
+
17
+ # A scripted provider failure, named by what went wrong (see Failures).
18
+ Failure = Data.define(:intent)
19
+
20
+ # The answer to the "winner" question of a System One judgment.
21
+ Answer = Struct.new(:choice, :probabilities)
22
+
23
+ # What Adapter#judge returns: answers by question name, like RubyLLM's Judgment.
24
+ Judgment = Struct.new(:answers) do
25
+ def [](name)
26
+ answers[name]
27
+ end
28
+ end
29
+
30
+ # The provider failures a script can name by intent rather than by the error class of any one SDK. Each becomes
31
+ # the Squishling error an adapter raises for it: LLMError for what is worth another attempt, ConfigurationError
32
+ # for what is not. Messages carry the failure and its status, like the real adapters' do.
33
+ module Failures
34
+ # intent => [error class, what happened, HTTP status or nil]
35
+ INTENTS = {
36
+ rate_limit: [LLMError, "rate limited", 429],
37
+ server_error: [LLMError, "server error", 500],
38
+ overloaded: [LLMError, "provider overloaded", 529],
39
+ timeout: [LLMError, "request timed out", nil],
40
+ context_length: [LLMError, "context length exceeded", 400],
41
+ bad_request: [ConfigurationError, "bad request", 400],
42
+ unauthorized: [ConfigurationError, "unauthorized", 401],
43
+ forbidden: [ConfigurationError, "forbidden", 403]
44
+ }.freeze
45
+
46
+ module_function
47
+
48
+ def check!(intent)
49
+ return intent if INTENTS.key?(intent)
50
+
51
+ raise ArgumentError, "unknown failure #{intent.inspect} (one of #{INTENTS.keys.map(&:inspect).join(', ')})"
52
+ end
53
+
54
+ def build(intent, label)
55
+ klass, text, status = INTENTS.fetch(intent)
56
+ klass.new("#{label}: #{text}#{" (HTTP #{status})" if status}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Run's follow-up messages (a private constant of Run): the helpers read what was sent through their parsers.
6
+ MESSAGES = Run.const_get(:Messages)
7
+ private_constant :MESSAGES
8
+
9
+ # One provider request as the test adapter saw it: what left the process, and what came back.
10
+ #
11
+ # arguments and context are the method's arguments and the squish_context / squish! context values the model was
12
+ # sent. For a judge they are those of the call being judged, and candidates holds the two samples. messages is
13
+ # the conversation as sent (empty for a System One judgment, whose payload is the input). conversation is the
14
+ # index of the chat in Adapter#conversations (nil for a judgment): a retry is the same conversation, an
15
+ # escalation a new one. reply is the Adapter::Response, or the error raised instead.
16
+ Request = Data.define(:label, :type, :role, :sample, :attempt, :attempts, :model, :provider, :params,
17
+ :system_prompt, :output_schema, :messages, :conversation, :payload, :reply) do
18
+ def arguments
19
+ input["arguments"]
20
+ end
21
+
22
+ def context
23
+ input["context"]
24
+ end
25
+
26
+ def candidates
27
+ payload["candidates"]
28
+ end
29
+
30
+ # The validation messages this request carried back to the model: a retry in the same conversation, or an
31
+ # escalation to the next step. Empty for a first attempt.
32
+ def errors
33
+ (retry_errors || escalation&.errors) || []
34
+ end
35
+
36
+ # The rejected output forwarded from the previous step (text, capped), or nil when none was.
37
+ def forwarded_rejected
38
+ escalation&.rejected
39
+ end
40
+
41
+ def failed?
42
+ reply.is_a?(Exception)
43
+ end
44
+
45
+ def judge?
46
+ role == :judge
47
+ end
48
+
49
+ # The JSON payload of the first user message, parsed (an escalation message wraps it in its own text).
50
+ def self.payload_of(messages)
51
+ text = messages.first.fetch(:content)
52
+ JSON.parse(MESSAGES.parse_escalation(text)&.input || text)
53
+ rescue JSON::ParserError
54
+ {}
55
+ end
56
+
57
+ private
58
+
59
+ # The method's arguments and context: the payload itself, or inside a judge's "input".
60
+ def input
61
+ judge? ? payload.fetch("input", {}) : payload
62
+ end
63
+
64
+ def retry_errors
65
+ text = messages.last&.fetch(:content, nil)
66
+ messages.size > 1 && text ? MESSAGES.parse_retry(text) : nil
67
+ end
68
+
69
+ def escalation
70
+ text = messages.first&.fetch(:content, nil)
71
+ text && MESSAGES.parse_escalation(text)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The helpers for RSpec (no rspec-mocks needed): `config.include Squishling::Testing::RSpec`, or include it in a
6
+ # group. What it installs (the adapter, observer, and stubs) is put back after each example.
7
+ #
8
+ # RSpec.describe RegexInferrer do
9
+ # it "retries a bad regex" do
10
+ # fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
11
+ # expect(described_class.call(sample_turns: turns).regex).to eq("Run.*")
12
+ # assert_sent_arguments fake, { sample_turns: turns }
13
+ # end
14
+ # end
15
+ module RSpec
16
+ include Helpers
17
+
18
+ def self.included(group)
19
+ super
20
+ group.after { Testing.reset! }
21
+ end
22
+
23
+ private
24
+
25
+ def squishling_failure(message)
26
+ raise ::RSpec::Expectations::ExpectationNotMetError, message
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # One scripted line: the requests it answers and what they get, in order. The last answer repeats unless the
6
+ # rule is #once. Build them through Script (the block Testing.install yields).
7
+ class Rule
8
+ # What a rule answers. nil means any. kind is :chat (Adapter#complete) or :judgment (Adapter#judge); role is
9
+ # nil (an escalation attempt, or any role when the rule names none), :sample, or :judge.
10
+ Selector = Data.define(:kind, :role, :sample, :model, :provider, :attempt)
11
+
12
+ # Rule#take when nothing is left to answer with.
13
+ EXHAUSTED = Object.new.freeze
14
+
15
+ attr_reader :selector
16
+
17
+ def initialize(selector)
18
+ @selector = selector
19
+ @replies = []
20
+ @taken = 0
21
+ @once = false
22
+ end
23
+
24
+ # Answers, in order: a Hash (the model's answer), a String (raw text, malformed or fenced JSON included), nil
25
+ # (an empty response), a Reply (with a model id and token usage), or an Exception (raised as is).
26
+ def reply(*replies)
27
+ @replies.concat(replies)
28
+ self
29
+ end
30
+
31
+ # A provider failure named by intent (see Failures::INTENTS).
32
+ def fail(intent)
33
+ @replies << Failure.new(Failures.check!(intent))
34
+ self
35
+ end
36
+
37
+ # Running out of answers is an error instead of repeating the last one.
38
+ def once
39
+ @once = true
40
+ self
41
+ end
42
+
43
+ def matches?(kind, call)
44
+ selector.kind == kind && fits?(selector.role, call[:role]) && fits?(selector.sample, call[:sample]) &&
45
+ fits?(selector.model, call[:model]) && fits?(selector.provider, call[:provider]) &&
46
+ fits?(selector.attempt, call[:attempt])
47
+ end
48
+
49
+ # How narrowly the rule aims, lowest first: rules for a sample or the judge, then those naming the most of a
50
+ # model, a provider, and an attempt, then the rest.
51
+ def specificity
52
+ return 0 if selector.sample || selector.role
53
+
54
+ 4 - [selector.model, selector.provider, selector.attempt].count { |named| !named.nil? }
55
+ end
56
+
57
+ # The next answer, or EXHAUSTED. Called with the script's lock held.
58
+ def take
59
+ return EXHAUSTED if @replies.empty? || (@once && @taken >= @replies.size)
60
+
61
+ @replies[[@taken, @replies.size - 1].min].tap { @taken += 1 }
62
+ end
63
+
64
+ def empty?
65
+ @replies.empty?
66
+ end
67
+
68
+ def describe
69
+ aimed = selector.to_h.compact.except(:kind).map { |key, value| "#{key}: #{value.inspect}" }
70
+ "#{selector.kind} #{aimed.empty? ? 'any' : aimed.join(', ')}"
71
+ end
72
+
73
+ private
74
+
75
+ def fits?(wanted, actual)
76
+ wanted.nil? || wanted.to_s == actual.to_s
77
+ end
78
+ end
79
+
80
+ # The judge's rules: a chat judge answers a verdict, a System One judge a judgment with a confidence.
81
+ class JudgeRules
82
+ CHOICES = %i[a b neither].freeze
83
+
84
+ def initialize(chat, judgment)
85
+ @chat = chat
86
+ @judgment = judgment
87
+ end
88
+
89
+ # A chat judge's strict verdict (:a, :b, or :neither) and its reason.
90
+ def verdict(choice, reason: "scripted verdict")
91
+ @chat.reply({ "verdict" => check(choice).to_s, "reason" => reason })
92
+ self
93
+ end
94
+
95
+ # A System One judgment (type: :judgment) choosing :a, :b, or :neither with that probability; the rest of the
96
+ # probability is split between the other two choices. Pass an Exception to raise it instead.
97
+ def judgment(choice, confidence: 0.95)
98
+ unless choice.is_a?(Exception) || confidence.between?(0, 1)
99
+ raise ArgumentError, "confidence is a probability from 0 to 1, got #{confidence.inspect}"
100
+ end
101
+
102
+ @judgment.reply(choice.is_a?(Exception) ? choice : answer(check(choice), confidence))
103
+ self
104
+ end
105
+
106
+ # Anything a chat judge's request can answer with (see Rule#reply): off-schema verdicts, raw text, ...
107
+ def reply(*replies)
108
+ @chat.reply(*replies)
109
+ self
110
+ end
111
+
112
+ def fail(intent)
113
+ @chat.fail(intent)
114
+ self
115
+ end
116
+
117
+ def fail_judgment(intent)
118
+ @judgment.fail(intent)
119
+ self
120
+ end
121
+
122
+ def once
123
+ @chat.once
124
+ @judgment.once
125
+ self
126
+ end
127
+
128
+ private
129
+
130
+ def check(choice)
131
+ return choice.to_sym if CHOICES.include?(choice.to_s.to_sym)
132
+
133
+ raise ArgumentError, "a judge choice is one of #{CHOICES.map(&:inspect).join(', ')}, got #{choice.inspect}"
134
+ end
135
+
136
+ def answer(choice, confidence)
137
+ others = (1 - confidence) / 2.0
138
+ probabilities = CHOICES.to_h { |key| [key, key == choice ? confidence.to_f : others] }
139
+ Judgment.new({ winner: Answer.new(choice, probabilities) })
140
+ end
141
+ end
142
+
143
+ # What a test says the model will do. Rules are matched most specific first (a sample or the judge, then a
144
+ # named model, then step(:any)), and by declaration order within that.
145
+ #
146
+ # llm.step("flash").reply({ score: 99 }, { score: 5 }) # garbage, then an answer
147
+ # llm.step("pro").fail(:rate_limit)
148
+ # llm.sample("a").reply(valid_a)
149
+ # llm.judge.verdict(:a)
150
+ class Script
151
+ def initialize
152
+ @rules = []
153
+ end
154
+
155
+ # Escalation attempts (and any other chat request no sample or judge rule claims) on a model, a provider, or
156
+ # :any model; attempt: narrows it to one attempt of the escalation (counting from 1).
157
+ def step(model = :any, provider: nil, attempt: nil)
158
+ add(:chat, model: model == :any ? nil : model, provider:, attempt:)
159
+ end
160
+
161
+ # A harness sample's requests ("a", "b", ...). Each sample has its own queue, so the order the sample threads
162
+ # happen to run in can't change what a sample gets.
163
+ def sample(name)
164
+ add(:chat, role: :sample, sample: name.to_s)
165
+ end
166
+
167
+ # The judge of :judged_squishsum, chat or System One.
168
+ def judge
169
+ @judge ||= JudgeRules.new(add(:chat, role: :judge), add(:judgment, role: :judge))
170
+ end
171
+
172
+ # The rule that answers a request of this kind, or nil. Called with the lock held (see Adapter).
173
+ def rule_for(kind, call)
174
+ candidates = @rules.each_with_index.select { |rule, _| rule.matches?(kind, call) }
175
+ candidates.min_by { |rule, index| [rule.specificity, index] }&.first
176
+ end
177
+
178
+ def describe
179
+ @rules.empty? ? "nothing" : @rules.map(&:describe).join("; ")
180
+ end
181
+
182
+ private
183
+
184
+ def add(kind, role: nil, sample: nil, model: nil, provider: nil, attempt: nil)
185
+ Rule.new(Rule::Selector.new(kind:, role:, sample:, model:, provider:, attempt:)).tap { |rule| @rules << rule }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # What the helpers changed in Squishling.config (the adapter and observer), remembered so Testing.reset! puts
6
+ # back exactly that: not an app's other settings (which Squishling.reset_config! would also clear), and not a
7
+ # value a test assigned itself after the helpers did.
8
+ module State
9
+ LOCK = Mutex.new
10
+ private_constant :LOCK
11
+
12
+ @entries = {} # setting => [what it was before the helpers, what the helpers set it to]
13
+ @config = nil
14
+
15
+ class << self
16
+ # Sets a Squishling.config setting, remembering what it was before the first change.
17
+ def set(setting, value)
18
+ LOCK.synchronize do
19
+ config = Squishling.config
20
+ @entries = {} unless config.equal?(@config) # a replaced config is already pristine
21
+ @config = config
22
+ original = original_of(config, setting)
23
+ config.public_send(:"#{setting}=", value)
24
+ @entries[setting] = [original, value]
25
+ end
26
+ end
27
+
28
+ # Puts back the settings still holding what the helpers set; leaves a config that has been replaced alone.
29
+ def restore!
30
+ config, entries = LOCK.synchronize do
31
+ [@config, @entries].tap do
32
+ @entries = {}
33
+ @config = nil
34
+ end
35
+ end
36
+ return unless config.equal?(Squishling.config)
37
+
38
+ entries.each do |setting, (original, installed)|
39
+ config.public_send(:"#{setting}=", original) if config.public_send(setting).equal?(installed)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # What to put back: the value before the helpers' first change, unless something else was assigned since.
46
+ def original_of(config, setting)
47
+ current = config.public_send(setting)
48
+ entry = @entries[setting]
49
+ entry && current.equal?(entry.last) ? entry.first : current
50
+ end
51
+ end
52
+ end
53
+
54
+ # What a test asked squawk to record: every attempt's raw output, error, and metadata (role, sample, attempt,
55
+ # final, usage, ...). Use Testing.with_squawk, which puts the previous hook back.
56
+ class SquawkCapture
57
+ Call = Data.define(:output, :error, :metadata)
58
+
59
+ def initialize
60
+ @lock = Mutex.new
61
+ @calls = []
62
+ end
63
+
64
+ def call(output: nil, error: nil, metadata: nil)
65
+ @lock.synchronize { @calls << Call.new(output:, error:, metadata:) }
66
+ end
67
+
68
+ def calls
69
+ @lock.synchronize { @calls.dup }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Replaces a squished method for the tests of its callers: the method returns (or raises) what the test says and
6
+ # never reaches Squishling, but what it returns is a real typed result, validated against the method's output
7
+ # schema, so the stand-in can't drift from the real result type. Cleared by Testing.reset!.
8
+ module Stubs
9
+ LOCK = Mutex.new
10
+ private_constant :LOCK
11
+
12
+ # [class, method name] => callable producing the result, or raising the error
13
+ @active = {}
14
+ # [class, method name] => the module prepended to the class. A prepended module can't be removed, so it stays
15
+ # (inert once its stub is cleared) and keeps its class alive: stub the classes of a suite, not one per test.
16
+ @hooks = {}
17
+
18
+ ERRORS = {
19
+ invalid_output: :invalid_output_error, llm_error: :llm_error, configuration: :configuration_error,
20
+ disagreement: :disagreement_error
21
+ }.freeze
22
+
23
+ class << self
24
+ # A result of the method's schema, with these attributes (squished: false for one the Ruby path returned).
25
+ def result(klass, attrs, method: :call, squished: true, &)
26
+ definition = klass.squishling_definition(method)
27
+ built = typed(definition, attrs, squished:)
28
+ activate(klass, method, -> { built }, &)
29
+ end
30
+
31
+ # The method raises this kind of error: :invalid_output (errors:, raw:, attempts:, models:), :llm_error
32
+ # (message:), :configuration (message:), or :disagreement (candidates:, verdict:, reason:, models:).
33
+ def error(klass, kind, method: :call, **options, &)
34
+ builder = ERRORS.fetch(kind) do
35
+ raise ArgumentError, "unknown error #{kind.inspect} (one of #{ERRORS.keys.map(&:inspect).join(', ')})"
36
+ end
37
+ definition = klass.squishling_definition(method)
38
+ # Built once now, so a mistyped option fails here and not in the code under test.
39
+ send(builder, definition, **options)
40
+ activate(klass, method, -> { raise send(builder, definition, **options) }, &)
41
+ end
42
+
43
+ def clear!
44
+ LOCK.synchronize { @active.clear }
45
+ end
46
+
47
+ # The stub for a method, called by the module prepended to its class (nil when there is none).
48
+ def stub_for(klass, method)
49
+ LOCK.synchronize { @active[[klass, method]] }
50
+ end
51
+
52
+ private
53
+
54
+ def activate(klass, method, callable)
55
+ previous = LOCK.synchronize { @active.delete([klass, method]).tap { @active[[klass, method]] = callable } }
56
+ hook(klass, method)
57
+ return klass unless block_given?
58
+
59
+ begin
60
+ yield
61
+ ensure
62
+ LOCK.synchronize { previous ? @active[[klass, method]] = previous : @active.delete([klass, method]) }
63
+ end
64
+ end
65
+
66
+ # Prepended once per method: calls the stub while there is one, and the real method otherwise.
67
+ def hook(klass, method)
68
+ LOCK.synchronize { @hooks[[klass, method]] ||= build_hook(klass, method) }
69
+ end
70
+
71
+ # A Wrapper, so Wrapper.implementation looks past it to the real method (its parameter names name the
72
+ # arguments the model is sent) once the stub is gone.
73
+ def build_hook(klass, method)
74
+ hook = Wrapper.new
75
+ hook.define_method(method) do |*args, **kwargs, &block|
76
+ stub = Stubs.stub_for(klass, method) if instance_of?(klass) # a subclass keeps its own behavior
77
+ stub ? stub.call : super(*args, **kwargs, &block)
78
+ end
79
+ klass.prepend(hook)
80
+ hook
81
+ end
82
+
83
+ # The attributes as the method's own typed result, validated by the same path as a Ruby-path return.
84
+ def typed(definition, attrs, squished:)
85
+ definition.build_result(attrs, squished:)
86
+ rescue InvalidOutputError => e
87
+ raise SetupError, "#{definition.label}: the stubbed result doesn't match its output_schema: " \
88
+ "#{e.errors.join('; ')}"
89
+ rescue ConfigurationError
90
+ raise SetupError, "#{definition.label} has no output_schema to build a result from"
91
+ end
92
+
93
+ def invalid_output_error(_definition, errors: ["stubbed invalid output"], raw: nil, attempts: 1, models: nil)
94
+ InvalidOutputError.new(errors: Array(errors), raw:, attempts:, models: models || ([nil] * attempts))
95
+ end
96
+
97
+ def llm_error(definition, message: "stubbed LLM failure")
98
+ LLMError.new("#{definition.label}: #{message}")
99
+ end
100
+
101
+ def configuration_error(definition, message: "stubbed configuration error")
102
+ ConfigurationError.new("#{definition.label}: #{message}")
103
+ end
104
+
105
+ def disagreement_error(definition, candidates:, verdict: nil, reason: nil, models: nil)
106
+ typed = candidates.map { |candidate| typed(definition, candidate, squished: true) }
107
+ DisagreementError.new(candidates: typed, verdict:, reason:, models:)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end