jev-feels 1.0.0 → 1.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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ Definition = Data.define(:name, :type, :instructions, :choices, :levels) do
5
+ def self.build(name:, instructions:, choices: nil, levels: nil)
6
+ instructions = instructions.to_s.dup.freeze
7
+ raise ArgumentError, "cannot use choices: and levels: together" if choices && levels
8
+
9
+ new(name: name.to_sym, instructions: instructions, **typed_fields(choices, levels))
10
+ end
11
+
12
+ def simple_noul?
13
+ type == :noul
14
+ end
15
+
16
+ def public_value
17
+ simple_noul? ? instructions : self
18
+ end
19
+
20
+ def level_names
21
+ levels&.keys
22
+ end
23
+
24
+ def level_descriptions
25
+ levels&.values
26
+ end
27
+
28
+ def to_question
29
+ question = { "type" => type.to_s, "instructions" => instructions }
30
+ criteria = question_criteria
31
+ question["criteria"] = criteria if criteria
32
+ question
33
+ end
34
+
35
+ def self.typed_fields(choices, levels)
36
+ if choices
37
+ { type: :choice, choices: normalize_choices(choices), levels: nil }
38
+ elsif levels
39
+ { type: :score, levels: normalize_levels(levels), choices: nil }
40
+ else
41
+ { type: :noul, choices: nil, levels: nil }
42
+ end
43
+ end
44
+ private_class_method :typed_fields
45
+
46
+ def self.normalize_choices(choices)
47
+ raise ArgumentError, "choices must be a Hash" unless choices.is_a?(Hash)
48
+ raise ArgumentError, "choices cannot be empty" if choices.empty?
49
+
50
+ unique_keys(choices, "choice").freeze
51
+ end
52
+ private_class_method :normalize_choices
53
+
54
+ def self.normalize_levels(levels)
55
+ raise ArgumentError, "levels must be a Hash" unless levels.is_a?(Hash)
56
+ raise ArgumentError, "levels must have at least 2 entries" if levels.size < 2
57
+
58
+ unique_keys(levels, "level").freeze
59
+ end
60
+ private_class_method :normalize_levels
61
+
62
+ def self.unique_keys(pairs, label)
63
+ seen = {}
64
+ pairs.each do |key, description|
65
+ symbol = normalize_key(key, label)
66
+ raise ArgumentError, "duplicate #{label} key: #{symbol.inspect}" if seen.key?(symbol)
67
+
68
+ seen[symbol] = description.to_s.dup.freeze
69
+ end
70
+ seen
71
+ end
72
+ private_class_method :unique_keys
73
+
74
+ def self.normalize_key(key, label)
75
+ raise ArgumentError, "invalid #{label} key: #{key.inspect}" unless key.is_a?(Symbol) || key.is_a?(String)
76
+ raise ArgumentError, "invalid #{label} key: #{key.inspect}" if key.to_s.empty?
77
+
78
+ key.to_sym
79
+ end
80
+ private_class_method :normalize_key
81
+
82
+ private
83
+
84
+ def question_criteria
85
+ case type
86
+ when :choice then choices.transform_keys(&:to_s)
87
+ when :score then level_descriptions
88
+ end
89
+ end
90
+ end
91
+ end
data/lib/jev/errors.rb CHANGED
@@ -8,4 +8,5 @@ module Jev
8
8
  class AuthenticationError < RequestError; end
9
9
  class RateLimitError < RequestError; end
10
10
  class InvalidResponseError < Error; end
11
+ class ReplayError < Error; end
11
12
  end
data/lib/jev/feels.rb CHANGED
@@ -10,6 +10,18 @@ module Jev
10
10
  def feels?(...)
11
11
  Jev.feels?(self, ...)
12
12
  end
13
+
14
+ def decide(...)
15
+ Jev.decide(self, ...)
16
+ end
17
+
18
+ def score(...)
19
+ Jev.score(self, ...)
20
+ end
21
+
22
+ def measure(...)
23
+ Jev.measure(self, ...)
24
+ end
13
25
  end
