Skip to content

feat(python-sdk): add deterministic bucketing and rule evaluation core - #1

Merged
usmanabbas7 merged 1 commit into
mainfrom
codex/python-sdk-deterministic-core
Mar 26, 2026
Merged

feat(python-sdk): add deterministic bucketing and rule evaluation core#1
usmanabbas7 merged 1 commit into
mainfrom
codex/python-sdk-deterministic-core

Conversation

@usmanabbas7

Copy link
Copy Markdown
Collaborator

Summary

This PR creates the initial Python SDK implementation scaffold and ports the deterministic core behavior from the JavaScript SDK. The focus of this PR is parity-critical logic that must match the JS SDK before we move on to higher-level managers and the public SDK surface.

What’s Included

  • Added a standalone python-sdk package scaffold with packaging/dev setup
  • Implemented MurmurHash3-based hashing used for visitor bucketing
  • Implemented JS-equivalent numeric/string coercion helpers
  • Implemented comparison operators used by rule evaluation:
    • equals
    • equalsNumber
    • matches
    • less
    • lessEqual
    • contains
    • isIn
    • startsWith
    • endsWith
    • regexMatches
  • Implemented RuleManager with the same OR -> AND -> OR_WHEN traversal model as the JS SDK
  • Implemented BucketingManager with deterministic visitor-based bucket selection
  • Added unit tests for string utilities, comparisons, rule evaluation, and bucketing

Parity Work Completed

  • Verified the Python MurmurHash outputs against the npm murmurhash package used by the JS SDK
  • Added exact golden hash assertions for representative seed/input combinations
  • Normalized number handling so Python behavior matches JS where JS treats numeric values under a single number type

Verification

  • Installed the package in a local virtual environment
  • Ran the Python test suite successfully: 41 passed
  • Confirmed exact hash parity for sampled cases between Python and JS

Out of Scope For This PR

This PR intentionally does not include:

  • ConvertSDK / public SDK entrypoint
  • Context
  • ApiManager
  • config fetching / refresh
  • tracking queue / event sending
  • DataManager
  • SegmentsManager
  • ExperienceManager
  • FeatureManager
  • DataStore integration

Those will follow in the next PRs once the deterministic core is locked down.

Why This Order

Bucketing and rule evaluation are the highest-risk areas for parity. Getting these right first ensures that later layers are built on top of behavior that already matches the JavaScript SDK.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request lays the foundational groundwork for the Python SDK by porting the critical deterministic core logic, specifically focusing on visitor bucketing and rule evaluation, directly from the existing JavaScript SDK. The primary goal of this initial phase is to achieve exact behavioral parity in these high-risk areas, ensuring a consistent and reliable base. Higher-level features, such as public SDK entry points, context management, API interactions, and data handling, are intentionally deferred to subsequent pull requests, allowing for a focused and robust implementation of the core decision-making mechanisms.

Highlights

  • Python SDK Scaffold: Added a standalone python-sdk package scaffold, including basic packaging and development setup.
  • Deterministic Bucketing: Implemented MurmurHash3-based hashing for visitor bucketing, ensuring parity with the JavaScript SDK.
  • Rule Evaluation Core: Implemented a RuleManager with comparison operators (e.g., equals, less, contains, regexMatches) and the OR -> AND -> OR_WHEN traversal model, mirroring the JS SDK's behavior.
  • Numeric and String Coercion: Introduced JS-equivalent numeric/string coercion helpers and normalized number handling to match JavaScript's single number type behavior.
  • Comprehensive Unit Tests: Included extensive unit tests for string utilities, comparison logic, rule evaluation, and bucketing to validate parity and correctness.
  • Parity Verification: Verified Python MurmurHash outputs against the npm murmurhash package used by the JS SDK and added golden hash assertions.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@usmanabbas7 usmanabbas7 self-assigned this Mar 25, 2026
