judge_rails 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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Facade
5
+ DEFAULT_NAME = :answer
6
+
7
+ def noul(instructions, criteria = nil, name: nil)
8
+ Question::Noul.new(instructions, criteria, name: name)
9
+ end
10
+
11
+ def choice(instructions, options, name: nil)
12
+ Question::Choice.new(instructions, options, name: name)
13
+ end
14
+
15
+ def score(instructions, levels, name: nil)
16
+ Question::Score.new(instructions, levels, name: name)
17
+ end
18
+
19
+ def ask(questions, text:, model: nil, adapter: nil)
20
+ single = single_question?(questions)
21
+ normalized = normalize_questions(questions)
22
+ raise ArgumentError, "ask needs at least one question" if normalized.empty?
23
+
24
+ results = (adapter || self.adapter).call(state: text, questions: normalized, model: model)
25
+ return results unless single
26
+
27
+ name = normalized.keys.first
28
+ results[name] || raise(InvalidResponseError, "no answer for #{name.inspect}")
29
+ end
30
+
31
+ def adapter
32
+ @adapter || Adapter.resolve(config.adapter)
33
+ end
34
+
35
+ attr_writer :adapter
36
+
37
+ private
38
+
39
+ def normalize_questions(input)
40
+ case input
41
+ when Question then { input.name || DEFAULT_NAME => input }
42
+ when String then { DEFAULT_NAME => noul(input) }
43
+ when Hash then named_from_hash(input)
44
+ when Array then named_from_array(input)
45
+ else raise ArgumentError, "expected a Question, String, Array or Hash, got #{input.class}"
46
+ end
47
+ end
48
+
49
+ def named_from_hash(input)
50
+ named = input.to_h { |name, q| [name.to_sym, coerce_question(q).with_name(name)] }
51
+ raise ArgumentError, "duplicate question names" if named.size != input.size
52
+
53
+ named
54
+ end
55
+
56
+ def single_question?(input)
57
+ input.is_a?(Question) || input.is_a?(String)
58
+ end
59
+
60
+ def coerce_question(value)
61
+ value.is_a?(String) ? noul(value) : value
62
+ end
63
+
64
+ def named_from_array(list)
65
+ named = list.each_with_index.to_h do |q, i|
66
+ question = coerce_question(q)
67
+ name = question.name || :"q#{i}"
68
+ [name, question.with_name(name)]
69
+ end
70
+ raise ArgumentError, "duplicate question names" if named.size != list.size
71
+
72
+ named
73
+ end
74
+ end
75
+ end
data/lib/judge/pool.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Pool
5
+ DEFAULT_CONCURRENCY = 8
6
+ THREAD_NAME = "judge-pool"
7
+
8
+ @worker_exit_hooks = []
9
+
10
+ class << self
11
+ def map(items, concurrency: nil, &work)
12
+ list = items.to_a
13
+ return [] if list.empty?
14
+
15
+ workers = (concurrency || Judge.config.concurrency || DEFAULT_CONCURRENCY).to_i
16
+ return list.map(&work) if workers <= 1 || list.one?
17
+
18
+ fan_out(list, [workers, list.size].min, &work)
19
+ end
20
+
21
+ def on_worker_exit(&hook)
22
+ @worker_exit_hooks << hook unless @worker_exit_hooks.include?(hook)
23
+ hook
24
+ end
25
+
26
+ private
27
+
28
+ def fan_out(list, workers, &work)
29
+ queue = Queue.new
30
+ list.each_with_index { |item, index| queue << [index, item] }
31
+ results = Array.new(list.size)
32
+ errors = []
33
+ lock = Mutex.new
34
+
35
+ threads = Array.new(workers) do
36
+ spawn(queue) do
37
+ while (job = pop(queue))
38
+ index, item = job
39
+ begin
40
+ value = work.call(item)
41
+ lock.synchronize { results[index] = value }
42
+ rescue StandardError => e
43
+ lock.synchronize { errors << e }
44
+ drain(queue)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ threads.each(&:join)
50
+
51
+ raise errors.first unless errors.empty?
52
+
53
+ results
54
+ ensure
55
+ drain(queue) if queue
56
+ end
57
+
58
+ def spawn(queue, &body)
59
+ tags = log_tags
60
+ thread = Thread.new do
61
+ finished = false
62
+ tagged(tags, &body)
63
+ finished = true
64
+ ensure
65
+ drain(queue) unless finished
66
+ @worker_exit_hooks.each(&:call)
67
+ end
68
+ thread.name = THREAD_NAME
69
+ thread.report_on_exception = false
70
+ thread
71
+ end
72
+
73
+ def log_tags
74
+ logger = Judge.config.logger
75
+ formatter = logger.respond_to?(:formatter) && logger.formatter
76
+ formatter.respond_to?(:current_tags) ? formatter.current_tags.dup : []
77
+ end
78
+
79
+ def tagged(tags, &)
80
+ logger = Judge.config.logger
81
+ tags.empty? || !logger.respond_to?(:tagged) ? yield : logger.tagged(*tags, &)
82
+ end
83
+
84
+ def pop(queue)
85
+ queue.pop(true)
86
+ rescue ThreadError
87
+ nil
88
+ end
89
+
90
+ def drain(queue)
91
+ loop { queue.pop(true) }
92
+ rescue ThreadError
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ class Question
5
+ class Choice < Question
6
+ TYPE = "choice"
7
+
8
+ def options
9
+ criteria.keys
10
+ end
11
+
12
+ private
13
+
14
+ def normalize(criteria)
15
+ options = case criteria
16
+ when Hash then entries(criteria)
17
+ when Array then array_options(criteria)
18
+ else
19
+ raise ArgumentError,
20
+ "choice criteria must be an array of options or a hash of option => description"
21
+ end
22
+ raise ArgumentError, "choice needs at least two options" if options.size < 2
23
+
24
+ options
25
+ end
26
+
27
+ def array_options(list)
28
+ names = list.map(&:to_s)
29
+ duplicates = names.select { |name| names.count(name) > 1 }.uniq
30
+ raise ArgumentError, "choice options #{duplicates.inspect} appear twice" if duplicates.any?
31
+
32
+ names.to_h { |name| [name.dup.freeze, name.tr("_", " ").freeze] }.freeze
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ class Question
5
+ class Noul < Question
6
+ TYPE = "noul"
7
+ KEYS = %w[true false].freeze
8
+
9
+ private
10
+
11
+ def normalize(criteria)
12
+ return nil if criteria.nil?
13
+
14
+ unless criteria.is_a?(Hash) && criteria.keys.map(&:to_s).sort == KEYS.sort
15
+ raise ArgumentError, "noul criteria must be a hash with exactly 'true' and 'false' keys"
16
+ end
17
+
18
+ entries(criteria)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ class Question
5
+ class Score < Question
6
+ TYPE = "score"
7
+
8
+ def levels
9
+ criteria
10
+ end
11
+
12
+ def max_level
13
+ criteria.size - 1
14
+ end
15
+
16
+ private
17
+
18
+ def normalize(criteria)
19
+ levels = criteria.is_a?(Range) ? criteria.to_a : criteria
20
+ unless levels.is_a?(Array)
21
+ raise ArgumentError,
22
+ "score criteria must be an array or range of ordered levels"
23
+ end
24
+ raise ArgumentError, "score needs at least two levels" if levels.size < 2
25
+ unless levels.all? { |level| level.is_a?(String) || level.is_a?(Symbol) || level.is_a?(Numeric) }
26
+ raise ArgumentError, "score levels must be strings, symbols or numbers"
27
+ end
28
+
29
+ labels = levels.map { |level| level.to_s.dup.freeze }
30
+ raise ArgumentError, "score levels must be distinct" if labels.uniq.size != labels.size
31
+
32
+ labels.freeze
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Judge
7
+ class Question
8
+ attr_reader :name, :instructions, :criteria, :digest
9
+
10
+ def self.type
11
+ self::TYPE
12
+ end
13
+
14
+ def initialize(instructions, criteria = nil, name: nil)
15
+ raise ArgumentError, "instructions must not be empty" if instructions.to_s.strip.empty?
16
+
17
+ @instructions = instructions.to_s.dup.freeze
18
+ @criteria = normalize(criteria).freeze
19
+ @name = name&.to_sym
20
+ @digest = Digest::SHA256.hexdigest(JSON.generate([type, @instructions, @criteria]))[0, 16].freeze
21
+ freeze
22
+ end
23
+
24
+ def type
25
+ self.class.type
26
+ end
27
+
28
+ def with_name(new_name)
29
+ return self if @name == new_name&.to_sym
30
+
31
+ self.class.new(@instructions, @criteria, name: new_name)
32
+ end
33
+
34
+ def to_payload
35
+ payload = { "type" => type, "instructions" => @instructions }
36
+ payload["criteria"] = @criteria unless @criteria.nil?
37
+ payload
38
+ end
39
+
40
+ def coerce(answer, name: @name)
41
+ Result.new(name: name, question: self, payload: answer)
42
+ end
43
+
44
+ def ==(other)
45
+ other.is_a?(Question) && other.type == type && other.instructions == instructions &&
46
+ other.criteria == criteria && other.name == name
47
+ end
48
+ alias eql? ==
49
+
50
+ def hash
51
+ [self.class, @instructions, @criteria, @name].hash
52
+ end
53
+
54
+ def inspect
55
+ "#<Judge::Question::#{self.class.name.split("::").last} #{@name.inspect} #{@instructions.inspect}>"
56
+ end
57
+
58
+ private
59
+
60
+ def normalize(criteria)
61
+ criteria
62
+ end
63
+
64
+ def entries(hash)
65
+ hash.to_h { |key, value| [key.to_s, entry(value, nested: false)] }.tap do |normalized|
66
+ duplicate_keys!(hash, normalized)
67
+ end.freeze
68
+ end
69
+
70
+ def entry(value, nested: true)
71
+ case value
72
+ when Hash then nested_entries(value)
73
+ when Array then value.map { |item| entry(item) }.freeze
74
+ when Numeric, true, false then nested ? value : value.to_s.freeze
75
+ else value.to_s.dup.freeze
76
+ end
77
+ end
78
+
79
+ def nested_entries(hash)
80
+ normalized = hash.compact.to_h { |key, value| [key.to_s, entry(value)] }
81
+ duplicate_keys!(hash.compact, normalized)
82
+ normalized.freeze
83
+ end
84
+
85
+ def duplicate_keys!(original, normalized)
86
+ return if original.size == normalized.size
87
+
88
+ keys = original.keys.map(&:to_s)
89
+ duplicates = keys.select { |key| keys.count(key) > 1 }.uniq
90
+ raise ArgumentError, "criteria keys #{duplicates.inspect} appear twice once converted to strings"
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ module Attributes
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ define_model_callbacks :judge_refresh, only: :after
10
+ end
11
+
12
+ class_methods do
13
+ def judge_attributes
14
+ @judge_attributes ||=
15
+ if superclass.respond_to?(:judge_attributes)
16
+ superclass.judge_attributes.inherit
17
+ else
18
+ Registry.new
19
+ end
20
+ end
21
+
22
+ def judge_source(source = nil, &block)
23
+ @judge_default_source = block || source
24
+ end
25
+
26
+ def judge_default_source
27
+ return @judge_default_source if @judge_default_source
28
+
29
+ superclass.judge_default_source if superclass.respond_to?(:judge_default_source)
30
+ end
31
+
32
+ def judge_attribute(name, question, source: nil, **options)
33
+ resolved = source || judge_default_source
34
+ unless resolved
35
+ raise ArgumentError,
36
+ "judge_attribute #{name.inspect} needs a source: or a model-level judge_source"
37
+ end
38
+
39
+ definition = Definition.new(name: name, question: question, source: resolved, **options)
40
+ judge_attributes.add(definition)
41
+ define_judge_methods(definition)
42
+ judge_install_callbacks(definition) if respond_to?(:judge_install_callbacks)
43
+ judge_define_scopes(definition) if respond_to?(:judge_define_scopes)
44
+ definition
45
+ end
46
+
47
+ def judge_definition(name)
48
+ judge_attributes.fetch(name)
49
+ end
50
+
51
+ private
52
+
53
+ def define_judge_methods(definition)
54
+ name = definition.name
55
+
56
+ define_method(:"#{name}_judge_meta") { judge_meta(name) }
57
+ define_method(:"#{name}_probability") { judge_meta(name)["probability"] }
58
+ define_method(:"#{name}_confidence") { judge_meta(name)["confidence"] }
59
+ define_method(:"#{name}_computed_at") { judge_computed_at(name) }
60
+ define_method(:"#{name}_stale?") { judge_stale?(name) }
61
+
62
+ return unless definition.type == "noul"
63
+
64
+ define_method(:"#{name}?") do |threshold = 0.5|
65
+ value = read_attribute(name)
66
+ !value.nil? && value >= threshold
67
+ end
68
+ end
69
+ end
70
+
71
+ def judge_meta(name)
72
+ Storage.sidecar(self, self.class.judge_definition(name))
73
+ end
74
+
75
+ def judge_computed_at(name)
76
+ raw = judge_meta(name)["computed_at"]
77
+ raw.is_a?(String) ? Time.parse(raw) : raw
78
+ end
79
+
80
+ def judge_stale?(name = nil)
81
+ Storage.stale_definitions(self, judge_definitions_for(Array(name))).any?
82
+ end
83
+
84
+ def judge_pending
85
+ Storage.stale_definitions(self, judge_definitions_for([])).map(&:name)
86
+ end
87
+
88
+ def judge_refresh(*names, force: false, adapter: nil, &)
89
+ Storage.compute(self, judge_definitions_for(names), force: force, adapter: adapter, &).map(&:name)
90
+ end
91
+
92
+ def judge_refresh!(*names, force: false, adapter: nil, &)
93
+ done = []
94
+ Storage.compute(self, judge_definitions_for(names), force: force, adapter: adapter, done: done, &)
95
+ rescue StandardError
96
+ judge_persist_after_failure(done) if done.any?
97
+ raise
98
+ else
99
+ judge_persist_judgments(done, adapter: adapter) if done.any?
100
+ done.map(&:name)
101
+ end
102
+
103
+ def judge_decide(name, above:, below: nil)
104
+ definition = self.class.judge_definition(name)
105
+ probability = if definition.type == "noul"
106
+ read_attribute(definition.value_column)
107
+ else
108
+ judge_meta(name)["probability"]
109
+ end
110
+ raise Judge::Error, "#{name} has not been computed yet" if probability.nil?
111
+
112
+ Judge::Decision.call(probability, above: above, below: below)
113
+ end
114
+
115
+ private
116
+
117
+ def judge_definitions_for(names)
118
+ names = names.flatten
119
+ return self.class.judge_attributes.to_a if names.empty?
120
+
121
+ names.map { |n| self.class.judge_definition(n) }
122
+ end
123
+
124
+ def judge_persist_judgments(definitions, adapter: nil, inline: true)
125
+ return judge_create_with_judgments(adapter, inline) if new_record?
126
+
127
+ run_callbacks(:judge_refresh) do
128
+ next if update_columns(judge_columns_to_store(definitions))
129
+
130
+ raise ActiveRecord::RecordNotFound.new(
131
+ "#{self.class} #{id.inspect} no longer exists, so its new judgments were not stored",
132
+ self.class.name, self.class.primary_key, id
133
+ )
134
+ end
135
+ end
136
+
137
+ def judge_persist_after_failure(definitions)
138
+ judge_persist_judgments(definitions, inline: false)
139
+ rescue StandardError => e
140
+ Judge::Rails.log_failure(self.class, e)
141
+ end
142
+
143
+ def judge_columns_to_store(definitions)
144
+ columns = definitions.flat_map { |d| [d.value_column, d.sidecar_column] }
145
+ .to_h { |c| [c, read_attribute(c)] }
146
+ now = current_time_from_proper_timezone
147
+ self.class.timestamp_attributes_for_update_in_model.each { |column| columns[column.to_sym] = now }
148
+ columns
149
+ end
150
+
151
+ def judge_create_with_judgments(adapter, inline)
152
+ @judge_refresh_adapter = adapter
153
+ @judge_skip_inline = !inline
154
+ run_callbacks(:judge_refresh) { save! }
155
+ ensure
156
+ @judge_refresh_adapter = nil
157
+ @judge_skip_inline = nil
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Judge
6
+ module Rails
7
+ class Definition
8
+ CALLBACK_MODES = %i[async inline disabled].freeze
9
+ ERROR_MODES = %i[pass fail raise].freeze
10
+
11
+ attr_reader :name, :question, :source, :model, :callbacks, :if_condition, :on_error
12
+
13
+ def initialize(name:, question:, source:, model: nil, sync: false, callbacks: nil,
14
+ if_condition: nil, on_error: :pass)
15
+ @name = name.to_sym
16
+ @question = question.with_name(@name)
17
+ @source = validate_source!(source)
18
+ @model = model
19
+ @callbacks = normalize_callbacks(callbacks, sync)
20
+ @if_condition = validate_condition!(if_condition)
21
+ @on_error = validate!(on_error.to_sym, ERROR_MODES, "on_error")
22
+ validate_error_mode!
23
+ freeze
24
+ end
25
+
26
+ def value_column
27
+ @name
28
+ end
29
+
30
+ def sidecar_column
31
+ :"#{@name}_judge"
32
+ end
33
+
34
+ def type
35
+ @question.type
36
+ end
37
+
38
+ def digest
39
+ model = effective_model
40
+ return @question.digest if model == Judge::Configuration::DEFAULT_MODEL
41
+
42
+ Digest::SHA256.hexdigest("#{@question.digest}:#{model}")[0, 16]
43
+ end
44
+
45
+ def effective_model
46
+ @model || Judge.config.model
47
+ end
48
+
49
+ def sync?
50
+ @callbacks == :inline
51
+ end
52
+
53
+ def enqueue?
54
+ @callbacks == :async
55
+ end
56
+
57
+ def state_for(record)
58
+ text = case @source
59
+ when Symbol, String then record.public_send(@source)
60
+ else evaluate(@source, record)
61
+ end
62
+ Array(text).reject { |part| part.to_s.strip.empty? }.join("\n\n")
63
+ end
64
+
65
+ def state_digest(state)
66
+ Digest::SHA256.hexdigest(state.to_s)[0, 16]
67
+ end
68
+
69
+ def applies_to?(record)
70
+ return true if @if_condition.nil?
71
+
72
+ @if_condition.is_a?(Proc) ? evaluate(@if_condition, record) : record.public_send(@if_condition)
73
+ end
74
+
75
+ def cast(result)
76
+ case type
77
+ when "noul", "score" then result.value
78
+ when "choice" then result.value.to_s
79
+ end
80
+ end
81
+
82
+ def sidecar(result, state_digest:, model: nil, latency: nil)
83
+ {
84
+ "digest" => digest,
85
+ "state_digest" => state_digest,
86
+ "computed_at" => Time.now.utc.iso8601,
87
+ "probability" => result.probability,
88
+ "confidence" => result.confidence,
89
+ "probabilities" => result.probabilities,
90
+ "legend" => result.legend.empty? ? nil : result.legend,
91
+ "model" => model,
92
+ "latency" => latency
93
+ }.compact
94
+ end
95
+
96
+ def stale?(value:, sidecar:, state:)
97
+ return true if value.nil?
98
+
99
+ meta = sidecar || {}
100
+ return true if meta["digest"] != digest
101
+
102
+ meta["state_digest"] != state_digest(state)
103
+ end
104
+
105
+ private
106
+
107
+ def evaluate(callable, record)
108
+ callable.arity.zero? ? record.instance_exec(&callable) : callable.call(record)
109
+ end
110
+
111
+ def validate_source!(source)
112
+ return source if source.is_a?(Symbol) || source.is_a?(String) || source.is_a?(Proc)
113
+
114
+ raise ArgumentError, "judge_attribute #{@name.inspect} source must be a Symbol, String or Proc, " \
115
+ "got #{source.class}"
116
+ end
117
+
118
+ def validate_condition!(condition)
119
+ return condition if condition.nil? || condition.is_a?(Symbol) || condition.is_a?(Proc)
120
+
121
+ raise ArgumentError, "if_condition must be a Symbol or a Proc, got #{condition.class}"
122
+ end
123
+
124
+ def normalize_callbacks(callbacks, sync)
125
+ return :inline if sync && callbacks.nil?
126
+ return :async if callbacks.nil?
127
+ if sync
128
+ raise ArgumentError,
129
+ "sync: true already means inline callbacks; drop callbacks: #{callbacks.inspect}"
130
+ end
131
+
132
+ mode = case callbacks
133
+ when false then :disabled
134
+ when true then :async
135
+ else callbacks.to_s.to_sym
136
+ end
137
+ validate!(mode, CALLBACK_MODES, "callbacks")
138
+ end
139
+
140
+ def validate_error_mode!
141
+ return if @on_error == :pass || @callbacks == :inline
142
+
143
+ raise ArgumentError,
144
+ "on_error: #{@on_error.inspect} only applies to a synchronous attribute. " \
145
+ "#{@name.inspect} is #{@callbacks.inspect}, so the record is already committed by the time " \
146
+ "the call runs and nothing can be blocked. Pass sync: true, or leave on_error as :pass."
147
+ end
148
+
149
+ def validate!(value, allowed, label)
150
+ return value if allowed.include?(value)
151
+
152
+ raise ArgumentError, "#{label} must be one of #{allowed.inspect}, got #{value.inspect}"
153
+ end
154
+ end
155
+ end
156
+ end