14
26
  end
15
27
  end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Jev
6
+ module Harness
7
+ THREAD_KEY = :jev_transport
8
+
9
+ module_function
10
+
11
+ def current_transport(configuration)
12
+ Thread.current[THREAD_KEY] || configuration.transport || Transport.new(configuration)
13
+ end
14
+
15
+ def stub(answers, &)
16
+ raise ArgumentError, "Jev.stub requires a block" unless block_given?
17
+
18
+ overlay(StubTransport.new(answers), &)
19
+ end
20
+
21
+ def record(&)
22
+ raise ArgumentError, "Jev.record requires a block" unless block_given?
23
+
24
+ tape = Tape.new
25
+ inner = current_transport(Jev.configuration)
26
+ overlay(RecordingTransport.new(inner, tape), &)
27
+ tape
28
+ end
29
+
30
+ def replay(tape, &)
31
+ raise ArgumentError, "Jev.replay requires a block" unless block_given?
32
+
33
+ tape = Tape.parse(tape) unless tape.is_a?(Tape)
34
+ overlay(ReplayTransport.new(tape), &)
35
+ end
36
+
37
+ def overlay(transport)
38
+ previous = Thread.current[THREAD_KEY]
39
+ Thread.current[THREAD_KEY] = transport
40
+ yield
41
+ ensure
42
+ Thread.current[THREAD_KEY] = previous
43
+ end
44
+ private_class_method :overlay
45
+
46
+ class Tape
47
+ def initialize(entries = [])
48
+ @entries = entries
49
+ end
50
+
51
+ def record(payload, response)
52
+ @entries << { "request" => canonical(payload), "response" => canonical(response) }
53
+ end
54
+
55
+ def lookup(payload)
56
+ request = canonical(payload)
57
+ entry = @entries.find { |item| item["request"] == request }
58
+ raise ReplayError, replay_message(payload) unless entry
59
+
60
+ entry["response"]
61
+ end
62
+
63
+ def to_json(*)
64
+ JSON.generate({ "version" => 1, "entries" => @entries })
65
+ end
66
+
67
+ def self.parse(json)
68
+ payload = json.is_a?(String) ? JSON.parse(json) : json
69
+ raise ArgumentError, "tape is not a JSON object" unless payload.is_a?(Hash)
70
+
71
+ entries = payload["entries"]
72
+ raise ArgumentError, "tape is missing entries" unless entries.is_a?(Array)
73
+
74
+ new(entries)
75
+ end
76
+
77
+ private
78
+
79
+ def canonical(value)
80
+ case value
81
+ when Hash
82
+ value.to_h.transform_keys(&:to_s).sort.to_h.transform_values { |item| canonical(item) }
83
+ when Array
84
+ value.map { |item| canonical(item) }
85
+ else
86
+ value
87
+ end
88
+ end
89
+
90
+ def replay_message(payload)
91
+ ids = payload.is_a?(Hash) ? payload["questions"]&.keys : nil
92
+ "Jev replay has no recorded answer for questions #{ids.inspect}"
93
+ end
94
+ end
95
+
96
+ class RecordingTransport
97
+ def initialize(inner, tape)
98
+ @inner = inner
99
+ @tape = tape
100
+ end
101
+
102
+ def call(payload)
103
+ response = @inner.call(payload)
104
+ @tape.record(payload, response)
105
+ response
106
+ end
107
+ end
108
+
109
+ class ReplayTransport
110
+ def initialize(tape)
111
+ @tape = tape
112
+ end
113
+
114
+ def call(payload)
115
+ @tape.lookup(payload)
116
+ end
117
+ end
118
+
119
+ class StubTransport
120
+ def initialize(answers)
121
+ @answers = answers.to_h.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
122
+ end
123
+
124
+ def call(payload)
125
+ questions = payload.fetch("questions")
126
+ {
127
+ "answers" => questions.to_h { |id, question| [id, answer_for(id, question)] }
128
+ }
129
+ end
130
+
131
+ private
132
+
133
+ def answer_for(id, question)
134
+ stub = @answers[id.to_sym] || @answers[Jev.send(:name_for_instructions, question["instructions"])]
135
+ raise ArgumentError, "unstubbed Jev question: #{id}" if stub.nil?
136
+
137
+ case question["type"]
138
+ when "noul" then noul_answer(stub)
139
+ when "choice" then choice_answer(stub, question)
140
+ when "score" then score_answer(stub, question)
141
+ else
142
+ raise ArgumentError, "unstubbed Jev question: #{id}"
143
+ end
144
+ end
145
+
146
+ def noul_answer(stub)
147
+ noul = stub.is_a?(Hash) ? stub[:noul] || stub["noul"] || stub[:probability] : stub
148
+ { "type" => "noul", "noul" => Float(noul) }
149
+ end
150
+
151
+ def choice_answer(stub, question)
152
+ winner, confidence, probabilities = unpack_stub(stub, :choice) { stub.to_s }
153
+ probabilities ||= default_choice_probabilities(question["criteria"] || {}, winner)
154
+ {
155
+ "type" => "choice",
156
+ "choice" => winner.to_s,
157
+ "confidence" => Float(confidence),
158
+ "probabilities" => probabilities.to_h { |key, value| [key.to_s, Float(value)] }
159
+ }
160
+ end
161
+
162
+ def default_choice_probabilities(criteria, winner)
163
+ keys = criteria.keys.map(&:to_s)
164
+ keys << winner unless keys.include?(winner)
165
+ keys.to_h { |key| [key, key == winner ? 1.0 : 0.0] }
166
+ end
167
+
168
+ def score_answer(stub, question)
169
+ score, confidence, probabilities = unpack_stub(stub, :score) { stub }
170
+ probabilities ||= default_score_probabilities(question["criteria"] || [], score)
171
+ {
172
+ "type" => "score",
173
+ "score" => Float(score),
174
+ "confidence" => Float(confidence),
175
+ "probabilities" => score_probability_hash(probabilities, question)
176
+ }
177
+ end
178
+
179
+ def unpack_stub(stub, key)
180
+ return [yield, 1.0, nil] unless stub_hash?(stub, key)
181
+
182
+ [stub_field(stub, key), stub_field(stub, :confidence) || 1.0, stub_field(stub, :probabilities)]
183
+ end
184
+
185
+ def stub_hash?(stub, key)
186
+ stub.is_a?(Hash) && (stub.key?(key) || stub.key?(key.to_s))
187
+ end
188
+
189
+ def stub_field(stub, key)
190
+ stub[key] || stub[key.to_s]
191
+ end
192
+
193
+ def default_score_probabilities(criteria, score)
194
+ size = [criteria.size, 1].max
195
+ nearest = Float(score).round.clamp(0, size - 1)
196
+ size.times.to_h { |index| [index.to_s, index == nearest ? 1.0 : 0.0] }
197
+ end
198
+
199
+ def score_probability_hash(probabilities, question)
200
+ names = Jev.send(:level_names_for, question["instructions"])
201
+ probabilities.to_h do |key, value|
202
+ index = score_index(key, names)
203
+ [index.to_s, Float(value)]
204
+ end
205
+ end
206
+
207
+ def score_index(key, names)
208
+ return key if key.is_a?(Integer)
209
+ return Integer(key) if key.is_a?(String) && key.match?(/\A\d+\z/)
210
+
211
+ names&.index(key.to_sym) || Integer(key)
212
+ end
213
+ end
214
+ end
215
+ end
data/lib/jev/match.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ class Matcher
5
+ def initialize(choice)
6
+ @choice = choice
7
+ @handled = false
8
+ @result = nil
9
+ end
10
+
11
+ def on(*keys)
12
+ return @result if @handled
13
+ return unless keys.map(&:to_sym).include?(@choice)
14
+
15
+ @handled = true
16
+ @result = yield
17
+ end
18
+
19
+ def otherwise
20
+ return @result if @handled
21
+
22
+ @handled = true
23
+ @result = yield
24
+ end
25
+
26
+ attr_reader :result
27
+ end
28
+ end
data/lib/jev/model.rb CHANGED
@@ -12,10 +12,21 @@ module Jev
12
12
  subclass.extend ClassMethods
