-
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 1 commit
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,210 @@ | ||
| import { createHash } from "node:crypto"; | ||
|
|
||
| /** | ||
| * Hypothesis metadata for A/B test pre-registration. | ||
| * | ||
| * Implements the advanced experimentation followup's Change Set A: a test | ||
| * can carry a structured hypothesis (objective, primary metric, expected | ||
| * lift, owner, experiment scope) that is locked (checksummed) before | ||
| * assignment manifest creation. After locking, the hypothesis cannot be | ||
| * changed without discarding the existing manifest and provisioning. | ||
| * | ||
| * This prevents post-hoc hypothesis adjustment (p-hacking) and provides | ||
| * a stable reference for experiment reports. | ||
| */ | ||
|
|
||
| export type ExpectedLift = | ||
| | { | ||
| kind: "relative"; | ||
| /** 0.10 = 10% relative lift over baseline. */ | ||
| value: number; | ||
| } | ||
| | { | ||
| kind: "absolute"; | ||
| value: number; | ||
| unit: "percentage_point" | "currency_per_recipient"; | ||
| }; | ||
|
|
||
| export interface ExperimentOwner { | ||
| /** Stable handle/ID within the organization. */ | ||
| id: string; | ||
| displayName?: string; | ||
| } | ||
|
|
||
| export interface ExperimentScope { | ||
| channel: "email"; | ||
| /** | ||
| * Dotted key identifying the experiment family, e.g. | ||
| * `onboarding.activation.day1`. Tests with the same family key | ||
| * and overlapping active windows are considered collisions. | ||
| */ | ||
| experimentFamilyKey: string; | ||
| /** Hours after exposure during which conversions are attributed. */ | ||
| attributionWindowHours: number; | ||
| /** Hours before/after the active window during which a subscriber | ||
| * is excluded from other experiments in the same family. */ | ||
| exclusionWindowHours: number; | ||
| } | ||
|
|
||
| export interface HypothesisMetadata { | ||
| objective: string; | ||
| hypothesis: string; | ||
| primaryMetric: { | ||
| type: "click_rate" | "conversion_rate" | "revenue_per_recipient"; | ||
| direction: "maximize" | "minimize"; | ||
|
Comment on lines
+52
to
+54
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 pre-registers Useful? React with 👍 / 👎.
Comment on lines
+52
to
+54
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 test supplies this field, Useful? React with 👍 / 👎. |
||
| }; | ||
| expectedLift: ExpectedLift; | ||
|
Comment on lines
+52
to
+56
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 independent types permit semantically incompatible metadata such as Useful? React with 👍 / 👎. |
||
| owner: ExperimentOwner; | ||
| experimentScope: ExperimentScope; | ||
| createdAt: string; | ||
| /** Set when the hypothesis is locked (pre-assignment). */ | ||
| lockedAt?: string; | ||
| /** Canonical SHA-256 checksum computed at lock time. */ | ||
| checksum?: string; | ||
| } | ||
|
|
||
| export class HypothesisValidationError extends Error { | ||
| constructor(message: string) { | ||
| super(message); | ||
| this.name = "HypothesisValidationError"; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validate hypothesis metadata. When `strict` is true (launch/pre-registration), | ||
| * all fields are required. When false (draft), missing fields are allowed. | ||
| */ | ||
| export function validateHypothesisMetadata( | ||
| metadata: Partial<HypothesisMetadata>, | ||
| strict: boolean = false, | ||
| ): void { | ||
| const require = (field: string, value: unknown): void => { | ||
| if (strict && (value === undefined || value === null || value === "")) { | ||
| throw new HypothesisValidationError(`${field} is required for launch`); | ||
| } | ||
| }; | ||
|
|
||
| require("objective", metadata.objective); | ||
| require("hypothesis", metadata.hypothesis); | ||
| require("primaryMetric", metadata.primaryMetric); | ||
| require("expectedLift", metadata.expectedLift); | ||
| require("owner", metadata.owner); | ||
| require("experimentScope", metadata.experimentScope); | ||
|
|
||
| if (metadata.objective !== undefined) { | ||
| if (typeof metadata.objective !== "string" || metadata.objective.trim().length === 0) { | ||
| throw new HypothesisValidationError( | ||
| "objective must be a non-empty string", | ||
| ); | ||
| } | ||
| } | ||
| if (metadata.hypothesis !== undefined) { | ||
| if (typeof metadata.hypothesis !== "string" || metadata.hypothesis.trim().length === 0) { | ||
| throw new HypothesisValidationError( | ||
| "hypothesis must be a non-empty string", | ||
| ); | ||
| } | ||
| } | ||
| if (metadata.expectedLift !== undefined) { | ||
| if (!Number.isFinite( | ||
| metadata.expectedLift.value, | ||
| ) || metadata.expectedLift.value <= 0) { | ||
| throw new HypothesisValidationError( | ||
| `expectedLift.value must be finite and positive, received ${metadata.expectedLift.value}`, | ||
| ); | ||
| } | ||
|
Comment on lines
+178
to
+235
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.
Strict validation only requires the nested objects to be present and checks Useful? React with 👍 / 👎. |
||
| } | ||
| if (metadata.owner !== undefined) { | ||
| if (!metadata.owner.id || metadata.owner.id.trim().length === 0) { | ||
| throw new HypothesisValidationError( | ||
| "owner.id must be a non-empty string", | ||
| ); | ||
| } | ||
|
Comment on lines
+244
to
+248
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 JavaScript or otherwise untyped callers, an owner such as Useful? React with 👍 / 👎. |
||
| } | ||
| if (metadata.experimentScope !== undefined) { | ||
| const scope = metadata.experimentScope; | ||
| if (scope.channel !== "email") { | ||
| throw new HypothesisValidationError( | ||
| `channel must be "email", received "${scope.channel}"`, | ||
| ); | ||
| } | ||
| if (!scope.experimentFamilyKey || scope.experimentFamilyKey.trim().length === 0) { | ||
| throw new HypothesisValidationError( | ||
| "experimentScope.experimentFamilyKey must be a non-empty string", | ||
| ); | ||
| } | ||
| if (!scope.experimentFamilyKey.match(/^[a-z0-9._-]+$/)) { | ||
| throw new HypothesisValidationError( | ||
| `experimentFamilyKey must match [a-z0-9._-]+, received "${scope.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 character-class check accepts delimiter-only and empty-segment keys such as Useful? React with 👍 / 👎. |
||
| } | ||
| if ( | ||
| !Number.isFinite(scope.attributionWindowHours) || | ||
| scope.attributionWindowHours <= 0 | ||
| ) { | ||
| throw new HypothesisValidationError( | ||
| `attributionWindowHours must be finite and positive, received ${scope.attributionWindowHours}`, | ||
| ); | ||
| } | ||
| if ( | ||
| !Number.isFinite(scope.exclusionWindowHours) || | ||
| scope.exclusionWindowHours < 0 | ||
| ) { | ||
| throw new HypothesisValidationError( | ||
| `exclusionWindowHours must be finite and non-negative, received ${scope.exclusionWindowHours}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Compute a canonical SHA-256 checksum for a HypothesisMetadata object. | ||
| * The checksum excludes `lockedAt` and `checksum` themselves so the | ||
| * same content always produces the same hash. | ||
| */ | ||
| export function computeHypothesisChecksum( | ||
| metadata: HypothesisMetadata, | ||
| ): string { | ||
| const canonical = { | ||
| objective: metadata.objective, | ||
| hypothesis: metadata.hypothesis, | ||
| primaryMetric: metadata.primaryMetric, | ||
| expectedLift: metadata.expectedLift, | ||
| owner: { id: metadata.owner.id, displayName: metadata.owner.displayName }, | ||
| experimentScope: metadata.experimentScope, | ||
| createdAt: metadata.createdAt, | ||
| }; | ||
| const json = JSON.stringify(canonical, Object.keys(canonical).sort()); | ||
|
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.
Useful? React with 👍 / 👎. |
||
| return createHash("sha256").update(json, "utf8").digest("hex"); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| /** | ||
| * Lock a hypothesis by computing its checksum and setting lockedAt. | ||
| * Returns a new object with checksum and lockedAt populated. | ||
| * Throws if the hypothesis is already locked. | ||
| */ | ||
| export function lockHypothesis( | ||
| metadata: HypothesisMetadata, | ||
| lockedAt: string = new Date().toISOString(), | ||
| ): HypothesisMetadata { | ||
| if (metadata.lockedAt) { | ||
|
Comment on lines
+366
to
+368
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 caller uses the supported timestamp override with Useful? React with 👍 / 👎. |
||
| throw new HypothesisValidationError( | ||
| "Hypothesis is already locked; create a new test revision to change it", | ||
| ); | ||
| } | ||
| validateHypothesisMetadata(metadata, true); | ||
| const checksum = computeHypothesisChecksum(metadata); | ||
| return { ...metadata, lockedAt, checksum }; | ||
|
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.
Because this is only a shallow copy, the returned locked hypothesis still shares Useful? React with 👍 / 👎.
Comment on lines
+381
to
+382
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 locked record's Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /** | ||
| * Verify that a locked hypothesis has not been tampered with. | ||
| * Returns true if the stored checksum matches the recomputed checksum. | ||
| */ | ||
| export function verifyHypothesisChecksum(metadata: HypothesisMetadata): boolean { | ||
| if (!metadata.checksum || !metadata.lockedAt) { | ||
| return false; | ||
| } | ||
| const recomputed = computeHypothesisChecksum(metadata); | ||
| return recomputed === metadata.checksum; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -157,6 +157,9 @@ const abTestSchema = z.object({ | |
| startedAt: z.string().datetime().optional(), | ||
| endsAt: z.string().datetime().optional(), | ||
| minimumTestSampleSize: z.number().int().positive().optional(), | ||
| assignmentProvenance: z | ||
| .enum(["manifest_v1", "legacy_unavailable"]) | ||
| .optional(), | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+160
to
+162
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 AGENTS.md reference: AGENTS.md:L155-L157 Useful? React with 👍 / 👎.
Comment on lines
+160
to
+162
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 new Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| const testResultsSchema = z.object({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,10 @@ export interface AbTest { | |
| }; | ||
| /** Per-test minimum sample size for the fixed-horizon gate. */ | ||
| minimumTestSampleSize?: number; | ||
| /** Hypothesis metadata for pre-registration (Change Set A). */ | ||
| hypothesis?: import("./hypothesis").HypothesisMetadata; | ||
|
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.
Adding AGENTS.md reference: AGENTS.md:L180-L183 Useful? React with 👍 / 👎.
Comment on lines
+90
to
+91
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 test is created through the shared CLI/MCP operation, AGENTS.md reference: AGENTS.md:L146-L149 Useful? React with 👍 / 👎.
Comment on lines
+90
to
+91
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.
Adding Useful? React with 👍 / 👎.
Comment on lines
+90
to
+91
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 selects Useful? React with 👍 / 👎. |
||
| /** Assignment provenance: whether the test has a deterministic manifest. */ | ||
| assignmentProvenance?: "manifest_v1" | "legacy_unavailable"; | ||
| /** | ||
| * Deterministic assignment manifest produced from the seed + audience. | ||
| * Once stored, retries and reconciliation reuse it rather than | ||
|
|
||
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 👍 / 👎.