Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
99e59e9
feat(abtest): add hypothesis metadata for pre-registration
imjlk Jul 25, 2026
42ecc34
feat(abtest): add recipient-domain stratification for assignment
imjlk Jul 25, 2026
39dafac
fix(abtest): validate totalAudience and assert quota convergence
imjlk Jul 25, 2026
d0a34c3
fix(abtest): harden hypothesis pre-registration integrity
imjlk Jul 25, 2026
60ef214
fix(abtest): verify locked hypothesis checksum at load time
imjlk Jul 25, 2026
461952d
fix(abtest): wire hypothesis+stratification into production paths
imjlk Jul 25, 2026
e79acec
fix(abtest): isolate stratification from provisioning failure path
imjlk Jul 25, 2026
853d6bf
fix(abtest): preserve legacy manifests, harden stratification validation
imjlk Jul 25, 2026
54d895b
fix(abtest): export classifier, validate quota components, verify pre…
imjlk Jul 25, 2026
48bf2c8
fix(abtest): forward stratification policy from create flows
imjlk Jul 25, 2026
d3e9078
feat(abtest): expose hypothesis+stratification through CLI, update ro…
imjlk Jul 25, 2026
750d9aa
fix(abtest): reject null hypothesis JSON, strict ISO at persistence b…
imjlk Jul 25, 2026
0e0ea0e
fix(abtest): boolean parsing, small-stratum merge, quota search, docs
imjlk Jul 25, 2026
3865d84
fix(abtest): bare flag, lockedAt validation, unknown merge, cell cove…
imjlk Jul 25, 2026
d0fba92
fix(abtest): stratum row coverage, pre-summary hypothesis validation
imjlk Jul 25, 2026
9788e08
fix(abtest): require consistent group keys across quota rows
imjlk Jul 25, 2026
26b2936
fix(abtest): empty-email guard, consistent totalAudience, family-key …
imjlk Jul 25, 2026
433ef74
fix(abtest): owner.displayName validation, docs accuracy
imjlk Jul 25, 2026
8b61ea5
fix(abtest): require manifest for manifest_v1 provenance
imjlk Jul 25, 2026
2097d24
docs(abtest): clarify Korean stratification does not change assignments
imjlk Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/abtest-hypothesis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
npm/@listmonk-ops/abtest: minor (Added)
---

Add hypothesis metadata for A/B test pre-registration: structured objective, primary metric, expected lift, owner, and experiment scope with canonical checksum locking. AbTest gains optional hypothesis and assignmentProvenance fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the new public experimentation APIs bilingually

The changesets publish hypothesis locking and recipient-domain stratification as minor user-facing additions, but neither README.md nor README_ko.md documents their contracts, validation rules, or usage, and the package README is unchanged as well. Add matching English and Korean guidance so operators and library consumers can discover and correctly use the newly exported behavior.

AGENTS.md reference: AGENTS.md:L233-L237

Useful? React with 👍 / 👎.

5 changes: 5 additions & 0 deletions .sampo/changesets/abtest-stratification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
npm/@listmonk-ops/abtest: minor (Added)
---

Add recipient-domain stratification for A/B test assignment: classify subscribers into provider strata and compute a constrained quota matrix where row sums match stratum sizes and column sums match exact variant/holdout counts. Includes the DEFAULT_STRATIFICATION_POLICY, normalizeDomain, classifyStratum, and a paired-swap computeStratifiedQuotas solver.
145 changes: 145 additions & 0 deletions packages/abtest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,3 +661,148 @@ assignment and chunked bulk list membership:
## License

MIT License - see LICENSE file for details.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update both root-language user guides

The user-visible hypothesis and stratification behavior is documented only in packages/abtest/README.md; the repository-wide README.md and README_ko.md remain unchanged. Add the relevant operator-facing creation inputs and behavior to both root guides rather than embedding a short Korean section only in the package's English README.

AGENTS.md reference: AGENTS.md:L235-L237

Useful? React with 👍 / 👎.

## Hypothesis pre-registration (advanced experimentation)

An A/B test can carry a **pre-registered hypothesis** that is locked
(checksummed) before recipient assignment. After locking, the hypothesis
cannot change without discarding the assignment manifest and provisioning.
This prevents post-hoc hypothesis adjustment (p-hacking) and gives reports a
stable reference.

### Locking and verification

```typescript
import {
lockHypothesis,
verifyHypothesisChecksum,
validateHypothesisMetadata,
} from "@listmonk-ops/abtest";

const metadata = {
objective: "Increase click-through rate on the welcome email",
hypothesis: "A shorter subject line will increase CTR by 10%",
primaryMetric: { type: "click_rate", direction: "maximize" },
expectedLift: { kind: "relative", value: 0.1 },
owner: { id: "user-1", displayName: "Test User" },
experimentScope: {
channel: "email",
experimentFamilyKey: "onboarding.welcome.subject",
attributionWindowHours: 72,
exclusionWindowHours: 168,
},
createdAt: new Date().toISOString(),
};

validateHypothesisMetadata(metadata, true); // strict launch check
const locked = lockHypothesis(metadata);
verifyHypothesisChecksum(locked); // true
```

The checksum recursively canonicalizes nested fields
(`primaryMetric`, `expectedLift`, `owner`, `experimentScope`, `createdAt`),
so any change after locking invalidates it.

### Wiring through creation

Pass a `hypothesis` field to `createAbTest`. The service locks it before
provisioning, so the assignment manifest is always bound to a frozen
hypothesis:
Comment on lines +706 to +710

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update both root user guides

This change adds user-visible hypothesis input and stratification output but documents them only in the package README; the repository-wide English and Korean guides remain unchanged even though they contain the CLI/MCP A/B-test usage sections. Add corresponding guidance to both README.md and README_ko.md so the new behavior is discoverable consistently.

AGENTS.md reference: AGENTS.md:L233-L237

Useful? React with 👍 / 👎.

Comment on lines +706 to +710

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update both root user guides for the new create contract

This adds a user-visible hypothesis field to the shared create operation, but the commit updates only packages/abtest/README.md; the root README.md and README_ko.md, which document the CLI/MCP A/B-test operations, contain no corresponding hypothesis or stratification guidance. Keep the paired operator documentation synchronized with the new surface contract.

AGENTS.md reference: AGENTS.md:L235-L237

Useful? React with 👍 / 👎.


```typescript
const test = await abTestExecutors.createAbTest({
name: "Subject Line Test",
variants: [/* ... */],
lists: [1, 2],
hypothesis: {
objective: "Increase CTR",
hypothesis: "Shorter subject lifts CTR",
primary_metric: { type: "click_rate", direction: "maximize" },
expected_lift: { kind: "relative", value: 0.1 },
owner: { id: "user-1" },
experiment_scope: {
channel: "email",
experiment_family_key: "onboarding.welcome",
attribution_window_hours: 72,
exclusion_window_hours: 168,
},
},
});
```

### Validation rules

- `createdAt` and `lockedAt` must be strict ISO 8601 timestamps (the year-zero
string `"0"`, localized formats like `"01/02/03"`, and overflowed dates like
`"2026-02-30"` are rejected).
- `primaryMetric.type` ∈ `click_rate | conversion_rate | revenue_per_recipient`.
- `primaryMetric.direction` ∈ `maximize | minimize`.
- `expectedLift.kind` ∈ `relative | absolute`. Absolute lifts require a `unit`
∈ `percentage_point | currency_per_recipient`.
- Metric/unit coupling: `revenue_per_recipient` requires
`currency_per_recipient` absolute lift; `click_rate`/`conversion_rate`
require `percentage_point`. Relative lift is unit-agnostic.
- `experimentScope.experimentFamilyKey` must be dotted alphanumeric segments
(`onboarding.activation.day1`); `.`, `foo.`, and `foo..bar` are rejected.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### 가설 사전 등록 (Korean)

A/B 테스트에 **사전 등록된 가설**을 설정하면 수신자 할당 전에 잠금(체크섬)
처리됩니다. 잠금 후에는 할당 매니페스트와 프로비저닝을 폐기하지 않는 한
가설을 변경할 수 없습니다. 이는 사후 가설 조정(p-hacking)을 방지하고
보고서에 안정적인 기준점을 제공합니다.

- `createAbTest`에 `hypothesis` 필드를 전달하면 서비스가 프로비저닝 전에
잠금 처리합니다.
- 체크섬은 중첩 필드(`primaryMetric`, `expectedLift`, `owner`,
`experimentScope`, `createdAt`)를 재귀적으로 정규화하므로 잠금 후 어떤
변경도 무효화됩니다.
- `createdAt`/`lockedAt`은 엄격한 ISO 8601이어야 합니다.
- `experimentFamilyKey`는 점으로 구분된 영숫자 세그먼트여야 합니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge List every accepted separator in the Korean guide

The Korean guidance says family keys must consist of dot-separated segments, but the validator and adjacent English documentation also accept _ and -. Korean readers may unnecessarily reject valid keys such as cart-recovery_24h; document all three separators here to keep the bilingual user guidance aligned.

AGENTS.md reference: AGENTS.md:L235-L237

Useful? React with 👍 / 👎.


## Recipient-domain stratification (advanced experimentation)

Stratification classifies subscribers by email-domain provider and computes a
**constrained quota matrix** so each provider stratum gets a proportional share
of every variant/holdout group. This prevents a single large provider (e.g.
Gmail) from dominating one variant and skewing results.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge State that quotas do not alter recipient assignment

This claims stratification prevents a provider from dominating a variant, but holdout provisioning populates lists from the ordinary ranked slices before computing this matrix, and the root guide explicitly says applying quotas to actual assignments is deferred. Operators relying on the package guide could therefore believe their experiment was stratified when only reporting metadata was produced; state this limitation in both the English and Korean package sections.

AGENTS.md reference: AGENTS.md:L235-L237

Useful? React with 👍 / 👎.


```typescript
import {
classifyStratum,
computeStratifiedQuotas,
DEFAULT_STRATIFICATION_POLICY,
} from "@listmonk-ops/abtest";

const policy = { ...DEFAULT_STRATIFICATION_POLICY, enabled: true };
const stratum = classifyStratum("user@gmail.com", policy); // "gmail"

const result = computeStratifiedQuotas({
stratumSizes: { gmail: 600, naver: 300, other: 100 },
groupExactCounts: { "variant:A": 500, "variant:B": 500 },
groupOrder: ["variant:A", "variant:B"],
totalAudience: 1000,
});
```

The solver uses the largest-remainder method per stratum row, then a paired-swap
column correction that preserves row sums while matching exact group column
counts. Configured domains in the provider map are normalized with the same
rules applied to subscriber emails, so mixed-case entries like `"GMAIL.COM"`
match correctly.

During holdout provisioning, when a stratification policy is enabled and the
resolved audience carries emails, the quota matrix is computed and stored on
the `AbTest.stratification` field for reporting and validation.

### 수신자 도메인 층화 (Korean)

층화는 구독자를 이메일 도메인 제공자별로 분류하고, 각 제공자 층(stratum)이
모든 변형/홀드아웃 그룹의 비례 배분을 받도록 **제약된 할당량 행렬**을
계산합니다. 단일 대형 제공자(예: Gmail)가 하나의 변형을 독점하여 결과를
왜곡하는 것을 방지합니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disclose that Korean stratification does not change assignments

For Korean readers, this section says stratification gives every provider a proportional allocation and prevents one provider from skewing a variant, but the implementation only records a target quota matrix and does not apply it to recipient slices, as the English section explicitly notes. An operator relying on this text can run an experiment believing provider balance was enforced when it was not; add the same deferred-assignment warning to the Korean documentation.

AGENTS.md reference: AGENTS.md:L235-L237

Useful? React with 👍 / 👎.


- `classifyStratum`으로 구독자를 분류하고, `computeStratifiedQuotas`로
할당량 행렬을 계산합니다.
- 홀드아웃 프로비저닝 시 층화 정책이 활성화되어 있으면 할당량 행렬이
`AbTest.stratification`에 저장되어 보고/검증에 사용됩니다.
18 changes: 18 additions & 0 deletions packages/abtest/src/abtest-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DEFAULT_STATISTICAL_POLICY,
fixedHorizonGate,
} from "./statistics";
import { lockHypothesis } from "./hypothesis";
import type {
AbTest,
AbTestConfig,
Expand Down Expand Up @@ -178,6 +179,13 @@ export class AbTestService {
autoDeployWinner,
campaignMappings: [],
testListMappings: [],
// Lock the pre-registration hypothesis before any provisioning so the
// assignment manifest is bound to a frozen, checksummed hypothesis.
hypothesis: config.hypothesis
? config.hypothesis.lockedAt
? config.hypothesis
: lockHypothesis(config.hypothesis)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify pre-locked hypotheses before provisioning

When a library caller supplies HypothesisMetadata with any truthy lockedAt, this branch accepts it without validation or verifyHypothesisChecksum. Because checksum is optional in the public type, a missing or tampered checksum can therefore reach campaign/list provisioning despite the pre-registration integrity guarantee; it may only be rejected on a later store read. Validate the locked timestamp and checksum before performing remote side effects.

Useful? React with 👍 / 👎.

: undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

// Create Listmonk campaigns if integration is available
Expand Down Expand Up @@ -224,6 +232,14 @@ export class AbTestService {
abTest.assignmentSeed = segmentationResult.assignmentSeed;
abTest.audienceSnapshot = segmentationResult.audienceSnapshot;
abTest.assignmentManifest = segmentationResult.assignmentManifest;
Comment on lines 258 to 260

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bind the hypothesis checksum into the assignment manifest

When hypothesis metadata is replaced after provisioning with a different, correctly re-locked hypothesis, persistence accepts the new self-consistent checksum while this unchanged manifest remains valid because it records no hypothesis checksum. The assignment therefore has no durable binding to the hypothesis that existed when recipients were allocated, so a hypothesis can be swapped without discarding or regenerating the manifest despite the pre-registration guarantee.

Useful? React with 👍 / 👎.

// Record that recipients were assigned through a deterministic
// manifest, so consumers can distinguish it from legacy splits.
abTest.assignmentProvenance = "manifest_v1";
// Capture the stratified quota matrix when a stratification
// policy produced one, so reports can show per-provider shares.
if (segmentationResult.stratification) {
abTest.stratification = segmentationResult.stratification;
}
} else {
// Use full-split methodology (legacy)
testListMappings = await this.listmonkIntegration.segmentSubscribers(
Expand All @@ -238,6 +254,8 @@ export class AbTestService {
);
testGroupSize = totalSubscribers;
holdoutGroupSize = 0;
// Full-split provisioning predates deterministic manifests.
abTest.assignmentProvenance = "legacy_unavailable";
}
provisionedResources = {
...provisionedResources,
Expand Down
4 changes: 4 additions & 0 deletions packages/abtest/src/audience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export interface AudienceMember {
subscriberId: number;
/** Stable UUID used for identity, dedupe, checksum, and deterministic assignment. */
subscriberUuid: string;
/** Subscriber email, used for recipient-domain stratification. Optional
* because legacy resolvers and test fixtures may not populate it. */
email?: string;
}

export interface AudienceSnapshot {
Expand Down Expand Up @@ -228,6 +231,7 @@ export function createListmonkAudienceResolver(
collected.push({
subscriberId: numericId,
subscriberUuid: uuid,
email: subscriber.email,
});
}
}
Expand Down
35 changes: 35 additions & 0 deletions packages/abtest/src/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,41 @@ export class CreateAbTestCommand {
ignoreStatisticalWarnings: input.ignore_sample_size_warnings || false,
durationHours: input.duration_hours,
launchAt: input.launch_at,
hypothesis: input.hypothesis
? {
objective: input.hypothesis.objective,
hypothesis: input.hypothesis.hypothesis,
primaryMetric: {
type: input.hypothesis.primary_metric.type,
direction: input.hypothesis.primary_metric.direction,
},
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor the pre-registered metric when selecting the winner

When a hypothesis declares click_rate but at least one conversion is recorded, the analyzer still switches to conversion rate via pickMetricRate(); it also always maximizes the selected rate, ignoring direction: "minimize", and never selects revenue_per_recipient. Consequently significance and winner selection can contradict the locked hypothesis and produce the wrong experimental decision; pass the stored primary metric and direction into the statistical selector instead of retaining the data-dependent default.

Useful? React with 👍 / 👎.

expectedLift:
input.hypothesis.expected_lift.kind === "relative"
? {
kind: "relative",
value: input.hypothesis.expected_lift.value,
}
: {
kind: "absolute",
value: input.hypothesis.expected_lift.value,
unit: input.hypothesis.expected_lift.unit,
},
owner: {
id: input.hypothesis.owner.id,
displayName: input.hypothesis.owner.display_name,
},
experimentScope: {
channel: input.hypothesis.experiment_scope.channel,
experimentFamilyKey:
input.hypothesis.experiment_scope.experiment_family_key,
attributionWindowHours:
input.hypothesis.experiment_scope.attribution_window_hours,
exclusionWindowHours:
input.hypothesis.experiment_scope.exclusion_window_hours,
},
createdAt: new Date().toISOString(),
}
: undefined,
};

return await this.abTestService.createTest(config);
Expand Down
Loading
Loading