Hunch
September 19, 2026 ยท View on GitHub
Probabilistic control flow for Ruby.
Hunch lets you ask questions about text or application data and use the answers in your Ruby code.
if Hunch.almost_certainly?("this order is fraudulent", given: order.attributes)
order.hold!
end
You can get a probability, choose between options, or rate something on a scale. Questions are written in plain English; answers come back as Ruby values.
Hunch uses TypeSafe's Jev. You can call it directly or through OpenRouter.
Installation
gem "hunch"
Hunch.configure do |config|
config.api_key = ENV["TYPESAFE_API_KEY"]
end
Usage
chance returns a probability between 0 and 1:
Hunch.chance("written by a real human, not spam", given: bio) # => 0.87
Use a named threshold when you want a boolean:
Hunch.possibly?("fraudulent", given: order) # chance >= 0.25
Hunch.likely?("fraudulent", given: order) # chance >= 0.5
Hunch.probably?("fraudulent", given: order) # chance >= 0.75
Hunch.almost_certainly?("fraudulent", given: order) # chance >= 0.93
Hunch.configure { |c| c.levels[:definitely] = 0.99 }
Hunch.definitely?("fraudulent", given: order) # chance >= 0.99
Configure built-in thresholds with the matching level names, for example
Hunch.configure { |c| c.levels[:probably] = 0.8 }.
pick chooses one of the options you provide:
Hunch.pick(:ham, :spam, given: email) # => :spam
Hunch.pick(urgent: "needs a reply today", routine: "can wait",
given: ticket) # => :routine
rate returns a rating with a position on an ordered scale:
mood = Hunch.rate(:calm, :frustrated, :livid, given: email)
mood.level # => :frustrated
mood.position # => 1.4
mood >= :livid # => false
Use pick for categories such as billing, support, and sales. Use rate
for ordered levels such as low, medium, and high.
Both accept symbols or symbol: "description" pairs, or a mix of the two.
Add question: if the options need more context. given: supplies the data
to evaluate. These two keywords are reserved and can't be used as option names.
Batching
Use Hunch.decide to ask several questions about the same data in one API call:
result = Hunch.decide(given: mail.raw_source) do |q|
q.probably? :urgent, "does this convey urgency?"
q.pick :team, billing: "payments", technical: "bugs", sales: "pricing"
q.rate :mood, :calm, :frustrated, :livid
end
result.urgent # => 0.92
result.urgent? # => true, at or above :probably
result.team # => :technical
result.team_probabilities # => { billing: 0.08, technical: 0.85, sales: 0.07 }
result.mood.level # => :livid
In a Rails app
The example/ Rails app contains these examples and their tests.
Validations
Check a display name and bio during validation:
class Signup < ApplicationRecord
validates :email, presence: true
validate :display_name_is_a_name, :bio_reads_like_a_human
private
def display_name_is_a_name
return if display_name.blank?
return if Hunch.likely?("a plausible human or company name, not an advert or URL", given: display_name)
errors.add(:display_name, "doesn't look like a name")
rescue Hunch::APIError
nil
end
def bio_reads_like_a_human
return if bio.blank?
return if Hunch.probably?("a genuine human bio, not spam or keyword stuffing", given: bio)
errors.add(:bio, "reads like spam")
rescue Hunch::APIError
nil
end
end
signup = Signup.new(display_name: "BEST-CRYPTO-DEALS dot example",
bio: "BUY CHEAP GOLD CLICK HERE best prices!!!")
signup.valid? # => false
signup.errors[:bio] # => ["reads like spam"]
These validations rescue Hunch::APIError, so an API failure adds no
validation error. Other validations, including the email presence check,
still apply.
Inbound email
Route incoming email by its contents:
class SortingMailbox < ApplicationMailbox
def process
team = Hunch.pick(
support: "questions about using or configuring the product",
billing: "invoices, payments, refunds",
spam: "unsolicited bulk or scam email",
given: "Subject: #{mail.subject}\n\n#{body}"
)
return if team == :spam
Ticket.create!(team: team.to_s, subject: mail.subject, body: body)
end
end
Error triage
Choose whether to ignore an error, send a notification, or page someone:
class ErrorTriage
def report(error, handled:, severity: nil, context: {}, source: nil)
verdict = Hunch.rate(
ignore: "known noise, expected in normal operation",
notify: "worth a look during working hours",
page: "users are impacted right now",
given: { class: error.class.name, message: error.message, handled:, source: }
)
case verdict.level
when :page then Pagerduty.trigger(error)
when :notify then SlackNotifier.post(error)
end
end
end
# config/initializers/error_reporting.rb
Rails.application.config.after_initialize do
Rails.error.subscribe(ErrorTriage.new)
end
Job retries
Use the error message and attempt count to decide whether to retry:
class WebhookDeliveryJob < ApplicationJob
rescue_from Delivery::Error do |error|
if Hunch.likely?("retrying this failed delivery will succeed",
given: { error: error.message, attempts: executions })
retry_job wait: 30.seconds
else
Rails.logger.warn("giving up on webhook: #{error.message}")
end
end
def perform(url, payload)
Delivery.post(url, payload)
end
end
Enum coercion
Map imported text to an existing enum value:
class Order < ApplicationRecord
enum :status, { pending: 0, shipped: 1, delivered: 2, cancelled: 3 }
def self.import_status(raw)
Hunch.pick(*statuses.keys.map(&:to_sym), given: raw,
question: "which order status does this text describe?")
end
end
Order.import_status("sent it out tuesday??") # => :shipped
Moderation
class Comment < ApplicationRecord
enum :status, { pending: 0, published: 1, held: 2, rejected: 3 }, default: :pending
def moderate!
tone = Hunch.rate(:civil, :heated, :abusive, given: body,
question: "how abusive is this comment?")
case tone.level
when :civil then published!
when :heated then held!
when :abusive then rejected!
end
end
end
Testing
Use the stub backend to supply answers without making API calls:
Hunch.backend = Hunch::Backends::Stub.new(fraud: 0.95, team: :billing, mood: :calm)
Use the question's key to supply its answer, or :answer for calls outside
a Hunch.decide block. Stub values depend on the method:
chanceand its predicates: a probability or boolean.pick: a symbol or a hash of probabilities.rate: a level symbol or numeric position.
Missing answers raise unless you supply default:. The stub records each
call in calls for assertions.
test "spam is dropped without a ticket" do
Hunch.backend = Hunch::Backends::Stub.new(answer: :spam)
assert_no_difference -> { Ticket.count } do
receive_inbound_email_from_mail(subject: "You have WON", body: "claim your prize")
end
end
Configuration
Hunch.configure do |config|
config.api_key = "..." # default: ENV["TYPESAFE_API_KEY"]
config.model = "jev-latest"
config.url = "https://api.typesafe.ai/v1/systemone"
config.timeout = 5
config.open_timeout = 2
config.max_retries = 2 # 429/5xx/timeouts, with backoff, honours Retry-After
config.levels[:definitely] = 0.99
end
Backends
Hunch includes a Jev backend, used by default, and a stub backend for testing.
OpenRouter
To call Jev through OpenRouter, set your API key, URL, and model:
Hunch.configure do |config|
config.api_key = ENV["OPENROUTER_API_KEY"]
config.url = "https://openrouter.ai/api/alpha/decisions"
config.model = "typesafe/jev-1.13"
end
License
MIT