toggly 0.6.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,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.1.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"
@@ -104,6 +106,29 @@ module Toggly
104
106
  !enabled?(feature_key, context: context)
105
107
  end
106
108
 
109
+ # Assign a catalog-local variant using the global client.
110
+ # See {Client#get_variant}.
111
+ #
112
+ # @param feature_key [String, Symbol]
113
+ # @param context [Context, nil]
114
+ # @return [VariantResult, nil]
115
+ def get_variant(feature_key, context: nil)
116
+ raise Error, "Toggly not configured. Call Toggly.configure first." unless @client
117
+
118
+ @client.get_variant(feature_key, context: context)
119
+ end
120
+
121
+ # Configuration value for the assigned variant. See {Client#get_variant_value}.
122
+ #
123
+ # @param feature_key [String, Symbol]
124
+ # @param context [Context, nil]
125
+ # @return [Object, nil]
126
+ def get_variant_value(feature_key, context: nil)
127
+ raise Error, "Toggly not configured. Call Toggly.configure first." unless @client
128
+
129
+ @client.get_variant_value(feature_key, context: context)
130
+ end
131
+
107
132
  # Reset the global client (mainly for testing)
108
133
  def reset!
109
134
  @client&.close
metadata CHANGED
@@ -1,14 +1,14 @@
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.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -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
@@ -1,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Toggly
4
- # Server-evaluated variant entry for a single feature flag, as returned by
5
- # `evaluated-variants-signed`. This is distinct from `FeatureDefinition`
6
- # (client-side rules): the server has already picked the winning variant.
7
- class EvaluatedVariantDef
8
- # @return [Boolean] Whether the feature is enabled for this assignment
9
- attr_reader :enabled
10
-
11
- # @return [String, nil] Assigned variant name, if any
12
- attr_reader :variant
13
-
14
- # @return [Object, nil] Configuration payload for the variant (shape depends on the feature)
15
- attr_reader :configuration_value
16
-
17
- def initialize(enabled:, variant: nil, configuration_value: nil)
18
- @enabled = enabled ? true : false
19
- @variant = variant
20
- @configuration_value = configuration_value
21
- end
22
-
23
- # Create from a hash (API response or snapshot; camelCase or snake_case).
24
- #
25
- # @param hash [Hash]
26
- # @return [EvaluatedVariantDef]
27
- def self.from_hash(hash)
28
- hash = symbolize_keys(hash)
29
- configuration_value = if hash.key?(:configurationValue)
30
- hash[:configurationValue]
31
- else
32
- hash[:configuration_value]
33
- end
34
-
35
- new(
36
- enabled: hash[:enabled],
37
- variant: hash[:variant],
38
- configuration_value: configuration_value
39
- )
40
- end
41
-
42
- # Convert to a hash for serialization (snapshot persistence).
43
- #
44
- # @return [Hash]
45
- def to_h
46
- {
47
- enabled: @enabled,
48
- variant: @variant,
49
- configuration_value: @configuration_value
50
- }
51
- end
52
-
53
- def ==(other)
54
- other.is_a?(EvaluatedVariantDef) &&
55
- @enabled == other.enabled &&
56
- @variant == other.variant &&
57
- @configuration_value == other.configuration_value
58
- end
59
- alias eql? ==
60
-
61
- def self.symbolize_keys(hash)
62
- return hash unless hash.is_a?(Hash)
63
-
64
- hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
65
- end
66
- private_class_method :symbolize_keys
67
- end
68
-
69
- # Assigned variant name and configuration value for a feature, returned by
70
- # `Client#get_variant` when `Config#enable_variants` is true.
71
- class VariantResult
72
- # @return [String] Variant name assigned by the server
73
- attr_reader :name
74
-
75
- # @return [Object, nil] Optional configuration payload for the variant
76
- attr_reader :configuration_value
77
-
78
- def initialize(name:, configuration_value: nil)
79
- @name = name
80
- @configuration_value = configuration_value
81
- end
82
-
83
- # @return [Hash]
84
- def to_h
85
- { name: @name, configuration_value: @configuration_value }
86
- end
87
- end
88
- end