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
@@ -1,17 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- # The opt-in observability hook: a callable run after every LLM attempt with the attempt's raw output, so
5
- # it can be sent somewhere (an error tracker, a tracing tool). It's the one sanctioned way for model output
6
- # to leave the process; nothing runs unless one is configured.
4
+ # The opt-in observability hook: a callable run after every LLM attempt (or only the rejected and retried
5
+ # ones, see squawk_on:) with the attempt's raw output, so it can be sent somewhere (an error tracker, a
6
+ # tracing tool). It's the one sanctioned way for model output to leave the process; nothing runs unless one
7
+ # is configured.
7
8
  module Squawk
8
9
  KEYWORD_TYPES = %i[key keyreq].freeze
9
10
  POSITIONAL_TYPES = %i[req opt].freeze
10
11
  KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
11
12
  KEYWORDS = %i[output metadata error].freeze
13
+ # The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
14
+ # number (the one its metadata carries), its error (nil when accepted), and whether it is the last one.
15
+ CONDITIONS = {
16
+ all: ->(_attempt, _error, _final) { true },
17
+ rejections: ->(_attempt, error, _final) { !error.nil? },
18
+ retries: ->(attempt, error, _final) { !error.nil? || attempt > 1 },
19
+ final: ->(_attempt, _error, final) { final }
20
+ }.freeze
21
+ ON = CONDITIONS.keys.freeze
12
22
 
13
23
  module_function
14
24
 
25
+ # Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
26
+ # every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
27
+ # first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
28
+ # accepted attempt is not final. A list runs the hook when any of them holds.
29
+ def validate_on(value, where)
30
+ return value if value.nil?
31
+
32
+ conditions = value.is_a?(Array) ? value : [value]
33
+ unless conditions.any? && conditions.all? { |condition| CONDITIONS.key?(condition) }
34
+ raise ConfigurationError,
35
+ "#{where} squawk_on must be one or a list of #{ON.map(&:inspect).join(', ')}, got #{value.inspect}"
36
+ end
37
+
38
+ value.is_a?(Array) ? value.dup.freeze : value
39
+ end
40
+
41
+ def report?(on, attempt, error, final)
42
+ Array(on).any? { |condition| CONDITIONS.fetch(condition).call(attempt, error, final) }
43
+ end
44
+
15
45
  # The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
16
46
  # only, which is checked here so a mismatch fails at setup, not inside the first attempt.
17
47
  def validate(hook, where)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Outside Rails, load it from your Rakefile: load "squishling/tasks/squishling.rake"
