{"version":3,"file":"ExperimentStrategies.cjs","names":["hashjs","getAsyncCtxSync"],"sources":["../../src/components/ExperimentStrategies.ts"],"sourcesContent":["import hashjs from \"hash.js\";\nimport { getAsyncCtxSync } from \"./execution/als.ts\";\nimport type { ExperimentSelectFn } from \"./InngestGroupTools.ts\";\n\nconst { sha256 } = hashjs;\n\n/**\n * Hash a string to a float in [0, 1) using SHA-256.\n */\nconst hashToFloat = (str: string): number => {\n  const hex = sha256().update(str).digest(\"hex\").slice(0, 8);\n  return Number.parseInt(hex, 16) / 0x100000000;\n};\n\n/**\n * Given a float in [0, 1) and a weights map, select the variant whose bucket\n * the float falls into. Entries are sorted alphabetically for determinism.\n */\nconst selectByWeight = (\n  hash01: number,\n  weights: Record<string, number>,\n): string => {\n  const entries = Object.entries(weights).sort(([a], [b]) =>\n    a.localeCompare(b),\n  );\n  const total = entries.reduce((sum, [, w]) => sum + w, 0);\n\n  let cursor = 0;\n  for (const [name, weight] of entries) {\n    cursor += weight / total;\n    if (hash01 < cursor) {\n      return name;\n    }\n  }\n\n  // Fallback to last entry (floating-point edge case)\n  return entries[entries.length - 1]![0]!;\n};\n\n/**\n * Build equal weights from variant names: `{ a: 1, b: 1, ... }`.\n */\nconst equalWeights = (variantNames: string[]): Record<string, number> => {\n  return Object.fromEntries(variantNames.map((name) => [name, 1]));\n};\n\n/**\n * Throw if all weights are zero.\n */\nconst validateWeights = (weights: Record<string, number>): void => {\n  for (const [name, w] of Object.entries(weights)) {\n    if (!Number.isFinite(w)) {\n      throw new Error(\n        `experiment.weighted(): weight for \"${name}\" is not a finite number (${w}); weights must be finite numbers >= 0`,\n      );\n    }\n    if (w < 0) {\n      throw new Error(\n        `experiment.weighted(): weight for \"${name}\" is negative (${w}); weights must be >= 0`,\n      );\n    }\n  }\n\n  const total = Object.values(weights).reduce((sum, w) => sum + w, 0);\n  if (total <= 0) {\n    throw new Error(\n      \"experiment.weighted(): all weights are zero; at least one weight must be positive\",\n    );\n  }\n};\n\n/**\n * Attach `__experimentConfig` to a select function, producing an\n * `ExperimentSelectFn`.\n */\nconst createSelectFn = (\n  fn: (variantNames?: string[]) => Promise<string> | string,\n  config: ExperimentSelectFn[\"__experimentConfig\"],\n): ExperimentSelectFn => {\n  return Object.assign(fn, { __experimentConfig: config });\n};\n\n/**\n * Factory functions for creating experiment selection strategies.\n *\n * Each factory returns an `ExperimentSelectFn` — a callable function with an\n * `__experimentConfig` property carrying strategy metadata.\n *\n * @example\n * ```ts\n * import { experiment, group, step } from \"inngest\";\n *\n * const result = await group.experiment(\"checkout-flow\", {\n *   variants: {\n *     control: () => step.run(\"old\", () => oldCheckout()),\n *     new_flow: () => step.run(\"new\", () => newCheckout()),\n *   },\n *   select: experiment.weighted({ control: 80, new_flow: 20 }),\n * });\n * ```\n *\n * @public\n */\nexport const experiment = {\n  /**\n   * Always selects the specified variant.\n   *\n   * @example\n   * ```ts\n   * select: experiment.fixed(\"control\")\n   * ```\n   */\n  fixed(variantName: string): ExperimentSelectFn {\n    return createSelectFn(() => variantName, { strategy: \"fixed\" });\n  },\n\n  /**\n   * Weighted random selection, seeded with the current run ID for\n   * determinism — the same run always gets the same variant.\n   *\n   * @example\n   * ```ts\n   * select: experiment.weighted({ gpt4: 50, claude: 50 })\n   * ```\n   *\n   * @throws If all weights are zero (validated at creation time).\n   */\n  weighted(weights: Record<string, number>): ExperimentSelectFn {\n    validateWeights(weights);\n\n    // Snapshot so that later mutations to the caller's object can't silently\n    // change runtime behaviour after validation has passed.\n    const frozen = { ...weights };\n\n    return createSelectFn(\n      () => {\n        const runId =\n          getAsyncCtxSync()?.execution?.ctx.runId ?? crypto.randomUUID();\n        return selectByWeight(hashToFloat(runId), frozen);\n      },\n      { strategy: \"weighted\", weights: frozen },\n    );\n  },\n\n  /**\n   * Consistent hashing — the same value always maps to the same variant.\n   *\n   * When `value` is `null` or `undefined`, an empty string is hashed instead.\n   *\n   * @example\n   * ```ts\n   * select: experiment.bucket(userId)\n   * select: experiment.bucket(userId, { weights: { a: 70, b: 30 } })\n   * ```\n   */\n  bucket(\n    value: unknown,\n    options?: { weights?: Record<string, number> },\n  ): ExperimentSelectFn {\n    if (options?.weights) {\n      validateWeights(options.weights);\n    }\n\n    const str = value == null ? \"\" : String(value);\n\n    return createSelectFn(\n      (variantNames?: string[]) => {\n        const weights =\n          options?.weights ??\n          (variantNames ? equalWeights(variantNames) : undefined);\n\n        if (!weights) {\n          throw new Error(\n            \"experiment.bucket() requires either explicit weights or variant \" +\n              \"names from group.experiment()\",\n          );\n        }\n\n        return selectByWeight(hashToFloat(str), weights);\n      },\n      {\n        strategy: \"bucket\",\n        weights: options?.weights,\n        ...(value == null && { nullishBucket: true }),\n      },\n    );\n  },\n\n  /**\n   * User-provided selection function. The function is called inside the\n   * memoized step, so it only runs once per run.\n   *\n   * @example\n   * ```ts\n   * select: experiment.custom(async () => {\n   *   const flag = await getFeatureFlag(\"checkout-variant\");\n   *   return flag;\n   * })\n   * ```\n   */\n  custom(fn: () => Promise<string> | string): ExperimentSelectFn {\n    return createSelectFn(fn, { strategy: \"custom\" });\n  },\n};\n"],"mappings":";;;;;;AAIA,MAAM,EAAE,WAAWA;;;;AAKnB,MAAM,eAAe,QAAwB;CAC3C,MAAM,MAAM,QAAQ,CAAC,OAAO,IAAI,CAAC,OAAO,MAAM,CAAC,MAAM,GAAG,EAAE;AAC1D,QAAO,OAAO,SAAS,KAAK,GAAG,GAAG;;;;;;AAOpC,MAAM,kBACJ,QACA,YACW;CACX,MAAM,UAAU,OAAO,QAAQ,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,OAClD,EAAE,cAAc,EAAE,CACnB;CACD,MAAM,QAAQ,QAAQ,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAExD,IAAI,SAAS;AACb,MAAK,MAAM,CAAC,MAAM,WAAW,SAAS;AACpC,YAAU,SAAS;AACnB,MAAI,SAAS,OACX,QAAO;;AAKX,QAAO,QAAQ,QAAQ,SAAS,GAAI;;;;;AAMtC,MAAM,gBAAgB,iBAAmD;AACvE,QAAO,OAAO,YAAY,aAAa,KAAK,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;;;;;AAMlE,MAAM,mBAAmB,YAA0C;AACjE,MAAK,MAAM,CAAC,MAAM,MAAM,OAAO,QAAQ,QAAQ,EAAE;AAC/C,MAAI,CAAC,OAAO,SAAS,EAAE,CACrB,OAAM,IAAI,MACR,sCAAsC,KAAK,4BAA4B,EAAE,wCAC1E;AAEH,MAAI,IAAI,EACN,OAAM,IAAI,MACR,sCAAsC,KAAK,iBAAiB,EAAE,yBAC/D;;AAKL,KADc,OAAO,OAAO,QAAQ,CAAC,QAAQ,KAAK,MAAM,MAAM,GAAG,EAAE,IACtD,EACX,OAAM,IAAI,MACR,oFACD;;;;;;AAQL,MAAM,kBACJ,IACA,WACuB;AACvB,QAAO,OAAO,OAAO,IAAI,EAAE,oBAAoB,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;AAwB1D,MAAa,aAAa;CASxB,MAAM,aAAyC;AAC7C,SAAO,qBAAqB,aAAa,EAAE,UAAU,SAAS,CAAC;;CAcjE,SAAS,SAAqD;AAC5D,kBAAgB,QAAQ;EAIxB,MAAM,SAAS,EAAE,GAAG,SAAS;AAE7B,SAAO,qBACC;AAGJ,UAAO,eAAe,YADpBC,6BAAiB,EAAE,WAAW,IAAI,SAAS,OAAO,YAAY,CACxB,EAAE,OAAO;KAEnD;GAAE,UAAU;GAAY,SAAS;GAAQ,CAC1C;;CAcH,OACE,OACA,SACoB;AACpB,MAAI,SAAS,QACX,iBAAgB,QAAQ,QAAQ;EAGlC,MAAM,MAAM,SAAS,OAAO,KAAK,OAAO,MAAM;AAE9C,SAAO,gBACJ,iBAA4B;GAC3B,MAAM,UACJ,SAAS,YACR,eAAe,aAAa,aAAa,GAAG;AAE/C,OAAI,CAAC,QACH,OAAM,IAAI,MACR,gGAED;AAGH,UAAO,eAAe,YAAY,IAAI,EAAE,QAAQ;KAElD;GACE,UAAU;GACV,SAAS,SAAS;GAClB,GAAI,SAAS,QAAQ,EAAE,eAAe,MAAM;GAC7C,CACF;;CAeH,OAAO,IAAwD;AAC7D,SAAO,eAAe,IAAI,EAAE,UAAU,UAAU,CAAC;;CAEpD"}