Files
2026-05-27 23:29:53 +01:00

207 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A/B testing completion — bandits, multivariate, segments, sequential, Bayesian, store
**Package**: `@suaveplan/ab-testing`
**Status**: PROPOSED
**Type**: spec-conformance + feature
## Why
A 2026-05-02 audit of the genesis `@suaveplan/ab-testing` package against the upstream library at `../suaveplan/packages/universal/ab-testing/src` and against this package's archived migration `openspec/archive/2026-04-24-enterprise-ab-testing/` found six entire capability surfaces silently scoped out without justification. The archived proposal's parity table either omits these features or marks them shipped without delivering them; the current genesis implementation ships only the frequentist subset (deterministic FNV-1a bucketing, targeting, conversion-rate results, assignment via providers).
Six feature surfaces are missing:
1. **Bandits** — `EpsilonGreedy`, `UCB1`, `ThompsonSamplingBandit` (153 L upstream `bandit/bandit.ts`). Without them, callers wanting exploration/exploitation must hand-roll the algorithms or import a third-party — which then can't reuse the assignment store, error codes, or OTel instrumentation already in genesis.
2. **Multivariate engine** — `fullFactorial`, `fractionalFactorial`, `analyzeMultivariate`, factor-effect / interaction-effect analysis (226 L upstream `multivariate/multivariate-engine.ts`). The frequentist `computeResults` only handles A/B/C variant comparisons, not factor × level designs.
3. **Segment analysis** — `analyzeBySegments`, `segmentConfidenceIntervals`, `detectSegmentInteraction` (268 L upstream `segmentation/segment-analyzer.ts`). Per-segment lift and segment × variant interactions are how product teams actually read experiment results; without them, the package only answers "did variant B win overall?".
4. **Sequential testing** — `obrienFlemingBoundary`, `alphaSpent`, `interimAnalysis`, `runSequentialTest` (221 L upstream `sequential/sequential-test.ts`). Fixed-horizon hypothesis testing inflates Type I error when peeked at — alpha-spending boundaries are how production experimentation platforms permit early stopping safely.
5. **Bayesian stats** — `betaPdf` / `betaCdf` / `betaQuantile` / `betaSample`, `posteriorBeta`, `thompsonSample`, `probabilityBGreaterA`, `credibleInterval`, `expectedLoss`, `bayesianAnalysis` (263 L upstream `stats/bayesian.ts`). The package ships pure point-estimate conversion rates with no posterior, no credible intervals, no probability-of-being-best — which the bandit and Bayesian analysis paths both require.
6. **Assignment store** — `AssignmentStore` interface + `MemoryAssignmentStore` class (78 L upstream `store/assignment-store.ts`). Genesis currently re-derives assignments from providers on every read, which makes consistent assignment across server restarts impossible without each consumer re-implementing the persistence contract.
The audit found "These were silently dropped from `openspec/archive/2026-04-24-enterprise-ab-testing` scope (no archive justification)." — this change closes the gap and ratifies a parity table that explicitly admits these features are required.
## What changes
### `@suaveplan/ab-testing` — additions
#### Bandit subsystem
- ADDED: `Bandit<TArmId extends string | number = number>` interface — `selectArm(): TArmId`, `update(arm: TArmId, reward: number): void`, `getValues(): readonly number[]`, `getCounts(): readonly number[]`. Generic over arm-id type so callers can use string variant IDs directly instead of a `number → variantId` map.
- ADDED: `EpsilonGreedyBandit<TArmId>` class — explore with probability `epsilon`, exploit best-known arm otherwise; injectable `Rng` from `@suaveplan/types::Rng`.
- ADDED: `UCB1Bandit<TArmId>` class — Upper Confidence Bound, plays each arm once, then `value + sqrt(2 ln(N) / n)`.
- ADDED: `ThompsonSamplingBandit<TArmId>` class — Beta-Bernoulli posterior sampling with injectable `Rng`.
- ADDED: `BanditConfig<TArmId>` interface — `arms: readonly TArmId[]`, optional `epsilon`, `rng`, `priorAlpha`, `priorBeta`.
#### Multivariate engine
- ADDED: `Factor` interface — `name: string`, `levels: readonly string[]`.
- ADDED: `TreatmentCombination`, `TreatmentResult`, `FactorEffect`, `LevelEffect`, `InteractionEffect`, `MultivariateResult` interfaces.
- ADDED: `fullFactorial(factors)` — generate every combination.
- ADDED: `fractionalFactorial(factors, fraction)` — sample every Nth combination.
- ADDED: `analyzeMultivariate(factors, data)` — produce `MultivariateResult` with treatment-level rates, factor-effect importance scores, and pairwise interaction-strength scores.
#### Segment analysis
- ADDED: `SegmentResult`, `SegmentConfidenceInterval`, `SegmentInteraction` interfaces.
- ADDED: `analyzeBySegments(assignments, conversions, variantIds, segmentAttribute)` — variant rates per segment value, with simple two-proportion z-test significance flag.
- ADDED: `segmentConfidenceIntervals(assignments, conversions, variantIds, segmentAttribute, confidence?)` — Wald intervals on conversion rates per segment × variant.
- ADDED: `detectSegmentInteraction(assignments, conversions, variantIds, segmentAttribute)` — interaction-strength score between segment value and variant assignment.
#### Sequential testing
- ADDED: `SequentialTestConfig` — `maxLooks`, `alpha`, `mde`, `power`, optional `boundaryType: 'pocock' | 'obrien-fleming'` (default `'obrien-fleming'`).
- ADDED: `InterimResult` — `lookNumber`, `totalLooks`, `informationFraction`, `spendingBoundary`, `testStatistic`, `pValue`, `shouldStop`, `stopReason: 'significance' | 'futility' | null`, `cumulativeAlphaSpent`.
- ADDED: `obrienFlemingBoundary(informationFraction, alpha)` — z-boundary at fractional time.
- ADDED: `pocockBoundary(informationFraction, alpha, maxLooks)` — constant-z Pocock alternative (genesis enhancement; upstream ships only O'Brien-Fleming).
- ADDED: `alphaSpent(informationFraction, alpha, boundaryType?)` — cumulative alpha spent under the chosen spending function.
- ADDED: `interimAnalysis(config, lookNumber, controlConv, controlPart, treatmentConv, treatmentPart)` — single look.
- ADDED: `runSequentialTest(config, dataAtLooks)` — drive the sequence, stop on first `shouldStop`.
#### Bayesian stats
- ADDED: `BetaParams` interface — `alpha`, `beta`.
- ADDED: `BayesianResult` interface — `posteriors`, `probabilityBIsGreater`, `credibleIntervalA`, `credibleIntervalB`, `expectedLossA`, `expectedLossB`.
- ADDED: `betaPdf(x, alpha, beta)` — Beta probability density.
- ADDED: `betaCdf(x, alpha, beta)` — Beta cumulative distribution via regularized incomplete beta + continued fraction.
- ADDED: `betaQuantile(p, alpha, beta)` — inverse CDF via bisection.
- ADDED: `betaSample(alpha, beta, rng?)` — Beta draw via two Gamma draws (Marsaglia-Tsang).
- ADDED: `posteriorBeta(conversions, participants, priorAlpha?, priorBeta?)` — conjugate Beta-Binomial posterior.
- ADDED: `thompsonSample(posteriors, rng?)` — pick the variant with highest sampled posterior value.
- ADDED: `probabilityBGreaterA(a, b, simulations?, rng?)` — Monte Carlo `P(B > A)`.
- ADDED: `credibleInterval(params, width?)` — quantile-based credible interval.
- ADDED: `expectedLoss(chosen, other, simulations?, rng?)` — expected lift forfeited by choosing `chosen`.
- ADDED: `bayesianAnalysis(results, priorAlpha?, priorBeta?, rng?)` — full pairwise analysis on two `VariantResults`.
#### Assignment store
- ADDED: `AssignmentStoreLike` interface — `get(experimentId, userId)`, `set(assignment)`, `getByExperiment(experimentId)`, `getByUser(userId)`, `deleteByExperiment(experimentId)`. Async throughout. Promoted to `@suaveplan/types` per AGENTS.md §0 rule 5 (referenced by ≥2 packages once consumers wire it up).
- ADDED: `MemoryAssignmentStore` class implementing `AssignmentStoreLike`.
- ADDED: `createMemoryAssignmentStore()` factory.
- ADDED: optional `store?: AssignmentStoreLike` field on `ExperimentManagerOptions`. When present, the manager SHALL persist via the store and check the store before re-bucketing.
#### Errors
- ADDED: `AB_CODES` entries — `INVALID_BANDIT_CONFIG`, `INVALID_FACTOR_DESIGN`, `INVALID_SEGMENT_ATTRIBUTE`, `INSUFFICIENT_SAMPLE_SIZE`, `INVALID_SEQUENTIAL_CONFIG`.
### Subpath exports (additive)
- ADDED: `./bandit`, `./multivariate`, `./segments`, `./sequential`, `./stats`, `./store`.
### Existing surface — preserved
- Bucketing, targeting, results, providers, errors, types, schemas, `createExperimentManager` — unchanged.
## Out of scope
- **Multi-armed contextual bandits** (LinUCB, Thompson with feature vectors) — upstream does not ship these; genesis will not introduce them without a separate proposal and a real consumer asking for them. The three context-free algorithms cover the documented use cases.
- **Frequency-domain / time-series adjustments** (CUPED, Bayesian state-space models) — not in upstream; orthogonal feature better served by a dedicated `@suaveplan/experiment-stats` follow-up if demand emerges.
- **Persistent assignment-store adapters** beyond `MemoryAssignmentStore` (Redis, SurrealDB, SQLite) — kept out of this proposal to keep dep-graph blast radius small. Each adapter will land as its own package (`@suaveplan/ab-testing-redis`, etc.) once `MemoryAssignmentStore` and the `AssignmentStoreLike` interface ship and the contract is pinned by `testing-integration-redis` etc.
- **Variance-reduced bandits** (linear / kernelized regression bandits) — large surface, not in upstream; deferred.
- **Multi-objective optimization** (Pareto-frontier multi-arm bandits) — not in upstream; deferred.
- **Mutual-information-based factor pruning** for the multivariate engine — not in upstream; the importance-by-sum-of-squares heuristic is sufficient for the documented use cases. Architectural reason: a real multi-objective design would change `MultivariateResult.bestCombination` semantics from "highest mean rate" to "Pareto-optimal set", which is a breaking shape change that needs its own proposal.
- **Continuous monitoring / always-valid p-values** (mSPRT, e-values) — distinct statistical regime from alpha-spending; the chosen O'Brien-Fleming + Pocock pair covers the documented "interim look" use case and is what upstream ships.
## Source mapping
This change is a migration from `../suaveplan/packages/universal/ab-testing/src/`. There is no genesis `enterprise/ab-testing.md` audit doc, so source mapping cites the upstream package source directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `BanditAlgorithm` interface | `packages/universal/ab-testing/src/bandit/bandit.ts` | L3-L13 | Renamed to `Bandit<TArmId>` and made generic over arm id |
| `EpsilonGreedy` class | `packages/universal/ab-testing/src/bandit/bandit.ts` | L15-L57 | Renamed to `EpsilonGreedyBandit<TArmId>`; replace `Math.random` default with explicit `Rng` injection |
| `UCB1` class | `packages/universal/ab-testing/src/bandit/bandit.ts` | L59-L105 | Renamed to `UCB1Bandit<TArmId>` |
| `ThompsonSamplingBandit` class | `packages/universal/ab-testing/src/bandit/bandit.ts` | L107-L153 | Verbatim (already correctly named); replace `Math.random` default with explicit `Rng` injection |
| `Factor` / `TreatmentCombination` / `TreatmentResult` / `FactorEffect` / `LevelEffect` / `InteractionEffect` / `MultivariateResult` interfaces | `packages/universal/ab-testing/src/multivariate/multivariate-engine.ts` | L1-L50 | Verbatim |
| `fullFactorial` | `packages/universal/ab-testing/src/multivariate/multivariate-engine.ts` | L52-L82 | Verbatim |
| `fractionalFactorial` | `packages/universal/ab-testing/src/multivariate/multivariate-engine.ts` | L84-L93 | Verbatim |
| `analyzeMultivariate` + factor-effect / interaction-effect helpers | `packages/universal/ab-testing/src/multivariate/multivariate-engine.ts` | L95-L226 | Verbatim |
| `SegmentResult` / `SegmentConfidenceInterval` / `SegmentInteraction` interfaces | `packages/universal/ab-testing/src/segmentation/segment-analyzer.ts` | L7-L33 | Verbatim |
| `analyzeBySegments` | `packages/universal/ab-testing/src/segmentation/segment-analyzer.ts` | L35-L85 | Verbatim |
| `segmentConfidenceIntervals` | `packages/universal/ab-testing/src/segmentation/segment-analyzer.ts` | L87-L123 | Verbatim |
| `detectSegmentInteraction` | `packages/universal/ab-testing/src/segmentation/segment-analyzer.ts` | L125-L162 | Verbatim |
| `normalQuantile` helper | `packages/universal/ab-testing/src/segmentation/segment-analyzer.ts` | L226-L268 | Move to shared `stats/normal.ts` (used by both segmentation and sequential) |
| `SequentialTestConfig` / `InterimResult` interfaces | `packages/universal/ab-testing/src/sequential/sequential-test.ts` | L1-L24 | Verbatim |
| `obrienFlemingBoundary` | `packages/universal/ab-testing/src/sequential/sequential-test.ts` | L26-L35 | Verbatim |
| `alphaSpent` | `packages/universal/ab-testing/src/sequential/sequential-test.ts` | L37-L43 | Extend with `boundaryType` parameter |
| `interimAnalysis` | `packages/universal/ab-testing/src/sequential/sequential-test.ts` | L45-L114 | Verbatim |
| `runSequentialTest` | `packages/universal/ab-testing/src/sequential/sequential-test.ts` | L129-L154 | Verbatim |
| `BetaParams` / `BayesianResult` interfaces | `packages/universal/ab-testing/src/stats/bayesian.ts` | L3-L17 | Verbatim |
| `betaPdf` / `betaCdf` / `betaQuantile` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L48-L116 | Verbatim |
| `betaSample` (Marsaglia-Tsang) | `packages/universal/ab-testing/src/stats/bayesian.ts` | L118-L159 | Verbatim; replace `Math.random` default with `Rng` injection |
| `posteriorBeta` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L161-L172 | Verbatim |
| `thompsonSample` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L174-L189 | Verbatim |
| `probabilityBGreaterA` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L191-L205 | Verbatim |
| `credibleInterval` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L207-L216 | Verbatim |
| `expectedLoss` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L218-L232 | Verbatim |
| `bayesianAnalysis` | `packages/universal/ab-testing/src/stats/bayesian.ts` | L234-L263 | Verbatim |
| `AssignmentStore` interface | `packages/universal/ab-testing/src/store/assignment-store.ts` | L3-L15 | Renamed to `AssignmentStoreLike`; promoted to `@suaveplan/types` |
| `MemoryAssignmentStore` class | `packages/universal/ab-testing/src/store/assignment-store.ts` | L17-L78 | Verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Generic `TArmId` parameter on `Bandit<TArmId>` | Ergonomic improvement over upstream's positional `number` arms — callers pass string variant IDs directly, eliminating an error-prone `number → variantId` lookup in user code. Upstream uses array indices; genesis preserves array indices as the default `TArmId = number` so existing-shape code continues to work. |
| Pocock spending boundary alternative | Upstream ships only O'Brien-Fleming. Pocock's constant-z spending is the canonical alternative; offering both lets callers pick the conservative-early (O'Brien-Fleming) vs. liberal-early (Pocock) trade-off. Adds < 30 lines and zero deps. |
| Inject `Rng` instead of defaulting to `Math.random` | CLAUDE.md determinism rule (Critical rule 9) requires no module-scope `Math.random()`. Upstream defaults all `rng` parameters to `Math.random`; genesis SHALL require an explicit `Rng` from `@suaveplan/types` at call boundary, with `crypto.getRandomValues`-based default available via `createDefaultRng()`. |
| `AssignmentStoreLike` promoted to `@suaveplan/types` | AGENTS.md §0 rule 5: shared interface across ≥2 packages (`@suaveplan/ab-testing` defines, future `@suaveplan/ab-testing-redis` / `-surrealdb` consume) must live in the types-only foundation to prevent circular deps. Upstream defines it inline. |
| New `AB_CODES` entries — `INVALID_BANDIT_CONFIG`, `INVALID_FACTOR_DESIGN`, `INVALID_SEGMENT_ATTRIBUTE`, `INSUFFICIENT_SAMPLE_SIZE`, `INVALID_SEQUENTIAL_CONFIG` | Upstream throws plain `Error`; genesis-wide policy (CLAUDE.md Critical rule 2 "every thrown error route through `@suaveplan/error`") requires registered codes for each public failure path. |
| OTel counters / histograms — `ab.bandit.arm.selected` (counter, by arm_id), `ab.multivariate.combinations.evaluated` (counter), `ab.sequential.alpha.spent` (histogram), `ab.bayesian.simulations.duration_ms` (histogram), `ab.assignment.store.lookup` (counter, by hit/miss) | Genesis-wide instrumentation policy. Upstream ships zero observability. |
## Verification approach
Each requirement in the spec delta carries a `**Verified by:**` line pointing at concrete `it(...)` titles. Test files that will exist after this change ships:
- `packages/testing/ab-testing/src/bandit/bandit.test.ts` — `EpsilonGreedyBandit` exploration vs. exploitation distribution; `UCB1Bandit` plays each arm once; `ThompsonSamplingBandit` posterior update on success vs. failure
- `packages/testing/ab-testing/src/bandit/bandit.spec-conformance.test.ts` — injected `Rng` is the only randomness source; `INVALID_BANDIT_CONFIG` thrown when `arms.length < 2`
- `packages/testing/ab-testing/src/multivariate/multivariate-engine.test.ts` — `fullFactorial` exhaustive enumeration; `fractionalFactorial` step semantics; factor importance ordering; `bestCombination` is the highest-rate treatment with > 0 participants
- `packages/testing/ab-testing/src/multivariate/multivariate-engine.spec-conformance.test.ts` — interaction strength is non-negative; pairwise interactions cover every `(i, j)` with `i < j`
- `packages/testing/ab-testing/src/segments/segment-analyzer.test.ts` — segment grouping by attribute value; significance gates on min sample size (≥10); confidence-interval coverage is `[0, 1]`-clamped
- `packages/testing/ab-testing/src/segments/segment-analyzer.spec-conformance.test.ts` — segment × variant interaction-strength threshold; `INVALID_SEGMENT_ATTRIBUTE` thrown for attributes absent from every assignment
- `packages/testing/ab-testing/src/sequential/sequential-test.test.ts` — O'Brien-Fleming boundary monotone-decreasing in informationFraction; Pocock boundary constant; `runSequentialTest` stops at first `shouldStop`
- `packages/testing/ab-testing/src/sequential/sequential-test.spec-conformance.test.ts` — alpha-spent monotone-increasing; futility check only fires when `informationFraction ≥ 0.75`; `INVALID_SEQUENTIAL_CONFIG` thrown for `alpha ∉ (0, 1)` or `maxLooks < 1`
- `packages/testing/ab-testing/src/stats/bayesian.test.ts` — `betaPdf` / `betaCdf` / `betaQuantile` round-trip; `posteriorBeta` conjugate update; `probabilityBGreaterA` Monte Carlo converges to closed-form for known cases; `credibleInterval` width parameter
- `packages/testing/ab-testing/src/stats/bayesian.spec-conformance.test.ts` — injected `Rng` reproducibility for `betaSample` / `thompsonSample` / `expectedLoss`
- `packages/testing/ab-testing/src/store/memory-assignment-store.test.ts` — `set`/`get` round-trip; `getByExperiment` / `getByUser` filtering; `deleteByExperiment` cascade; `clear`
- `packages/testing/ab-testing/src/store/memory-assignment-store.spec-conformance.test.ts` — `AssignmentStoreLike` contract pinned (will be re-used to verify future Redis/SurrealDB adapters)
- `tests/ab-testing-public-surface.test.ts` (cross-package) — pins the additive subpath-export surface so removal is a deliberate spec change
## Migration impact
- **Existing `createExperimentManager` consumers** — no behavior change. The manager gains an optional `store?: AssignmentStoreLike` field on `ExperimentManagerOptions`; existing consumers that don't pass it keep the in-memory derivation path.
- **`@suaveplan/ab-testing` direct dependents** — none in the genesis monorepo (`bunx turbo ls --filter=...@suaveplan/ab-testing` returns the package itself only).
- **Subpath imports** — six new subpaths added (`./bandit`, `./multivariate`, `./segments`, `./sequential`, `./stats`, `./store`). The root barrel re-exports from each. No removed or renamed exports.
- **`@suaveplan/types`** — the `AssignmentStoreLike` interface is promoted into `@suaveplan/types` per AGENTS.md §0 rule 5. This is additive to `@suaveplan/types` and does not affect existing consumers.
- **Error-code consumers** — additive only; existing `AB_CODES` entries preserved.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Deterministic FNV-1a bucketing | YES | YES | Already on genesis; preserved |
| Targeting rules | YES | YES | Already on genesis; preserved |
| Variant assignment & providers | YES | YES | Already on genesis; preserved |
| Frequentist results (`computeResults`) | YES | YES | Already on genesis; preserved |
| Bandits (`EpsilonGreedy`, `UCB1`, `ThompsonSampling`) | YES | YES | This change |
| Multivariate engine (full + fractional factorial, factor effects, interactions) | YES | YES | This change |
| Segment analysis (per-segment lift, CI, interaction) | YES | YES | This change |
| Sequential testing (O'Brien-Fleming alpha-spending, futility, early stopping) | YES | YES | This change |
| Sequential testing (Pocock boundary alternative) | NO | YES | Genesis enhancement; documented in "no source line" table |
| Bayesian stats (Beta-Binomial posteriors, credible intervals, expected loss) | YES | YES | This change |
| Assignment store (`AssignmentStore` + `MemoryAssignmentStore`) | YES | YES | This change |
| Persistent assignment-store adapters (Redis / SurrealDB / SQLite) | NO | NO | Scoped out — each adapter ships as its own package once `AssignmentStoreLike` is pinned |
| Contextual bandits (LinUCB, Thompson with features) | NO | NO | Scoped out — not in upstream, no consumer asking |
| Multi-objective / Pareto bandits | NO | NO | Scoped out — not in upstream |
| Continuous-monitoring p-values (mSPRT, e-values) | NO | NO | Scoped out — distinct statistical regime; alpha-spending covers the documented use case |
## Affected dependents
`bunx turbo ls --filter=...@suaveplan/ab-testing` enumerates only the package itself (no in-repo dependents).
- `@suaveplan/ab-testing` (self) — [x] tests run / [x] docs reviewed / [ ] no action needed
`@suaveplan/types` is touched additively (new `AssignmentStoreLike` interface). Verify the dep graph after promotion:
- `@suaveplan/types` — [x] tests run / [x] docs reviewed / [ ] no action needed (purely additive types-only export; `byte-size` test guards no runtime code is shipped)
No external dependents on genesis. The proposal is additive (no removed or modified exports), so no downstream sweep is required.