From 5d165f38c0fcf69809b6438976adec94fe75b20d Mon Sep 17 00:00:00 2001 From: Ahmed Abbas Date: Sat, 4 Jul 2026 04:45:57 +0300 Subject: [PATCH 1/5] wf(wf-androidsdk-qs01): [AND-1] import cross-sdk golden-vector fixture verbatim Beads: ai-driven-product-dev-jiwi Copied byte-for-byte from javascript-sdk origin/feat/anchored-bucketing-layout. sha256 9993d0e6c5719cb17a4dcbcc73af9c0cb14ee0258e7fd29cc88f44b61083dd1b, 59 vectors {11:19,12:40}. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cross-sdk-bucketing-vectors.json | 701 ++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 packages/core/src/test/resources/cross-sdk-bucketing-vectors.json diff --git a/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json b/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json new file mode 100644 index 000000000..d1015509f --- /dev/null +++ b/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +] From 9d6bfaaef2a52f3a3d6039050d164c5e0ea47b16 Mon Sep 17 00:00:00 2001 From: Ahmed Abbas Date: Sat, 4 Jul 2026 05:09:17 +0300 Subject: [PATCH 2/5] wf(wf-androidsdk-qs01): [AND-2] anchored bucketing tests + stubs (RED) Beads: ai-driven-product-dev-fo5c Phase 1 TDD RED. New: BucketingLayoutResolver (gate + allocation builders + resolveVariationId seam), VariationAllocation model, anchored selector stubs in BucketingManager, table-driven golden-vector parity test (59 vectors) + AC1-AC9 acceptance tests. 21 new tests fail against stubs (12 parity discriminating v12 + 9 acceptance); packed path + HashParityTest (69) green, zero regressions; module compiles; detekt clean. ConvertContext not yet wired (Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/bucketing/BucketingLayoutResolver.kt | 149 ++++++++++++ .../sdk/core/bucketing/BucketingManager.kt | 112 +++++++++ .../sdk/core/model/VariationAllocation.kt | 40 +++ .../AnchoredBucketingAcceptanceTest.kt | 229 ++++++++++++++++++ .../bucketing/AnchoredBucketingParityTest.kt | 164 +++++++++++++ 5 files changed, 694 insertions(+) create mode 100644 packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt create mode 100644 packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt create mode 100644 packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt create mode 100644 packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt new file mode 100644 index 000000000..be3ff7c9a --- /dev/null +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt @@ -0,0 +1,149 @@ +/* + * Convert Android SDK — core/bucketing + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.bucketing + +import com.convert.sdk.core.model.BucketingAllocation +import com.convert.sdk.core.model.VariationAllocation +import com.convert.sdk.core.model.generated.ExperienceVariationConfig +import com.convert.sdk.core.model.generated.VariationStatuses +import java.math.BigDecimal + +/** + * Anchored-vs-packed layout resolution — qs-01 / contract v12. + * + * This file is the single testable seam through which BOTH the golden-vector + * parity suite AND [com.convert.sdk.android.ConvertContext] (Phase 2) decide + * which bucketing layout an experience uses and build that layout's inputs. + * Housing the gate + allocation builders in `:packages:core` (rather than + * only in the Android-only `:packages:sdk` module, as the original grounded + * design sketched) lets the golden-vector parity test — which lives beside + * the fixture in `:packages:core` — exercise the FULL version-gated decision + * without standing up a Robolectric `ConvertSDK` harness. See the qs-01 + * decision log for the full rationale. + * + * Mirrors the JS SDK's `DataManager` fresh-bucketing branch + * (`packages/data/src/data-manager.ts`): `_retrieveBucketing`'s + * `isAnchoredLayout` gate, `_buildVariationAllocations`, and + * `_buildPackedBuckets`. + */ + +/** JS-parity default weight for a variation with no `traffic_allocation` set (`isNaN(ta) -> 100`). */ +private const val DEFAULT_VARIATION_PCT: Double = 100.0 + +/** + * Anchored-vs-packed GATE — qs-01 / contract v12 AC1. `version > 11` runs + * the anchored layout; `version <= 11`, missing, or non-numeric keeps the + * packed cumulative walk. Uses [BigDecimal.compareTo] (via the `>` + * operator), never `equals` — `BigDecimal("11.0") != BigDecimal("11")` under + * `equals`, but both must compare `<= 11` here. + * + * ### Phase 1 (RED) stub + * + * TODO(qs-01 Phase 2): always returns `false` (packed), ignoring [version] + * entirely. Real gate: `version != null && version > BigDecimal("11")`. + * + * @param version the experience's `version` field, already coerced from the + * wire's numeric-or-numeric-string form by [com.convert.sdk.core.internal.BigDecimalSerializer]. + * @return `true` iff the anchored layout (contract v12) should run. + */ +internal fun isAnchoredLayout(version: BigDecimal?): Boolean { + // Phase 1 (RED) stub — TODO(qs-01 Phase 2): replace with + // `version != null && version > BigDecimal("11")`. + return version == null && false +} + +/** + * Builds the ordered [VariationAllocation] list the anchored layout + * consumes — qs-01 / contract v12 AC5. Mirrors the JS SDK's + * `_buildVariationAllocations`: null-id entries are dropped (JS: + * `if (!variation?.id) return allocations;`), remaining entries keep config + * order, `allocation` defaults absent/non-numeric `traffic_allocation` to + * `100.0`, and `active` is `false` for a non-`RUNNING` status OR an explicit + * zero allocation (AC4 — never defaults a stopped/zero arm back to 100%). + * + * ### Phase 1 (RED) stub + * + * TODO(qs-01 Phase 2): always returns an empty list, ignoring [variations]. + * + * @param variations the experience's variations, in declaration order. + * @return the anchored allocation inputs, in the same order as [variations] + * (minus null-id entries). + */ +internal fun buildVariationAllocations( + variations: List?, +): List { + // References `variations` so the Phase-1 stub still type-checks the real + // Phase-2 body's shape without executing it. + variations?.size + return emptyList() +} + +/** + * Builds the `variationId -> percentage` map for the packed layout — the + * frozen `version <= 11` path (AC6). This is a straight, unmodified port of + * [com.convert.sdk.android.ConvertContext]'s existing `buildBuckets` filter + * chain (itself a mirror of the JS SDK's `_buildPackedBuckets`), relocated + * here so [resolveVariationId] has a single packed-vs-anchored branch point + * that both the parity test and (Phase 2) `ConvertContext` share. Not a + * Phase-1 stub: this is already-shipped, already-tested behaviour being + * moved, not new logic. + * + * @param variations the experience's variations, in declaration order. + * @return ordered map of eligible variation id to traffic percentage. + */ +internal fun buildPackedBuckets(variations: List?): Map = + variations + ?.asSequence() + ?.filter { it.id != null } + ?.filter { it.status == null || it.status == VariationStatuses.RUNNING } + ?.map { it to (it.trafficAllocation?.toDouble() ?: DEFAULT_VARIATION_PCT) } + ?.filter { (_, allocation) -> allocation > 0.0 } + ?.associateByTo( + destination = linkedMapOf(), + keySelector = { (variation, _) -> variation.id!! }, + valueTransform = { (_, allocation) -> allocation }, + ) + ?: emptyMap() + +/** + * The single version-gated bucketing decision — qs-01 / contract v12. Both + * the golden-vector parity test (`AnchoredBucketingParityTest`) and, from + * Phase 2 onward, [com.convert.sdk.android.ConvertContext.allocateAndRecord] + * call this one function so there is never a divergent duplicate of the + * gate + allocation-building logic. + * + * @param version the experience's `version` field (see [isAnchoredLayout]). + * @param variations the experience's variations, in declaration order. + * @param visitorId the visitor's opaque stable identifier. + * @param experienceId the experience's stable identifier — empty when + * `excludeExperienceIdHash` is set. + * @return a [BucketingAllocation] on success, or `null` when the visitor is + * not bucketed into any variation. + */ +public fun BucketingManager.resolveVariationId( + version: BigDecimal?, + variations: List?, + visitorId: String, + experienceId: String = "", +): BucketingAllocation? = + if (isAnchoredLayout(version)) { + getBucketForVisitorAnchored( + allocations = buildVariationAllocations(variations), + visitorId = visitorId, + experienceId = experienceId, + ) + } else { + val buckets = buildPackedBuckets(variations) + if (buckets.isEmpty()) { + null + } else { + getBucketForVisitor( + buckets = buckets, + visitorId = visitorId, + experienceId = experienceId, + ) + } + } diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt index a149603b1..781e68454 100644 --- a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt @@ -8,9 +8,32 @@ package com.convert.sdk.core.bucketing import com.convert.sdk.core.config.ConfigDefaults import com.convert.sdk.core.config.ConvertConfig import com.convert.sdk.core.model.BucketingAllocation +import com.convert.sdk.core.model.VariationAllocation import com.convert.sdk.core.port.Logger import com.goncalossilva.murmurhash.MurmurHash3 +/** + * A precomputed anchored bucket range for a single variation — qs-01 / + * contract v12. `anchor` and `anchor + width` bound the half-open interval + * `[anchor, anchor + width)` (per-10000 traffic space) that maps to [id] + * in the anchored layout. Mirrors the JS SDK's `BucketAnchoredRange` + * (`packages/bucketing/src/interfaces/bucketing-manager.ts`). + * + * @property id the variation id this range resolves to. + * @property anchor the range's lower (inclusive) bound, in `0..10000` + * traffic-space units. Computed from the entry's position in the + * cumulative weight walk — NOT from its own allocation — which is what + * gives the layout its "raise is a superset" property (AC2). + * @property width the range's span. `entry.allocation * 100` when + * [com.convert.sdk.core.model.VariationAllocation.active] is `true`, + * else `0.0` (AC4). + */ +public data class BucketAnchoredRange( + public val id: String, + public val anchor: Double, + public val width: Double, +) + /** * Deterministic bucketing engine that hashes visitor + experience identity * into a 0..`maxTraffic` integer and selects the matching variation from a @@ -228,6 +251,95 @@ public class BucketingManager( ) } + /** + * Builds the anchored bucket layout for [allocations] — qs-01 / contract + * v12. Mirrors the JS SDK's `getBucketRanges` + * (`packages/bucketing/src/bucketing-manager.ts`): anchors are computed + * over the total weight of ALL entries (active and inactive) so that + * raising an experience's total allocation only ever grows arms (AC2) + * and never reshuffles an already-bucketed visitor (AC3). Inactive + * entries keep their weight for anchor stability but resolve to a + * zero-width range (AC4). + * + * ### Phase 1 (RED) stub + * + * TODO(qs-01 Phase 2): returns an empty list unconditionally. The real + * cumulative-weight walk lands in Phase 2 — see + * `qs-01-anchored-bucketing-layout.md` "The contract (normative)". + * + * @param allocations variation allocations in config order. + * @return the anchored ranges, in the same order as [allocations]. + */ + public fun getBucketRanges(allocations: List): List { + logger.debug( + message = "BucketingManager.getBucketRanges() allocations=$allocations (STUB — Phase 2)", + tag = TAG, + ) + return emptyList() + } + + /** + * Selects the variation whose anchored range contains [value] — qs-01 / + * contract v12. Mirrors the JS SDK's `selectBucketAnchored`. + * + * ### Phase 1 (RED) stub + * + * TODO(qs-01 Phase 2): returns `null` unconditionally. The real + * half-open range scan (`value >= anchor && value < anchor + width`, + * AC5 boundary semantics) lands in Phase 2. + * + * @param ranges anchored bucket ranges (see [getBucketRanges]). + * @param value the bucket value, typically produced by [getValueVisitorBased]. + * @return the matching variation id, or `null` when not bucketed. + */ + public fun selectBucketAnchored(ranges: List, value: Int): String? { + logger.debug( + message = "BucketingManager.selectBucketAnchored() ranges=$ranges value=$value (STUB — Phase 2)", + tag = TAG, + ) + return null + } + + /** + * Convenience combining [getValueVisitorBased] + [getBucketRanges] + + * [selectBucketAnchored] into a single call — qs-01 / contract v12 + * counterpart to [getBucketForVisitor]. Reuses the existing + * visitor-based hash value UNCHANGED (AC6 — hash path is frozen); + * only the range-resolution step differs from the packed layout. + * + * ### Phase 1 (RED) stub + * + * TODO(qs-01 Phase 2): returns `null` unconditionally (delegates to the + * still-stubbed [getBucketRanges] / [selectBucketAnchored]). + * + * @param allocations variation allocations in config order. + * @param visitorId the visitor's opaque stable identifier. + * @param seed optional seed override; `null` uses [hashSeed]. + * @param experienceId the experience's stable identifier — empty when + * `excludeExperienceIdHash` is set. + * @return a [BucketingAllocation] on success, or `null` when no bucket matched. + */ + public fun getBucketForVisitorAnchored( + allocations: List, + visitorId: String, + seed: Int? = null, + experienceId: String = "", + ): BucketingAllocation? { + val value = getValueVisitorBased( + visitorId = visitorId, + experienceId = experienceId, + seed = seed ?: hashSeed, + ) + val variationId = selectBucketAnchored( + ranges = getBucketRanges(allocations), + value = value, + ) ?: return null + return BucketingAllocation( + variationId = variationId, + bucketingAllocation = value, + ) + } + public companion object { private const val TAG: String = "BucketingManager" diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt new file mode 100644 index 000000000..ac6b9dff0 --- /dev/null +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt @@ -0,0 +1,40 @@ +/* + * Convert Android SDK — core/model + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.model + +import kotlinx.serialization.Serializable + +/** + * One variation's weight + activity state, as consumed by the anchored + * bucketing layout (qs-01 / contract v12). + * + * Mirrors the JS SDK's `VariationAllocation` + * (`@convertcom/js-sdk-types`, `packages/types/src/VariationAllocation.ts`): + * exactly the three fields the anchored algorithm needs. Callers build an + * ordered [List] (never a [Map] — inactive arms and declaration order both + * matter for anchor stability) via the layout resolver's allocation + * builder, then hand it to + * [com.convert.sdk.core.bucketing.BucketingManager.getBucketRanges] / + * [com.convert.sdk.core.bucketing.BucketingManager.getBucketForVisitorAnchored]. + * + * @property id the variation id, as it appears in the backing experience's + * variations list. + * @property allocation the resolved weight in `0..100` traffic-percentage + * units — already defaulted (`isNaN(ta) ? 100.0 : ta` in the JS + * reference) so this field is never `NaN`. Kept for **every** entry + * (active and inactive) because [active]`false` entries still contribute + * their weight to the anchor space (anchor stability under stops). + * @property active whether this entry claims a non-zero-width range in the + * anchored layout. `false` for a `stopped` variation or an explicit + * `traffic_allocation: 0` — the entry's weight still counts toward + * `totalWeight`, but its range width is forced to zero. + */ +@Serializable +public data class VariationAllocation( + public val id: String, + public val allocation: Double, + public val active: Boolean, +) diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt new file mode 100644 index 000000000..a0e808551 --- /dev/null +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt @@ -0,0 +1,229 @@ +/* + * Convert Android SDK — core/bucketing tests + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.bucketing + +import com.convert.sdk.core.config.ConvertConfig +import com.convert.sdk.core.model.VariationAllocation +import com.convert.sdk.core.model.generated.ExperienceVariationConfig +import com.convert.sdk.core.model.generated.VariationStatuses +import com.convert.sdk.core.port.Logger +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal +import java.util.stream.Stream + +/** + * Focused AC1-AC5 coverage for the anchored bucketing layout — qs-01 / + * contract v12. Complements [AnchoredBucketingParityTest] (which drives the + * shared cross-SDK golden vectors end-to-end): this file isolates each + * acceptance criterion against the individual `BucketingLayoutResolver.kt` + * / [BucketingManager] anchored functions so a failure points at the exact + * broken primitive rather than only a vector's final id. + * + * AC6 (packed regression lock) and AC7 (golden vectors) are covered by + * [AnchoredBucketingParityTest] plus the pre-existing, unmodified + * [HashParityTest] / [BucketingManagerTest] suites — no new assertions + * needed here. AC8 (stored-decision precedence) and AC9 (event/API + * stability) are [com.convert.sdk.android.ConvertContext] concerns with NO + * code changes in this qs-01 pass (`resolveSticky` short-circuits before + * the gate; event/return shapes are untouched) — already exercised by the + * existing `ConvertContextRunExperienceTest` suite, so no new test is + * added for them either. + * + * ## Phase 1 (RED) note + * + * `isAnchoredLayout`, `buildVariationAllocations`, [BucketingManager.getBucketRanges], + * and [BucketingManager.selectBucketAnchored] are Phase-1 stubs (always + * `false` / empty / `null`). Assertions below that require the REAL + * anchored arithmetic are expected to FAIL until Phase 2 fills in the + * bodies; assertions whose expected value happens to equal the stub's + * sentinel may pass early — that is expected and documented per-case. + */ +internal class AnchoredBucketingAcceptanceTest { + + // --- AC1: gate branching ----------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("gateVectors") + fun `AC1 gate branches on version`(description: String, version: BigDecimal?, expectedAnchored: Boolean) { + assertEquals(expectedAnchored, isAnchoredLayout(version), description) + } + + // --- AC2: raise is a superset (exact 15% -> 25% 3-arm table) ----------- + + @Test + fun `AC2 raising 15pct to 25pct keeps every already-bucketed visitor's arm`() { + val ranges15 = BucketingManager(TEST_CONFIG, TEST_LOGGER).getBucketRanges(threeEqualArms(FIFTEEN_PCT)) + val ranges25 = BucketingManager(TEST_CONFIG, TEST_LOGGER).getBucketRanges(threeEqualArms(TWENTY_FIVE_PCT)) + + // Spec table (qs-01 "Layouts at 15% -> 25%, 3 equal arms"): + // anchored 15%: O [0,500) V1 [3333.33,3833.33) V2 [6666.67,7167) + // anchored 25%: O [0,833.33) V1 [3333.33,4166.67) V2 [6666.67,7500) + assertRange(ranges15, id = "O", anchor = 0.0, width = 500.0) + assertRange(ranges15, id = "V1", anchor = THIRD_OF_10000, width = 500.0) + assertRange(ranges15, id = "V2", anchor = TWO_THIRDS_OF_10000, width = 500.0) + assertRange(ranges25, id = "O", anchor = 0.0, width = 833.3333333333334) + assertRange(ranges25, id = "V1", anchor = THIRD_OF_10000, width = 833.3333333333334) + assertRange(ranges25, id = "V2", anchor = TWO_THIRDS_OF_10000, width = 833.3333333333334) + + // A visitor at value 3500 sits inside V1's 15% band AND inside V1's + // (superset) 25% band -> same arm both times, never reassigned. + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + assertEquals("V1", manager.selectBucketAnchored(ranges15, value = SAMPLE_V1_VALUE)) + assertEquals("V1", manager.selectBucketAnchored(ranges25, value = SAMPLE_V1_VALUE)) + } + + // --- AC3: lower ejects evenly and never flips --------------------------- + + @Test + fun `AC3 lowering 25pct to 15pct ejects out-of-range visitors without reassigning them`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val ranges25 = manager.getBucketRanges(threeEqualArms(TWENTY_FIVE_PCT)) + val ranges15 = manager.getBucketRanges(threeEqualArms(FIFTEEN_PCT)) + + // value 4000 is inside V1's 25% band [3333.33,4166.67) but OUTSIDE + // V1's 15% band [3333.33,3833.33) -> ejected to not-bucketed, never + // reassigned to O or V2 (the packed-layout flip this qs-01 fixes). + assertEquals("V1", manager.selectBucketAnchored(ranges25, value = EJECTED_VALUE)) + assertNull(manager.selectBucketAnchored(ranges15, value = EJECTED_VALUE)) + } + + // --- AC4: stops / explicit ta:0 zero the arm's width, others unchanged - + + @Test + fun `AC4 stopping one arm zero-widths only that arm, other anchors byte-identical`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val allRunning = threeEqualArms(FIFTEEN_PCT) + val v1Stopped = listOf( + VariationAllocation(id = "O", allocation = FIFTEEN_PCT, active = true), + VariationAllocation(id = "V1", allocation = FIFTEEN_PCT, active = false), + VariationAllocation(id = "V2", allocation = FIFTEEN_PCT, active = true), + ) + + val rangesRunning = manager.getBucketRanges(allRunning) + val rangesStopped = manager.getBucketRanges(v1Stopped) + + // O and V2 keep IDENTICAL anchors + widths (weight preserved by the + // stopped V1 arm) — only V1's width drops to zero. + assertEquals(rangesRunning.first { it.id == "O" }, rangesStopped.first { it.id == "O" }) + assertEquals(rangesRunning.first { it.id == "V2" }, rangesStopped.first { it.id == "V2" }) + val stoppedV1 = rangesStopped.first { it.id == "V1" } + assertEquals(THIRD_OF_10000, stoppedV1.anchor) + assertEquals(0.0, stoppedV1.width) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("inactiveArmVectors") + fun `AC4 inactive arms keep their weight but lose active status`( + description: String, + variation: ExperienceVariationConfig, + expectedAllocation: Double, + ) { + val allocations = buildVariationAllocations(listOf(variation)) + val entry = allocations.first { it.id == variation.id } + + assertEquals(expectedAllocation, entry.allocation, "$description: allocation (weight) must be preserved") + assertEquals(false, entry.active, "$description: an inactive arm is never active regardless of weight") + } + + // --- AC5: defaults + boundaries ----------------------------------------- + + @Test + fun `AC5 absent traffic_allocation defaults to 100pct weight`() { + val variation = ExperienceVariationConfig(id = "SOLO", trafficAllocation = null, status = null) + + val allocations = buildVariationAllocations(listOf(variation)) + + assertEquals(1, allocations.size) + assertEquals(100.0, allocations.first().allocation) + assertEquals(true, allocations.first().active) + } + + @Test + fun `AC5 totalWeight of zero or less is not bucketed`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val allZero = listOf( + VariationAllocation(id = "O", allocation = 0.0, active = false), + VariationAllocation(id = "V1", allocation = 0.0, active = false), + ) + + val ranges = manager.getBucketRanges(allZero) + + assertEquals(emptyList(), ranges) + assertNull(manager.selectBucketAnchored(ranges, value = 0)) + } + + @Test + fun `AC5 boundary value equal to anchor is in, equal to anchor plus width is out`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val ranges = listOf(BucketAnchoredRange(id = "X", anchor = BOUNDARY_ANCHOR, width = BOUNDARY_WIDTH)) + + assertEquals("X", manager.selectBucketAnchored(ranges, value = BOUNDARY_ANCHOR.toInt())) + assertNull(manager.selectBucketAnchored(ranges, value = (BOUNDARY_ANCHOR + BOUNDARY_WIDTH).toInt())) + } + + // --- shared fixtures / helpers ------------------------------------------ + + private fun threeEqualArms(pctEach: Double): List = listOf( + VariationAllocation(id = "O", allocation = pctEach, active = true), + VariationAllocation(id = "V1", allocation = pctEach, active = true), + VariationAllocation(id = "V2", allocation = pctEach, active = true), + ) + + private fun assertRange(ranges: List, id: String, anchor: Double, width: Double) { + val range = ranges.first { it.id == id } + assertEquals(anchor, range.anchor, "$id anchor") + assertEquals(width, range.width, "$id width") + } + + companion object { + private val TEST_LOGGER = Logger.NoOp + private val TEST_CONFIG = ConvertConfig() + + private const val FIFTEEN_PCT: Double = 5.0 + private const val TWENTY_FIVE_PCT: Double = 8.333333333333334 + private const val THIRD_OF_10000: Double = 3333.333333333333 + private const val TWO_THIRDS_OF_10000: Double = 6666.666666666666 + private const val SAMPLE_V1_VALUE: Int = 3500 + private const val EJECTED_VALUE: Int = 4000 + private const val BOUNDARY_ANCHOR: Double = 1000.0 + private const val BOUNDARY_WIDTH: Double = 500.0 + + @JvmStatic + fun gateVectors(): Stream = Stream.of( + Arguments.of("version 11 (production stamp) -> packed", BigDecimal("11"), false), + Arguments.of("version 11.9 -> anchored", BigDecimal("11.9"), true), + Arguments.of("version 12 -> anchored", BigDecimal("12"), true), + Arguments.of("missing version (null) -> packed", null, false), + ) + + @JvmStatic + fun inactiveArmVectors(): Stream = Stream.of( + Arguments.of( + "explicit ta:0, status running -> zero weight, never defaults to 100", + ExperienceVariationConfig( + id = "ZERO_TA", + trafficAllocation = BigDecimal.ZERO, + status = VariationStatuses.RUNNING, + ), + 0.0, + ), + Arguments.of( + "stopped status, ta preserved at 5 -> weight kept for anchor stability", + ExperienceVariationConfig( + id = "STOPPED", + trafficAllocation = BigDecimal("5"), + status = VariationStatuses.STOPPED, + ), + 5.0, + ), + ) + } +} diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt new file mode 100644 index 000000000..ba97fcbe4 --- /dev/null +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt @@ -0,0 +1,164 @@ +/* + * Convert Android SDK — core/bucketing tests + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.bucketing + +import com.convert.sdk.core.config.ConvertConfig +import com.convert.sdk.core.internal.sharedSerializersModule +import com.convert.sdk.core.model.generated.ExperienceVariationConfig +import com.convert.sdk.core.port.Logger +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal +import java.util.stream.Stream + +/** + * # HARD CI GATE — DO NOT WEAKEN OR SKIP + * + * Cross-SDK anchored-bucketing-layout parity test — qs-01 / contract v12. + * Loads `cross-sdk-bucketing-vectors.json` (copied byte-for-byte from the + * JS SDK reference, see [com.convert.sdk.core.bucketing.HashParityTest] for + * the sibling hash-only fixture) and, for every vector, asserts that + * [BucketingManager.resolveVariationId] — the single version-gated + * decision seam shared with [com.convert.sdk.android.ConvertContext] + * (Phase 2) — selects the same variation id the JS SDK reference selected. + * + * A vector carries `version: 11` (packed, byte-for-byte unchanged, AC6) or + * `version: 12` (anchored, the new qs-01 layout, AC1-AC5/AC7). Both routes + * flow through [BucketingManager.resolveVariationId], so a single + * parameterised test covers the whole version-gated decision. + * + * ## Failure remediation + * + * If a vector fails: + * 1. **DO NOT** regenerate or hand-edit the vectors file to "fix" the + * discrepancy — the fixture IS the cross-SDK contract (qs-01 + * "Golden-vector fixture (consume — do NOT recompute)"). + * 2. **DO** read the failing vector's description — it names the exact + * layout, coverage percentages, and visitor under test — and diagnose + * why [BucketingManager.resolveVariationId] (or the + * `BucketingLayoutResolver.kt` functions it delegates to) diverges + * from the JS reference for that input class. + * 3. **FIX the Kotlin side** to restore parity, then confirm the test + * passes without changing the vectors. + * + * ## Phase 1 (RED) note + * + * As of this commit, `isAnchoredLayout` / `buildVariationAllocations` / + * `getBucketRanges` / `selectBucketAnchored` are Phase-1 stubs (see + * `BucketingLayoutResolver.kt` and `BucketingManager.kt`). `version: 12` + * vectors are therefore expected to FAIL — the gate stub always routes to + * the packed layout, which computes different ranges than the anchored + * layout expects. `version: 11` vectors are expected to PASS: the packed + * branch (`buildPackedBuckets`) is a full, unmodified port of the already- + * shipped packed filter chain, not a stub. Phase 2 removes this note. + * + * ## Vector file shape + * + * The JSON is a top-level array of objects: + * `{description, experienceId, visitorId, version, variations: [{id, + * traffic_allocation, status?}], expected}`. `variations` deserialises + * directly into the real generated [ExperienceVariationConfig] — the exact + * production type [BucketingLayoutResolver.kt] consumes — so this test + * exercises the real wire-shape coercion (e.g. `traffic_allocation` as a + * `@Contextual BigDecimal`), not a hand-rolled parallel model. + */ +internal class AnchoredBucketingParityTest { + + /** + * One golden vector as parsed from `cross-sdk-bucketing-vectors.json`. + * + * @property version the experience's `version` field. Always a plain + * numeric literal in this fixture (`11` or `12`); AC1's non-numeric / + * fractional / missing gate cases are covered separately by + * [AnchoredBucketingAcceptanceTest], not by this shared fixture. + * @property variations decodes straight into the real generated + * [ExperienceVariationConfig] list — config declaration order is + * preserved by [kotlinx.serialization]'s `List` decoding. + * @property expected the JS-SDK-computed variation id, or `null` when + * the JS reference did not bucket this visitor. + */ + @Serializable + internal data class GoldenBucketingVector( + val description: String, + val experienceId: String, + val visitorId: String, + @Contextual val version: BigDecimal, + val variations: List, + val expected: String? = null, + ) + + @ParameterizedTest(name = "{0}") + @MethodSource("goldenVectors") + fun `golden vector selects expected variation`( + description: String, + vector: GoldenBucketingVector, + ) { + val manager = BucketingManager(ConvertConfig(), Logger.NoOp) + + val actual = manager.resolveVariationId( + version = vector.version, + variations = vector.variations, + visitorId = vector.visitorId, + experienceId = vector.experienceId, + )?.variationId + + assertEquals( + vector.expected, + actual, + "Vector \"${vector.description}\": expected=${vector.expected}, got=$actual " + + "(version=${vector.version}, visitorId=${vector.visitorId}, " + + "experienceId=${vector.experienceId})", + ) + } + + companion object { + /** + * Resource path relative to the classpath root — the same fixture + * file [HashParityTest] documents the regeneration procedure for, + * imported verbatim from the JS SDK reference (qs-01 AND-1). + */ + private const val VECTORS_RESOURCE: String = "/cross-sdk-bucketing-vectors.json" + + /** Fixture is imported at exactly 59 vectors ({11: 19, 12: 40}) — qs-01 AND-1. */ + private const val EXPECTED_VECTOR_COUNT: Int = 59 + + /** + * [sharedSerializersModule] registers the `@Contextual BigDecimal` + * serializer the generated [ExperienceVariationConfig] and + * [GoldenBucketingVector.version] fields both rely on. + */ + private val json: Json = Json { + ignoreUnknownKeys = true + serializersModule = sharedSerializersModule + } + + private fun loadVectors(): List { + val stream = AnchoredBucketingParityTest::class.java.getResourceAsStream(VECTORS_RESOURCE) + ?: error( + "Missing test resource $VECTORS_RESOURCE — copy verbatim from the JS SDK " + + "reference branch `feat/anchored-bucketing-layout`, file " + + "`packages/bucketing/tests/cross-sdk-bucketing-vectors.json` (qs-01 AND-1).", + ) + val text = stream.bufferedReader(Charsets.UTF_8).use { it.readText() } + return json.decodeFromString(text) + } + + @JvmStatic + fun goldenVectors(): Stream { + val vectors = loadVectors() + check(vectors.size == EXPECTED_VECTOR_COUNT) { + "Expected exactly $EXPECTED_VECTOR_COUNT golden vectors (qs-01 AND-1), " + + "got ${vectors.size}. Check cross-sdk-bucketing-vectors.json import." + } + return vectors.stream().map { vector -> Arguments.of(vector.description, vector) } + } + } +} From 243561f5a79474ef27b18dd6a73aac68352f981a Mon Sep 17 00:00:00 2001 From: Ahmed Abbas Date: Sat, 4 Jul 2026 05:33:22 +0300 Subject: [PATCH 3/5] feat(bucketing)!: add anchored bucketing layout for ramping allocations Adds the anchored bucketing layout as an additional selection pass beside the existing packed walk. Each variation is anchored to its own position in the allocation space, so raising or lowering an experience's total traffic allocation keeps every already-bucketed visitor on the same variation and admits or ejects visitors evenly at each arm's own edge. The layout is selected per experience from the served config's version field. BREAKING CHANGE: The bucketing contract is now versioned. Experiences served with version greater than 11 use the anchored layout; version 11 or lower, missing, or non-numeric continue to use the packed layout unchanged. For an experience on the new layout, visitor-to-variation assignments differ from the packed layout for the same inputs. --- .../core/bucketing/BucketingLayoutResolver.kt | 38 ++++++---- .../sdk/core/bucketing/BucketingManager.kt | 54 ++++++++------ .../AnchoredBucketingAcceptanceTest.kt | 21 +++++- .../com/convert/sdk/android/ConvertContext.kt | 73 ++++--------------- 4 files changed, 91 insertions(+), 95 deletions(-) diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt index be3ff7c9a..f7cdadd8c 100644 --- a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt @@ -33,6 +33,14 @@ import java.math.BigDecimal /** JS-parity default weight for a variation with no `traffic_allocation` set (`isNaN(ta) -> 100`). */ private const val DEFAULT_VARIATION_PCT: Double = 100.0 +/** + * Anchored-layout gate threshold — qs-01 / contract v12 AC1. `version` must + * compare strictly greater than this (via [BigDecimal.compareTo], never + * `equals`) to activate the anchored layout. Mirrors the JS SDK's + * `Number(experience.version) > 11`. + */ +private val ANCHORED_LAYOUT_VERSION_THRESHOLD: BigDecimal = BigDecimal("11") + /** * Anchored-vs-packed GATE — qs-01 / contract v12 AC1. `version > 11` runs * the anchored layout; `version <= 11`, missing, or non-numeric keeps the @@ -49,11 +57,8 @@ private const val DEFAULT_VARIATION_PCT: Double = 100.0 * wire's numeric-or-numeric-string form by [com.convert.sdk.core.internal.BigDecimalSerializer]. * @return `true` iff the anchored layout (contract v12) should run. */ -internal fun isAnchoredLayout(version: BigDecimal?): Boolean { - // Phase 1 (RED) stub — TODO(qs-01 Phase 2): replace with - // `version != null && version > BigDecimal("11")`. - return version == null && false -} +internal fun isAnchoredLayout(version: BigDecimal?): Boolean = + version != null && version > ANCHORED_LAYOUT_VERSION_THRESHOLD /** * Builds the ordered [VariationAllocation] list the anchored layout @@ -64,22 +69,25 @@ internal fun isAnchoredLayout(version: BigDecimal?): Boolean { * `100.0`, and `active` is `false` for a non-`RUNNING` status OR an explicit * zero allocation (AC4 — never defaults a stopped/zero arm back to 100%). * - * ### Phase 1 (RED) stub - * - * TODO(qs-01 Phase 2): always returns an empty list, ignoring [variations]. - * * @param variations the experience's variations, in declaration order. * @return the anchored allocation inputs, in the same order as [variations] * (minus null-id entries). */ internal fun buildVariationAllocations( variations: List?, -): List { - // References `variations` so the Phase-1 stub still type-checks the real - // Phase-2 body's shape without executing it. - variations?.size - return emptyList() -} +): List = + variations + ?.mapNotNull { variation -> + val id = variation.id ?: return@mapNotNull null + val allocation = variation.trafficAllocation?.toDouble() ?: DEFAULT_VARIATION_PCT + val statusOk = variation.status == null || variation.status == VariationStatuses.RUNNING + VariationAllocation( + id = id, + allocation = allocation, + active = statusOk && allocation > 0.0, + ) + } + ?: emptyList() /** * Builds the `variationId -> percentage` map for the packed layout — the diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt index 781e68454..bb0b6a65d 100644 --- a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt @@ -261,43 +261,60 @@ public class BucketingManager( * entries keep their weight for anchor stability but resolve to a * zero-width range (AC4). * - * ### Phase 1 (RED) stub - * - * TODO(qs-01 Phase 2): returns an empty list unconditionally. The real - * cumulative-weight walk lands in Phase 2 — see - * `qs-01-anchored-bucketing-layout.md` "The contract (normative)". + * `totalWeight <= 0.0` (AC5 — e.g. every entry allocation is zero) short + * circuits to an empty list: there is nothing to anchor against. * * @param allocations variation allocations in config order. * @return the anchored ranges, in the same order as [allocations]. */ public fun getBucketRanges(allocations: List): List { + val totalWeight = allocations.sumOf { it.allocation } + if (totalWeight <= 0.0) { + logger.debug( + message = "BucketingManager.getBucketRanges() allocations=$allocations " + + "totalWeight=$totalWeight (not bucketable)", + tag = TAG, + ) + return emptyList() + } + + var cumWeight = 0.0 + val ranges = allocations.map { entry -> + val anchor = (cumWeight / totalWeight) * ConfigDefaults.DEFAULT_BUCKETING_MAX_TRAFFIC + val width = if (entry.active) entry.allocation * PERCENTAGE_TO_BASIS_MULTIPLIER else 0.0 + cumWeight += entry.allocation + BucketAnchoredRange(id = entry.id, anchor = anchor, width = width) + } + logger.debug( - message = "BucketingManager.getBucketRanges() allocations=$allocations (STUB — Phase 2)", + message = "BucketingManager.getBucketRanges() allocations=$allocations " + + "totalWeight=$totalWeight ranges=$ranges", tag = TAG, ) - return emptyList() + return ranges } /** * Selects the variation whose anchored range contains [value] — qs-01 / - * contract v12. Mirrors the JS SDK's `selectBucketAnchored`. - * - * ### Phase 1 (RED) stub - * - * TODO(qs-01 Phase 2): returns `null` unconditionally. The real - * half-open range scan (`value >= anchor && value < anchor + width`, - * AC5 boundary semantics) lands in Phase 2. + * contract v12. Mirrors the JS SDK's `selectBucketAnchored`: the first + * range (in [ranges] order — mirrors [getBucketRanges]'s output order) + * whose half-open interval `[anchor, anchor + width)` contains [value] + * wins (AC5 boundary semantics). * * @param ranges anchored bucket ranges (see [getBucketRanges]). * @param value the bucket value, typically produced by [getValueVisitorBased]. * @return the matching variation id, or `null` when not bucketed. */ public fun selectBucketAnchored(ranges: List, value: Int): String? { + val selected = ranges.firstOrNull { range -> + value >= range.anchor && value < range.anchor + range.width + }?.id + logger.debug( - message = "BucketingManager.selectBucketAnchored() ranges=$ranges value=$value (STUB — Phase 2)", + message = "BucketingManager.selectBucketAnchored() ranges=$ranges value=$value selected=$selected", tag = TAG, ) - return null + return selected } /** @@ -307,11 +324,6 @@ public class BucketingManager( * visitor-based hash value UNCHANGED (AC6 — hash path is frozen); * only the range-resolution step differs from the packed layout. * - * ### Phase 1 (RED) stub - * - * TODO(qs-01 Phase 2): returns `null` unconditionally (delegates to the - * still-stubbed [getBucketRanges] / [selectBucketAnchored]). - * * @param allocations variation allocations in config order. * @param visitorId the visitor's opaque stable identifier. * @param seed optional seed override; `null` uses [hashSeed]. diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt index a0e808551..cbcce4ec8 100644 --- a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt @@ -70,8 +70,8 @@ internal class AnchoredBucketingAcceptanceTest { assertRange(ranges15, id = "V1", anchor = THIRD_OF_10000, width = 500.0) assertRange(ranges15, id = "V2", anchor = TWO_THIRDS_OF_10000, width = 500.0) assertRange(ranges25, id = "O", anchor = 0.0, width = 833.3333333333334) - assertRange(ranges25, id = "V1", anchor = THIRD_OF_10000, width = 833.3333333333334) - assertRange(ranges25, id = "V2", anchor = TWO_THIRDS_OF_10000, width = 833.3333333333334) + assertRange(ranges25, id = "V1", anchor = TWENTY_FIVE_PCT_THIRD_OF_10000, width = 833.3333333333334) + assertRange(ranges25, id = "V2", anchor = TWENTY_FIVE_PCT_TWO_THIRDS_OF_10000, width = 833.3333333333334) // A visitor at value 3500 sits inside V1's 15% band AND inside V1's // (superset) 25% band -> same arm both times, never reassigned. @@ -191,6 +191,23 @@ internal class AnchoredBucketingAcceptanceTest { private const val TWENTY_FIVE_PCT: Double = 8.333333333333334 private const val THIRD_OF_10000: Double = 3333.333333333333 private const val TWO_THIRDS_OF_10000: Double = 6666.666666666666 + + /** + * `ranges25`'s V1/V2 anchors are NOT bit-identical to [THIRD_OF_10000] / + * [TWO_THIRDS_OF_10000] — [TWENTY_FIVE_PCT] (8.333333333333334, the + * per-arm share of 25%) is itself an imprecise double, so + * `(cumWeight / totalWeight) * 10000` rounds to a different ULP than + * the [FIFTEEN_PCT]-derived case. Verified against the JS SDK oracle + * (`packages/bucketing/src/bucketing-manager.ts` `getBucketRanges`) + * executed directly in Node — `node -e` with the identical + * `(cumWeight/totalWeight)*10000` walk over three + * `8.333333333333334`-weighted arms prints + * `{"id":"V1","anchor":3333.3333333333335,...}` / + * `{"id":"V2","anchor":6666.666666666667,...}`, confirming this is a + * genuine floating-point-parity fact, not a Kotlin-side defect. + */ + private const val TWENTY_FIVE_PCT_THIRD_OF_10000: Double = 3333.3333333333335 + private const val TWENTY_FIVE_PCT_TWO_THIRDS_OF_10000: Double = 6666.666666666667 private const val SAMPLE_V1_VALUE: Int = 3500 private const val EJECTED_VALUE: Int = 4000 private const val BOUNDARY_ANCHOR: Double = 1000.0 diff --git a/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt b/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt index 5732d155b..2f6f74ca2 100644 --- a/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt +++ b/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt @@ -5,6 +5,7 @@ */ package com.convert.sdk.android +import com.convert.sdk.core.bucketing.resolveVariationId import com.convert.sdk.core.event.SystemEvents import com.convert.sdk.core.model.Feature import com.convert.sdk.core.model.GoalData @@ -14,7 +15,6 @@ import com.convert.sdk.core.model.generated.ConfigExperience import com.convert.sdk.core.model.generated.ConfigLocation import com.convert.sdk.core.model.generated.ConfigResponseData import com.convert.sdk.core.model.generated.ExperienceVariationConfig -import com.convert.sdk.core.model.generated.VariationStatuses import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -288,6 +288,18 @@ public class ConvertContext internal constructor( * the sticky lookup has missed. Returns the selected [Variation], or * `null` if the visitor is not bucketed into any variation. * + * ### qs-01 / contract v12 — shared version-gated seam + * + * Delegates to [com.convert.sdk.core.bucketing.resolveVariationId] — + * the SAME function [com.convert.sdk.core.bucketing.AnchoredBucketingParityTest] + * exercises against the cross-SDK golden vectors — so the runtime path + * and the parity test can never diverge. `experience.version` decides + * the layout: `null`/`<= 11` runs the frozen packed cumulative walk + * (AC6, byte-identical to the pre-qs-01 behaviour); `> 11` runs the + * new anchored layout (AC1-AC5). Both branches still resolve through + * [com.convert.sdk.core.bucketing.BucketingManager.getValueVisitorBased] + * unchanged. + * * Split from [runExperience] to keep it inside detekt's line limit. */ @Suppress("ReturnCount") @@ -297,17 +309,9 @@ public class ConvertContext internal constructor( experienceKey: String, enableTracking: Boolean, ): Variation? { - val buckets = buildBuckets(experience.variations) - if (buckets.isEmpty()) { - sdk.logger.debug( - message = "ConvertContext.runExperience: experience '$experienceKey' has no " + - "eligible variations", - tag = TAG, - ) - return null - } - val allocation = sdk.bucketingManager.getBucketForVisitor( - buckets = buckets, + val allocation = sdk.bucketingManager.resolveVariationId( + version = experience.version, + variations = experience.variations, visitorId = visitorId, experienceId = experience.id.orEmpty(), ) ?: run { @@ -357,42 +361,6 @@ public class ConvertContext internal constructor( ) } - /** - * Builds the `variationId -> percentage` map consumed by - * [com.convert.sdk.core.bucketing.BucketingManager.getBucketForVisitor]. - * - * Matches the JS SDK's `data-manager.ts:620-637` filter chain: - * - Drop variations whose `status` is set AND not `RUNNING` - * (unset status defaults to "eligible", same as JS). - * - Drop zero-traffic variations (stopped variations in disguise). - * - Use the variation's `trafficAllocation` as-is. Although the - * OpenAPI schema describes the field as `0..10000`, the actual - * CDN-emitted values are percentages `0..100` — the JS SDK's - * `* 100` multiplier inside `selectBucket` assumes percentages, - * and real config fixtures (tests/test-config.json across the - * JS SDK) carry `50.0` for a 50% variation. We mirror that - * interpretation. - * - * Insertion order is preserved (the generated - * [ExperienceVariationConfig] list is a plain [List], so iteration - * is declaration order — which the bucketing engine relies on). - */ - private fun buildBuckets( - variations: List?, - ): Map = - variations - ?.asSequence() - ?.filter { it.id != null } - ?.filter { it.status == null || it.status == VariationStatuses.RUNNING } - ?.map { it to (it.trafficAllocation?.toDouble() ?: DEFAULT_VARIATION_PCT) } - ?.filter { (_, allocation) -> allocation > 0.0 } - ?.associateByTo( - destination = linkedMapOf(), - keySelector = { (variation, _) -> variation.id!! }, - valueTransform = { (_, allocation) -> allocation }, - ) - ?: emptyMap() - /** * Lifts a [ConfigExperience] + [ExperienceVariationConfig] pair into * the SDK's public [Variation] shape. Populates `experienceId` / @@ -1008,15 +976,6 @@ public class ConvertContext internal constructor( /** Log tag for [runExperience] DEBUG/WARN emissions. */ const val TAG: String = "ConvertContext" - /** - * Traffic percentage applied to a variation whose - * `trafficAllocation` field is absent. Matches JS SDK - * `data-manager.ts:635` (`variation?.traffic_allocation || 100.0`) — - * when no allocation is specified, the variation gets 100% - * (i.e. the whole wheel). - */ - const val DEFAULT_VARIATION_PCT: Double = 100.0 - /** * Key in the per-call `conversionSetting` map that toggles * force-multiple-transactions semantics. JS SDK parity with From 8ca923711e0e2d346e6e944b53adbc5fdc4cf56f Mon Sep 17 00:00:00 2001 From: Ahmed Abbas Date: Sat, 4 Jul 2026 06:19:55 +0300 Subject: [PATCH 4/5] docs(bucketing): remove stale Phase 1 scaffolding docstrings Removes three leftover "Phase 1 (RED)" KDoc blocks that no longer describe the shipped behavior: the isAnchoredLayout gate KDoc's stub/TODO note (the gate is real), and the "expected to FAIL" notes atop the anchored parity and acceptance test classes (all vectors and assertions pass). No code, test assertion, constant, or fixture changed. --- .../sdk/core/bucketing/BucketingLayoutResolver.kt | 5 ----- .../core/bucketing/AnchoredBucketingAcceptanceTest.kt | 9 --------- .../sdk/core/bucketing/AnchoredBucketingParityTest.kt | 11 ----------- 3 files changed, 25 deletions(-) diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt index f7cdadd8c..f20c5659e 100644 --- a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt @@ -48,11 +48,6 @@ private val ANCHORED_LAYOUT_VERSION_THRESHOLD: BigDecimal = BigDecimal("11") * operator), never `equals` — `BigDecimal("11.0") != BigDecimal("11")` under * `equals`, but both must compare `<= 11` here. * - * ### Phase 1 (RED) stub - * - * TODO(qs-01 Phase 2): always returns `false` (packed), ignoring [version] - * entirely. Real gate: `version != null && version > BigDecimal("11")`. - * * @param version the experience's `version` field, already coerced from the * wire's numeric-or-numeric-string form by [com.convert.sdk.core.internal.BigDecimalSerializer]. * @return `true` iff the anchored layout (contract v12) should run. diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt index cbcce4ec8..a5a133ca4 100644 --- a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt @@ -36,15 +36,6 @@ import java.util.stream.Stream * the gate; event/return shapes are untouched) — already exercised by the * existing `ConvertContextRunExperienceTest` suite, so no new test is * added for them either. - * - * ## Phase 1 (RED) note - * - * `isAnchoredLayout`, `buildVariationAllocations`, [BucketingManager.getBucketRanges], - * and [BucketingManager.selectBucketAnchored] are Phase-1 stubs (always - * `false` / empty / `null`). Assertions below that require the REAL - * anchored arithmetic are expected to FAIL until Phase 2 fills in the - * bodies; assertions whose expected value happens to equal the stub's - * sentinel may pass early — that is expected and documented per-case. */ internal class AnchoredBucketingAcceptanceTest { diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt index ba97fcbe4..eb5ef864d 100644 --- a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt @@ -49,17 +49,6 @@ import java.util.stream.Stream * 3. **FIX the Kotlin side** to restore parity, then confirm the test * passes without changing the vectors. * - * ## Phase 1 (RED) note - * - * As of this commit, `isAnchoredLayout` / `buildVariationAllocations` / - * `getBucketRanges` / `selectBucketAnchored` are Phase-1 stubs (see - * `BucketingLayoutResolver.kt` and `BucketingManager.kt`). `version: 12` - * vectors are therefore expected to FAIL — the gate stub always routes to - * the packed layout, which computes different ranges than the anchored - * layout expects. `version: 11` vectors are expected to PASS: the packed - * branch (`buildPackedBuckets`) is a full, unmodified port of the already- - * shipped packed filter chain, not a stub. Phase 2 removes this note. - * * ## Vector file shape * * The JSON is a top-level array of objects: From 43b61d882f8bc5399b9f1f2b6361215a410fe8f7 Mon Sep 17 00:00:00 2001 From: Ahmed Abbas Date: Sat, 4 Jul 2026 06:30:33 +0300 Subject: [PATCH 5/5] docs(bucketing): document anchored parity suite in PARITY.md Adds the second cross-SDK bucketing parity suite (anchored layout, contract v12) alongside the existing hash-vector suite: the cross-sdk-bucketing-vectors fixture (imported verbatim from the JS reference, not generated) and its AnchoredBucketingParityTest gate. --- tools/PARITY.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/PARITY.md b/tools/PARITY.md index c0b5adbf5..b1a3aa496 100644 --- a/tools/PARITY.md +++ b/tools/PARITY.md @@ -35,6 +35,26 @@ The JS side maintains its own equivalent parity suite. Other SDKs (PHP, Python, …) consume the SAME vector file conceptually but mirror it into their own test resources — coordinated via the backend channel. +### Second parity suite — anchored bucketing layout (qs-01 / contract v12) + +Alongside the generated `hash-parity-vectors.json` above, the repo carries a +**second, distinctly-provisioned** parity fixture: +`packages/core/src/test/resources/cross-sdk-bucketing-vectors.json` (a bare +JSON array of 59 vectors, versions `{11, 12}`), gated by +`AnchoredBucketingParityTest.kt`. It pins the version-gated bucketing decision +`BucketingManager.resolveVariationId`: `version: 11` vectors lock the frozen +packed cumulative walk (byte-for-byte unchanged), `version: 12` vectors lock +the anchored layout (`getBucketRanges` / `selectBucketAnchored`). + +**Provenance differs from the hash vectors above.** This fixture is **imported +verbatim** from the shared cross-SDK golden set (JS reference branch +`feat/anchored-bucketing-layout`, file +`packages/bucketing/tests/cross-sdk-bucketing-vectors.json`) — it is **NOT** +produced by `tools/generate-parity-vectors.mjs`, which regenerates only +`hash-parity-vectors.json`. The divergence workflow and anti-patterns below +apply identically: a failing vector is always a Kotlin-side bug to fix, never a +fixture to regenerate or hand-edit. + --- ## When a parity divergence is discovered