@usmanabbas7
usmanabbas7 requested a review from abbaseya March 25, 2026 20:57

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request establishes the foundational Python SDK for Convert, implementing core functionalities such as deterministic MurmurHash3-based bucketing and a robust rule management system with various comparison methods. The changes include comprehensive project setup, modular SDK structure, and initial unit tests to ensure parity with existing JavaScript behavior. The review identified several areas for optimization and code quality improvement, including enhancing the efficiency of float-to-integer truncation and list/set lookups in utility functions, caching method lists in the rule manager, addressing a typo in an enum, and refactoring a duplicated dataclass to adhere to the DRY principle.

experience_id = str(options.get("experienceId", ""))
hash_value = generate_hash(experience_id + str(visitor_id), seed)
value = (hash_value / DEFAULT_MAX_HASH) * self._max_traffic
return int(str(value).split(".", 1)[0])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This method of truncating a float to an integer is inefficient and not idiomatic Python. It involves converting the number to a string, splitting it, and then converting it back to an integer. A direct conversion using int() is much cleaner and more performant.

Suggested change
return int(str(value).split(".", 1)[0])
return int(value)



class BucketingError(str, Enum):
VARIAION_NOT_DECIDED = "convert.com_variation_not_decided"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is a typo in the enum member name VARIAION_NOT_DECIDED. It should be VARIATION_NOT_DECIDED.

Suggested change
VARIAION_NOT_DECIDED = "convert.com_variation_not_decided"
VARIATION_NOT_DECIDED = "convert.com_variation_not_decided"

