@naturalcycles/abba 2.11.0 → 2.12.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.
- package/dist/abba.d.ts +15 -1
- package/dist/abba.js +56 -1
- package/dist/dao/userAssignment.dao.d.ts +6 -0
- package/dist/dao/userAssignment.dao.js +11 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/src/abba.ts +80 -1
- package/src/dao/userAssignment.dao.ts +14 -0
- package/src/types.ts +7 -0
package/dist/abba.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Unsaved } from '@naturalcycles/js-lib/types';
|
|
2
2
|
import type { GetAllExperimentsOpts } from './dao/experiment.dao.js';
|
|
3
|
-
import type { AbbaConfig, Bucket, BucketInput, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentInput, ExperimentWithBuckets, SegmentationData, UserExperiment } from './types.js';
|
|
3
|
+
import type { AbbaConfig, Bucket, BucketInput, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentInput, ExperimentWithBuckets, ManualUserAssignmentInput, SegmentationData, UserExperiment } from './types.js';
|
|
4
4
|
export declare class Abba {
|
|
5
5
|
cfg: AbbaConfig;
|
|
6
6
|
private experimentDao;
|
|
@@ -52,6 +52,20 @@ export declare class Abba {
|
|
|
52
52
|
* @param segmentationData Required if existingOnly is false
|
|
53
53
|
*/
|
|
54
54
|
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<DecoratedUserAssignment | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Manually assigns users to specific buckets, overwriting any existing assignments.
|
|
57
|
+
* Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
|
|
58
|
+
* QA / internal testing where deterministic bucket placement is required.
|
|
59
|
+
*
|
|
60
|
+
* An empty input returns an empty array. If the input contains duplicate
|
|
61
|
+
* (userId, experimentKey) pairs, the last occurrence wins.
|
|
62
|
+
*
|
|
63
|
+
* Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
|
|
64
|
+
* first invalid row; no rows are written if validation fails.
|
|
65
|
+
*
|
|
66
|
+
* Cold method.
|
|
67
|
+
*/
|
|
68
|
+
saveManualUserAssignments(inputs: readonly ManualUserAssignmentInput[]): Promise<DecoratedUserAssignment[]>;
|
|
55
69
|
/**
|
|
56
70
|
* Get all existing user assignments.
|
|
57
71
|
* Hot method.
|
package/dist/abba.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { __decorate } from "tslib";
|
|
2
|
-
import { _shuffle } from '@naturalcycles/js-lib/array/array.util.js';
|
|
2
|
+
import { _mapBy, _shuffle, _uniq } from '@naturalcycles/js-lib/array/array.util.js';
|
|
3
3
|
import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js';
|
|
4
4
|
import { _Memo } from '@naturalcycles/js-lib/decorators/memo.decorator.js';
|
|
5
5
|
import { _assert } from '@naturalcycles/js-lib/error/assert.js';
|
|
@@ -223,6 +223,61 @@ export class Abba {
|
|
|
223
223
|
bucketData: bucket?.data || null,
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Manually assigns users to specific buckets, overwriting any existing assignments.
|
|
228
|
+
* Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
|
|
229
|
+
* QA / internal testing where deterministic bucket placement is required.
|
|
230
|
+
*
|
|
231
|
+
* An empty input returns an empty array. If the input contains duplicate
|
|
232
|
+
* (userId, experimentKey) pairs, the last occurrence wins.
|
|
233
|
+
*
|
|
234
|
+
* Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
|
|
235
|
+
* first invalid row; no rows are written if validation fails.
|
|
236
|
+
*
|
|
237
|
+
* Cold method.
|
|
238
|
+
*/
|
|
239
|
+
async saveManualUserAssignments(inputs) {
|
|
240
|
+
if (!inputs.length)
|
|
241
|
+
return [];
|
|
242
|
+
const dedupedInputs = Array.from(_mapBy(inputs, input => `${input.userId}|${input.experimentKey}`).values());
|
|
243
|
+
const experimentKeys = _uniq(dedupedInputs.map(input => input.experimentKey));
|
|
244
|
+
const experiments = await pMap(experimentKeys, async (experimentKey) => {
|
|
245
|
+
const experiment = await this.experimentDao.getByKey(experimentKey);
|
|
246
|
+
_assert(experiment && !experiment.deleted, `Experiment does not exist: ${experimentKey}`, {
|
|
247
|
+
code: AbbaErrorCode.ExperimentNotFound,
|
|
248
|
+
});
|
|
249
|
+
const buckets = await this.bucketDao.getByExperimentId(experiment.id);
|
|
250
|
+
return { ...experiment, buckets };
|
|
251
|
+
});
|
|
252
|
+
const experimentByKey = _mapBy(experiments, experiment => experiment.key);
|
|
253
|
+
const resolvedInputs = dedupedInputs.map(input => {
|
|
254
|
+
const experiment = experimentByKey.get(input.experimentKey);
|
|
255
|
+
const bucket = experiment.buckets.find(bucket => bucket.key === input.bucketKey);
|
|
256
|
+
_assert(bucket, `Bucket does not exist on experiment ${input.experimentKey}: ${input.bucketKey}`, { code: AbbaErrorCode.BucketNotFound });
|
|
257
|
+
return { input, experiment, bucket };
|
|
258
|
+
});
|
|
259
|
+
const userIds = _uniq(dedupedInputs.map(input => input.userId));
|
|
260
|
+
const experimentIds = experiments.map(experiment => experiment.id);
|
|
261
|
+
const existingAssignments = await this.userAssignmentDao.getByUserIdsAndExperimentIds(userIds, experimentIds);
|
|
262
|
+
const existingByKey = _mapBy(existingAssignments, assignment => `${assignment.userId}|${assignment.experimentId}`);
|
|
263
|
+
const toSave = resolvedInputs.map(({ input, experiment, bucket }) => ({
|
|
264
|
+
...existingByKey.get(`${input.userId}|${experiment.id}`),
|
|
265
|
+
userId: input.userId,
|
|
266
|
+
experimentId: experiment.id,
|
|
267
|
+
bucketId: bucket.id,
|
|
268
|
+
}));
|
|
269
|
+
const savedAssignments = await this.userAssignmentDao.saveBatch(toSave);
|
|
270
|
+
return savedAssignments.map((savedAssignment, i) => {
|
|
271
|
+
const resolvedInput = resolvedInputs[i];
|
|
272
|
+
return {
|
|
273
|
+
...savedAssignment,
|
|
274
|
+
experimentKey: resolvedInput.experiment.key,
|
|
275
|
+
experimentData: resolvedInput.experiment.data,
|
|
276
|
+
bucketKey: resolvedInput.bucket.key,
|
|
277
|
+
bucketData: resolvedInput.bucket.data,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
226
281
|
/**
|
|
227
282
|
* Get all existing user assignments.
|
|
228
283
|
* Hot method.
|
|
@@ -4,6 +4,12 @@ import type { UserAssignment } from '../types.js';
|
|
|
4
4
|
export declare class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
5
5
|
getUserAssignmentByExperimentId(userId: string, experimentId: string): Promise<UserAssignment | null>;
|
|
6
6
|
getUserAssigmentsByExperimentIds(userId: string, experimentIds: string[]): Promise<UserAssignment[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
|
|
9
|
+
* is in `experimentIds`. This is the cross-product, not paired lookup: callers must
|
|
10
|
+
* filter the result by the specific (userId, experimentId) pairs they care about.
|
|
11
|
+
*/
|
|
12
|
+
getByUserIdsAndExperimentIds(userIds: string[], experimentIds: string[]): Promise<UserAssignment[]>;
|
|
7
13
|
deleteByExperimentId(experimentId: string): Promise<void>;
|
|
8
14
|
getCountByExperimentId(experimentId: string): Promise<number>;
|
|
9
15
|
getCountByBucketId(bucketId: string): Promise<number>;
|
|
@@ -10,6 +10,17 @@ export class UserAssignmentDao extends CommonDao {
|
|
|
10
10
|
const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds);
|
|
11
11
|
return await this.runQuery(query);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
|
|
15
|
+
* is in `experimentIds`. This is the cross-product, not paired lookup: callers must
|
|
16
|
+
* filter the result by the specific (userId, experimentId) pairs they care about.
|
|
17
|
+
*/
|
|
18
|
+
async getByUserIdsAndExperimentIds(userIds, experimentIds) {
|
|
19
|
+
if (!userIds.length || !experimentIds.length)
|
|
20
|
+
return [];
|
|
21
|
+
const query = this.query().filterIn('userId', userIds).filterIn('experimentId', experimentIds);
|
|
22
|
+
return await this.runQuery(query);
|
|
23
|
+
}
|
|
13
24
|
async deleteByExperimentId(experimentId) {
|
|
14
25
|
await this.query().filterEq('experimentId', experimentId).deleteByQuery();
|
|
15
26
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const AbbaErrorCode: {
|
|
|
6
6
|
readonly SegmentationDataRequired: 'abba/segmentationDataRequired';
|
|
7
7
|
readonly InvalidBucketRatio: 'abba/invalidBucketRatio';
|
|
8
8
|
readonly BucketDeterminationFailed: 'abba/bucketDeterminationFailed';
|
|
9
|
+
readonly BucketNotFound: 'abba/bucketNotFound';
|
|
9
10
|
};
|
|
10
11
|
export interface AbbaConfig {
|
|
11
12
|
db: CommonDB;
|
|
@@ -81,6 +82,11 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
81
82
|
experimentId: string;
|
|
82
83
|
bucketId: string | null;
|
|
83
84
|
};
|
|
85
|
+
export interface ManualUserAssignmentInput {
|
|
86
|
+
userId: string;
|
|
87
|
+
experimentKey: string;
|
|
88
|
+
bucketKey: string;
|
|
89
|
+
}
|
|
84
90
|
export type DecoratedUserAssignment = UserAssignment & {
|
|
85
91
|
experimentKey: Experiment['key'];
|
|
86
92
|
experimentData: Experiment['data'];
|
package/dist/types.js
CHANGED
|
@@ -4,6 +4,7 @@ export const AbbaErrorCode = {
|
|
|
4
4
|
SegmentationDataRequired: 'abba/segmentationDataRequired',
|
|
5
5
|
InvalidBucketRatio: 'abba/invalidBucketRatio',
|
|
6
6
|
BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
|
|
7
|
+
BucketNotFound: 'abba/bucketNotFound',
|
|
7
8
|
};
|
|
8
9
|
export var AssignmentStatus;
|
|
9
10
|
(function (AssignmentStatus) {
|
package/package.json
CHANGED
package/src/abba.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _shuffle } from '@naturalcycles/js-lib/array/array.util.js'
|
|
1
|
+
import { _mapBy, _shuffle, _uniq } from '@naturalcycles/js-lib/array/array.util.js'
|
|
2
2
|
import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js'
|
|
3
3
|
import { _Memo } from '@naturalcycles/js-lib/decorators/memo.decorator.js'
|
|
4
4
|
import { _assert } from '@naturalcycles/js-lib/error/assert.js'
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
ExperimentAssignmentStatistics,
|
|
20
20
|
ExperimentInput,
|
|
21
21
|
ExperimentWithBuckets,
|
|
22
|
+
ManualUserAssignmentInput,
|
|
22
23
|
SegmentationData,
|
|
23
24
|
UserAssignment,
|
|
24
25
|
UserExperiment,
|
|
@@ -328,6 +329,84 @@ export class Abba {
|
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Manually assigns users to specific buckets, overwriting any existing assignments.
|
|
334
|
+
* Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
|
|
335
|
+
* QA / internal testing where deterministic bucket placement is required.
|
|
336
|
+
*
|
|
337
|
+
* An empty input returns an empty array. If the input contains duplicate
|
|
338
|
+
* (userId, experimentKey) pairs, the last occurrence wins.
|
|
339
|
+
*
|
|
340
|
+
* Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
|
|
341
|
+
* first invalid row; no rows are written if validation fails.
|
|
342
|
+
*
|
|
343
|
+
* Cold method.
|
|
344
|
+
*/
|
|
345
|
+
async saveManualUserAssignments(
|
|
346
|
+
inputs: readonly ManualUserAssignmentInput[],
|
|
347
|
+
): Promise<DecoratedUserAssignment[]> {
|
|
348
|
+
if (!inputs.length) return []
|
|
349
|
+
|
|
350
|
+
const dedupedInputs = Array.from(
|
|
351
|
+
_mapBy(inputs, input => `${input.userId}|${input.experimentKey}`).values(),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const experimentKeys = _uniq(dedupedInputs.map(input => input.experimentKey))
|
|
355
|
+
const experiments = await pMap(experimentKeys, async experimentKey => {
|
|
356
|
+
const experiment = await this.experimentDao.getByKey(experimentKey)
|
|
357
|
+
_assert(experiment && !experiment.deleted, `Experiment does not exist: ${experimentKey}`, {
|
|
358
|
+
code: AbbaErrorCode.ExperimentNotFound,
|
|
359
|
+
})
|
|
360
|
+
const buckets = await this.bucketDao.getByExperimentId(experiment.id)
|
|
361
|
+
return { ...experiment, buckets }
|
|
362
|
+
})
|
|
363
|
+
const experimentByKey = _mapBy(experiments, experiment => experiment.key)
|
|
364
|
+
|
|
365
|
+
const resolvedInputs = dedupedInputs.map(input => {
|
|
366
|
+
const experiment = experimentByKey.get(input.experimentKey)!
|
|
367
|
+
const bucket = experiment.buckets.find(bucket => bucket.key === input.bucketKey)
|
|
368
|
+
_assert(
|
|
369
|
+
bucket,
|
|
370
|
+
`Bucket does not exist on experiment ${input.experimentKey}: ${input.bucketKey}`,
|
|
371
|
+
{ code: AbbaErrorCode.BucketNotFound },
|
|
372
|
+
)
|
|
373
|
+
return { input, experiment, bucket }
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const userIds = _uniq(dedupedInputs.map(input => input.userId))
|
|
377
|
+
const experimentIds = experiments.map(experiment => experiment.id)
|
|
378
|
+
const existingAssignments = await this.userAssignmentDao.getByUserIdsAndExperimentIds(
|
|
379
|
+
userIds,
|
|
380
|
+
experimentIds,
|
|
381
|
+
)
|
|
382
|
+
const existingByKey = _mapBy(
|
|
383
|
+
existingAssignments,
|
|
384
|
+
assignment => `${assignment.userId}|${assignment.experimentId}`,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const toSave: Unsaved<UserAssignment>[] = resolvedInputs.map(
|
|
388
|
+
({ input, experiment, bucket }) => ({
|
|
389
|
+
...existingByKey.get(`${input.userId}|${experiment.id}`),
|
|
390
|
+
userId: input.userId,
|
|
391
|
+
experimentId: experiment.id,
|
|
392
|
+
bucketId: bucket.id,
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const savedAssignments = await this.userAssignmentDao.saveBatch(toSave)
|
|
397
|
+
|
|
398
|
+
return savedAssignments.map((savedAssignment, i) => {
|
|
399
|
+
const resolvedInput = resolvedInputs[i]!
|
|
400
|
+
return {
|
|
401
|
+
...savedAssignment,
|
|
402
|
+
experimentKey: resolvedInput.experiment.key,
|
|
403
|
+
experimentData: resolvedInput.experiment.data,
|
|
404
|
+
bucketKey: resolvedInput.bucket.key,
|
|
405
|
+
bucketData: resolvedInput.bucket.data,
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
331
410
|
/**
|
|
332
411
|
* Get all existing user assignments.
|
|
333
412
|
* Hot method.
|
|
@@ -21,6 +21,20 @@ export class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
|
21
21
|
return await this.runQuery(query)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
|
|
26
|
+
* is in `experimentIds`. This is the cross-product, not paired lookup: callers must
|
|
27
|
+
* filter the result by the specific (userId, experimentId) pairs they care about.
|
|
28
|
+
*/
|
|
29
|
+
async getByUserIdsAndExperimentIds(
|
|
30
|
+
userIds: string[],
|
|
31
|
+
experimentIds: string[],
|
|
32
|
+
): Promise<UserAssignment[]> {
|
|
33
|
+
if (!userIds.length || !experimentIds.length) return []
|
|
34
|
+
const query = this.query().filterIn('userId', userIds).filterIn('experimentId', experimentIds)
|
|
35
|
+
return await this.runQuery(query)
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
async deleteByExperimentId(experimentId: string): Promise<void> {
|
|
25
39
|
await this.query().filterEq('experimentId', experimentId).deleteByQuery()
|
|
26
40
|
}
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const AbbaErrorCode = {
|
|
|
7
7
|
SegmentationDataRequired: 'abba/segmentationDataRequired',
|
|
8
8
|
InvalidBucketRatio: 'abba/invalidBucketRatio',
|
|
9
9
|
BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
|
|
10
|
+
BucketNotFound: 'abba/bucketNotFound',
|
|
10
11
|
} as const
|
|
11
12
|
|
|
12
13
|
export interface AbbaConfig {
|
|
@@ -92,6 +93,12 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
92
93
|
bucketId: string | null
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
export interface ManualUserAssignmentInput {
|
|
97
|
+
userId: string
|
|
98
|
+
experimentKey: string
|
|
99
|
+
bucketKey: string
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
export type DecoratedUserAssignment = UserAssignment & {
|
|
96
103
|
experimentKey: Experiment['key']
|
|
97
104
|
experimentData: Experiment['data']
|