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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- 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
|