abmeter 0.0.2 → 0.2.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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class FeatureFlag
7
+ include Exposable
8
+
9
+ attr_reader :id, :variant, :audience
10
+
11
+ def initialize(id:, variant:, audience:)
12
+ @id = id
13
+ @variant = variant
14
+ @audience = audience
15
+ end
16
+
17
+ def self.from_json(json)
18
+ json.map do |flag|
19
+ new(
20
+ id: flag[:id],
21
+ audience: Audience.from_json(flag[:audience]),
22
+ variant: Variant.from_json(flag[:variant])
23
+ )
24
+ end
25
+ end
26
+
27
+ def serialize(*_)
28
+ {
29
+ id: id,
30
+ audience: audience.serialize,
31
+ variant: variant.serialize
32
+ }
33
+ end
34
+
35
+ # Expose parameter for feature flags
36
+ def expose_parameter(user, parameter)
37
+ validate_expose_parameter_args!(user.user_id, parameter, audience)
38
+ raise ArgumentError, 'Variant must be provided for feature flags' unless variant
39
+
40
+ make_exposure(user, parameter, 'FeatureFlag', id, audience, variant)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class Parameter
7
+ attr_reader :id, :slug, :parameter_type, :default_value, :space_id
8
+
9
+ def initialize(id:, slug:, parameter_type:, default_value:, space_id:)
10
+ @id = id
11
+ @slug = slug
12
+ @parameter_type = parameter_type
13
+ @default_value = default_value
14
+ @space_id = space_id
15
+ end
16
+
17
+ def self.from_json(json)
18
+ json.map do |param|
19
+ new(
20
+ id: param[:id],
21
+ slug: param[:slug],
22
+ default_value: ABMeter::Core::Protocol.cast!(param[:default_value], param[:parameter_type]),
23
+ parameter_type: param[:parameter_type],
24
+ space_id: param[:space_id]
25
+ )
26
+ end
27
+ end
28
+
29
+ def serialize(*_)
30
+ {
31
+ id: id,
32
+ slug: slug,
33
+ parameter_type: parameter_type,
34
+ default_value: default_value.to_s,
35
+ space_id: space_id
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class Space
7
+ attr_reader :id, :salt
8
+
9
+ def initialize(id:, salt:)
10
+ @id = id
11
+ @salt = salt
12
+ end
13
+
14
+ def self.from_json(json)
15
+ json.map do |space|
16
+ new(
17
+ id: space[:id],
18
+ salt: space[:salt]
19
+ )
20
+ end
21
+ end
22
+
23
+ def serialize
24
+ {
25
+ id: id,
26
+ salt: salt
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class Variant
7
+ attr_reader :id, :parameter_values
8
+
9
+ def initialize(id:, parameter_values:)
10
+ @id = id
11
+ @parameter_values = parameter_values
12
+ end
13
+
14
+ def parameter_value(parameter_slug)
15
+ @parameter_values[parameter_slug]
16
+ end
17
+
18
+ def self.from_json(variant)
19
+ parameter_values = variant[:parameter_values].map { |pv| [pv[:slug], pv[:value]] }.to_h
20
+ Variant.new(id: variant[:id], parameter_values: parameter_values)
21
+ end
22
+
23
+ def serialize
24
+ {
25
+ id: id,
26
+ parameter_values: parameter_values.map do |parameter_slug, value|
27
+ {
28
+ slug: parameter_slug,
29
+ value: value
30
+ }
31
+ end
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ABMeter
6
+ module Core
7
+ module AssignmentConfig
8
+ class Config
9
+ attr_reader :feature_flags, :experiments, :parameters, :spaces
10
+
11
+ def initialize(feature_flags:, experiments:, parameters:, spaces:)
12
+ @feature_flags = feature_flags
13
+ @experiments = experiments
14
+ @parameters = parameters
15
+ @spaces = spaces
16
+ end
17
+
18
+ def to_json(*_)
19
+ serialize.to_json
20
+ end
21
+
22
+ def serialize
23
+ {
24
+ spaces: spaces.sort_by(&:id).map(&:serialize),
25
+ parameters: parameters.sort_by(&:id).map(&:serialize),
26
+ feature_flags: feature_flags.sort_by(&:id).map(&:serialize),
27
+ experiments: experiments.sort_by(&:id).map(&:serialize)
28
+ }
29
+ end
30
+ end
31
+
32
+ def self.from_json(json)
33
+ parsed_json = JSON.parse(json, symbolize_names: true)
34
+
35
+ spaces = Space.from_json(parsed_json[:spaces])
36
+ space_salts = spaces.to_h { |space| [space.id, space.salt] }
37
+ parameters = Parameter.from_json(parsed_json[:parameters])
38
+ feature_flags = FeatureFlag.from_json(parsed_json[:feature_flags])
39
+ experiments = parsed_json[:experiments] ? Experiment.from_json(parsed_json[:experiments], space_salts) : []
40
+
41
+ Config.new(
42
+ spaces: spaces,
43
+ feature_flags: feature_flags,
44
+ experiments: experiments,
45
+ parameters: parameters
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module Protocol
6
+ # Type name constants
7
+ STRING = 'String'
8
+ INTEGER = 'Integer'
9
+ FLOAT = 'Float'
10
+ BOOLEAN = 'Boolean'
11
+
12
+ class Type
13
+ attr_reader :name
14
+
15
+ def initialize(name)
16
+ @name = name
17
+ end
18
+
19
+ def numerical?
20
+ false
21
+ end
22
+
23
+ def cast!(value)
24
+ value
25
+ end
26
+
27
+ def to_s
28
+ name
29
+ end
30
+ end
31
+
32
+ class StringType < Type
33
+ def initialize
34
+ super(STRING)
35
+ end
36
+
37
+ def cast!(value)
38
+ value.to_s
39
+ end
40
+ end
41
+
42
+ class IntegerType < Type
43
+ def initialize
44
+ super(INTEGER)
45
+ end
46
+
47
+ def numerical?
48
+ true
49
+ end
50
+
51
+ def cast!(value)
52
+ Integer(value)
53
+ end
54
+ end
55
+
56
+ class FloatType < Type
57
+ def initialize
58
+ super(FLOAT)
59
+ end
60
+
61
+ def numerical?
62
+ true
63
+ end
64
+
65
+ def cast!(value)
66
+ Float(value)
67
+ end
68
+ end
69
+
70
+ class BooleanType < Type
71
+ def initialize
72
+ super(BOOLEAN)
73
+ end
74
+
75
+ def cast!(value)
76
+ case value
77
+ when true, 'true', 'TRUE', 't', 'T', '1', 1
78
+ true
79
+ when false, 'false', 'FALSE', 'f', 'F', '0', 0, nil, ''
80
+ false
81
+ else
82
+ raise ArgumentError, "Cannot cast #{value.inspect} to Boolean"
83
+ end
84
+ end
85
+ end
86
+
87
+ class TypeRegistry
88
+ def initialize
89
+ @types = {}
90
+ register_default_types
91
+ end
92
+
93
+ def register(type)
94
+ @types[type.name] = type
95
+ end
96
+
97
+ def get(type_name)
98
+ @types[type_name] || raise("Unknown type: #{type_name}")
99
+ end
100
+
101
+ def all
102
+ @types.values
103
+ end
104
+
105
+ def numerical_types
106
+ @_numerical_types ||= all.select(&:numerical?)
107
+ end
108
+
109
+ private
110
+
111
+ def register_default_types
112
+ register(StringType.new)
113
+ register(IntegerType.new)
114
+ register(FloatType.new)
115
+ register(BooleanType.new)
116
+ end
117
+ end
118
+
119
+ # Protocol class methods
120
+ class << self
121
+ def string
122
+ type(STRING)
123
+ end
124
+
125
+ def integer
126
+ type(INTEGER)
127
+ end
128
+
129
+ def float
130
+ type(FLOAT)
131
+ end
132
+
133
+ def boolean
134
+ type(BOOLEAN)
135
+ end
136
+
137
+ def type(name)
138
+ registry.get(name)
139
+ end
140
+
141
+ def all_types
142
+ registry.all.map(&:name)
143
+ end
144
+
145
+ def numerical?(type_name)
146
+ type(type_name).numerical?
147
+ rescue StandardError
148
+ false
149
+ end
150
+
151
+ def cast!(value, type_name)
152
+ type(type_name).cast!(value)
153
+ end
154
+
155
+ def valid_for_type?(value, type_name)
156
+ cast!(value, type_name)
157
+ true
158
+ rescue ArgumentError
159
+ false
160
+ end
161
+
162
+ private
163
+
164
+ def registry
165
+ @registry ||= TypeRegistry.new
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ class User
6
+ attr_reader :user_id, :email
7
+
8
+ def initialize(user_id:, email:)
9
+ @user_id = user_id
10
+ @email = email
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ class UserParameterResolver
6
+ attr_reader :config
7
+
8
+ def initialize(config:)
9
+ @config = config
10
+ end
11
+
12
+ def exposure_for(user:, parameter_slug:)
13
+ validate_user!(user)
14
+
15
+ # Find parameter once, upfront
16
+ parameter = @config.parameters.find { |p| p.slug == parameter_slug }
17
+ raise "Parameter '#{parameter_slug}' not found" unless parameter
18
+
19
+ # First check feature flags that control this parameter
20
+ feature_flag = find_matching_feature_flag(user, parameter_slug)
21
+ return feature_flag.expose_parameter(user, parameter) if feature_flag
22
+
23
+ # Then check experiments that control this parameter
24
+ experiment_result = find_matching_experiment_variant(user, parameter_slug)
25
+ if experiment_result
26
+ variant, experiment, audience = experiment_result
27
+ return experiment.expose_parameter(user, parameter, variant, audience)
28
+ end
29
+
30
+ {
31
+ parameter_id: parameter.id,
32
+ space_id: parameter.space_id,
33
+ resolved_value: parameter.default_value,
34
+ user_id: nil,
35
+ exposable_type: nil,
36
+ exposable_id: nil,
37
+ audience_id: nil,
38
+ resolved_at: Time.now
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def validate_user!(user)
45
+ raise ArgumentError, 'User must have user_id' unless user.respond_to?(:user_id)
46
+ raise ArgumentError, 'User must have email' unless user.respond_to?(:email)
47
+ end
48
+
49
+ def find_matching_feature_flag(user, parameter_slug)
50
+ @config.feature_flags.find do |feature_flag|
51
+ # Only match if user is in audience AND the flag's variant controls this parameter
52
+ feature_flag.audience.matches?(user) &&
53
+ feature_flag_controls_parameter?(feature_flag, parameter_slug)
54
+ end
55
+ end
56
+
57
+ def feature_flag_controls_parameter?(feature_flag, parameter_slug)
58
+ feature_flag.variant&.parameter_values&.key?(parameter_slug)
59
+ end
60
+
61
+ def find_matching_experiment_variant(user, parameter_slug)
62
+ @config.experiments.each do |experiment|
63
+ # Skip experiments that don't control this parameter
64
+ next unless experiment_controls_parameter?(experiment, parameter_slug)
65
+
66
+ # Check if user is allocated to this experiment using space salt
67
+ experiment_percentage = ABMeter::Core::Utils::NumUtils.to_percentage(experiment.space_salt, user.user_id)
68
+ next unless experiment.range.include?(experiment_percentage)
69
+
70
+ # Then check which audience the user belongs to using experiment salt
71
+ user_percentage = ABMeter::Core::Utils::NumUtils.to_percentage(experiment.salt, user.user_id)
72
+
73
+ assigned_av = experiment.audience_variants.find do |av|
74
+ av.first.range.include?(user_percentage)
75
+ end
76
+
77
+ return [assigned_av.last, experiment, assigned_av.first] if assigned_av
78
+ end
79
+ nil
80
+ end
81
+
82
+ def experiment_controls_parameter?(experiment, parameter_slug)
83
+ experiment.audience_variants.any? do |av|
84
+ variant = av.last
85
+ variant&.parameter_values&.key?(parameter_slug)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module ABMeter
6
+ module Core
7
+ module Utils
8
+ # NumUtils provides deterministic percentage assignment for A/B testing
9
+ #
10
+ # This implementation uses SHA256 + multiply-and-shift algorithm:
11
+ # - Zero external dependencies (uses Ruby's built-in Digest)
12
+ # - Fast execution (< 2 microseconds per assignment)
13
+ # - Cryptographically secure randomness
14
+ # - Distribution quality sufficient for A/B testing:
15
+ # - 10K samples: ~3% average deviation (normal for this sample size)
16
+ # - 100K samples: ~0.9% average deviation (good for statistical significance)
17
+ # - 1M samples: ~0.7% average deviation (excellent uniformity)
18
+ #
19
+ # The multiply-and-shift method avoids modulo bias by scaling the hash
20
+ # value proportionally across the entire 64-bit space before mapping
21
+ # to the 1-100 range.
22
+ class NumUtils
23
+ def self.to_percentage(salt, id)
24
+ # Use a hash function to generate a deterministic but random-looking number
25
+ hash = Digest::SHA256.hexdigest("#{salt}:#{id}")
26
+
27
+ # Convert first 16 characters (64 bits) to integer for better distribution
28
+ # This gives us a number between 0 and 2^64-1
29
+ num = hash[0..15].to_i(16)
30
+
31
+ # Industry-standard multiply-and-shift method for uniform distribution
32
+ # This avoids modulo bias by scaling the 64-bit space proportionally
33
+ # Formula: (num * range) >> bits = (num * 100) >> 64
34
+ # This maps [0, 2^64) uniformly to [0, 100)
35
+ percentage = (num * 100) >> 64
36
+
37
+ # Add 1 to get 1-100 range instead of 0-99
38
+ percentage + 1
39
+ end
40
+
41
+ # [10, 20, 30, 40] -> [(1..10) (11..30), (31..60), (61..100)]
42
+ def self.percentages_to_ranges(percentages)
43
+ ranges = []
44
+ percentages.each do |percentage|
45
+ last_end = ranges.last&.end || 0
46
+ start_val = last_end + 1
47
+ end_val = last_end + percentage
48
+ ranges << (start_val..end_val)
49
+ end
50
+ ranges
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require_relative 'version'
5
+
6
+ # Utils
7
+ require_relative 'core/utils/num_utils'
8
+
9
+ # Protocol types
10
+ require_relative 'core/protocol/type'
11
+
12
+ # Assignment Config - order matters
13
+ require_relative 'core/assignment_config/exposable'
14
+ require_relative 'core/assignment_config/space'
15
+ require_relative 'core/assignment_config/parameter'
16
+ require_relative 'core/assignment_config/variant'
17
+ require_relative 'core/assignment_config/audience'
18
+ require_relative 'core/assignment_config/experiment'
19
+ require_relative 'core/assignment_config/feature_flag'
20
+ require_relative 'core/assignment_config'
21
+
22
+ # Parameter resolution
23
+ require_relative 'core/user_parameter_resolver'
24
+
25
+ # Domain objects
26
+ require_relative 'core/user'
27
+
28
+ module ABMeter
29
+ module Core
30
+ class Error < StandardError; end
31
+
32
+ class << self
33
+ # Get type object by name
34
+ def type(type_name)
35
+ Protocol.type(type_name)
36
+ end
37
+
38
+ # Get all available types
39
+ def all_types
40
+ Protocol.all_types
41
+ end
42
+
43
+ # Check if type is numerical
44
+ def numerical?(type_name)
45
+ Protocol.numerical?(type_name)
46
+ end
47
+
48
+ # Cast value to given type (strict - raises ArgumentError on invalid)
49
+ def cast!(value, type_name)
50
+ Protocol.cast!(value, type_name)
51
+ end
52
+
53
+ # Check if value is valid for type
54
+ def valid_for_type?(value, type_name)
55
+ Protocol.valid_for_type?(value, type_name)
56
+ end
57
+
58
+ # Build a resolver from JSON configuration
59
+ def build_resolver_from_json(json)
60
+ config = AssignmentConfig.from_json(json)
61
+ UserParameterResolver.new(config: config)
62
+ end
63
+
64
+ # Provide convenience access to utilities
65
+ def num_utils
66
+ Utils::NumUtils
67
+ end
68
+
69
+ # Convert percentages to ranges for experiment allocation
70
+ def percentages_to_ranges(percentages)
71
+ Utils::NumUtils.percentages_to_ranges(percentages)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,39 @@
1
+ module ABMeter
2
+ module ErrorSafety
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ # DSL method to wrap methods with error handling
9
+ # Usage: error_safe :method_name
10
+ def error_safe(method_name)
11
+ original_method = instance_method(method_name)
12
+
13
+ define_method(method_name) do |*args, **kwargs, &block|
14
+ original_method.bind(self).call(*args, **kwargs, &block)
15
+ rescue StandardError => e
16
+ log_error("Failed to execute #{method_name}", e)
17
+ call_error_callback(e) if @config&.error_callback
18
+
19
+ nil
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def log_error(message, error)
27
+ return unless @config&.logger
28
+
29
+ @config.logger.error("#{message}: #{error.class} - #{error.message}")
30
+ end
31
+
32
+ def call_error_callback(error)
33
+ @config.error_callback&.call(error)
34
+ rescue StandardError => e
35
+ # Don't let callback errors escape either
36
+ log_error('Error in error callback', e)
37
+ end
38
+ end
39
+ end