Skip to content

rule evaluation

Joseph Samir edited this page Jun 21, 2026 · 2 revisions

Rule Evaluation & Targeting

In Bucketing Algorithm, we learned how the SDK uses a "Sorting Hat" approach (hashing) to consistently assign visitors to different variations based on traffic percentages, after they've already qualified for an experiment.

But how does the SDK decide if a visitor qualifies in the first place? What if you only want to run your headline A/B test for visitors from Canada who are using the Safari browser? How does the SDK check these conditions?

The Problem: Are You on the List?

Imagine you're running a special promotion on your website, but only for visitors who meet specific criteria:

  • They must be visiting from Canada.
  • They must be using a mobile device.
  • They must have visited your pricing page before (maybe indicated by a custom attribute).

When a visitor arrives, the SDK needs a way to check if they satisfy all these conditions before even considering showing them the promotion (or bucketing them into an experiment variation). How does it perform this check against potentially complex sets of rules?

What is RuleManager? The Club Bouncer

Think of the RuleManager as the bouncer at an exclusive club. Before anyone gets in (i.e., qualifies for an experiment or feature), the bouncer checks their credentials against the entry rules.

  • The Rules: The bouncer has a list of requirements (the targeting rules you set up in your Convert project, like "country: Canada", "device: mobile", "custom attribute 'has_visited_pricing': true").
  • The Visitor's ID: The bouncer looks at the visitor's details (the visitor attributes provided to the SDK, such as country, device, and custom variable values, plus any default segments set via context.setDefaultSegments).
  • The Check: The bouncer compares the visitor's details against the rules one by one. Do they match?
  • The Decision: The bouncer decides: "Yes, this visitor meets all the criteria, let them in!" or "No, they don't meet the criteria, they can't enter."

The RuleManager does exactly this: it takes a data map of [String: String] attributes and compares it against a set of predefined rules using various comparison methods (like "equals", "contains", "less than"). It then returns whether the visitor is a "match" or "no match".

RuleManager (Sources/ConvertSwiftSDKCore/Rules/RuleManager.swift) is a stateless internal struct — it owns no mutable state; its only stored property is an injected Logger for fail-closed WARN lines.

How it's Used (The Bouncer Reports to the Manager)

You, as the SDK user, never interact with RuleManager directly. It is called internally by ExperienceManager during selectVariation.

Remember the selectVariation flow?

  1. ExperienceManager reads the experience's audience and location rule lists from the ProjectConfig snapshot.
  2. RuleAdapter flattens the generated 3-level rule graphs (OR → AND → OR_WHEN → leaf) into the flat [RuleGroup] model RuleManager consumes.
  3. ExperienceManager asks the RuleManager: "Does this visitor (with these attributes) match these targeting rules?" by calling ruleManager.evaluate(rules:against:).
  4. The RuleManager (the bouncer) performs the checks and reports back: true (match) or false (no match). An empty rule set returns false (fail-closed).
  5. If the RuleManager reports true for both the audience rules AND the location rules, then ExperienceManager proceeds to ask the BucketingManager to assign a variation. If false, the process stops, and the visitor doesn't see the experiment.

So, RuleManager is the specialist that ExperienceManager consults to verify visitor eligibility based on targeting rules.

Under the Hood: How the Rules are Checked

1. The Rule Structure (Flat Model)

The targeting rules you define in Convert arrive in the serving config as a hierarchical object (OR → AND → OR_WHEN → leaf). The RuleAdapter flattens this into the [RuleGroup] model RuleManager consumes:

  • Outer array ([RuleGroup]): An OR — the overall evaluation passes if ANY RuleGroup passes.
  • RuleGroup.conditions: An AND — ALL conditions inside a group must pass for that group to pass.

A single RuleCondition carries:

  • key: String — the visitor-data key to look up (e.g. "country")
  • matchType: String — the comparison operator (e.g. "equals", "contains")
  • value: String? — the rule's target value to compare against
  • negation: Bool — whether to invert the result