Comment on lines +34 to +39
def get_comparison_processor_methods(self) -> list[str]:
return [
name
for name, value in self._comparison_processor.items()
if callable(value)
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This method recalculates the list of method names on every call. Since it's used inside loops during rule processing (_process_rule_item), this can be inefficient. It's better to cache this list and invalidate the cache only when the comparison_processor is updated.

To implement this, you'll need to:

  1. Add self._comparison_methods_cache: list[str] | None = None to __init__.
  2. Invalidate the cache in the comparison_processor setter by setting self._comparison_methods_cache = None.
  3. Update this method to use the cache.
Suggested change
def get_comparison_processor_methods(self) -> list[str]:
return [
name
for name, value in self._comparison_processor.items()
if callable(value)
]
def get_comparison_processor_methods(self) -> list[str]:
if self._comparison_methods_cache is None:
self._comparison_methods_cache = [
name
for name, value in self._comparison_processor.items()
if callable(value)
]
return self._comparison_methods_cache

Comment on lines +17 to +20
@dataclass(frozen=True)
class BucketingAllocationType:
variation_id: str
bucketing_allocation: int

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The BucketingAllocationType dataclass appears to be a duplicate of BucketingAllocation defined in src/convertcom_sdk/bucketing/bucketing_manager.py. To adhere to the DRY (Don't Repeat Yourself) principle and improve maintainability, this type should be defined in a single location (e.g., here in types.py) and imported wherever it's needed.

Comment on lines +32 to +36
if object_not_empty(value):
return _apply_negation(
str(test_against) in [str(key) for key in value.keys()],
negation,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Creating a new list of all keys converted to strings for every check ([str(key) for key in value.keys()]) can be inefficient for large dictionaries. Using a generator expression with any() is more memory-efficient as it avoids creating the full list in memory.

Suggested change
if object_not_empty(value):
return _apply_negation(
str(test_against) in [str(key) for key in value.keys()],
negation,
)
if object_not_empty(value):
return _apply_negation(
any(str(test_against) == str(key) for key in value),
negation,
)

Comment on lines +82 to +99
def is_in(
values: str | int | float,
test_against: list[str | int | float] | str,
negation: bool = False,
splitter: str = "|",
) -> bool:
matched_values = [str(item) for item in str(values).split(splitter)]
if isinstance(test_against, str):
candidates: list[Any] = test_against.split(splitter)
elif isinstance(test_against, list):
candidates = test_against
else:
candidates = []
normalized_candidates = [str(item).lower() for item in candidates]
for item in matched_values:
if item in normalized_candidates:
return _apply_negation(True, negation)
return _apply_negation(False, negation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The check if item in normalized_candidates: is performed inside a loop. Since normalized_candidates is a list, this check has a time complexity of O(N), where N is the number of candidates. For a large number of candidates, this can be inefficient. Converting normalized_candidates to a set would make the lookups average O(1) time complexity.

Suggested change
def is_in(
values: str | int | float,
test_against: list[str | int | float] | str,
negation: bool = False,
splitter: str = "|",
) -> bool:
matched_values = [str(item) for item in str(values).split(splitter)]
if isinstance(test_against, str):
candidates: list[Any] = test_against.split(splitter)
elif isinstance(test_against, list):
candidates = test_against
else:
candidates = []
normalized_candidates = [str(item).lower() for item in candidates]
for item in matched_values:
if item in normalized_candidates:
return _apply_negation(True, negation)
return _apply_negation(False, negation)
def is_in(
values: str | int | float,
test_against: list[str | int | float] | str,
negation: bool = False,
splitter: str = "|",
) -> bool:
matched_values = (str(item) for item in str(values).split(splitter))
if isinstance(test_against, str):
candidates: list[Any] = test_against.split(splitter)
elif isinstance(test_against, list):
candidates = test_against
else:
candidates = []
normalized_candidates = {str(item).lower() for item in candidates}
for item in matched_values:
if item in normalized_candidates:
return _apply_negation(True, negation)
return _apply_negation(False, negation)

@usmanabbas7
usmanabbas7 merged commit b777dbd into main Mar 26, 2026
@abbaseya
abbaseya deleted the codex/python-sdk-deterministic-core branch June 18, 2026 16:37
abbaseya added a commit that referenced this pull request Aug 8, 2026
All eight open Dependabot alerts on this repo sit in `yarn.lock` — the
dev-only semantic-release tooling. None of them reach the published
wheel/sdist (httpx is still the only runtime dependency).

Three of the four highs were already reachable inside the existing semver
ranges; the fourth (sigstore GHSA-52v5-jr5w-gjxr, `certificateOIDs`
verification constraints silently dropped) needed sigstore >= 4.1.1, which
only arrives through a major bump:

  semantic-release 24.2.9 -> 25.0.9
    @semantic-release/npm 12.0.2 -> 13.1.5
      npm 10.9.8 -> 11.19.0
        libnpmpublish 10 -> 11.2.0  }-> sigstore ^3 -> ^4  (4.1.1)
        pacote        19 -> 21.5.1  }   @sigstore/core 2.0.0 -> 3.2.1
        tar 7.5.16 -> 7.5.22

`@semantic-release/github` moves to ^12 to match what semantic-release 25
depends on — leaving it at ^11 would hoist the older copy to the project
root and shadow the one core resolves.

Resolved (4 high, 4 medium — no criticals were open):

  #14 high    ip-address       10.2.0 -> 10.4.0   (needs >= 10.3.1)
  #9  high    brace-expansion  2.1.1  -> 5.0.9    (needs >= 2.1.2)
  #7  high    js-yaml          4.2.0  -> 4.3.1    (needs >= 4.3.0)
  #2  high    sigstore         3.1.0  -> 4.1.1    (needs >= 4.1.1)
  #13 medium  ip-address       (same bump as #14)
  #12 medium  ip-address       (same bump as #14)
  #4  medium  tar              7.5.16 -> 7.5.22   (needs >= 7.5.18)
  #1  medium  @sigstore/core   2.0.0  -> 3.2.1    (needs >= 3.2.1)

Node: semantic-release 25 requires ^22.14.0 || >= 24.10.0. release.yml
installs `lts/*`, currently Node 24.19.0 — satisfied, and every future LTS
line stays above the floor.

Verified: `yarn install --immutable` (what release.yml runs) passes against
the regenerated lockfile with the lockfile format unchanged (__metadata
version 10), and `yarn npm audit --all --recursive` reports no suggestions.
A `semantic-release --dry-run` against this branch loads all four configured
plugins, passes verifyConditions for both exec and github (GitHub
authentication + push permission), and analyzes commits to "no release" —
correct, since `chore` is a non-releasing type in release.config.mjs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant