toggly 0.6.0 → 1.0.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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # A single named variant of a feature flag, as sent on the catalog
5
+ # (`definitions` / `definitions-signed`) wire alongside `filters`.
6
+ #
7
+ # Matches the shape produced by `Microsoft.FeatureManagement`'s
8
+ # `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
9
+ class FeatureVariant
10
+ # Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
11
+ STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
12
+
13
+ # @return [String] Variant name
14
+ attr_reader :name
15
+
16
+ # @return [Object, nil] Untyped configuration payload for this variant
17
+ attr_reader :configuration_value
18
+
19
+ # @return [String] "None" | "Enabled" | "Disabled"
20
+ attr_reader :status_override
21
+
22
+ def initialize(name:, configuration_value: nil, status_override: "None")
23
+ @name = name.to_s
24
+ @configuration_value = configuration_value
25
+ @status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
26
+ end
27
+
28
+ # @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
29
+ # @return [FeatureVariant]
30
+ def self.from_hash(hash)
31
+ hash = symbolize_keys(hash)
32
+ configuration_value = if hash.key?(:configurationValue)
33
+ hash[:configurationValue]
34
+ else
35
+ hash[:configuration_value]
36
+ end
37
+
38
+ new(
39
+ name: hash[:name],
40
+ configuration_value: configuration_value,
41
+ status_override: hash[:statusOverride] || hash[:status_override] || "None"
42
+ )
43
+ end
44
+
45
+ # @return [Boolean] True when this variant forces the feature to be
46
+ # reported enabled regardless of the base filter-evaluated state.
47
+ def enabled_override?
48
+ @status_override == "Enabled"
49
+ end
50
+
51
+ # @return [Boolean] True when this variant forces the feature to be
52
+ # reported disabled regardless of the base filter-evaluated state.
53
+ def disabled_override?
54
+ @status_override == "Disabled"
55
+ end
56
+
57
+ # @return [Hash]
58
+ def to_h
59
+ {
60
+ name: @name,
61
+ configuration_value: @configuration_value,
62
+ status_override: @status_override
63
+ }
64
+ end
65
+
66
+ def ==(other)
67
+ other.is_a?(FeatureVariant) &&
68
+ @name == other.name &&
69
+ @configuration_value == other.configuration_value &&
70
+ @status_override == other.status_override
71
+ end
72
+ alias eql? ==
73
+
74
+ def hash
75
+ [@name, @configuration_value, @status_override].hash
76
+ end
77
+
78
+ def self.symbolize_keys(hash)
79
+ return {} unless hash.is_a?(Hash)
80
+
81
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
82
+ end
83
+ private_class_method :symbolize_keys
84
+ end
85
+
86
+ # Allocation rules for assigning variants to users/groups/percentile
87
+ # buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
88
+ class FeatureVariantAllocation
89
+ # @return [String, nil] Variant assigned when the feature is enabled and
90
+ # no user/group/percentile allocation matched.
91
+ attr_reader :default_when_enabled
92
+
93
+ # @return [String, nil] Variant assigned when the feature is disabled.
94
+ attr_reader :default_when_disabled
95
+
96
+ # @return [String, nil] Seed for the percentile hash; falls back to
97
+ # "allocation\n{featureName}" when nil.
98
+ attr_reader :seed
99
+
100
+ # @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
101
+ attr_reader :user
102
+
103
+ # @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
104
+ attr_reader :group
105
+
106
+ # @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
107
+ attr_reader :percentile
108
+
109
+ def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
110
+ @default_when_enabled = default_when_enabled
111
+ @default_when_disabled = default_when_disabled
112
+ @seed = seed
113
+ @user = Array(user)
114
+ @group = Array(group)
115
+ @percentile = Array(percentile)
116
+ end
117
+
118
+ # @param hash [Hash, nil]
119
+ # @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
120
+ def self.from_hash(hash)
121
+ return nil unless hash.is_a?(Hash)
122
+
123
+ hash = symbolize_keys(hash)
124
+ new(
125
+ default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
126
+ default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
127
+ seed: hash[:seed],
128
+ user: parse_entries(hash[:user], :users),
129
+ group: parse_entries(hash[:group], :groups),
130
+ percentile: parse_percentile_entries(hash[:percentile])
131
+ )
132
+ end
133
+
134
+ # @return [Hash]
135
+ def to_h
136
+ {
137
+ default_when_enabled: @default_when_enabled,
138
+ default_when_disabled: @default_when_disabled,
139
+ seed: @seed,
140
+ user: @user,
141
+ group: @group,
142
+ percentile: @percentile
143
+ }
144
+ end
145
+
146
+ def ==(other)
147
+ other.is_a?(FeatureVariantAllocation) &&
148
+ @default_when_enabled == other.default_when_enabled &&
149
+ @default_when_disabled == other.default_when_disabled &&
150
+ @seed == other.seed &&
151
+ @user == other.user &&
152
+ @group == other.group &&
153
+ @percentile == other.percentile
154
+ end
155
+ alias eql? ==
156
+
157
+ def hash
158
+ [@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
159
+ end
160
+
161
+ def self.parse_entries(raw, members_key)
162
+ Array(raw).filter_map do |entry|
163
+ next unless entry.is_a?(Hash)
164
+
165
+ entry = symbolize_keys(entry)
166
+ { variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
167
+ end
168
+ end
169
+ private_class_method :parse_entries
170
+
171
+ def self.parse_percentile_entries(raw)
172
+ Array(raw).filter_map do |entry|
173
+ next unless entry.is_a?(Hash)
174
+
175
+ entry = symbolize_keys(entry)
176
+ { variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
177
+ end
178
+ end
179
+ private_class_method :parse_percentile_entries
180
+
181
+ def self.symbolize_keys(hash)
182
+ return {} unless hash.is_a?(Hash)
183
+
184
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
185
+ end
186
+ private_class_method :symbolize_keys
187
+ end
188
+ end
@@ -40,21 +40,6 @@ module Toggly
40
40
  false
41
41
  end
42
42
 
43
- # Save evaluated variants snapshot (dual-rail, used when
44
- # `config.enable_variants` is true). Default: no-op. Override in
45
- # subclasses to persist variants across restarts.
46
- #
47
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants to save
48
- # @param metadata [Hash] Optional metadata
49
- def save_variants(_variants, _metadata = {}); end
50
-
51
- # Load evaluated variants snapshot (dual-rail).
52
- #
53
- # @return [Hash, nil] Hash with :variants and :metadata, or nil if not available
54
- def load_variants
55
- nil
56
- end
57
-
58
43
  protected
59
44
 
60
45
  # Serialize definitions to a storable format
@@ -77,26 +62,6 @@ module Toggly
77
62
  hash[definition.feature_key] = definition
78
63
  end
79
64
  end
80
-
81
- # Serialize evaluated variants to a storable format
82
- #
83
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
84
- # @return [Hash<String, Hash>]
85
- def serialize_variants(variants)
86
- variants.transform_values(&:to_h)
87
- end
88
-
89
- # Deserialize evaluated variants from stored format
90
- #
91
- # @param data [Hash<String, Hash>] Serialized variants
92
- # @return [Hash<String, EvaluatedVariantDef>]
93
- def deserialize_variants(data)
94
- return {} unless data.is_a?(Hash)
95
-
96
- data.each_with_object({}) do |(key, value), hash|
97
- hash[key.to_s] = EvaluatedVariantDef.from_hash(value)
98
- end
99
- end
100
65
  end
101
66
  end
102
67
  end
@@ -12,14 +12,10 @@ module Toggly
12
12
  # @return [String] Path to the snapshot file
13
13
  attr_reader :path
14
14
 
15
- # @return [String] Path to the evaluated-variants snapshot file (dual-rail)
16
- attr_reader :variants_path
17
-
18
15
  # @param path [String] Path to the snapshot file
19
16
  def initialize(path:)
20
17
  super()
21
18
  @path = path
22
- @variants_path = derive_variants_path(path)
23
19
  @mutex = Mutex.new
24
20
  end
25
21
 
@@ -70,7 +66,6 @@ module Toggly
70
66
  def clear
71
67
  @mutex.synchronize do
72
68
  FileUtils.rm_f(@path)
73
- FileUtils.rm_f(@variants_path)
74
69
  end
75
70
  rescue StandardError => e
76
71
  raise SnapshotError, "Failed to clear snapshot: #{e.message}"
@@ -83,48 +78,6 @@ module Toggly
83
78
  ::File.exist?(@path)
84
79
  end
85
80
 
86
- # Save evaluated variants to file using atomic write (dual-rail)
87
- #
88
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
89
- # @param metadata [Hash] Optional metadata
90
- def save_variants(variants, metadata = {})
91
- @mutex.synchronize do
92
- ensure_directory_exists
93
-
94
- data = {
95
- "variants" => serialize_variants(variants),
96
- "metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
97
- }
98
-
99
- temp_path = "#{@variants_path}.tmp"
100
- ::File.write(temp_path, JSON.pretty_generate(data))
101
- ::File.rename(temp_path, @variants_path)
102
- end
103
- rescue StandardError => e
104
- raise SnapshotError, "Failed to save variants snapshot: #{e.message}"
105
- end
106
-
107
- # Load evaluated variants from file (dual-rail)
108
- #
109
- # @return [Hash, nil] Hash with :variants and :metadata
110
- def load_variants
111
- @mutex.synchronize do
112
- return nil unless ::File.exist?(@variants_path)
113
-
114
- content = ::File.read(@variants_path)
115
- data = JSON.parse(content)
116
-
117
- {
118
- variants: deserialize_variants(data["variants"]),
119
- metadata: symbolize_keys(data["metadata"] || {})
120
- }
121
- end
122
- rescue JSON::ParserError => e
123
- raise SnapshotError, "Failed to parse variants snapshot: #{e.message}"
124
- rescue StandardError => e
125
- raise SnapshotError, "Failed to load variants snapshot: #{e.message}"
126
- end
127
-
128
81
  private
129
82
 
130
83
  def ensure_directory_exists
@@ -132,13 +85,6 @@ module Toggly
132
85
  FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
133
86
  end
134
87
 
135
- def derive_variants_path(path)
136
- dir = ::File.dirname(path)
137
- ext = ::File.extname(path)
138
- base = ::File.basename(path, ext)
139
- ::File.join(dir, "#{base}_variants#{ext}")
140
- end
141
-
142
88
  def symbolize_keys(hash)
143
89
  return {} unless hash.is_a?(Hash)
144
90
 
@@ -9,7 +9,6 @@ module Toggly
9
9
  def initialize
10
10
  super
11
11
  @data = nil
12
- @variants_data = nil
13
12
  @mutex = Mutex.new
14
13
  end
15
14
 
@@ -44,7 +43,6 @@ module Toggly
44
43
  def clear
45
44
  @mutex.synchronize do
46
45
  @data = nil
47
- @variants_data = nil
48
46
  end
49
47
  end
50
48
 
@@ -56,33 +54,6 @@ module Toggly
56
54
  !@data.nil?
57
55
  end
58
56
  end
59
-
60
- # Save evaluated variants to memory (dual-rail)
61
- #
62
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
63
- # @param metadata [Hash] Optional metadata
64
- def save_variants(variants, metadata = {})
65
- @mutex.synchronize do
66
- @variants_data = {
67
- variants: serialize_variants(variants),
68
- metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
69
- }
70
- end
71
- end
72
-
73
- # Load evaluated variants from memory (dual-rail)
74
- #
75
- # @return [Hash, nil] Hash with :variants and :metadata
76
- def load_variants
77
- @mutex.synchronize do
78
- return nil unless @variants_data
79
-
80
- {
81
- variants: deserialize_variants(@variants_data[:variants]),
82
- metadata: @variants_data[:metadata]
83
- }
84
- end
85
- end
86
57
  end
87
58
  end
88
59
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Toggly
6
+ # Catalog-local, MF-parity variant allocator.
7
+ #
8
+ # Assigns a feature variant purely from a {FeatureDefinition}'s
9
+ # `variants` / `allocation` and a targeting context (`identity` + `groups`),
10
+ # bit-for-bit matching `Microsoft.FeatureManagement` 4.7.0's
11
+ # `FeatureManager#GetVariantAsync`. Verified against the shared
12
+ # `variant-allocator-corpus/cases.json` gold corpus.
13
+ #
14
+ # This replaces the `evaluated-variants-signed` dual-rail: there is no
15
+ # network call here — the caller supplies the already filter-evaluated
16
+ # `enabled` boolean (the same value `Client#enabled?` would return for
17
+ # this feature/context) and this module does the rest locally.
18
+ module VariantAllocator
19
+ module_function
20
+
21
+ # Assignment reasons (mirrors MF `AssignmentReason`).
22
+ REASON_NONE = "None"
23
+ REASON_USER = "User"
24
+ REASON_GROUP = "Group"
25
+ REASON_PERCENTILE = "Percentile"
26
+ REASON_DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
27
+ REASON_DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled"
28
+
29
+ # Result of a variant assignment.
30
+ #
31
+ # @!attribute variant_name [String, nil] Assigned variant name, or nil if unassigned
32
+ # @!attribute configuration_value [Object, nil] The assigned variant's configuration payload
33
+ # @!attribute enabled [Boolean] Effective enabled flag after StatusOverride is applied
34
+ # @!attribute reason [String] One of the REASON_* constants above
35
+ Assignment = Struct.new(:variant_name, :configuration_value, :enabled, :reason, keyword_init: true)
36
+
37
+ # Assign a variant for a feature + targeting context.
38
+ #
39
+ # @param definition [FeatureDefinition] Feature definition (variants + allocation)
40
+ # @param enabled [Boolean] The filter-evaluated enabled state for this context
41
+ # (i.e. what `EvaluationEngine#evaluate` returns for this definition/context)
42
+ # @param identity [String, nil] Targeting user id
43
+ # @param groups [Array<String>] Targeting groups
44
+ # @param ignore_case [Boolean] MF `TargetingEvaluationOptions.IgnoreCase` parity knob.
45
+ # Defaults to `false`, matching Microsoft.FeatureManagement's default.
46
+ # @return [Assignment]
47
+ def assign(definition, enabled:, identity: nil, groups: [], ignore_case: false)
48
+ variants = definition&.variants || []
49
+ allocation = definition&.allocation
50
+
51
+ return build_assignment(nil, variants, enabled, REASON_NONE) if variants.empty?
52
+
53
+ unless enabled
54
+ variant_name = allocation&.default_when_disabled
55
+ return build_assignment(variant_name, variants, enabled, REASON_DEFAULT_WHEN_DISABLED)
56
+ end
57
+
58
+ variant_name, reason = resolve_enabled_allocation(
59
+ allocation, identity, groups, ignore_case, definition.feature_key
60
+ )
61
+ build_assignment(variant_name, variants, enabled, reason)
62
+ end
63
+
64
+ def resolve_enabled_allocation(allocation, identity, groups, ignore_case, feature_key)
65
+ return [nil, REASON_DEFAULT_WHEN_ENABLED] unless allocation
66
+
67
+ user_variant = match_user(allocation.user, identity, ignore_case)
68
+ return [user_variant, REASON_USER] if user_variant
69
+
70
+ group_variant = match_group(allocation.group, groups, ignore_case)
71
+ return [group_variant, REASON_GROUP] if group_variant
72
+
73
+ percentile_variant = match_percentile(allocation.percentile, allocation.seed, identity, feature_key, ignore_case)
74
+ return [percentile_variant, REASON_PERCENTILE] if percentile_variant
75
+
76
+ [allocation.default_when_enabled, REASON_DEFAULT_WHEN_ENABLED]
77
+ end
78
+
79
+ def match_user(entries, identity, ignore_case)
80
+ return nil if identity.nil? || identity.to_s.empty?
81
+
82
+ Array(entries).each do |entry|
83
+ users = Array(entry[:users])
84
+ matched = if ignore_case
85
+ users.any? { |u| u.to_s.casecmp?(identity.to_s) }
86
+ else
87
+ users.include?(identity.to_s)
88
+ end
89
+ return entry[:variant] if matched
90
+ end
91
+ nil
92
+ end
93
+
94
+ def match_group(entries, groups, ignore_case)
95
+ context_groups = Array(groups).map(&:to_s)
96
+ return nil if context_groups.empty?
97
+
98
+ Array(entries).each do |entry|
99
+ entry_groups = Array(entry[:groups]).map(&:to_s)
100
+ matched = if ignore_case
101
+ entry_groups.any? { |eg| context_groups.any? { |cg| cg.casecmp?(eg) } }
102
+ else
103
+ entry_groups.intersect?(context_groups)
104
+ end
105
+ return entry[:variant] if matched
106
+ end
107
+ nil
108
+ end
109
+
110
+ def match_percentile(entries, seed, identity, feature_key, ignore_case)
111
+ list = Array(entries)
112
+ return nil if list.empty?
113
+
114
+ pct = compute_percentile(identity, seed, feature_key, ignore_case)
115
+ list.each do |entry|
116
+ from = entry[:from].to_f
117
+ to = entry[:to].to_f
118
+ matched = to >= 100.0 ? pct >= from : (pct >= from && pct < to)
119
+ return entry[:variant] if matched
120
+ end
121
+ nil
122
+ end
123
+
124
+ # MF-parity percentile hash.
125
+ #
126
+ # `contextId = "{userId}\n{hint}"`, `hint = seed` (if non-nil) else
127
+ # `"allocation\n{featureName}"`. SHA-256 over the UTF-8 bytes; the first
128
+ # 4 bytes read as a little-endian uint32; `pct = marker / 0xFFFFFFFF * 100`.
129
+ #
130
+ # @return [Float] Percentile in [0, 100]
131
+ def compute_percentile(identity, seed, feature_key, ignore_case)
132
+ user_id = identity.to_s
133
+ user_id = user_id.downcase if ignore_case
134
+ hint = seed.nil? ? "allocation\n#{feature_key}" : seed.to_s
135
+ context_id = "#{user_id}\n#{hint}"
136
+
137
+ digest = Digest::SHA256.digest(context_id)
138
+ marker = digest.byteslice(0, 4).unpack1("V") # little-endian uint32; unpack1("V") is endian-explicit
139
+ (marker.to_f / 0xFFFFFFFF) * 100.0
140
+ end
141
+
142
+ def build_assignment(variant_name, variants, base_enabled, reason)
143
+ variant = Array(variants).find { |v| v.name == variant_name }
144
+ effective_enabled =
145
+ if variant&.enabled_override?
146
+ true
147
+ elsif variant&.disabled_override?
148
+ false
149
+ else
150
+ base_enabled
151
+ end
152
+
153
+ Assignment.new(
154
+ variant_name: variant_name,
155
+ configuration_value: variant&.configuration_value,
156
+ enabled: effective_enabled,
157
+ reason: reason
158
+ )
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Assigned variant for a feature, returned by `Client#get_variant`.
5
+ #
6
+ # Assignment is computed locally from the feature's catalog
7
+ # `variants` / `allocation` (see {VariantAllocator}), matching
8
+ # `Microsoft.FeatureManagement`'s `IVariantFeatureManager` bit-for-bit.
9
+ # There is no network round-trip: this is not the `enable_variants`
10
+ # dual-rail from earlier releases (removed; see CHANGELOG).
11
+ class VariantResult
12
+ # @return [String] Variant name
13
+ attr_reader :name
14
+
15
+ # @return [Object, nil] Untyped configuration payload for this variant
16
+ attr_reader :configuration_value
17
+
18
+ # @return [Boolean] Effective enabled flag for this feature/context after
19
+ # the variant's `StatusOverride` is applied. This can differ from
20
+ # `Client#enabled?`, which stays filter-based only; use this field when
21
+ # you need MF-identical `GetVariantAsync`-style effective-enabled
22
+ # semantics (e.g. a variant with `StatusOverride: "Enabled"` on an
23
+ # otherwise-disabled feature).
24
+ attr_reader :enabled
25
+
26
+ # @return [String] Assignment reason: "User" | "Group" | "Percentile" |
27
+ # "DefaultWhenEnabled" | "DefaultWhenDisabled"
28
+ attr_reader :reason
29
+
30
+ def initialize(name:, configuration_value: nil, enabled: true, reason: nil)
31
+ @name = name
32
+ @configuration_value = configuration_value
33
+ @enabled = enabled
34
+ @reason = reason
35
+ end
36
+
37
+ # @return [Hash]
38
+ def to_h
39
+ { name: @name, configuration_value: @configuration_value, enabled: @enabled, reason: @reason }
40
+ end
41
+
42
+ def ==(other)
43
+ other.is_a?(VariantResult) &&
44
+ @name == other.name &&
45
+ @configuration_value == other.configuration_value &&
46
+ @enabled == other.enabled &&
47
+ @reason == other.reason
48
+ end
49
+ alias eql? ==
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.6.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -9,8 +9,10 @@ require_relative "toggly/sticky_hash"
9
9
  require_relative "toggly/user_agent_parser"
10
10
  require_relative "toggly/context"
11
11
  require_relative "toggly/errors"
12
+ require_relative "toggly/feature_variant"
12
13
  require_relative "toggly/feature_definition"
13
- require_relative "toggly/evaluated_variant"
14
+ require_relative "toggly/variant_allocator"
15
+ require_relative "toggly/variant_result"
14
16
  require_relative "toggly/evaluators/base"
15
17
  require_relative "toggly/evaluators/segment_helpers"
16
18
  require_relative "toggly/evaluators/always_on"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
@@ -31,7 +31,6 @@ files:
31
31
  - lib/toggly/definitions_provider.rb
32
32
  - lib/toggly/entity_context.rb
33
33
  - lib/toggly/errors.rb
34
- - lib/toggly/evaluated_variant.rb
35
34
  - lib/toggly/evaluation_engine.rb
36
35
  - lib/toggly/evaluators/always_off.rb
37
36
  - lib/toggly/evaluators/always_on.rb
@@ -49,6 +48,7 @@ files:
49
48
  - lib/toggly/evaluators/time_window.rb
50
49
  - lib/toggly/evaluators/user_claims.rb
51
50
  - lib/toggly/feature_definition.rb
51
+ - lib/toggly/feature_variant.rb
52
52
  - lib/toggly/http_request_mapper.rb
53
53
  - lib/toggly/registry.rb
54
54
  - lib/toggly/request_context.rb
@@ -67,6 +67,8 @@ files:
67
67
  - lib/toggly/telemetry/runtime_flush.rb
68
68
  - lib/toggly/telemetry/usage_batcher.rb
69
69
  - lib/toggly/user_agent_parser.rb
70
+ - lib/toggly/variant_allocator.rb
71
+ - lib/toggly/variant_result.rb
70
72
  - lib/toggly/version.rb
71
73
  - proto/metrics.proto
72
74
  - proto/usage.proto