A decision model can answer a typed question about a value with a calibrated probability.
Probably showed what code reads like when
an if can ask one of those questions directly instead of hand-rolling a prompt. feelings
brings that construct, feels, to Ruby. Inspired by Probably.
It works with Jev through OpenRouter or Typesafe's API via ruby_decision_model, the default judge.
gem "feelings"No configuration is required beyond an API key. Set OPENROUTER_API_KEY or
TYPESAFE_API_KEY in your environment and feelings picks a judge automatically the first
time you ask it something.
require "feelings"
Feelings("that email").like?("genuinely urgent")
# => true or falselike? asks a yes/no question about a value.
f = Feelings(email.body)
f.like?("spam")like returns a Mood you can branch on. Declare a maybe branch and the yes/maybe/no split
widens to 0.3/0.7 automatically, so an ambiguous answer has somewhere to go.
f.like("full of corporate jargon") do |mood|
mood.yes { rewrite(draft) }
mood.maybe { flag_for_review(draft) }
mood.no { draft }
endPass at_least: to set your own probability floor instead. like? then returns nil for the
middle zone rather than forcing a guess.
f.like?("a security incident", at_least: 0.8) # true, false, or nilmost_like picks the best-fitting label out of a set and hands back the winning symbol.
KINDS = { invitation: "an invitation to an event", sales_pitch: "someone selling something" }
Feelings(email).most_like(KINDS) # => :invitationPass confidence: to get nil back instead of a shaky guess.
Keep your descriptions and label sets out of the codebase and load them once.
# config/feelings.yml
kinds:
invitation: an invitation to an event
sales_pitch: someone selling something
other: anything else
spam: an unsolicited commercial emailFeelings.load("config/feelings.yml")
Feelings(email).like?(:spam) # uses the registered description
Feelings(email).most_like(:kinds) # uses the registered label setYAML loading is lazy, so plain require "feelings" never pulls in the YAML library. You can also
register descriptions straight from Ruby without touching a file:
Feelings.register(spam: "an unsolicited commercial email", kinds: KINDS)Every construct works fine with an empty registry and no config file. Feelings.load and
Feelings.register can both be called more than once; later calls override earlier keys.
Any like? or like call given a Hash asks everything it names in one request.
Feelings(email).like?(spam: "spam", urgent: :urgent, kind: :kinds)
# => { spam: false, urgent: true, kind: :invitation }match dispatches on the winning label with a block, inline descriptions included.
Feelings(email).match(KINDS) do
on(:invitation) { accept_invite(email) }
on(:sales_pitch, :other) { archive(email) }
otherwise { flag_for_review(email) }
endA bounded loop that keeps calling your block while a value still feels a certain way.
draft = Feelings.while(draft, "full of corporate jargon", max: 5) { |current| rewrite(current) }It raises Feelings::LoopLimit if the value still feels that way after max iterations.
Feelings.chaos samples from the answer's probability distribution instead of always taking
the top pick, useful for simulations and load-testing prompts.
Feelings.chaos { Feelings(email).most_like(KINDS) }Record a real run once, then replay it in tests without ever calling a judge again.
tape = Feelings.record { Feelings(email).like?("spam") }
Feelings.replay(tape) { Feelings(email).like?("spam") } # judge is never calledA tape round-trips through JSON with tape.to_json and Feelings::Tape.from_json.
require "feelings/core_ext"
email.feels_like?("spam")
email.feels_most_like(KINDS)Feelings.judge = Feelings::Judges::Stub.new("spam" => 0.9, kinds: :invitation)
Feelings(email).like?("spam") # => true, no network call0.1.0. The API above is the whole surface.
Publishing runs through RubyGems trusted publishing, so no API key is stored anywhere. To ship a version:
- Bump
lib/feelings/version.rb. - Add the version to
CHANGELOG.md. - Merge to
main. The Release workflow runs the suite, builds the gem withgem build --strict, checks the built gem carries every file underlib/, and pushes it. A version already on RubyGems is skipped, so the workflow is safe to re-run.
The same workflow can be started by hand from the Actions tab or with
gh workflow run release.yml.