@traffical/core 0.1.2

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.
Files changed (68) hide show
  1. package/dist/dedup/decision-dedup.d.ts +74 -0
  2. package/dist/dedup/decision-dedup.d.ts.map +1 -0
  3. package/dist/dedup/decision-dedup.js +132 -0
  4. package/dist/dedup/decision-dedup.js.map +1 -0
  5. package/dist/dedup/index.d.ts +5 -0
  6. package/dist/dedup/index.d.ts.map +1 -0
  7. package/dist/dedup/index.js +5 -0
  8. package/dist/dedup/index.js.map +1 -0
  9. package/dist/edge/client.d.ts +109 -0
  10. package/dist/edge/client.d.ts.map +1 -0
  11. package/dist/edge/client.js +154 -0
  12. package/dist/edge/client.js.map +1 -0
  13. package/dist/edge/index.d.ts +7 -0
  14. package/dist/edge/index.d.ts.map +1 -0
  15. package/dist/edge/index.js +7 -0
  16. package/dist/edge/index.js.map +1 -0
  17. package/dist/hashing/bucket.d.ts +56 -0
  18. package/dist/hashing/bucket.d.ts.map +1 -0
  19. package/dist/hashing/bucket.js +89 -0
  20. package/dist/hashing/bucket.js.map +1 -0
  21. package/dist/hashing/fnv1a.d.ts +17 -0
  22. package/dist/hashing/fnv1a.d.ts.map +1 -0
  23. package/dist/hashing/fnv1a.js +27 -0
  24. package/dist/hashing/fnv1a.js.map +1 -0
  25. package/dist/hashing/index.d.ts +8 -0
  26. package/dist/hashing/index.d.ts.map +1 -0
  27. package/dist/hashing/index.js +8 -0
  28. package/dist/hashing/index.js.map +1 -0
  29. package/dist/ids/index.d.ts +83 -0
  30. package/dist/ids/index.d.ts.map +1 -0
  31. package/dist/ids/index.js +165 -0
  32. package/dist/ids/index.js.map +1 -0
  33. package/dist/index.d.ts +20 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +32 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/resolution/conditions.d.ts +81 -0
  38. package/dist/resolution/conditions.d.ts.map +1 -0
  39. package/dist/resolution/conditions.js +197 -0
  40. package/dist/resolution/conditions.js.map +1 -0
  41. package/dist/resolution/engine.d.ts +54 -0
  42. package/dist/resolution/engine.d.ts.map +1 -0
  43. package/dist/resolution/engine.js +382 -0
  44. package/dist/resolution/engine.js.map +1 -0
  45. package/dist/resolution/index.d.ts +8 -0
  46. package/dist/resolution/index.d.ts.map +1 -0
  47. package/dist/resolution/index.js +10 -0
  48. package/dist/resolution/index.js.map +1 -0
  49. package/dist/types/index.d.ts +440 -0
  50. package/dist/types/index.d.ts.map +1 -0
  51. package/dist/types/index.js +8 -0
  52. package/dist/types/index.js.map +1 -0
  53. package/package.json +51 -0
  54. package/src/dedup/decision-dedup.ts +175 -0
  55. package/src/dedup/index.ts +6 -0
  56. package/src/edge/client.ts +256 -0
  57. package/src/edge/index.ts +16 -0
  58. package/src/hashing/bucket.ts +115 -0
  59. package/src/hashing/fnv1a.test.ts +87 -0
  60. package/src/hashing/fnv1a.ts +31 -0
  61. package/src/hashing/index.ts +15 -0
  62. package/src/ids/index.ts +221 -0
  63. package/src/index.ts +136 -0
  64. package/src/resolution/conditions.ts +253 -0
  65. package/src/resolution/engine.test.ts +242 -0
  66. package/src/resolution/engine.ts +480 -0
  67. package/src/resolution/index.ts +32 -0
  68. package/src/types/index.ts +508 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Resolution Engine
