feelings 0.0.1

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: b6ea114c41b7209ab7464a90648496db559758e2c2b227d0e84bb83cc4381331
4
+ data.tar.gz: ce9a7258312c3501aee7eb748e9e157b218afed0a8fb689f57d156d173573fe8
5
+ SHA512:
6
+ metadata.gz: 41d0a2e969d98fe47b788d92736b0cda09e6a1420cd7acb1c3b5b1f085dbede5d1520f14954b0a400596c808b3dcf7e0aac91d5ff3d801907ed15683536e7b99
7
+ data.tar.gz: 1857417a1c55ca4888e025e470ae321e1f0777f77ae68febb4dbe0bae186e4f35bcb22f4932b5e864e210014f5673fab61abac3b52849c7256338a27b5ab01f6
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ - Initial release. `Feelings(value).like?`, `.like`, `.most_like`, `.pick`, and `.match` for
6
+ probabilistic conditionals backed by a decision model. YAML-backed label registry, hash
7
+ batching into a single request, `Feelings.while` for bounded loops, `Feelings.chaos` for
8
+ distribution sampling, and `Feelings.record` / `Feelings.replay` for tapes. Default judge is
9
+ `RubyDecisionModel.client`; ships with a `Feelings::Judges::Stub` for tests and an optional
10
+ Rails railtie.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Obie Fernandez
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # feelings
2
+
3
+ A decision model can answer a typed question about a value with a calibrated probability.
4
+ [Probably](https://probably-lang.southpolesteve.workers.dev/) showed what code reads like when
5
+ an `if` can ask one of those questions directly instead of hand-rolling a prompt. `feelings`
6
+ brings that construct, `feels`, to Ruby. Inspired by Probably.
7
+
8
+ It works with Jev through OpenRouter or Typesafe's API via
9
+ [ruby_decision_model](https://rubygems.org/gems/ruby_decision_model), the default judge.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ gem "feelings"
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ No configuration is required beyond an API key. Set `OPENROUTER_API_KEY` or
20
+ `TYPESAFE_API_KEY` in your environment and `feelings` picks a judge automatically the first
21
+ time you ask it something.
22
+
23
+ ```ruby
24
+ require "feelings"
25
+
26
+ Feelings("that email").like?("genuinely urgent")
27
+ # => true or false
28
+ ```
29
+
30
+ ## like? and like
31
+
32
+ `like?` asks a yes/no question about a value.
33
+
34
+ ```ruby
35
+ f = Feelings(email.body)
36
+ f.like?("spam")
37
+ ```
38
+
39
+ `like` returns a `Mood` you can branch on. Declare a `maybe` branch and the yes/maybe/no split
40
+ widens to 0.3/0.7 automatically, so an ambiguous answer has somewhere to go.
41
+
42
+ ```ruby
43
+ f.like("full of corporate jargon") do |mood|
44
+ mood.yes { rewrite(draft) }
45
+ mood.maybe { flag_for_review(draft) }
46
+ mood.no { draft }
47
+ end
48
+ ```
49
+
50
+ Pass `at_least:` to set your own probability floor instead. `like?` then returns `nil` for the
51
+ middle zone rather than forcing a guess.
52
+
53
+ ```ruby
54
+ f.like?("a security incident", at_least: 0.8) # true, false, or nil
55
+ ```
56
+
57
+ ## most_like
58
+
59
+ `most_like` picks the best-fitting label out of a set and hands back the winning symbol.
60
+
61
+ ```ruby
62
+ KINDS = { invitation: "an invitation to an event", sales_pitch: "someone selling something" }
63
+ Feelings(email).most_like(KINDS) # => :invitation
64
+ ```
65
+
66
+ Pass `confidence:` to get `nil` back instead of a shaky guess.
67
+
68
+ ## YAML registry
69
+
70
+ Keep your descriptions and label sets out of the codebase and load them once.
71
+
72
+ ```yaml
73
+ # config/feelings.yml
74
+ kinds:
75
+ invitation: an invitation to an event
76
+ sales_pitch: someone selling something
77
+ other: anything else
78
+ spam: an unsolicited commercial email
79
+ ```
80
+
81
+ ```ruby
82
+ Feelings.load("config/feelings.yml")
83
+ Feelings(email).like?(:spam) # uses the registered description
84
+ Feelings(email).most_like(:kinds) # uses the registered label set
85
+ ```
86
+
87
+ YAML loading is lazy, so plain `require "feelings"` never pulls in the YAML library. You can also
88
+ register descriptions straight from Ruby without touching a file:
89
+
90
+ ```ruby
91
+ Feelings.register(spam: "an unsolicited commercial email", kinds: KINDS)
92
+ ```
93
+
94
+ Every construct works fine with an empty registry and no config file. `Feelings.load` and
95
+ `Feelings.register` can both be called more than once; later calls override earlier keys.
96
+
97
+ ## Hash batching
98
+
99
+ Any `like?` or `like` call given a Hash asks everything it names in one request.
100
+
101
+ ```ruby
102
+ Feelings(email).like?(spam: "spam", urgent: :urgent, kind: :kinds)
103
+ # => { spam: false, urgent: true, kind: :invitation }
104
+ ```
105
+
106
+ ## match
107
+
108
+ `match` dispatches on the winning label with a block, inline descriptions included.
109
+
110
+ ```ruby
111
+ Feelings(email).match(KINDS) do
112
+ on(:invitation) { accept_invite(email) }
113
+ on(:sales_pitch, :other) { archive(email) }
114
+ otherwise { flag_for_review(email) }
115
+ end
116
+ ```
117
+
118
+ ## Feelings.while
119
+
120
+ A bounded loop that keeps calling your block while a value still feels a certain way.
121
+
122
+ ```ruby
123
+ draft = Feelings.while(draft, "full of corporate jargon", max: 5) { |current| rewrite(current) }
124
+ ```
125
+
126
+ It raises `Feelings::LoopLimit` if the value still feels that way after `max` iterations.
127
+
128
+ ## Chaos
129
+
130
+ `Feelings.chaos` samples from the answer's probability distribution instead of always taking
131
+ the top pick, useful for simulations and load-testing prompts.
132
+
133
+ ```ruby
134
+ Feelings.chaos { Feelings(email).most_like(KINDS) }
135
+ ```
136
+
137
+ ## Record and replay
138
+
139
+ Record a real run once, then replay it in tests without ever calling a judge again.
140
+
141
+ ```ruby
142
+ tape = Feelings.record { Feelings(email).like?("spam") }
143
+ Feelings.replay(tape) { Feelings(email).like?("spam") } # judge is never called
144
+ ```
145
+
146
+ A tape round-trips through JSON with `tape.to_json` and `Feelings::Tape.from_json`.
147
+
148
+ ## Core extension
149
+
150
+ ```ruby
151
+ require "feelings/core_ext"
152
+
153
+ email.feels_like?("spam")
154
+ email.feels_most_like(KINDS)
155
+ ```
156
+
157
+ ## Testing with Stub
158
+
159
+ ```ruby
160
+ Feelings.judge = Feelings::Judges::Stub.new("spam" => 0.9, kinds: :invitation)
161
+ Feelings(email).like?("spam") # => true, no network call
162
+ ```
163
+
164
+ ## Status
165
+
166
+ 0.0.1. The API above is the whole surface.
167
+
168
+ ## Releasing
169
+
170
+ Publishing runs through RubyGems trusted publishing, so no API key is stored
171
+ anywhere. To ship a version:
172
+
173
+ 1. Bump `lib/feelings/version.rb`.
174
+ 2. Add the version to `CHANGELOG.md`.
175
+ 3. Merge to `main`. The Release workflow runs the suite, builds the gem with
176
+ `gem build --strict`, checks the built gem carries every file under
177
+ `lib/`, and pushes it. A version already on RubyGems is skipped, so the
178
+ workflow is safe to re-run.
179
+
180
+ The same workflow can be started by hand from the Actions tab or with
181
+ `gh workflow run release.yml`.
data/Rakefile ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
13
+
14
+ # Release tooling, the single-gem shape of the terret repo's rake release
15
+ # tasks. The gemspec is the one source of name and version; nothing here
16
+ # hardcodes either. The release workflow drives these three tasks in order.
17
+ namespace :release do
18
+ gemspec = -> { Gem::Specification.load(Dir["*.gemspec"].first) }
19
+
20
+ published = lambda do |spec|
21
+ require "open-uri"
22
+ require "json"
23
+ body = URI.open("https://rubygems.org/api/v1/versions/#{spec.name}.json", &:read)
24
+ JSON.parse(body).any? { |v| v["number"] == spec.version.to_s }
25
+ rescue OpenURI::HTTPError
26
+ false # 404 => the gem has never been published, so there is nothing to skip
27
+ end
28
+
29
+ desc "Build the gem into pkg/ with --strict (proof the gemspec is valid). " \
30
+ "Reversible, pkg/ is gitignored, and the local half of a release; " \
31
+ "release:push is the irreversible half."
32
+ task :build do
33
+ require "fileutils"
34
+ spec = gemspec.call
35
+ FileUtils.rm_rf("pkg")
36
+ FileUtils.mkdir_p("pkg")
37
+ # --strict escalates any spec warning to a build failure.
38
+ sh "gem build #{File.basename(spec.loaded_from)} --strict --output pkg/#{spec.name}-#{spec.version}.gem"
39
+ end
40
+
41
+ desc "Print publish=true when the gemspec's version is not yet on RubyGems " \
42
+ "and publish=false when it is. The release workflow reads this to decide " \
43
+ "whether to fetch credentials and push at all."
44
+ task :status do
45
+ puts "publish=#{!published.call(gemspec.call)}"
46
+ end
47
+
48
+ desc "Push pkg/<gem>-<version>.gem to RubyGems. A version already published " \
49
+ "is skipped, so a re-run is safe. The release workflow runs this with a " \
50
+ "short-lived key from trusted publishing; by hand it needs RubyGems MFA."
51
+ task :push do
52
+ spec = gemspec.call
53
+ gem_file = "pkg/#{spec.name}-#{spec.version}.gem"
54
+ abort "release:push: #{gem_file} is missing, run `rake release:build` first" unless File.exist?(gem_file)
55
+
56
+ if published.call(spec)
57
+ puts "skip #{spec.name} #{spec.version} (already on RubyGems)"
58
+ next
59
+ end
60
+
61
+ puts "push #{gem_file}"
62
+ sh "gem push #{gem_file}"
63
+ end
64
+ end
data/feelings.gemspec ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/feelings/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "feelings"
7
+ spec.version = Feelings::VERSION
8
+ spec.authors = ["Obie Fernandez"]
9
+ spec.email = ["obiefernandez@gmail.com"]
10
+
11
+ spec.summary = "Probabilistic conditionals for Ruby: Feelings(message).like?(\"genuinely urgent\")"
12
+ spec.description = <<~DESC.strip.gsub(/\n/, " ")
13
+ feelings brings the feels construct from the Probably language to Ruby. A decision
14
+ model judges how well a description fits a value, and feelings turns that into
15
+ conditionals, semantic match, bounded loops, chaos sampling, and replayable tapes.
16
+ Descriptions and label sets can live in YAML. Works with any judge; ruby_decision_model
17
+ is the default.
18
+ DESC
19
+ spec.homepage = "https://github.com/obie/feelings"
20
+ spec.license = "MIT"
21
+ spec.required_ruby_version = ">= 3.2"
22
+
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "ruby_decision_model", "~> 0.1"
34
+
35
+ spec.add_development_dependency "minitest", "~> 5.0"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+
38
+ spec.metadata["rubygems_mfa_required"] = "true"
39
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feelings
4
+ # The wrapper returned by Feelings(value). Holds the value and memoizes
5
+ # answers per question so asking the same thing twice never re-judges.
6
+ class About
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ @resolved_state = State.resolve(value)
12
+ @memo = {}
13
+ end
14
+
15
+ def like?(arg = nil, at_least: nil, confidence: nil, **rest)
16
+ hash = batch_hash(arg, rest)
17
+ if hash
18
+ return batch(hash, at_least: at_least, confidence: confidence).transform_values { |result| coerce_bool(result) }
19
+ end
20
+
21
+ ask_mood(arg, at_least: at_least).to_bool
22
+ end
23
+
24
+ def like(arg = nil, at_least: nil, confidence: nil, **rest, &block)
25
+ hash = batch_hash(arg, rest)
26
+ return batch(hash, at_least: at_least, confidence: confidence) if hash
27
+
28
+ mood = ask_mood(arg, at_least: at_least)
29
+ return mood unless block
30
+
31
+ mood.collect(&block)
32
+ end
33
+
34
+ def most_like(*args, confidence: nil, **rest)
35
+ args = args + [rest] unless rest.empty?
36
+ labels = build_label_set(args)
37
+ pick = ask_pick(labels, confidence: confidence)
38
+ pick&.label
39
+ end
40
+
41
+ def pick(*args, confidence: nil, **rest)
42
+ args = args + [rest] unless rest.empty?
43
+ labels = build_label_set(args)
44
+ ask_pick(labels, confidence: confidence)
45
+ end
46
+
47
+ def match(labels = nil, confidence: nil, &block)
48
+ builder = MatchBuilder.new
49
+ builder.instance_eval(&block) if block
50
+
51
+ merged = labels ? Labels.resolve(labels) : {}
52
+ builder.labels.each { |key, description| merged[key] = description }
53
+ builder.branches.each do |branch|
54
+ branch.keys.each { |key| merged[key] ||= Labels.description_for(key) }
55
+ end
56
+ Labels.validate!(merged)
57
+
58
+ pick = ask_pick(merged, confidence: confidence)
59
+ branch = pick && builder.branch_for(pick.label)
60
+
61
+ if branch
62
+ branch.block.call
63
+ elsif builder.otherwise
64
+ builder.otherwise.call
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def batch_hash(arg, rest)
71
+ if arg.is_a?(Hash)
72
+ rest.empty? ? arg : arg.merge(rest)
73
+ elsif !rest.empty?
74
+ rest
75
+ end
76
+ end
77
+
78
+ def coerce_bool(result)
79
+ case result
80
+ when Mood
81
+ result.to_bool
82
+ when Pick
83
+ result.label
84
+ else
85
+ result
86
+ end
87
+ end
88
+
89
+ # Raw answers are memoized per question so a second ask with a different
90
+ # threshold reuses the judgment instead of paying for another request.
91
+ def ask_mood(description, at_least:)
92
+ resolved = resolve_description(description)
93
+ answer = @memo[[:noul, resolved]] ||=
94
+ Engine.call(value: @resolved_state, specs: { sole: { kind: :noul, description: resolved } })[:sole]
95
+ build_mood(answer, resolved, at_least: at_least)
96
+ end
97
+
98
+ def ask_pick(labels, confidence:)
99
+ answer = @memo[[:choice, labels]] ||=
100
+ Engine.call(value: @resolved_state, specs: { sole: { kind: :choice, labels: labels } })[:sole]
101
+ build_pick(answer, confidence: confidence)
102
+ end
103
+
104
+ def build_mood(answer, description, at_least:)
105
+ Mood.new(
106
+ probability: answer[:probability],
107
+ description: description,
108
+ value: value,
109
+ model: answer[:model],
110
+ at_least: at_least,
111
+ draw: answer[:draw]
112
+ )
113
+ end
114
+
115
+ def build_pick(answer, confidence:)
116
+ pick = Pick.new(
117
+ label: answer[:choice],
118
+ confidence: answer[:confidence],
119
+ probabilities: answer[:probabilities],
120
+ model: answer[:model]
121
+ )
122
+ return nil if confidence && pick.confidence < confidence
123
+
124
+ pick
125
+ end
126
+
127
+ def batch(hash, at_least: nil, confidence: nil)
128
+ specs = {}
129
+ descriptions = {}
130
+
131
+ hash.each do |id, raw|
132
+ kind, payload = classify_value(raw)
133
+ if kind == :noul
134
+ specs[id] = { kind: :noul, description: payload }
135
+ descriptions[id] = payload
136
+ else
137
+ specs[id] = { kind: :choice, labels: payload }
138
+ end
139
+ end
140
+
141
+ answers = Engine.call(value: @resolved_state, specs: specs)
142
+ answers.each_with_object({}) do |(id, answer), results|
143
+ results[id] = if specs[id][:kind] == :noul
144
+ build_mood(answer, descriptions[id], at_least: at_least)
145
+ else
146
+ build_pick(answer, confidence: confidence)
147
+ end
148
+ end
149
+ end
150
+
151
+ def classify_value(raw)
152
+ case raw
153
+ when String
154
+ [:noul, raw]
155
+ when Hash, Array
156
+ [:choice, Labels.resolve(raw)]
157
+ when Symbol
158
+ registered = Feelings[raw]
159
+ if registered.is_a?(Hash)
160
+ [:choice, Labels.resolve(registered)]
161
+ else
162
+ [:noul, registered.is_a?(String) ? registered : Labels.humanize(raw)]
163
+ end
164
+ else
165
+ raise ArgumentError, "unsupported question value #{raw.inspect}"
166
+ end
167
+ end
168
+
169
+ def resolve_description(description)
170
+ case description
171
+ when String
172
+ description
173
+ when Symbol
174
+ registered = Feelings[description]
175
+ registered.is_a?(String) || registered.is_a?(Hash) ? registered : Labels.humanize(description)
176
+ else
177
+ raise ArgumentError, "description must be a String or Symbol, got #{description.class}"
178
+ end
179
+ end
180
+
181
+ def build_label_set(args)
182
+ labels =
183
+ if args.size == 1 && (args.first.is_a?(Symbol) || args.first.is_a?(Array) || args.first.is_a?(Hash))
184
+ Labels.resolve(args.first)
185
+ else
186
+ args.each_with_object({}) do |arg, hash|
187
+ case arg
188
+ when Symbol
189
+ hash[arg] = Labels.description_for(arg)
190
+ when Hash
191
+ arg.each { |key, description| hash[key.to_sym] = description }
192
+ else
193
+ raise BadLabels, "unsupported label argument #{arg.inspect}"
194
+ end
195
+ end
196
+ end
197
+
198
+ Labels.validate!(labels)
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../feelings"
4
+
5
+ class Object
6
+ def feels_like?(...)
7
+ Feelings(self).like?(...)
8
+ end
9
+
10
+ def feels_like(...)
11
+ Feelings(self).like(...)
12
+ end
13
+
14
+ def feels_most_like(...)
15
+ Feelings(self).most_like(...)
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feelings
4
+ # Helpers for turning a probability spread into a normalized distribution
5
+ # and sampling from it, used by Feelings.chaos.
6
+ module Distribution
7
+ TOLERANCE = 0.02
8
+
9
+ module_function
10
+
11
+ def normalize(probabilities)
12
+ raise InvalidDistribution, "distribution must not be empty" if probabilities.nil? || probabilities.empty?
13
+
14
+ floats = probabilities.transform_values(&:to_f)
15
+ sum = floats.values.sum
16
+
17
+ if sum.zero?
18
+ even = 1.0 / floats.size
19
+ return floats.transform_values { even }
20
+ end
21
+
22
+ unless (sum - 1.0).abs <= TOLERANCE
23
+ raise InvalidDistribution, "distribution sums to #{sum}, expected roughly 1.0"
24
+ end
25
+
26
+ floats.transform_values { |v| v / sum }
27
+ end
28
+
29
+ def sample(probabilities, draw)
30
+ normalized = normalize(probabilities)
31
+ cumulative = 0.0
32
+ normalized.each do |key, probability|
33
+ cumulative += probability
34
+ return key if draw < cumulative
35
+ end
36
+ normalized.keys.last
37
+ end
38
+ end
39
+ end