13
13
  end
14
14
 
15
- def feels(attribute, name, description)
15
+ def feels(attribute, name, description = nil, **options)
16
+ declare_jev(attribute, name, description, options)
17
+ end
18
+
19
+ def decide(attribute, name, description = nil, **options)
20
+ declare_jev(attribute, name, description, options)
21
+ end
22
+
23
+ def score(attribute, name, description = nil, **options)
24
+ declare_jev(attribute, name, description, options)
25
+ end
26
+
27
+ def bind_jev_attribute(attribute, name)
16
28
  raise ArgumentError, "attribute must be a Symbol" unless attribute.is_a?(Symbol)
17
29
 
18
- Jev.define(self, name, description)
19
30
  (@jev_feels_attributes ||= {})[name.to_sym] = attribute
20
31
  end
21
32
 
@@ -28,14 +39,55 @@ module Jev
28
39
  current = current.superclass
29
40
  end
30
41
  end
42
+
43
+ def jev_bound_fields
44
+ fields = []
45
+ current = self
46
+ while current && current != Object
47
+ fields.concat(Array(current.instance_variable_get(:@jev_feels_attributes)&.values))
48
+ current = current.superclass
49
+ end
50
+ fields.uniq
51
+ end
52
+
53
+ private
54
+
55
+ def declare_jev(attribute, name, description, options)
56
+ bind_jev_attribute(attribute, name)
57
+ return if description.nil? && options.empty?
58
+
59
+ Jev.define(self, name, description, **options)
60
+ end
31
61
  end
