Skip to content

feat(switchback): serve switchback experiments (Phase 1B) - #15

Open
marcio-absmartly wants to merge 36 commits into
mainfrom
feat/switchback-phase1b
Open

marcio-absmartly wants to merge 36 commits into
mainfrom
feat/switchback-phase1b

Conversation

@marcio-absmartly

@marcio-absmartly marcio-absmartly commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Switchback experiments, Phase 1B on the Java SDK side: serve them correctly. Before this change, the SDK ignored the switchback fields and served a switchback as an ordinary sticky per-unit experiment.

The platform (absmartly/abs #4921/#4922) persists and analyses switchbacks. The Collector side (absmartly/abs, feat/switchback-phase1b-collector) now sends the config in ContextData.

What changed

  • Models (json/Experiment) gain 9 nullable fields:

    • randomizationType and switchbackClusterUnitType;
    • switchbackStartAt, switchbackPeriodMs, switchbackCarryoverDurationMs, switchbackBurnInDurationMs, switchbackBurnOutDurationMs;
    • switchbackDesignType and switchbackEstimand.

    They are included in equals/hashCode/toString and deserialization. An absent field means a unit experiment.

  • internal/SwitchbackAssigner is a port of the platform's canonical SwitchbackAssignment. It uses murmur3_32 over the canonical unit\^_cycle key and the experiment seed, followed by a Fisher–Yates permutation per (cluster, cycle). It is Java 1.6 compatible, uses the SDK's own Murmur3_32/Buffers, and has no Guava.

  • Context serving:

    • periodIndex = floorDiv(now − anchor, periodMs). The anchor is ceil'd to the hour.
    • The cluster identity is the same MD5/base64url unit hash the Collector uses.
    • Precedence is unchanged: override, holdouts, audience, full-on, traffic, then custom or the switchback draw. Traffic stays cluster-sticky.
    • Ineligible (variant 0) before the anchor, with a missing anchor or period, or when the cluster unit type does not match. There is never a fallback to unit assignment.
  • Exposure semantics:

    • Cached per period (name + "\^_" + periodIndex), so at most one exposure per experiment, cluster and period. A switchback exposure is stamped with the clock reading that selected its period, so a call straddling a boundary cannot publish one period's variant with the next period's timestamp.
    • At most two period indices are retained per experiment. On installing a third, the lowest index other than the one just installed is evicted.
    • No new exposure or publish fields. The payload is asserted byte-for-byte.
  • Refresh: when an experiment's switchback identity changes (id, iteration, type, cluster unit type, anchor, period, design), it gets a new generation, which is invalidated lazily on the next resolution. setData still takes only dataLock_. An earlier iteration that also took the context lock deadlocked with audience callbacks that refresh, and three regression tests pin that this is fixed.

  • SWITCHBACK.md documents the contract, the dedup bound and the old-SDK behaviour: older SDKs ignore the fields and serve sticky unit assignment, and there is no capability filter. A README section points to it.

Testing

  • :core-api:check (spotless, animalsniffer for Java 1.6, jacoco): 463 tests pass, compared with 447 before.
  • Golden vectors: all 882 assignSwitchback and 504 permutation vectors from the platform fixture pass. Reviewers recomputed 10/10 vectors independently, using a separate Python murmur3 port, including Long.MIN/MAX seeds and period indices.
  • Behaviour tests:
    • 1h/24h boundaries and the pre-anchor instant;
    • 2/3/10 arms;
    • same-period cache plus next-period rollover (one exposure each);
    • boundary-straddling exposure timestamps;
    • refresh/identity transitions (switchback → unit → switchback, removal and restore), including without an intervening lookup;
    • overrides;
    • concurrent resolution of one period (deterministic, barrier-based);
    • refresh-from-audience-callback deadlock regressions (inline and async provider);
    • unit-experiment publish payload unchanged.
  • Review: three review rounds (code quality, test quality, performance), with every finding reproduced before it was fixed, followed by a behaviour-neutral simplifier pass. The collected test IDs were identical before and after.

Known limits (documented)

  • Re-entering an evicted period (a clock moving back by two or more periods) re-exposes once per period re-entered. Analysis treats it as a repeat when seed, split and custom assignment are unchanged.
  • An experiment that is removed and never queried again keeps its retained slots (at most three) for the life of the context.

Collector side: https://github.com/absmartly/abs/pull/5360

Summary by CodeRabbit

  • New Features
    • Added support for switchback experiments, assigning variants by cluster and time period. Assignments update as periods advance, with treatment and variable exposures recorded once per period.
    • Switchback assignments follow configured eligibility and timing rules. Experiments with missing or unsupported switchback settings are ineligible; standard unit-based experiments retain their existing behaviour.
    • Older SDK versions continue to serve switchback experiments using sticky unit assignments.
  • Documentation
    • Added guidance on switchback configuration, assignment rules, exposure tracking, analysis and compatibility with older SDK versions.

Conformance: absmartly/cross-sdk-tests#13

…Data

Adds randomizationType, switchbackClusterUnitType, switchbackStartAt,
switchbackPeriodMs, carryover/burn-in/burn-out durations, design type and
estimand to json.Experiment, including equals/hashCode/toString. Absent
fields stay null; unknown fields are still ignored.
…ctors

Java 1.6 port of the platform's SwitchbackAssignment using the SDK's own
Murmur3_32 and little-endian Buffers (le64 seed then le32 key hash).
The platform golden-vector fixture is copied verbatim; every
assignSwitchback (882) and fisherYatesPermutation (504) case is asserted,
parsing 64-bit fields from decimal strings.
For randomizationType=switchback the variant draw becomes
SwitchbackAssigner.assign(hashUnit(uid), periodIndex, seed, arms) with
periodIndex = floorDiv(now - ceilHour(switchbackStartAt), periodMs), read
once from the injected Clock. Assignments are cached under
name + U+001F + periodIndex, so each period gets a fresh exposure state:
one exposure per experiment/cluster/period, rollover on the boundary.

Precedence is unchanged (override, holdouts, audience, full-on, custom,
traffic, variant); traffic eligibility keeps the per-unit traffic hash.
Before the anchor, with a null start/period, or with a cluster unit type
different from the unit type, the experiment is ineligible; a missing
unit stays unassigned. There is no unit-assignment fallback.

Grid fields (type, cluster unit type, start, period, design) join the
cache-validity check so a refresh that changes them re-resolves; burn,
carryover and estimand do not. Per-period entries are bounded to the two
most recent periods per experiment. Exposure payloads are unchanged.
Hashed cluster identity, 2/3/10-arm schedules against the platform's
Python reference, 1h/24h edges, pre-anchor and null-start ineligibility,
same-period cache and rollover exposures, variables, missing unit,
full-on, override/custom, sticky traffic, refresh invalidation, holdouts,
bounded cache, and the byte-exact publish payload.
A switchback assignment's period is selected from one clock reading, but
enqueueExposure read the clock again, so a call crossing a period boundary
published the previous period's variant with an exposedAt in the next
period. The decision instant now flows from getTreatment/getVariableValue
through getAssignment and settleExposure to the exposure. Each exposing call
uses its own instant, so an exposure after a peek is not pinned to the peek.
Unit experiments and holdout exposures keep the enqueue-time reading.
Retention compared period indices across grids and always evicted the
lowest, so a refresh that lowered the current index evicted the entry just
installed and every call re-exposed. Retention is now held per experiment
under its switchback identity (id, iteration, cluster unit type, start,
period length, design). An identity change drops that experiment's previous
period slots together, and eviction removes the lowest index other than the
one just resolved. A rewound clock re-exposes an evicted period once, not
once per call.
…nments

The override fast path checked only the forced variant and holdout set and
the override branch never pinned the grid, so an overridden switchback slot
survived a grid change and kept the previous definition's variable values.
Override assignments now pin the switchback identity and revalidate it like
ordinary ones. Override precedence and holdout firing are unchanged.
Bare-name and period slots were managed independently, so after
switchback -> unit -> identical switchback (or removal and restore) the old
period slot matched the restored definition again and was returned with its
exposed state. All of an experiment's switchback slots now share one epoch
keyed by its switchback identity. setData drops every slot of an epoch the
installed definition no longer matches, under the context write lock, so an
identity transition is atomic for resolvers and a return to an identical
definition re-resolves.
Document that exposure dedup is guaranteed only within the two retained
periods, what a re-entered period costs, which identity changes start a new
epoch, and that switchback exposures are stamped with the resolution
instant. Correct the precedence order (traffic gates custom assignment).
Stop claiming the Collector already serves switchback fields or that the
reconciliation query itself flags schedule mismatches.
…ock_ only

Installing data under contextLock_ write deadlocked a refresh started from
audience evaluation, which already runs under contextLock_: a completed
provider future ran setData inline and attempted a read-to-write upgrade,
and an async provider's completion thread waited on the lock held by the
matcher that waited on it. This affected unit-only contexts too.

setData is back to dataLock_ write only. While installing the index it
compares each experiment's switchback identity (randomization type, id,
iteration, cluster unit type, startAt, periodMs, design) with the previous
index and either carries the switchback generation over or assigns a fresh
one. A SwitchbackEpoch records the generation it was created under;
resolution drops the whole epoch (period and bare-name slots, overrides
included) when the installed definition is absent, not switchback, or of a
different generation. Every transition, including an unobserved unit or
removed state, yields a new generation, so a stale slot is never
resurrected.
Two readers revalidating the same cached period slot are held at a barrier
inside audience evaluation, so both reject the slot under contextLock_ read
before either installs a replacement. Exactly one exposure must result; with
the write-lock recheck disabled the test produces two.
…ention

A re-created evicted period has the same variant only while seed, split and
custom assignment are unchanged; re-entry dedup is bounded-cache best effort.
Retention keeps at most two period indices and evicts the lowest other than
the one just installed. Epoch invalidation is described as generation-based.
@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Walkthrough

The SDK now supports switchback experiments that assign variants by cluster and time period. It uses a UTC-hour-aligned anchor and a deterministic assignment schedule. Context caches assignments by period and tracks switchback configuration across refreshes. Treatment and variable exposures use the timestamp that resolved the period. The change adds experiment fields, assignment utilities, tests, and switchback documentation.

Priority: Normal

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

Change: Feature

Possibly related PRs

  • absmartly/java-sdk#12: Adds holdout fields and Context assignment/cache behaviour also touched by this PR.
  • absmartly/java-sdk#16: Adds canonical unit identifier conversion used by switchback cluster assignment.
Merge Risk: Minimal · up to 6c87d

This change adds switchback experiment serving to the SDK, and ordinary unit experiments keep their existing behavior. No actionable merge-blocking risk was identified in this review.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title "feat(switchback): serve switchback experiments (Phase 1B)" names the main change: the Java SDK now serves switchback experiments instead of treating them as sticky per-unit experiments. The…
Docstring Coverage Passed Docstring coverage is inconclusive: files from earlier reviews still need to be rechecked within the check's analysis limits. Not blocking.
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.
Full details: Title check

Explanation

The title "feat(switchback): serve switchback experiments (Phase 1B)" names the main change: the Java SDK now serves switchback experiments instead of treating them as sticky per-unit experiments. The changeset covers configuration fields, cluster bucketing, period assignment, exposure caching and documentation, all of which fall under this serving change. The title is specific and concise. The "Phase 1B" label refers to the project phase rather than the content, and the PR has since added Phase 1E hashed cluster buckets, which the title does not mention. This is a minor gap, not a mismatch.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

A rabbit marks the hours with care,
And maps each cluster to its share.
New periods bring variants round,
While exposures keep their timestamp sound.
Two slots wait beside the clock,
As carrots hop from block to block.

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

@jervasion-absmartly

jervasion-absmartly commented Oct 12, 2026 •

Copy link
Copy Markdown

Review submitted

The automated review has been submitted. See the review for the verdict and any findings.

Head: 6c87d6fd1e4b | Updated: 2026-10-30 00:24 UTC

@jervasion-absmartly jervasion-absmartly 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.

APPROVED WITH COMMENTS - The switchback serving change passes behavioral and compatibility checks; no merge blocker survives.

Reviewed head 24af47f747d762333458ee3e9b5548d27a458f49 against base/merge-base 34716f86fe6a0a3de8ca5c6c53562fa9400d45f6.

Scope and result

The change adds nullable experiment metadata, the balanced-block schedule, period-scoped treatment/variable assignments and exposures, and refresh-generation invalidation. I traced the public lookup methods through cache resolution, holdouts, overrides, timestamp selection and publishing. The actual affected surfaces match the declared SDK scope. No verified functional defect or measured blocker was established.

Rollout remains separate from merging this SDK change: the Collector must emit the fields, and all serving SDKs must be upgraded before enabling switchbacks. The linked Collector PR explicitly keeps creation beta-gated until serving clients are ready. The documented two-period deduplication bound and lazy retention of removed experiments are intentional limitations, not newly inferred guarantees. No deployed/customer adoption claim is needed for this verdict.

Validation

  • On verified JDK 11: ./gradlew :core-api:test :core-api:animalsnifferMain :core-api:jacocoTestCoverageVerification :core-api:jar --no-daemon passed: 463 tests, Java 6 API compatibility, coverage gates and JAR packaging.
  • Executed scenarios include 1h/24h boundaries, pre-anchor/null-start behavior, 2/3/10-arm schedules, hashed cluster identity, treatment and variable rollover, exposure deduplication across flushes, resolution-time timestamps, holdout suppression, overrides/custom assignments, sticky traffic, refresh/type/removal transitions, bounded cache/rewind behavior, concurrent same-period resolution and inline/async audience-refresh deadlock regressions.
  • All 882 assignment and 504 permutation golden vectors passed, including signed 64-bit boundaries.
  • git diff --check passed. The introduced-blob helper completed successfully; all 30 content writes across the 15 PR commits were audited, including superseded content.
  • Exact-head Java-SDK CI build completed successfully with ./gradlew clean build; the build job and its steps passed. Exact-head CodeRabbit commit status is also successful.
  • Local clean build could not initialize the Groovy formatter because download.eclipse.org failed DNS resolution. Formatter/full-build evidence therefore comes from the passing exact-head CI, not a claimed local pass. No production deployment or cross-SDK end-to-end rollout was performed.

Passes (non-blocking)

Run: core runtime/API/error/concurrency/performance; tests/CI/packaging; behavior-bearing data (wire fixture and golden vectors); cross-cutting simplicity (independent passes); comment hygiene and diff composition (primary audit). Not run: deployment/operations/observability (no deployment, operational configuration, metric/log/trace emission changed); shared frontend blast radius (no frontend source changed).

Comment hygiene (non-blocking)

113 comment / 1515 code added lines, ratio ~1:13.4; 8 blocks flagged.
threshold T=16; restatements counted: no (10 × C < E)
The following short blocks restate adjacent code and could be removed locally; none qualify for promotion:

  • Context.java:653: retry reads a fresh clock.
  • Context.java:1054: absent/unit experiments return the sentinel.
  • Context.java:1062: the sentinel uses the bare cache name.
  • Context.java:1235: assignment fields are compared by the named helper.
  • Context.java:1338: stale epochs take the write path.
  • ContextSwitchbackTest.java:66: describes nextRead control flow.
  • ContextSwitchbackTest.java:885: narrates the unchanged-identity assertion.
  • ContextSwitchbackTest.java:891: describes the refreshing matcher’s callback.
    Protocol parity, timing/concurrency rationale, external analysis constraints and fixture provenance comments were preserved.

Diff composition (non-blocking)

Every file is required for the change; none flagged. No scratch reports, generated build output or credential-bearing content was identified in the cumulative diff or introduced content writes.

Simplifications (non-blocking)

2 candidates; neither has a demonstrated, tested saving and neither affects this approval:

  • Context.java:997–1019,1235–1240: assignment grid snapshots overlap refresh-generation identity. A smaller alternative would retain the switchback boolean/type check and rely on epoch invalidation instead of the four copied grid fields and pinning helper. Do not apply blindly: equivalence requires immutable installed definitions; arbitrary mutation through returned data is a counterexample. LOC delta: not measured; no scratch equivalence claim.
  • Context.java:1076–1081: the sole caller already guarantees the second definition is switchback. The helper could reject only an absent/non-switchback predecessor and then compare identity fields, removing the unreachable two-non-switchback branch. LOC delta: not measured; below any demonstrated blocking threshold.

Run a simplification pass locally before pushing: these candidates cost less to address now than as a further review round.

@marcio-absmartly

Copy link
Copy Markdown
Contributor Author

On hold — do not merge. Product decision (Márcio, 2026-10-22): switchbacks move to hashed cluster buckets (10/50/100/1000 over the unit or a unit attribute, spec Revision 3.2 / Phase 1E). This PR implements per-value clustering, so it stays open and Phase 1E lands on this branch before the first switchback-capable release. The current approval covers the per-value version only.

…esolution

Port of lib:utils SwitchbackBuckets (MD5 bucket, attribute normalisation) and the
Collector's SwitchbackAttributePath, Java 1.6 compatible. Copies the platform's
bucket-golden-vectors.json and attribute-path-cases.json byte-identical and
reproduces all 702 bucket vectors, the unit-key and attribute-key vectors and
every path case.
…tribute

Both are optional; an absent field deserialises to null, so unit experiments
and older payloads are unchanged.
The schedule is drawn for bucketOf(seed, key, switchbackClusterCount) instead of
the per-value cluster. The key is the published unit hash, or the context
attribute at switchbackClusterAttribute resolved by the Collector's path rules
and normalised like the Collector (latest setAt at or before the call wins).

A disallowed or missing count or an unsupported path makes the grid
unevaluable (variant 0, not eligible), as the Collector's unservable rules do; a
unit without a key is ineligible. The key is read once per period slot, so a
setAttribute mid-period never moves a unit to another bucket (R32-3). Count and
attribute join the switchback identity, so changing them starts a new epoch.
Serves contexts through Context and compares every exposure with the
Collector's ContextAPI.switchbackVariant, taking the expected bucket from
bucket-golden-vectors.json and path outcomes from attribute-path-cases.json:
unit and attribute sources, top-level, nested and array paths, ineligible
values, disallowed counts and all three allowed counts.
With a null switchbackClusterUnitType the experiment's unitType alone chooses the
hashed unit, but it was not part of the switchback identity, so a replacement slot
inherited the previous unit type's cluster key.
…ute, warn on failure

Jackson recursed into a cyclic Map or List until StackOverflowError while the
contextLock_ write lock was held. An IdentityHashMap walk bounded at depth 64
rejects those values first, and every unencodable value now logs a warning naming
the attribute and the exception class. The outcome is unchanged: no cluster key.
@jervasion-absmartly
jervasion-absmartly dismissed their stale review October 23, 2026 05:33

New commits were added after this approval. Jervasion will reassess the updated head.

@marcio-absmartly

Copy link
Copy Markdown
Contributor Author

Phase 1E pushed onto this branch. Still on hold, do not merge.

The branch is now at e19fd59, which adds 17 commits on top of the approved 24af47f. They switch switchback assignment to hashed cluster buckets, so the approval no longer covers this head.

  • Bucket function: SwitchbackBuckets.bucketOf(seedHi, seedLo, key, count) returns MD5("sbcluster␟seedHi:seedLo␟" + key) (first 4 bytes, big-endian, unsigned) mod count, with count ∈ {10, 50, 100}. It reproduces all 702 platform golden vectors byte for byte.
  • Bucket key:
    • Unit source: the unit hash (getUnitHash).
    • Attribute source: the attribute named by switchbackClusterAttribute. It is resolved with the Collector's path rules (/ segments, quoted segments, array indices via Ints.tryParse semantics) and normalised through the same JSON round trip the Collector sees. Strings and whole numbers are accepted. Anything else, or a missing value, is ineligible.
  • Fields: two new fields, switchbackClusterCount and switchbackClusterAttribute, both null on unit experiments. Unit payloads and assignments are unchanged.
  • Within a period: the cluster key is pinned per (experiment, period). A setAttribute mid-period does not reassign; the change takes effect in the next period. A config change starts a new epoch. That covers unitType, count and attribute.
  • Removed: per-value clustering. A cross-unit-type cluster stays ineligible.
  • Parity: SwitchbackServerParityTest mirrors the Collector's ContextAPI assignment from abs#5596.
  • Review: performance, code-quality and test-quality reviews, one fix round, then the simplifier.
  • Tests: 463 → 501, all green. ./gradlew check is green, including spotless, animalsniffer (Java 1.6) and jacoco.

Platform counterpart: absmartly/abs#5596. Cross-SDK bucket scenarios will follow in cross-sdk-tests#13.

@jervasion-absmartly jervasion-absmartly 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.

❓ Needs a human decision

The new hashed-bucket assignment passes the SDK checks; I found no material code blocker in the changes since the earlier review. I am not renewing approval because Márcio’s latest instruction explicitly says “Still on hold, do not merge.” Passing tests do not clear that hold.

Next step: Márcio should confirm the remaining release/merge prerequisites and explicitly clear the hold before this PR is treated as merge-ready.

Suggestions

  • [nit] Correct the swapped fixture provenance hashes in the two helper test files (inline).
  • Seven short comment blocks could be trimmed; see review details. These are non-blocking.

Human review required

Question: What remains before the Phase 1E merge hold can be lifted, and may this exact head merge once those prerequisites are satisfied?

Why it matters: The latest author instruction retains the hold even after the bucket implementation landed. Cross-SDK bucket scenarios are explicitly still a follow-up; the discussion does not say whether they or other prerequisites must land first.

Evidence checked: Both author hold comments, the latest Phase 1E scope statement, the linked platform PR #5596, SDK documentation, the new tests, and exact-head CI.

Needed from: Márcio / the switchback product and release owner.

What I checked

  • ✅ All 501 SDK tests pass, including bucket/path fixtures, JSON normalization, period pinning, refresh invalidation, concurrent attribute updates, exposures and unchanged unit behavior.
  • ✅ All 702 bucket vectors also match an independent Python hashlib calculation; existing assignment/permutation vectors pass.
  • ✅ Java 6 API compatibility, coverage gates and JAR packaging pass on JDK 11.
  • ✅ Exact-head Java-SDK CI build and CodeRabbit status pass. CI covers the formatter/full build; the local full build could not download the Groovy formatter because download.eclipse.org failed DNS resolution.
  • ⚠️ The explicit product merge hold remains. No coordinated Collector/warehouse/cross-SDK deployment validation is claimed.
Review details

Scope and intent

  • Review round 2, exact head e19fd59713cefd90ee5adcb4e22bc36c92060ff3.
  • Supplied base and verified merge-base: 34716f86fe6a0a3de8ca5c6c53562fa9400d45f6.
  • Accepted earlier head / responsibility baseline: 24af47f747d762333458ee3e9b5548d27a458f49. Reviewed the 17 intervening commits and their 15-file delta, with surrounding whole-PR runtime context. Previously accepted behavior was not reopened.
  • Intended change: replace per-value switchback clusters with 10/50/100 hashed buckets, sourced from the published unit hash or a normalized attribute path; carry the two new nullable fields and invalidate epochs for cluster configuration/unit-type changes. Actual consumers are treatment/variable resolution, assignment caches, exposure eligibility, model deserialization/equality and downstream Collector/analysis parity.
  • Owner-reported validation: 463 → 501 tests and ./gradlew check. Independently executed evidence is below.
  • Verified facts: supported counts and hash formula match the latest owner statement and platform counterpart; SDK tests exercise key pinning, missing keys, traffic restoration, audience revalidation and epoch changes. The author explicitly keeps cross-unit-type clusters ineligible and retains the merge hold.
  • Supported inference (high confidence): Phase 1E is pre-release integration work, based on the explicit first-release hold and linked beta-gated platform work. No customer-adoption or deployed-state claim is needed for this review. Remaining merge prerequisites are unknown and require the owner decision above.

Validation commands and outcomes

  • Verified /usr/lib/jvm/java-11-openjdk-arm64/bin/java -version and javac -version: both 11.0.32.1.
  • JAVA_HOME="$JAVA_HOME_11" ./gradlew clean build --no-daemon: blocked during formatter task initialization by DNS for download.eclipse.org; not a local passing build.
  • JAVA_HOME="$JAVA_HOME_11" ./gradlew :core-api:test :core-api:animalsnifferMain :core-api:jacocoTestCoverageVerification :core-api:jar --no-daemon: passed, 501 tests, no failed/skipped tests reported, compatibility/coverage/package tasks passed.
  • Independent Python calculation: signed seed halves → UTF-8 sbcluster prefix/key → MD5 → unsigned first four big-endian bytes modulo count: 702 vectors, zero mismatches.
  • git diff --check 24af47f e19fd597: passed.
  • Exact-head Java-SDK run 39906412546: completed successfully; build job and ./gradlew clean build step successful. Exact-head CodeRabbit commit status: success.
  • No repository AGENTS.md or CLAUDE.md found. Read root/module build configuration, relevant documentation and workflow. Two independent read-only passes covered runtime/parity/cache/error paths and tests/packaging/simplicity; primary verified surviving feedback and owned the verdict.

Passes (non-blocking)

Run: core runtime/API/error/concurrency/performance (four executable source files changed); tests/CI/packaging (new helpers, new public assignment behavior and tests); deployment/operations/observability (new warning emissions on attribute encoding/cycle failures); behavior-bearing data (new golden/path fixtures and experiment wire fixture); cross-cutting simplicity; primary comment hygiene and diff composition.

Not run: shared-component/frontend blast radius (no frontend source changed). No deployment manifests, migrations or operational commands changed; operational review was limited to the new warning/failure paths.

Comment hygiene (non-blocking)

117 comment / 1303 code added lines, ratio ~1:11.1; 7 blocks flagged.

threshold T=14; restatements counted: no (10 × C < E)

All locations below are relative to core-api/src/; these short blocks restate adjacent implementation/test setup:

  • main/java/com/absmartly/sdk/Context.java:1293–1294: “Set once the cluster key was read … null for a unit without one.”
  • main/java/com/absmartly/sdk/Context.java:1851: “Parsed switchbackClusterAttribute; null for the unit source or an unsupported path.”
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:26: “Parallel to keys; null where the segment is a map key.”
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:74: “Each of these decodes back to an equal value of the same type.”
  • test/java/com/absmartly/sdk/SwitchbackServerParityTest.java:50: the bucket-map key layout.
  • test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java:200: “Variant 0’s own config, like any ineligible assignment.”
  • test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java:108: “65 nested maps: one past the depth bound.”

These restatements do not count toward promotion at this density. Protocol constraints, Collector normalization/path quirks, fixture provenance, concurrency rationale and deliberately pinned per-period behavior were preserved rather than flagged.

Diff composition (non-blocking)

Every file is required for the change; none flagged. The pinned introduced_blobs.py helper completed successfully for the full merge-base-to-head range: 65 content writes (35 since approval), including superseded source/doc/test content. Inspected their content and the cumulative file inventory; no scratch artifacts or credential-bearing content identified. README credential examples are placeholders, not live credentials.

Simplifications (non-blocking)

None. No concrete behavior-preserving construct elimination with demonstrated savings was established in the post-approval delta.

@jervasion-absmartly jervasion-absmartly 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.

❓ Needs a human decision

The SDK checks pass, but Márcio’s explicit “Still on hold, do not merge” instruction remains. This round also reproduced an attribution case that needs an owner decision: a peek can pin one attribute-backed bucket while the later exposure records attributes for another. I am not asserting an analysis defect or renewing approval without that decision.

Next step: Márcio / the switchback analysis owner should confirm the intended attribution rule below and the prerequisites for clearing the hold.

Human review required

Question: Is this intended: peekTreatment with city=Lisboa, then setAttribute(city, Porto) in the same period, then the first getTreatment, serves Lisboa’s pinned variant but publishes an exposure whose attribute at exposedAt is Porto? If so, where is that single-exposure case reconciled or explicitly accepted? There is no earlier exposure to reveal two observed cluster values.

Why it matters: The executed reproduction uses count 100, seeds −5/−7, three arms and period 0. Lisboa selects variant 0, Porto selects variant 1; the single eligible exposure contains variant 0 at the later timestamp. The Collector selects Porto at that timestamp, and attribute-mode analysis derives its bucket from that recorded attribute. Period pinning itself is explicitly intended; the question is how attribution should remain consistent, not whether to remove pinning. This answer and the existing merge hold prevent a merge-readiness verdict.

Evidence checked: SDK cache/publish paths and reproduced payload; the owner’s Phase 1E comment; Collector and analysis code at abs#5596 head 890ed8a9; notes documenting exclusion when multiple cluster values occur across exposure rows. Those notes do not establish handling for a peek followed by only one exposure. Full production analysis orchestration and the external specification were not independently exercised.

Needed from: Márcio / switchback product and analysis owner. Please also clarify what remains before the existing hold can be lifted.

Suggestions

  • Seven short comment blocks remain optional cleanup; see details. The existing fixture-provenance nit is acknowledged as fixed locally but not pushed; no duplicate inline comment is added.

What I checked

  • ✅ 501 SDK tests, no failures or skips; Java 6 compatibility, coverage gates and JAR packaging pass on JDK 11.
  • ✅ Golden vectors, attribute normalization/path cases, rollover, key pinning, refresh invalidation, concurrency and unchanged unit behavior are exercised.
  • ✅ A separate temporary test reproduced the peek → attribute update → first-exposure attribution case.
  • ✅ Exact-head Java-SDK CI and CodeRabbit status pass. CI supplies full-build/formatter evidence; local full build cannot initialize the formatter because download.eclipse.org fails DNS resolution.
  • ⚠️ The product merge hold remains, and the attribution contract needs an owner decision.
Review details

Scope and evidence

Round 3; exact head e19fd59713cefd90ee5adcb4e22bc36c92060ff3. Supplied base and verified merge-base 34716f86fe6a0a3de8ca5c6c53562fa9400d45f6. Accepted prior head / post-approval responsibility baseline 24af47f747d762333458ee3e9b5548d27a458f49. Head is unchanged since round 2. Re-read source, tests, documentation, discussion, reviews, inline reply and CI. The unpublished cc22ea2 is not reviewed. No prior defect acceptance criterion remains open; the prior human decision on the hold remains unanswered.

Intent: replace per-value clusters with 10/50/100 hashed buckets over a unit hash or normalized attribute path, pin keys within a period, and invalidate epochs on configuration changes. Actual consumers include treatment/variable lookup, exposure caching/publishing, model deserialization and downstream Collector/analysis parity. The PR body describes earlier Phase 1B; the latest owner comment supplies Phase 1E scope and reports 501 tests plus ./gradlew check.

Verified facts: SDK pinning/timestamps, reproduced payload, Collector timestamp-based selection, analysis deriving a bucket from row attributes, passing CI and explicit hold. Supported inference (high confidence): pre-release integration work, supported by first-release hold and linked platform work; no customer-adoption claim needed. Unknown: intended end-to-end handling of the single-exposure discrepancy and remaining hold prerequisites. No defect severity is assigned to that unknown.

The new question concerns independent Phase 1E code, not remediation. Earlier review tested pinning and payload timing separately; this round combined a non-exposing peek, attribute change and first exposure, then traced the result through the linked Collector and analysis code. This new evidence warrants a narrow contract question without reopening accepted pinning behavior.

Validation

  • Verified /usr/lib/jvm/java-11-openjdk-arm64/bin/java -version and javac -version: 11.0.32.1.
  • JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew :core-api:test :core-api:animalsnifferMain :core-api:jacocoTestCoverageVerification :core-api:jar --no-daemon: passed. Independent pass executed the suite; primary repeated successfully and inspected XML: 501 tests, 0 failures/errors/skips.
  • JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew clean build --no-daemon: blocked during Eclipse Groovy formatter initialization by DNS, not a source/test failure.
  • Scratch test ReviewAttributionTest.peekThenAttributeUpdatePublishesLaterKeyWithEarlierVariants, run with ./gradlew :core-api:test --tests com.absmartly.sdk.ReviewAttributionTest.peekThenAttributeUpdatePublishesLaterKeyWithEarlierVariants --no-daemon on JDK 11: 1 test passed, asserting the discrepant payload. Production code unmodified.
  • Independent runtime pass recalculated all 702 bucket vectors: zero mismatches. Assignment/permutation vectors pass in the suite.
  • git diff --check 24af47f HEAD: passed. No applicable AGENTS.md or CLAUDE.md found; read README compatibility guidance, switchback contract, build/compatibility configuration and workflows.
  • Exact-head Java-SDK run 39906412546: completed successfully, including build job/full build step. CodeRabbit commit status: success.
  • Collector timestamp extraction, latest attribute selection, and analysis bucket derivation checked directly. Contamination notes address multiple observed values. This is a code/payload trace, not coordinated Collector/warehouse/cross-SDK end-to-end validation.

Passes (non-blocking)

Run: independent core runtime/API/security/data/error/concurrency/performance (executable source/cache behavior changed); independent tests/CI/packaging and cross-cutting simplicity (new helpers/tests/public assignment behavior); behavior-bearing data (wire/path/golden fixtures); operations/observability limited to new encoding/cycle warning paths; primary comment hygiene/diff composition. Not run: shared-component/frontend blast radius (no frontend source changed); deployment procedures/manifests (none changed).

Comment hygiene (non-blocking)

117 comment / 1303 code added lines, ratio ~1:11.1; 7 blocks flagged. Independently recomputed over 24af47f..e19fd597 Java source/tests.

threshold T=14; restatements counted: no (10 × C < E)

All seven are short restatements; paths relative to core-api/src/:

  • main/java/com/absmartly/sdk/Context.java:1293–1294: “Set once the cluster key was read … null for a unit without one.”
  • main/java/com/absmartly/sdk/Context.java:1851: “Parsed switchbackClusterAttribute; null for the unit source or an unsupported path.”
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:26: “Parallel to keys; null where the segment is a map key.”
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:74: “Each of these decodes back to an equal value of the same type.”
  • test/java/com/absmartly/sdk/SwitchbackServerParityTest.java:50: bucket-map key layout.
  • test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java:200: “Variant 0’s own config, like any ineligible assignment.”
  • test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java:108: “65 nested maps: one past the depth bound.”

Protocol constraints, normalization/path quirks, fixture provenance, concurrency rationale and deliberate period-pinning constraints preserved.

Diff composition (non-blocking)

Every file is required for the change; none flagged. Pinned introduced_blobs.py completed successfully for full merge-base-to-head range: 65 content writes including superseded content (35 since acceptance). Audited destinations and blob contents for unnecessary artifacts/embedded credentials; none identified. README credential examples are placeholders.

Simplifications (non-blocking)

None. No concrete behavior-preserving construct elimination with demonstrated savings established in post-approval changes. The resolved-key boolean distinguishes unread from read-and-missing, and the JSON round trip carries tested normalization semantics.

@jervasion-absmartly jervasion-absmartly 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.

❓ Needs a human decision

The full PR passes the SDK checks, with no new verified blocking finding. The code and owner discussion are unchanged since round 3: the explicit do-not-merge hold and the attribution question remain open. A new review request does not establish that either has been cleared.

Next step: Márcio / the switchback analysis owner should answer the attribution question and explicitly state the remaining merge prerequisites or clear the hold.

Suggestions

  • Two optional simplification candidates and 15 short comment blocks are listed in the details. These do not block merging. The existing fixture-hash nit remains acknowledged but unpushed; no duplicate inline feedback is added.

Human review required

Question: For peekTreatment with city=Lisboa, then setAttribute(city, Porto) in the same period, then the first exposing lookup: is serving the earlier pinned variant while recording Porto at exposure time intended, and where is the single-exposure discrepancy reconciled or explicitly accepted? Also, what remains before the existing hold can be lifted?

Why it matters: Round 3 reproduced one eligible exposure with Lisboa’s variant 0, although Porto’s expected variant is 1 for the test seed/count/period. Collector selects the later attribute at exposedAt; attribute-mode analysis derives its bucket from that attribute. Multiple-observed-bucket exclusion does not by itself establish handling for a peek followed by only one exposure. Pinning is explicitly intended, so this remains a contract question, not a demand to remove it or an asserted analysis defect.

Evidence checked: Current PR description, all owner comments and reviews/replies; unchanged SDK cache/publish paths and round 3 reproduction; pinned Collector/analysis evidence at abs#5596 SHA 890ed8a9bec9c2e2bf7844ba7656210d6592c330, including contamination notes. No owner response clears either question.

Needed from: Márcio / switchback product, release and analysis owner.

What I checked

  • ✅ Fresh execution: 501 tests, no failures; Java 6 API compatibility, coverage verification and JAR packaging pass on JDK 11.
  • ✅ Full PR scheduling/cache/refresh/exposure behavior rechecked, including boundaries, holdouts, variable lookup, period retention, overrides and concurrency. Golden-vector suites pass.
  • ✅ Exact-head Java-SDK CI and CodeRabbit status pass.
  • ⚠️ Local full build still cannot initialize the Groovy formatter because download.eclipse.org fails DNS resolution; passing exact-head CI supplies full-build evidence.
  • ⚠️ The owner’s merge hold and intended downstream attribution behavior remain unresolved.
Review details

Scope, intent and evidence

Round 4, triggered by a new review_requested event. Head e19fd59713cefd90ee5adcb4e22bc36c92060ff3; supplied base and verified merge-base 34716f86fe6a0a3de8ca5c6c53562fa9400d45f6. This new request uses the full merge-base-to-head range as responsibility baseline, rather than the post-approval range of rounds 2–3. Rechecked all 20 changed files and full-PR context; there is no code delta since round 3.

Intent: add switchback serving, nullable wire metadata, deterministic balanced-block schedules, period-specific assignment/exposure state and refresh generations; Phase 1E changes clustering to 10/50/100 hashed buckets over unit hashes or normalized attribute paths. Actual consumers include treatment and variable resolution, holdouts/overrides, assignment/exposure caches, model deserialization and downstream Collector/analysis parity. Latest owner-reported tests are 501 plus ./gradlew check; the older PR description reports 463.

Known intentional limits remain the two-period deduplication window, lazy retention for removed/unqueried experiments, same-period key pinning and no old-client capability filter. Collector emission and coordinated SDK rollout remain integration dependencies.

Verified: unchanged head, passing local checks/CI, intended key pinning, previous emitted-payload discrepancy and explicit hold. Supported inference (high confidence): pre-release integration work, based on the first-release hold and foundational linked work; no customer-adoption assertion needed. Unknown: intended single-exposure attribution handling and outstanding hold prerequisites. No defect severity assigned to those unknowns.

No unresolved original defect blocker, remediation regression or new independent verified finding was established. The unanswered round 3 question remains a human decision, not a changes-request acceptance criterion.

Validation and review passes

Verified Java and javac 11.0.32.1 at /usr/lib/jvm/java-11-openjdk-arm64. Fresh command:
JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew :core-api:test :core-api:animalsnifferMain :core-api:jacocoTestCoverageVerification :core-api:jar --no-daemon — all seven tasks executed successfully; 501 tests passed.

JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew clean build --no-daemon — blocked during formatter task initialization by UnknownHostException: download.eclipse.org, not a test/source failure.

git diff --check 34716f86 HEAD passed. Full-range changed fixtures parse successfully. Independent runtime pass recalculated 882 assignment and 504 permutation vectors with zero mismatches; earlier independent calculation of 702 bucket vectors applies to this unchanged head. Round 3’s temporary attribution reproduction is reused, not claimed as freshly rerun. No coordinated Collector/warehouse/cross-SDK end-to-end execution is claimed.

Exact-head Java-SDK run 39906412546, build job and full build step are completed/successful. Exact-head CodeRabbit status is success. CI is not the reason approval is withheld.

No applicable AGENTS.md or CLAUDE.md found. Repository compatibility/build guidance, switchback contract and workflows remain unchanged. Two independent passes re-inspected full runtime and tests/packaging/simplicity scope.

Passes (non-blocking)

Run: core runtime/API/security/data/error/concurrency/performance (five executable Java files changed); tests/CI/packaging (behavioral tests/public assignment and exposure changes); behavior-bearing data (wire, path and golden fixtures); operations/observability (attribute-encoding/cycle warning emissions); cross-cutting simplicity; primary comment hygiene and diff composition. Not run: shared-component/frontend blast radius (no frontend source changed). No deployment manifests/procedures changed; operational review is limited to failure/warning behavior.

Comment hygiene (non-blocking)

222 comment / 2791 code added lines, ratio ~1:12.6; 15 blocks flagged over the full new-request baseline.

threshold T=28; restatements counted: no (10 × C < E)

All listed blocks restate adjacent code or setup; none qualify for promotion. Paths below are relative to core-api/src/:

  • main/java/com/absmartly/sdk/Context.java:655: retry reads a fresh clock.
  • main/java/com/absmartly/sdk/Context.java:1071: absent/unit experiments return the sentinel.
  • main/java/com/absmartly/sdk/Context.java:1107: sentinel resolutions use the bare name.
  • main/java/com/absmartly/sdk/Context.java:1285: grid fields are compared by the named helper.
  • main/java/com/absmartly/sdk/Context.java:1293–1294: resolved key is the key or null.
  • main/java/com/absmartly/sdk/Context.java:1394: stale epochs take the write path.
  • main/java/com/absmartly/sdk/Context.java:1851: parsed path field is null for unit/unsupported paths.
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:26: indices parallel keys.
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:74: listed primitive types decode to the same value/type.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:70: nextRead control flow.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:891: unchanged identity keeps the epoch.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:897: describes the refreshing matcher callback.
  • test/java/com/absmartly/sdk/SwitchbackServerParityTest.java:50: bucket-map key layout.
  • test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java:200: variant 0’s own config.
  • test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java:108: 65 nested maps exceed depth bound.

Protocol parity, external constraints, normalization quirks, concurrency/timing rationale and deliberate pinning semantics preserved.

Diff composition (non-blocking)

Every file is required for the change; none flagged. The pinned introduced_blobs.py helper was rerun successfully: 65 content writes over the full range, identical to the round 3 audited set. Reused that unchanged content audit, including superseded blobs; no unnecessary artifacts or credential-bearing content identified. README credential examples are placeholders.

Simplifications (non-blocking)

Two candidates carried forward from the first full-range review; neither has a demonstrated tested saving or affects the verdict:

  • Context.java:1002–1026,1285–1292,1395–1397: assignment grid snapshots overlap refresh generations. Smaller alternative: retain the switchback discriminator and rely on generations instead of duplicate field snapshots/comparison/copying. Conditional only: installed Experiment objects are mutable and getData exposes them, so in-place changes can invalidate snapshots without changing generations. Equivalence requires an established immutable-definition contract; do not blindly remove the checks or add defensive-copy machinery for this cleanup. LOC delta: not measured.
  • Context.java:1123–1128: sole caller at 1999–2005 already guarantees the second definition is a non-null switchback. Smaller alternative: reject only an absent/non-switchback predecessor, then compare identity fields, removing the unused both-non-switchback branch and second discriminator. LOC delta: not measured; no blocking threshold claim.

Run a simplification pass locally before pushing: these candidates cost less to address now than as a further review round.

@joalves

joalves commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Pushed dc0fb50 for the java-sdk 1.7.0 work (#16): new errorAnyOf expectation in the harness (+4 harness tests, 107 pass); scenarios 147/148/149/188/189 accept Context finalized or Context closed; Java wrapper waits for the real lifecycle event after the SDK future completes and reads units through getUnitValue/getUnitValues; core-api pin 2.0.0 -> 1.7.0. Java lane against the java-sdk#16 checkout: 222 pass, 0 fail, 7 skipped.

A numeric UID reaches switchback only through the shared unit hash
(Hashing.unitToString, JavaScript toFixed(0)), so its schedule, bucket,
published unit and exposures match the equivalent string UID at 10 and
100 buckets. Pinned against an independent MD5/murmur3 reference.
…omments

attribute-path-cases.json is blob 7b1d3fe and bucket-golden-vectors.json
is blob c2da442; the two comments had them swapped.
@marcio-absmartly

Copy link
Copy Markdown
Contributor Author

Freshened against main (no rebase) and pushed: 6c87d6fd1e. This also releases the earlier development hold: Márcio asked for this PR to be made merge-ready (CI green + Jerv approval). No merge is authorized by this update.

  • f8c5968 merges main a15a7a942d (feat: numeric unit UIDs, keeping 1.6.3 compatibility — 1.7.0 #16 numeric unit UIDs, 1.7.0). Zero conflicts; --remerge-diff empty; version stays main's 1.7.0.
  • Numeric UID integration audit: every switchback read of the unit (unit-sourced cluster key, bucket, traffic, holdouts, publish) goes through feat: numeric unit UIDs, keeping 1.6.3 compatibility — 1.7.0 #16's shared getUnitHash → Hashing.unitToString, so no production change was needed. a095da9 adds two tests: numeric UIDs vs their canonical string at 10 and 100 buckets (schedule, published unit, exposures), and a golden check against an independent Python MD5/murmur3 reference (123456789 → bucket 0/10, 2^53+1 → 7/37). Four mutants (Long skipping double rounding, raw-number cluster key, raw-number publish hash, Object.toString) all fail both tests.
  • 93ec673 fixes the swapped fixture provenance hashes (Jerv nit).
  • 6c87d6f test-helper simplification only.
  • :core-api:check (spotless, animalsniffer Java 1.6, jacoco): 522 pass, 0 fail/skip (was 501 at e19fd59; main 436). All 882 assign / 504 permutation / 702 bucket / attribute-path golden vectors pass.
  • Test IDs: all 82 switchback tests retained (+2). Three ContextTest IDs (returnsDefaultsWhenNotReady, refreshDoesNotCallPublishWhenFailed, closeAsyncReturnsSameFutureWhenCalledTwice) are main feat: numeric unit UIDs, keeping 1.6.3 compatibility — 1.7.0 #16's own deletions with replacements; our ContextTest.java is byte-identical to main's.
  • Independent narrow review PASS: pre-ready reads throw identically for switchback and ordinary experiments; failed load/refresh and close-retry keep switchback caches correct; M2 reproduced and killed; 522/522.

@jervasion-absmartly jervasion-absmartly 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.

❓ Needs a human decision

The development hold is now explicitly lifted, and the updated SDK passes its checks. The separate attribution question from rounds 3–4 remains unanswered, so I cannot yet establish that this exact head is merge-ready. No new verified blocking defect was found.

Next step: Márcio / the switchback analysis owner should confirm how the single-exposure case below is reconciled, or explicitly accept its specific consequence.

Human review required

Question: Is it intended that peekTreatment with city=Lisboa, followed by setAttribute(city, Porto) in the same period and then the first getTreatment, serves Lisboa’s pinned variant while the only exposure is attributed to Porto? Where is that case reconciled or explicitly accepted?

Why it matters: The earlier reproduction selected variant 0 for Lisboa versus variant 1 for Porto (100 buckets, seeds −5/−7, three arms, period 0). The current SDK still pins the earlier key but timestamps the exposure at the later call. Its payload contains both timestamped attributes; Collector selects Porto at that timestamp, and attribute-mode analysis derives the bucket from the recorded row attribute. Exclusion based on multiple observed buckets across exposure rows does not establish handling for this one-row case. This is an unresolved contract question, not an asserted analysis defect or a request to remove intentional period pinning.

Evidence checked: Current SDK cache, attribute and publish paths; SWITCHBACK.md; previous reviews and the latest owner comment releasing the hold; current platform counterpart abs#5596 at 67e9fced7707e4a9b75af975875242475f894981, including Collector attribute selection, analysis bucket derivation and contamination notes. The general request to make the PR merge-ready does not explicitly answer or accept this specific case.

Needed from: Márcio / switchback product and analysis owner.

Suggestions

  • Two optional simplification candidates and 15 short comment blocks remain in the details. The swapped fixture-provenance hashes are fixed.

What I checked

  • ✅ Fresh execution: 522 tests, zero failures/errors/skips; Java 6 API compatibility, coverage gates and JAR packaging pass on JDK 11.
  • ✅ Numeric UID integration, golden schedules/buckets, attribute paths and normalization, period rollover/pinning, refresh, holdouts, exposure timing and concurrency are covered by the passing suites.
  • ✅ Exact-head Java-SDK CI full build passed. Local full-build initialization is blocked by formatter-download DNS; CI supplies that evidence.
  • ⚠️ End-to-end attribution for the single-exposure sequence still needs the owner decision above; CI is not the reason approval is withheld.
Review details

Scope, intent and evidence

Round 5. Head 6c87d6fd1e4bdddbcc07be857681c932caf46fab; base and verified merge-base a15a7a942dcc6bbe7bb025faa6faabc61ea5c3c0. Reviewed the full cumulative range, all 20 changed files, branch commits, discussion, previous reviews/replies and exact-head CI. Main was merged without rebasing; the latest additions integrate numeric UID tests, correct fixture hashes and share a publish helper. No applicable AGENTS.md or CLAUDE.md was found; README, switchback contract and build/compatibility/workflow documentation were read.

Intent: serve switchbacks on a deterministic balanced-block time grid using 10/50/100 hashed buckets from unit hashes or normalized attribute paths, retain period-specific assignment/exposure state and invalidate refresh generations. Actual consumers include treatment and variable APIs, holdouts, overrides, deserialization, publishing and downstream Collector/analysis. This supersedes the older Phase 1B description of per-value clustering. The latest owner reports 522 passing tests and explicitly releases the development hold.

Known intentional limits: two retained period indices and best-effort deduplication on re-entry; lazy retention for removed/unqueried experiments; same-period cluster-key pinning; old clients ignore switchback metadata. Collector emission and coordinated serving-client rollout are separate integration dependencies.

Verified facts: current SDK data/control flow, passing local tests and exact-head build, corrected fixture hashes and explicit hold release. Supported inference (high confidence): foundational pre-release switchback integration, supported by the branch history and first-capable-release discussion; no customer-adoption assertion is required. Unknown: intended downstream handling or specific risk acceptance of the one-exposure attribution discrepancy. Current Collector selects attributes at exposedAt (ContextAPI.java:599–601,772–784); current analysis derives the bucket from those attributes (ClickHouseSwitchbackExpr.java:108–127). Platform notes describe multi-row contamination exclusions and leave experiment-aware orchestration outside that work. No coordinated warehouse/engine end-to-end execution is claimed. The earlier scratch reproduction is prior evidence, not a freshly executed test this round; the current flow was independently re-traced.

Convergence summary — review round 5

  • Unresolved original blockers: 0
  • Remediation regressions: 0
  • Independent PR-change findings: 0
  • Acceptance criteria still open: none for a verified defect; the prior attribution contract question remains open.
  • Human adjudication requested: yes — the analysis owner must establish reconciliation or explicitly accept the specific single-exposure consequence. No unsuccessful-remediation lineage is asserted.

The earlier hold question is closed by the owner's latest comment. The prior fixture-hash suggestion is satisfied. The earlier attribution question remains distinct from both; no new inline duplicate is posted.

Validation

Verified Java and javac 11.0.32.1 at /usr/lib/jvm/java-11-openjdk-arm64.

  • JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew :core-api:test :core-api:animalsnifferMain :core-api:jacocoTestCoverageVerification :core-api:jar --no-daemon: passed, seven tasks executed. XML totals: 522 tests, 0 failures, 0 errors, 0 skips.
  • JAVA_HOME=/usr/lib/jvm/java-11-openjdk-arm64 ./gradlew clean build --no-daemon: could not initialize Eclipse Groovy formatter because download.eclipse.org fails DNS resolution. Not a source/test failure.
  • Exact-head Java-SDK run 40978086901, build job 123810988376, including Build Java SDK: completed/success. Workflow runs ./gradlew clean build without path filtering. Commit-status endpoint contained no statuses; its aggregate pending label is not a posted pending status. No pending required Actions job was observed.
  • Golden suites passed: 882 assignments, 504 permutations, 702 bucket vectors and attribute-path cases. Numeric tests include canonical numeric strings, fractional rounding and a long above 2^53 with literal independent expected schedules and published hashes/exposures.
  • git diff --check a15a7a9 HEAD: passed. Fixture blob hashes match their corrected comments. ContextTest.java is unchanged against the supplied base; main's lifecycle test replacements are not deletions by this PR.

Passes (non-blocking)

Run: independent core runtime/API/security/data/error/concurrency/performance (five executable Java files); independent tests/CI/packaging (new behavioral suites, observable assignment/exposure behavior); independent cross-cutting simplicity; behavior-bearing data (wire and golden fixtures); operations/observability (new attribute-encoding/cycle warning paths); primary comment hygiene and diff composition. Not run: shared-component/frontend blast radius (no frontend files). No deployment manifests or operational procedures changed.

Comment hygiene (non-blocking)

227 comment / 2842 code added lines, ratio ~1:12.5; 15 blocks flagged.

threshold T=29; restatements counted: no (10 × C < E)

All listed blocks restate adjacent code/setup; none qualifies for promotion. Paths below are relative to core-api/src/:

  • main/java/com/absmartly/sdk/Context.java:687: retry reads a fresh clock.
  • main/java/com/absmartly/sdk/Context.java:1117: absent/unit experiments return the sentinel.
  • main/java/com/absmartly/sdk/Context.java:1153: sentinel resolutions use the bare name.
  • main/java/com/absmartly/sdk/Context.java:1331: grid snapshot is compared by the named helper.
  • main/java/com/absmartly/sdk/Context.java:1339–1340: resolved key is the key or null.
  • main/java/com/absmartly/sdk/Context.java:1440: stale epochs take the write path.
  • main/java/com/absmartly/sdk/Context.java:1897: parsed path is null for unit/unsupported paths.
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:26: indices parallel keys.
  • main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java:74: listed primitives decode to equal values/types.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:70: nextRead control flow.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:891: unchanged identity keeps epoch.
  • test/java/com/absmartly/sdk/ContextSwitchbackTest.java:897: refreshing audience callback.
  • test/java/com/absmartly/sdk/SwitchbackServerParityTest.java:50: bucket-map key layout.
  • test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java:262: variant 0’s own config.
  • test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java:108: 65 nested maps exceed depth bound.

Preserved protocol parity, external constraints, numeric canonicalization, normalization quirks, concurrency/timing rationale, fixture provenance and deliberate key-pinning semantics.

Diff composition (non-blocking)

Every file is required for the change; none flagged. The pinned introduced_blobs.py completed successfully over merge-base..head: 71 content writes / 71 unique blobs, including superseded content and merge-written resolutions. Destinations and content audited for stray artifacts and embedded credentials; README API-key examples are placeholders.

Simplifications (non-blocking)

Two candidates, neither demonstrated as an equivalent tested alternative or claimed as a blocker:

  • Context.java:1048–1073,1333–1338: grid snapshots overlap refresh generations. Smaller alternative: retain the switchback/type discriminator and rely on generations instead of duplicate field snapshots/comparisons. Conditional: Experiment objects remain mutable and exposed via getData; in-place changes can invalidate snapshots without changing generations. An immutable-definition contract is needed before removing the checks. LOC delta: not measured.
  • Context.java:1169–1174: the sole generation caller already guarantees its second argument is a non-null switchback. Smaller alternative: reject only an absent/non-switchback predecessor before comparing identity fields, removing unused both-non-switchback handling. LOC delta: not measured.

Run a simplification pass locally before pushing: these candidates cost less to address now than as a further review round.

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.

3 participants