Skip to content

feat: holdout support — normalized models, assignment precedence, exposure fields (ISS-61) - #12

Open
marcio-absmartly wants to merge 10 commits into
mainfrom
feat/holdouts
Open

feat: holdout support — normalized models, assignment precedence, exposure fields (ISS-61)#12
marcio-absmartly wants to merge 10 commits into
mainfrom
feat/holdouts

Conversation

@marcio-absmartly

@marcio-absmartly marcio-absmartly commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Client-side holdout support (ISS-61, Phase 3) — companion to absmartly/abs#4539 (server side).

  • Normalized payload: ContextData.holdouts[] (definitions: {id, seedHi, seedLo, split}) + Experiment.holdoutIds[] references — definitions are not duplicated per experiment.
  • Assignment precedence: Override > Holdout > Audience > Traffic > Variant. Held-out units get variant 0 with assigned=true; overrides bypass holdouts (debugging escape hatch).
  • Consistent membership: reuses VariantAssigner with the holdout's own seed — same verdict across all experiments referencing the holdout.
  • Cache invalidation: changing an experiment's holdoutIds or a referenced definition (seed/split) invalidates cached assignments.
  • Exposure reporting: exposures carry heldOut + holdoutId for downstream analysis.
  • Robustness: unknown holdoutIds and malformed definitions (null/short split) are skipped gracefully.

Verification

  • 250 tests green (:core-api:test), findbugsMain clean
  • Full ensemble review (security/performance/code-quality/test-coverage) + fixes, then simplifier pass
  • ContextHoldoutTest matrix: in/out, multi-holdout independence, override-beats-holdout, holdout-beats-custom-assignment, audience interplay, cache invalidation, serializer round-trip, exposure fields

Notes

  • History is a squashed restore (original branch lost to a tooling accident; content re-verified from scratch afterwards).
  • Cross-SDK conformance scenarios (absmartly/cross-sdk-tests) are a follow-up.

Summary by CodeRabbit

  • New Features

    • Added experiment holdouts to treatment assignment.
    • Holdouts can override audience, traffic, full-on, custom, and experiment override rules.
    • Added support for multiple holdouts, deterministic matching, and full-on holdouts.
    • Exposure events now record whether a unit was held out and the matching holdout.
  • Bug Fixes

    • Updated assignment caching to re-evaluate when holdout configuration changes.
    • Invalid or unknown holdout configurations are safely ignored.
  • Tests

    • Added comprehensive coverage for holdout assignment, precedence, caching, serialisation, and data handling.

marcio-absmartly and others added 3 commits July 4, 2026 17:28
…osure fields, tests

Squashed restore of feat/holdouts work (original commits lost to a tooling
accident; content fully preserved):
- ExperimentHoldout json model; ContextData.holdouts[] + Experiment.holdoutIds[]
  (normalized payload: definitions not duplicated per experiment)
- Context: holdout check after override, before audience/traffic/variant
  (Override > Holdout > Audience > Traffic > Variant); id->def map with
  graceful skip of unknown ids; cache invalidation on holdoutIds and
  resolved definition changes
- Exposure heldOut + holdoutId, serializer round-trip
- ContextHoldoutTest matrix + serializer/deserializer coverage
Add missing exposure assertion for the not-held-out-but-has-holdouts
path (heldOut=false/holdoutId=0 after holdouts are actually evaluated),
plus empty-holdoutIds and empty-split branch coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reuse the already-bound unitType local in the traffic-split branch
instead of re-reading experiment.data.unitType, matching the new
holdout branch. Behaviour-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9ee7f386-52dc-48b4-8a74-150341b61a6e

📥 Commits

Reviewing files that changed from the base of the PR and between b2e54ed and eb7dd64.

📒 Files selected for processing (3)
  • core-api/src/main/java/com/absmartly/sdk/Context.java
  • core-api/src/main/java/com/absmartly/sdk/json/ExperimentHoldout.java
  • core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • core-api/src/main/java/com/absmartly/sdk/Context.java

Walkthrough

Adds experiment holdout models and context data, resolves configured holdouts, evaluates holdout membership during assignment, and records held-out state in exposures. Cache matching now includes holdout metadata. The change also adds deserialisation, assignment, precedence, refresh, full-on, publication, and serialisation coverage with supporting fixtures.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

I’m a rabbit with whiskers bright,
Sorting holdouts left and right.
Seeds hop in, assignments flow,
Exposures tell what bunnies know.
Tests nibble every edge—
Carrots dance along the hedge!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarises the main change: client-side holdout support with model normalisation, assignment precedence, and exposure updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/holdouts

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

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java (1)

223-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Repeated publish-assertion boilerplate.

The PublishEvent/when(eventHandler.publish...)/verify(...) sequence is duplicated across skipsHoldoutWhenUnitMissingForUnitType, exposureCarriesHeldOutAndHoldoutId, and exposureCarriesNotHeldOutFieldsWhenUnitNotInHoldout. Consider extracting a small helper (e.g. assertPublishedExposure(context, expectedExposures)) to reduce repetition.

Also applies to: 358-414

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java` around
lines 223 - 250, The publish assertion setup is duplicated in ContextHoldoutTest
across multiple holdout tests, including skipsHoldoutWhenUnitMissingForUnitType,
exposureCarriesHeldOutAndHoldoutId, and
exposureCarriesNotHeldOutFieldsWhenUnitNotInHoldout. Extract the repeated
PublishEvent construction, when(eventHandler.publish(...)) stubbing, and
verify(...) timeout assertion into a small helper such as
assertPublishedExposure or assertPublishEvent so the tests stay focused on the
scenario-specific expectations.
core-api/src/main/java/com/absmartly/sdk/Context.java (1)

786-865: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider de-duplicating the unit lookup in the holdout/traffic-split branches.

The uid = units_.get(unitType) lookup (plus the follow-on getUnitHash/getVariantAssigner calls) is repeated once for holdout evaluation and again for traffic-split evaluation. Hoisting the uid lookup once at the top of this block would remove the duplication and slightly reduce the complexity of an already dense method (holdout precedence, audience matching, full-on/traffic logic are all interleaved here).

♻️ Suggested simplification
 if (experiment != null) {
 	final String unitType = experiment.data.unitType;
+	final String uid = units_.get(unitType);
 
 	assignment.holdoutIds = experiment.data.holdoutIds;
 	assignment.holdouts = experiment.holdouts;
 
-	if (experiment.holdouts != null && experiment.holdouts.length > 0) {
-		final String uid = units_.get(unitType);
-		if (uid != null) {
-			final byte[] unitHash = Context.this.getUnitHash(unitType, uid);
-			final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, unitHash);
-			for (final ExperimentHoldout holdout : experiment.holdouts) {
+	if (uid != null && experiment.holdouts != null && experiment.holdouts.length > 0) {
+		final byte[] unitHash = Context.this.getUnitHash(unitType, uid);
+		final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, unitHash);
+		for (final ExperimentHoldout holdout : experiment.holdouts) {
 				if (assigner.assign(holdout.split, holdout.seedHi, holdout.seedLo) == 0) {
 					assignment.heldOut = true;
 					assignment.holdoutId = holdout.id;
 					assignment.variant = 0;
 					assignment.assigned = true;
 					break;
 				}
-			}
-		}
+		}
 	}
 
 	if (!assignment.heldOut) {
 		...
 		} else if (experiment.data.fullOnVariant == 0) {
-			final String uid = units_.get(unitType);
 			if (uid != null) {
 				final byte[] unitHash = Context.this.getUnitHash(unitType, uid);
 				final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, unitHash);
 				...
 			}
 		} else {
 			...
 		}
 	}
 	...
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core-api/src/main/java/com/absmartly/sdk/Context.java` around lines 786 -
865, The unit lookup and assigner setup in the experiment assignment flow is
duplicated in both the holdout and traffic-split branches. In Context’s
assignment block, hoist the units_.get(unitType) lookup (and, if available, the
derived getUnitHash/getVariantAssigner setup) once before the conditional
branches, then reuse it for holdout and eligibility evaluation. Keep the
existing holdout precedence and audience/full-on behavior unchanged while
simplifying the repeated logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@core-api/src/main/java/com/absmartly/sdk/Context.java`:
- Around line 786-865: The unit lookup and assigner setup in the experiment
assignment flow is duplicated in both the holdout and traffic-split branches. In
Context’s assignment block, hoist the units_.get(unitType) lookup (and, if
available, the derived getUnitHash/getVariantAssigner setup) once before the
conditional branches, then reuse it for holdout and eligibility evaluation. Keep
the existing holdout precedence and audience/full-on behavior unchanged while
simplifying the repeated logic.

In `@core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java`:
- Around line 223-250: The publish assertion setup is duplicated in
ContextHoldoutTest across multiple holdout tests, including
skipsHoldoutWhenUnitMissingForUnitType, exposureCarriesHeldOutAndHoldoutId, and
exposureCarriesNotHeldOutFieldsWhenUnitNotInHoldout. Extract the repeated
PublishEvent construction, when(eventHandler.publish(...)) stubbing, and
verify(...) timeout assertion into a small helper such as
assertPublishedExposure or assertPublishEvent so the tests stay focused on the
scenario-specific expectations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 00fd9122-82f1-4830-b808-f01c239e7192

📥 Commits

Reviewing files that changed from the base of the PR and between f538b50 and c85fd0f.

📒 Files selected for processing (12)
  • core-api/src/main/java/com/absmartly/sdk/Context.java
  • core-api/src/main/java/com/absmartly/sdk/json/ContextData.java
  • core-api/src/main/java/com/absmartly/sdk/json/Experiment.java
  • core-api/src/main/java/com/absmartly/sdk/json/ExperimentHoldout.java
  • core-api/src/main/java/com/absmartly/sdk/json/Exposure.java
  • core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java
  • core-api/src/test/java/com/absmartly/sdk/ContextTest.java
  • core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java
  • core-api/src/test/java/com/absmartly/sdk/DefaultContextEventSerializerTest.java
  • core-api/src/test/java/com/absmartly/sdk/json/ContextDataTest.java
  • core-api/src/test/java/com/absmartly/sdk/json/ExperimentHoldoutTest.java
  • core-api/src/test/resources/holdouts_context.json

marcio-absmartly and others added 7 commits July 5, 2026 09:10
- Assignment cache: treat held-out assignments as up-to-date even when a
  custom assignment is set. Holdout wins over custom assignment, so the
  cached variant (0) can never equal the custom variant; the old condition
  forced a cache miss and a fresh Assignment (exposed=false) on every call,
  queueing a duplicate exposure per getTreatment.
- Tests: cached held-out assignment with custom assignment set; holdout
  precedence over fullOnVariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a custom assignment is set but the cached variant was forced by a
higher-precedence rule (holdout, full-on, traffic ineligibility, or a strict
audience mismatch), the custom value can never equal that variant. The old
cache-validity check compared them directly, so it forced a cache miss and a
fresh Assignment (exposed=false) on every getTreatment call, queueing a
duplicate exposure each time.

Introduce variantForcedRegardlessOfCustom() covering all forced-variant cases
and treat them as cache-valid. 5af036a fixed the holdout case only; this
generalizes the same fix to the audience-strict-mismatch and traffic-ineligible
cases, which are pre-existing on main.

Tests: cached forced-variant assignments with a custom assignment set for both
the strict audience mismatch and traffic ineligible cases (stable pending count
across repeated calls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assignment.holdoutIds/holdouts alias the arrays owned by ContextExperiment
instead of copying them. This is safe only because experiment data is treated
as immutable after setData and the arrays are read-only here. Note the
invariant at the assignment site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ash parity

The gpt-5.6-sol pass changed the SDK normalizer to 1.0/0x100000000 to make
the probability range half-open [0,1). But the collector (and every other
ABsmartly SDK) uses 1.0/0xffffffff, where the max 32-bit hash maps to exactly
1.0. Assignment requires bit-exact hash parity between the SDK and the
collector -- diverging the normalizer would make the SDK and server disagree
on borderline units (including holdout membership). Reverted VariantAssigner
to match main/collector; removed the range test that asserted the divergent
behaviour.
Add optional fullOn field to ExperimentHoldout (tolerant, defaults to
null/absent = full holdout, unchanged behavior). A full_on holdout only
applies to full-on experiments (fullOnVariant != 0); for other
experiments the SDK skips it entirely during heldOut evaluation,
matching the collector's server-side behavior bit-exactly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant