Skip to content

epic-1/story-4: Run Local Experience Evaluations - #26

Closed
usmanabbas7 wants to merge 9 commits into
epic-1/story-2-support-sdkkey-and-direct-config-initializationfrom
epic-1/story-4-run-local-experience-evaluations
Closed

epic-1/story-4: Run Local Experience Evaluations#26
usmanabbas7 wants to merge 9 commits into
epic-1/story-2-support-sdkkey-and-direct-config-initializationfrom
epic-1/story-4-run-local-experience-evaluations

Conversation

@usmanabbas7

Copy link
Copy Markdown
Collaborator

Summary

Implements BMAD story 1.4 (1-4-run-local-experience-evaluations) for the Convert Python SDK — local, network-free experience evaluation against the immutable config snapshot.

  • Pure-Python MurmurHash3-32 bucketing (evaluation/bucketing.py): murmurhash3_32(value, seed=9999) returning unsigned 32-bit, byte-exact with the npm murmurhash v3 output the JS SDK uses (audit finding F-009 — no mmh3/third-party hashing dep; httpx remains the only runtime dependency). Parity verified against 72 JS-derived vectors in tests/parity/fixtures/bucketing_vectors.json (generated via scripts/generate_bucketing_vectors.js).
  • Audience/location rule evaluation (evaluation/rules.py): OR/AND/OR_WHEN rule tree + JS comparison operators for current FullStack config shapes.
  • Typed results + experience selection (domain/results.py, evaluation/experiences.py): typed variation results; normal misses return None/empty collections, never exceptions or raw dicts.
  • Visitor-scoped Context (context.py, core.create_context): run_experience() / run_experiences() with request-time attribute overlays that never mutate stored visitor state. (Story 1.3's Context was absent on this branch lineage — minimal foundation built here, logged in conductor audit trail.)

Tests: 170 passing (53 baseline + 117 new). Wheel builds clean.

Traceability

Review notes

  • Code review: clean round 1, no warnings.
  • Readiness gate scored 8.5/10 (PASS); 4 ambiguities auto-delegated in sprint mode (murmurhash byte semantics → JS UTF-16 code-unit ground truth; parity fixture sourcing → machine-generated with metadata; missing Story 1.3 Context → minimal foundation built; MVP rule subset). See readiness-assessment.md in the conductor work dir.

🤖 Generated with Claude Code

usmanabbas7 and others added 8 commits June 6, 2026 20:16
Beads: ai-driven-product-dev-lm55

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure-Python murmurhash3_32 (seed 9999, unsigned 32-bit) byte-exact with npm
murmurhash v3 (charCodeAt&0xff UTF-16 semantics). 72 JS-derived parity vectors.

Beads: ai-driven-product-dev-lm55

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-scbd

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
is_rule_matched (OR/AND/OR_WHEN tree + JS comparison operators) and qualifies()
(matchRulesByField MVP policy). Added audience id/key indexes to ConfigSnapshot.

Beads: ai-driven-product-dev-scbd

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-ranl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation (GREEN)

ExperienceResult frozen dataclass (read-only variation payload) and
select_experience (qualify -> build buckets -> deterministic select),
mirroring JS _retrieveBucketing minus storage/tracking.

Beads: ai-driven-product-dev-ranl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-84o7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EEN)

Minimal visitor-scoped Context (immutable stored attributes, ephemeral
request-time overlay), Core.create_context, run_experience/run_experiences
(local-only, no network/tracking). ExperienceResult exported additively.

Beads: ai-driven-product-dev-84o7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@usmanabbas7 usmanabbas7 self-assigned this Jun 7, 2026
@usmanabbas7
usmanabbas7 requested a review from clllaur June 7, 2026 08:55

@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 implements local experience evaluation (Story 1.4) for the Convert Python SDK, introducing visitor-scoped evaluation contexts, a pure-Python MurmurHash3-32 implementation with JS parity, and nested rule qualification. The reviewer's feedback focuses on enhancing robustness and defensive programming, specifically by ensuring consistent case-insensitivity in comparisons, wrapping comparison operators in try-except blocks to prevent TypeErrors, and adding type guards to handle potentially malformed configurations safely.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +54 to +59
def _equals(value: Any, test_against: Any) -> bool:
if isinstance(value, (list, tuple)):
return test_against in value
if isinstance(value, Mapping):
return str(test_against) in {str(k) for k in value.keys()}
return str(value).lower() == str(test_against).lower()

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 _equals operator is case-insensitive for scalar string comparisons, but performs case-sensitive comparisons when checking elements within a list/tuple or keys within a mapping. To ensure consistent evaluation behavior, case-insensitivity should be applied uniformly across all supported data structures.

def _equals(value: Any, test_against: Any) -> bool:\n    if isinstance(value, (list, tuple)):\n        return str(test_against).lower() in {str(item).lower() for item in value}\n    if isinstance(value, Mapping):\n        return str(test_against).lower() in {str(k).lower() for k in value.keys()}\n    return str(value).lower() == str(test_against).lower()
References
  1. Ensure consistent behavior and case-insensitivity across all data structures during comparison operations.

Comment on lines +78 to +83
def _less(value: Any, test_against: Any) -> bool:
v = _to_number(value) if _is_numeric(value) else value
t = _to_number(test_against) if _is_numeric(test_against) else test_against
if type(v) is not type(t):
return False
return v < t

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

Comparing non-comparable types of the same class (such as None < None or dictionary comparisons) will raise a TypeError in Python 3. To fulfill the contract of never raising exceptions during rule evaluation, wrap the comparison in a try...except block to safely return False on incompatible types.

def _less(value: Any, test_against: Any) -> bool:\n    try:\n        v = _to_number(value) if _is_numeric(value) else value\n        t = _to_number(test_against) if _is_numeric(test_against) else test_against\n        if type(v) is not type(t):\n            return False\n        return v < t\n    except (TypeError, ValueError):\n        return False
References
  1. Defensive programming: wrap operations that can raise TypeError or ValueError in try-except blocks to prevent runtime crashes.

Comment on lines +86 to +91
def _less_equal(value: Any, test_against: Any) -> bool:
v = _to_number(value) if _is_numeric(value) else value
t = _to_number(test_against) if _is_numeric(test_against) else test_against
if type(v) is not type(t):
return False
return v <= t

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

Comparing non-comparable types of the same class (such as None <= None or dictionary comparisons) will raise a TypeError in Python 3. Wrap the comparison in a try...except block to safely return False on incompatible types.

def _less_equal(value: Any, test_against: Any) -> bool:\n    try:\n        v = _to_number(value) if _is_numeric(value) else value\n        t = _to_number(test_against) if _is_numeric(test_against) else test_against\n        if type(v) is not type(t):\n            return False\n        return v <= t\n    except (TypeError, ValueError):\n        return False
References
  1. Defensive programming: wrap operations that can raise TypeError or ValueError in try-except blocks to prevent runtime crashes.

Comment on lines +121 to +123
def _process_rule_item(data: Optional[Mapping[str, Any]], rule: Mapping[str, Any]) -> bool:
"""Evaluate a single rule item against a key-value ``data`` mapping."""
matching = rule.get("matching") or {}

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

If the rule parameter is malformed and passed as a non-mapping type (e.g., a list or None), calling rule.get will raise an AttributeError. Adding an explicit isinstance check ensures robust handling of malformed configurations.

def _process_rule_item(data: Optional[Mapping[str, Any]], rule: Mapping[str, Any]) -> bool:\n    \"\"\"Evaluate a single rule item against a key-value ``data`` mapping.\"\"\"\n    if not isinstance(rule, Mapping):\n        return False\n    matching = rule.get(\"matching\") or {}
References
  1. Defensive programming: ensure appropriate guards exist before object property accesses or operations on potentially nullable/malformed inputs to prevent runtime crashes.

Comment on lines +150 to +152
def _process_or_when(data: Optional[Mapping[str, Any]], rules_subset: Mapping[str, Any]) -> bool:
"""OR_WHEN: any rule item true -> true."""
items: Sequence[Mapping[str, Any]] = rules_subset.get("OR_WHEN") or []

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

If rules_subset is malformed and passed as a non-mapping type, calling rules_subset.get will raise an AttributeError. Adding an explicit isinstance check ensures robust handling of malformed configurations.

