Skip to content
Open
31 changes: 0 additions & 31 deletions dev_test.rb

This file was deleted.

1 change: 1 addition & 0 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require_relative 'segments/models'
require_relative 'utils/hash_func'
require_relative 'evaluation/mappers'
require_relative 'evaluation/core'

module Flagsmith
module Engine
Expand Down
167 changes: 163 additions & 4 deletions lib/flagsmith/engine/evaluation/core.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,179 @@
# frozen_string_literal: true

require_relative '../utils/hash_func'
require_relative '../features/constants'
require_relative '../segments/evaluator'

module Flagsmith
module Engine
module Evaluation
# Core evaluation logic module
module Core
extend self
include Flagsmith::Engine::Utils::HashFunc
include Flagsmith::Engine::Features::TargetingReasons
include Flagsmith::Engine::Segments::Evaluator
# Get evaluation result from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [Hash] Evaluation result with flags and segments
def self.get_evaluation_result(evaluation_context)
# TODO: Implement core evaluation logic
# returns EvaluationResultWithMetadata
def get_evaluation_result(evaluation_context)
segments, segment_overrides = evaluate_segments(evaluation_context)
flags = evaluate_features(evaluation_context, segment_overrides)
{
flags: {},
segments: []
flags: flags,
segments: segments,
}
end

# Returns { segments: EvaluationResultSegments; segmentOverrides: Record<string, SegmentOverride>; }
def evaluate_segments(evaluation_context)
if evaluation_context[:identity].nil? || evaluation_context[:segments].nil?
return [], {}
end

identity_segments = get_identity_segments_from_context(evaluation_context)

segments = identity_segments.map do |segment|
result = {
name: segment[:name]
}

if segment[:metadata]
result[:metadata] = segment[:metadata].dup
end

result
end

segment_overrides = process_segment_overrides(identity_segments)

return segments, segment_overrides
end

# Returns Record<string: override.name, SegmentOverride>
def process_segment_overrides(identity_segments)
segment_overrides = {}

identity_segments.each do |segment|
next unless segment[:overrides]

overrides_list = segment[:overrides].is_a?(Array) ? segment[:overrides] : []

overrides_list.each do |override|
if should_apply_override(override, segment_overrides)
segment_overrides[override[:name]] = {
feature: override,
segment_name: segment[:name]
}
end
end
end

segment_overrides
end

# returns EvaluationResultFlags<Metadata>
def evaluate_features(evaluation_context, segment_overrides)
flags = {}

(evaluation_context[:features] || {}).each_value do |feature|
segment_override = segment_overrides[feature[:name]]
final_feature = segment_override ? segment_override[:feature] : feature
has_override = !segment_override.nil?

# Evaluate feature value
evaluated = if has_override
{ value: final_feature[:value], reason: nil }
else
evaluate_feature_value(final_feature, get_identity_key(evaluation_context))
end
Comment on lines +83 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably reproduces the bug existing in the reference implementation, covered by test_multivariate__segment_override__expected_allocation.

Can we add tests to CI so we don't have to guess?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not part of this sprint?

Copy link
Member

@khvn26 khvn26 Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're confusing with Flagsmith/flagsmith-python-client#172 (and other similar issues in Flagsmith/flagsmith#5977)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is — I don't think we should ship with that bug. The test covering it is part of engine-test-data anyway.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I remember, I raised this during the standup to fix it in Rust, but we decided not to include it in this sprint. Is this bug present in all the SDKs?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We’re not releasing without the fix, but it can be included in a separate pull request. As far as I remember, that was the decision — please correct me if I’m mistaken.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, there's no reason not to inlclude the fix in this PR.


# Build flag result
flag_result = {
name: final_feature[:name],
enabled: final_feature[:enabled],
value: evaluated[:value]
}

# Add metadata if present
flag_result[:metadata] = final_feature[:metadata] if final_feature[:metadata]

# Set reason
flag_result[:reason] = evaluated[:reason] ||
get_targeting_match_reason({ type: 'SEGMENT', override: segment_override })

flags[final_feature[:name].to_sym] = flag_result
end

flags
end

# Returns {value: any; reason?: string}
def evaluate_feature_value(feature, identity_key = nil)
if feature[:variants]&.any? && identity_key
return get_multivariate_feature_value(feature, identity_key)
end

{ value: feature[:value], reason: nil }
end

# Returns {value: any; reason?: string}
def get_multivariate_feature_value(feature, identity_key)
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || Float::INFINITY }