The original source had a 4-level hierarchy (OR → AND → OR_WHEN → RuleElement); the flat model collapses OR_WHEN leaves into the AND-group's conditions list — a single AND-block with two OR_WHEN leaves becomes one RuleGroup with two RuleConditions.

2. The Evaluation Process (evaluate(rules:against:))

When ExperienceManager calls ruleManager.evaluate(rules: audienceRules, against: attributes):

  1. If rules is empty → returns false and logs WARN (fail-closed, AC3). Eligibility is NEVER vacuous-true.
  2. It iterates each RuleGroup in the outer OR array. The first group that passes causes the whole evaluation to return true.
  3. For each RuleGroup, it iterates the conditions with allSatisfy. If any condition fails, the group fails and the next group in the OR is tried.
  4. For each RuleCondition:
    • It looks up attributes[condition.key] — this is a String? (nil when the key is absent). The nil flows straight into Comparisons.evaluate for EVERY operator — RuleManager never short-circuits on a missing key, because exists / doesNotExist rely on nil reaching the comparator to compute presence (AC2).
    • It dispatches to Comparisons.evaluate(matchType:value:testAgainst:negated:logger:).
    • Returns the boolean result.

3. The Comparison Methods (Comparisons)

The actual checking ("equals", "contains", "less than", etc.) happens in Comparisons (Sources/ConvertSwiftSDKCore/Rules/Comparisons.swift), a stateless internal enum with pure static functions. The dispatch is a static [String: Comparator] table (rather than a switch) to avoid SwiftLint cyclomatic_complexity flags.

Every comparator is parity-verified line-by-line against the live JavaScript SDK comparisons.ts. Seven subtle behaviours are pinned by test vectors in ComparisonsTests.swift:

  1. equals / equalsNumber / matches are case-insensitive (lowercase both sides). equalsNumber and matches are aliases for string equality — NOT numeric comparison.
  2. contains is case-insensitive; value (the visitor side) is the HAYSTACK and testAgainst (the rule side) is the NEEDLE; an empty / whitespace-only needle always returns true.
  3. startsWith / endsWith are case-insensitive — lowercase both sides.
  4. less / lessEqual are type-guarded: a non-numeric operand on either side yields false. The numeric gate matches JS isNumeric (accepts comma-grouped thousands; rejects sci-notation, hex, Infinity, leading-plus).
  5. regexMatches is case-insensitive (JS 'i' flag via NSRegularExpression.Options.caseInsensitive); an invalid pattern yields false.
  6. isIn is asymmetric: the value candidates are NOT lowercased; the testAgainst allow-list IS lowercased. Split uses components(separatedBy:) (not split) to preserve JS empty-segment semantics.
  7. exists / not_exists treat the empty string as absent; doesNotExist is an alias for not_exists.

Available comparison operators (wire matchType strings):

"equals"        "equalsNumber"  "matches"
"less"          "lessEqual"
"contains"      "isIn"
"startsWith"    "endsWith"      "regexMatches"
"exists"        "not_exists"    "doesNotExist"

Negation inverts the result of every real operator at the end of evaluate (via applyNegation). An unknown operator fails closed: it logs WARN and returns false directly, WITHOUT negation applied (JS parity).

4. The RuleAdapter

Before RuleManager can evaluate, RuleAdapter (Sources/ConvertSwiftSDKCore/Rules/RuleAdapter.swift) flattens the generated rule graphs. The generated types (RuleObjectAudience / RuleObject) are structurally OR → AND → OR_WHEN → leaf. The adapter:

  • One RuleGroup per AND-block.
  • That block's OR_WHEN leaves are extracted as RuleConditions.
  • An absent OR yields an empty [RuleGroup] (which RuleManager treats as fail-closed).

Leaf families currently handled: GenericTextMatchRule-backed text rules (city, campaign, keyword, medium, region, url, page_tag, etc.) and CountryMatchRule. Every other family degrades to a fail-closed condition (matchType = "", absent from Comparisons.comparators) — evaluates to false without ever matching wrong-positive.

