hunch 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +309 -0
- data/lib/hunch/backends/jev.rb +95 -0
- data/lib/hunch/backends/stub.rb +73 -0
- data/lib/hunch/configuration.rb +46 -0
- data/lib/hunch/decision.rb +41 -0
- data/lib/hunch/errors.rb +23 -0
- data/lib/hunch/questions.rb +37 -0
- data/lib/hunch/rating.rb +39 -0
- data/lib/hunch/result.rb +65 -0
- data/lib/hunch/version.rb +3 -0
- data/lib/hunch.rb +82 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4fe18b2f4a16d13a14ca5ff3c9887404e1ebc0b455e66b9c151951efc27c5fae
|
|
4
|
+
data.tar.gz: 638b1ee2b57368e9acd352f2fe06bfb6beafc89e29f93b42de1026cc5160c8c7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 95b72749b648e44622a5e414fe3f9e6c6df1d8926072e324f20802f1223e86382b1130e647038b256ecd9290558491976a9d8c5da593882ca86d789580728e42
|
|
7
|
+
data.tar.gz: 36256933743330d1b84d04b2b296fa8d758a90a24ea89c4ba50c373190eadb806553a6587734bcdc0c0b6da69eadfcf4d81e04c1108a964466339d8202012eef
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carl Dawson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Hunch
|
|
2
|
+
|
|
3
|
+
Probabilistic control flow for Ruby.
|
|
4
|
+
|
|
5
|
+
Ruby gives you `if`, `case`, and `<=>` for facts. Hunch gives you the same
|
|
6
|
+
three moves for judgment calls. Every question is a conditional probability —
|
|
7
|
+
_how likely is this, given that_ — and the API reads that way:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
if Hunch.almost_certain?("this order is fraudulent", given: order.attributes)
|
|
11
|
+
order.hold!
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The English is the configuration. No prompts, no parsing, no tuning DSL.
|
|
16
|
+
Answers come from [TypeSafe's Jev](https://typesafe.ai), a System One model:
|
|
17
|
+
a single fast parallel pass that returns typed, calibrated probabilities
|
|
18
|
+
instead of text — fast and cheap enough to sit inside a request cycle.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "hunch"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Hunch.configure do |config|
|
|
28
|
+
config.api_key = ENV["TYPESAFE_API_KEY"]
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## The three primitives
|
|
33
|
+
|
|
34
|
+
**`chance`** answers _whether_, as a probability:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Hunch.chance("written by a real human, not spam", given: bio) # => 0.87
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Named levels collapse it into predicates:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
Hunch.possible?("fraudulent", given: order) # chance >= 0.25
|
|
44
|
+
Hunch.likely?("fraudulent", given: order) # chance >= 0.5
|
|
45
|
+
Hunch.probable?("fraudulent", given: order) # chance >= 0.75
|
|
46
|
+
Hunch.almost_certain?("fraudulent", given: order) # chance >= 0.93
|
|
47
|
+
|
|
48
|
+
Hunch.configure { |c| c.levels[:paranoid] = 0.99 }
|
|
49
|
+
Hunch.paranoid?("fraudulent", given: order) # chance >= 0.99
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**`pick`** answers _which_:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Hunch.pick(:ham, :spam, given: email) # => :spam
|
|
56
|
+
Hunch.pick(urgent: "needs a reply today", routine: "can wait",
|
|
57
|
+
given: ticket) # => :routine
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**`rate`** answers _how much_, on an ordered scale:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
mood = Hunch.rate(:calm, :frustrated, :livid, given: email)
|
|
64
|
+
mood.level # => :frustrated
|
|
65
|
+
mood.position # => 1.4
|
|
66
|
+
mood >= :livid # => false
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If shuffling the options wouldn't change their meaning, use `pick`; if they
|
|
70
|
+
form a ladder, use `rate`. Options are bare symbols or `symbol: "description"`,
|
|
71
|
+
mixed freely, plus an optional `question:` when the options alone don't carry
|
|
72
|
+
it. The keywords `given` and `question` are reserved.
|
|
73
|
+
|
|
74
|
+
## Batching
|
|
75
|
+
|
|
76
|
+
Several questions about one piece of state cost one API call:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
result = Hunch.decide(given: mail.raw_source) do |q|
|
|
80
|
+
q.likely? :urgent, "does this convey urgency?", over: :probable
|
|
81
|
+
q.pick :team, billing: "payments", technical: "bugs", sales: "pricing"
|
|
82
|
+
q.rate :mood, :calm, :frustrated, :livid
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
result.urgent # => 0.92
|
|
86
|
+
result.urgent? # => true, past :probable
|
|
87
|
+
result.team # => :technical
|
|
88
|
+
result.team_probabilities # => { billing: 0.08, technical: 0.85, sales: 0.07 }
|
|
89
|
+
result.mood.level # => :livid
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## In a Rails app
|
|
93
|
+
|
|
94
|
+
Everything below is lifted from [`example/`](example), a Rails app in this
|
|
95
|
+
repo — its test suite covers each snippet, and all of them have been run
|
|
96
|
+
against the live model.
|
|
97
|
+
|
|
98
|
+
### Validations
|
|
99
|
+
|
|
100
|
+
A validation method is just an `if`:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class Signup < ApplicationRecord
|
|
104
|
+
validates :email, presence: true
|
|
105
|
+
validate :display_name_is_a_name, :bio_reads_like_a_human
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def display_name_is_a_name
|
|
110
|
+
return if display_name.blank?
|
|
111
|
+
return if Hunch.likely?("a plausible human or company name, not an advert or URL", given: display_name)
|
|
112
|
+
|
|
113
|
+
errors.add(:display_name, "doesn't look like a name")
|
|
114
|
+
rescue Hunch::APIError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def bio_reads_like_a_human
|
|
119
|
+
return if bio.blank?
|
|
120
|
+
return if Hunch.probable?("a genuine human bio, not spam or keyword stuffing", given: bio)
|
|
121
|
+
|
|
122
|
+
errors.add(:bio, "reads like spam")
|
|
123
|
+
rescue Hunch::APIError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
signup = Signup.new(display_name: "BEST-CRYPTO-DEALS dot example",
|
|
131
|
+
bio: "BUY CHEAP GOLD CLICK HERE best prices!!!")
|
|
132
|
+
signup.valid? # => false
|
|
133
|
+
signup.errors[:bio] # => ["reads like spam"]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The `rescue nil` is a deliberate policy: if the API is unreachable at save
|
|
137
|
+
time, the record saves anyway. Fail closed instead where it matters more,
|
|
138
|
+
like a spam gate.
|
|
139
|
+
|
|
140
|
+
### Inbound email
|
|
141
|
+
|
|
142
|
+
ActionMailbox routing without the regex graveyard:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class SortingMailbox < ApplicationMailbox
|
|
146
|
+
def process
|
|
147
|
+
team = Hunch.pick(
|
|
148
|
+
support: "questions about using or configuring the product",
|
|
149
|
+
billing: "invoices, payments, refunds",
|
|
150
|
+
spam: "unsolicited bulk or scam email",
|
|
151
|
+
given: "Subject: #{mail.subject}\n\n#{body}"
|
|
152
|
+
)
|
|
153
|
+
return if team == :spam
|
|
154
|
+
|
|
155
|
+
Ticket.create!(team: team.to_s, subject: mail.subject, body: body)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Error triage
|
|
161
|
+
|
|
162
|
+
Replace the hand-maintained ignore-list with one judgment:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class ErrorTriage
|
|
166
|
+
def report(error, handled:, severity: nil, context: {}, source: nil)
|
|
167
|
+
verdict = Hunch.rate(
|
|
168
|
+
ignore: "known noise, expected in normal operation",
|
|
169
|
+
notify: "worth a look during working hours",
|
|
170
|
+
page: "users are impacted right now",
|
|
171
|
+
given: { class: error.class.name, message: error.message, handled:, source: }
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
case verdict.level
|
|
175
|
+
when :page then Pagerduty.trigger(error)
|
|
176
|
+
when :notify then SlackNotifier.post(error)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# config/initializers/error_reporting.rb
|
|
182
|
+
Rails.application.config.after_initialize do
|
|
183
|
+
Rails.error.subscribe(ErrorTriage.new)
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Job retries
|
|
188
|
+
|
|
189
|
+
Exception classes don't tell you whether a failure is transient. Ask:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
class WebhookDeliveryJob < ApplicationJob
|
|
193
|
+
rescue_from Delivery::Error do |error|
|
|
194
|
+
if Hunch.likely?("retrying this failed delivery will succeed",
|
|
195
|
+
given: { error: error.message, attempts: executions })
|
|
196
|
+
retry_job wait: 30.seconds
|
|
197
|
+
else
|
|
198
|
+
Rails.logger.warn("giving up on webhook: #{error.message}")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def perform(url, payload)
|
|
203
|
+
Delivery.post(url, payload)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A `503 upstream timeout` retries; a `404 endpoint not found` doesn't.
|
|
209
|
+
|
|
210
|
+
### Enum coercion
|
|
211
|
+
|
|
212
|
+
Messy import data, typed by construction — `pick` can only return one of
|
|
213
|
+
your enum's values:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
class Order < ApplicationRecord
|
|
217
|
+
enum :status, { pending: 0, shipped: 1, delivered: 2, cancelled: 3 }
|
|
218
|
+
|
|
219
|
+
def self.import_status(raw)
|
|
220
|
+
Hunch.pick(*statuses.keys.map(&:to_sym), given: raw,
|
|
221
|
+
question: "which order status does this text describe?")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
Order.import_status("sent it out tuesday??") # => :shipped
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Moderation
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class Comment < ApplicationRecord
|
|
232
|
+
enum :status, { pending: 0, published: 1, held: 2, rejected: 3 }, default: :pending
|
|
233
|
+
|
|
234
|
+
def moderate!
|
|
235
|
+
tone = Hunch.rate(:civil, :heated, :abusive, given: body,
|
|
236
|
+
question: "how abusive is this comment?")
|
|
237
|
+
|
|
238
|
+
case tone.level
|
|
239
|
+
when :civil then published!
|
|
240
|
+
when :heated then held!
|
|
241
|
+
when :abusive then rejected!
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Testing
|
|
248
|
+
|
|
249
|
+
The stub backend is just another backend:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
Hunch.backend = Hunch::Backends::Stub.new(fraud: 0.95, team: :billing, mood: :calm)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Stub values are keyed by question key (`:answer` for the single-shot
|
|
256
|
+
methods) and follow the question type: a probability or boolean for chance
|
|
257
|
+
and its predicates, a symbol or probabilities hash for `pick`, a level
|
|
258
|
+
symbol or position for `rate`. Unstubbed questions raise unless you pass
|
|
259
|
+
`default:`. The stub records `calls` for assertions:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
test "spam is dropped without a ticket" do
|
|
263
|
+
Hunch.backend = Hunch::Backends::Stub.new(answer: :spam)
|
|
264
|
+
assert_no_difference -> { Ticket.count } do
|
|
265
|
+
receive_inbound_email_from_mail(subject: "You have WON", body: "claim your prize")
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Configuration
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
Hunch.configure do |config|
|
|
274
|
+
config.api_key = "..." # default: ENV["TYPESAFE_API_KEY"]
|
|
275
|
+
config.model = "jev-latest"
|
|
276
|
+
config.url = "https://api.typesafe.ai/v1/systemone"
|
|
277
|
+
config.timeout = 5
|
|
278
|
+
config.open_timeout = 2
|
|
279
|
+
config.max_retries = 2 # 429/5xx/timeouts, with backoff, honours Retry-After
|
|
280
|
+
config.levels[:paranoid] = 0.99
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Via OpenRouter
|
|
285
|
+
|
|
286
|
+
Jev speaks the same wire format through
|
|
287
|
+
[OpenRouter's Decisions endpoint](https://openrouter.ai/typesafe), so an
|
|
288
|
+
OpenRouter key works today without the TypeSafe waitlist:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
Hunch.configure do |config|
|
|
292
|
+
config.api_key = ENV["OPENROUTER_API_KEY"]
|
|
293
|
+
config.url = "https://openrouter.ai/api/alpha/decisions"
|
|
294
|
+
config.model = "typesafe/jev-1.13"
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Honesty about backends
|
|
299
|
+
|
|
300
|
+
The interface is uniform; the guarantees are not. Jev's probabilities are
|
|
301
|
+
calibrated, its answers are typed by construction, and it responds in
|
|
302
|
+
milliseconds. A future LLM backend can implement the same three primitives,
|
|
303
|
+
but its confidences are estimates, not calibrated probabilities, and it is
|
|
304
|
+
orders of magnitude slower and more expensive. Same interface, different
|
|
305
|
+
guarantees — choose accordingly.
|
|
306
|
+
|
|
307
|
+
## License
|
|
308
|
+
|
|
309
|
+
MIT
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hunch
|
|
6
|
+
module Backends
|
|
7
|
+
class Jev
|
|
8
|
+
def initialize(config = Hunch.configuration, transport: nil, sleeper: nil)
|
|
9
|
+
@config = config
|
|
10
|
+
@transport = transport || method(:http_post)
|
|
11
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def decide(state:, questions:, model: nil)
|
|
15
|
+
payload = {
|
|
16
|
+
"state" => state,
|
|
17
|
+
"model" => model || @config.model,
|
|
18
|
+
"questions" => questions.to_h { |key, question| [key.to_s, question.payload] }
|
|
19
|
+
}
|
|
20
|
+
with_retries { handle(*@transport.call(payload)) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def with_retries
|
|
26
|
+
attempts = 0
|
|
27
|
+
begin
|
|
28
|
+
attempts += 1
|
|
29
|
+
yield
|
|
30
|
+
rescue RateLimitError, OverloadedError, ServerError, TimeoutError, ConnectionError => e
|
|
31
|
+
raise if attempts > @config.max_retries
|
|
32
|
+
|
|
33
|
+
@sleeper.call(e.respond_to?(:retry_after) && e.retry_after || backoff(attempts))
|
|
34
|
+
retry
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def backoff(attempt)
|
|
39
|
+
(0.5 * (2**(attempt - 1))) + rand * 0.25
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle(status, headers, body)
|
|
43
|
+
case status
|
|
44
|
+
when 200..299 then JSON.parse(body)
|
|
45
|
+
when 401 then raise AuthenticationError, error_message(body, "missing or invalid API key")
|
|
46
|
+
when 422 then raise ValidationError, error_message(body, "invalid request")
|
|
47
|
+
when 429 then raise RateLimitError.new(error_message(body, "rate limited"), retry_after: retry_after(headers))
|
|
48
|
+
when 529 then raise OverloadedError, error_message(body, "service overloaded")
|
|
49
|
+
when 500..599 then raise ServerError, "server error (#{status})"
|
|
50
|
+
else raise APIError, error_message(body, "unexpected response (#{status})")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def error_message(body, fallback)
|
|
55
|
+
parsed = JSON.parse(body)
|
|
56
|
+
error = parsed["error"] || parsed["message"] || fallback
|
|
57
|
+
error.is_a?(Hash) ? error["message"] || fallback : error
|
|
58
|
+
rescue JSON::ParserError, TypeError
|
|
59
|
+
fallback
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def retry_after(headers)
|
|
63
|
+
value = headers["retry-after"] || headers["Retry-After"]
|
|
64
|
+
value&.to_f&.nonzero?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def http_post(payload)
|
|
68
|
+
uri = URI(@config.url)
|
|
69
|
+
request = Net::HTTP::Post.new(uri)
|
|
70
|
+
request["Authorization"] = "Bearer #{api_key!}"
|
|
71
|
+
request["Content-Type"] = "application/json"
|
|
72
|
+
request["User-Agent"] = "hunch-ruby/#{VERSION}"
|
|
73
|
+
request.body = JSON.generate(payload)
|
|
74
|
+
|
|
75
|
+
response = Net::HTTP.start(
|
|
76
|
+
uri.host, uri.port,
|
|
77
|
+
use_ssl: uri.scheme == "https",
|
|
78
|
+
open_timeout: @config.open_timeout,
|
|
79
|
+
read_timeout: @config.timeout
|
|
80
|
+
) { |http| http.request(request) }
|
|
81
|
+
|
|
82
|
+
[response.code.to_i, response.each_header.to_h, response.body]
|
|
83
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
84
|
+
raise TimeoutError, e.message
|
|
85
|
+
rescue SocketError, SystemCallError, EOFError, OpenSSL::SSL::SSLError => e
|
|
86
|
+
raise ConnectionError, e.message
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def api_key!
|
|
90
|
+
@config.api_key or raise ConfigurationError,
|
|
91
|
+
"no API key: set TYPESAFE_API_KEY or Hunch.configure { |c| c.api_key = ... }"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
module Backends
|
|
3
|
+
class Stub
|
|
4
|
+
attr_reader :calls
|
|
5
|
+
|
|
6
|
+
def initialize(default: nil, **answers)
|
|
7
|
+
@answers = answers
|
|
8
|
+
@default = default
|
|
9
|
+
@calls = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def decide(state:, questions:, model: nil)
|
|
13
|
+
@calls << { state:, questions: }
|
|
14
|
+
answers = questions.to_h do |key, question|
|
|
15
|
+
[key.to_s, answer_for(key, question)]
|
|
16
|
+
end
|
|
17
|
+
{ "answers" => answers, "model" => "stub" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def answer_for(key, question)
|
|
23
|
+
value = @answers.fetch(key, @default)
|
|
24
|
+
if value.nil?
|
|
25
|
+
raise MissingStubAnswer,
|
|
26
|
+
"no stubbed answer for #{key.inspect}: Stub.new(#{key}: ...) or pass default:"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
case question.type
|
|
30
|
+
when :noul then noul(value)
|
|
31
|
+
when :choice then choice(value, question)
|
|
32
|
+
when :rate then rate(value, question)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def noul(value)
|
|
37
|
+
probability =
|
|
38
|
+
case value
|
|
39
|
+
when true then 1.0
|
|
40
|
+
when false then 0.0
|
|
41
|
+
when Numeric then value.to_f
|
|
42
|
+
else raise ArgumentError, "noul stub must be true, false, or a probability"
|
|
43
|
+
end
|
|
44
|
+
{ "type" => "noul", "noul" => probability }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def choice(value, question)
|
|
48
|
+
probabilities =
|
|
49
|
+
case value
|
|
50
|
+
when Symbol then question.options.keys.to_h { |option| [option.to_s, option == value ? 1.0 : 0.0] }
|
|
51
|
+
when Hash then value.transform_keys(&:to_s).transform_values(&:to_f)
|
|
52
|
+
else raise ArgumentError, "choice stub must be a symbol or a probabilities hash"
|
|
53
|
+
end
|
|
54
|
+
winner = probabilities.max_by { |_, p| p }.first
|
|
55
|
+
{ "type" => "choice", "choice" => winner, "probabilities" => probabilities, "confidence" => probabilities[winner] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def rate(value, question)
|
|
59
|
+
levels = question.levels.keys
|
|
60
|
+
position =
|
|
61
|
+
case value
|
|
62
|
+
when Symbol
|
|
63
|
+
levels.index(value) or raise ArgumentError, "#{value.inspect} is not a level of #{question.key}"
|
|
64
|
+
when Numeric then value.to_f
|
|
65
|
+
else raise ArgumentError, "rate stub must be a level symbol or a position"
|
|
66
|
+
end
|
|
67
|
+
nearest = position.round.clamp(0, levels.size - 1)
|
|
68
|
+
probabilities = levels.each_index.to_h { |i| [i.to_s, i == nearest ? 1.0 : 0.0] }
|
|
69
|
+
{ "type" => "score", "score" => position.to_f, "probabilities" => probabilities, "confidence" => 1.0 }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
class Configuration
|
|
3
|
+
LEVELS = { possible: 0.25, likely: 0.5, probable: 0.75, almost_certain: 0.93 }.freeze
|
|
4
|
+
|
|
5
|
+
attr_accessor :api_key, :model, :url, :timeout, :open_timeout, :max_retries, :levels
|
|
6
|
+
attr_writer :backend
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@api_key = ENV["TYPESAFE_API_KEY"]
|
|
10
|
+
@model = "jev-latest"
|
|
11
|
+
@url = "https://api.typesafe.ai/v1/systemone"
|
|
12
|
+
@timeout = 5
|
|
13
|
+
@open_timeout = 2
|
|
14
|
+
@max_retries = 2
|
|
15
|
+
@levels = LEVELS.dup
|
|
16
|
+
@backend = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve_level(value)
|
|
20
|
+
case value
|
|
21
|
+
when Numeric then value.to_f
|
|
22
|
+
when Symbol
|
|
23
|
+
levels.fetch(value) do
|
|
24
|
+
raise ConfigurationError, "unknown level #{value.inspect}; known levels: #{levels.keys.join(", ")}"
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "at_least must be a number or a level name"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def backend
|
|
32
|
+
@backend = resolve(@backend)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def resolve(backend)
|
|
38
|
+
case backend
|
|
39
|
+
when nil, :jev then Backends::Jev.new(self)
|
|
40
|
+
when :stub then Backends::Stub.new
|
|
41
|
+
when Symbol then raise ConfigurationError, "unknown backend #{backend.inspect}"
|
|
42
|
+
else backend
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
class Decision
|
|
3
|
+
attr_reader :questions
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@questions = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def likely?(key, question, yes: nil, no: nil, over: 0.5)
|
|
10
|
+
threshold = Hunch.configuration.resolve_level(over)
|
|
11
|
+
add Questions::Noul.new(key: key.to_sym, question:, yes:, no:, threshold:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pick(key, *options, question: nil, **described)
|
|
15
|
+
all = describe(options, described)
|
|
16
|
+
raise ArgumentError, "pick needs at least two options" if all.size < 2
|
|
17
|
+
|
|
18
|
+
add Questions::Choice.new(key: key.to_sym, question:, options: all)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def rate(key, *levels, question: nil, **described)
|
|
22
|
+
all = describe(levels, described)
|
|
23
|
+
raise ArgumentError, "rate needs at least two levels" if all.size < 2
|
|
24
|
+
|
|
25
|
+
add Questions::Rate.new(key: key.to_sym, question:, levels: all)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def describe(bare, described)
|
|
31
|
+
bare.to_h { |name| [name.to_sym, name.to_s.tr("_", " ")] }.merge(described)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add(question)
|
|
35
|
+
raise ArgumentError, "duplicate question key #{question.key}" if @questions.key?(question.key)
|
|
36
|
+
|
|
37
|
+
@questions[question.key] = question
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/hunch/errors.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ConfigurationError < Error; end
|
|
5
|
+
class AuthenticationError < Error; end
|
|
6
|
+
class ValidationError < Error; end
|
|
7
|
+
class APIError < Error; end
|
|
8
|
+
class ServerError < APIError; end
|
|
9
|
+
class OverloadedError < APIError; end
|
|
10
|
+
class TimeoutError < APIError; end
|
|
11
|
+
class ConnectionError < APIError; end
|
|
12
|
+
|
|
13
|
+
class RateLimitError < APIError
|
|
14
|
+
attr_reader :retry_after
|
|
15
|
+
|
|
16
|
+
def initialize(message = "rate limited", retry_after: nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@retry_after = retry_after
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class MissingStubAnswer < Error; end
|
|
23
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
module Questions
|
|
3
|
+
Noul = Data.define(:key, :question, :yes, :no, :threshold) do
|
|
4
|
+
def type = :noul
|
|
5
|
+
|
|
6
|
+
def payload
|
|
7
|
+
criteria = { "true" => yes, "false" => no }.compact
|
|
8
|
+
base = { "type" => "noul", "instructions" => question }
|
|
9
|
+
criteria.empty? ? base : base.merge("criteria" => criteria)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Choice = Data.define(:key, :question, :options) do
|
|
14
|
+
def type = :choice
|
|
15
|
+
|
|
16
|
+
def payload
|
|
17
|
+
{
|
|
18
|
+
"type" => "choice",
|
|
19
|
+
"instructions" => question || "Which of these best describes the state?",
|
|
20
|
+
"criteria" => options.transform_keys(&:to_s)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Rate = Data.define(:key, :question, :levels) do
|
|
26
|
+
def type = :rate
|
|
27
|
+
|
|
28
|
+
def payload
|
|
29
|
+
{
|
|
30
|
+
"type" => "score",
|
|
31
|
+
"instructions" => question || "Where does the state sit on this scale?",
|
|
32
|
+
"criteria" => levels.values
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/hunch/rating.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
class Rating
|
|
3
|
+
include Comparable
|
|
4
|
+
|
|
5
|
+
attr_reader :position, :levels, :probabilities, :confidence
|
|
6
|
+
|
|
7
|
+
def initialize(position:, levels:, probabilities:, confidence: nil)
|
|
8
|
+
@position = position.to_f
|
|
9
|
+
@levels = levels.freeze
|
|
10
|
+
@probabilities = probabilities.freeze
|
|
11
|
+
@confidence = confidence
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def level
|
|
15
|
+
levels[position.round.clamp(0, levels.size - 1)]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <=>(other)
|
|
19
|
+
case other
|
|
20
|
+
when Rating then position <=> other.position
|
|
21
|
+
when Numeric then position <=> other
|
|
22
|
+
when Symbol
|
|
23
|
+
index = levels.index(other)
|
|
24
|
+
index && position <=> index
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ==(other)
|
|
29
|
+
other.is_a?(Symbol) ? level == other : super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_sym = level
|
|
33
|
+
def to_f = position
|
|
34
|
+
|
|
35
|
+
def inspect
|
|
36
|
+
"#<Hunch::Rating #{level} (#{position.round(2)})>"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/hunch/result.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Hunch
|
|
2
|
+
class Result
|
|
3
|
+
attr_reader :model, :usage
|
|
4
|
+
|
|
5
|
+
def initialize(questions:, answers:, model: nil, usage: nil)
|
|
6
|
+
@model = model
|
|
7
|
+
@usage = usage
|
|
8
|
+
@values = {}
|
|
9
|
+
questions.each do |key, question|
|
|
10
|
+
raw = answers[key.to_s] || answers[key]
|
|
11
|
+
raise Error, "backend returned no answer for #{key}" unless raw
|
|
12
|
+
|
|
13
|
+
build(key, question, raw)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def [](key)
|
|
18
|
+
@values.fetch(key.to_sym)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h = @values.dup
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build(key, question, raw)
|
|
26
|
+
case question.type
|
|
27
|
+
when :noul then build_noul(key, question, raw)
|
|
28
|
+
when :choice then build_choice(key, raw)
|
|
29
|
+
when :rate then build_rate(key, question, raw)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_noul(key, question, raw)
|
|
34
|
+
probability = raw["noul"].to_f
|
|
35
|
+
set(key, probability)
|
|
36
|
+
define(:"#{key}?") { probability >= question.threshold }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_choice(key, raw)
|
|
40
|
+
probabilities = (raw["probabilities"] || {}).transform_keys(&:to_sym)
|
|
41
|
+
choice = raw["choice"]&.to_sym || probabilities.max_by { |_, p| p }&.first
|
|
42
|
+
set(key, choice)
|
|
43
|
+
define(:"#{key}_probabilities") { probabilities }
|
|
44
|
+
define(:"#{key}_confidence") { raw["confidence"] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_rate(key, question, raw)
|
|
48
|
+
levels = question.levels.keys
|
|
49
|
+
by_index = raw["probabilities"] || {}
|
|
50
|
+
probabilities = levels.each_with_index.to_h do |level, i|
|
|
51
|
+
[level, (by_index[i.to_s] || by_index[i] || 0.0).to_f]
|
|
52
|
+
end
|
|
53
|
+
set(key, Rating.new(position: raw["score"], levels:, probabilities:, confidence: raw["confidence"]))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set(key, value)
|
|
57
|
+
@values[key.to_sym] = value
|
|
58
|
+
define(key.to_sym) { value }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def define(name, &block)
|
|
62
|
+
define_singleton_method(name, &block)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/hunch.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require_relative "hunch/version"
|
|
2
|
+
require_relative "hunch/errors"
|
|
3
|
+
require_relative "hunch/questions"
|
|
4
|
+
require_relative "hunch/rating"
|
|
5
|
+
require_relative "hunch/decision"
|
|
6
|
+
require_relative "hunch/result"
|
|
7
|
+
require_relative "hunch/configuration"
|
|
8
|
+
require_relative "hunch/backends/jev"
|
|
9
|
+
require_relative "hunch/backends/stub"
|
|
10
|
+
|
|
11
|
+
module Hunch
|
|
12
|
+
class << self
|
|
13
|
+
def configure
|
|
14
|
+
yield configuration
|
|
15
|
+
configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset_configuration!
|
|
23
|
+
@configuration = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def backend=(backend)
|
|
27
|
+
configuration.backend = backend
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def decide(given:)
|
|
31
|
+
decision = Decision.new
|
|
32
|
+
yield decision
|
|
33
|
+
raise ArgumentError, "decide needs at least one question" if decision.questions.empty?
|
|
34
|
+
|
|
35
|
+
raw = configuration.backend.decide(state: given, questions: decision.questions, model: configuration.model)
|
|
36
|
+
Result.new(
|
|
37
|
+
questions: decision.questions,
|
|
38
|
+
answers: raw["answers"] || {},
|
|
39
|
+
model: raw["model"],
|
|
40
|
+
usage: raw["usage"]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def chance(question, given:, yes: nil, no: nil)
|
|
45
|
+
decide(given:) { |q| q.likely?(:answer, question, yes:, no:) }.answer
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Configuration::LEVELS.each_key do |level|
|
|
49
|
+
define_method(:"#{level}?") do |question, given:, yes: nil, no: nil|
|
|
50
|
+
chance(question, given:, yes:, no:) >= configuration.levels.fetch(level)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pick(*options, given:, question: nil, **described)
|
|
55
|
+
decide(given:) { |q| q.pick(:answer, *options, question:, **described) }.answer
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def rate(*levels, given:, question: nil, **described)
|
|
59
|
+
decide(given:) { |q| q.rate(:answer, *levels, question:, **described) }.answer
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def custom_level(name)
|
|
65
|
+
return unless name.to_s.end_with?("?")
|
|
66
|
+
|
|
67
|
+
level = name.to_s.delete_suffix("?").to_sym
|
|
68
|
+
configuration.levels.key?(level) ? level : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def method_missing(name, *args, **options, &block)
|
|
72
|
+
level = custom_level(name)
|
|
73
|
+
return super unless level
|
|
74
|
+
|
|
75
|
+
chance(*args, **options) >= configuration.levels.fetch(level)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def respond_to_missing?(name, include_private = false)
|
|
79
|
+
!custom_level(name).nil? || super
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hunch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Carl Dawson
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'likely?, pick, and score: three primitives that let Ruby branch on judgment
|
|
13
|
+
calls, answered by TypeSafe''s Jev System One model or any backend you plug in.'
|
|
14
|
+
email:
|
|
15
|
+
- carldawson@hey.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE.txt
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/hunch.rb
|
|
23
|
+
- lib/hunch/backends/jev.rb
|
|
24
|
+
- lib/hunch/backends/stub.rb
|
|
25
|
+
- lib/hunch/configuration.rb
|
|
26
|
+
- lib/hunch/decision.rb
|
|
27
|
+
- lib/hunch/errors.rb
|
|
28
|
+
- lib/hunch/questions.rb
|
|
29
|
+
- lib/hunch/rating.rb
|
|
30
|
+
- lib/hunch/result.rb
|
|
31
|
+
- lib/hunch/version.rb
|
|
32
|
+
homepage: https://github.com/carldaws/hunch
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://github.com/carldaws/hunch
|
|
37
|
+
source_code_uri: https://github.com/carldaws/hunch
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.2'
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 4.0.18
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Probabilistic control flow for Ruby
|
|
55
|
+
test_files: []
|