32
62
 
33
63
  def feels(predicate)
34
64
  Jev.feels(jev_text_for(predicate), self.class, predicate)
35
65
  end
36
66
 
37
- def feels?(predicate, threshold: Jev.configuration.threshold)
38
- Jev.feels?(jev_text_for(predicate), self.class, predicate, threshold: threshold)
67
+ def feels?(predicate, **)
68
+ Jev.feels?(jev_text_for(predicate), self.class, predicate, **)
69
+ end
70
+
71
+ def decide(predicate, **)
72
+ Jev.decide(jev_text_for(predicate), self.class, predicate, **)
73
+ end
74
+
75
+ def score(predicate, **)
76
+ Jev.score(jev_text_for(predicate), self.class, predicate, **)
77
+ end
78
+
79
+ def measure(predicate = nil, **, &)
80
+ if block_given?
81
+ Jev.measure(jev_batch_text, self.class, **, &)
82
+ else
83
+ raise ArgumentError, "question is required" if predicate.nil?
84
+
85
+ Jev.measure(jev_text_for(predicate), self.class, predicate, **)
86
+ end
87
+ end
88
+
89
+ def match(predicate, **, &)
90
+ Jev.match(jev_text_for(predicate), self.class, predicate, **, &)
39
91
  end
40
92
 
41
93
  private
@@ -47,5 +99,13 @@ module Jev
47
99
 
48
100
  public_send(attribute).to_s
49
101
  end
102
+
103
+ def jev_batch_text
104
+ fields = self.class.jev_bound_fields
105
+ raise ArgumentError, "no Jev field is bound for #{self.class}" if fields.empty?
106
+ raise ArgumentError, "measure block needs one field, got #{fields.inspect}" if fields.size > 1
107
+
108
+ public_send(fields.first).to_s
109
+ end
50
110
  end
51
111
  end
