Skip to content

feat: add custom assignment rules evaluation#52

Merged
joalves merged 22 commits into
mainfrom
feature/sdk-rules-implementation
Apr 13, 2026
Merged

feat: add custom assignment rules evaluation#52
joalves merged 22 commits into
mainfrom
feature/sdk-rules-implementation

Conversation

@joalves
Copy link
Copy Markdown
Contributor

@joalves joalves commented Mar 23, 2026

Summary

  • Add evaluateRules() to AudienceMatcher supporting the new rules[].or[] payload structure with environment scoping and JsonExpr conditions
  • Integrate rules evaluation into Context._assign() — rules are checked after audience filter but before audienceStrict / traffic split
  • Add getEnvironment() to Client so the Context can access the environment name for rule filtering
  • Add targetingRule flag to exposure events for explicit identification in analytics
  • Validate rule.variant is a number; skip invalid rules instead of aborting iteration

Assignment Flags

Rule-matched assignments set assigned=true, eligible=true, overridden=true, targetingRule=true.

All possible assignment states

State assigned eligible overridden fullOn custom audienceMismatch targetingRule
Not running false true false false false false false
Override false true true false false false false
Audience strict mismatch false true false false false true false
Rule match true true true false false * true
Normal (eligible) true true false false false false false
Normal (not eligible) true false false false false false false
Custom assignment true true false false true false false
Full-on true true false true false false false

* = audienceMismatch is set independently before rules, can be either value.

Engine bitmask

targetingRule uses bit 8 (value 256). The existing filter bitAnd(flags, 207) = 3 has bit 8 as 0 in the mask, so it's automatically ignored — no engine query changes needed. The engine's Exposure.java and EventForwardingManager.java will need the new field when implementing server-side support.

Test Plan

  • 21 unit tests for evaluateRules in matcher
  • 16 integration tests in context (flags, cache invalidation, overrides, variables)
  • All 299 tests pass (29 suites)

Summary by CodeRabbit

  • New Features

    • Environment-scoped assignment rules can force variant selection; exposures and published payloads now include a ruleOverride flag and variables honour rule-selected variants.
  • Behaviour

    • Rules use first-match semantics and environment gating; malformed/invalid or out-of-range rule results fall back to normal assignment; explicit overrides take precedence; caching and assignment recompute when attributes or rule context change.
  • Tests

    • Expanded coverage for rule evaluation, environment gating, precedence, cache invalidation and many edge cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1fb232dd-5194-4979-bf5b-ea464d92b67f

📥 Commits

Reviewing files that changed from the base of the PR and between e3a1c05 and 6f3757f.

📒 Files selected for processing (2)
  • src/__tests__/context.test.js
  • src/context.ts
✅ Files skipped from review due to trivial changes (2)
  • src/context.ts
  • src/tests/context.test.js

Walkthrough

Adds rule-based assignment evaluation and environment-aware rule gating. AudienceMatcher gains evaluateRules(assignmentRulesString: string, environmentId: number | null, vars: Record<string, unknown>) returning a numeric variant or null. Context accepts ContextData.environment_id, stores it on _environmentId, and supports ExperimentData.assignmentRules plus per-assignment fields ruleVariant, ruleKey and ruleOverride. Assignments are created and re-evaluated using rules (including cache invalidation when attributes or ruleKey change), ruleOverride is included in exposure events and queued publishes, variable gating considers ruleOverride, and client.getEnvironment() was relocated within the class. Tests expanded to cover rule evaluation, environment scoping and exposure flags.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I nibble through JSON, hop rules in a row,
I check the env ID where the breezes blow,
If conditions sing true and the variant fits,
I mark a tiny override and tidy up the bits.
Tests clap their paws — a carrot for the show.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding custom assignment rules evaluation functionality. It accurately reflects the core feature introduced across the codebase.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/sdk-rules-implementation

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.42.1)
src/__tests__/context.test.js

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/__tests__/matcher.test.js (1)