def _process_or_when(data: Optional[Mapping[str, Any]], rules_subset: Mapping[str, Any]) -> bool:\n    \"\"\"OR_WHEN: any rule item true -> true.\"\"\"\n    if not isinstance(rules_subset, Mapping):\n        return False\n    items: Sequence[Mapping[str, Any]] = rules_subset.get(\"OR_WHEN\") or []
References
  1. Defensive programming: ensure appropriate guards exist before object property accesses or operations on potentially nullable/malformed inputs to prevent runtime crashes.

Comment on lines +161 to +163
def _process_and(data: Optional[Mapping[str, Any]], rules_subset: Mapping[str, Any]) -> bool:
"""AND: every OR_WHEN block must be true."""
blocks: Sequence[Mapping[str, Any]] = rules_subset.get("AND") or []

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

If rules_subset is malformed and passed as a non-mapping type, calling rules_subset.get will raise an AttributeError. Adding an explicit isinstance check ensures robust handling of malformed configurations.

def _process_and(data: Optional[Mapping[str, Any]], rules_subset: Mapping[str, Any]) -> bool:\n    \"\"\"AND: every OR_WHEN block must be true.\"\"\"\n    if not isinstance(rules_subset, Mapping):\n        return False\n    blocks: Sequence[Mapping[str, Any]] = rules_subset.get(\"AND\") or []
References
  1. Defensive programming: ensure appropriate guards exist before object property accesses or operations on potentially nullable/malformed inputs to prevent runtime crashes.

Comment on lines +60 to +61
for variation in experience.get("variations", []) or []:
if not _is_running(variation):

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

If the variations list contains malformed non-mapping elements, calling _is_running(variation) will raise an AttributeError. Adding an explicit isinstance check ensures robust handling of malformed configurations.

    for variation in experience.get(\"variations\", []) or []:\n        if not isinstance(variation, Mapping):\n            continue\n        if not _is_running(variation):
References
  1. Defensive programming: ensure appropriate guards exist before object property accesses or operations on potentially nullable/malformed inputs to prevent runtime crashes.

Comment on lines +141 to +142
for variation in experience.get("variations", []) or []:
if str(variation.get("id")) == str(variation_id):

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

If the variations list contains malformed non-mapping elements, calling variation.get will raise an AttributeError. Adding an explicit isinstance check ensures robust handling of malformed configurations.

    for variation in experience.get(\"variations\", []) or []:\n        if not isinstance(variation, Mapping):\n            continue\n        if str(variation.get(\"id\")) == str(variation_id):
References
  1. Defensive programming: ensure appropriate guards exist before object property accesses or operations on potentially nullable/malformed inputs to prevent runtime crashes.

…P parity — F-063

The pure-Python MurmurHash3-32 hashed UTF-16 code units (charCodeAt & 0xff) and
mixed in the code-unit count, diverging from the JS SDK (npm `murmurhash` v3 →
`new TextEncoder().encode()`, UTF-8) and PHP (`unpack('C*')`, UTF-8) for every
non-ASCII visitor id — so non-ASCII visitors bucketed to a DIFFERENT variation
in Python than in JS/PHP.

- bucketing.py: hash value.encode("utf-8"); mix in UTF-8 byte length; delete
  _utf16_code_units and the false "UTF-16 byte-exact" docstring claim.
- scripts/generate_bucketing_vectors.js: replace the co-buggy charCodeAt hand
  port with the REAL npm `murmurhash` package as the oracle; add café/🎯emoji
  hardening inputs; fix the false UTF-16 provenance metadata.
- tests/parity/fixtures/bucketing_vectors.json: regenerated from the real npm
  oracle (用户123@9999 531569922 → 3859151469; +8 multi-byte/astral vectors).
- tests/test_bucketing.py: correct the co-buggy 用户123 unit assertion to the
  real-oracle value.

Verified: murmurhash3_32("用户123",9999)==3859151469 (real npm) and would fail
the old UTF-16 531569922. Parity suite 82 passed; full suite 178 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@abbaseya

Copy link
Copy Markdown
Collaborator

Superseded — all commits already in main (bc76b64). Closing without merge as part of post-sprint cleanup.

@abbaseya abbaseya closed this Jun 18, 2026
@abbaseya
abbaseya deleted the epic-1/story-4-run-local-experience-evaluations branch June 18, 2026 16:31
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