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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/Rakefile +64 -0
- data/feelings.gemspec +39 -0
- data/lib/feelings/about.rb +201 -0
- data/lib/feelings/core_ext.rb +17 -0
- data/lib/feelings/distribution.rb +39 -0
- data/lib/feelings/engine.rb +141 -0
- data/lib/feelings/errors.rb +21 -0
- data/lib/feelings/judges/decision_model.rb +37 -0
- data/lib/feelings/judges/stub.rb +91 -0
- data/lib/feelings/labels.rb +51 -0
- data/lib/feelings/match_builder.rb +33 -0
- data/lib/feelings/mood.rb +102 -0
- data/lib/feelings/pick.rb +19 -0
- data/lib/feelings/questions.rb +27 -0
- data/lib/feelings/railtie.rb +10 -0
- data/lib/feelings/registry.rb +72 -0
- data/lib/feelings/state.rb +28 -0
- data/lib/feelings/tape.rb +53 -0
- data/lib/feelings/version.rb +5 -0
- data/lib/feelings.rb +166 -0
- metadata +116 -0
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
|