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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ module Jobs
6
+ extend ActiveSupport::Concern
7
+
8
+ RETRYABLE_ERRORS = [Judge::RateLimitError, Judge::ServerError, Judge::TransportError,
9
+ ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout].freeze
10
+
11
+ Payload = Struct.new(:model, :ids, :names, keyword_init: true) do
12
+ def model_name
13
+ model.name
14
+ end
15
+ end
16
+
17
+ class << self
18
+ def enqueuer
19
+ @enqueuer || method(:default_enqueue)
20
+ end
21
+
22
+ def enqueuer=(callable)
23
+ unless callable.respond_to?(:call)
24
+ raise ArgumentError, "enqueuer must respond to call. Use reset_enqueuer! to restore ActiveJob"
25
+ end
26
+
27
+ @enqueuer = callable
28
+ end
29
+
30
+ def reset_enqueuer!
31
+ @enqueuer = nil
32
+ end
33
+
34
+ def enqueue_record(record, names)
35
+ enqueuer.call(Payload.new(model: record.class, ids: [record.id], names: Array(names)))
36
+ end
37
+
38
+ def perform(payload, batch_size: 100, adapter: nil, raise_retryable: false)
39
+ model = payload.model.is_a?(String) ? payload.model.constantize : payload.model
40
+ names = payload.names
41
+ model.where(id: payload.ids).find_each(batch_size: batch_size) do |record|
42
+ refresh_safely(record, names, adapter: adapter, raise_retryable: raise_retryable)
43
+ end
44
+ end
45
+
46
+ def refresh_safely(record, names = [], adapter: nil, raise_retryable: false)
47
+ record.judge_refresh!(*names, adapter: adapter)
48
+ true
49
+ rescue *RETRYABLE_ERRORS => e
50
+ raise if raise_retryable
51
+
52
+ Judge::Rails.log_failure(record.class, e)
53
+ false
54
+ rescue StandardError => e
55
+ Judge::Rails.log_failure(record.class, e)
56
+ false
57
+ end
58
+
59
+ private
60
+
61
+ def default_enqueue(payload)
62
+ refresh_job.perform_later(payload.model_name, payload.ids.first, payload.names.map(&:to_s))
63
+ end
64
+
65
+ def refresh_job
66
+ require "judge/rails/refresh_job" if defined?(::ActiveJob)
67
+ return Judge::Rails::RefreshJob if defined?(Judge::Rails::RefreshJob)
68
+
69
+ raise Judge::ConfigurationError,
70
+ "async judge attributes need ActiveJob or a custom Judge::Rails::Jobs.enqueuer. " \
71
+ "Otherwise declare them with sync: true or callbacks: false"
72
+ end
73
+ end
74
+
75
+ class_methods do
76
+ def judge_install_callbacks(definition)
77
+ judge_install_save_callback if definition.sync? || definition.enqueue?
78
+ judge_install_commit_callback if definition.enqueue?
79
+ end
80
+
81
+ private
82
+
83
+ def judge_install_save_callback
84
+ return if defined?(@judge_save_callback) && @judge_save_callback
85
+
86
+ @judge_save_callback = true
87
+ before_save :judge_before_save
88
+ end
89
+
90
+ def judge_install_commit_callback
91
+ return if defined?(@judge_commit_callback) && @judge_commit_callback
92
+
93
+ @judge_commit_callback = true
94
+ after_commit :judge_enqueue_refresh, on: %i[create update]
95
+ after_rollback :judge_forget_flagged
96
+ end
97
+ end
98
+
99
+ def judge_refresh_later(*names)
100
+ names = names.flatten
101
+ names = self.class.judge_attributes.names if names.empty?
102
+ Jobs.enqueue_record(self, names)
103
+ end
104
+
105
+ private
106
+
107
+ def judge_before_save
108
+ automatic = self.class.judge_attributes.select { |d| d.sync? || d.enqueue? }
109
+ pending, blank = Storage.plan(self, automatic)
110
+ blank.each { |definition| Storage.clear(self, definition) }
111
+ judge_flag_async(pending.keys.select(&:enqueue?))
112
+ return if @judge_skip_inline
113
+
114
+ Storage.groups(pending.select { |definition, _| definition.sync? }).each do |(state, model), group|
115
+ compute_group_inline(state, model, group)
116
+ end
117
+ end
118
+
119
+ def judge_flag_async(definitions)
120
+ (@judge_flagged_async ||= Set.new).merge(definitions.map(&:name))
121
+ end
122
+
123
+ def judge_forget_flagged
124
+ @judge_flagged_async = nil
125
+ end
126
+
127
+ def compute_group_inline(state, model, group)
128
+ Storage.ask(self, state, model, group, adapter: @judge_refresh_adapter)
129
+ rescue StandardError => e
130
+ handle_inline_error(e, group)
131
+ end
132
+
133
+ def handle_inline_error(error, group)
134
+ modes = group.map(&:on_error)
135
+ raise error if modes.include?(:raise)
136
+
137
+ Judge::Rails.log_failure(self.class, error)
138
+ return unless modes.include?(:fail)
139
+
140
+ names = group.select { |d| d.on_error == :fail }.map(&:name)
141
+ errors.add(:base, "could not judge #{names.join(", ")}: #{error.class}")
142
+ throw :abort
143
+ end
144
+
145
+ def judge_enqueue_refresh
146
+ flagged = @judge_flagged_async
147
+ judge_forget_flagged
148
+ return if flagged.nil? || flagged.empty?
149
+
150
+ definitions = flagged.map { |name| self.class.judge_definition(name) }
151
+ pending = Storage.stale_definitions(self, definitions)
152
+ Jobs.enqueue_record(self, pending.map(&:name)) if pending.any?
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,6 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ judge_refuted: 'matched "%{instruction}"'
5
+ judge_unmatched: 'did not match "%{instruction}"'
6
+ judge_unavailable: 'could not be checked against "%{instruction}"'
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Judge
6
+ module Rails
7
+ module Migration
8
+ VALUE_TYPES = { noul: :float, score: :float, choice: :string }.freeze
9
+
10
+ class << self
11
+ def value_type(type)
12
+ VALUE_TYPES.fetch(type.to_s.to_sym) do
13
+ raise ArgumentError,
14
+ "unknown judge attribute type #{type.inspect}, expected one of #{VALUE_TYPES.keys.inspect}"
15
+ end
16
+ end
17
+
18
+ def value_options(type, connection = nil)
19
+ value_type(type) == :float && mysql?(connection) ? { limit: 53 } : {}
20
+ end
21
+
22
+ def nullable!(null)
23
+ return if null
24
+
25
+ raise ArgumentError, "a judge value column must allow NULL: it is empty until judged and cleared " \
26
+ "when its text goes blank"
27
+ end
28
+
29
+ def sidecar_column(name)
30
+ :"#{name}_judge"
31
+ end
32
+
33
+ def sidecar_type(connection = nil)
34
+ postgresql?(connection) ? :jsonb : :json
35
+ end
36
+
37
+ def sidecar_options(connection = nil)
38
+ mysql?(connection) ? {} : { null: false, default: {} }
39
+ end
40
+
41
+ def postgresql?(connection = nil)
42
+ adapter_name(connection).start_with?("postg")
43
+ end
44
+
45
+ def mysql?(connection = nil)
46
+ adapter_name(connection).match?(/mysql|trilogy/)
47
+ end
48
+
49
+ def adapter_name(connection = nil)
50
+ target = connection.respond_to?(:delegate) ? connection.delegate : connection
51
+ target = ActiveRecord::Base.lease_connection unless target.respond_to?(:adapter_name)
52
+ target.adapter_name.to_s.downcase
53
+ end
54
+ end
55
+
56
+ def judge_attribute(table, name, type, index: true, null: true)
57
+ Migration.nullable!(null)
58
+ add_column table, name, Migration.value_type(type), null: null,
59
+ **Migration.value_options(type, connection)
60
+ add_column table, Migration.sidecar_column(name), Migration.sidecar_type(connection),
61
+ **Migration.sidecar_options(connection)
62
+ add_index table, name if index
63
+ end
64
+
65
+ def remove_judge_attribute(table, name, type = nil, index: true, null: true)
66
+ if type.nil? && connection.is_a?(ActiveRecord::Migration::CommandRecorder)
67
+ raise ActiveRecord::IrreversibleMigration,
68
+ "remove_judge_attribute #{table.inspect}, #{name.inspect} needs its type " \
69
+ "(:noul, :choice or :score) to be reversible"
70
+ end
71
+
72
+ sidecar = Migration.sidecar_column(name)
73
+ remove_index table, name, if_exists: true if index
74
+ remove_column table, sidecar, Migration.sidecar_type(connection),
75
+ **Migration.sidecar_options(connection)
76
+ value_options = type ? Migration.value_options(type, connection) : {}
77
+ remove_column table, name, (type && Migration.value_type(type)), null: null, **value_options
78
+ end
79
+
80
+ module TableDefinition
81
+ def judge_attribute(name, type, index: true, null: true)
82
+ Migration.nullable!(null)
83
+ conn = judge_connection
84
+ column name, Migration.value_type(type), null: null, index: index,
85
+ **Migration.value_options(type, conn)
86
+ column Migration.sidecar_column(name), Migration.sidecar_type(conn),
87
+ **Migration.sidecar_options(conn)
88
+ end
89
+
90
+ private
91
+
92
+ def judge_connection
93
+ conn = instance_variable_get(:@conn)
94
+ conn if conn.respond_to?(:adapter_name)
95
+ end
96
+ end
97
+
98
+ module Table
99
+ def judge_attribute(name, type, index: true, null: true)
100
+ Migration.nullable!(null)
101
+ column name, Migration.value_type(type), null: null, **Migration.value_options(type, @base)
102
+ column Migration.sidecar_column(name), Migration.sidecar_type(@base),
103
+ **Migration.sidecar_options(@base)
104
+ index name if index
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ ActiveSupport.on_load(:active_record) do
112
+ ActiveRecord::Migration.include(Judge::Rails::Migration)
113
+ ActiveRecord::ConnectionAdapters::TableDefinition.include(Judge::Rails::Migration::TableDefinition)
114
+ ActiveRecord::ConnectionAdapters::Table.include(Judge::Rails::Migration::Table)
115
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ module Refresh
6
+ extend ActiveSupport::Concern
7
+
8
+ Summary = Struct.new(:records, :computed, :skipped, :failed, :calls, keyword_init: true) do
9
+ def to_h
10
+ { records: records, computed: computed, skipped: skipped, failed: failed, calls: calls }
11
+ end
12
+ end
13
+
14
+ class_methods do
15
+ def judge_refresh_all(scope = all, batch_size: 100, force: false, resume: false, adapter: nil)
16
+ summary = Summary.new(records: 0, computed: 0, skipped: 0, failed: 0, calls: 0)
17
+ effective_force = force && !resume
18
+ scope.find_each(batch_size: batch_size) do |record|
19
+ summary.records += 1
20
+ judge_refresh_record(record, effective_force, adapter, summary)
21
+ end
22
+ summary
23
+ end
24
+
25
+ private
26
+
27
+ def judge_refresh_record(record, force, adapter, summary)
28
+ changed = record.judge_refresh!(force: force, adapter: adapter) { summary.calls += 1 }
29
+ changed.empty? ? summary.skipped += 1 : summary.computed += 1
30
+ rescue StandardError => e
31
+ summary.failed += 1
32
+ Judge::Rails.log_failure(record.class, e)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ class RefreshJob < ActiveJob::Base
6
+ queue_as { "default" }
7
+ retry_on(*Jobs::RETRYABLE_ERRORS, wait: :polynomially_longer, attempts: 5)
8
+
9
+ def perform(model_name, id, names = [])
10
+ payload = Jobs::Payload.new(model: model_name.constantize, ids: [id], names: names)
11
+ Jobs.perform(payload, raise_retryable: true)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ class Registry
6
+ include Enumerable
7
+
8
+ def initialize(parent = nil)
9
+ @parent = parent
10
+ @own = {}
11
+ @version = 0
12
+ end
13
+
14
+ def add(definition)
15
+ @own[definition.name] = definition
16
+ @version += 1
17
+ definition
18
+ end
19
+
20
+ def version
21
+ @version + (@parent ? @parent.version : 0)
22
+ end
23
+
24
+ def [](name)
25
+ @own[name.to_sym] || @parent&.[](name)
26
+ end
27
+
28
+ def fetch(name)
29
+ self[name] || raise(ArgumentError, "no judge attribute named #{name.inspect}")
30
+ end
31
+
32
+ def to_h
33
+ current = version
34
+ return @flattened if @flattened_version == current
35
+
36
+ @flattened_version = current
37
+ @flattened = (@parent ? @parent.to_h : {}).merge(@own).freeze
38
+ end
39
+
40
+ def each(&)
41
+ to_h.each_value(&)
42
+ end
43
+
44
+ def names
45
+ to_h.keys
46
+ end
47
+
48
+ def empty?
49
+ to_h.empty?
50
+ end
51
+
52
+ def inherit
53
+ self.class.new(self)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ module Relation
6
+ ADHOC_NAME = :judge_adhoc
7
+
8
+ Target = Struct.new(:question, :option, :level) do
9
+ def measure(result)
10
+ case question.type
11
+ when "choice" then result.probability_of(option) || 0.0
12
+ else result.value
13
+ end
14
+ end
15
+
16
+ def passes?(result, threshold)
17
+ return result.value >= level - 0.5 if question.type == "score"
18
+
19
+ measure(result) >= threshold
20
+ end
21
+ end
22
+
23
+ module ClassMethods
24
+ def judge_filter(question, limit: nil, source: nil, threshold: 0.5, option: nil, at_least: nil,
25
+ model: nil, adapter: nil, concurrency: nil)
26
+ question = judge_adhoc_question(question)
27
+ target = judge_target(question, :judge_filter, option: option, at_least: at_least)
28
+ judge_check_threshold!(threshold)
29
+ judged = judge_judge(question, :judge_filter, limit: limit, source: source, model: model,
30
+ adapter: adapter, concurrency: concurrency)
31
+ judged.select { |_record, result| target.passes?(result, threshold) }.keys
32
+ end
33
+
34
+ def judge_map(question, limit: nil, source: nil, model: nil, adapter: nil, concurrency: nil)
35
+ judge_judge(judge_adhoc_question(question), :judge_map, limit: limit, source: source, model: model,
36
+ adapter: adapter, concurrency: concurrency)
37
+ end
38
+
39
+ def judge_sort(question, limit: nil, source: nil, dir: :desc, option: nil, model: nil, adapter: nil,
40
+ concurrency: nil)
41
+ direction = dir.to_s.downcase.to_sym
42
+ unless %i[asc desc].include?(direction)
43
+ raise ArgumentError, "dir must be :asc or :desc, got #{dir.inspect}"
44
+ end
45
+
46
+ question = judge_adhoc_question(question)
47
+ target = judge_target(question, :judge_sort, option: option, at_least: :any)
48
+ judged = judge_judge(question, :judge_sort, limit: limit, source: source, model: model,
49
+ adapter: adapter, concurrency: concurrency)
50
+ sign = direction == :asc ? 1 : -1
51
+ judged.sort_by { |_record, result| sign * target.measure(result) }.map(&:first)
52
+ end
53
+
54
+ private
55
+
56
+ def judge_adhoc_question(question)
57
+ question.is_a?(Judge::Question) ? question : Judge.noul(question.to_s)
58
+ end
59
+
60
+ def judge_target(question, caller_name, option:, at_least:)
61
+ case question.type
62
+ when "choice" then Target.new(question, judge_target_option(question, option, caller_name), nil)
63
+ when "score" then Target.new(question, nil, judge_target_level(question, at_least, caller_name))
64
+ else Target.new(question, nil, nil)
65
+ end
66
+ end
67
+
68
+ def judge_target_option(question, option, caller_name)
69
+ return option.to_s if question.options.include?(option.to_s)
70
+
71
+ raise ArgumentError, "#{caller_name} with a choice question needs option: one of " \
72
+ "#{question.options.inspect}, got #{option.inspect}"
73
+ end
74
+
75
+ def judge_target_level(question, at_least, caller_name)
76
+ return nil if at_least == :any
77
+
78
+ if at_least.nil?
79
+ raise ArgumentError, "#{caller_name} with a score question needs at_least: one of " \
80
+ "#{question.levels.inspect}"
81
+ end
82
+ judge_level(at_least, question.levels, caller_name)
83
+ end
84
+
85
+ def judge_check_threshold!(threshold)
86
+ return if threshold.is_a?(Numeric) && threshold.between?(0, 1)
87
+
88
+ raise ArgumentError, "threshold must be a number between 0 and 1, got #{threshold.inspect}"
89
+ end
90
+
91
+ def judge_judge(question, caller_name, limit:, source:, model:, adapter:, concurrency:)
92
+ judge_check_limit!(limit, caller_name)
93
+ definition = Definition.new(name: ADHOC_NAME, question: question,
94
+ source: judge_adhoc_source(source, caller_name))
95
+
96
+ records = all.limit([all.limit_value&.then { |value| Integer(value) }, limit].compact.min).to_a
97
+ # Built here so the workers below never check out a database connection.
98
+ judgeable = records.map { |record| [record, definition.state_for(record)] }
99
+ .reject { |_record, state| state.empty? }
100
+
101
+ texts = judgeable.map(&:last).uniq
102
+ answers = Judge::Pool.map(texts, concurrency: concurrency) do |text|
103
+ Judge.ask(definition.question, text: text, model: model, adapter: adapter)
104
+ end
105
+ by_text = texts.zip(answers).to_h
106
+
107
+ judgeable.to_h { |record, state| [record, by_text.fetch(state)] }
108
+ end
109
+
110
+ def judge_check_limit!(limit, caller_name)
111
+ if limit.nil?
112
+ raise ArgumentError,
113
+ "#{caller_name} requires limit:. It makes one API call per row, so an unbounded " \
114
+ "scan would judge the whole table. Pass an explicit limit: you are willing to pay for."
115
+ end
116
+ return if limit.is_a?(Integer) && limit.positive?
117
+
118
+ raise ArgumentError, "limit: must be a positive Integer, got #{limit.inspect}"
119
+ end
120
+
121
+ def judge_adhoc_source(source, caller_name)
122
+ resolved = source || (respond_to?(:judge_default_source) && judge_default_source)
123
+ return resolved if resolved
124
+
125
+ raise ArgumentError,
126
+ "#{caller_name} needs source: (a column symbol or a callable returning the text to judge), " \
127
+ "or a model-level judge_source"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judge
4
+ module Rails
5
+ module Scopes
6
+ extend ActiveSupport::Concern
7
+
8
+ DIRECTIONS = %i[asc desc].freeze
9
+ NUMERIC_LEVEL = /\A-?\d+\z/
10
+
11
+ class_methods do
12
+ def judge_define_scopes(definition)
13
+ column = definition.value_column
14
+ name = definition.name
15
+
16
+ judge_scope(:"#{name}_unknown") { all.where(column => nil) }
17
+ judge_scope(:"order_by_#{name}") { |dir = :desc| all.order(column => judge_order_direction(dir)) }
18
+
19
+ case definition.type
20
+ when "noul" then judge_define_noul_scopes(name, column)
21
+ when "choice" then judge_define_choice_scopes(name, column, definition.question.options)
22
+ when "score" then judge_define_score_scopes(name, column, definition.question.levels)
23
+ end
24
+ judge_define_model_scopes
25
+ end
26
+
27
+ private
28
+
29
+ def judge_define_noul_scopes(name, column)
30
+ judge_scope(:"#{name}_above") do |probability|
31
+ all.where(arel_table[column].gteq(judge_probability(probability, name)))
32
+ end
33
+ judge_scope(:"#{name}_below") do |probability|
34
+ all.where(arel_table[column].lteq(judge_probability(probability, name)))
35
+ end
36
+ judge_scope(:"#{name}_between") do |low, high|
37
+ all.where(arel_table[column].gt(judge_probability(low, name)))
38
+ .where(arel_table[column].lt(judge_probability(high, name)))
39
+ end
40
+ end
41
+
42
+ def judge_define_choice_scopes(name, column, options)
43
+ judge_scope(:"#{name}_is") { |*values| all.where(column => judge_options(values, options, name)) }
44
+ judge_scope(:"#{name}_not") do |*values|
45
+ all.where.not(column => judge_options(values, options, name))
46
+ end
47
+ end
48
+
49
+ def judge_define_score_scopes(name, column, levels)
50
+ judge_scope(:"#{name}_at_least") do |level|
51
+ all.where(arel_table[column].gteq(judge_level(level, levels, name) - 0.5))
52
+ end
53
+ judge_scope(:"#{name}_at_most") do |level|
54
+ all.where(arel_table[column].lt(judge_level(level, levels, name) + 0.5))
55
+ end
56
+ judge_scope(:"#{name}_level") do |level|
57
+ index = judge_level(level, levels, name)
58
+ all.where(column => (index - 0.5)...(index + 0.5))
59
+ end
60
+ end
61
+
62
+ def judge_define_model_scopes
63
+ judge_scope(:judge_computed) do
64
+ judge_value_columns.reduce(all) { |relation, column| relation.where.not(column => nil) }
65
+ end
66
+ judge_scope(:judge_uncomputed) do
67
+ columns = judge_value_columns
68
+ columns.empty? ? all : all.where(columns.map { |c| arel_table[c].eq(nil) }.reduce(:or))
69
+ end
70
+ end
71
+
72
+ def judge_scope(name, &body)
73
+ return if singleton_class.method_defined?(name) && !judge_generated_scope?(name)
74
+
75
+ (@judge_generated_scopes ||= Set.new) << name
76
+ singleton_class.send(:define_method, name, &body)
77
+ end
78
+
79
+ def judge_generated_scope?(name)
80
+ ancestors.grep(Class).any? do |klass|
81
+ klass.instance_variable_get(:@judge_generated_scopes)&.include?(name)
82
+ end
83
+ end
84
+
85
+ def judge_value_columns
86
+ judge_attributes.map(&:value_column)
87
+ end
88
+
89
+ def judge_order_direction(dir)
90
+ direction = dir.to_s.downcase.to_sym
91
+ return direction if DIRECTIONS.include?(direction)
92
+
93
+ raise ArgumentError, "order direction must be one of #{DIRECTIONS.inspect}, got #{dir.inspect}"
94
+ end
95
+
96
+ def judge_probability(value, name)
97
+ return value if value.is_a?(Numeric) && value.between?(0, 1)
98
+
99
+ raise ArgumentError, "#{name} scopes take a probability between 0 and 1, got #{value.inspect}"
100
+ end
101
+
102
+ def judge_options(values, options, name)
103
+ picked = values.flatten.map(&:to_s)
104
+ raise ArgumentError, "#{name} needs at least one option" if picked.empty?
105
+
106
+ unknown = picked - options
107
+ return picked if unknown.empty?
108
+
109
+ raise ArgumentError,
110
+ "unknown #{name} option(s) #{unknown.inspect}, expected one of #{options.inspect}"
111
+ end
112
+
113
+ def judge_level(level, levels, name)
114
+ by_index = level.is_a?(Integer) && !levels.all? { |label| label.match?(NUMERIC_LEVEL) }
115
+ index = by_index ? level : levels.index(level.to_s)
116
+ return index if index&.between?(0, levels.size - 1)
117
+
118
+ raise ArgumentError, "unknown #{name} level #{level.inspect}, expected one of #{levels.inspect} " \
119
+ "or 0..#{levels.size - 1}"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end