data/lib/jev/query.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ class Query
5
+ attr_reader :questions
6
+
7
+ def initialize(scope)
8
+ @scope = scope
9
+ @questions = {}
10
+ end
11
+
12
+ def feels(name, **options)
13
+ add(name, options)
14
+ end
15
+
16
+ def decide(name, **options)
17
+ add(name, options)
18
+ end
19
+
20
+ def score(name, **options)
21
+ add(name, options)
22
+ end
23
+
24
+ private
25
+
26
+ def add(name, options)
27
+ raise ArgumentError, "batch questions must be a defined Symbol" unless name.is_a?(Symbol)
28
+ raise ArgumentError, "duplicate batch question: #{name.inspect}" if @questions.key?(name)
29
+
30
+ @questions[name] = Jev.send(:resolve_definition, name, scope: @scope, **options)
31
+ end
32
+ end
33
+ end
data/lib/jev/registry.rb CHANGED
@@ -7,14 +7,13 @@ module Jev
7
7
  @definitions = {}
8
8
  end
9
9
 
10
- def define(scope, name, description)
10
+ def define(scope, name, definition)
11
11
  key = name.to_sym
12
- value = description.to_s.dup.freeze
13
12
  scope_key = storage_key(scope)
14
13
  @mutex.synchronize do
15
- (@definitions[scope_key] ||= {})[key] = value
14
+ (@definitions[scope_key] ||= {})[key] = definition
16
15
  end
17
- value
16
+ definition.public_value
18
17
  end
19
18
 
20
19
  def fetch(name, scope: nil, fallback: true, inherit: true)
@@ -33,7 +32,19 @@ module Jev
33
32
  end
34
33
 
35
34
  def all(scope = nil)
36
- @mutex.synchronize { (@definitions[storage_key(scope)] || {}).dup.freeze }
35
+ @mutex.synchronize do
36
+ (@definitions[storage_key(scope)] || {}).transform_values(&:public_value).freeze
37
+ end
38
+ end
39
+
40
+ def name_for_instructions(instructions)
41
+ each_definition { |name, definition| return name if definition.instructions == instructions }
42
+ nil
43
+ end
44
+
45
+ def find_by_instructions(instructions)
46
+ each_definition { |_name, definition| return definition if definition.instructions == instructions }
47
+ nil
37
48
  end
38
49
 
39
50
  def reset!
@@ -42,6 +53,14 @@ module Jev
42
53
 
43
54
  private
44
55
 
56
+ def each_definition(&block)
57
+ @mutex.synchronize do
58
+ @definitions.each_value do |defs|
59
+ defs.each(&block)
60
+ end
61
+ end
62
+ end
63
+
45
64
  def storage_key(scope)
46
65
  return if scope.nil?
47
66
 