4
+ unless Rake::Task.task_defined?("squishling:verify")
5
+ namespace :squishling do
6
+ desc "Verify every squished class without making a request (see Squishling.verify)"
7
+ task :verify do
8
+ # Only loaded classes are known, so load them all first. Rails defines :environment after loading its
9
+ # railties' tasks, so it can't be a prerequisite declared here.
10
+ Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
11
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
12
+
13
+ report = Squishling.verify
14
+ puts report
15
+ abort unless report.ok?
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # A Squishling adapter that answers from a script instead of a provider (see Script). Install it with
6
+ # Testing.install. It records every request (#requests) and groups them into the chats they belong to
7
+ # (#conversations), and it is safe to ask from the worker threads a harness's samples run on: everything shared
8
+ # is taken under one lock, and replies are chosen by (step, sample, attempt), never by arrival order.
9
+ class Adapter < Squishling::Adapter::Base
10
+ # One chat: its sample (nil unless a harness sample's) and the turns exchanged so far.
11
+ Conversation = Struct.new(:index, :sample, :transcript)
12
+
13
+ attr_reader :script
14
+
15
+ def initialize(script = Script.new)
16
+ super()
17
+ @script = script
18
+ @lock = Mutex.new
19
+ @requests = []
20
+ @conversations = []
21
+ end
22
+
23
+ def judgments?
24
+ true
25
+ end
26
+
27
+ # Every request so far, in the order they arrived (Testing::Request).
28
+ def requests
29
+ @lock.synchronize { @requests.dup }
30
+ end
31
+
32
+ # The requests of each chat, in order: a retry on the same step shares a chat, a new step starts one.
33
+ def conversations
34
+ requests.select(&:conversation).group_by(&:conversation).sort.map(&:last)
35
+ end
36
+
37
+ def requests_for(role: nil, sample: nil)
38
+ requests.select do |request|
39
+ (role.nil? || request.role == role) && (sample.nil? || request.sample == sample.to_s)
40
+ end
41
+ end
42
+
43
+ # The models the requests went to, in order (nil for a step that names none).
44
+ def models
45
+ requests.map(&:model)
46
+ end
47
+
48
+ # The first request's arguments: what left the process.
49
+ def arguments
50
+ requests.first&.arguments
51
+ end
52
+
53
+ def complete(request)
54
+ outcome = @lock.synchronize { answer(request) }
55
+ raise outcome if outcome.is_a?(Exception)
56
+
57
+ outcome
58
+ end
59
+
60
+ def judge(input, questions:, step:, label:)
61
+ outcome = @lock.synchronize { judged(input, questions, step, label) }
62
+ raise outcome if outcome.is_a?(Exception)
63
+
64
+ outcome
65
+ end
66
+
67
+ private
68
+
69
+ def answer(request)
70
+ call = call_of(request)
71
+ outcome = outcome_for(:chat, call, request.label, request.model)
72
+ conversation = conversation_for(request, call[:sample])
73
+ conversation.transcript.push(*turns(request, outcome)) unless outcome.is_a?(Exception)
74
+ record(request, call, outcome, conversation.index)
75
+ outcome
76
+ end
77
+
78
+ def judged(input, _questions, step, label)
79
+ call = { role: :judge, sample: nil, attempt: Observer.current&.attempt, attempts: Observer.current&.attempts,
80
+ model: step.model, provider: step.provider }
81
+ outcome = outcome_for(:judgment, call, label, step.model)
82
+ payload = Schema.jsonify(input)
83
+ @requests << Request.new(label:, type: :judgment, role: :judge, sample: nil, attempt: call[:attempt],
84
+ attempts: call[:attempts], model: step.model, provider: step.provider, params: step.params,
85
+ system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:, reply: outcome)
86
+ outcome
87
+ end
88
+
89
+ def outcome_for(kind, call, label, model)
90
+ rule = @script.rule_for(kind, call)
91
+ item = rule ? rule.take : Rule::EXHAUSTED
92
+ return unscripted(kind, call, label, model, rule) if item.equal?(Rule::EXHAUSTED)
93
+
94
+ resolve(item, label)
95
+ end
96
+
97
+ def resolve(item, label)
98
+ case item
99
+ when Failure then Failures.build(item.intent, label)
100
+ when Exception, Judgment then item
101
+ when Class then item <= Exception ? item.new : response(item)
102
+ when Reply then Squishling::Adapter::Response.new(content: item.content, model: item.model, tokens: item.usage)
103
+ else response(item)
104
+ end
105
+ end
106
+
107
+ def response(content)
108
+ Squishling::Adapter::Response.new(content:)
109
+ end
110
+
111
+ def unscripted(kind, call, label, model, rule)
112
+ aimed = call.compact.except(:attempts).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
113
+ why = if rule
114
+ "its script #{rule.empty? ? 'has no replies' : 'has no answers left (once)'}"
115
+ else
116
+ "no script covers it"
117
+ end
118
+ UnscriptedRequestError.new("#{label}: unscripted #{kind} request to #{model.inspect} (#{aimed}): #{why}. " \
119
+ "Scripted: #{@script.describe}")
120
+ end
121
+
122
+ # The role, sample, and attempt of the request: from the observer that wraps it, or, when the adapter was
123
+ # installed without one, what the request itself shows.
124
+ def call_of(request)
125
+ observed = Observer.current
126
+ base = { model: request.model, provider: request.provider }
127
+ if observed
128
+ return base.merge(role: observed.role, sample: observed.sample, attempt: observed.attempt,
129
+ attempts: observed.attempts)
130
+ end
131
+
132
+ base.merge(role: judge_schema?(request.schema) ? :judge : nil, sample: nil, attempt: nil, attempts: nil)
133
+ end
134
+
135
+ def judge_schema?(schema)
136
+ schema["schema"] == Schema.for(Judge::VERDICT_SCHEMA).llm_schema["schema"]
137
+ end
138
+
139
+ # A request that continues a chat sends the chat's transcript back, so it finds its conversation by it.
140
+ def conversation_for(request, sample)
141
+ history = request.messages[0...-1]
142
+ found = history.empty? ? nil : @conversations.reverse.find { |c| c.sample == sample && c.transcript == history }
143
+ found || Conversation.new(@conversations.size, sample, []).tap { |chat| @conversations << chat }
144
+ end
145
+
146
+ def turns(request, response)
147
+ [{ role: :user, content: request.messages.last.fetch(:content) }, { role: :assistant, content: response.text }]
148
+ end
149
+
150
+ def record(request, call, outcome, conversation)
151
+ @requests << Request.new(label: request.label, type: :chat, role: call[:role], sample: call[:sample]&.to_s,
152
+ attempt: call[:attempt], attempts: call[:attempts], model: request.model, provider: request.provider,
153
+ params: request.params, system_prompt: request.instructions, output_schema: request.schema["schema"],
154
+ messages: request.messages, conversation:, payload: Request.payload_of(request.messages), reply: outcome)
155
+ end
156
+ end
157
+
158
+ # The adapter behind Testing.forbid_provider_calls!: any request that reaches it raises ProviderCallError, which
159
+ # is not a Squishling::Error, so a squish_fallback can't absorb it. It remembers the calls that were attempted,
160
+ # for a test that rescued the error.
161
+ class ForbiddenAdapter < Squishling::Adapter::Base
162
+ def initialize
163
+ super
164
+ @lock = Mutex.new
165
+ @attempts = []
166
+ end
167
+
168
+ # The labels ("Klass#method") of the calls that reached it.
169
+ def attempts
170
+ @lock.synchronize { @attempts.dup }
171
+ end
172
+
173
+ def judgments?
174
+ true
175
+ end
176
+
177
+ def complete(request)
178
+ refuse(request.label)
179
+ end
180
+
181
+ def judge(_input, label:, **)
182
+ refuse(label)
183
+ end
184
+
185
+ private
186
+
187
+ def refuse(label)
188
+ @lock.synchronize { @attempts << label }
189
+ raise ProviderCallError, "#{label} reached the provider, but no LLM call was expected. Script it with " \
190
+ "Squishling::Testing.install, or fix the code under test"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The assertions, independent of any test framework: each returns a failure message, or nil when it holds. The
6
+ # Minitest and RSpec mixins turn a message into a failure in their own way.
7
+ module Checks
8
+ module_function
9
+
10
+ # The requests of a fake adapter (or an Array of them) sent exactly these arguments, as JSON would carry them.
11
+ def sent_arguments(source, expected, request: 0)
12
+ sent = requests_of(source)[request]
13
+ return "expected request ##{request} to have been sent, but only #{requests_of(source).size} were" unless sent
14
+
15
+ wanted = Schema.jsonify(expected)
16
+ return nil if sent.arguments == wanted
17
+
18
+ "expected the model to be sent arguments #{wanted.inspect}, but it was sent #{sent.arguments.inspect}"
19
+ end
20
+
21
+ def call_count(source, expected)
22
+ actual = requests_of(source).size
23
+ actual == expected ? nil : "expected #{expected} LLM call(s), but there were #{actual}"
24
+ end
25
+
26
+ # Runs the block with provider calls forbidden: a failure message when it tried to make one.
27
+ def no_llm_call(&)
28
+ attempts = Testing.no_llm_call(&)
29
+ return nil if attempts.empty?
30
+
31
+ "expected no LLM call, but #{attempts.uniq.join(', ')} reached the provider (#{attempts.size} request(s))"
32
+ end
33
+
34
+ def requests_of(source)
35
+ source.respond_to?(:requests) ? source.requests : Array(source)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Runs the parts of a squished method that don't need a model, for unit tests: the output checks, the fallback,
6
+ # and the prompt. Each takes the method's arguments as args: and keywords (as the method is called), and a
7
+ # receiver: for a class whose constructor takes arguments.
8
+ module Direct
9
+ class << self
10
+ # The messages squish_validate and the output schema reject this reply with ([] when it would be accepted).
11
+ def validate(klass, reply, method: :call, receiver: nil, args: [], **kwargs)
12
+ definition = klass.squishling_definition(method)
13
+ schema = definition.schema or raise SetupError, "#{definition.label} has no output_schema"
14
+ inputs = definition.bind_arguments(args, kwargs)
15
+ prompt = Invoker::Prompt.new(instructions: nil, schema:, input: nil, role: nil)
16
+ OutputCheck.new(definition, receiver || klass.new, inputs).call(reply, prompt).last.map(&:message)
17
+ end
18
+
19
+ # What squish_fallback returns for this error, typed like any result of the method.
20
+ def fallback_for(klass, error, method: :call, receiver: nil, args: [], **kwargs)
21
+ definition = klass.squishling_definition(method)
22
+ handler = definition.fallback or raise SetupError, "#{definition.label} has no squish_fallback"
23
+ inputs = definition.bind_arguments(args, kwargs)
24
+ definition.coerce((receiver || klass.new).instance_exec(error, **inputs, &handler))
25
+ end
26
+
27
+ # The first request a call would make, without making it: { system_prompt:, user_message:, output_schema:,
28
+ # step: } (the step is the ModelPath::Step with the model, provider and params; rotation isn't advanced).
29
+ def preview(klass, method: :call, receiver: nil, args: [], **kwargs)
30
+ definition = klass.squishling_definition(method)
31
+ receiver ||= klass.new
32
+ inputs = definition.bind_arguments(args, kwargs)
33
+ prompt = Invoker.new(definition, receiver, inputs).prompt
34
+ { system_prompt: prompt.instructions, user_message: prompt.input,
35
+ output_schema: prompt.schema.llm_schema["schema"],
36
+ step: definition.escalation_path(receiver, inputs, advance: false).first }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # What the test helpers raise. None of these is a Squishling::Error, so squish_fallback, which absorbs
6
+ # InvalidOutputError and LLMError, can never hide a test setup mistake.
7
+ class Error < StandardError; end
8
+
9
+ # A request reached the test adapter and no script covers it.
10
+ class UnscriptedRequestError < Error; end
11
+
12
+ # A request reached the provider seam while provider calls were forbidden (see Testing.forbid_provider_calls!).
13
+ class ProviderCallError < Error; end
14
+
15
+ # A stub, a fixture, or a direct check was asked for something the method's schema doesn't allow.
16
+ class SetupError < Error; end
17
+ end
18
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Replies derived from an output schema, so a test that doesn't care about the content stays valid when the schema
6
+ # changes, and a test of "out of range gets re-asked" doesn't hand-write a bad payload. Replies are Hashes with
7
+ # string keys, as JSON would carry them, and are always checked against the schema before they are returned.
8
+ module Fixtures
9
+ FORMATS = {
10
+ "date-time" => "2024-01-01T00:00:00Z", "date" => "2024-01-01", "time" => "00:00:00Z",
11
+ "email" => "user@example.com", "uri" => "https://example.com", "uuid" => "00000000-0000-4000-8000-000000000000"
12
+ }.freeze
13
+ MAX_DEPTH = 8
14
+ WRONG_VALUES = ["not valid", -1_000_000_007, [], {}, true, nil].freeze
15
+
16
+ class << self
17
+ # A reply that satisfies the schema of a squished class's method, a Squishling::Schema, or a schema Hash.
18
+ # overrides replace top-level attributes.
19
+ def valid_reply_for(source, method: :call, **overrides)
20
+ schema = schema_of(source, method)
21
+ reply = Generator.new(schema.json_schema).value
22
+ reply = reply.merge(overrides.transform_keys(&:to_s)) if reply.is_a?(Hash)
23
+ verify!(schema, reply, "valid")
24
+ end
25
+
26
+ # A valid reply with one top-level attribute (violating:) broken: out of its enum or range, the wrong type,
27
+ # null, or missing. how: (:enum, :range, :type, :null, :missing) picks the break; by default the first that
28
+ # the schema rejects. Overrides are applied to the valid reply first.
29
+ def invalid_reply_for(source, violating:, how: nil, method: :call, **overrides)
30
+ schema = schema_of(source, method)
31
+ base = valid_reply_for(schema, **overrides)
32
+ key = violating.to_s
33
+ raise SetupError, "the schema has no property #{key.inspect} to violate" unless schema.json_schema.fetch(
34
+ "properties", {}
35
+ ).key?(key)
36
+
37
+ candidates = breaks(schema.json_schema, key, how)
38
+ broken = candidates.lazy.map { |value| mutate(base, key, value) }.find { |reply| schema.validate(reply).any? }
39
+ broken or raise SetupError,
40
+ "couldn't find a value of #{key.inspect} that the schema rejects#{" (how: #{how.inspect})" if how}"
41
+ end
42
+
43
+ private
44
+
45
+ def schema_of(source, method)
46
+ case source
47
+ when Schema then source
48
+ when Class then source.squishling_definition(method).schema or raise SetupError,
49
+ "#{source} has no output_schema"
50
+ else Schema.for(source)
51
+ end
52
+ end
53
+
54
+ def verify!(schema, reply, kind)
55
+ errors = schema.validate(reply)
56
+ return reply if errors.empty?
57
+
58
+ raise SetupError,
59
+ "couldn't build a #{kind} reply (#{errors.join('; ')}); pass the attributes it needs as overrides"
60
+ end
61
+
62
+ def mutate(base, key, value)
63
+ value.equal?(:missing) ? base.except(key) : base.merge(key => value)
64
+ end
65
+
66
+ def breaks(json_schema, key, how)
67
+ node = Generator.new(json_schema).node_for(json_schema.fetch("properties").fetch(key))
68
+ all = { enum: enum_breaks(node), range: range_breaks(node), type: WRONG_VALUES.compact, null: [nil],
69
+ missing: [:missing] }
70
+ return all.fetch(how) { raise ArgumentError, "how: is one of #{all.keys.map(&:inspect).join(', ')}" } if how
71
+
72
+ all.values.flatten(1)
73
+ end
74
+
75
+ def enum_breaks(node)
76
+ values = node["enum"] ? ["not in the enum"] : []
77
+ node.key?("const") ? values + ["not the const"] : values
78
+ end
79
+
80
+ def range_breaks(node)
81
+ bounds = node.slice("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength",
82
+ "minItems", "maxItems")
83
+ bounds.flat_map { |name, limit| beyond(name, limit) }
84
+ end
85
+
86
+ def beyond(name, limit)
87
+ case name
88
+ when "minimum", "exclusiveMinimum" then [limit - 1]
89
+ when "maximum", "exclusiveMaximum" then [limit + 1]
90
+ when "minLength" then ["x" * [limit - 1, 0].max]
91
+ when "maxLength" then ["x" * (limit + 1)]
92
+ when "minItems" then [[nil] * [limit - 1, 0].max]
93
+ when "maxItems" then [[nil] * (limit + 1)]
94
+ end
95
+ end
96
+ end
97
+
98
+ # Builds one value that satisfies a JSON schema (see Fixtures).
99
+ class Generator
100
+ def initialize(root)
101
+ @root = root
102
+ @expanding = []
103
+ end
104
+
105
+ # The value for a node. A $ref being expanded is remembered, so a recursive schema (a node whose child is
106
+ # the same node or null) ends at the null instead of nesting forever.
107
+ def value(node = @root, depth = 0)
108
+ ref = node["$ref"]
109
+ @expanding.push(ref) if ref
110
+ generate(node_for(node), depth)
111
+ ensure
112
+ @expanding.pop if ref
113
+ end
114
+
115
+ # The schema node with a root-relative $ref followed.
116
+ def node_for(node)
117
+ ref = node["$ref"]
118
+ return node unless ref
119
+
120
+ path = ref.delete_prefix("#").split("/").reject(&:empty?)
121
+ node.except("$ref").merge(path.reduce(@root) do |memo, part|
122
+ memo.fetch(part.gsub("~1", "/").gsub("~0", "~"))
123
+ end)
124
+ end
125
+
126
+ private
127
+
128
+ def generate(node, depth)
129
+ return node["const"] if node.key?("const")
130
+ return node["enum"].first if node["enum"]
131
+ return from_branch(node, depth) if node["anyOf"] || node["oneOf"]
132
+ return from_all_of(node, depth) if node["allOf"]
133
+
134
+ typed(node, depth)
135
+ end
136
+
137
+ def from_branch(node, depth)
138
+ branches = node["anyOf"] || node["oneOf"]
139
+ open = branches.reject { |branch| branch["$ref"] && @expanding.include?(branch["$ref"]) }
140
+ chosen = open.find { |branch| branch["type"] != "null" } || open.first || branches.first
141
+ value(chosen, depth + 1)
142
+ end
143
+
144
+ def from_all_of(node, depth)
145
+ parts = node["allOf"].map { |part| value(part, depth + 1) }.grep(Hash)
146
+ parts.reduce({}) { |memo, part| memo.merge(part) }
147
+ end
148
+
149
+ def typed(node, depth)
150
+ case primary_type(node)
151
+ when "object" then object(node, depth)
152
+ when "array" then array(node, depth)
153
+ when "string" then string(node)
154
+ when "integer", "number" then number(node)
155
+ when "boolean" then true
156
+ end
157
+ end
158
+
159
+ def primary_type(node)
160
+ types = Array(node["type"])
161
+ types.find { |type| type != "null" } || (types.first if types.any?) || inferred_type(node)
162
+ end
163
+
164
+ def inferred_type(node)
165
+ return "object" if node["properties"]
166
+
167
+ "array" if node["items"]
168
+ end
169
+
170
+ def object(node, depth)
171
+ return {} if depth > MAX_DEPTH
172
+
173
+ (node["properties"] || {}).transform_values { |property| value(property, depth + 1) }
174
+ end
175
+
176
+ def array(node, depth)
177
+ return [] if depth > MAX_DEPTH || !node["items"]
178
+
179
+ Array.new(node["minItems"].to_i) { value(node["items"], depth + 1) }
180
+ end
181
+
182
+ def string(node)
183
+ text = FORMATS.fetch(node["format"], "example")
184
+ text = text.ljust(node["minLength"], "x") if node["minLength"]
185
+ node["maxLength"] ? text[0, node["maxLength"]] : text
186
+ end
187
+
188
+ def number(node)
189
+ number = clamp_up(0, node["minimum"], node["exclusiveMinimum"])
190
+ number = clamp_down(number, node["maximum"], node["exclusiveMaximum"])
191
+ primary_type(node) == "integer" ? number.ceil : number
192
+ end
193
+
194
+ def clamp_up(number, minimum, exclusive)
195
+ number = minimum if minimum && number < minimum
196
+ exclusive && number <= exclusive ? exclusive + 1 : number
197
+ end
198
+
199
+ def clamp_down(number, maximum, exclusive)
200
+ number = maximum if maximum && number > maximum
201
+ exclusive && number >= exclusive ? exclusive - 1 : number
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The test-facing helpers, shared by the Minitest and RSpec mixins. A mixin supplies #squishling_failure(message),
6
+ # which fails the current test.
7
+ module Helpers
8
+ # Installs a scripted adapter and returns it. The block scripts it (see Script); the adapter's #requests,
9
+ # #conversations and #arguments show what was sent.
10
+ def fake_squishling(&)
11
+ Testing.install(&)
12
+ end
13
+
14
+ # Any LLM request raises until a test installs a fake, naming the squished method that made it.
15
+ def forbid_provider_calls!
16
+ Testing.forbid_provider_calls!
17
+ end
18
+
19
+ def assert_sent_arguments(source, expected, request: 0)
20
+ failure = Checks.sent_arguments(source, expected, request:)
21
+ squishling_failure(failure) if failure
22
+ end
23
+
24
+ def assert_llm_calls(source, expected)
25
+ failure = Checks.call_count(source, expected)
26
+ squishling_failure(failure) if failure
27
+ end
28
+
29
+ def assert_no_llm_call(&)
30
+ failure = Checks.no_llm_call(&)
31
+ squishling_failure(failure) if failure
32
+ end
33
+
34
+ def stub_squishling_result(klass, attrs = nil, **, &)
35
+ Testing.stub_result(klass, attrs, **, &)
36
+ end
37
+
38
+ def stub_squishling_error(klass, kind, **, &)
39
+ Testing.stub_error(klass, kind, **, &)
40
+ end
41
+
42
+ def squishling_validate(klass, reply, **inputs)
43
+ Testing.validate(klass, reply, **inputs)
44
+ end
45
+
46
+ def squishling_fallback_for(klass, error, **inputs)
47
+ Testing.fallback_for(klass, error, **inputs)
48
+ end
49
+
50
+ def squishling_preview(klass, **inputs)
51
+ Testing.preview(klass, **inputs)
52
+ end
53
+
54
+ def valid_reply_for(source, **overrides)
55
+ Testing.valid_reply_for(source, **overrides)
56
+ end
57
+
58
+ def invalid_reply_for(source, **)
59
+ Testing.invalid_reply_for(source, **)
60
+ end
61
+
62
+ def capture_squawk
63
+ Testing.capture_squawk
64
+ end
65
+
66
+ def with_squawk(capture = capture_squawk, &)
67
+ Testing.with_squawk(capture, &)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # The helpers for Minitest: include it in a test case (or ActiveSupport::TestCase). What it installs (the
6
+ # adapter, observer, and stubs) is put back after each test.
7
+ #
8
+ # class RegexInferrerTest < Minitest::Test
9
+ # include Squishling::Testing::Minitest
10
+ #
11
+ # def test_retries_a_bad_regex
12
+ # fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
13
+ # assert_equal "Run.*", RegexInferrer.call(sample_turns: turns).regex
14
+ # assert_sent_arguments fake, { sample_turns: turns }
15
+ # end
16
+ # end
17
+ module Minitest
18
+ include Helpers
19
+
20
+ def after_teardown
21
+ Testing.reset!
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def squishling_failure(message)
28
+ flunk message
29
+ end
30
+ end
31
+ end
32
+ end