jev-feels 0.2.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 ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ module Model
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def inherited(subclass)
11
+ super
12
+ subclass.extend ClassMethods
13
+ end
14
+
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)
28
+ raise ArgumentError, "attribute must be a Symbol" unless attribute.is_a?(Symbol)
29
+
30
+ (@jev_feels_attributes ||= {})[name.to_sym] = attribute
31
+ end
32
+
33
+ def jev_feels_attribute(name)
34
+ current = self
35
+ while current && current != Object
36
+ found = current.instance_variable_get(:@jev_feels_attributes)&.[](name)
37
+ return found if found
38
+
39
+ current = current.superclass
40
+ end
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
61
+ end
62
+
63
+ def feels(predicate)
64
+ Jev.feels(jev_text_for(predicate), self.class, predicate)
65
+ end
66
+
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, **, &)
91
+ end
92
+
93
+ private
94
+
95
+ def jev_text_for(predicate)
96
+ attribute = self.class.jev_feels_attribute(predicate.to_sym) || raise(
97
+ UndefinedDefinition, "Undefined Jev definition: #{predicate.inspect} for #{self.class}"
98
+ )
99
+
100
+ public_send(attribute).to_s
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
110
+ end
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,32 +7,96 @@ 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
12
+ scope_key = storage_key(scope)
13
13
  @mutex.synchronize do
14
- (@definitions[scope] ||= {})[key] = value
14
+ (@definitions[scope_key] ||= {})[key] = definition
15
15
  end
16
- value
16
+ definition.public_value
17
17
  end
18
18
 
19
- def fetch(name, scope: nil, fallback: true)
19
+ def fetch(name, scope: nil, fallback: true, inherit: true)
20
20
  key = name.to_sym
21
21
  @mutex.synchronize do
22
- scoped = @definitions.dig(scope, key) if scope
23
- return scoped if scoped
24
- return unless fallback || scope.nil?
22
+ return @definitions.dig(nil, key) if scope.nil?
25
23
 
26
- @definitions.dig(nil, key)
24
+ lookup_keys(scope, inherit: inherit).each do |scope_key|
25
+ found = @definitions.dig(scope_key, key)
26
+ return found if found
27
+ end
28
+ return @definitions.dig(nil, key) if fallback
29
+
30
+ nil
27
31
  end
28
32
  end
29
33
 
30
34
  def all(scope = nil)
31
- @mutex.synchronize { (@definitions[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
32
48
  end
33
49
 
34
50
  def reset!
35
51
  @mutex.synchronize { @definitions.clear }
36
52
  end
53
+
54
+ private
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
+
64
+ def storage_key(scope)
65
+ return if scope.nil?
66
+
67
+ key =
68
+ case scope
69
+ when String, Symbol then scope.to_s
70
+ when Module then scope.name || scope
71
+ else raise ArgumentError, "scope must be a Module, String, or Symbol"
72
+ end
73
+ raise ArgumentError, "scope must be a non-empty String" if key.is_a?(String) && key.empty?
74
+
75
+ key
76
+ end
77
+
78
+ def lookup_keys(scope, inherit:)
79
+ keys = [storage_key(scope)]
80
+ return keys unless inherit
81
+
82
+ klass = scope.is_a?(Class) ? scope : constantize(scope)
83
+ return keys unless klass.is_a?(Class)
84
+
85
+ current = klass.superclass
86
+ while current && current != Object
87
+ keys << (current.name || current)
88
+ current = current.superclass
89
+ end
90
+ keys.uniq
91
+ end
92
+
93
+ def constantize(name)
94
+ name = name.to_s if name.is_a?(Symbol)
95
+ return unless name.is_a?(String)
96
+
97
+ name.split("::").reduce(Object) { |mod, part| mod.const_get(part, false) }
98
+ rescue NameError
99
+ nil
100
+ end
37
101
  end
38
102
  end