@variantlab/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import { E as Experiment, a as ExperimentsConfig } from './types-BkXPpEyg.js';
2
+ export { A as AssignmentStrategy, R as RollbackConfig, T as Targeting, V as Variant, b as VariantContext } from './types-BkXPpEyg.js';
3
+ export { C as ConfigIssue, a as ConfigValidationError, I as IssueCode, v as validateConfig } from './config-B3DTOt1J.js';
4
+ export { E as EngineEvent, a as EngineOptions, F as FailMode, L as Listener, b as ListenerSet, U as UnknownExperimentError, V as VariantChangeSource, c as VariantEngine, d as createEngine } from './engine-BoNBfZBL.js';
5
+ export { EvalContext, EvaluableTargeting, ExplainField, ExplainResult, ExplainStep, TargetingResult, evaluate, explain, hashUserId, matchRoute, matchSemver, matchTargeting } from './_size/targeting.js';
6
+
7
+ /**
8
+ * Default assignment: always returns the experiment's `default`
9
+ * variant. Used when no strategy is configured, when the user is
10
+ * not yet bucketable (no `userId`), or as the fallback in
11
+ * fail-open error paths.
12
+ */
13
+
14
+ declare function assignDefault(experiment: Experiment): string;
15
+
16
+ /**
17
+ * Synchronous 32-bit string hash for deterministic assignment.
18
+ *
19
+ * `getVariant` is required to be synchronous and O(1) (see
20
+ * `ARCHITECTURE.md` runtime data flow and the design principle
21
+ * of cheap, render-safe reads). Web Crypto's `subtle.digest` is
22
+ * always async, so we hand-roll a tiny hash here:
23
+ *
24
+ * - FNV-1a (32-bit) core for fast streaming.
25
+ * - Murmur3 finalizer for avalanche (uniform bit distribution).
26
+ *
27
+ * Total: ~150 bytes gzipped. Deterministic across every runtime we
28
+ * target (Node 18+, browsers, RN, Deno, Bun, Edge) because every
29
+ * step is a 32-bit integer operation with defined overflow semantics
30
+ * via `Math.imul` and `>>> 0`.
31
+ *
32
+ * This is **not** cryptographic — do not use for signing. The async
33
+ * sha256 path in `../targeting/hash.ts` remains available for
34
+ * advanced use cases. The phase-0 "which hash algorithm" open
35
+ * question in `docs/phases/phase-0-foundation.md` resolves here.
36
+ */
37
+ /** FNV-1a 32-bit hash + Murmur3 finalizer. Returns a uint32. */
38
+ declare function hash32(input: string): number;
39
+ /** Map a userId to a [0, 100) bucket. Used by user-id hash-mod targeting. */
40
+ declare function bucketUserId(userId: string): number;
41
+
42
+ declare function resolveMutex(userId: string, mutexGroup: string, candidateIds: readonly string[]): string | undefined;
43
+
44
+ /**
45
+ * "Random" assignment.
46
+ *
47
+ * In phase 1 MVP this is behaviorally identical to `sticky-hash`:
48
+ * a deterministic function of `(userId, experimentId)`. The two
49
+ * strategies exist as distinct config values because the contract
50
+ * differs:
51
+ *
52
+ * - `sticky-hash` — promised to be stable across devices for the
53
+ * same `userId`, zero storage needed.
54
+ * - `random` — the engine is allowed to bucket the user
55
+ * however it likes as long as each user sees a consistent
56
+ * answer. Future phases may back this with storage-cached
57
+ * uniform-random assignments.
58
+ *
59
+ * The non-negotiable rule (design-principles.md §performance &
60
+ * ARCHITECTURE.md §core invariants): **no `Math.random` in the hot
61
+ * path**. Delegating to `assignStickyHash` keeps us honest and
62
+ * collapses both paths to the same tree-shaken code.
63
+ */
64
+
65
+ declare function assignRandom(experiment: Experiment, userId: string): string;
66
+
67
+ /**
68
+ * Sticky-hash assignment.
69
+ *
70
+ * Deterministic from `(userId, experimentId)`: hash the pair with
71
+ * the sync 32-bit hash and map the result modulo the sorted variant
72
+ * count. Sort order is lexicographic on `variant.id` so that the
73
+ * mapping is stable across processes, devices, and restarts.
74
+ *
75
+ * Adding a new variant renumbers existing users (the classic
76
+ * hash-mod trade-off). This is acceptable for phase 1 — the
77
+ * config-format doc calls out that variant additions are effectively
78
+ * a new experiment. Rendezvous hashing would fix this but costs
79
+ * ~300 bytes we don't want to spend before someone asks for it.
80
+ */
81
+
82
+ declare function assignStickyHash(experiment: Experiment, userId: string): string;
83
+
84
+ /**
85
+ * Weighted assignment.
86
+ *
87
+ * Maps `(userId, experimentId)` to a `[0, 10000)` bucket via the
88
+ * sync hash, then walks the split's cumulative boundaries in
89
+ * sorted-key order. `split` is validated at config-load time to
90
+ * sum to exactly 100, so we multiply by 100 internally to get an
91
+ * integer 10000-unit range without floating point.
92
+ *
93
+ * Determinism: same user, same experiment, same split → same
94
+ * variant forever. Changing the split re-buckets everyone, which
95
+ * is the documented behavior in `config-format.md`.
96
+ */
97
+
98
+ declare function assignWeighted(experiment: Experiment, userId: string): string;
99
+
100
+ /**
101
+ * Assignment barrel.
102
+ *
103
+ * `assignVariant` is the single entry point used by the engine hot
104
+ * path. It dispatches on `experiment.assignment` and falls back to
105
+ * the default variant whenever the user isn't bucketable (missing
106
+ * `userId`) — every non-`default` strategy depends on a stable
107
+ * identity to be deterministic.
108
+ */
109
+
110
+ declare function assignVariant(experiment: Experiment, userId: string | undefined): string;
111
+
112
+ /**
113
+ * Deterministic JSON serializer (RFC 8785-inspired, not strict).
114
+ *
115
+ * - Object keys are sorted by UTF-16 code unit (`Array#sort` default).
116
+ * - Only own enumerable keys are serialized.
117
+ * - `undefined` values on objects are dropped; `undefined` in arrays
118
+ * is emitted as `null` (matches `JSON.stringify`).
119
+ * - Finite numbers only; `NaN`/`Infinity`/`-Infinity` throw.
120
+ * - `BigInt`, functions, and symbols throw.
121
+ * - No whitespace in the output.
122
+ * - A depth cap of 512 guards against accidental unbounded recursion.
123
+ *
124
+ * The output is the canonical form used as input to HMAC signing
125
+ * (Phase 2). It is **not** used for the 1 MB size check — that is
126
+ * measured against the raw input bytes.
127
+ */
128
+ declare function canonicalStringify(value: unknown): string;
129
+
130
+ /**
131
+ * Recursively freeze a plain-JSON-shaped value.
132
+ *
133
+ * - Primitives (including `null`/`undefined`) are returned as-is.
134
+ * - Arrays and plain objects are frozen in-place with `Object.freeze`.
135
+ * - Already-frozen subtrees are skipped (short-circuit) so this is
136
+ * cheap to call on shared references and idempotent.
137
+ *
138
+ * Sanitized config trees are tree-shaped (no cycles, no class
139
+ * instances) so no cycle detection is required.
140
+ */
141
+ declare function deepFreeze<T>(value: T): T;
142
+
143
+ /**
144
+ * In-memory crash counter for crash-rollback.
145
+ *
146
+ * For each experiment, we maintain a sliding window of crash
147
+ * timestamps. `record()` appends, `countWithin()` drops expired
148
+ * entries and returns the current count. Persistence across
149
+ * process restarts is phase 4 (`rollback.persistent`) — the phase 1
150
+ * MVP only protects users within a single session.
151
+ */
152
+ declare class CrashCounter {
153
+ private readonly crashes;
154
+ /** Record a crash; returns the new in-window count (post-prune). */
155
+ record(experimentId: string, now: number, window: number): number;
156
+ countWithin(experimentId: string, now: number, window: number): number;
157
+ clear(experimentId?: string): void;
158
+ }
159
+
160
+ /**
161
+ * Kill-switch logic.
162
+ *
163
+ * Returns `true` when an experiment should short-circuit to its
164
+ * default variant for a reason that isn't context-specific:
165
+ *
166
+ * - Global `config.enabled === false` — admin kill switch for
167
+ * fast incident response.
168
+ * - `status === "archived"` — the experiment is over, keep the
169
+ * config around for history but never evaluate it.
170
+ * - `status === "draft"` — the experiment is being authored; in
171
+ * production this behaves the same as archived (returns the
172
+ * default), the debug overlay shows it with a badge so devs
173
+ * can still preview it.
174
+ */
175
+
176
+ declare function isKilled(config: ExperimentsConfig, experiment: Experiment): boolean;
177
+
178
+ /**
179
+ * Start/end date gate.
180
+ *
181
+ * `startDate` is inclusive, `endDate` is exclusive. Matches the
182
+ * semantics documented in `config-format.md` §`startDate / endDate`.
183
+ *
184
+ * `now` is injected (not read via `Date.now()` here) so that the
185
+ * engine can snapshot time at a single point per resolution and
186
+ * so that tests can pin time without mocking globals. The engine
187
+ * calls `Date.now()` once per `getVariant` call at the outer
188
+ * boundary.
189
+ *
190
+ * Malformed ISO strings fail-closed (the gate returns `true` to
191
+ * keep the experiment inactive) — a broken date is a config
192
+ * mistake the validator catches at load time, but defending in
193
+ * depth here is ~10 bytes we're happy to spend.
194
+ */
195
+
196
+ declare function isTimeGated(experiment: Experiment, now: number): boolean;
197
+
198
+ /**
199
+ * Fixed-size ring buffer for engine event history.
200
+ *
201
+ * Overwrites the oldest entry when full. `toArray()` returns the
202
+ * stored entries in chronological order (oldest → newest) which is
203
+ * the order the debug overlay and time-travel replayer expect.
204
+ *
205
+ * Default capacity is 500 — at ~200 bytes per event this caps
206
+ * in-memory history at ~100 KB per engine, which is negligible
207
+ * compared to any other memory cost the app already pays.
208
+ */
209
+ declare class RingBuffer<T> {
210
+ readonly capacity: number;
211
+ private buffer;
212
+ private head;
213
+ private count;
214
+ constructor(capacity?: number);
215
+ push(item: T): void;
216
+ toArray(): T[];
217
+ clear(): void;
218
+ get size(): number;
219
+ }
220
+
221
+ declare const VERSION = "0.0.0";
222
+
223
+ export { CrashCounter, Experiment, ExperimentsConfig, RingBuffer, VERSION, assignDefault, assignRandom, assignStickyHash, assignVariant, assignWeighted, bucketUserId, canonicalStringify, deepFreeze, hash32, isKilled, isTimeGated, resolveMutex };