start_percentage = 0
sorted_variants.each do |variant|
limit = start_percentage + variant[:weight]
if start_percentage <= percentage_value && percentage_value < limit
return {
value: variant[:value],
reason: get_targeting_match_reason({ type: 'SPLIT', weight: variant[:weight] })
}
end
start_percentage = limit
end

{ value: feature[:value], reason: nil }
end

# returns boolean
def should_apply_override(override, existing_overrides)
current_override = existing_overrides[override[:name]]
!current_override || higher_priority?(override[:priority], current_override[:feature][:priority])
end

private

# Extract identity key from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [String, nil] The identity key or nil if no identity
def get_identity_key(evaluation_context)
return nil unless evaluation_context[:identity]

evaluation_context[:identity][:key] ||
"#{evaluation_context[:environment][:key]}_#{evaluation_context[:identity][:identifier]}"
end

# returns boolean
def higher_priority?(priority_a, priority_b)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def higher_priority?(priority_a, priority_b)
def stronger_priority?(priority_a, priority_b)

Or maybe even

Suggested change
def higher_priority?(priority_a, priority_b)
def is_stronger_priority?(priority_a, priority_b)

(priority_a || Float::INFINITY) < (priority_b || Float::INFINITY)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe have a WEAKEST_PRIORITY constant set to Float::INFINITY.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see it addressed in #89; feel free to resolve or integrate the changes here.

end

def get_targeting_match_reason(match_object)
type = match_object[:type]

if type == 'SEGMENT'
return match_object[:override] ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{match_object[:override][:segment_name]}" : TARGETING_REASON_DEFAULT
end

return "#{TARGETING_REASON_SPLIT}; weight=#{match_object[:weight]}" if type == 'SPLIT'

TARGETING_REASON_DEFAULT
end
end
end
end
Expand Down
50 changes: 24 additions & 26 deletions lib/flagsmith/engine/evaluation/mappers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ module Mappers
def self.get_evaluation_context(environment, identity = nil, override_traits = nil)
environment_context = map_environment_model_to_evaluation_context(environment)
identity_context = identity ? map_identity_model_to_identity_context(identity, override_traits) : nil

context = environment_context.dup
context[:identity] = identity_context if identity_context

context
end

# Maps environment model to evaluation context
#
# @param environment [Flagsmith::Engine::Environment] The environment model
Expand Down Expand Up @@ -105,7 +105,7 @@ def self.map_environment_model_to_evaluation_context(environment)
def self.uuid_to_big_int(uuid)
uuid.gsub('-', '').to_i(16)
end

# Maps identity model to identity context
#
# @param identity [Flagsmith::Engine::Identity] The identity model
Expand All @@ -127,7 +127,7 @@ def self.map_identity_model_to_identity_context(identity, override_traits = nil)
traits: traits_hash
}
end

# Maps segment rule model to rule hash
#
# @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model
Expand All @@ -138,38 +138,37 @@ def self.map_segment_rule_model_to_rule(rule)
}

# Map conditions if present
if rule.conditions&.any?
result[:conditions] = rule.conditions.map do |condition|
{
property: condition.property,
operator: condition.operator,
value: condition.value
}
end
else
result[:conditions] = []
end

if rule.rules&.any?
result[:rules] = rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) }
else
result[:rules] = []
end
result[:conditions] = if rule.conditions&.any?
rule.conditions.map do |condition|
{
property: condition.property,
operator: condition.operator,
value: condition.value
}
end
else
[]
end

result[:rules] = if rule.rules&.any?
rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) }
else
[]
end

result
end

# Maps identity overrides to segments
#
# @param identity_overrides [Array<Flagsmith::Engine::Identity>] Array of identity override models
# @return [Hash] Segments hash for identity overrides
def self.map_identity_overrides_to_segments(identity_overrides)

segments = {}
features_to_identifiers = {}

identity_overrides.each do |identity|
next if identity.identity_features.nil? || !identity.identity_features.any?
next if identity.identity_features.nil? || identity.identity_features.none?

# Sort features by name for consistent hashing
sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name }
Expand Down Expand Up @@ -228,4 +227,3 @@ def self.map_identity_overrides_to_segments(identity_overrides)
end
end
end

14 changes: 14 additions & 0 deletions lib/flagsmith/engine/features/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Flagsmith
module Engine
module Features
# Targeting reason constants for evaluation results
module TargetingReasons
TARGETING_REASON_DEFAULT = 'DEFAULT'
TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH'
TARGETING_REASON_SPLIT = 'SPLIT'
end
end
end
end
Loading
Loading