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 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Hunch
2
+ VERSION = "0.1.0"
3
+ 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: []