data/lib/jev/result.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ module Result
5
+ def self.parse(answer, definition)
6
+ raise InvalidResponseError, "Jev response is missing an answer" unless answer.is_a?(Hash)
7
+
8
+ case definition.type
9
+ when :noul then Noul.parse(answer)
10
+ when :choice then Choice.parse(answer)
11
+ when :score then Score.parse(answer, definition)
12
+ else
13
+ raise InvalidResponseError, "unknown Jev definition type: #{definition.type.inspect}"
14
+ end
15
+ end
16
+
17
+ def self.finite_unit(value, label)
18
+ raise InvalidResponseError, "Jev response is missing #{label}" unless value.is_a?(Numeric)
19
+
20
+ value = Float(value)
21
+ raise InvalidResponseError, "Jev #{label} is not finite" unless value.finite?
22
+
23
+ value
24
+ end
25
+
26
+ class Noul
27
+ attr_reader :probability
28
+
29
+ def initialize(probability:)
30
+ @probability = probability
31
+ freeze
32
+ end
33
+
34
+ def self.parse(answer)
35
+ noul = answer["noul"]
36
+ raise InvalidResponseError, "Jev response is missing a noul probability" unless noul.is_a?(Numeric)
37
+
38
+ noul = Float(noul)
39
+ raise InvalidResponseError, "Jev noul probability is not finite" unless noul.finite?
40
+
41
+ # ponytail: clamp out-of-range noul; raise if Jev starts returning uncalibrated values
42
+ new(probability: noul.clamp(0.0, 1.0))
43
+ end
44
+
45
+ def type
46
+ :noul
47
+ end
48
+
49
+ def collapsed(threshold: 0.5)
50
+ probability >= threshold
51
+ end
52
+ end
53
+
54
+ class Choice
55
+ attr_reader :choice, :confidence, :probabilities
56
+
57
+ def initialize(choice:, confidence:, probabilities:)
58
+ @choice = choice
59
+ @confidence = confidence
60
+ @probabilities = probabilities
61
+ freeze
62
+ end
63
+
64
+ def self.parse(answer)
65
+ winner = answer["choice"]
66
+ raise InvalidResponseError, "Jev response is missing a choice" if winner.nil?
67
+
68
+ raw = answer["probabilities"]
69
+ raise InvalidResponseError, "Jev response is missing choice probabilities" unless raw.is_a?(Hash)
70
+
71
+ probabilities = raw.to_h { |key, value| [key.to_sym, Result.finite_unit(value, "choice probability")] }
72
+ new(
73
+ choice: winner.to_sym,
74
+ confidence: Result.finite_unit(answer["confidence"], "choice confidence").clamp(0.0, 1.0),
75
+ probabilities: probabilities.freeze
76
+ )
77
+ end
78
+
79
+ def type
80
+ :choice
81
+ end
82
+
83
+ def collapsed(*)
84
+ choice
85
+ end
86
+ end
87
+
88
+ class Score
89
+ attr_reader :score, :confidence, :probabilities, :levels, :level
90
+
91
+ def initialize(score:, confidence:, probabilities:, levels:, level:)
92
+ @score = score
93
+ @confidence = confidence
94
+ @probabilities = probabilities
95
+ @levels = levels
96
+ @level = level
97
+ freeze
98
+ end
99
+
100
+ def self.parse(answer, definition)
101
+ names = definition.level_names
102
+ probabilities = score_probabilities(answer["probabilities"], names)
103
+ score = Result.finite_unit(answer["score"], "score")
104
+ new(
105
+ score: score,
106
+ confidence: Result.finite_unit(answer["confidence"], "score confidence").clamp(0.0, 1.0),
107
+ probabilities: probabilities,
108
+ levels: definition.levels,
109
+ level: named_level(probabilities, names)
110
+ )
111
+ end
112
+
113
+ def self.score_probabilities(raw, names)
114
+ raise InvalidResponseError, "Jev response is missing score probabilities" unless raw.is_a?(Hash)
115
+
116
+ raw.to_h do |key, value|
117
+ index = Integer(key)
118
+ [names ? names.fetch(index, index) : index, Result.finite_unit(value, "score probability")]
119
+ end.freeze
120
+ rescue ArgumentError, TypeError
121
+ raise InvalidResponseError, "Jev score probabilities are not keyed by level number"
122
+ end
123
+
124
+ def self.named_level(probabilities, names)
125
+ return unless names
126
+
127
+ key, = probabilities.max_by { |_, weight| weight }
128
+ key if key.is_a?(Symbol)
129
+ end
130
+ private_class_method :named_level
131
+
132
+ def type
133
+ :score
134
+ end
135
+
136
+ def collapsed(*)
137
+ score
138
+ end
139
+ end
140
+
141
+ class Batch
142
+ def initialize(results)
143
+ @results = results.transform_keys(&:to_sym).freeze
144
+ freeze
145
+ end
146
+
147
+ def [](key)
148
+ @results[key.to_sym]
149
+ end
150
+
151
+ def to_h
152
+ @results.transform_values(&:collapsed)
153
+ end
154
+
155
+ def deconstruct_keys(keys)
156
+ collapsed = to_h
157
+ keys ? collapsed.slice(*keys.map(&:to_sym)) : collapsed
158
+ end
159
+
160
+ def type
161
+ :batch
162
+ end
163
+ end
164
+ end
165
+ end