214-218: Consider adding a test for rules with missing variant property.

The malformed rules tests cover invalid rules structure but don't test the case where a rule object lacks the variant property. This would help document the expected behaviour (and catch the issue flagged in matcher.ts).

📝 Proposed additional test
it("should return null when rule has no variant property", () => {
  const audience = JSON.stringify({
    rules: [
      {
        or: [
          {
            name: "rule1",
            and: [{ value: true }],
            environments: [],
            // variant is missing
          },
        ],
      },
    ],
  });
  expect(matcher.evaluateRules(audience, "production", {})).toBe(null);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/matcher.test.js` around lines 214 - 218, Add a test to
src/__tests__/matcher.test.js that asserts matcher.evaluateRules returns null
when a rule object is missing the required variant property: construct an
audience JSON string with rules containing an object that has
name/and/environments but no variant and call matcher.evaluateRules(audience,
"production", {}) expecting null; this will document the expected behaviour and
catch the case flagged in matcher.evaluateRules.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/matcher.ts`:
- Around line 36-45: The matcher currently returns rule.variant directly which
can be undefined if the rule object lacks a variant, letting callers treat it as
non-null; update the logic in the matching method (the block referencing rule,
rule.variant, conditions, and this._jsonExpr.evaluateBooleanExpr) to explicitly
validate the presence of a variant before returning it: when conditions are
absent or when evaluateBooleanExpr returns true, check that the rule has a
defined variant (e.g., using Object.prototype.hasOwnProperty or typeof
rule.variant !== 'undefined') and return that value only if present, otherwise
return null so callers (like context.ts which tests ruleVariant !== null) won't
receive undefined.

---

Nitpick comments:
In `@src/__tests__/matcher.test.js`:
- Around line 214-218: Add a test to src/__tests__/matcher.test.js that asserts
matcher.evaluateRules returns null when a rule object is missing the required
variant property: construct an audience JSON string with rules containing an
object that has name/and/environments but no variant and call
matcher.evaluateRules(audience, "production", {}) expecting null; this will
document the expected behaviour and catch the case flagged in
matcher.evaluateRules.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 517d7527-f95e-4572-89b4-24e573253ef8

📥 Commits

Reviewing files that changed from the base of the PR and between 47390ae and 5c2e5a8.

📒 Files selected for processing (5)
  • src/__tests__/context.test.js
  • src/__tests__/matcher.test.js
  • src/client.ts
  • src/context.ts
  • src/matcher.ts

Comment thread src/matcher.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/__tests__/matcher.test.js (1)

255-277: Consider adding a test with multiple objects in the rules array.

All current tests use a single object in the rules array with variation only in the or array. If the implementation iterates through multiple top-level rule groups in rules[], a test verifying that behaviour would improve coverage.

🧪 Example test case
it("should evaluate rules across multiple rule groups", () => {
    const audience = JSON.stringify({
        rules: [
            {
                or: [
                    {
                        name: "rule1",
                        and: [{ eq: [{ var: "country" }, { value: "GB" }] }],
                        environments: [],
                        variant: 1,
                    },
                ],
            },
            {
                or: [
                    {
                        name: "rule2",
                        and: [{ eq: [{ var: "country" }, { value: "US" }] }],
                        environments: [],
                        variant: 2,
                    },
                ],
            },
        ],
    });
    expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/matcher.test.js` around lines 255 - 277, Add a new unit test
that verifies matcher.evaluateRules correctly iterates multiple top-level rule
groups in the rules array: create an audience JSON with two objects in rules[]
(first containing a GB rule with variant 1, second containing a US rule with
variant 2), call matcher.evaluateRules(audience, "production", { country: "US"
}) and assert it returns 2; name the test something like "should evaluate rules
across multiple rule groups" so it complements the existing single-group tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/__tests__/matcher.test.js`:
- Around line 255-277: Add a new unit test that verifies matcher.evaluateRules
correctly iterates multiple top-level rule groups in the rules array: create an
audience JSON with two objects in rules[] (first containing a GB rule with
variant 1, second containing a US rule with variant 2), call
matcher.evaluateRules(audience, "production", { country: "US" }) and assert it
returns 2; name the test something like "should evaluate rules across multiple
rule groups" so it complements the existing single-group tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 979d44b4-6279-4763-9d8a-833b504314e8

📥 Commits

Reviewing files that changed from the base of the PR and between 5c2e5a8 and 003dada.

📒 Files selected for processing (3)
  • src/__tests__/context.test.js
  • src/__tests__/matcher.test.js
  • src/matcher.ts
✅ Files skipped from review due to trivial changes (1)
  • src/tests/context.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/matcher.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/context.ts`:
- Around line 550-553: The code in _assign sets assignment.assigned,
assignment.variant and incorrectly sets assignment.overridden when ruleVariant
!== null; remove or avoid setting assignment.overridden in the rule-match branch
so rule-derived assignments remain distinct from manual overrides. Update the
logic in _assign (the branch that checks ruleVariant) to only mark
assigned/variant for rule matches and leave overridden false (or introduce a
separate ruleMatched flag if needed), and ensure the existing manual override
fast-path is the only place that sets assignment.overridden to true so
override("...") will always take the explicit-override semantics.
- Around line 541-548: The cached assignment reuse logic only checks
assignment.audienceMismatch but not the actual rule-driven variant, so updates
to attributes used by rules can return a stale variant; compute the current
ruleVariant via _audienceMatcher.evaluateRules(experiment.data.audience,
this._environmentName, this._getAttributesMap()) (as already done) and compare
it to the cached assignment's rule-derived identity (e.g., a stored ruleVariant
id or serialized ruleVariant) and treat a mismatch as invalidation (in addition
to assignment.audienceMismatch), or store the ruleVariant id onto the assignment
when first created so subsequent reuse logic compares that id against the newly
computed ruleVariant and rejects reuse if they differ.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44c23f01-bff6-485e-ba9a-bb01d4572414

📥 Commits

Reviewing files that changed from the base of the PR and between 003dada and 76d1d6e.

📒 Files selected for processing (2)
  • src/__tests__/context.test.js
  • src/context.ts

Comment thread src/context.ts Outdated
Comment thread src/context.ts Outdated
@joalves joalves marked this pull request as draft March 23, 2026 05:48
@joalves joalves marked this pull request as ready for review March 23, 2026 08:17
@joalves
Copy link
Copy Markdown
Contributor Author

joalves commented Mar 23, 2026

Code review

Found 2 issues (both now fixed in f32df8e):

  1. return null on invalid variant aborts all rule iteration -- a matched rule with a non-numeric variant would return null, preventing subsequent valid rules from being evaluated. Changed to continue so the loop skips invalid rules and tries the next one.

continue;
}
}
const conditions = rule.and;
if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) {
return typeof rule.variant === "number" ? rule.variant : null;
}
if (Array.isArray(conditions)) {
const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars);

  1. audienceMatches does not persist ruleVariant back to the cached assignment -- after re-evaluating rules and confirming the variant hasn't changed, assignment.attrsSeq was updated but assignment.ruleVariant was not. This breaks the invariant that if attrsSeq is up-to-date then ruleVariant is up-to-date, causing unnecessary re-evaluations on subsequent attribute changes.

const ruleVariant = this._audienceMatcher.evaluateRules(
experiment.audience,
this._environmentName,
attrs
);
if (ruleVariant !== (assignment.ruleVariant ?? null)) {

Copy link
Copy Markdown
Contributor

@calthejuggler calthejuggler left a comment

Choose a reason for hiding this comment

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

Looking really good so far 🙂
Good to put it in a separate PR, but let's not forget to add the SDK version to the payload before we publish this.

Comment thread src/context.ts
Comment thread src/context.ts Outdated
Comment thread src/matcher.ts Outdated
Comment thread src/__tests__/context.test.js Outdated
Comment thread src/__tests__/context.test.js Outdated
Comment thread src/__tests__/context.test.js Outdated
Comment thread src/context.ts Outdated
Comment thread src/context.ts Outdated
Comment thread src/context.ts
Comment thread src/context.ts
@calthejuggler calthejuggler force-pushed the feature/sdk-rules-implementation branch from c6d8137 to 75540b6 Compare April 1, 2026 11:18
joalves and others added 15 commits April 7, 2026 13:01
Add support for targeting rules in the audience payload. Rules are
evaluated after audience filter but before audienceStrict/traffic split,
allowing forced variant assignment based on context attributes and
environment scoping.

- Add getEnvironment() to Client
- Add evaluateRules() to AudienceMatcher (parses rules[].or[] structure)
- Integrate rules into Context._assign() with custom=true flag
- Add comprehensive tests for matcher and context integration
- Guard against missing/non-number variant in evaluateRules returning
  undefined instead of null
- Switch context tests from exp_test_ab (2 variants, normal=1) to
  exp_test_abc (3 variants, normal=2) so rule variant (1) differs from
  normal assignment, making tests actually meaningful
- Add tests for missing variant and non-number variant properties
Rule-matched assignments should be excluded from statistical analysis,
same as overrides. The custom flag is for developer-driven randomization
which is still included in analysis.
Rules bypass the traffic split, so eligible must be explicitly set to
true (same as fullOn) rather than relying on the default value.
Add tests checking the complete flag combination for each scenario:
- Rule match: assigned=true, eligible=true, overridden=true
- No match (normal): assigned=true, eligible=true, overridden=false
- Rule match with audienceMismatch: overridden=true, audienceMismatch=true
- Override priority over rule: assigned=false, overridden=true
Re-evaluate rules in the audienceMatches cache check so that attribute
changes affecting rule conditions properly invalidate the cached
assignment. Store ruleVariant on the assignment for comparison.
When a rule caches an assignment with overridden=true and assigned=true,
a subsequent override() with the same variant would incorrectly reuse
the cached rule assignment (which has assigned=true). Add !assigned
check to the override cache path so it always recomputes, producing the
correct override flags (assigned=false, overridden=true).
- Log errors in evaluateRules catch block (matching evaluate() pattern)
- Extract _getAttributesMap() to avoid double call in assignment path
- Add test: multiple rule groups (second group matches when first doesn't)
- Add test: variableValue returns correct config for rule-assigned variant
- Add test: cache invalidation when rule switches between matching variants
Remove unnecessary optional chaining on sdk.getClient() to fail fast
consistently with the rest of the codebase. Document how rule-matched
assignments (assigned=true, overridden=true) are distinguished from SDK
overrides (assigned=false, overridden=true) in analytics.
Change from return null to continue when a matched rule has a
non-numeric variant, so subsequent valid rules are still evaluated.
Also persist ruleVariant in audienceMatches cache validation to maintain
cache consistency across attribute changes.
Add a dedicated targetingRule boolean flag (bit 8, value 256) to
exposure events so analytics can explicitly identify rule-forced
assignments without relying on the assigned+overridden combination.

- Rule match: targetingRule=true, overridden=true, assigned=true
- SDK override: targetingRule=false, overridden=true, assigned=false
- Normal assignment: targetingRule=false, overridden=false
…dback

- Rename targetingRule flag to ruleOverride per Cal's suggestion
- Reduce nesting in evaluateRules with early returns
- Remove unnecessary comment in test fixtures
- Add tests for multiple and conditions, multiple environments,
  and multiple or rules in context integration tests
Rule-forced users are not real experiment participants. Set
assigned=false and overridden=false, relying on ruleOverride=true to
identify them. The assigned=false already excludes them from the engine
filter (bitAnd(flags, 207) = 3 requires assigned=true).

Update variableValue/peekVariable to check ruleOverride alongside
assigned and overridden for returning variable configs.

Remove the !assignment.assigned override cache workaround since rules
no longer set overridden=true.
@calthejuggler calthejuggler force-pushed the feature/sdk-rules-implementation branch from 0ec80ce to ecc0e1f Compare April 7, 2026 12:01
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/context.ts (1)

24-35: ⚠️ Potential issue | 🟠 Major

Make assignment_rules optional on the exported payload type.

The runtime already treats this field as optional (if (experiment.data.assignment_rules && ...)), and multiple payloads in this PR omit it entirely. Because ExperimentData is exported, making a newly added wire field mandatory is a breaking type change for consumer fixtures and stubs.

💡 Suggested fix
 export type ExperimentData = {
 	id: number;
 	name: string;
 	unitType: string | null;
 	iteration: number;
 	fullOnVariant: number;
 	trafficSplit: number[];
 	trafficSeedHi: number;
 	trafficSeedLo: number;
 	audience: string;
-	assignment_rules: string;
+	assignment_rules?: string;
 	audienceStrict: boolean;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context.ts` around lines 24 - 35, The exported ExperimentData type
currently declares assignment_rules as required but runtime and fixtures treat
it as optional; update the ExperimentData definition to make assignment_rules
optional (e.g., change to assignment_rules?: string or string | undefined) so
consumers/stubs aren’t broken, and scan usages of ExperimentData (references to
ExperimentData and any code accessing experiment.data.assignment_rules) to
ensure they handle the possibly undefined value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/context.ts`:
- Around line 563-571: The value returned from evaluateRules() (assigned to
ruleVariant) must be canonicalised to null unless it is an integer within [0,
experiment.data.variants.length) before being used as an index or stored; update
the branch that sets
assignment.ruleVariant/assignment.variant/assignment.ruleOverride to first
validate ruleVariant (e.g., check Number.isInteger(ruleVariant) and 0 <=
ruleVariant < experiment.data.variants.length) and set it to null otherwise, and
apply the same canonicalisation when comparing against cached values (where
audienceMatches or cache comparison uses ruleVariant) so only valid integer
indices are treated as matches and invalid values become null.

---

Outside diff comments:
In `@src/context.ts`:
- Around line 24-35: The exported ExperimentData type currently declares
assignment_rules as required but runtime and fixtures treat it as optional;
update the ExperimentData definition to make assignment_rules optional (e.g.,
change to assignment_rules?: string or string | undefined) so consumers/stubs
aren’t broken, and scan usages of ExperimentData (references to ExperimentData
and any code accessing experiment.data.assignment_rules) to ensure they handle
the possibly undefined value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8ae62f71-e790-4aec-9196-4d4dc0f3af44

📥 Commits

Reviewing files that changed from the base of the PR and between 296806b and ecc0e1f.

📒 Files selected for processing (5)
  • src/__tests__/context.test.js
  • src/__tests__/matcher.test.js
  • src/client.ts
  • src/context.ts
  • src/matcher.ts
✅ Files skipped from review due to trivial changes (1)
  • src/client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/matcher.ts

Comment thread src/context.ts
Comment thread src/context.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/context.ts (1)

24-35: ⚠️ Potential issue | 🟠 Major

Do not make assignmentRules a required public input.

ExperimentData is exported and used by custom ContextDataProvider implementations. Requiring this field makes the upgrade source-incompatible for TypeScript users even though the runtime already treats a missing value as “no rules”. Please keep it optional, or default it during initialisation.

♻️ Proposed fix
 export type ExperimentData = {
 	id: number;
 	name: string;
 	unitType: string | null;
 	iteration: number;
 	fullOnVariant: number;
 	trafficSplit: number[];
 	trafficSeedHi: number;
 	trafficSeedLo: number;
 	audience: string;
-	assignmentRules: string;
+	assignmentRules?: string | null;
 	audienceStrict: boolean;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context.ts` around lines 24 - 35, The exported ExperimentData type
currently requires assignmentRules which breaks TypeScript compatibility for
custom ContextDataProvider implementations; change the ExperimentData
declaration so assignmentRules is optional (e.g., assignmentRules?: string) or
ensure the initialization code for ExperimentData (where ContextDataProvider
results are consumed) assigns a default (empty string or null) when
assignmentRules is missing; update any references that assume it exists to
handle undefined (e.g., checks in assignment/validation logic) so runtime
behavior remains “no rules” when the field is absent.
♻️ Duplicate comments (1)
src/context.ts (1)

472-493: ⚠️ Potential issue | 🟠 Major

Refresh-time rule changes can still reuse a stale assignment.

This cache check only re-evaluates when attributes change, and the rule comparison is skipped entirely once experiment.assignmentRules becomes empty. A refresh() that adds/removes rules or changes environment_id can therefore keep serving an old ruleVariant/ruleOverride indefinitely. Please treat rule config and environment as cache keys too, and explicitly invalidate when a cached rule result exists but the refreshed experiment no longer has rules.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context.ts` around lines 472 - 493, The current cache refresh only checks
attribute sequence (_attrsSeq vs assignment.attrsSeq) and skips rule comparison
when experiment.assignmentRules is empty, allowing stale
assignment.ruleVariant/ruleOverride to persist; update the logic in the block
that references _attrsSeq, experiment.assignmentRules,
_audienceMatcher.evaluateRules, assignment.ruleVariant, assignment.ruleOverride
and _environmentId so that rule configuration and environment are treated as
cache keys: compute/compare a ruleKey (derived from experiment.assignmentRules
content and _environmentId) against a stored assignment.ruleKey and if they
differ or if the refreshed experiment has no rules while assignment.ruleVariant
or assignment.ruleOverride exists, invalidate (return false) and clear
assignment.ruleVariant/ruleOverride/ruleKey before proceeding; ensure
assignment.ruleVariant is still set only when evaluateRules is called and
matches the new ruleKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/matcher.test.js`:
- Around line 385-399: The test title claims it covers fractional environment
IDs but it doesn't pass a fractional ID to AudienceMatcher.evaluateRules; update
the test in matcher.test.js to either (a) assert the fractional case by adding
expect(matcher.evaluateRules(audience, 1.5, {})).toBe(null) so it actually
exercises the fractional-ID path, or (b) change the spec string to reflect only
integer checks (rename the test). Refer to matcher.evaluateRules /
AudienceMatcher.evaluateRules and the environments array in the rule when making
this change.

---

Outside diff comments:
In `@src/context.ts`:
- Around line 24-35: The exported ExperimentData type currently requires
assignmentRules which breaks TypeScript compatibility for custom
ContextDataProvider implementations; change the ExperimentData declaration so
assignmentRules is optional (e.g., assignmentRules?: string) or ensure the
initialization code for ExperimentData (where ContextDataProvider results are
consumed) assigns a default (empty string or null) when assignmentRules is
missing; update any references that assume it exists to handle undefined (e.g.,
checks in assignment/validation logic) so runtime behavior remains “no rules”
when the field is absent.

---

Duplicate comments:
In `@src/context.ts`:
- Around line 472-493: The current cache refresh only checks attribute sequence
(_attrsSeq vs assignment.attrsSeq) and skips rule comparison when
experiment.assignmentRules is empty, allowing stale
assignment.ruleVariant/ruleOverride to persist; update the logic in the block
that references _attrsSeq, experiment.assignmentRules,
_audienceMatcher.evaluateRules, assignment.ruleVariant, assignment.ruleOverride
and _environmentId so that rule configuration and environment are treated as
cache keys: compute/compare a ruleKey (derived from experiment.assignmentRules
content and _environmentId) against a stored assignment.ruleKey and if they
differ or if the refreshed experiment has no rules while assignment.ruleVariant
or assignment.ruleOverride exists, invalidate (return false) and clear
assignment.ruleVariant/ruleOverride/ruleKey before proceeding; ensure
assignment.ruleVariant is still set only when evaluateRules is called and
matches the new ruleKey.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ca12f6af-ef09-4cbf-9c47-25db3e38ccb3

📥 Commits

Reviewing files that changed from the base of the PR and between 296806b and 0bbd5ea.

📒 Files selected for processing (5)
  • src/__tests__/context.test.js
  • src/__tests__/matcher.test.js
  • src/client.ts
  • src/context.ts
  • src/matcher.ts
✅ Files skipped from review due to trivial changes (1)
  • src/client.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/matcher.ts
  • src/tests/context.test.js

Comment thread src/__tests__/matcher.test.js Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/context.ts (1)

499-506: ⚠️ Potential issue | 🟠 Major

Reintroduced: normalise out-of-range ruleVariant values to null.

src/matcher.ts:20-68 only narrows rule results to “integer or null”; it does not bound them to variants.length. This path caches the raw integer in assignment.ruleVariant, but only treats it as a real rule match when it is in range. Two different invalid rule results therefore become two different cached states even though the effective assignment is the same fallback, which can invalidate the cache and duplicate exposures after attribute changes.

Suggested fix
+		const normaliseRuleVariant = (value: number | null, variantCount: number) =>
+			value !== null && value >= 0 && value < variantCount ? value : null;
+
 		const audienceMatches = (experiment: ExperimentData, assignment: Assignment) => {
 			const ruleKey = experiment.assignmentRules
 				? `${experiment.assignmentRules}:${this._environmentId}`
 				: "";
@@
 				if (experiment.assignmentRules && experiment.assignmentRules.length > 0) {
-					const ruleVariant = this._audienceMatcher.evaluateRules(experiment.assignmentRules, this._environmentId, attrs);
+					const ruleVariant = normaliseRuleVariant(
+						this._audienceMatcher.evaluateRules(experiment.assignmentRules, this._environmentId, attrs),
+						experiment.variants.length
+					);
 					if (ruleVariant !== (assignment.ruleVariant ?? null)) {
 						return false;
 					}
 
 					assignment.ruleVariant = ruleVariant;
@@
 				if (experiment.data.assignmentRules && experiment.data.assignmentRules.length > 0) {
-					ruleVariant = this._audienceMatcher.evaluateRules(experiment.data.assignmentRules, this._environmentId, attrs);
+					ruleVariant = normaliseRuleVariant(
+						this._audienceMatcher.evaluateRules(experiment.data.assignmentRules, this._environmentId, attrs),
+						experiment.data.variants.length
+					);
 				}
 
 				assignment.ruleVariant = ruleVariant;
@@
-				if (ruleVariant !== null && ruleVariant >= 0 && ruleVariant < experiment.data.variants.length) {
+				if (ruleVariant !== null) {
 					assignment.variant = ruleVariant;
 					assignment.ruleOverride = true;
 				} else if (experiment.data.audienceStrict && assignment.audienceMismatch) {

Also applies to: 583-590

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context.ts` around lines 499 - 506, The code stores raw integer rule
results into assignment.ruleVariant without bounding them to the experiment's
variants, causing distinct out-of-range integers to be cached differently;
update the logic in the block that calls this._audienceMatcher.evaluateRules
(and similarly in the other occurrence) to normalise any ruleVariant that is not
null and is outside the valid range (i.e., < 0 or >= experiment.variants.length)
to null before comparing to assignment.ruleVariant and before assigning
assignment.ruleVariant, so the cache only records either a valid variant index
or null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/context.ts`:
- Around line 499-506: The code stores raw integer rule results into
assignment.ruleVariant without bounding them to the experiment's variants,
causing distinct out-of-range integers to be cached differently; update the
logic in the block that calls this._audienceMatcher.evaluateRules (and similarly
in the other occurrence) to normalise any ruleVariant that is not null and is
outside the valid range (i.e., < 0 or >= experiment.variants.length) to null
before comparing to assignment.ruleVariant and before assigning
assignment.ruleVariant, so the cache only records either a valid variant index
or null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f432351-27b2-44ef-9d52-754ee6b24afc

📥 Commits

Reviewing files that changed from the base of the PR and between 0bbd5ea and 6d67092.

📒 Files selected for processing (2)
  • src/__tests__/matcher.test.js
  • src/context.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/tests/matcher.test.js

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/context.ts (1)

499-509: Extract the rule evaluation into one helper.

ruleKey construction and rawRuleVariant normalisation now live in both the cache-validation path and the fresh-assignment path. These two branches need to stay perfectly in sync for cache invalidation to remain correct, so a small shared helper would make future edits much safer.

Also applies to: 582-592

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context.ts` around lines 499 - 509, Extract the duplicated
rule-evaluation + normalization logic into a single helper (e.g., a private
method like computeRuleVariant) that takes (experiment, environmentId, attrs) or
the minimal inputs and internally calls this._audienceMatcher.evaluateRules and
normalizes rawRuleVariant into either a valid variant index or null; replace
both uses of the inline logic (the cache-validation branch that checks
assignment.ruleVariant and the fresh-assignment branch that sets
assignment.ruleVariant) to call this helper so both paths use the exact same
behavior (ensure you reference experiment.assignmentRules, this._environmentId,
attrs, evaluateRules result, and assignment.ruleVariant in the new helper).
src/__tests__/context.test.js (1)

2057-2736: Consider a small response-builder for the rules suite.

Most of these cases rebuild the same exp_test_abc fixture with tiny tweaks to audience, assignmentRules, or environment_id. A helper here would cut a lot of noise and make each scenario’s intent much easier to read.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/context.test.js` around lines 2057 - 2736, The tests repeatedly
reconstruct the same exp_test_abc experiment with small changes to audience,
assignmentRules, or environment_id; add a small response-builder helper (e.g.
makeRulesResponse or buildExperimentResponse) used by the "rules evaluation"
suite to produce variants of the base getContextResponse for exp_test_abc by
accepting overrides for audience, assignmentRules, and environment_id; update
each test to call that helper (referencing the existing symbols assignmentRules,
audience, envScopedRulesContextResponse, rulesContextResponse,
rulesStrictContextResponse, etc.) instead of manually mapping
getContextResponse.experiments each time to reduce duplication and improve
readability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/__tests__/context.test.js`:
- Around line 2057-2736: The tests repeatedly reconstruct the same exp_test_abc
experiment with small changes to audience, assignmentRules, or environment_id;
add a small response-builder helper (e.g. makeRulesResponse or
buildExperimentResponse) used by the "rules evaluation" suite to produce
variants of the base getContextResponse for exp_test_abc by accepting overrides
for audience, assignmentRules, and environment_id; update each test to call that
helper (referencing the existing symbols assignmentRules, audience,
envScopedRulesContextResponse, rulesContextResponse, rulesStrictContextResponse,
etc.) instead of manually mapping getContextResponse.experiments each time to
reduce duplication and improve readability.

In `@src/context.ts`:
- Around line 499-509: Extract the duplicated rule-evaluation + normalization
logic into a single helper (e.g., a private method like computeRuleVariant) that
takes (experiment, environmentId, attrs) or the minimal inputs and internally
calls this._audienceMatcher.evaluateRules and normalizes rawRuleVariant into
either a valid variant index or null; replace both uses of the inline logic (the
cache-validation branch that checks assignment.ruleVariant and the
fresh-assignment branch that sets assignment.ruleVariant) to call this helper so
both paths use the exact same behavior (ensure you reference
experiment.assignmentRules, this._environmentId, attrs, evaluateRules result,
and assignment.ruleVariant in the new helper).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: afcef707-87fa-4707-969e-95cf47eaedbb

📥 Commits

Reviewing files that changed from the base of the PR and between 6d67092 and e3a1c05.

📒 Files selected for processing (2)
  • src/__tests__/context.test.js
  • src/context.ts

@joalves joalves merged commit 0770641 into main Apr 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants