-
Notifications
You must be signed in to change notification settings - Fork 0
feat(abtest): hypothesis metadata for pre-registration (Change Set A) #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
99e59e9
42ecc34
39dafac
d0a34c3
60ef214
461952d
e79acec
853d6bf
54d895b
48bf2c8
d3e9078
750d9aa
0e0ea0e
3865d84
d0fba92
9788e08
26b2936
433ef74
8b61ea5
2097d24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -661,3 +661,148 @@ assignment and chunked bulk list membership: | |
| ## License | ||
|
|
||
| MIT License - see LICENSE file for details. | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The user-visible hypothesis and stratification behavior is documented only in 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 AGENTS.md reference: AGENTS.md:L233-L237 Useful? React with 👍 / 👎.
Comment on lines
+706
to
+710
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This adds a user-visible hypothesis field to the shared create operation, but the commit updates only 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. | ||
|
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`는 점으로 구분된 영숫자 세그먼트여야 합니다. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Korean guidance says family keys must consist of dot-separated segments, but the validator and adjacent English documentation also accept 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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)가 하나의 변형을 독점하여 결과를 | ||
| 왜곡하는 것을 방지합니다. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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`에 저장되어 보고/검증에 사용됩니다. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { | |
| DEFAULT_STATISTICAL_POLICY, | ||
| fixedHorizonGate, | ||
| } from "./statistics"; | ||
| import { lockHypothesis } from "./hypothesis"; | ||
| import type { | ||
| AbTest, | ||
| AbTestConfig, | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a library caller supplies Useful? React with 👍 / 👎. |
||
| : undefined, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }; | ||
|
|
||
| // Create Listmonk campaigns if integration is available | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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( | ||
|
|
@@ -238,6 +254,8 @@ export class AbTestService { | |
| ); | ||
| testGroupSize = totalSubscribers; | ||
| holdoutGroupSize = 0; | ||
| // Full-split provisioning predates deterministic manifests. | ||
| abTest.assignmentProvenance = "legacy_unavailable"; | ||
| } | ||
| provisionedResources = { | ||
| ...provisionedResources, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a hypothesis declares 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changesets publish hypothesis locking and recipient-domain stratification as minor user-facing additions, but neither
README.mdnorREADME_ko.mddocuments 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 👍 / 👎.