@naturalcycles/abba 2.0.1 → 2.0.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.
- package/dist/abba.d.ts +7 -6
- package/dist/abba.js +86 -93
- package/dist/dao/bucket.dao.d.ts +3 -3
- package/dist/dao/bucket.dao.js +20 -15
- package/dist/dao/experiment.dao.d.ts +3 -3
- package/dist/dao/experiment.dao.js +33 -28
- package/dist/dao/userAssignment.dao.d.ts +5 -1
- package/dist/dao/userAssignment.dao.js +21 -4
- package/dist/types.d.ts +12 -11
- package/dist/util.d.ts +8 -8
- package/dist/util.js +18 -18
- package/package.json +3 -2
- package/src/abba.ts +103 -95
- package/src/dao/bucket.dao.ts +9 -5
- package/src/dao/experiment.dao.ts +10 -5
- package/src/dao/userAssignment.dao.ts +29 -3
- package/src/types.ts +12 -12
- package/src/util.ts +16 -18
package/dist/abba.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import type { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets,
|
|
2
|
+
import type { AbbaConfig, Bucket, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, SegmentationData, UserExperiment } from './types.js';
|
|
3
3
|
export declare class Abba {
|
|
4
4
|
cfg: AbbaConfig;
|
|
5
5
|
private experimentDao;
|
|
@@ -10,7 +10,8 @@ export declare class Abba {
|
|
|
10
10
|
* Returns all experiments.
|
|
11
11
|
* Cached (see CACHE_TTL)
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
getAllExperimentsWithBuckets(): Promise<ExperimentWithBuckets[]>;
|
|
14
|
+
getAllExperimentsWithUserAssignments(userId: string): Promise<UserExperiment[]>;
|
|
14
15
|
/**
|
|
15
16
|
* Updates all user assignments with a given userId with the provided userId.
|
|
16
17
|
*/
|
|
@@ -18,7 +19,7 @@ export declare class Abba {
|
|
|
18
19
|
/**
|
|
19
20
|
* Returns all experiments.
|
|
20
21
|
*/
|
|
21
|
-
|
|
22
|
+
getAllExperimentsWithBucketsNoCache(): Promise<ExperimentWithBuckets[]>;
|
|
22
23
|
/**
|
|
23
24
|
* Creates a new experiment.
|
|
24
25
|
* Cold method.
|
|
@@ -49,20 +50,20 @@ export declare class Abba {
|
|
|
49
50
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
50
51
|
* @param segmentationData Required if existingOnly is false
|
|
51
52
|
*/
|
|
52
|
-
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<
|
|
53
|
+
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<DecoratedUserAssignment | null>;
|
|
53
54
|
/**
|
|
54
55
|
* Get all existing user assignments.
|
|
55
56
|
* Hot method.
|
|
56
57
|
* Not cached, because Assignments are fast-changing.
|
|
57
58
|
* Only to be used for testing
|
|
58
59
|
*/
|
|
59
|
-
|
|
60
|
+
getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]>;
|
|
60
61
|
/**
|
|
61
62
|
* Generate user assignments for all active experiments.
|
|
62
63
|
* Will return any existing and attempt to generate any new assignments if existingOnly is false.
|
|
63
64
|
* Hot method.
|
|
64
65
|
*/
|
|
65
|
-
generateUserAssignments(userId: string, segmentationData: SegmentationData, existingOnly?: boolean): Promise<
|
|
66
|
+
generateUserAssignments(userId: string, segmentationData: SegmentationData, existingOnly?: boolean): Promise<DecoratedUserAssignment[]>;
|
|
66
67
|
/**
|
|
67
68
|
* Get assignment statistics for an experiment.
|
|
68
69
|
* Cold method.
|
package/dist/abba.js
CHANGED
|
@@ -21,8 +21,30 @@ export class Abba {
|
|
|
21
21
|
* Returns all experiments.
|
|
22
22
|
* Cached (see CACHE_TTL)
|
|
23
23
|
*/
|
|
24
|
-
async
|
|
25
|
-
return await this.
|
|
24
|
+
async getAllExperimentsWithBuckets() {
|
|
25
|
+
return await this.getAllExperimentsWithBucketsNoCache();
|
|
26
|
+
}
|
|
27
|
+
async getAllExperimentsWithUserAssignments(userId) {
|
|
28
|
+
const experiments = await this.getAllExperimentsWithBuckets();
|
|
29
|
+
const experimentIds = experiments.map(e => e.id);
|
|
30
|
+
const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(userId, experimentIds);
|
|
31
|
+
return experiments.map(experiment => {
|
|
32
|
+
const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id);
|
|
33
|
+
const existingAssignmentBucket = experiment.buckets.find(b => b.id === existingAssignment?.bucketId);
|
|
34
|
+
return {
|
|
35
|
+
...experiment,
|
|
36
|
+
...(existingAssignment && {
|
|
37
|
+
userAssignment: {
|
|
38
|
+
...existingAssignment,
|
|
39
|
+
experimentId: experiment.id,
|
|
40
|
+
experimentData: experiment.data,
|
|
41
|
+
experimentKey: experiment.key,
|
|
42
|
+
bucketData: existingAssignmentBucket?.data || null,
|
|
43
|
+
bucketKey: existingAssignmentBucket?.key || null,
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
26
48
|
}
|
|
27
49
|
/**
|
|
28
50
|
* Updates all user assignments with a given userId with the provided userId.
|
|
@@ -34,7 +56,7 @@ export class Abba {
|
|
|
34
56
|
/**
|
|
35
57
|
* Returns all experiments.
|
|
36
58
|
*/
|
|
37
|
-
async
|
|
59
|
+
async getAllExperimentsWithBucketsNoCache() {
|
|
38
60
|
const experiments = await this.experimentDao.getAll();
|
|
39
61
|
const buckets = await this.bucketDao.getAll();
|
|
40
62
|
return experiments.map(experiment => ({
|
|
@@ -128,64 +150,47 @@ export class Abba {
|
|
|
128
150
|
* @param segmentationData Required if existingOnly is false
|
|
129
151
|
*/
|
|
130
152
|
async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
|
|
131
|
-
const experiment = await this.experimentDao.
|
|
153
|
+
const experiment = await this.experimentDao.getByKey(experimentKey);
|
|
132
154
|
_assert(experiment, `Experiment does not exist: ${experimentKey}`);
|
|
133
155
|
// Inactive experiments should never return an assignment
|
|
134
156
|
if (experiment.status === AssignmentStatus.Inactive) {
|
|
135
|
-
return
|
|
136
|
-
experiment,
|
|
137
|
-
assignment: null,
|
|
138
|
-
};
|
|
157
|
+
return null;
|
|
139
158
|
}
|
|
140
|
-
const buckets = await this.bucketDao.
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const bucket = buckets.find(b => b.id === existing.bucketId);
|
|
159
|
+
const buckets = await this.bucketDao.getByExperimentId(experiment.id);
|
|
160
|
+
const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(userId, experiment.id);
|
|
161
|
+
if (userAssignment) {
|
|
162
|
+
const bucket = buckets.find(b => b.id === userAssignment.bucketId);
|
|
145
163
|
return {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
bucketData: bucket?.data || null,
|
|
152
|
-
},
|
|
164
|
+
...userAssignment,
|
|
165
|
+
experimentData: experiment.data,
|
|
166
|
+
experimentKey: experiment.key,
|
|
167
|
+
bucketKey: bucket?.key || null,
|
|
168
|
+
bucketData: bucket?.data || null,
|
|
153
169
|
};
|
|
154
170
|
}
|
|
155
171
|
// No existing assignment, but we don't want to generate a new one
|
|
156
172
|
if (existingOnly || experiment.status === AssignmentStatus.Paused) {
|
|
157
|
-
return
|
|
158
|
-
experiment,
|
|
159
|
-
assignment: null,
|
|
160
|
-
};
|
|
173
|
+
return null;
|
|
161
174
|
}
|
|
162
|
-
const experiments = await this.
|
|
163
|
-
const exclusionSet = getUserExclusionSet(experiments
|
|
175
|
+
const experiments = await this.getAllExperimentsWithUserAssignments(userId);
|
|
176
|
+
const exclusionSet = getUserExclusionSet(experiments);
|
|
164
177
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
165
|
-
return
|
|
166
|
-
experiment,
|
|
167
|
-
assignment: null,
|
|
168
|
-
};
|
|
178
|
+
return null;
|
|
169
179
|
}
|
|
170
180
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
171
181
|
const experimentWithBuckets = { ...experiment, buckets };
|
|
172
182
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData);
|
|
173
183
|
if (!assignment) {
|
|
174
|
-
return
|
|
175
|
-
experiment,
|
|
176
|
-
assignment: null,
|
|
177
|
-
};
|
|
184
|
+
return null;
|
|
178
185
|
}
|
|
179
186
|
const newAssignment = await this.userAssignmentDao.save(assignment);
|
|
180
187
|
const bucket = buckets.find(b => b.id === newAssignment.bucketId);
|
|
181
188
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
bucketData: bucket?.data || null,
|
|
188
|
-
},
|
|
189
|
+
...newAssignment,
|
|
190
|
+
experimentData: experiment.data,
|
|
191
|
+
experimentKey: experiment.key,
|
|
192
|
+
bucketKey: bucket?.key || null,
|
|
193
|
+
bucketData: bucket?.data || null,
|
|
189
194
|
};
|
|
190
195
|
}
|
|
191
196
|
/**
|
|
@@ -194,19 +199,17 @@ export class Abba {
|
|
|
194
199
|
* Not cached, because Assignments are fast-changing.
|
|
195
200
|
* Only to be used for testing
|
|
196
201
|
*/
|
|
197
|
-
async
|
|
202
|
+
async getAllExistingUserAssignments(userId) {
|
|
198
203
|
const assignments = await this.userAssignmentDao.getBy('userId', userId);
|
|
199
204
|
return await pMap(assignments, async (assignment) => {
|
|
200
205
|
const experiment = await this.experimentDao.requireById(assignment.experimentId);
|
|
201
206
|
const bucket = await this.bucketDao.getById(assignment.bucketId);
|
|
202
207
|
return {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
bucketData: bucket?.data || null,
|
|
209
|
-
},
|
|
208
|
+
...assignment,
|
|
209
|
+
experimentData: experiment.data,
|
|
210
|
+
experimentKey: experiment.key,
|
|
211
|
+
bucketKey: bucket?.key || null,
|
|
212
|
+
bucketData: bucket?.data || null,
|
|
210
213
|
};
|
|
211
214
|
});
|
|
212
215
|
}
|
|
@@ -216,47 +219,43 @@ export class Abba {
|
|
|
216
219
|
* Hot method.
|
|
217
220
|
*/
|
|
218
221
|
async generateUserAssignments(userId, segmentationData, existingOnly = false) {
|
|
219
|
-
const experiments = await this.
|
|
220
|
-
const
|
|
221
|
-
const exclusionSet = getUserExclusionSet(experiments, existingAssignments);
|
|
222
|
-
const assignments = [];
|
|
223
|
-
const newAssignments = [];
|
|
222
|
+
const experiments = await this.getAllExperimentsWithUserAssignments(userId);
|
|
223
|
+
const exclusionSet = getUserExclusionSet(experiments);
|
|
224
224
|
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
225
225
|
// as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
|
|
226
226
|
// This is simmpler than trying to resolve after assignments have already been determined
|
|
227
227
|
const availableExperiments = _shuffle(experiments.filter(e => e.status === AssignmentStatus.Active || e.status === AssignmentStatus.Paused));
|
|
228
|
+
const assignments = [];
|
|
229
|
+
const newAssignments = [];
|
|
228
230
|
for (const experiment of availableExperiments) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
const { userAssignment } = experiment;
|
|
232
|
+
// Already assigned to this experiment
|
|
233
|
+
if (userAssignment) {
|
|
234
|
+
assignments.push(userAssignment);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Not already assigned, but we don't want to generate a new assignment
|
|
238
|
+
if (existingOnly)
|
|
239
|
+
continue;
|
|
240
|
+
// We are not allowed to generate new assignments for this experiment
|
|
241
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet))
|
|
242
|
+
continue;
|
|
243
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData);
|
|
244
|
+
if (assignment) {
|
|
245
|
+
// Add to list of new assignments to be saved
|
|
246
|
+
const newAssignment = this.userAssignmentDao.create(assignment);
|
|
247
|
+
newAssignments.push(newAssignment);
|
|
248
|
+
// Add the assignment to the list of assignments
|
|
249
|
+
const bucket = experiment.buckets.find(b => b.id === assignment.bucketId);
|
|
232
250
|
assignments.push({
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
bucketData: bucket?.data || null,
|
|
239
|
-
},
|
|
251
|
+
...newAssignment,
|
|
252
|
+
experimentKey: experiment.key,
|
|
253
|
+
experimentData: experiment.data,
|
|
254
|
+
bucketKey: bucket?.key || null,
|
|
255
|
+
bucketData: bucket?.data || null,
|
|
240
256
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const assignment = generateUserAssignmentData(experiment, userId, segmentationData);
|
|
244
|
-
if (assignment) {
|
|
245
|
-
const created = this.userAssignmentDao.create(assignment);
|
|
246
|
-
newAssignments.push(created);
|
|
247
|
-
const bucket = experiment.buckets.find(b => b.id === created.bucketId);
|
|
248
|
-
assignments.push({
|
|
249
|
-
experiment,
|
|
250
|
-
assignment: {
|
|
251
|
-
...created,
|
|
252
|
-
experimentKey: experiment.key,
|
|
253
|
-
bucketKey: bucket?.key || null,
|
|
254
|
-
bucketData: bucket?.data || null,
|
|
255
|
-
},
|
|
256
|
-
});
|
|
257
|
-
// Prevent future exclusion clashes
|
|
258
|
-
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
259
|
-
}
|
|
257
|
+
// Prevent future exclusion clashes
|
|
258
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
260
259
|
}
|
|
261
260
|
}
|
|
262
261
|
await this.userAssignmentDao.saveBatch(newAssignments);
|
|
@@ -267,16 +266,10 @@ export class Abba {
|
|
|
267
266
|
* Cold method.
|
|
268
267
|
*/
|
|
269
268
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
270
|
-
const totalAssignments = await this.userAssignmentDao
|
|
271
|
-
|
|
272
|
-
.filterEq('experimentId', experimentId)
|
|
273
|
-
.runQueryCount();
|
|
274
|
-
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
269
|
+
const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
|
|
270
|
+
const buckets = await this.bucketDao.getByExperimentId(experimentId);
|
|
275
271
|
const bucketAssignments = await pMap(buckets, async (bucket) => {
|
|
276
|
-
const totalAssignments = await this.userAssignmentDao
|
|
277
|
-
.query()
|
|
278
|
-
.filterEq('bucketId', bucket.id)
|
|
279
|
-
.runQueryCount();
|
|
272
|
+
const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
|
|
280
273
|
return {
|
|
281
274
|
bucketId: bucket.id,
|
|
282
275
|
totalAssignments,
|
|
@@ -290,4 +283,4 @@ export class Abba {
|
|
|
290
283
|
}
|
|
291
284
|
__decorate([
|
|
292
285
|
_Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
293
|
-
], Abba.prototype, "
|
|
286
|
+
], Abba.prototype, "getAllExperimentsWithBuckets", null);
|
package/dist/dao/bucket.dao.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
|
-
import type { Saved } from '@naturalcycles/js-lib';
|
|
4
3
|
import type { BaseBucket, Bucket } from '../types.js';
|
|
5
|
-
type BucketDBM =
|
|
4
|
+
type BucketDBM = BaseBucket & {
|
|
6
5
|
data: string | null;
|
|
7
6
|
};
|
|
8
7
|
export declare class BucketDao extends CommonDao<Bucket, BucketDBM> {
|
|
8
|
+
getByExperimentId(experimentId: string): Promise<Bucket[]>;
|
|
9
9
|
}
|
|
10
|
-
export declare
|
|
10
|
+
export declare function bucketDao(db: CommonDB): BucketDao;
|
|
11
11
|
export {};
|
package/dist/dao/bucket.dao.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
2
|
export class BucketDao extends CommonDao {
|
|
3
|
+
async getByExperimentId(experimentId) {
|
|
4
|
+
return await this.query().filterEq('experimentId', experimentId).runQuery();
|
|
5
|
+
}
|
|
3
6
|
}
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export function bucketDao(db) {
|
|
8
|
+
return new BucketDao({
|
|
9
|
+
db,
|
|
10
|
+
table: 'Bucket',
|
|
11
|
+
hooks: {
|
|
12
|
+
beforeBMToDBM: bm => {
|
|
13
|
+
return {
|
|
14
|
+
...bm,
|
|
15
|
+
data: bm.data ? JSON.stringify(bm.data) : null,
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
beforeDBMToBM: dbm => ({
|
|
19
|
+
...dbm,
|
|
20
|
+
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
21
|
+
}),
|
|
13
22
|
},
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
17
|
-
}),
|
|
18
|
-
},
|
|
19
|
-
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
|
-
import type { Saved } from '@naturalcycles/js-lib';
|
|
4
3
|
import type { BaseExperiment, Experiment } from '../types.js';
|
|
5
|
-
type ExperimentDBM =
|
|
4
|
+
type ExperimentDBM = BaseExperiment & {
|
|
6
5
|
rules: string | null;
|
|
7
6
|
exclusions: string | null;
|
|
8
7
|
data: string | null;
|
|
9
8
|
};
|
|
10
9
|
export declare class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
|
|
10
|
+
getByKey(key: string): Promise<Experiment | null>;
|
|
11
11
|
}
|
|
12
|
-
export declare
|
|
12
|
+
export declare function experimentDao(db: CommonDB): ExperimentDao;
|
|
13
13
|
export {};
|
|
@@ -1,35 +1,40 @@
|
|
|
1
1
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
2
|
import { localDate } from '@naturalcycles/js-lib';
|
|
3
3
|
export class ExperimentDao extends CommonDao {
|
|
4
|
+
async getByKey(key) {
|
|
5
|
+
return await this.getOneBy('key', key);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function experimentDao(db) {
|
|
9
|
+
return new ExperimentDao({
|
|
10
|
+
db,
|
|
11
|
+
table: 'Experiment',
|
|
12
|
+
hooks: {
|
|
13
|
+
beforeBMToDBM: bm => ({
|
|
14
|
+
...bm,
|
|
15
|
+
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
16
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
17
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
18
|
+
exclusions: bm.exclusions.length
|
|
19
|
+
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
20
|
+
: null,
|
|
21
|
+
data: bm.data ? JSON.stringify(bm.data) : null,
|
|
22
|
+
}),
|
|
23
|
+
beforeDBMToBM: dbm => ({
|
|
24
|
+
...dbm,
|
|
25
|
+
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
26
|
+
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
27
|
+
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
28
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
29
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
30
|
+
exclusions: (dbm.exclusions &&
|
|
31
|
+
JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
|
|
32
|
+
[],
|
|
33
|
+
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
4
37
|
}
|
|
5
|
-
export const experimentDao = (db) => new ExperimentDao({
|
|
6
|
-
db,
|
|
7
|
-
table: 'Experiment',
|
|
8
|
-
hooks: {
|
|
9
|
-
beforeBMToDBM: bm => ({
|
|
10
|
-
...bm,
|
|
11
|
-
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
12
|
-
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
13
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
14
|
-
exclusions: bm.exclusions.length
|
|
15
|
-
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
16
|
-
: null,
|
|
17
|
-
data: bm.data ? JSON.stringify(bm.data) : null,
|
|
18
|
-
}),
|
|
19
|
-
beforeDBMToBM: dbm => ({
|
|
20
|
-
...dbm,
|
|
21
|
-
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
22
|
-
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
23
|
-
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
24
|
-
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
25
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
26
|
-
exclusions: (dbm.exclusions &&
|
|
27
|
-
JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
|
|
28
|
-
[],
|
|
29
|
-
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
30
|
-
}),
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
38
|
/**
|
|
34
39
|
* https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
|
|
35
40
|
* MySQL Automatically parses Date fields as Date objects
|
|
@@ -2,5 +2,9 @@ import type { CommonDB } from '@naturalcycles/db-lib';
|
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
3
|
import type { UserAssignment } from '../types.js';
|
|
4
4
|
export declare class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
5
|
+
getUserAssignmentByExperimentId(userId: string, experimentId: string): Promise<UserAssignment | null>;
|
|
6
|
+
getUserAssigmentsByExperimentIds(userId: string, experimentIds: string[]): Promise<UserAssignment[]>;
|
|
7
|
+
getCountByExperimentId(experimentId: string): Promise<number>;
|
|
8
|
+
getCountByBucketId(bucketId: string): Promise<number>;
|
|
5
9
|
}
|
|
6
|
-
export declare
|
|
10
|
+
export declare function userAssignmentDao(db: CommonDB): UserAssignmentDao;
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
2
|
export class UserAssignmentDao extends CommonDao {
|
|
3
|
+
async getUserAssignmentByExperimentId(userId, experimentId) {
|
|
4
|
+
const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId);
|
|
5
|
+
const [userAssignment] = await this.runQuery(query);
|
|
6
|
+
return userAssignment || null;
|
|
7
|
+
}
|
|
8
|
+
async getUserAssigmentsByExperimentIds(userId, experimentIds) {
|
|
9
|
+
const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds);
|
|
10
|
+
return await this.runQuery(query);
|
|
11
|
+
}
|
|
12
|
+
async getCountByExperimentId(experimentId) {
|
|
13
|
+
return await this.query().filterEq('experimentId', experimentId).runQueryCount();
|
|
14
|
+
}
|
|
15
|
+
async getCountByBucketId(bucketId) {
|
|
16
|
+
return await this.query().filterEq('bucketId', bucketId).runQueryCount();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function userAssignmentDao(db) {
|
|
20
|
+
return new UserAssignmentDao({
|
|
21
|
+
db,
|
|
22
|
+
table: 'UserAssignment',
|
|
23
|
+
});
|
|
3
24
|
}
|
|
4
|
-
export const userAssignmentDao = (db) => new UserAssignmentDao({
|
|
5
|
-
db,
|
|
6
|
-
table: 'UserAssignment',
|
|
7
|
-
});
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
-
import type { AnyObject, BaseDBEntity, IsoDate
|
|
2
|
+
import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib';
|
|
3
3
|
export interface AbbaConfig {
|
|
4
4
|
db: CommonDB;
|
|
5
5
|
}
|
|
@@ -35,8 +35,8 @@ export type Experiment = BaseExperiment & {
|
|
|
35
35
|
exclusions: string[];
|
|
36
36
|
data: AnyObject | null;
|
|
37
37
|
};
|
|
38
|
-
export type ExperimentWithBuckets =
|
|
39
|
-
buckets:
|
|
38
|
+
export type ExperimentWithBuckets = Experiment & {
|
|
39
|
+
buckets: Bucket[];
|
|
40
40
|
};
|
|
41
41
|
export type BaseBucket = BaseDBEntity & {
|
|
42
42
|
experimentId: string;
|
|
@@ -51,14 +51,12 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
51
51
|
experimentId: string;
|
|
52
52
|
bucketId: string | null;
|
|
53
53
|
};
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
experiment: Saved<Experiment>;
|
|
61
|
-
}
|
|
54
|
+
export type DecoratedUserAssignment = UserAssignment & {
|
|
55
|
+
experimentKey: Experiment['key'];
|
|
56
|
+
experimentData: Experiment['data'];
|
|
57
|
+
bucketKey: Bucket['key'] | null;
|
|
58
|
+
bucketData: Bucket['data'];
|
|
59
|
+
};
|
|
62
60
|
export type SegmentationData = AnyObject;
|
|
63
61
|
export declare enum AssignmentStatus {
|
|
64
62
|
/**
|
|
@@ -105,3 +103,6 @@ export interface BucketAssignmentStatistics {
|
|
|
105
103
|
totalAssignments: number;
|
|
106
104
|
}
|
|
107
105
|
export type ExclusionSet = Set<string>;
|
|
106
|
+
export interface UserExperiment extends ExperimentWithBuckets {
|
|
107
|
+
userAssignment?: DecoratedUserAssignment;
|
|
108
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment } from './types.js';
|
|
2
|
+
import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment, UserExperiment } from './types.js';
|
|
3
3
|
import { SegmentationRuleOperator } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Generate a new assignment for a given user.
|
|
6
6
|
* Doesn't save it.
|
|
7
7
|
*/
|
|
8
|
-
export declare
|
|
8
|
+
export declare function generateUserAssignmentData(experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData): Unsaved<UserAssignment> | null;
|
|
9
9
|
declare class RandomService {
|
|
10
10
|
/**
|
|
11
11
|
* Generate a random number between 0 and 100
|
|
@@ -16,15 +16,15 @@ export declare const randomService: RandomService;
|
|
|
16
16
|
/**
|
|
17
17
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
18
18
|
*/
|
|
19
|
-
export declare
|
|
19
|
+
export declare function determineAssignment(sampling: number, buckets: Bucket[]): Bucket | null;
|
|
20
20
|
/**
|
|
21
21
|
* Determines which bucket a user assignment will recieve
|
|
22
22
|
*/
|
|
23
|
-
export declare
|
|
23
|
+
export declare function determineBucket(buckets: Bucket[]): Bucket;
|
|
24
24
|
/**
|
|
25
25
|
* Validate the total ratio of the buckets equals 100
|
|
26
26
|
*/
|
|
27
|
-
export declare
|
|
27
|
+
export declare function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void;
|
|
28
28
|
/**
|
|
29
29
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
30
30
|
*
|
|
@@ -32,7 +32,7 @@ export declare const validateTotalBucketRatio: (buckets: Unsaved<Bucket>[]) => v
|
|
|
32
32
|
* @param segmentationData
|
|
33
33
|
* @returns
|
|
34
34
|
*/
|
|
35
|
-
export declare
|
|
35
|
+
export declare function validateSegmentationRules(rules: SegmentationRule[], segmentationData: SegmentationData): boolean;
|
|
36
36
|
/**
|
|
37
37
|
* Map of segmentation rule validators
|
|
38
38
|
*/
|
|
@@ -40,10 +40,10 @@ export declare const segmentationRuleMap: Record<SegmentationRuleOperator, Segme
|
|
|
40
40
|
/**
|
|
41
41
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
42
42
|
*/
|
|
43
|
-
export declare
|
|
43
|
+
export declare function canGenerateNewAssignments(experiment: Experiment, exclusionSet: ExclusionSet): boolean;
|
|
44
44
|
/**
|
|
45
45
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
46
46
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
47
47
|
*/
|
|
48
|
-
export declare
|
|
48
|
+
export declare function getUserExclusionSet(experiments: UserExperiment[]): ExclusionSet;
|
|
49
49
|
export {};
|
package/dist/util.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
|
|
|
5
5
|
* Generate a new assignment for a given user.
|
|
6
6
|
* Doesn't save it.
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
8
|
+
export function generateUserAssignmentData(experiment, userId, segmentationData) {
|
|
9
9
|
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData);
|
|
10
10
|
if (!segmentationMatch)
|
|
11
11
|
return null;
|
|
@@ -15,7 +15,7 @@ export const generateUserAssignmentData = (experiment, userId, segmentationData)
|
|
|
15
15
|
experimentId: experiment.id,
|
|
16
16
|
bucketId: bucket?.id || null,
|
|
17
17
|
};
|
|
18
|
-
}
|
|
18
|
+
}
|
|
19
19
|
class RandomService {
|
|
20
20
|
/**
|
|
21
21
|
* Generate a random number between 0 and 100
|
|
@@ -28,18 +28,18 @@ export const randomService = new RandomService();
|
|
|
28
28
|
/**
|
|
29
29
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
30
30
|
*/
|
|
31
|
-
export
|
|
31
|
+
export function determineAssignment(sampling, buckets) {
|
|
32
32
|
// Should this person be considered for the experiment?
|
|
33
33
|
if (randomService.rollDie() > sampling) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
// get their bucket
|
|
37
37
|
return determineBucket(buckets);
|
|
38
|
-
}
|
|
38
|
+
}
|
|
39
39
|
/**
|
|
40
40
|
* Determines which bucket a user assignment will recieve
|
|
41
41
|
*/
|
|
42
|
-
export
|
|
42
|
+
export function determineBucket(buckets) {
|
|
43
43
|
const bucketRoll = randomService.rollDie();
|
|
44
44
|
let range;
|
|
45
45
|
const bucket = buckets.find(b => {
|
|
@@ -57,16 +57,16 @@ export const determineBucket = (buckets) => {
|
|
|
57
57
|
throw new Error('Could not detetermine bucket from ratios');
|
|
58
58
|
}
|
|
59
59
|
return bucket;
|
|
60
|
-
}
|
|
60
|
+
}
|
|
61
61
|
/**
|
|
62
62
|
* Validate the total ratio of the buckets equals 100
|
|
63
63
|
*/
|
|
64
|
-
export
|
|
64
|
+
export function validateTotalBucketRatio(buckets) {
|
|
65
65
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
66
66
|
if (bucketSum !== 100) {
|
|
67
67
|
throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
|
|
68
68
|
}
|
|
69
|
-
}
|
|
69
|
+
}
|
|
70
70
|
/**
|
|
71
71
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
72
72
|
*
|
|
@@ -74,14 +74,14 @@ export const validateTotalBucketRatio = (buckets) => {
|
|
|
74
74
|
* @param segmentationData
|
|
75
75
|
* @returns
|
|
76
76
|
*/
|
|
77
|
-
export
|
|
77
|
+
export function validateSegmentationRules(rules, segmentationData) {
|
|
78
78
|
for (const rule of rules) {
|
|
79
79
|
const { key, value, operator } = rule;
|
|
80
80
|
if (!segmentationRuleMap[operator](segmentationData[key], value))
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
83
83
|
return true;
|
|
84
|
-
}
|
|
84
|
+
}
|
|
85
85
|
/**
|
|
86
86
|
* Map of segmentation rule validators
|
|
87
87
|
*/
|
|
@@ -115,24 +115,24 @@ export const segmentationRuleMap = {
|
|
|
115
115
|
/**
|
|
116
116
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
117
117
|
*/
|
|
118
|
-
export
|
|
118
|
+
export function canGenerateNewAssignments(experiment, exclusionSet) {
|
|
119
119
|
return (!exclusionSet.has(experiment.id) &&
|
|
120
120
|
experiment.status === AssignmentStatus.Active &&
|
|
121
121
|
localDate.today().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)'));
|
|
122
|
-
}
|
|
122
|
+
}
|
|
123
123
|
/**
|
|
124
124
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
125
125
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
126
126
|
*/
|
|
127
|
-
export
|
|
127
|
+
export function getUserExclusionSet(experiments) {
|
|
128
128
|
const exclusionSet = new Set();
|
|
129
|
-
|
|
129
|
+
experiments.forEach(experiment => {
|
|
130
|
+
const { userAssignment } = experiment;
|
|
130
131
|
// Users who are excluded from an experiment due to sampling
|
|
131
132
|
// should not prevent potential assignment to other mutually exclusive experiments
|
|
132
|
-
if (
|
|
133
|
+
if (!userAssignment || userAssignment?.bucketId === null)
|
|
133
134
|
return;
|
|
134
|
-
|
|
135
|
-
experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
135
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
136
136
|
});
|
|
137
137
|
return exclusionSet;
|
|
138
|
-
}
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.2",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"prepare": "husky",
|
|
7
7
|
"build": "dev-lib build",
|
|
@@ -46,5 +46,6 @@
|
|
|
46
46
|
},
|
|
47
47
|
"description": "AB test assignment configuration tool for Node.js",
|
|
48
48
|
"author": "Natural Cycles Team",
|
|
49
|
-
"license": "MIT"
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
50
51
|
}
|
package/src/abba.ts
CHANGED
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
AbbaConfig,
|
|
9
9
|
Bucket,
|
|
10
10
|
BucketAssignmentStatistics,
|
|
11
|
+
DecoratedUserAssignment,
|
|
11
12
|
Experiment,
|
|
12
13
|
ExperimentAssignmentStatistics,
|
|
13
14
|
ExperimentWithBuckets,
|
|
14
|
-
GeneratedUserAssignment,
|
|
15
15
|
SegmentationData,
|
|
16
16
|
UserAssignment,
|
|
17
|
+
UserExperiment,
|
|
17
18
|
} from './types.js'
|
|
18
19
|
import { AssignmentStatus } from './types.js'
|
|
19
20
|
import {
|
|
@@ -40,8 +41,39 @@ export class Abba {
|
|
|
40
41
|
* Cached (see CACHE_TTL)
|
|
41
42
|
*/
|
|
42
43
|
@_Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
43
|
-
async
|
|
44
|
-
return await this.
|
|
44
|
+
async getAllExperimentsWithBuckets(): Promise<ExperimentWithBuckets[]> {
|
|
45
|
+
return await this.getAllExperimentsWithBucketsNoCache()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getAllExperimentsWithUserAssignments(userId: string): Promise<UserExperiment[]> {
|
|
49
|
+
const experiments = await this.getAllExperimentsWithBuckets()
|
|
50
|
+
|
|
51
|
+
const experimentIds = experiments.map(e => e.id)
|
|
52
|
+
const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(
|
|
53
|
+
userId,
|
|
54
|
+
experimentIds,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return experiments.map(experiment => {
|
|
58
|
+
const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id)
|
|
59
|
+
const existingAssignmentBucket = experiment.buckets.find(
|
|
60
|
+
b => b.id === existingAssignment?.bucketId,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...experiment,
|
|
65
|
+
...(existingAssignment && {
|
|
66
|
+
userAssignment: {
|
|
67
|
+
...existingAssignment,
|
|
68
|
+
experimentId: experiment.id,
|
|
69
|
+
experimentData: experiment.data,
|
|
70
|
+
experimentKey: experiment.key,
|
|
71
|
+
bucketData: existingAssignmentBucket?.data || null,
|
|
72
|
+
bucketKey: existingAssignmentBucket?.key || null,
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
45
77
|
}
|
|
46
78
|
|
|
47
79
|
/**
|
|
@@ -55,7 +87,7 @@ export class Abba {
|
|
|
55
87
|
/**
|
|
56
88
|
* Returns all experiments.
|
|
57
89
|
*/
|
|
58
|
-
async
|
|
90
|
+
async getAllExperimentsWithBucketsNoCache(): Promise<ExperimentWithBuckets[]> {
|
|
59
91
|
const experiments = await this.experimentDao.getAll()
|
|
60
92
|
const buckets = await this.bucketDao.getAll()
|
|
61
93
|
|
|
@@ -191,49 +223,40 @@ export class Abba {
|
|
|
191
223
|
userId: string,
|
|
192
224
|
existingOnly: boolean,
|
|
193
225
|
segmentationData?: SegmentationData,
|
|
194
|
-
): Promise<
|
|
195
|
-
const experiment = await this.experimentDao.
|
|
226
|
+
): Promise<DecoratedUserAssignment | null> {
|
|
227
|
+
const experiment = await this.experimentDao.getByKey(experimentKey)
|
|
196
228
|
_assert(experiment, `Experiment does not exist: ${experimentKey}`)
|
|
197
229
|
|
|
198
230
|
// Inactive experiments should never return an assignment
|
|
199
231
|
if (experiment.status === AssignmentStatus.Inactive) {
|
|
200
|
-
return
|
|
201
|
-
experiment,
|
|
202
|
-
assignment: null,
|
|
203
|
-
}
|
|
232
|
+
return null
|
|
204
233
|
}
|
|
205
234
|
|
|
206
|
-
const buckets = await this.bucketDao.
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
235
|
+
const buckets = await this.bucketDao.getByExperimentId(experiment.id)
|
|
236
|
+
const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(
|
|
237
|
+
userId,
|
|
238
|
+
experiment.id,
|
|
239
|
+
)
|
|
240
|
+
if (userAssignment) {
|
|
241
|
+
const bucket = buckets.find(b => b.id === userAssignment.bucketId)
|
|
211
242
|
return {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
bucketData: bucket?.data || null,
|
|
218
|
-
},
|
|
243
|
+
...userAssignment,
|
|
244
|
+
experimentData: experiment.data,
|
|
245
|
+
experimentKey: experiment.key,
|
|
246
|
+
bucketKey: bucket?.key || null,
|
|
247
|
+
bucketData: bucket?.data || null,
|
|
219
248
|
}
|
|
220
249
|
}
|
|
221
250
|
|
|
222
251
|
// No existing assignment, but we don't want to generate a new one
|
|
223
252
|
if (existingOnly || experiment.status === AssignmentStatus.Paused) {
|
|
224
|
-
return
|
|
225
|
-
experiment,
|
|
226
|
-
assignment: null,
|
|
227
|
-
}
|
|
253
|
+
return null
|
|
228
254
|
}
|
|
229
255
|
|
|
230
|
-
const experiments = await this.
|
|
231
|
-
const exclusionSet = getUserExclusionSet(experiments
|
|
256
|
+
const experiments = await this.getAllExperimentsWithUserAssignments(userId)
|
|
257
|
+
const exclusionSet = getUserExclusionSet(experiments)
|
|
232
258
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
233
|
-
return
|
|
234
|
-
experiment,
|
|
235
|
-
assignment: null,
|
|
236
|
-
}
|
|
259
|
+
return null
|
|
237
260
|
}
|
|
238
261
|
|
|
239
262
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
@@ -241,10 +264,7 @@ export class Abba {
|
|
|
241
264
|
const experimentWithBuckets = { ...experiment, buckets }
|
|
242
265
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
243
266
|
if (!assignment) {
|
|
244
|
-
return
|
|
245
|
-
experiment,
|
|
246
|
-
assignment: null,
|
|
247
|
-
}
|
|
267
|
+
return null
|
|
248
268
|
}
|
|
249
269
|
|
|
250
270
|
const newAssignment = await this.userAssignmentDao.save(assignment)
|
|
@@ -252,13 +272,11 @@ export class Abba {
|
|
|
252
272
|
const bucket = buckets.find(b => b.id === newAssignment.bucketId)
|
|
253
273
|
|
|
254
274
|
return {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
bucketData: bucket?.data || null,
|
|
261
|
-
},
|
|
275
|
+
...newAssignment,
|
|
276
|
+
experimentData: experiment.data,
|
|
277
|
+
experimentKey: experiment.key,
|
|
278
|
+
bucketKey: bucket?.key || null,
|
|
279
|
+
bucketData: bucket?.data || null,
|
|
262
280
|
}
|
|
263
281
|
}
|
|
264
282
|
|
|
@@ -268,19 +286,17 @@ export class Abba {
|
|
|
268
286
|
* Not cached, because Assignments are fast-changing.
|
|
269
287
|
* Only to be used for testing
|
|
270
288
|
*/
|
|
271
|
-
async
|
|
289
|
+
async getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]> {
|
|
272
290
|
const assignments = await this.userAssignmentDao.getBy('userId', userId)
|
|
273
291
|
return await pMap(assignments, async assignment => {
|
|
274
292
|
const experiment = await this.experimentDao.requireById(assignment.experimentId)
|
|
275
293
|
const bucket = await this.bucketDao.getById(assignment.bucketId)
|
|
276
294
|
return {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
bucketData: bucket?.data || null,
|
|
283
|
-
},
|
|
295
|
+
...assignment,
|
|
296
|
+
experimentData: experiment.data,
|
|
297
|
+
experimentKey: experiment.key,
|
|
298
|
+
bucketKey: bucket?.key || null,
|
|
299
|
+
bucketData: bucket?.data || null,
|
|
284
300
|
}
|
|
285
301
|
})
|
|
286
302
|
}
|
|
@@ -294,12 +310,9 @@ export class Abba {
|
|
|
294
310
|
userId: string,
|
|
295
311
|
segmentationData: SegmentationData,
|
|
296
312
|
existingOnly = false,
|
|
297
|
-
): Promise<
|
|
298
|
-
const experiments = await this.
|
|
299
|
-
const
|
|
300
|
-
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
301
|
-
const assignments: GeneratedUserAssignment[] = []
|
|
302
|
-
const newAssignments: UserAssignment[] = []
|
|
313
|
+
): Promise<DecoratedUserAssignment[]> {
|
|
314
|
+
const experiments = await this.getAllExperimentsWithUserAssignments(userId)
|
|
315
|
+
const exclusionSet = getUserExclusionSet(experiments)
|
|
303
316
|
|
|
304
317
|
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
305
318
|
// as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
|
|
@@ -310,41 +323,43 @@ export class Abba {
|
|
|
310
323
|
),
|
|
311
324
|
)
|
|
312
325
|
|
|
326
|
+
const assignments: DecoratedUserAssignment[] = []
|
|
327
|
+
const newAssignments: Unsaved<UserAssignment>[] = []
|
|
328
|
+
|
|
313
329
|
for (const experiment of availableExperiments) {
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
330
|
+
const { userAssignment } = experiment
|
|
331
|
+
// Already assigned to this experiment
|
|
332
|
+
if (userAssignment) {
|
|
333
|
+
assignments.push(userAssignment)
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Not already assigned, but we don't want to generate a new assignment
|
|
338
|
+
if (existingOnly) continue
|
|
339
|
+
// We are not allowed to generate new assignments for this experiment
|
|
340
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet)) continue
|
|
341
|
+
|
|
342
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
|
|
343
|
+
if (assignment) {
|
|
344
|
+
// Add to list of new assignments to be saved
|
|
345
|
+
const newAssignment = this.userAssignmentDao.create(assignment)
|
|
346
|
+
newAssignments.push(newAssignment)
|
|
347
|
+
// Add the assignment to the list of assignments
|
|
348
|
+
const bucket = experiment.buckets.find(b => b.id === assignment.bucketId)
|
|
317
349
|
assignments.push({
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
bucketData: bucket?.data || null,
|
|
324
|
-
},
|
|
350
|
+
...newAssignment,
|
|
351
|
+
experimentKey: experiment.key,
|
|
352
|
+
experimentData: experiment.data,
|
|
353
|
+
bucketKey: bucket?.key || null,
|
|
354
|
+
bucketData: bucket?.data || null,
|
|
325
355
|
})
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (assignment) {
|
|
329
|
-
const created = this.userAssignmentDao.create(assignment)
|
|
330
|
-
newAssignments.push(created)
|
|
331
|
-
const bucket = experiment.buckets.find(b => b.id === created.bucketId)
|
|
332
|
-
assignments.push({
|
|
333
|
-
experiment,
|
|
334
|
-
assignment: {
|
|
335
|
-
...created,
|
|
336
|
-
experimentKey: experiment.key,
|
|
337
|
-
bucketKey: bucket?.key || null,
|
|
338
|
-
bucketData: bucket?.data || null,
|
|
339
|
-
},
|
|
340
|
-
})
|
|
341
|
-
// Prevent future exclusion clashes
|
|
342
|
-
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
343
|
-
}
|
|
356
|
+
// Prevent future exclusion clashes
|
|
357
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
344
358
|
}
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
await this.userAssignmentDao.saveBatch(newAssignments)
|
|
362
|
+
|
|
348
363
|
return assignments
|
|
349
364
|
}
|
|
350
365
|
|
|
@@ -355,18 +370,11 @@ export class Abba {
|
|
|
355
370
|
async getExperimentAssignmentStatistics(
|
|
356
371
|
experimentId: string,
|
|
357
372
|
): Promise<ExperimentAssignmentStatistics> {
|
|
358
|
-
const totalAssignments = await this.userAssignmentDao
|
|
359
|
-
|
|
360
|
-
.filterEq('experimentId', experimentId)
|
|
361
|
-
.runQueryCount()
|
|
373
|
+
const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId)
|
|
374
|
+
const buckets = await this.bucketDao.getByExperimentId(experimentId)
|
|
362
375
|
|
|
363
|
-
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
364
376
|
const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
|
|
365
|
-
const totalAssignments = await this.userAssignmentDao
|
|
366
|
-
.query()
|
|
367
|
-
.filterEq('bucketId', bucket.id)
|
|
368
|
-
.runQueryCount()
|
|
369
|
-
|
|
377
|
+
const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id)
|
|
370
378
|
return {
|
|
371
379
|
bucketId: bucket.id,
|
|
372
380
|
totalAssignments,
|
package/src/dao/bucket.dao.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
-
import type { Saved } from '@naturalcycles/js-lib'
|
|
4
3
|
import type { BaseBucket, Bucket } from '../types.js'
|
|
5
4
|
|
|
6
|
-
type BucketDBM =
|
|
5
|
+
type BucketDBM = BaseBucket & {
|
|
7
6
|
data: string | null
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
export class BucketDao extends CommonDao<Bucket, BucketDBM> {
|
|
9
|
+
export class BucketDao extends CommonDao<Bucket, BucketDBM> {
|
|
10
|
+
async getByExperimentId(experimentId: string): Promise<Bucket[]> {
|
|
11
|
+
return await this.query().filterEq('experimentId', experimentId).runQuery()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
11
14
|
|
|
12
|
-
export
|
|
13
|
-
new BucketDao({
|
|
15
|
+
export function bucketDao(db: CommonDB): BucketDao {
|
|
16
|
+
return new BucketDao({
|
|
14
17
|
db,
|
|
15
18
|
table: 'Bucket',
|
|
16
19
|
hooks: {
|
|
@@ -26,3 +29,4 @@ export const bucketDao = (db: CommonDB): BucketDao =>
|
|
|
26
29
|
}),
|
|
27
30
|
},
|
|
28
31
|
})
|
|
32
|
+
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
-
import type { IsoDate
|
|
3
|
+
import type { IsoDate } from '@naturalcycles/js-lib'
|
|
4
4
|
import { localDate } from '@naturalcycles/js-lib'
|
|
5
5
|
import type { BaseExperiment, Experiment } from '../types.js'
|
|
6
6
|
|
|
7
|
-
type ExperimentDBM =
|
|
7
|
+
type ExperimentDBM = BaseExperiment & {
|
|
8
8
|
rules: string | null
|
|
9
9
|
exclusions: string | null
|
|
10
10
|
data: string | null
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
|
|
13
|
+
export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
|
|
14
|
+
async getByKey(key: string): Promise<Experiment | null> {
|
|
15
|
+
return await this.getOneBy('key', key)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
14
18
|
|
|
15
|
-
export
|
|
16
|
-
new ExperimentDao({
|
|
19
|
+
export function experimentDao(db: CommonDB): ExperimentDao {
|
|
20
|
+
return new ExperimentDao({
|
|
17
21
|
db,
|
|
18
22
|
table: 'Experiment',
|
|
19
23
|
hooks: {
|
|
@@ -42,6 +46,7 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
42
46
|
}),
|
|
43
47
|
},
|
|
44
48
|
})
|
|
49
|
+
}
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
52
|
* https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
|
|
@@ -2,10 +2,36 @@ import type { CommonDB } from '@naturalcycles/db-lib'
|
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
3
|
import type { UserAssignment } from '../types.js'
|
|
4
4
|
|
|
5
|
-
export class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
5
|
+
export class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
6
|
+
async getUserAssignmentByExperimentId(
|
|
7
|
+
userId: string,
|
|
8
|
+
experimentId: string,
|
|
9
|
+
): Promise<UserAssignment | null> {
|
|
10
|
+
const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId)
|
|
11
|
+
const [userAssignment] = await this.runQuery(query)
|
|
12
|
+
return userAssignment || null
|
|
13
|
+
}
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
async getUserAssigmentsByExperimentIds(
|
|
16
|
+
userId: string,
|
|
17
|
+
experimentIds: string[],
|
|
18
|
+
): Promise<UserAssignment[]> {
|
|
19
|
+
const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds)
|
|
20
|
+
return await this.runQuery(query)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getCountByExperimentId(experimentId: string): Promise<number> {
|
|
24
|
+
return await this.query().filterEq('experimentId', experimentId).runQueryCount()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getCountByBucketId(bucketId: string): Promise<number> {
|
|
28
|
+
return await this.query().filterEq('bucketId', bucketId).runQueryCount()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function userAssignmentDao(db: CommonDB): UserAssignmentDao {
|
|
33
|
+
return new UserAssignmentDao({
|
|
9
34
|
db,
|
|
10
35
|
table: 'UserAssignment',
|
|
11
36
|
})
|
|
37
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
-
import type { AnyObject, BaseDBEntity, IsoDate
|
|
2
|
+
import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib'
|
|
3
3
|
|
|
4
4
|
export interface AbbaConfig {
|
|
5
5
|
db: CommonDB
|
|
@@ -39,8 +39,8 @@ export type Experiment = BaseExperiment & {
|
|
|
39
39
|
data: AnyObject | null
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export type ExperimentWithBuckets =
|
|
43
|
-
buckets:
|
|
42
|
+
export type ExperimentWithBuckets = Experiment & {
|
|
43
|
+
buckets: Bucket[]
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export type BaseBucket = BaseDBEntity & {
|
|
@@ -59,15 +59,11 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
59
59
|
bucketId: string | null
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
bucketData: AnyObject | null
|
|
68
|
-
})
|
|
69
|
-
| null
|
|
70
|
-
experiment: Saved<Experiment>
|
|
62
|
+
export type DecoratedUserAssignment = UserAssignment & {
|
|
63
|
+
experimentKey: Experiment['key']
|
|
64
|
+
experimentData: Experiment['data']
|
|
65
|
+
bucketKey: Bucket['key'] | null
|
|
66
|
+
bucketData: Bucket['data']
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
export type SegmentationData = AnyObject
|
|
@@ -127,3 +123,7 @@ export interface BucketAssignmentStatistics {
|
|
|
127
123
|
}
|
|
128
124
|
|
|
129
125
|
export type ExclusionSet = Set<string>
|
|
126
|
+
|
|
127
|
+
export interface UserExperiment extends ExperimentWithBuckets {
|
|
128
|
+
userAssignment?: DecoratedUserAssignment
|
|
129
|
+
}
|
package/src/util.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SegmentationRule,
|
|
11
11
|
SegmentationRuleFn,
|
|
12
12
|
UserAssignment,
|
|
13
|
+
UserExperiment,
|
|
13
14
|
} from './types.js'
|
|
14
15
|
import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
|
|
15
16
|
|
|
@@ -17,11 +18,11 @@ import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
|
|
|
17
18
|
* Generate a new assignment for a given user.
|
|
18
19
|
* Doesn't save it.
|
|
19
20
|
*/
|
|
20
|
-
export
|
|
21
|
+
export function generateUserAssignmentData(
|
|
21
22
|
experiment: ExperimentWithBuckets,
|
|
22
23
|
userId: string,
|
|
23
24
|
segmentationData: SegmentationData,
|
|
24
|
-
): UserAssignment | null
|
|
25
|
+
): Unsaved<UserAssignment> | null {
|
|
25
26
|
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
|
|
26
27
|
if (!segmentationMatch) return null
|
|
27
28
|
|
|
@@ -31,7 +32,7 @@ export const generateUserAssignmentData = (
|
|
|
31
32
|
userId,
|
|
32
33
|
experimentId: experiment.id,
|
|
33
34
|
bucketId: bucket?.id || null,
|
|
34
|
-
}
|
|
35
|
+
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
class RandomService {
|
|
@@ -48,7 +49,7 @@ export const randomService = new RandomService()
|
|
|
48
49
|
/**
|
|
49
50
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
50
51
|
*/
|
|
51
|
-
export
|
|
52
|
+
export function determineAssignment(sampling: number, buckets: Bucket[]): Bucket | null {
|
|
52
53
|
// Should this person be considered for the experiment?
|
|
53
54
|
if (randomService.rollDie() > sampling) {
|
|
54
55
|
return null
|
|
@@ -61,7 +62,7 @@ export const determineAssignment = (sampling: number, buckets: Bucket[]): Bucket
|
|
|
61
62
|
/**
|
|
62
63
|
* Determines which bucket a user assignment will recieve
|
|
63
64
|
*/
|
|
64
|
-
export
|
|
65
|
+
export function determineBucket(buckets: Bucket[]): Bucket {
|
|
65
66
|
const bucketRoll = randomService.rollDie()
|
|
66
67
|
let range: [number, number] | undefined
|
|
67
68
|
const bucket = buckets.find(b => {
|
|
@@ -86,7 +87,7 @@ export const determineBucket = (buckets: Bucket[]): Bucket => {
|
|
|
86
87
|
/**
|
|
87
88
|
* Validate the total ratio of the buckets equals 100
|
|
88
89
|
*/
|
|
89
|
-
export
|
|
90
|
+
export function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void {
|
|
90
91
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
|
|
91
92
|
if (bucketSum !== 100) {
|
|
92
93
|
throw new Error('Total bucket ratio must be 100 before you can activate an experiment')
|
|
@@ -100,10 +101,10 @@ export const validateTotalBucketRatio = (buckets: Unsaved<Bucket>[]): void => {
|
|
|
100
101
|
* @param segmentationData
|
|
101
102
|
* @returns
|
|
102
103
|
*/
|
|
103
|
-
export
|
|
104
|
+
export function validateSegmentationRules(
|
|
104
105
|
rules: SegmentationRule[],
|
|
105
106
|
segmentationData: SegmentationData,
|
|
106
|
-
): boolean
|
|
107
|
+
): boolean {
|
|
107
108
|
for (const rule of rules) {
|
|
108
109
|
const { key, value, operator } = rule
|
|
109
110
|
if (!segmentationRuleMap[operator](segmentationData[key], value)) return false
|
|
@@ -144,10 +145,10 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
|
|
|
144
145
|
/**
|
|
145
146
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
146
147
|
*/
|
|
147
|
-
export
|
|
148
|
+
export function canGenerateNewAssignments(
|
|
148
149
|
experiment: Experiment,
|
|
149
150
|
exclusionSet: ExclusionSet,
|
|
150
|
-
): boolean
|
|
151
|
+
): boolean {
|
|
151
152
|
return (
|
|
152
153
|
!exclusionSet.has(experiment.id) &&
|
|
153
154
|
experiment.status === AssignmentStatus.Active &&
|
|
@@ -159,18 +160,15 @@ export const canGenerateNewAssignments = (
|
|
|
159
160
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
160
161
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
161
162
|
*/
|
|
162
|
-
export
|
|
163
|
-
experiments: Experiment[],
|
|
164
|
-
existingAssignments: UserAssignment[],
|
|
165
|
-
): ExclusionSet => {
|
|
163
|
+
export function getUserExclusionSet(experiments: UserExperiment[]): ExclusionSet {
|
|
166
164
|
const exclusionSet: ExclusionSet = new Set()
|
|
167
|
-
|
|
165
|
+
experiments.forEach(experiment => {
|
|
166
|
+
const { userAssignment } = experiment
|
|
168
167
|
// Users who are excluded from an experiment due to sampling
|
|
169
168
|
// should not prevent potential assignment to other mutually exclusive experiments
|
|
170
|
-
if (
|
|
169
|
+
if (!userAssignment || userAssignment?.bucketId === null) return
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
171
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
174
172
|
})
|
|
175
173
|
return exclusionSet
|
|
176
174
|
}
|