Simplified Sequence Diagram:

sequenceDiagram
    participant EM as ExperienceManager
    participant RA as RuleAdapter
    participant RM as RuleManager
    participant Comp as Comparisons

    EM->>+RA: flatten(audience.rules)
    RA-->>-EM: [RuleGroup] (flat OR-of-AND)

    EM->>+RM: evaluate(rules: [RuleGroup], against: attributes)
    Note over RM: Start outer OR loop...
    RM->>RM: group = rules[0]; allSatisfy(conditions)
    RM->>RM: condition: key="country", matchType="equals", value="Canada"
    RM->>Comp: evaluate("equals", value: "Canada", testAgainst: "Canada", negated: false)
    Comp-->>RM: true
    RM->>RM: condition: key="devices", matchType="equals", value="mobile"
    RM->>Comp: evaluate("equals", value: "mobile", testAgainst: "mobile", negated: false)
    Comp-->>RM: true
    Note over RM: All conditions in group passed — outer OR is true.
    RM-->>-EM: true (Visitor matches)
Loading

Implementation Details

The RuleManager Constructor

// internal — not part of the public API
internal struct RuleManager {
    private let logger: Logger
    init(logger: Logger) { self.logger = logger }
}
  • The logger is the only injected dependency — for the fail-closed WARN lines (empty outer set / empty group).
  • There is NO keys_case_sensitive flag on the iOS RuleManager itself; attribute key lookup is direct dictionary subscripting (attributes[condition.key]), and the ruleKeysCaseSensitive configuration option is propagated at the ConvertConfiguration level for future use.
  • There is NO pluggable comparator constructor parameter — the iOS SDK uses Comparisons directly via a static dispatch table, rather than passing the comparison processor as a constructor argument.

The Main Entry Point (evaluate(rules:against:))

func evaluate(rules: [RuleGroup], against attributes: [String: String]) -> Bool
  • Empty rulesfalse + WARN (fail-closed, AC3).
  • Iterates rules; first group whose evaluate(group:against:) returns true → returns true.
  • If all groups fail → false.

Processing AND Groups (evaluate(group:against:))

  • Empty group.conditionsfalse + WARN (fail-closed, AC3).
  • group.conditions.allSatisfy { evaluate(condition:against:) } — short-circuits on the first failing condition.

Processing a Single Condition (evaluate(condition:against:))

  • Looks up attributes[condition.key] as String?.
  • Calls Comparisons.evaluate(matchType:value:testAgainst:negated:logger:) with the (possibly nil) value.

Conclusion

The RuleManager is the iOS SDK's engine for evaluating complex targeting rules. Like a diligent bouncer, it checks if a visitor's attributes (location, device, custom properties set via createContext or setDefaultSegments) match the conditions defined for an experiment, audience, or feature flag.

Key takeaways:

  1. Why rule evaluation is necessary for targeted experiences.
  2. The "bouncer" analogy for RuleManager.
  3. How rules are structured — generated 3-level graphs flattened by RuleAdapter into a flat [RuleGroup] OR-of-AND model.
  4. How RuleManager processes these rules through evaluate(rules:against:) and evaluate(group:against:).
  5. How it uses comparison methods from the Comparisons enum (parity-verified against the JS SDK) to perform individual checks.
  6. The fail-closed behaviour on empty rule sets and unknown operators (AC2, AC3).
  7. That it's primarily used internally by ExperienceManager to determine visitor eligibility before bucketing.

Now we understand how the SDK gets configuration data (Architecture), manages individual visitor sessions (Context), handles experiences (Experiences and Variations) and features (Feature Management), stores data and orchestrates decisions (Data Management), allocates traffic fairly (Bucketing Algorithm), and evaluates targeting rules (Rule Evaluation & Targeting).

But how does the SDK actually get the project configuration data (including those rules) from the Convert servers in the first place? And how does it send tracking data back? That's the job of the communicator module.

Let's explore how the SDK interacts with external APIs next: API Management!

Clone this wiki locally