3
+ *
4
+ * Pure functions for parameter resolution using layered config and policies.
5
+ * Implements the Google-inspired layering system where:
6
+ * - Parameters are partitioned into layers
7
+ * - Within a layer, only one policy can be active for a unit
8
+ * - Across layers, policies overlap freely (different parameters)
9
+ *
10
+ * Resolution order (lowest to highest priority):
11
+ * 1. Caller defaults (always safe fallback)
12
+ * 2. Parameter defaults (from bundle)
13
+ * 3. Layer policies (each parameter belongs to exactly one layer)
14
+ */
15
+
16
+ import type {
17
+ ConfigBundle,
18
+ BundleParameter,
19
+ BundlePolicy,
20
+ BundleAllocation,
21
+ Context,
22
+ ParameterValue,
23
+ DecisionResult,
24
+ LayerResolution,
25
+ Id,
26
+ } from "../types/index.js";
27
+ import { computeBucket, findMatchingAllocation } from "../hashing/bucket.js";
28
+ import { evaluateConditions } from "./conditions.js";
29
+ import { generateDecisionId } from "../ids/index.js";
30
+ import { fnv1a } from "../hashing/fnv1a.js";
31
+
32
+ /**
33
+ * Filters context to only include fields allowed by matched policies.
34
+ * Collects the union of all allowed fields from policies with contextLogging config.
35
+ *
36
+ * @param context - The full evaluation context
37
+ * @param policies - Matched policies from resolution
38
+ * @returns Filtered context with only allowed fields, or undefined if no fields allowed
39
+ */
40
+ function filterContext(
41
+ context: Context,
42
+ policies: BundlePolicy[]
43
+ ): Context | undefined {
44
+ // Collect union of all allowed fields from matched policies
45
+ const allowedFields = new Set<string>();
46
+ for (const policy of policies) {
47
+ if (policy.contextLogging?.allowedFields) {
48
+ for (const field of policy.contextLogging.allowedFields) {
49
+ allowedFields.add(field);
50
+ }
51
+ }
52
+ }
53
+
54
+ // If no fields are allowed, return undefined
55
+ if (allowedFields.size === 0) {
56
+ return undefined;
57
+ }
58
+
59
+ // Filter context to only include allowed fields
60
+ const filtered: Context = {};
61
+ for (const field of allowedFields) {
62
+ if (field in context) {
63
+ filtered[field] = context[field];
64
+ }
65
+ }
66
+
67
+ // Return undefined if no fields matched
68
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
69
+ }
70
+
71
+ // =============================================================================
72
+ // Per-Entity Resolution Helpers
73
+ // =============================================================================
74
+
75
+ /**
76
+ * Builds an entity ID from context using the policy's entityKeys.
77
+ *
78
+ * @param entityKeys - Array of context keys that identify the entity
79
+ * @param context - The evaluation context
80
+ * @returns Entity ID string, or null if any key is missing
81
+ */
82
+ function buildEntityId(entityKeys: string[], context: Context): string | null {
83
+ const parts: string[] = [];
84
+ for (const key of entityKeys) {
85
+ const value = context[key];
86
+ if (value === undefined || value === null) {
87
+ return null;
88
+ }
89
+ parts.push(String(value));
90
+ }
91
+ return parts.join("_");
92
+ }
93
+
94
+ /**
95
+ * Performs deterministic weighted selection using a hash.
96
+ *
97
+ * Uses the entity ID + unit key + policy ID to deterministically select
98
+ * an allocation based on weights. This ensures the same entity always
99
+ * gets the same allocation for a given weight distribution.
100
+ *
101
+ * @param weights - Array of weights (should sum to 1.0)
102
+ * @param seed - Seed string for deterministic hashing
103
+ * @returns Index of selected allocation
104
+ */
105
+ function weightedSelection(weights: number[], seed: string): number {
106
+ if (weights.length === 0) return 0;
107
+ if (weights.length === 1) return 0;
108
+
109
+ // Compute a deterministic random value in [0, 1) using hash
110
+ const hash = fnv1a(seed);
111
+ const random = (hash % 10000) / 10000;
112
+
113
+ // Select based on cumulative weights
114
+ let cumulative = 0;
115
+ for (let i = 0; i < weights.length; i++) {
116
+ cumulative += weights[i];
117
+ if (random < cumulative) {
118
+ return i;
119
+ }
120
+ }
121
+
122
+ // Fallback to last allocation (handles floating point edge cases)
123
+ return weights.length - 1;
124
+ }
125
+
126
+ /**
127
+ * Creates uniform weights for dynamic allocations.
128
+ *
129
+ * @param count - Number of allocations
130
+ * @returns Array of equal weights summing to 1.0
131
+ */
132
+ function createUniformWeights(count: number): number[] {
133
+ if (count <= 0) return [];
134
+ const weight = 1 / count;
135
+ return Array(count).fill(weight);
136
+ }
137
+
138
+ /**
139
+ * Gets entity weights from the bundle's entityState.
140
+ *
141
+ * @param bundle - The config bundle
142
+ * @param policyId - The policy ID
143
+ * @param entityId - The entity ID
144
+ * @param allocationCount - Number of allocations (for dynamic allocations)
145
+ * @returns Entity weights or uniform weights for cold start
146
+ */
147
+ function getEntityWeights(
148
+ bundle: ConfigBundle,
149
+ policyId: Id,
150
+ entityId: string,
151
+ allocationCount: number
152
+ ): number[] {
153
+ const policyState = bundle.entityState?.[policyId];
154
+
155
+ if (!policyState) {
156
+ // No state for this policy - use uniform weights
157
+ return createUniformWeights(allocationCount);
158
+ }
159
+
160
+ // Try entity-specific weights first
161
+ const entityWeights = policyState.entities[entityId];
162
+ if (entityWeights && entityWeights.weights.length === allocationCount) {
163
+ return entityWeights.weights;
164
+ }
165
+
166
+ // Fall back to global prior
167
+ const globalWeights = policyState._global;
168
+ if (globalWeights && globalWeights.weights.length === allocationCount) {
169
+ return globalWeights.weights;
170
+ }
171
+
172
+ // Last resort: uniform weights
173
+ return createUniformWeights(allocationCount);
174
+ }
175
+
176
+ /**
177
+ * Resolves a per-entity policy using weighted selection.
178
+ *
179
+ * @param bundle - The config bundle
180
+ * @param policy - The policy with entityConfig
181
+ * @param context - The evaluation context
182
+ * @param unitKeyValue - The unit key value for hashing
183
+ * @returns The selected allocation and entity ID, or null if cannot resolve
184
+ */
185
+ function resolvePerEntityPolicy(
186
+ bundle: ConfigBundle,
187
+ policy: BundlePolicy,
188
+ context: Context,
189
+ unitKeyValue: string
190
+ ): { allocation: BundleAllocation; entityId: string } | null {
191
+ const entityConfig = policy.entityConfig;
192
+ if (!entityConfig) return null;
193
+
194
+ // Build entity ID from context
195
+ const entityId = buildEntityId(entityConfig.entityKeys, context);
196
+ if (!entityId) {
197
+ // Missing entity keys - cannot resolve
198
+ return null;
199
+ }
200
+
201
+ // Determine allocations
202
+ let allocations: BundleAllocation[];
203
+ let allocationCount: number;
204
+
205
+ if (entityConfig.dynamicAllocations) {
206
+ // Dynamic allocations from context
207
+ const countKey = entityConfig.dynamicAllocations.countKey;
208
+ const count = context[countKey];
209
+ if (typeof count !== "number" || count <= 0) {
210
+ return null;
211
+ }
212
+ allocationCount = Math.floor(count);
213
+
214
+ // Create synthetic allocations for dynamic mode
215
+ // Each allocation is an index (0, 1, 2, ..., count-1)
216
+ allocations = Array.from({ length: allocationCount }, (_, i) => ({
217
+ id: `${policy.id}_dynamic_${i}`,
218
+ name: String(i),
219
+ bucketRange: [0, 0] as [number, number], // Not used for per-entity
220
+ overrides: {}, // Overrides are applied differently for dynamic
221
+ }));
222
+ } else {
223
+ // Fixed allocations from policy
224
+ allocations = policy.allocations;
225
+ allocationCount = allocations.length;
226
+ }
227
+
228
+ if (allocationCount === 0) return null;
229
+
230
+ // Get weights for this entity
231
+ const weights = getEntityWeights(bundle, policy.id, entityId, allocationCount);
232
+
233
+ // Deterministic weighted selection
234
+ const seed = `${entityId}:${unitKeyValue}:${policy.id}`;
235
+ const selectedIndex = weightedSelection(weights, seed);
236
+
237
+ return {
238
+ allocation: allocations[selectedIndex],
239
+ entityId,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Extracts the unit key value from context using the bundle's hashing config.
245
+ *
246
+ * @param bundle - The config bundle
247
+ * @param context - The evaluation context
248
+ * @returns The unit key value as a string, or null if not found
249
+ */
250
+ export function getUnitKeyValue(
251
+ bundle: ConfigBundle,
252
+ context: Context
253
+ ): string | null {
254
+ const value = context[bundle.hashing.unitKey];
255
+
256
+ if (value === undefined || value === null) {
257
+ return null;
258
+ }
259
+
260
+ return String(value);
261
+ }
262
+
263
+ /**
264
+ * Internal resolution result with metadata.
265
+ */
266
+ interface ResolutionResult<T> {
267
+ assignments: T;
268
+ unitKeyValue: string;
269
+ layers: LayerResolution[];
270
+ /** Matched policies with context logging config */
271
+ matchedPolicies: BundlePolicy[];
272
+ }
273
+
274
+ /**
275
+ * Internal function that performs parameter resolution with metadata tracking.
276
+ * This is the single source of truth for resolution logic.
277
+ *
278
+ * @param bundle - The config bundle (can be null if unavailable)
279
+ * @param context - The evaluation context
280
+ * @param defaults - Default values for parameters (required, used as fallback)
281
+ * @returns Resolution result with assignments and metadata
282
+ */
283
+ function resolveInternal<T extends Record<string, ParameterValue>>(
284
+ bundle: ConfigBundle | null,
285
+ context: Context,
286
+ defaults: T
287
+ ): ResolutionResult<T> {
288
+ // Start with caller defaults (always safe)
289
+ const assignments = { ...defaults } as Record<string, ParameterValue>;
290
+ const layers: LayerResolution[] = [];
291
+ const matchedPolicies: BundlePolicy[] = [];
292
+
293
+ // If no bundle, return defaults with empty metadata
294
+ if (!bundle) {
295
+ return { assignments: assignments as T, unitKeyValue: "", layers, matchedPolicies };
296
+ }
297
+
298
+ // Try to get unit key
299
+ const unitKeyValue = getUnitKeyValue(bundle, context);
300
+ if (!unitKeyValue) {
301
+ // Missing unit key - return defaults
302
+ return { assignments: assignments as T, unitKeyValue: "", layers, matchedPolicies };
303
+ }
304
+
305
+ // Get requested parameter keys from defaults
306
+ const requestedKeys = new Set(Object.keys(defaults));
307
+
308
+ // Filter bundle parameters to only those requested
309
+ const params = bundle.parameters.filter((p) => requestedKeys.has(p.key));
310
+
311
+ // Apply bundle defaults (overrides caller defaults)
312
+ for (const param of params) {
313
+ if (param.key in assignments) {
314
+ assignments[param.key] = param.default;
315
+ }
316
+ }
317
+
318
+ // Group by layer
319
+ const paramsByLayer = new Map<Id, BundleParameter[]>();
320
+ for (const param of params) {
321
+ const existing = paramsByLayer.get(param.layerId) || [];
322
+ existing.push(param);
323
+ paramsByLayer.set(param.layerId, existing);
324
+ }
325
+
326
+ // Process each layer (order doesn't matter since params are partitioned)
327
+ for (const layer of bundle.layers) {
328
+ const layerParams = paramsByLayer.get(layer.id);
329
+ if (!layerParams || layerParams.length === 0) continue;
330
+
331
+ // Compute bucket
332
+ const bucket = computeBucket(
333
+ unitKeyValue,
334
+ layer.id,
335
+ bundle.hashing.bucketCount
336
+ );
337
+
338
+ let matchedPolicy: BundlePolicy | undefined;
339
+ let matchedAllocation: BundleAllocation | undefined;
340
+
341
+ // Find matching policy
342
+ for (const policy of layer.policies) {
343
+ if (policy.state !== "running") continue;
344
+
345
+ // Check bucket eligibility BEFORE conditions (performance optimization)
346
+ // This enables non-overlapping experiments within a layer
347
+ if (policy.eligibleBucketRange) {
348
+ const { start, end } = policy.eligibleBucketRange;
349
+ if (bucket < start || bucket > end) {
350
+ continue; // User's bucket not eligible for this policy
351
+ }
352
+ }
353
+
354
+ if (!evaluateConditions(policy.conditions, context)) continue;
355
+
356
+ // Check if this is a per-entity policy
357
+ if (policy.entityConfig && policy.entityConfig.resolutionMode === "bundle") {
358
+ const result = resolvePerEntityPolicy(bundle, policy, context, unitKeyValue);
359
+ if (result) {
360
+ matchedPolicy = policy;
361
+ matchedAllocation = result.allocation;
362
+
363
+ // Track matched policy for context filtering
364
+ matchedPolicies.push(policy);
365
+
366
+ // Apply overrides
367
+ // For dynamic allocations, the allocation name IS the value
368
+ if (policy.entityConfig.dynamicAllocations) {
369
+ // For per-entity dynamic policies, we return the selected index
370
+ // The SDK caller should use metadata.allocationName to get the index
371
+ // No parameter overrides to apply in this mode
372
+ } else {
373
+ // For fixed allocations, apply normal overrides
374
+ for (const [key, value] of Object.entries(result.allocation.overrides)) {
375
+ if (key in assignments) {
376
+ assignments[key] = value;
377
+ }
378
+ }
379
+ }
380
+ break; // Only one policy per layer
381
+ }
382
+ } else if (policy.entityConfig && policy.entityConfig.resolutionMode === "edge") {
383
+ // Edge mode: skip for now, will be handled by SDK's async resolution
384
+ // In synchronous resolution, we fall through to bucket-based resolution
385
+ // The SDK should use async decide() for edge mode policies
386
+ continue;
387
+ } else {
388
+ // Standard bucket-based resolution
389
+ const allocation = findMatchingAllocation(bucket, policy.allocations);
390
+ if (allocation) {
391
+ matchedPolicy = policy;
392
+ matchedAllocation = allocation;
393
+
394
+ // Track matched policy for context filtering
395
+ matchedPolicies.push(policy);
396
+
397
+ // Apply overrides
398
+ for (const [key, value] of Object.entries(allocation.overrides)) {
399
+ if (key in assignments) {
400
+ assignments[key] = value;
401
+ }
402
+ }
403
+ break; // Only one policy per layer
404
+ }
405
+ }
406
+ }
407
+
408
+ layers.push({
409
+ layerId: layer.id,
410
+ bucket,
411
+ policyId: matchedPolicy?.id,
412
+ allocationId: matchedAllocation?.id,
413
+ allocationName: matchedAllocation?.name,
414
+ });
415
+ }
416
+
417
+ return { assignments: assignments as T, unitKeyValue, layers, matchedPolicies };
418
+ }
419
+
420
+ /**
421
+ * Resolves parameters with required defaults as fallback.
422
+ * This is the primary SDK function that guarantees safe defaults.
423
+ *
424
+ * Resolution priority (highest wins):
425
+ * 1. Policy overrides (from bundle)
426
+ * 2. Parameter defaults (from bundle)
427
+ * 3. Caller defaults (always safe fallback)
428
+ *
429
+ * @param bundle - The config bundle (can be null if unavailable)
430
+ * @param context - The evaluation context
431
+ * @param defaults - Default values for parameters (required, used as fallback)
432
+ * @returns Resolved parameter assignments (always returns safe values with inferred types)
433
+ */
434
+ export function resolveParameters<T extends Record<string, ParameterValue>>(
435
+ bundle: ConfigBundle | null,
436
+ context: Context,
437
+ defaults: T
438
+ ): T {
439
+ return resolveInternal(bundle, context, defaults).assignments;
440
+ }
441
+
442
+ /**
443
+ * Makes a decision with full metadata for tracking.
444
+ * Requires defaults for graceful degradation.
445
+ *
446
+ * Resolution priority (highest wins):
447
+ * 1. Policy overrides (from bundle)
448
+ * 2. Parameter defaults (from bundle)
449
+ * 3. Caller defaults (always safe fallback)
450
+ *
451
+ * @param bundle - The config bundle (can be null if unavailable)
452
+ * @param context - The evaluation context
453
+ * @param defaults - Default values for parameters (required, used as fallback)
454
+ * @returns Decision result with metadata (always returns safe values)
455
+ */
456
+ export function decide<T extends Record<string, ParameterValue>>(
457
+ bundle: ConfigBundle | null,
458
+ context: Context,
459
+ defaults: T
460
+ ): DecisionResult {
461
+ const { assignments, unitKeyValue, layers, matchedPolicies } = resolveInternal(
462
+ bundle,
463
+ context,
464
+ defaults
465
+ );
466
+
467
+ // Filter context based on matched policies' contextLogging config
468
+ const filteredContext = filterContext(context, matchedPolicies);
469
+
470
+ return {
471
+ decisionId: generateDecisionId(),
472
+ assignments,
473
+ metadata: {
474
+ timestamp: new Date().toISOString(),
475
+ unitKeyValue,
476
+ layers,
477
+ filteredContext,
478
+ },
479
+ };
480
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Resolution Module
3
+ *
4
+ * Exports all resolution-related functions.
5
+ */
6
+
7
+ export {
8
+ resolveParameters,
9
+ decide,
10
+ getUnitKeyValue,
11
+ } from "./engine.js";
12
+
13
+ export {
14
+ evaluateCondition,
15
+ evaluateConditions,
16
+ // Condition builders
17
+ eq,
18
+ neq,
19
+ inValues,
20
+ notIn,
21
+ gt,
22
+ gte,
23
+ lt,
24
+ lte,
25
+ contains,
26
+ startsWith,
27
+ endsWith,
28
+ regex,
29
+ exists,
30
+ notExists,
31
+ } from "./conditions.js";
32
+